Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions app/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,16 @@ All notable changes to the SimUtil desktop app will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [0.4.0]

## [0.3.1] - 2026-10-14
### Added

- Update SimUtil from inside the app: on launch a toast tells you when a
newer version is out, and "Update & restart" installs it the way you
installed SimUtil (Homebrew, DMG, Windows installer or AppImage), no
reinstall needed, with download and install progress, and a toast once
the new version starts. Missed it? Click the arrow next to the SimUtil
title.

### Changed

Expand Down
14 changes: 3 additions & 11 deletions app/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import 'package:simutil_app/src/scenarios/scenario_runs_cubit.dart';
import 'package:simutil_app/src/settings/view_settings_cubit.dart';
import 'package:simutil_app/src/stream/streams_cubit.dart';
import 'package:simutil_app/src/stream/touch_marks.dart';
import 'package:simutil_app/src/update/app_update_cubit.dart';

Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
Expand All @@ -37,21 +38,12 @@ Future<void> main() async {
),
);

getIt<AppUpdateCubit>().check();
final devices = getIt<DevicesCubit>()..start();
final streams = getIt<StreamsCubit>();
devices.state.subscribe((state) => streams.onDevices(state.all));
// Closing the window quits, after streams and recordings are released.
windowManager.addListener(
_QuitOnClose(() async {
try {
await disposeDependencies();
} catch (e, s) {
debugPrint('Dispose on quit failed: $e\n$s');
} finally {
await windowManager.destroy();
}
}),
);
windowManager.addListener(_QuitOnClose(quitApp));
await windowManager.setPreventClose(true);

// Shared by the MCP tools and the scenario editor.
Expand Down
4 changes: 4 additions & 0 deletions app/lib/src/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import 'package:simutil_app/src/settings/device_settings_cubit.dart';
import 'package:simutil_app/src/settings/view_settings_cubit.dart';
import 'package:simutil_app/src/stream/streams_cubit.dart';
import 'package:simutil_app/src/ui/app_shell.dart';
import 'package:simutil_app/src/update/app_update_cubit.dart';

/// Provides the cubits around the shared [SimutilShell].
class SimutilApp extends StatelessWidget {
Expand All @@ -35,6 +36,9 @@ class SimutilApp extends StatelessWidget {
BlocSignalProvider<SlimModeCubit>(
create: (_) => getIt<SlimModeCubit>(),
),
BlocSignalProvider<AppUpdateCubit>(
create: (_) => getIt<AppUpdateCubit>(),
),
],
child: const SimutilShell(),
);
Expand Down
22 changes: 22 additions & 0 deletions app/lib/src/di.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:get_it/get_it.dart';
import 'package:simutil_app/src/logging/debug_log.dart';
import 'package:simutil_adb/simutil_adb.dart';
Expand All @@ -11,6 +12,9 @@ import 'package:simutil_app/src/settings/device_settings_cubit.dart';
import 'package:simutil_app/src/settings/view_settings_cubit.dart';
import 'package:simutil_app/src/stream/streams_cubit.dart';
import 'package:simutil_app/src/stream/touch_marks.dart';
import 'package:simutil_app/src/update/app_installer.dart';
import 'package:simutil_app/src/update/app_update_cubit.dart';
import 'package:window_manager/window_manager.dart';
import 'package:simutil_apple/simutil_apple.dart';
import 'package:simutil_core/simutil_core.dart';

