Updater: update channel Stable / Dev in Settings → Updates; Dev stays on test builds after a release

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 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-18 17:33:30 +03:00
1 parent 69fae18fdc
commit f2721d125c
11 files changed
+364 -28

No files matched your search

+1
View File
@@ -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,
@@ -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<void> _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<bool>(
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<UpdateChannel>(
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});
+54 -1
View File
@@ -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<void> _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);
@@ -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<void> 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<String?>(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<UpdateManifest?>(null);
Future<void>? _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<UpdateManifest?> 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<void> 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,
@@ -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 = <String>[];
// 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<AppSettings> 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<void>.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);
});
});
}