From f2721d125c6ee73a0cc32bf7e9272c2d02b08032 Mon Sep 17 00:00:00 2001 From: foxyz Date: Sun, 18 Oct 2026 04:07:53 +0300 Subject: [PATCH] =?UTF-8?q?Updater:=20update=20channel=20Stable=20/=20Dev?= =?UTF-8?q?=20in=20Settings=20=E2=86=92=20Updates;=20Dev=20stays=20on=20te?= =?UTF-8?q?st=20builds=20after=20a=20release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dev reads the dev and stable manifests and offers the newer by semver; a test build stores Dev on its first start, so the release that updates it keeps the channel. Switching to Stable in a test build asks before going back to an older release. tool/dev_release.sh's one-time command also writes the update-channel file that sets Dev. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + apps/desktop/lib/src/app/app_graph.dart | 1 + .../lib/src/screens/home/settings_page.dart | 71 ++++++++++++++++ apps/desktop/lib/src/state/settings.dart | 55 ++++++++++++- .../lib/src/update/update_controller.dart | 46 ++++++++++- apps/desktop/test/update_controller_test.dart | 82 +++++++++++++++++++ docs/ARCHITECTURE.md | 27 ++++-- docs/DEVELOPMENT.md | 11 ++- packages/core/lib/src/updater.dart | 58 ++++++++++--- packages/core/test/updater_test.dart | 32 +++++++- tool/dev_release.sh | 8 +- 11 files changed, 364 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf7a29b..932b68a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Links you put up are downloaded once for everyone, quick chat and danmaku in the ### Added - What's new: the update band shows what changed, and after an update the app shows the full list once (also in Settings → About) +- Update channel in Settings → Updates: Stable, or Dev for test builds; Dev stays on test builds after a release and still gets the newer release when there is one - Sharper subtitles and picture: the video and its subtitles are drawn at your screen's resolution instead of the video's (Settings → Player → Sharp video and subtitles, on by default) - Invite links: Invite copies a starsync:// link; a friend clicks it (or just copies it and switches to Starsync) and the app asks "Join Bob's server?" - Invite finds your internet address, lets you pick it or your home-network address, and says when your router needs the port forwarded diff --git a/apps/desktop/lib/src/app/app_graph.dart b/apps/desktop/lib/src/app/app_graph.dart index cb84601..15095a5 100644 --- a/apps/desktop/lib/src/app/app_graph.dart +++ b/apps/desktop/lib/src/app/app_graph.dart @@ -167,6 +167,7 @@ class AppGraph { final updates = UpdateController.instance; final whatsNew = WhatsNew(store: SettingsWhatsNewStore(settings)); updates.beforeRestart ??= whatsNew.installing; + updates.channelOf ??= () => settings.updateChannel.peek(); return AppGraph._( ports: p, sfx: sfx, diff --git a/apps/desktop/lib/src/screens/home/settings_page.dart b/apps/desktop/lib/src/screens/home/settings_page.dart index 7d4ccbc..0a1f853 100644 --- a/apps/desktop/lib/src/screens/home/settings_page.dart +++ b/apps/desktop/lib/src/screens/home/settings_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:signals/signals_flutter.dart'; +import 'package:sync_relay_core/core.dart' show UpdateChannel, isDevBuild; import '../../app/app_graph.dart'; import '../../state/settings.dart'; @@ -195,6 +196,7 @@ class UpdateSettings extends StatelessWidget { ], ), const SizedBox(height: 8), + UpdateChannelSetting(controller: c), SignalBuilder( builder: (context) => SwitchListTile( key: const ValueKey('auto-check-updates'), @@ -210,6 +212,75 @@ class UpdateSettings extends StatelessWidget { } } +/// Stable or Dev (test builds). Leaving Dev in a test build asks whether +/// to go back to the latest release now when that one is older. +class UpdateChannelSetting extends StatelessWidget { + final UpdateController controller; + final AppSettings? settings; + const UpdateChannelSetting({super.key, required this.controller, this.settings}); + + Future _choose(BuildContext context, UpdateChannel ch) async { + final s = settings ?? AppSettings.instance; + if (ch == s.updateChannel.peek()) return; + s.setUpdateChannel(ch); + if (ch == UpdateChannel.stable && isDevBuild(controller.currentVersion)) { + final m = await controller.stableToGoBackTo(); + if (m != null && context.mounted) { + final back = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('Go back to stable ${m.version}?'), + content: Text('This is test build ${controller.currentVersion}, newer than the latest release. ' + 'Going back installs ${m.version} and restarts the app. Or stay on this build: ' + 'the next release newer than it is offered as usual.'), + actions: [ + TextButton( + key: const ValueKey('stay-on-test-build'), + onPressed: () => Navigator.pop(context, false), + child: const Text('Stay on this build'), + ), + TextButton( + key: const ValueKey('go-back-to-stable'), + onPressed: () => Navigator.pop(context, true), + child: Text('Go back to ${m.version}'), + ), + ], + ), + ); + if (back == true) { + await controller.goBackTo(m); + return; + } + } + } + await runUpdateCheck(controller, true); + } + + @override + Widget build(BuildContext context) { + final s = settings ?? AppSettings.instance; + return SignalBuilder(builder: (context) { + final ch = s.updateChannel.value; + return ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Update channel'), + subtitle: Text('On ${ch == UpdateChannel.dev ? 'Dev' : 'Stable'}, running ${controller.currentVersion}. ' + '${ch == UpdateChannel.dev ? 'Test builds and releases, whichever is newer; stays on Dev after a release' : 'Releases only'}'), + trailing: SegmentedButton( + key: const ValueKey('update-channel'), + showSelectedIcon: false, + segments: const [ + ButtonSegment(value: UpdateChannel.stable, label: Text('Stable')), + ButtonSegment(value: UpdateChannel.dev, label: Text('Dev')), + ], + selected: {ch}, + onSelectionChanged: (sel) => _choose(context, sel.first), + ), + ); + }); + } +} + class AutoAdvanceSetting extends StatelessWidget { const AutoAdvanceSetting({super.key}); diff --git a/apps/desktop/lib/src/state/settings.dart b/apps/desktop/lib/src/state/settings.dart index f019f25..6d11faa 100644 --- a/apps/desktop/lib/src/state/settings.dart +++ b/apps/desktop/lib/src/state/settings.dart @@ -1,8 +1,9 @@ -import 'dart:io' show Platform; +import 'dart:io' show File, Platform; import 'package:flutter/foundation.dart'; import 'package:hive_ce/hive.dart'; import 'package:signals/signals_flutter.dart'; +import 'package:sync_relay_core/core.dart' show UpdateChannel, appVersion, defaultUpdateChannel; import '../controls/model/controls_prefs.dart'; import '../danmaku/danmaku.dart' show DanmakuStyle; @@ -52,6 +53,12 @@ const _keyLiftSubtitles = 'liftSubtitles'; const _keyNotifyStyle = 'notifyStyle'; const _keySubScale = 'subScale'; const _keyDanmakuStyle = 'danmakuStyle'; +const _keyUpdateChannel = 'updateChannel'; + +/// A file next to the binary naming a channel (`dev` / `stable`): the +/// app takes it as the update channel on its next start and deletes it. +/// The one-time command `tool/dev_release.sh` prints writes it. +const updateChannelFileName = 'update-channel'; enum HwdecMode { auto, off, custom } @@ -73,9 +80,30 @@ String get autoHwdec => Platform.isWindows ? 'auto-copy' : 'auto'; class AppSettings { static final AppSettings instance = AppSettings(); + /// The running build's version: picks the default [updateChannel]. + final String runningVersion; + /// The [updateChannelFileName] file to adopt at load (null: none). + final File? channelFile; + + AppSettings({this.runningVersion = appVersion, File? channelFile}) + : channelFile = channelFile ?? _channelFileNextToBinary(); + + static File? _channelFileNextToBinary() { + try { + return File('${File(Platform.resolvedExecutable).parent.path}/$updateChannelFileName'); + } catch (_) { + return null; + } + } + final hwdecMode = signal(HwdecMode.auto); final hwdecCustom = signal(''); final autoCheckUpdates = signal(true); + /// Where updates come from (Settings → Updates). Never chosen: dev for + /// a test build, else stable; a test build stores dev on its first + /// start, so the install stays on test builds after a release (also + /// when that release updated it). + late final updateChannel = signal(defaultUpdateChannel(runningVersion)); /// An episode this app puts up (Jellyfin, a file of a local show) is /// continued by the next one (Up next's `auto` item, the server's /// countdown at its end). @@ -186,6 +214,7 @@ class AppSettings { if (custom is String) hwdecCustom.value = custom; final auto = box.get(_keyAutoCheckUpdates); if (auto is bool) autoCheckUpdates.value = auto; + await _loadUpdateChannel(box); final advance = box.get(_keyAutoAdvance); if (advance is bool) autoAdvance.value = advance; final unready = box.get(_keyUnreadyOnSwap); @@ -275,6 +304,30 @@ class AppSettings { _put(_keyAutoCheckUpdates, on); } + Future _loadUpdateChannel(Box box) async { + UpdateChannel? requested; + final file = channelFile; + try { + if (file != null && file.existsSync()) { + requested = UpdateChannel.fromName(file.readAsStringSync().trim()); + file.deleteSync(); + } + } catch (e) { + debugPrint('update channel file: $e'); + } + final stored = requested ?? UpdateChannel.fromName(box.get(_keyUpdateChannel)); + final channel = stored ?? defaultUpdateChannel(runningVersion); + updateChannel.value = channel; + if (channel.name != box.get(_keyUpdateChannel) && (requested != null || channel == UpdateChannel.dev)) { + await box.put(_keyUpdateChannel, channel.name); + } + } + + void setUpdateChannel(UpdateChannel channel) { + updateChannel.value = channel; + _put(_keyUpdateChannel, channel.name); + } + void setAutoAdvance(bool on) { autoAdvance.value = on; _put(_keyAutoAdvance, on); diff --git a/apps/desktop/lib/src/update/update_controller.dart b/apps/desktop/lib/src/update/update_controller.dart index 8a5941b..2541221 100644 --- a/apps/desktop/lib/src/update/update_controller.dart +++ b/apps/desktop/lib/src/update/update_controller.dart @@ -54,6 +54,7 @@ class UpdateController { final String currentVersion; final String manifestUrl; + final String devUrl; final String? executablePath; final Directory? installRoot; final RestartFn restart; @@ -61,15 +62,22 @@ class UpdateController { /// Awaited after the apply, before [restart]: keeps the new version's /// notes for its first start ([WhatsNew.installing], wired by `AppGraph`). Future Function(UpdateManifest manifest)? beforeRestart; + /// The update channel each check reads (the setting, wired by + /// `AppGraph`); null: [defaultUpdateChannel] of [currentVersion]. + UpdateChannel Function()? channelOf; UpdateController( {this.currentVersion = appVersion, this.manifestUrl = updateManifestUrl, + this.devUrl = devManifestUrl, this.executablePath, this.installRoot, this.restart = _closeAndRestart, this.skipDevBuilds = true, - this.beforeRestart}); + this.beforeRestart, + this.channelOf}); + + UpdateChannel get channel => channelOf?.call() ?? defaultUpdateChannel(currentVersion); final status = ValueNotifier(const UpdateStatus(UpdatePhase.idle)); /// The manifest of the last check that read one, newer or not (What's @@ -78,6 +86,9 @@ class UpdateController { /// Outcome of the last finished check, for Settings/About wording /// ("Up to date", "Check failed: ..."); null until one finishes. final lastCheck = ValueNotifier(null); + /// On the stable channel in a test build: the latest release when it + /// is older than this build (offered only by [goBackTo], after asking). + final stableBehind = ValueNotifier(null); Future? _check; late final String _exe = executablePath ?? currentExecutablePath(); late final Directory? _root = installRoot ?? findInstallRoot(_exe); @@ -113,9 +124,19 @@ class UpdateController { final root = _root; if (root != null) await cleanupUpdateLeftovers(root); try { + final ch = channel; final m = await fetchLatestManifest( - currentVersion: currentVersion, url: manifestUrl); + currentVersion: currentVersion, channel: ch, url: manifestUrl, devUrl: devUrl); latest.value = m; + stableBehind.value = ch == UpdateChannel.stable && isStableBehind(currentVersion, m) ? m : null; + if (stableBehind.value != null) { + _log.info('stable channel: latest release ${m.version} is older than $currentVersion'); + lastCheck.value = 'Stable is at ${m.version}, older than this test build'; + if (!_installing && status.value.manifest?.version != m.version) { + status.value = const UpdateStatus(UpdatePhase.idle); + } + return; + } if (!isNewerVersion(currentVersion, m.version)) { _log.info('up to date ($currentVersion, latest ${m.version})'); lastCheck.value = 'Up to date'; @@ -133,6 +154,27 @@ class UpdateController { } } + /// The latest release when it is older than this test build (the + /// question before going back to stable); null when it is not, or the + /// check failed. + Future stableToGoBackTo() async { + try { + final m = await fetchManifest(url: manifestUrl); + return isStableBehind(currentVersion, m) ? m : null; + } catch (e) { + _log.warning('stable manifest: $e'); + return null; + } + } + + /// Installs [m] although it is older (going back to stable, asked + /// first): offered as an update, then [install]. + Future goBackTo(UpdateManifest m) async { + if (_installing) return; + status.value = UpdateStatus(UpdatePhase.available, manifest: m, canInstall: _root != null); + if (_root != null) await install(); + } + bool get _installing => switch (status.value.phase) { UpdatePhase.downloading || UpdatePhase.applying || UpdatePhase.restarting => true, _ => false, diff --git a/apps/desktop/test/update_controller_test.dart b/apps/desktop/test/update_controller_test.dart index fe7bfd4..d6dd9c9 100644 --- a/apps/desktop/test/update_controller_test.dart +++ b/apps/desktop/test/update_controller_test.dart @@ -3,7 +3,9 @@ import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:hive_ce/hive.dart'; import 'package:sync_relay_core/core.dart'; +import 'package:starsync/src/state/settings.dart'; import 'package:starsync/src/update/update_controller.dart'; // Work dir base: WT_TEST_TMP or the system temp. Staging lives inside @@ -257,5 +259,85 @@ void main() { expect(await File('${root.path}/watch_together').readAsString(), 'binary-v1'); }, skip: _posixOnly); + + test('channel: dev offers a newer release; stable in a test build asks before going back', () async { + var channel = UpdateChannel.dev; + final root = await _fakeRoot(work); + final restarts = []; + // The dev manifest is missing (404): only the stable 0.2.0 is read. + final c = UpdateController( + currentVersion: '0.2.0-dev.3', + manifestUrl: server.manifestUrl, + devUrl: '${server.base}/dev.json', + executablePath: '${root.path}/watch_together', + restart: (exe, _) async => restarts.add(exe)) + ..channelOf = () => channel; + await c.checkOnce(); + expect(c.status.value.phase, UpdatePhase.available); + expect(c.status.value.manifest!.version, '0.2.0'); + expect(c.stableBehind.value, isNull); + + final behind = UpdateController( + currentVersion: '0.3.0-dev.1', + manifestUrl: server.manifestUrl, + devUrl: '${server.base}/dev.json', + executablePath: '${root.path}/watch_together', + restart: (exe, _) async => restarts.add(exe)) + ..channelOf = () => channel; + await behind.checkOnce(); + expect(behind.status.value.phase, UpdatePhase.idle); // dev: 0.2.0 is older + expect(behind.stableBehind.value, isNull); + channel = UpdateChannel.stable; + await behind.checkNow(); + expect(behind.status.value.phase, UpdatePhase.idle); // never offered by itself + expect(behind.stableBehind.value?.version, '0.2.0'); + expect(behind.lastCheck.value, contains('older than this test build')); + final m = await behind.stableToGoBackTo(); + expect(m?.version, '0.2.0'); + await behind.goBackTo(m!); + expect(behind.status.value.phase, UpdatePhase.restarting); + expect(await File('${root.path}/starsync').readAsString(), 'binary-v2'); + expect(restarts, hasLength(1)); + }, skip: _posixOnly); + }); + + group('update channel setting', () { + late Directory dir; + setUp(() async { + dir = await _work(); + Hive.init('${dir.path}/hive'); + }); + tearDown(() async { + await Hive.close(); + await dir.delete(recursive: true); + }); + + Future load(String version) async { + await Hive.close(); + final s = AppSettings(runningVersion: version, channelFile: File('${dir.path}/update-channel')); + await s.ensureLoaded(); + return s; + } + + test('a test build keeps Dev across an update to a release; a choice stays', () async { + expect((await load('1.3.2')).updateChannel.value, UpdateChannel.stable); + // Stable by default is not stored: a test build later still starts on Dev. + expect((await load('1.4.0-dev.1')).updateChannel.value, UpdateChannel.dev); + // Updated to the release: stays on Dev. + final s = await load('1.4.0'); + expect(s.updateChannel.value, UpdateChannel.dev); + s.setUpdateChannel(UpdateChannel.stable); + await Future.delayed(const Duration(milliseconds: 50)); + expect((await load('1.4.1-dev.1')).updateChannel.value, UpdateChannel.stable); + }); + + test('the update-channel file sets the channel once and is deleted', () async { + expect((await load('1.3.2')).updateChannel.value, UpdateChannel.stable); + final f = File('${dir.path}/update-channel'); + await f.writeAsString('dev\n'); + expect((await load('1.3.2')).updateChannel.value, UpdateChannel.dev); + expect(f.existsSync(), isFalse); + expect((await load('1.3.2')).updateChannel.value, UpdateChannel.dev); + }); }); } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9ae7599..12b42fa 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2813,11 +2813,28 @@ update has no Later. Nothing downloads before the click. The app checks at start and every 4 hours while running (`updateCheckEvery`), both with the auto-check setting on; a re-check never touches an install under way. Settings has Check now and the auto-check toggle. The manifest is `updates/manifest.json` in -this repo, written by `tool/release.sh`. Test builds (`X.Y.Z-dev.N`, -`isDevBuild`) also read `devManifestUrl`, the `manifest.json` of the `dev` -prerelease (`tool/dev_release.sh`), and take the newer of the two -(`fetchLatestManifest`); the release X.Y.Z outranks its test builds, so -testers land back on stable. `0.0.0-dev` (a dev run) checks nothing. +this repo, written by `tool/release.sh`. + +**Update channel.** `UpdateChannel` stable or dev, the setting +`updateChannel` (Settings → Updates, "Update channel", which also says +the channel and version running). Dev also reads `devManifestUrl`, the +`manifest.json` of the `dev` prerelease (`tool/dev_release.sh`), and takes +the newer of the two by semver (`pickManifest`, `fetchLatestManifest`): a +release 1.4.0 is offered over 1.4.0-dev.3, and after it 1.4.1-dev.1 is, +the install staying on Dev. Stable reads only the stable manifest. Never +chosen, the channel is `defaultUpdateChannel`: dev for a test build +(`X.Y.Z-dev.N`, `isDevBuild`), else stable; a test build stores dev on its +first start, so the release that later updates it stays on Dev (stable by +default is not stored). A file `update-channel` next to the binary (`dev` +/ `stable`, written by the one-time command `tool/dev_release.sh` prints) +is taken as the channel at the next start and deleted. Switching the +setting re-checks at once. On stable in a test build, a release older than +the build (`isStableBehind`) is never offered by itself +(`UpdateController.stableBehind`, "Stable is at X, older than this test +build"); switching to Stable there asks "Go back to stable X?": going +back installs it (`goBackTo`), staying keeps the build until a newer +release. `--upgrade` uses the default channel of the binary's version. +`0.0.0-dev` (a dev run) checks nothing. **What's new.** The manifest's `notes` is one line (all 1.2.x and 1.3.x read; they ignore other keys) and `changes` (`{"added": [...], "changed": diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 8760a0f..be481ef 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -316,10 +316,13 @@ minor / major when asked, and once such a line is published later runs follow it until a release moves the pubspec) and replaces the assets of the `dev` prerelease with the tarball and its `manifest.json` (`changes`: the changelog's Unreleased, which also becomes the prerelease's text). Dev builds -update from it ([Updater](ARCHITECTURE.md#updater)). A stable install -moves onto it once with the command the script prints (run with the app -open: it finds the install from the running process and copies the new -files over it). +update from it on the Dev update channel, which a test build starts on +and keeps after a release ([Updater](ARCHITECTURE.md#updater)). Any +install switches channel in Settings → Updates; the command the script +prints is a shortcut that moves a stable install onto the test build at +once (run with the app open: it finds the install from the running +process, copies the new files over it and writes the `update-channel` +file that sets Dev). Tokens come from the environment (`RELEASE_TOKEN`, or Actions-provided `GITHUB_TOKEN`/`GITEA_TOKEN`). The manifest has no `sha256` by default; the diff --git a/packages/core/lib/src/updater.dart b/packages/core/lib/src/updater.dart index 3659ebb..d123ae3 100644 --- a/packages/core/lib/src/updater.dart +++ b/packages/core/lib/src/updater.dart @@ -237,16 +237,58 @@ Future fetchManifest( /// A test build (`1.1.3-dev.4`); `0.0.0-dev` (a dev run) is not one. bool isDevBuild(String version) => version.contains('-dev.'); -/// The manifest to update from: the stable one, and for a [isDevBuild] -/// also the dev one, whichever is newer (either alone if the other fails). -/// A release outranks its own test builds, so testers return to stable. +/// Where an install takes its updates from. [stable]: releases only. +/// [dev]: test builds as well (`devManifestUrl`), whichever of the two is +/// newer, so a release newer than the running test build is installed and +/// the next test line after it still is. +enum UpdateChannel { + stable, + dev; + + static UpdateChannel? fromName(Object? name) { + for (final c in values) { + if (c.name == name) return c; + } + return null; + } +} + +/// The channel of an install that never chose one: [UpdateChannel.dev] +/// for a test build, else [UpdateChannel.stable]. +UpdateChannel defaultUpdateChannel(String version) => + isDevBuild(version) ? UpdateChannel.dev : UpdateChannel.stable; + +/// Of the stable and dev manifests read (null: that one failed), the one +/// [channel] updates from: on stable the stable one, on dev the newer of +/// the two by semver (a release outranks its own test builds). +UpdateManifest? pickManifest(UpdateChannel channel, + {UpdateManifest? stable, UpdateManifest? dev}) { + if (channel == UpdateChannel.stable || dev == null) return stable; + if (stable == null) return dev; + return isNewerVersion(stable.version, dev.version) ? dev : stable; +} + +/// A stable [latest] that is older than the running test build +/// [currentVersion]: on [UpdateChannel.stable] it is offered only after +/// asking ("go back to stable X?"), never as an update. +bool isStableBehind(String currentVersion, UpdateManifest latest) => + isDevBuild(currentVersion) && + !isDevBuild(latest.version) && + compareVersions(latest.version, currentVersion) < 0; + +/// The manifest to update from on [channel] (default by [currentVersion]: +/// [defaultUpdateChannel]): the stable one, and on dev also the dev one, +/// the newer of the two ([pickManifest]; either alone if the other fails). Future fetchLatestManifest( {HttpClient? http, String currentVersion = appVersion, + UpdateChannel? channel, String url = updateManifestUrl, String devUrl = devManifestUrl}) async { final stable = fetchManifest(http: http, url: url); - if (!isDevBuild(currentVersion)) return stable; + if ((channel ?? defaultUpdateChannel(currentVersion)) == UpdateChannel.stable) { + return stable; + } stable.ignore(); // awaited below; don't let an early failure go uncaught final dev = await fetchManifest(http: http, url: devUrl) .then((m) => m, onError: (Object e) { @@ -254,12 +296,8 @@ Future fetchLatestManifest( return null; }); if (dev == null) return stable; - try { - final s = await stable; - return isNewerVersion(s.version, dev.version) ? dev : s; - } catch (_) { - return dev; - } + final s = await stable.then((m) => m, onError: (Object _) => null); + return pickManifest(UpdateChannel.dev, stable: s, dev: dev)!; } typedef DownloadProgress = void Function(int received, int? total); diff --git a/packages/core/test/updater_test.dart b/packages/core/test/updater_test.dart index 88b5f46..0a21e65 100644 --- a/packages/core/test/updater_test.dart +++ b/packages/core/test/updater_test.dart @@ -278,21 +278,47 @@ void main() { await req.response.close(); }); final base = 'http://127.0.0.1:${http.port}'; - Future latest(String current) async => (await fetchLatestManifest( - currentVersion: current, url: '$base/stable', devUrl: '$base/dev')) + Future latest(String current, [UpdateChannel? channel]) async => (await fetchLatestManifest( + currentVersion: current, channel: channel, url: '$base/stable', devUrl: '$base/dev')) .version; try { expect(await latest('1.1.2'), '1.1.2'); // stable builds never see dev expect(await latest('1.1.3-dev.1'), '1.1.3-dev.2'); versions['/stable'] = '1.1.3'; // the release outranks its test builds expect(await latest('1.1.3-dev.2'), '1.1.3'); + // A release installed on the dev channel still sees the next test line. + versions['/dev'] = '1.1.4-dev.1'; + expect(await latest('1.1.3', UpdateChannel.dev), '1.1.4-dev.1'); + expect(await latest('1.1.3', UpdateChannel.stable), '1.1.3'); + expect(await latest('1.1.4-dev.1', UpdateChannel.stable), '1.1.3'); versions.remove('/stable'); - expect(await latest('1.1.3-dev.1'), '1.1.3-dev.2'); + expect(await latest('1.1.3-dev.1'), '1.1.4-dev.1'); } finally { await http.close(force: true); } }); + test('update channel: default, pick by semver, a stable behind a test build', () { + UpdateManifest m(String v) => UpdateManifest(version: v, protocol: 1, url: 'http://x/$v', notes: ''); + expect(defaultUpdateChannel('1.4.0-dev.3'), UpdateChannel.dev); + expect(defaultUpdateChannel('1.4.0'), UpdateChannel.stable); + expect(defaultUpdateChannel('0.0.0-dev'), UpdateChannel.stable); + expect(UpdateChannel.fromName('dev'), UpdateChannel.dev); + expect(UpdateChannel.fromName('beta'), isNull); + String? pick(UpdateChannel ch, String? s, String? d) => + pickManifest(ch, stable: s == null ? null : m(s), dev: d == null ? null : m(d))?.version; + expect(pick(UpdateChannel.dev, '1.4.0', '1.4.0-dev.3'), '1.4.0'); + expect(pick(UpdateChannel.dev, '1.4.0', '1.4.1-dev.1'), '1.4.1-dev.1'); + expect(pick(UpdateChannel.dev, '1.3.2', '1.4.0-dev.3'), '1.4.0-dev.3'); + expect(pick(UpdateChannel.dev, null, '1.4.0-dev.3'), '1.4.0-dev.3'); + expect(pick(UpdateChannel.dev, '1.3.2', null), '1.3.2'); + expect(pick(UpdateChannel.stable, '1.3.2', '1.4.0-dev.3'), '1.3.2'); + expect(isStableBehind('1.4.0-dev.3', m('1.3.2')), isTrue); + expect(isStableBehind('1.4.0-dev.3', m('1.4.0')), isFalse); + expect(isStableBehind('1.3.2', m('1.3.1')), isFalse); // not a test build + expect(isStableBehind('1.4.0-dev.3', m('1.4.0-dev.2')), isFalse); + }); + group('upgrade flow', () { late Directory work; late _Server server; diff --git a/tool/dev_release.sh b/tool/dev_release.sh index 286d755..cc2f8a2 100755 --- a/tool/dev_release.sh +++ b/tool/dev_release.sh @@ -12,7 +12,9 @@ # (a Gitea access token with repository read/write; keep the file 0600). # The manifest's `changes` (What's new) and the dev release's page are # CHANGELOG.md's Unreleased. -# Prints the one-time command that moves a stable install onto test builds. +# Prints the one-time command that moves a stable install onto test builds +# and sets its update channel to Dev (the `update-channel` file the app +# takes at its next start). set -euo pipefail PUBLISH=1 @@ -97,6 +99,6 @@ done cat < "\$d"/update-channel && rm -rf "\$t" && echo "Done: restart the app." || echo "Failed: is the app open?" EOF