Expand Down Expand Up @@ -90,6 +94,13 @@ Future<void> configureDependencies() async {
),
dispose: (cubit) => cubit.close(),
)
..registerLazySingleton<AppInstaller>(
() => AppInstaller(exec, quit: quitApp),
)
..registerSingleton<AppUpdateCubit>(
AppUpdateCubit(currentVersion: await getAppVersion()),
dispose: (cubit) => cubit.close(),
)
..registerLazySingleton<SlimModeCubit>(
() => SlimModeCubit(
devices: getIt<DevicesCubit>(),
Expand All @@ -114,3 +125,14 @@ class _SimulatorSlimControl implements SlimControl {

/// Closes cubits and stops the shell-command isolate.
Future<void> disposeDependencies() => getIt.reset();

/// Releases streams and recordings, then closes the window and quits.
Future<void> quitApp() async {
try {
await disposeDependencies();
} catch (e, s) {
debugPrint('Dispose on quit failed: $e\n$s');
} finally {
await windowManager.destroy();
}
}
2 changes: 1 addition & 1 deletion app/lib/src/recording/saved_recordings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class SavedRecordings {

void add(String path) => _saved.add(path);

/// Opens [path] in the default video player.
/// Opens [path] (or a URL) with its default app.
Future<void> open(String path) => _exec.run(
switch (Platform.operatingSystem) {
'macos' => 'open',
Expand Down
19 changes: 19 additions & 0 deletions app/lib/src/ui/design/toast.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ class SimuToast extends StatelessWidget {
this.subtitle,
this.subtitleLines = 1,
this.actions = const [],
this.progressBar = false,
this.progress,
this.onTap,
this.onClose,
});
Expand All @@ -42,6 +44,11 @@ class SimuToast extends StatelessWidget {
final String? subtitle;
final int subtitleLines;
final List<Widget> actions;

/// Shows a 4 px bar under the text (adds 12 px): filled to [progress],
/// or indeterminate while [progress] is `null`.
final bool progressBar;
final double? progress;
final VoidCallback? onTap;
final VoidCallback? onClose;

Expand Down Expand Up @@ -101,6 +108,18 @@ class SimuToast extends StatelessWidget {
style: subtitleStyle(t),
),
],
if (progressBar) ...[
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: progress,
minHeight: 4,
color: tone,
backgroundColor: tone.withValues(alpha: 0.14),
),
),
],
if (actions.isNotEmpty) ...[
const SizedBox(height: 10),
Row(spacing: 8, children: actions),
Expand Down
23 changes: 23 additions & 0 deletions app/lib/src/ui/devices/device_sidebar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import 'package:simutil_app/src/devices/devices_state.dart';
import 'package:simutil_app/src/ui/design/design.dart';
import 'package:simutil_app/src/ui/devices/device_row.dart';
import 'package:simutil_app/src/ui/devices/device_search_field.dart';
import 'package:simutil_app/src/ui/toasts/update_toast.dart';
import 'package:simutil_app/src/update/app_update_cubit.dart';
import 'package:simutil_core/simutil_core.dart';
import 'package:window_manager/window_manager.dart';

Expand Down Expand Up @@ -154,6 +156,7 @@ class _BrandState extends State<_Brand> {
),
const SizedBox(width: 10),
Text('SimUtil', style: t.title),
const _UpdateButton(),
],
),
),
Expand All @@ -162,6 +165,26 @@ class _BrandState extends State<_Brand> {
}
}

/// Shown while a newer release exists; reopens the update toast.
class _UpdateButton extends StatelessWidget {
const _UpdateButton();

@override
Widget build(BuildContext context) {
final update = context.value<AppUpdateCubit, AppUpdate?>();
if (update == null) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(left: 4),
child: SimuIconButton(
icon: LucideIcons.circleArrowUp,
tooltip: 'SimUtil ${update.version} is available',
color: SimuTokens.of(context).accent,
onPressed: () => showUpdateToast(context, update),
),
);
}
}

