From 27dc32635e2346393de827dd6e7ae637e29da5cb Mon Sep 17 00:00:00 2001 From: dungngminh <63831488+dungngminh@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:01:57 +0700 Subject: [PATCH 1/2] feat(app): update the desktop app in place from its install source Check GitHub for a newer app-v* release on launch and show a toast; a button next to the SimUtil title reopens it. "Update & restart" upgrades through Homebrew, swaps the bundle from the DMG, runs the Windows installer silently, or replaces the AppImage, verifying downloads against checksums.txt, then relaunches. Progress shows in the toast and a success toast follows the restart. The running version comes from the bundle via package_info_plus. --- app/CHANGELOG.md | 9 + app/lib/main.dart | 14 +- app/lib/src/app.dart | 4 + app/lib/src/di.dart | 22 ++ app/lib/src/recording/saved_recordings.dart | 2 +- app/lib/src/ui/design/toast.dart | 19 ++ app/lib/src/ui/devices/device_sidebar.dart | 23 ++ app/lib/src/ui/recording_toast.dart | 22 ++ app/lib/src/ui/toasts/update_toast.dart | 90 +++++ app/lib/src/update/app_installer.dart | 321 ++++++++++++++++++ app/lib/src/update/app_update_cubit.dart | 110 ++++++ .../Flutter/GeneratedPluginRegistrant.swift | 2 + app/pubspec.yaml | 2 + app/test/app_installer_test.dart | 65 ++++ app/test/app_update_test.dart | 60 ++++ docs/ai/deployment.md | 3 +- pubspec.lock | 16 + 17 files changed, 771 insertions(+), 13 deletions(-) create mode 100644 app/lib/src/ui/toasts/update_toast.dart create mode 100644 app/lib/src/update/app_installer.dart create mode 100644 app/lib/src/update/app_update_cubit.dart create mode 100644 app/test/app_installer_test.dart create mode 100644 app/test/app_update_test.dart diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index 779ef4f..0210e99 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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. + ## [0.3.1] - 2026-10-14 ### Changed diff --git a/app/lib/main.dart b/app/lib/main.dart index 3d2dd5e..bd231da 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -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 main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -37,21 +38,12 @@ Future main() async { ), ); + getIt().check(); final devices = getIt()..start(); final streams = getIt(); 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. diff --git a/app/lib/src/app.dart b/app/lib/src/app.dart index 2be4d3b..cdc6c60 100644 --- a/app/lib/src/app.dart +++ b/app/lib/src/app.dart @@ -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 { @@ -35,6 +36,9 @@ class SimutilApp extends StatelessWidget { BlocSignalProvider( create: (_) => getIt(), ), + BlocSignalProvider( + create: (_) => getIt(), + ), ], child: const SimutilShell(), ); diff --git a/app/lib/src/di.dart b/app/lib/src/di.dart index f3a61bb..cf0d430 100644 --- a/app/lib/src/di.dart +++ b/app/lib/src/di.dart @@ -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'; @@ -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'; @@ -90,6 +94,13 @@ Future configureDependencies() async { ), dispose: (cubit) => cubit.close(), ) + ..registerLazySingleton( + () => AppInstaller(exec, quit: quitApp), + ) + ..registerSingleton( + AppUpdateCubit(currentVersion: await getAppVersion()), + dispose: (cubit) => cubit.close(), + ) ..registerLazySingleton( () => SlimModeCubit( devices: getIt(), @@ -114,3 +125,14 @@ class _SimulatorSlimControl implements SlimControl { /// Closes cubits and stops the shell-command isolate. Future disposeDependencies() => getIt.reset(); + +/// Releases streams and recordings, then closes the window and quits. +Future quitApp() async { + try { + await disposeDependencies(); + } catch (e, s) { + debugPrint('Dispose on quit failed: $e\n$s'); + } finally { + await windowManager.destroy(); + } +} diff --git a/app/lib/src/recording/saved_recordings.dart b/app/lib/src/recording/saved_recordings.dart index c72eace..b9296c8 100644 --- a/app/lib/src/recording/saved_recordings.dart +++ b/app/lib/src/recording/saved_recordings.dart @@ -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 open(String path) => _exec.run( switch (Platform.operatingSystem) { 'macos' => 'open', diff --git a/app/lib/src/ui/design/toast.dart b/app/lib/src/ui/design/toast.dart index 0bc1ffa..7ad12d6 100644 --- a/app/lib/src/ui/design/toast.dart +++ b/app/lib/src/ui/design/toast.dart @@ -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, }); @@ -42,6 +44,11 @@ class SimuToast extends StatelessWidget { final String? subtitle; final int subtitleLines; final List 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; @@ -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), diff --git a/app/lib/src/ui/devices/device_sidebar.dart b/app/lib/src/ui/devices/device_sidebar.dart index 4722118..780d55a 100644 --- a/app/lib/src/ui/devices/device_sidebar.dart +++ b/app/lib/src/ui/devices/device_sidebar.dart @@ -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'; @@ -154,6 +156,7 @@ class _BrandState extends State<_Brand> { ), const SizedBox(width: 10), Text('SimUtil', style: t.title), + const _UpdateButton(), ], ), ), @@ -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(); + 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); diff --git a/app/lib/src/ui/recording_toast.dart b/app/lib/src/ui/recording_toast.dart index 3ac819e..f101db9 100644 --- a/app/lib/src/ui/recording_toast.dart +++ b/app/lib/src/ui/recording_toast.dart @@ -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'; @@ -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. @@ -202,6 +205,24 @@ class _ToastFeedsState extends State<_ToastFeeds> { if (getIt.isRegistered()) { _unsubscribeSlim = getIt().state.subscribe(_onSlimMode); } + if (getIt.isRegistered()) { + final current = getIt().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().state.subscribe((update) { + if (update == null || !mounted) return; + _unsubscribeUpdate?.call(); + showUpdateToast(context, update); + }); + } } void _onDeviceNotice(DeviceNotice n) { @@ -227,6 +248,7 @@ class _ToastFeedsState extends State<_ToastFeeds> { _deviceNotices?.cancel(); _unsubscribeStreams?.call(); _unsubscribeSlim?.call(); + _unsubscribeUpdate?.call(); super.dispose(); } diff --git a/app/lib/src/ui/toasts/update_toast.dart b/app/lib/src/ui/toasts/update_toast.dart new file mode 100644 index 0000000..a15b2da --- /dev/null +++ b/app/lib/src/ui/toasts/update_toast.dart @@ -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().channel; + final inPlace = channel != UpdateChannel.manual; + final current = getIt().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().open(update.url); + }, + ), + SimuButton(label: 'Later', onPressed: close), + ], + ), + ); +} + +Future _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().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().open(update.url); + } +} diff --git a/app/lib/src/update/app_installer.dart b/app/lib/src/update/app_installer.dart new file mode 100644 index 0000000..b0ebd95 --- /dev/null +++ b/app/lib/src/update/app_installer.dart @@ -0,0 +1,321 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; +import 'package:simutil_app/src/update/app_update_cubit.dart'; +import 'package:simutil_core/simutil_core.dart'; + +/// How the running app was installed, which decides how it updates. +enum UpdateChannel { + /// `brew install --cask simutil-desktop`: `brew upgrade` replaces it. + homebrew, + + /// Copied from the DMG: the new bundle is swapped in place. + dmg, + + /// Inno Setup installer (direct or winget): the new one runs silently. + windowsInstaller, + + /// AppImage: the file is replaced. + appImage, + + /// Debug builds and unknown installs: open the release page. + manual, +} + +const _caskrooms = [ + '/opt/homebrew/Caskroom/simutil-desktop', + '/usr/local/Caskroom/simutil-desktop', +]; + +/// The `.app` bundle holding [executable], or `null` outside one. +String? appBundleOf(String executable) { + final i = executable.indexOf('.app/'); + return i < 0 ? null : executable.substring(0, i + 4); +} + +/// Picks the update channel from where the app runs. +UpdateChannel detectUpdateChannel({ + required String os, + required String executable, + required Map environment, + required bool Function(String path) dirExists, + bool releaseBuild = kReleaseMode, +}) { + if (!releaseBuild) return UpdateChannel.manual; + return switch (os) { + 'macos' when appBundleOf(executable) == null => UpdateChannel.manual, + 'macos' when _caskrooms.any(dirExists) => UpdateChannel.homebrew, + 'macos' => UpdateChannel.dmg, + 'windows' => UpdateChannel.windowsInstaller, + 'linux' when environment['APPIMAGE'] != null => UpdateChannel.appImage, + _ => UpdateChannel.manual, + }; +} + +/// The SHA-256 of [file] listed in a release's `checksums.txt` ([body]). +String? checksumFor(String body, String file) { + for (final line in body.split('\n')) { + final parts = line.trim().split(RegExp(r'\s+\*?')); + if (parts.length == 2 && parts[1] == file) return parts[0].toLowerCase(); + } + return null; +} + +/// One step of an in-place update, for the progress toast. +final class UpdateProgress { + const UpdateProgress(this.step, [this.fraction]); + + /// What is happening, e.g. "Downloading 12.3 / 54.4 MB". + final String step; + + /// 0..1, or `null` when the step can't measure it. + final double? fraction; +} + +/// Updates the running app in place through its [UpdateChannel], then +/// quits and relaunches it. +abstract interface class AppInstaller { + factory AppInstaller( + CommandExec exec, { + required Future Function() quit, + }) = _AppInstaller; + + UpdateChannel get channel; + + /// Installs [update]; on success the app quits and the new version + /// starts. Throws when anything fails before quitting, leaving the + /// current install untouched. + Future install( + AppUpdate update, { + void Function(UpdateProgress)? onProgress, + }); +} + +final class _AppInstaller implements AppInstaller { + _AppInstaller(this._exec, {required Future Function() quit}) + : _quit = quit; + + final CommandExec _exec; + final Future Function() _quit; + void Function(UpdateProgress) _progress = (_) {}; + + @override + late final UpdateChannel channel = detectUpdateChannel( + os: Platform.operatingSystem, + executable: Platform.resolvedExecutable, + environment: Platform.environment, + dirExists: (path) => Directory(path).existsSync(), + ); + + @override + Future install( + AppUpdate update, { + void Function(UpdateProgress)? onProgress, + }) async { + _progress = onProgress ?? (_) {}; + switch (channel) { + case UpdateChannel.homebrew: + await _brewUpgrade(); + case UpdateChannel.dmg: + await _swapBundle(update); + case UpdateChannel.windowsInstaller: + await _runInstaller(update); + case UpdateChannel.appImage: + await _replaceAppImage(update); + case UpdateChannel.manual: + throw StateError('No in-place update for this install'); + } + } + + Future _brewUpgrade() async { + final bundle = appBundleOf(Platform.resolvedExecutable)!; + final brew = [ + '/opt/homebrew/bin/brew', + '/usr/local/bin/brew', + ].firstWhere((p) => File(p).existsSync()); + _progress(const UpdateProgress('Upgrading with Homebrew…')); + final result = await _exec.run( + brew, + arguments: ['upgrade', '--cask', 'simutil-desktop'], + timeout: const Duration(minutes: 10), + ); + _check(result, 'brew upgrade'); + await _relaunch(['open', bundle]); + } + + /// Copies the new bundle next to the old one while still running, so a + /// failure (download, checksum, no write access) changes nothing; the + /// relaunch script then only renames. + Future _swapBundle(AppUpdate update) async { + final bundle = appBundleOf(Platform.resolvedExecutable)!; + final dir = await Directory.systemTemp.createTemp('simutil-update'); + final dmg = await _download(update, 'SimUtil-macos.dmg', dir); + final mount = '${dir.path}/mnt'; + await Directory(mount).create(); + _check( + await _exec.run( + 'hdiutil', + arguments: [ + 'attach', + '-nobrowse', + '-readonly', + '-mountpoint', + mount, + dmg.path, + ], + ), + 'hdiutil attach', + ); + _progress(const UpdateProgress('Installing…')); + try { + final stale = Directory('$bundle.new'); + if (stale.existsSync()) await stale.delete(recursive: true); + _check( + await _exec.run( + 'ditto', + arguments: ['$mount/SimUtil.app', '$bundle.new'], + ), + 'copy new app', + ); + } finally { + await _exec.run('hdiutil', arguments: ['detach', mount, '-force']); + } + await _relaunch([ + '/bin/sh', + '-c', + r'mv "$1" "$1.old" && mv "$1.new" "$1"; rm -rf "$1.old" "$2"; open "$1"', + 'sh', + bundle, + dir.path, + ]); + } + + Future _runInstaller(AppUpdate update) async { + final dir = await Directory.systemTemp.createTemp('simutil-update'); + final setup = await _download(update, 'SimUtil-windows-x64-setup.exe', dir); + String quote(String s) => "'${s.replaceAll("'", "''")}'"; + // The installer replaces files only once this process is gone. + await Process.start('powershell', [ + '-NoProfile', + '-Command', + 'Wait-Process -Id $pid -ErrorAction SilentlyContinue; ' + 'Start-Process -Wait -FilePath ${quote(setup.path)} ' + "-ArgumentList '/VERYSILENT','/SUPPRESSMSGBOXES','/NORESTART'; " + 'Start-Process -FilePath ${quote(Platform.resolvedExecutable)}', + ], mode: ProcessStartMode.detached); + _progress(const UpdateProgress('Restarting…', 1)); + await _quit(); + } + + /// Linux lets a running AppImage be renamed over. + Future _replaceAppImage(AppUpdate update) async { + final appImage = Platform.environment['APPIMAGE']!; + final file = await _download( + update, + 'SimUtil-linux-x86_64.AppImage', + File(appImage).parent, + saveAs: '${File(appImage).uri.pathSegments.last}.part', + ); + _progress(const UpdateProgress('Installing…')); + _check(await _exec.run('chmod', arguments: ['+x', file.path]), 'chmod'); + await file.rename(appImage); + await _relaunch([appImage]); + } + + /// Downloads [asset] of [update] into [dir] and checks it against the + /// release's `checksums.txt`. + Future _download( + AppUpdate update, + String asset, + Directory dir, { + String? saveAs, + }) async { + final base = + 'https://github.com/dungngminh/simutil/releases/download/app-v${update.version}'; + final file = File('${dir.path}/${saveAs ?? asset}'); + final expected = checksumFor( + await _get( + Uri.parse('$base/checksums.txt'), + (response) => utf8.decodeStream(response), + ), + asset, + ); + if (expected == null) throw StateError('No checksum for $asset'); + await _get(Uri.parse('$base/$asset'), (response) async { + final total = response.contentLength; + final sink = file.openWrite(); + var received = 0; + var reported = -1; + try { + await for (final chunk in response) { + sink.add(chunk); + received += chunk.length; + // One update per percent, not per chunk. + final percent = total > 0 ? received * 100 ~/ total : -1; + if (percent == reported && total > 0) continue; + reported = percent; + _progress( + UpdateProgress( + total > 0 + ? 'Downloading ${_mb(received)} / ${_mb(total)} MB' + : 'Downloading ${_mb(received)} MB', + total > 0 ? received / total : null, + ), + ); + } + } finally { + await sink.close(); + } + }); + _progress(const UpdateProgress('Verifying download…')); + final actual = (await sha256.bind(file.openRead()).first).toString(); + if (actual != expected) { + await file.delete(); + throw StateError('Checksum mismatch for $asset'); + } + return file; + } + + /// GETs [uri] (following GitHub's redirect to the asset host) and hands + /// the body to [read]. + Future _get( + Uri uri, + Future Function(HttpClientResponse) read, + ) async { + final client = HttpClient() + ..connectionTimeout = const Duration(seconds: 10) + ..userAgent = 'simutil-desktop'; + try { + final response = await (await client.getUrl(uri)).close(); + if (response.statusCode != HttpStatus.ok) { + throw HttpException('HTTP ${response.statusCode}', uri: uri); + } + return await read(response); + } finally { + client.close(force: true); + } + } + + static String _mb(int bytes) => (bytes / 1e6).toStringAsFixed(1); + + /// Runs [command] detached once this process has exited, then quits. + Future _relaunch(List command) async { + _progress(const UpdateProgress('Restarting…', 1)); + await Process.start('/bin/sh', [ + '-c', + r'while kill -0 "$1" 2>/dev/null; do sleep 0.2; done; shift; exec "$@"', + 'sh', + '$pid', + ...command, + ], mode: ProcessStartMode.detached); + await _quit(); + } + + void _check(CommandResult result, String step) { + if (!result.success) { + throw StateError('$step failed: ${result.stderr.trim()}'); + } + } +} diff --git a/app/lib/src/update/app_update_cubit.dart b/app/lib/src/update/app_update_cubit.dart new file mode 100644 index 0000000..58aa7ba --- /dev/null +++ b/app/lib/src/update/app_update_cubit.dart @@ -0,0 +1,110 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:bloc_signals/bloc_signals.dart'; +import 'package:equatable/equatable.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:simutil_shared/simutil_shared.dart'; + +/// The running app's `X.Y.Z`: `version:` in `app/pubspec.yaml`, or +/// `--build-name` when given, as baked into the bundle (Info.plist, the +/// .exe version resource, `version.json` on Linux). +Future getAppVersion() async => + (await PackageInfo.fromPlatform()).version; + +const _releasesUrl = + 'https://api.github.com/repos/dungngminh/simutil/releases?per_page=30'; + +/// A published desktop release newer than the running app. +final class AppUpdate extends Equatable { + const AppUpdate({required this.version, required this.url}); + + final String version; + + /// The GitHub release page with the installers. + final String url; + + @override + List get props => [version, url]; +} + +/// The newest published `app-vX.Y.Z` release in a GitHub releases list +/// ([json]), if newer than [current]. CLI releases (`v*`) share the repo, +/// so `/releases/latest` can't be used. +AppUpdate? newerAppRelease(String json, String current) { + for (final release in jsonDecode(json) as List) { + final r = release as Map; + final tag = r['tag_name'] as String? ?? ''; + if (!tag.startsWith('app-v') || + r['draft'] == true || + r['prerelease'] == true) { + continue; + } + final version = tag.substring('app-v'.length); + // The list is newest first: the first app release decides. + if (!isNewerVersion(version, current)) return null; + return AppUpdate(version: version, url: r['html_url'] as String); + } + return null; +} + +/// Records [current] as the last run version (in [path], default +/// `~/.simutil/desktop-version`) and returns the previous one when [current] +/// is newer, i.e. the app was just updated. First runs return `null`. +Future versionBeforeUpdate({ + required String current, + String? path, +}) async { + try { + final file = File( + path ?? '${File(resolveStatePath()).parent.path}/desktop-version', + ); + final previous = file.existsSync() + ? (await file.readAsString()).trim() + : null; + if (previous != current) { + await file.parent.create(recursive: true); + await file.writeAsString(current); + } + return previous != null && isNewerVersion(current, previous) + ? previous + : null; + } catch (_) { + return null; + } +} + +/// Holds the available update, `null` while up to date or unchecked. +class AppUpdateCubit extends CubitSignal { + AppUpdateCubit({required this.currentVersion, HttpGet? httpGet}) + : _httpGet = httpGet ?? _get, + super(initialState: null); + + /// From [getAppVersion]. + final String currentVersion; + final HttpGet _httpGet; + + /// Checks GitHub once; offline or rate limited stays silent. + Future check() async { + if (Platform.environment.containsKey('SIMUTIL_NO_UPDATE_CHECK')) return; + try { + final body = await _httpGet(Uri.parse(_releasesUrl)); + if (body != null) emit(newerAppRelease(body, currentVersion)); + } catch (_) {} + } +} + +Future _get(Uri uri) async { + const timeout = Duration(seconds: 5); + final client = HttpClient()..connectionTimeout = timeout; + try { + final request = await client.getUrl(uri).timeout(timeout); + // GitHub's API rejects requests without a User-Agent. + request.headers.set(HttpHeaders.userAgentHeader, 'simutil-desktop'); + final response = await request.close().timeout(timeout); + if (response.statusCode != HttpStatus.ok) return null; + return await response.transform(utf8.decoder).join().timeout(timeout); + } finally { + client.close(force: true); + } +} diff --git a/app/macos/Flutter/GeneratedPluginRegistrant.swift b/app/macos/Flutter/GeneratedPluginRegistrant.swift index 5e56fe5..4c2faac 100644 --- a/app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,6 +7,7 @@ import Foundation import desktop_drop import file_selector_macos +import package_info_plus import screen_retriever_macos import simutil_h264 import window_manager @@ -14,6 +15,7 @@ import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) SimutilH264Plugin.register(with: registry.registrar(forPlugin: "SimutilH264Plugin")) WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin")) diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 19304ea..13d9a32 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -12,6 +12,7 @@ dependencies: flutter: sdk: flutter bloc_signals: ^1.6.0 + crypto: ^3.0.7 bloc_signals_flutter: ^1.4.0 equatable: ^2.0.7 get_it: ^9.3.0 @@ -29,6 +30,7 @@ dependencies: desktop_drop: ^0.8.4 file_selector: ^1.1.0 tray_manager: ^0.8.0 + package_info_plus: ^10.2.2 dev_dependencies: flutter_lints: ^6.0.0 diff --git a/app/test/app_installer_test.dart b/app/test/app_installer_test.dart new file mode 100644 index 0000000..3e11afe --- /dev/null +++ b/app/test/app_installer_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:simutil_app/src/update/app_installer.dart'; + +UpdateChannel _detect( + String os, + String executable, { + Map environment = const {}, + Set dirs = const {}, + bool releaseBuild = true, +}) => detectUpdateChannel( + os: os, + executable: executable, + environment: environment, + dirExists: dirs.contains, + releaseBuild: releaseBuild, +); + +void main() { + const bundleExe = '/Applications/SimUtil.app/Contents/MacOS/SimUtil'; + + test('macOS: Homebrew cask when its Caskroom exists, else DMG', () { + expect( + _detect( + 'macos', + bundleExe, + dirs: {'/opt/homebrew/Caskroom/simutil-desktop'}, + ), + UpdateChannel.homebrew, + ); + expect(_detect('macos', bundleExe), UpdateChannel.dmg); + expect(appBundleOf(bundleExe), '/Applications/SimUtil.app'); + }); + + test('Windows installer, Linux AppImage only under APPIMAGE', () { + expect( + _detect('windows', r'C:\Program Files\SimUtil\simutil_app.exe'), + UpdateChannel.windowsInstaller, + ); + expect( + _detect( + 'linux', + '/tmp/.mount_x/simutil_app', + environment: {'APPIMAGE': '/home/u/.local/bin/simutil-desktop'}, + ), + UpdateChannel.appImage, + ); + expect(_detect('linux', '/opt/simutil/simutil_app'), UpdateChannel.manual); + }); + + test('debug builds never update in place', () { + expect( + _detect('macos', bundleExe, releaseBuild: false), + UpdateChannel.manual, + ); + }); + + test('checksumFor reads sha256sum output', () { + const body = + 'abc123 SimUtil-macos.dmg\n' + 'DEF456 SimUtil-windows-x64-setup.exe\n'; + expect(checksumFor(body, 'SimUtil-macos.dmg'), 'abc123'); + expect(checksumFor(body, 'SimUtil-windows-x64-setup.exe'), 'def456'); + expect(checksumFor(body, 'SimUtil-linux-x86_64.AppImage'), isNull); + }); +} diff --git a/app/test/app_update_test.dart b/app/test/app_update_test.dart new file mode 100644 index 0000000..5c3fca1 --- /dev/null +++ b/app/test/app_update_test.dart @@ -0,0 +1,60 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:simutil_app/src/update/app_update_cubit.dart'; + +String _releases(List> releases) => jsonEncode([ + for (final r in releases) + { + 'draft': false, + 'prerelease': false, + 'html_url': 'https://example.com/${r['tag_name']}', + ...r, + }, +]); + +void main() { + test('picks the newest app release, skipping CLI tags', () { + final json = _releases([ + {'tag_name': 'v1.2.0'}, + {'tag_name': 'app-v0.4.0'}, + {'tag_name': 'app-v0.3.0'}, + ]); + expect( + newerAppRelease(json, '0.3.1'), + const AppUpdate(version: '0.4.0', url: 'https://example.com/app-v0.4.0'), + ); + }); + + test('skips drafts and prereleases', () { + final json = _releases([ + {'tag_name': 'app-v0.5.0', 'prerelease': true}, + {'tag_name': 'app-v0.4.1', 'draft': true}, + {'tag_name': 'app-v0.4.0'}, + ]); + expect(newerAppRelease(json, '0.3.1')?.version, '0.4.0'); + }); + + test('null when up to date', () { + final json = _releases([ + {'tag_name': 'app-v0.3.1'}, + ]); + expect(newerAppRelease(json, '0.3.1'), isNull); + expect(newerAppRelease('[]', '0.3.1'), isNull); + }); + + test( + 'versionBeforeUpdate reports the old version once after an update', + () async { + final dir = Directory.systemTemp.createTempSync('simutil-version'); + addTearDown(() => dir.deleteSync(recursive: true)); + final path = '${dir.path}/desktop-version'; + // First run: nothing to announce. + expect(await versionBeforeUpdate(current: '0.2.0', path: path), isNull); + expect(await versionBeforeUpdate(current: '0.2.0', path: path), isNull); + expect(await versionBeforeUpdate(current: '0.3.0', path: path), '0.2.0'); + expect(await versionBeforeUpdate(current: '0.3.0', path: path), isNull); + }, + ); +} diff --git a/docs/ai/deployment.md b/docs/ai/deployment.md index 83dd6c5..11b42a5 100644 --- a/docs/ai/deployment.md +++ b/docs/ai/deployment.md @@ -210,7 +210,8 @@ quarantine flag on install. ### Desktop app release 1. Date the version heading in [app/CHANGELOG.md](../../app/CHANGELOG.md) - (`## [X.Y.Z] - YYYY-MM-DD`) and bump `version:` in `app/pubspec.yaml`. + (`## [X.Y.Z] - YYYY-MM-DD`) and bump `version:` in `app/pubspec.yaml` + (the in-app update check reads it from the built bundle). 2. Merge, then `git tag app-vX.Y.Z && git push origin app-vX.Y.Z`. 3. Check the draft release, then **Publish** it; that fires `deploy-app.yaml`. 4. First release only: WinGet updates need the package to exist already. diff --git a/pubspec.lock b/pubspec.lock index d052eef..22e0310 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -672,6 +672,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: "73ef2cd5c11a7548f9b7f6fc2a4f6113de2e64aa2a70de63feede95548822908" + url: "https://pub.dev" + source: hosted + version: "10.2.2" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 + url: "https://pub.dev" + source: hosted + version: "4.1.0" path: dependency: transitive description: From ae6c482c0f1156c146cc7f75293823673e12056f Mon Sep 17 00:00:00 2001 From: dungngminh <63831488+dungngminh@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:02:01 +0700 Subject: [PATCH 2/2] chore(app): bump app to 0.4.0 The app-v0.3.1 draft release was never published, so its changes ship in 0.4.0. --- app/CHANGELOG.md | 4 +--- app/pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/app/CHANGELOG.md b/app/CHANGELOG.md index 0210e99..85e5125 100644 --- a/app/CHANGELOG.md +++ b/app/CHANGELOG.md @@ -5,7 +5,7 @@ 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] ### Added @@ -16,8 +16,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the new version starts. Missed it? Click the arrow next to the SimUtil title. -## [0.3.1] - 2026-10-14 - ### Changed - Pump `simutil_apple` to 1.3.1 for supporting iOS real devices discovery in later macOS 27 and older than macOS 27 diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 13d9a32..a04b857 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -1,7 +1,7 @@ name: simutil_app description: SimUtil desktop app — multi-device streaming. publish_to: none -version: 0.3.1 +version: 0.4.0 resolution: workspace environment: