forked from Foxyz/sync_replay_project
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:
1 parent
69fae18fdc
commit
f2721d125c
11 files changed
+364
-28
No files matched your search
@@ -12,6 +12,7 @@ Links you put up are downloaded once for everyone, quick chat and danmaku in the
|
|||||||
### Added
|
### 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)
|
- 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)
|
- 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 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
|
- Invite finds your internet address, lets you pick it or your home-network address, and says when your router needs the port forwarded
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ class AppGraph {
|
|||||||
final updates = UpdateController.instance;
|
final updates = UpdateController.instance;
|
||||||
final whatsNew = WhatsNew(store: SettingsWhatsNewStore(settings));
|
final whatsNew = WhatsNew(store: SettingsWhatsNewStore(settings));
|
||||||
updates.beforeRestart ??= whatsNew.installing;
|
updates.beforeRestart ??= whatsNew.installing;
|
||||||
|
updates.channelOf ??= () => settings.updateChannel.peek();
|
||||||
return AppGraph._(
|
return AppGraph._(
|
||||||
ports: p,
|
ports: p,
|
||||||
sfx: sfx,
|
sfx: sfx,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart' show UpdateChannel, isDevBuild;
|
||||||
|
|
||||||
import '../../app/app_graph.dart';
|
import '../../app/app_graph.dart';
|
||||||
import '../../state/settings.dart';
|
import '../../state/settings.dart';
|
||||||
@@ -195,6 +196,7 @@ class UpdateSettings extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
UpdateChannelSetting(controller: c),
|
||||||
SignalBuilder(
|
SignalBuilder(
|
||||||
builder: (context) => SwitchListTile(
|
builder: (context) => SwitchListTile(
|
||||||
key: const ValueKey('auto-check-updates'),
|
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 {
|
class AutoAdvanceSetting extends StatelessWidget {
|
||||||
const AutoAdvanceSetting({super.key});
|
const AutoAdvanceSetting({super.key});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import 'dart:io' show Platform;
|
import 'dart:io' show File, Platform;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:hive_ce/hive.dart';
|
import 'package:hive_ce/hive.dart';
|
||||||
import 'package:signals/signals_flutter.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 '../controls/model/controls_prefs.dart';
|
||||||
import '../danmaku/danmaku.dart' show DanmakuStyle;
|
import '../danmaku/danmaku.dart' show DanmakuStyle;
|
||||||
@@ -52,6 +53,12 @@ const _keyLiftSubtitles = 'liftSubtitles';
|
|||||||
const _keyNotifyStyle = 'notifyStyle';
|
const _keyNotifyStyle = 'notifyStyle';
|
||||||
const _keySubScale = 'subScale';
|
const _keySubScale = 'subScale';
|
||||||
const _keyDanmakuStyle = 'danmakuStyle';
|
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 }
|
enum HwdecMode { auto, off, custom }
|
||||||
|
|
||||||
@@ -73,9 +80,30 @@ String get autoHwdec => Platform.isWindows ? 'auto-copy' : 'auto';
|
|||||||
class AppSettings {
|
class AppSettings {
|
||||||
static final AppSettings instance = 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 hwdecMode = signal(HwdecMode.auto);
|
||||||
final hwdecCustom = signal('');
|
final hwdecCustom = signal('');
|
||||||
final autoCheckUpdates = signal(true);
|
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
|
/// 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
|
/// continued by the next one (Up next's `auto` item, the server's
|
||||||
/// countdown at its end).
|
/// countdown at its end).
|
||||||
@@ -186,6 +214,7 @@ class AppSettings {
|
|||||||
if (custom is String) hwdecCustom.value = custom;
|
if (custom is String) hwdecCustom.value = custom;
|
||||||
final auto = box.get(_keyAutoCheckUpdates);
|
final auto = box.get(_keyAutoCheckUpdates);
|
||||||
if (auto is bool) autoCheckUpdates.value = auto;
|
if (auto is bool) autoCheckUpdates.value = auto;
|
||||||
|
await _loadUpdateChannel(box);
|
||||||
final advance = box.get(_keyAutoAdvance);
|
final advance = box.get(_keyAutoAdvance);
|
||||||
if (advance is bool) autoAdvance.value = advance;
|
if (advance is bool) autoAdvance.value = advance;
|
||||||
final unready = box.get(_keyUnreadyOnSwap);
|
final unready = box.get(_keyUnreadyOnSwap);
|
||||||
@@ -275,6 +304,30 @@ class AppSettings {
|
|||||||
_put(_keyAutoCheckUpdates, on);
|
_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) {
|
void setAutoAdvance(bool on) {
|
||||||
autoAdvance.value = on;
|
autoAdvance.value = on;
|
||||||
_put(_keyAutoAdvance, on);
|
_put(_keyAutoAdvance, on);
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class UpdateController {
|
|||||||
|
|
||||||
final String currentVersion;
|
final String currentVersion;
|
||||||
final String manifestUrl;
|
final String manifestUrl;
|
||||||
|
final String devUrl;
|
||||||
final String? executablePath;
|
final String? executablePath;
|
||||||
final Directory? installRoot;
|
final Directory? installRoot;
|
||||||
final RestartFn restart;
|
final RestartFn restart;
|
||||||
@@ -61,15 +62,22 @@ class UpdateController {
|
|||||||
/// Awaited after the apply, before [restart]: keeps the new version's
|
/// Awaited after the apply, before [restart]: keeps the new version's
|
||||||
/// notes for its first start ([WhatsNew.installing], wired by `AppGraph`).
|
/// notes for its first start ([WhatsNew.installing], wired by `AppGraph`).
|
||||||
Future<void> Function(UpdateManifest manifest)? beforeRestart;
|
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(
|
UpdateController(
|
||||||
{this.currentVersion = appVersion,
|
{this.currentVersion = appVersion,
|
||||||
this.manifestUrl = updateManifestUrl,
|
this.manifestUrl = updateManifestUrl,
|
||||||
|
this.devUrl = devManifestUrl,
|
||||||
this.executablePath,
|
this.executablePath,
|
||||||
this.installRoot,
|
this.installRoot,
|
||||||
this.restart = _closeAndRestart,
|
this.restart = _closeAndRestart,
|
||||||
this.skipDevBuilds = true,
|
this.skipDevBuilds = true,
|
||||||
this.beforeRestart});
|
this.beforeRestart,
|
||||||
|
this.channelOf});
|
||||||
|
|
||||||
|
UpdateChannel get channel => channelOf?.call() ?? defaultUpdateChannel(currentVersion);
|
||||||
|
|
||||||
final status = ValueNotifier(const UpdateStatus(UpdatePhase.idle));
|
final status = ValueNotifier(const UpdateStatus(UpdatePhase.idle));
|
||||||
/// The manifest of the last check that read one, newer or not (What's
|
/// 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
|
/// Outcome of the last finished check, for Settings/About wording
|
||||||
/// ("Up to date", "Check failed: ..."); null until one finishes.
|
/// ("Up to date", "Check failed: ..."); null until one finishes.
|
||||||
final lastCheck = ValueNotifier<String?>(null);
|
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;
|
Future<void>? _check;
|
||||||
late final String _exe = executablePath ?? currentExecutablePath();
|
late final String _exe = executablePath ?? currentExecutablePath();
|
||||||
late final Directory? _root = installRoot ?? findInstallRoot(_exe);
|
late final Directory? _root = installRoot ?? findInstallRoot(_exe);
|
||||||
@@ -113,9 +124,19 @@ class UpdateController {
|
|||||||
final root = _root;
|
final root = _root;
|
||||||
if (root != null) await cleanupUpdateLeftovers(root);
|
if (root != null) await cleanupUpdateLeftovers(root);
|
||||||
try {
|
try {
|
||||||
|
final ch = channel;
|
||||||
final m = await fetchLatestManifest(
|
final m = await fetchLatestManifest(
|
||||||
currentVersion: currentVersion, url: manifestUrl);
|
currentVersion: currentVersion, channel: ch, url: manifestUrl, devUrl: devUrl);
|
||||||
latest.value = m;
|
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)) {
|
if (!isNewerVersion(currentVersion, m.version)) {
|
||||||
_log.info('up to date ($currentVersion, latest ${m.version})');
|
_log.info('up to date ($currentVersion, latest ${m.version})');
|
||||||
lastCheck.value = 'Up to date';
|
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) {
|
bool get _installing => switch (status.value.phase) {
|
||||||
UpdatePhase.downloading || UpdatePhase.applying || UpdatePhase.restarting => true,
|
UpdatePhase.downloading || UpdatePhase.applying || UpdatePhase.restarting => true,
|
||||||
_ => false,
|
_ => false,
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:hive_ce/hive.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
import 'package:starsync/src/state/settings.dart';
|
||||||
import 'package:starsync/src/update/update_controller.dart';
|
import 'package:starsync/src/update/update_controller.dart';
|
||||||
|
|
||||||
// Work dir base: WT_TEST_TMP or the system temp. Staging lives inside
|
// 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(),
|
expect(await File('${root.path}/watch_together').readAsString(),
|
||||||
'binary-v1');
|
'binary-v1');
|
||||||
}, skip: _posixOnly);
|
}, 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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
+22
-5
@@ -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
|
start and every 4 hours while running (`updateCheckEvery`), both with the
|
||||||
auto-check setting on; a re-check never touches an install under way.
|
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
|
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`,
|
this repo, written by `tool/release.sh`.
|
||||||
`isDevBuild`) also read `devManifestUrl`, the `manifest.json` of the `dev`
|
|
||||||
prerelease (`tool/dev_release.sh`), and take the newer of the two
|
**Update channel.** `UpdateChannel` stable or dev, the setting
|
||||||
(`fetchLatestManifest`); the release X.Y.Z outranks its test builds, so
|
`updateChannel` (Settings → Updates, "Update channel", which also says
|
||||||
testers land back on stable. `0.0.0-dev` (a dev run) checks nothing.
|
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
|
**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":
|
read; they ignore other keys) and `changes` (`{"added": [...], "changed":
|
||||||
|
|||||||
+7
-4
@@ -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
|
follow it until a release moves the pubspec) and replaces the assets of the
|
||||||
`dev` prerelease with the tarball and its `manifest.json` (`changes`:
|
`dev` prerelease with the tarball and its `manifest.json` (`changes`:
|
||||||
the changelog's Unreleased, which also becomes the prerelease's text). Dev builds
|
the changelog's Unreleased, which also becomes the prerelease's text). Dev builds
|
||||||
update from it ([Updater](ARCHITECTURE.md#updater)). A stable install
|
update from it on the Dev update channel, which a test build starts on
|
||||||
moves onto it once with the command the script prints (run with the app
|
and keeps after a release ([Updater](ARCHITECTURE.md#updater)). Any
|
||||||
open: it finds the install from the running process and copies the new
|
install switches channel in Settings → Updates; the command the script
|
||||||
files over it).
|
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
|
Tokens come from the environment (`RELEASE_TOKEN`, or Actions-provided
|
||||||
`GITHUB_TOKEN`/`GITEA_TOKEN`). The manifest has no `sha256` by default; the
|
`GITHUB_TOKEN`/`GITEA_TOKEN`). The manifest has no `sha256` by default; the
|
||||||
|
|||||||
@@ -237,16 +237,58 @@ Future<UpdateManifest> fetchManifest(
|
|||||||
/// A test build (`1.1.3-dev.4`); `0.0.0-dev` (a dev run) is not one.
|
/// 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.');
|
bool isDevBuild(String version) => version.contains('-dev.');
|
||||||
|
|
||||||
/// The manifest to update from: the stable one, and for a [isDevBuild]
|
/// Where an install takes its updates from. [stable]: releases only.
|
||||||
/// also the dev one, whichever is newer (either alone if the other fails).
|
/// [dev]: test builds as well (`devManifestUrl`), whichever of the two is
|
||||||
/// A release outranks its own test builds, so testers return to stable.
|
/// 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<UpdateManifest> fetchLatestManifest(
|
Future<UpdateManifest> fetchLatestManifest(
|
||||||
{HttpClient? http,
|
{HttpClient? http,
|
||||||
String currentVersion = appVersion,
|
String currentVersion = appVersion,
|
||||||
|
UpdateChannel? channel,
|
||||||
String url = updateManifestUrl,
|
String url = updateManifestUrl,
|
||||||
String devUrl = devManifestUrl}) async {
|
String devUrl = devManifestUrl}) async {
|
||||||
final stable = fetchManifest(http: http, url: url);
|
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
|
stable.ignore(); // awaited below; don't let an early failure go uncaught
|
||||||
final dev = await fetchManifest(http: http, url: devUrl)
|
final dev = await fetchManifest(http: http, url: devUrl)
|
||||||
.then<UpdateManifest?>((m) => m, onError: (Object e) {
|
.then<UpdateManifest?>((m) => m, onError: (Object e) {
|
||||||
@@ -254,12 +296,8 @@ Future<UpdateManifest> fetchLatestManifest(
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
if (dev == null) return stable;
|
if (dev == null) return stable;
|
||||||
try {
|
final s = await stable.then<UpdateManifest?>((m) => m, onError: (Object _) => null);
|
||||||
final s = await stable;
|
return pickManifest(UpdateChannel.dev, stable: s, dev: dev)!;
|
||||||
return isNewerVersion(s.version, dev.version) ? dev : s;
|
|
||||||
} catch (_) {
|
|
||||||
return dev;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
typedef DownloadProgress = void Function(int received, int? total);
|
typedef DownloadProgress = void Function(int received, int? total);
|
||||||
|
|||||||
@@ -278,21 +278,47 @@ void main() {
|
|||||||
await req.response.close();
|
await req.response.close();
|
||||||
});
|
});
|
||||||
final base = 'http://127.0.0.1:${http.port}';
|
final base = 'http://127.0.0.1:${http.port}';
|
||||||
Future<String> latest(String current) async => (await fetchLatestManifest(
|
Future<String> latest(String current, [UpdateChannel? channel]) async => (await fetchLatestManifest(
|
||||||
currentVersion: current, url: '$base/stable', devUrl: '$base/dev'))
|
currentVersion: current, channel: channel, url: '$base/stable', devUrl: '$base/dev'))
|
||||||
.version;
|
.version;
|
||||||
try {
|
try {
|
||||||
expect(await latest('1.1.2'), '1.1.2'); // stable builds never see dev
|
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');
|
expect(await latest('1.1.3-dev.1'), '1.1.3-dev.2');
|
||||||
versions['/stable'] = '1.1.3'; // the release outranks its test builds
|
versions['/stable'] = '1.1.3'; // the release outranks its test builds
|
||||||
expect(await latest('1.1.3-dev.2'), '1.1.3');
|
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');
|
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 {
|
} finally {
|
||||||
await http.close(force: true);
|
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', () {
|
group('upgrade flow', () {
|
||||||
late Directory work;
|
late Directory work;
|
||||||
late _Server server;
|
late _Server server;
|
||||||
|
|||||||
+5
-3
@@ -12,7 +12,9 @@
|
|||||||
# (a Gitea access token with repository read/write; keep the file 0600).
|
# (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
|
# The manifest's `changes` (What's new) and the dev release's page are
|
||||||
# CHANGELOG.md's Unreleased.
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
PUBLISH=1
|
PUBLISH=1
|
||||||
@@ -97,6 +99,6 @@ done
|
|||||||
cat <<EOF
|
cat <<EOF
|
||||||
|
|
||||||
Published $VERSION. Dev builds pick it up with their next update check.
|
Published $VERSION. Dev builds pick it up with their next update check.
|
||||||
A stable install moves onto test builds once: with the app open, run (bash)
|
A stable install moves onto test builds (update channel Dev) once: with the app open, run (bash)
|
||||||
p=\$(pgrep -xo 'starsync|watch_together') && d=\$(dirname "\$(readlink /proc/\$p/exe)") && t=\$(mktemp -d) && curl -fsSL $BASE_URL/$(basename "$TARBALL") | tar -xz -C "\$t" --strip-components=1 && cp -rf --remove-destination "\$t"/. "\$d"/ && rm -rf "\$t" && echo "Done: restart the app." || echo "Failed: is the app open?"
|
p=\$(pgrep -xo 'starsync|watch_together') && d=\$(dirname "\$(readlink /proc/\$p/exe)") && t=\$(mktemp -d) && curl -fsSL $BASE_URL/$(basename "$TARBALL") | tar -xz -C "\$t" --strip-components=1 && cp -rf --remove-destination "\$t"/. "\$d"/ && echo dev > "\$d"/update-channel && rm -rf "\$t" && echo "Done: restart the app." || echo "Failed: is the app open?"
|
||||||
EOF
|
EOF
|
||||||
Reference in new issue
Block a user