From 602d294da47841c848c5436843cc8d7b8c0999d1 Mon Sep 17 00:00:00 2001 From: gasaichan Date: Mon, 22 Jun 2026 02:30:50 +0400 Subject: [PATCH] feature(updater): Implement auto-updating system using AppCast protocol --- .gitignore | 5 +- AGENTS.md | 16 +- README.md | 16 +- docs/launcher_self_update.md | 134 ++++++++++++++ installer/moonwell_launcher.iss | 6 +- lib/config.dart | 3 + .../data/launcher_update_service.dart | 99 +++++++++++ lib/main.dart | 7 +- lib/service_container.config.dart | 4 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + macos/Podfile.lock | 13 ++ pubspec.lock | 32 ++++ pubspec.yaml | 1 + .../data/launcher_update_service_test.dart | 163 ++++++++++++++++++ .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + windows/runner/Runner.rc | 8 + windows/runner/resources/dsa_pub.pem | 20 +++ 18 files changed, 524 insertions(+), 9 deletions(-) create mode 100644 docs/launcher_self_update.md create mode 100644 lib/features/launcher/data/launcher_update_service.dart create mode 100644 test/features/launcher/data/launcher_update_service_test.dart create mode 100644 windows/runner/resources/dsa_pub.pem diff --git a/.gitignore b/.gitignore index ac83f76..b1636b2 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,7 @@ app.*.map.json /android/app/release # FVM Version Cache -.fvm/ \ No newline at end of file +.fvm/ + +# Local launcher update signing material +.moonwell_signing/ diff --git a/AGENTS.md b/AGENTS.md index 72a0a1f..fa85169 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,8 +46,8 @@ dart run build_runner build --delete-conflicting-outputs dart format --set-exit-if-changed . flutter analyze flutter test -flutter run -d windows --dart-define=MOONWELL_API_BASE_URL=https://host -flutter build windows --dart-define=MOONWELL_API_BASE_URL=https://host +flutter run -d windows --dart-define=MOONWELL_API_BASE_URL=https://host --dart-define=MOONWELL_APPCAST_URL=https://host/launcher/updates/appcast.xml +flutter build windows --dart-define=MOONWELL_API_BASE_URL=https://host --dart-define=MOONWELL_APPCAST_URL=https://host/launcher/updates/appcast.xml ``` See `docs/linting.md` for the required format, analyze, test, and Lefthook @@ -61,6 +61,15 @@ The launcher API base URL is compile-time configuration: --dart-define=MOONWELL_API_BASE_URL=https://host ``` +The optional Windows self-update feed is also compile-time configuration: + +```powershell +--dart-define=MOONWELL_APPCAST_URL=https://host/launcher/updates/appcast.xml +``` + +Production AppCast feeds must be public HTTPS URLs. Omit the setting to disable +self-update in local builds. + Do not commit private credentials, production secrets, temporary bearer tokens, or local-only API URLs. @@ -71,6 +80,9 @@ or local-only API URLs. Do not store sessions or other secrets in `shared_preferences`. - File sync behavior should stay in `ClientSyncUseCase` and `GameInstallationService`. +- Launcher self-update behavior should stay behind `LauncherUpdateService`. + Windows updates use WinSparkle/AppCast and the signed Inno Setup artifact; + they are separate from game-client synchronization. - The server manifest is the source of truth for client files. - Downloads must write to `*.moonwell.part`, verify size and SHA-256, then replace the destination file only after successful verification. diff --git a/README.md b/README.md index 082dfbc..661e22d 100644 --- a/README.md +++ b/README.md @@ -41,10 +41,19 @@ Set the launcher Web API base URL at compile time: --dart-define=MOONWELL_API_BASE_URL=https://host ``` +Set the public AppCast feed URL to enable Windows launcher self-updates: + +```powershell +--dart-define=MOONWELL_APPCAST_URL=https://host/launcher/updates/appcast.xml +``` + +The updater is disabled when `MOONWELL_APPCAST_URL` is omitted. Production +feeds must use HTTPS. + Example run command: ```powershell -flutter run -d windows --dart-define=MOONWELL_API_BASE_URL=https://host +flutter run -d windows --dart-define=MOONWELL_API_BASE_URL=https://host --dart-define=MOONWELL_APPCAST_URL=https://host/launcher/updates/appcast.xml ``` ## Development @@ -67,10 +76,12 @@ pre-commit workflow. Build the Windows launcher: ```powershell -flutter build windows --dart-define=MOONWELL_API_BASE_URL=https://host +flutter build windows --dart-define=MOONWELL_API_BASE_URL=https://host --dart-define=MOONWELL_APPCAST_URL=https://host/launcher/updates/appcast.xml ``` The Inno Setup installer script lives at `installer/moonwell_launcher.iss`. +See `docs/launcher_self_update.md` for the AppCast format, signing keys, and +manual release procedure. ## Architecture @@ -99,3 +110,4 @@ See `docs/launcher_web_api_spec.md` for the API and sync contract. - `docs/project_review.md`: project review with strengths, weaknesses, and upgrade feasibility notes - `docs/launcher_web_api_spec.md`: current launcher Web API and sync behavior +- `docs/launcher_self_update.md`: Windows self-update and release operations diff --git a/docs/launcher_self_update.md b/docs/launcher_self_update.md new file mode 100644 index 0000000..4d6bc52 --- /dev/null +++ b/docs/launcher_self_update.md @@ -0,0 +1,134 @@ +# Launcher Self-Update + +MoonWell Launcher uses `auto_updater` 1.0.0, backed by WinSparkle 0.8.1, to +update installed Windows launchers from an AppCast feed. This is independent of +the manifest-based World of Warcraft client synchronization. + +The launcher performs one background update check after its window is visible. +WinSparkle shows its native prompt only when a newer version is available. +Missing configuration, network failures, and malformed feeds do not block the +launcher. Periodic WinSparkle checks are disabled. + +## Build Configuration + +Pass the public feed URL at build time: + +```powershell +flutter build windows ` + --dart-define=MOONWELL_API_BASE_URL=https://host ` + --dart-define=MOONWELL_APPCAST_URL=https://host/launcher/updates/appcast.xml +``` + +The feed URL must be an absolute HTTPS URL. Omit +`MOONWELL_APPCAST_URL` to disable self-update, including for ordinary local +development. + +## Signing Keys + +WinSparkle verifies installer artifacts with a DSA signature. The public key is +committed at `windows/runner/resources/dsa_pub.pem` and embedded as the +`DSAPub` resource. The corresponding private key is local signing material at +`.moonwell_signing/dsa_priv.pem`; it is ignored by Git and must be backed up to +the project secret store before publishing the first update. + +Never commit or upload the private key. Losing it prevents existing +installations from accepting future updates. Rotating it requires shipping an +ordinary installer containing the replacement public key before publishing +artifacts signed by the replacement private key. + +The AppCast signature is not Authenticode. Publicly trusted Authenticode +certificates and managed signing services are paid products, so MoonWell does +not currently Authenticode-sign the installer. Windows can therefore display +an unknown-publisher or SmartScreen warning. A free self-signed Authenticode +certificate would not establish trust on player machines. + +## Version and AppCast Contract + +Increase the `X.Y.Z` version in `pubspec.yaml` for every launcher release. A +build-number-only change such as `1.0.0+2` is not sufficient because WinSparkle +compares the Windows product version. Pass the same `X.Y.Z` value to Inno Setup: + +```powershell +ISCC.exe /DMyAppVersion=1.1.0 installer\moonwell_launcher.iss +``` + +The resulting artifact is +`build/installer/moonwell_launcher_1.1.0_windows_setup.exe`. + +Use a public AppCast with absolute URLs: + +```xml + + + + MoonWell Launcher + MoonWell Launcher updates + ru + + MoonWell Launcher 1.1.0 + https://host/launcher/updates/1.1.0/release-notes.html + Sun, 21 Jun 2026 12:00:00 +0400 + + + + +``` + +The installer URL and release-notes URL must be absolute. The publication host +must serve the feed, notes, and installer without authentication or redirects +to an authenticated endpoint. + +## Manual Release + +Perform Windows signing on the Windows release machine because the package +invokes WinSparkle's Windows signing utility. + +1. Update `pubspec.yaml` to the new `X.Y.Z+N` version. +2. Run `flutter pub get`, formatting, analysis, and all tests. +3. Build Windows with both production `--dart-define` values. +4. Compile the installer with `ISCC.exe /DMyAppVersion=X.Y.Z`. +5. Make the private key available outside the repository and sign the artifact: + + ```powershell + dart run auto_updater:sign_update ` + build\installer\moonwell_launcher_X.Y.Z_windows_setup.exe ` + C:\secure-path\dsa_priv.pem + ``` + +6. Copy the emitted `sparkle:dsaSignature` into the enclosure. The package's + Windows helper reports `length="0"`; replace it with the actual value from: + + ```powershell + (Get-Item build\installer\moonwell_launcher_X.Y.Z_windows_setup.exe).Length + ``` + +7. Upload the versioned installer and release notes first. +8. Download the hosted installer, verify its byte length, and test its DSA + signature before changing the feed. +9. Publish `appcast.xml` last so clients never observe an incomplete release. + +Keep the preceding installer available for rollback and support. Rollback is a +new release with a product version higher than the faulty release; WinSparkle +does not install a lower version as an update. + +## Release Verification + +Test from an installed older version on a clean Windows x64 machine: + +- offline or invalid feed: launcher remains usable and writes a log entry +- same or older feed version: no update prompt +- valid newer release: prompt, download, launcher exit, install, and relaunch +- modified installer or invalid DSA signature: WinSparkle rejects the update +- launcher session and selected game directory remain after installation + +Watch the debug output for Flutter platform-channel thread warnings during the +test. `auto_updater` has an open Windows callback-thread issue; do not release +if it reproduces. Patch and pin `auto_updater_windows` so native callbacks are +marshalled onto Flutter's platform thread, then repeat the complete update test. diff --git a/installer/moonwell_launcher.iss b/installer/moonwell_launcher.iss index c0319a8..ebf3443 100644 --- a/installer/moonwell_launcher.iss +++ b/installer/moonwell_launcher.iss @@ -1,6 +1,8 @@ #define MyAppName "MoonWell Launcher" #define MyAppExeName "MoonWell.exe" -#define MyAppVersion "1.0.0" +#ifndef MyAppVersion + #define MyAppVersion "1.0.0" +#endif #define MyAppPublisher "MoonWell" #define MyAppURL "https://moon-well.online" #define MyAppAssocName MyAppName + " Launcher" @@ -19,7 +21,7 @@ DefaultGroupName={#MyAppName} DisableProgramGroupPage=yes PrivilegesRequired=lowest OutputDir=..\build\installer -OutputBaseFilename=moonwell_launcher_setup +OutputBaseFilename=moonwell_launcher_{#MyAppVersion}_windows_setup SetupIconFile=..\windows\runner\resources\app_icon.ico UninstallDisplayIcon={app}\{#MyAppExeName} Compression=lzma diff --git a/lib/config.dart b/lib/config.dart index 9eaed86..029e467 100644 --- a/lib/config.dart +++ b/lib/config.dart @@ -2,6 +2,9 @@ final class Config { static const launcherApiBaseUrl = String.fromEnvironment( 'MOONWELL_API_BASE_URL', ); + static const launcherAppcastUrl = String.fromEnvironment( + 'MOONWELL_APPCAST_URL', + ); static const launcherLogFileName = 'launcher.log'; static const gameExecutableName = 'Wow.exe'; static const cacheDirectoryName = 'Cache'; diff --git a/lib/features/launcher/data/launcher_update_service.dart b/lib/features/launcher/data/launcher_update_service.dart new file mode 100644 index 0000000..b14a6af --- /dev/null +++ b/lib/features/launcher/data/launcher_update_service.dart @@ -0,0 +1,99 @@ +import 'dart:io'; + +import 'package:auto_updater/auto_updater.dart'; +import 'package:injectable/injectable.dart'; +import 'package:moonwell_launcher/config.dart'; +import 'package:moonwell_launcher/features/launcher/data/launcher_log_service.dart'; + +abstract interface class LauncherUpdateClient { + Future setFeedUrl(String feedUrl); + + Future disableScheduledChecks(); + + Future checkInBackground(); +} + +final class AutoUpdaterLauncherUpdateClient implements LauncherUpdateClient { + @override + Future setFeedUrl(String feedUrl) => autoUpdater.setFeedURL(feedUrl); + + @override + Future disableScheduledChecks() => + autoUpdater.setScheduledCheckInterval(0); + + @override + Future checkInBackground() => + autoUpdater.checkForUpdates(inBackground: true); +} + +@lazySingleton +class LauncherUpdateService { + LauncherUpdateService(LauncherLogService launcherLogService) + : this.forTesting( + launcherLogService: launcherLogService, + updateClient: AutoUpdaterLauncherUpdateClient(), + isWindows: Platform.isWindows, + ); + + LauncherUpdateService.forTesting({ + required LauncherLogService launcherLogService, + required LauncherUpdateClient updateClient, + required bool isWindows, + }) : _launcherLogService = launcherLogService, + _updateClient = updateClient, + _isWindows = isWindows; + + final LauncherLogService _launcherLogService; + final LauncherUpdateClient _updateClient; + final bool _isWindows; + + bool _hasChecked = false; + + Future checkForUpdates({ + String feedUrl = Config.launcherAppcastUrl, + }) async { + if (!_isWindows || _hasChecked) { + return; + } + + final normalizedFeedUrl = feedUrl.trim(); + if (normalizedFeedUrl.isEmpty) { + await _launcherLogService.info( + 'Launcher self-update is disabled because no AppCast URL is configured.', + ); + return; + } + + final uri = Uri.tryParse(normalizedFeedUrl); + if (uri == null || + !uri.isAbsolute || + uri.scheme != 'https' || + !uri.hasAuthority || + uri.host.isEmpty) { + await _launcherLogService.error( + 'Launcher self-update AppCast URL is invalid.', + details: {'feedUrl': normalizedFeedUrl}, + ); + return; + } + + _hasChecked = true; + + try { + await _updateClient.setFeedUrl(normalizedFeedUrl); + await _updateClient.disableScheduledChecks(); + await _updateClient.checkInBackground(); + await _launcherLogService.info( + 'Launcher self-update check started.', + details: {'feedUrl': normalizedFeedUrl}, + ); + } catch (error, stackTrace) { + await _launcherLogService.error( + 'Launcher self-update check failed.', + details: {'feedUrl': normalizedFeedUrl}, + error: error, + stackTrace: stackTrace, + ); + } + } +} diff --git a/lib/main.dart b/lib/main.dart index 3aa0d7c..0c1a022 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:moonwell_launcher/app/mw_app.dart'; +import 'package:moonwell_launcher/features/launcher/data/launcher_update_service.dart'; import 'package:moonwell_launcher/service_container.dart'; import 'package:window_manager/window_manager.dart'; @@ -10,6 +11,9 @@ Future main() async { WidgetsFlutterBinding.ensureInitialized(); await windowManager.ensureInitialized(); + await configureDependencies(env: 'flutter'); + final launcherUpdateService = getIt(); + WindowOptions windowOptions = WindowOptions( size: Size(960, 540), center: true, @@ -22,10 +26,9 @@ Future main() async { windowManager.waitUntilReadyToShow(windowOptions, () async { await windowManager.show(); await windowManager.focus(); + await launcherUpdateService.checkForUpdates(); }), ); - await configureDependencies(env: 'flutter'); - runApp(const MoonWellApp()); } diff --git a/lib/service_container.config.dart b/lib/service_container.config.dart index 8a52867..8d1f987 100644 --- a/lib/service_container.config.dart +++ b/lib/service_container.config.dart @@ -18,6 +18,7 @@ import 'features/launcher/application/client_sync_use_case.dart' as _i757; import 'features/launcher/data/game_installation_service.dart' as _i315; import 'features/launcher/data/launcher_api_client.dart' as _i703; import 'features/launcher/data/launcher_log_service.dart' as _i43; +import 'features/launcher/data/launcher_update_service.dart' as _i605; import 'features/preferences/data/repositories/local_preferences_repository.dart' as _i658; import 'features/preferences/domain/repositories/preferences_repository.dart' @@ -47,6 +48,9 @@ extension GetItInjectableX on _i174.GetIt { gh.lazySingleton<_i558.FlutterSecureStorage>( () => flutterSecureStorageModule.storage, ); + gh.lazySingleton<_i605.LauncherUpdateService>( + () => _i605.LauncherUpdateService(gh<_i43.LauncherLogService>()), + ); gh.lazySingleton<_i703.LauncherApiClient>( () => _i703.LauncherApiClient( launcherLogService: gh<_i43.LauncherLogService>(), diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 8ca8948..8be8248 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,7 @@ import FlutterMacOS import Foundation +import auto_updater_macos import file_picker import flutter_secure_storage_darwin import screen_retriever_macos @@ -12,6 +13,7 @@ import shared_preferences_foundation import window_manager func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AutoUpdaterMacosPlugin.register(with: registry.registrar(forPlugin: "AutoUpdaterMacosPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index b442bbc..ce8c07f 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,4 +1,7 @@ PODS: + - auto_updater_macos (0.0.1): + - FlutterMacOS + - Sparkle - file_picker (0.0.1): - FlutterMacOS - flutter_secure_storage_darwin (10.0.0): @@ -10,10 +13,12 @@ PODS: - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS + - Sparkle (2.9.3) - window_manager (0.5.0): - FlutterMacOS DEPENDENCIES: + - auto_updater_macos (from `Flutter/ephemeral/.symlinks/plugins/auto_updater_macos/macos`) - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) - flutter_secure_storage_darwin (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_darwin/darwin`) - FlutterMacOS (from `Flutter/ephemeral`) @@ -21,7 +26,13 @@ DEPENDENCIES: - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) +SPEC REPOS: + trunk: + - Sparkle + EXTERNAL SOURCES: + auto_updater_macos: + :path: Flutter/ephemeral/.symlinks/plugins/auto_updater_macos/macos file_picker: :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos flutter_secure_storage_darwin: @@ -36,11 +47,13 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos SPEC CHECKSUMS: + auto_updater_macos: 3a42f1a06be6981f1a18be37e6e7bf86aa732118 file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 screen_retriever_macos: c5508cc3c66ff0d4db650480cf0ab691e220d933 shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + Sparkle: 39dbdf28637f6056fbf5ee6494bdbfae54f49f41 window_manager: b729e31d38fb04905235df9ea896128991cad99e PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 diff --git a/pubspec.lock b/pubspec.lock index 766e479..92798d1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + auto_updater: + dependency: "direct main" + description: + name: auto_updater + sha256: "74fd008b021d15e4fc50fb5f1923870e6374ae831bb1168241c23bc35a4e898b" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + auto_updater_macos: + dependency: transitive + description: + name: auto_updater_macos + sha256: ef9de0daf38d782d2243fe131503a9404d62df762f5386e5360020e43c01867f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + auto_updater_platform_interface: + dependency: transitive + description: + name: auto_updater_platform_interface + sha256: ed5dff8cea18c58bc5ac787ff9122fedd1644272e02015d28c9b592f8078c5dc + url: "https://pub.dev" + source: hosted + version: "1.0.0" + auto_updater_windows: + dependency: transitive + description: + name: auto_updater_windows + sha256: "2bba20a71eee072f49b7267fedd5c4f1406c4b1b1e5b83932c634dbab75b80c9" + url: "https://pub.dev" + source: hosted + version: "1.0.0" bloc: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 0eb13a1..c5ee14f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,6 +45,7 @@ dependencies: flutter_secure_storage: ^10.3.1 shared_preferences: ^2.5.3 window_manager: ^0.5.1 + auto_updater: 1.0.0 dev_dependencies: flutter_test: diff --git a/test/features/launcher/data/launcher_update_service_test.dart b/test/features/launcher/data/launcher_update_service_test.dart new file mode 100644 index 0000000..b2f69a4 --- /dev/null +++ b/test/features/launcher/data/launcher_update_service_test.dart @@ -0,0 +1,163 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:moonwell_launcher/features/launcher/data/launcher_log_service.dart'; +import 'package:moonwell_launcher/features/launcher/data/launcher_update_service.dart'; + +void main() { + group('LauncherUpdateService', () { + test('does nothing on unsupported platforms', () async { + final client = _FakeLauncherUpdateClient(); + final logger = _FakeLauncherLogService(); + final service = LauncherUpdateService.forTesting( + launcherLogService: logger, + updateClient: client, + isWindows: false, + ); + + await service.checkForUpdates( + feedUrl: 'https://moon-well.online/launcher/updates/appcast.xml', + ); + + expect(client.calls, isEmpty); + expect(logger.entries, isEmpty); + }); + + test('logs disabled state when the feed URL is empty', () async { + final client = _FakeLauncherUpdateClient(); + final logger = _FakeLauncherLogService(); + final service = LauncherUpdateService.forTesting( + launcherLogService: logger, + updateClient: client, + isWindows: true, + ); + + await service.checkForUpdates(feedUrl: ' '); + + expect(client.calls, isEmpty); + expect(logger.entries.single, startsWith('INFO:')); + }); + + test('rejects a non-HTTPS feed URL without invoking the updater', () async { + final client = _FakeLauncherUpdateClient(); + final logger = _FakeLauncherLogService(); + final service = LauncherUpdateService.forTesting( + launcherLogService: logger, + updateClient: client, + isWindows: true, + ); + + await service.checkForUpdates( + feedUrl: 'http://moon-well.online/appcast.xml', + ); + + expect(client.calls, isEmpty); + expect(logger.entries.single, startsWith('ERROR:')); + }); + + test('rejects an HTTPS URL without a host', () async { + final client = _FakeLauncherUpdateClient(); + final logger = _FakeLauncherLogService(); + final service = LauncherUpdateService.forTesting( + launcherLogService: logger, + updateClient: client, + isWindows: true, + ); + + await service.checkForUpdates(feedUrl: 'https:appcast.xml'); + + expect(client.calls, isEmpty); + expect(logger.entries.single, startsWith('ERROR:')); + }); + + test( + 'configures WinSparkle before starting one background check', + () async { + final client = _FakeLauncherUpdateClient(); + final logger = _FakeLauncherLogService(); + final service = LauncherUpdateService.forTesting( + launcherLogService: logger, + updateClient: client, + isWindows: true, + ); + const feedUrl = 'https://moon-well.online/launcher/updates/appcast.xml'; + + await service.checkForUpdates(feedUrl: feedUrl); + await service.checkForUpdates(feedUrl: feedUrl); + + expect(client.calls, [ + 'setFeedUrl:$feedUrl', + 'disableScheduledChecks', + 'checkInBackground', + ]); + expect(logger.entries.single, startsWith('INFO:')); + }, + ); + + test('logs updater failures without propagating them', () async { + final client = _FakeLauncherUpdateClient(failure: Exception('offline')); + final logger = _FakeLauncherLogService(); + final service = LauncherUpdateService.forTesting( + launcherLogService: logger, + updateClient: client, + isWindows: true, + ); + + await expectLater( + service.checkForUpdates( + feedUrl: 'https://moon-well.online/launcher/updates/appcast.xml', + ), + completes, + ); + + expect(logger.entries.single, contains('offline')); + }); + }); +} + +class _FakeLauncherUpdateClient implements LauncherUpdateClient { + _FakeLauncherUpdateClient({this.failure}); + + final Object? failure; + final List calls = []; + + @override + Future setFeedUrl(String feedUrl) async { + calls.add('setFeedUrl:$feedUrl'); + if (failure case final error?) { + throw error; + } + } + + @override + Future disableScheduledChecks() async { + calls.add('disableScheduledChecks'); + } + + @override + Future checkInBackground() async { + calls.add('checkInBackground'); + } +} + +class _FakeLauncherLogService extends LauncherLogService { + final List entries = []; + + @override + Future info( + String message, { + String? installationDir, + Map details = const {}, + }) async { + entries.add('INFO:$message'); + } + + @override + Future error( + String message, { + String? installationDir, + Map details = const {}, + Object? error, + StackTrace? stackTrace, + }) async { + entries.add('ERROR:$message:$error'); + } +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index e3176cf..aedb01e 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,11 +6,14 @@ #include "generated_plugin_registrant.h" +#include #include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + AutoUpdaterWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AutoUpdaterWindowsPluginCApi")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); ScreenRetrieverWindowsPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 8827481..8987186 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + auto_updater_windows flutter_secure_storage_windows screen_retriever_windows window_manager diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index 5106d84..f11b722 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -55,6 +55,14 @@ END IDI_APP_ICON ICON "resources\\app_icon.ico" +///////////////////////////////////////////////////////////////////////////// +// +// WinSparkle update signature verification +// + +DSAPub DSAPEM "resources\\dsa_pub.pem" + + ///////////////////////////////////////////////////////////////////////////// // // Version diff --git a/windows/runner/resources/dsa_pub.pem b/windows/runner/resources/dsa_pub.pem new file mode 100644 index 0000000..32dd7cf --- /dev/null +++ b/windows/runner/resources/dsa_pub.pem @@ -0,0 +1,20 @@ +-----BEGIN PUBLIC KEY----- +MIIDQjCCAjUGByqGSM44BAEwggIoAoIBAQC5rT00uqOtUBNAtY/Kzep4xFXX34Xh +ArYMNMsc0lSCm7WzwklN1GTqKwp18gUxU1WX4YepePBwb4Mtm22VdYW76xKUMXpP +fZ/d2e+l2jwo/urixdQ9vdGrYTkPJLS9Mn6q/7/XQnIx/MTroOcYNh1QJj3Gynxy +y2C95Ty4yaxpjz9BP4TzT8XSvAy4ItdE0Xv2DqvruAP8VLR7HLAYMZFxvCuC65EC +3nLIRByL8s7CGq8w2Wn7x8WKI/PQ/RvngMoc0xrK58qV+I2hFefXSYG4T7/aDvOb +MTnIHlBZcgSEGCFDhtRqStKcBVW+ybk9SRpePU8rWL6QTtZTaBRlGlyRAh0A9A66 +ZMG2sKJqs2w6X+dUCXzeNUMSrYpQit6UjwKCAQBJShETJlN90ZvrtYSOUnTrXKlH +rka5s6d1xFUNyqVNlI84oRC2gfw+eVAzbjtwFXv342uSHgC+kwQwK5UClxBTDxcm +YK4yC3288a4BoCf76SkLcUOdEp2/Ef1MjMSWKorcX9SAKY98RihrRXoHLrmDN3vk +/SVqgLv1Auu5X7yHMO2IDWvI29Sh6W13i2joXFt2O5ufsMY1k6sQtnT6R7sLphVw +ifE6ulDwRNjSkPMFCrKyKpnqXmC1TvtAdkSd0DsWEfQuqp3878PZoVTJzHxrl1Y0 +3M7yrbVj5OojdGA9kX0zelrNCIPuH+2vmfeFis8y5XFFEOxc9yWezFYJ3TfEA4IB +BQACggEAfoxCOU4ofKti7T7QVV3d5Oh6fIi2eTLG4utP6Fvtb6AktF6OPJWeDmDe +g3ffLkKyx8/uA+KuZJO3/t/pHvzUvWNmc487bFZYd0dhlObQEu6Qg5uqDVWG4nqy +K1zNmhVO97SqzBjUklyaxQre65w3XI4a1XSk8RAkcMzu5rze4P9BodEChaG/kEih +qCJZivBjhMjaKi5cmE/S9G0zMrdTf152V8WaN0EjbFThC1izxk2j+76t+ALAe1Tt +I1bY9RT8pzjdfRwWHja0cooPILCWL82pcolOAvrNzhkbR9sIcw5t9F517EQch4VD +0W9SMpZl5uKcw3RS0oiFWyyKNWavvQ== +-----END PUBLIC KEY-----