class _Section extends StatelessWidget {
const _Section(this.title, this.devices, this.state);

Expand Down
22 changes: 22 additions & 0 deletions app/lib/src/ui/recording_toast.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import 'package:simutil_app/src/stream/streams_cubit.dart';
import 'package:simutil_app/src/stream/streams_state.dart';
import 'package:simutil_app/src/ui/design/design.dart';
import 'package:simutil_app/src/ui/toasts/slim_suggestion.dart';
import 'package:simutil_app/src/ui/toasts/update_toast.dart';
import 'package:simutil_app/src/update/app_update_cubit.dart';
import 'package:simutil_core/simutil_core.dart';
import 'package:zentoast/zentoast.dart';

Expand Down Expand Up @@ -178,6 +180,7 @@ class _ToastFeedsState extends State<_ToastFeeds> {
var _slimSuggested = false;

void Function()? _unsubscribeSlim;
void Function()? _unsubscribeUpdate;
var _slimApplying = false;

/// "Switching…" toast while open simulators reboot into the new mode.
Expand All @@ -202,6 +205,24 @@ class _ToastFeedsState extends State<_ToastFeeds> {
if (getIt.isRegistered<SlimModeCubit>()) {
_unsubscribeSlim = getIt<SlimModeCubit>().state.subscribe(_onSlimMode);
}
if (getIt.isRegistered<AppUpdateCubit>()) {
final current = getIt<AppUpdateCubit>().currentVersion;
versionBeforeUpdate(current: current).then((previous) {
if (previous == null || !mounted) return;
showNotice(
context,
'Updated to SimUtil $current',
subtitle: 'From $previous',
tone: NoticeTone.success,
);
});
// Once per launch; the sidebar button reopens it.
_unsubscribeUpdate = getIt<AppUpdateCubit>().state.subscribe((update) {
if (update == null || !mounted) return;
_unsubscribeUpdate?.call();
showUpdateToast(context, update);
});
}
}

void _onDeviceNotice(DeviceNotice n) {
Expand All @@ -227,6 +248,7 @@ class _ToastFeedsState extends State<_ToastFeeds> {
_deviceNotices?.cancel();
_unsubscribeStreams?.call();
_unsubscribeSlim?.call();
_unsubscribeUpdate?.call();
super.dispose();
}

Expand Down
90 changes: 90 additions & 0 deletions app/lib/src/ui/toasts/update_toast.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import 'package:flutter/widgets.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:simutil_app/src/di.dart';
import 'package:simutil_app/src/recording/saved_recordings.dart';
import 'package:simutil_app/src/ui/design/design.dart';
import 'package:simutil_app/src/ui/recording_toast.dart';
import 'package:simutil_app/src/update/app_installer.dart';
import 'package:simutil_app/src/update/app_update_cubit.dart';
import 'package:zentoast/zentoast.dart';

/// Tells the user [update] is out. Update installs it the way the app was
/// installed (Homebrew, DMG, Windows installer, AppImage) and relaunches;
/// other installs get Download, which opens the release page.
void showUpdateToast(BuildContext context, AppUpdate update) {
const height = 100.0;
final t = SimuTokens.of(context);
final channel = getIt<AppInstaller>().channel;
final inPlace = channel != UpdateChannel.manual;
final current = getIt<AppUpdateCubit>().currentVersion;
showSimuToast(
context,
height,
(close) => SimuToast(
height: height,
icon: LucideIcons.circleArrowUp,
tone: t.accent,
title: 'SimUtil ${update.version} is available',
subtitle: switch (channel) {
UpdateChannel.homebrew => 'You have $current · via Homebrew',
_ => 'You have $current',
},
onClose: close,
actions: [
SimuButton(
label: inPlace ? 'Update & restart' : 'Download',
icon: inPlace ? LucideIcons.refreshCw : LucideIcons.download,
primary: true,
onPressed: () {
close();
inPlace
? _install(context, update)
: getIt<SavedRecordings>().open(update.url);
},
),
SimuButton(label: 'Later', onPressed: close),
],
),
);
}

Future<void> _install(BuildContext context, AppUpdate update) async {
final progress = ValueNotifier(const UpdateProgress('Starting…'));
const height = 72.0;
final toasts = ToastProvider.of(context);
final toast = showSimuToast(
context,
height,
(close) => ValueListenableBuilder(
valueListenable: progress,
builder: (context, p, _) => SimuToast(
height: height,
icon: LucideIcons.circleArrowUp,
tone: SimuTokens.of(context).accent,
title: p.fraction == null
? 'Updating to ${update.version}'
: 'Updating to ${update.version} · ${(p.fraction! * 100).round()}%',
subtitle: p.step,
progressBar: true,
progress: p.fraction,
),
),
);
try {
// Quits the app on success.
await getIt<AppInstaller>().install(
update,
onProgress: (p) => progress.value = p,
);
} catch (e) {
toasts.hide(toast);
if (!context.mounted) return;
showNotice(
context,
'Update failed',
subtitle: '$e',
tone: NoticeTone.error,
);
getIt<SavedRecordings>().open(update.url);
}
}
Loading
Loading