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-----