Updater: real-layout apply with rollback, in-app update + restart

- Strip the tarball's top-level folder; stage inside the install root
  (same filesystem) and swap with full rollback; keep file modes.
- Semver prerelease ordering (1.2.3-rc1 < 1.2.3).
- UpdateController: background check, download with progress, apply,
  restart; required-update wording for newer protocols; Check now.
- update.sh/update.bat in the bundle; Windows apply path (untested).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-07 00:41:02 +03:00
1 parent e2301aa4ec
commit beae87b0cc
8 files changed
+1131 -374

No files matched your search

@@ -0,0 +1,4 @@
@echo off
rem Update this install in place (same as clicking Update in the app).
"%~dp0watch_together.exe" --upgrade %*
pause
+3
View File
@@ -0,0 +1,3 @@
#!/bin/sh
# Update this install in place (same as clicking Update in the app).
cd "$(dirname "$0")" && exec ./watch_together --upgrade "$@"
@@ -0,0 +1,160 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:sync_relay_core/core.dart';
import 'updater.dart';
// In-app update flow behind the Home update indicator. Same code path as
// `--upgrade` (downloadAndApply); this adds state for the UI and the
// restart. Check failures are logged only — the banner shows up just when
// there is something to install.
final _log = createLogger('update');
enum UpdatePhase { idle, available, downloading, applying, restarting, failed }
class UpdateStatus {
final UpdatePhase phase;
final UpdateManifest? manifest;
final double? progress;
final String? error;
// False for dev runs / unpacked builds: banner tells the user to update
// by hand instead of offering the button.
final bool canInstall;
const UpdateStatus(this.phase,
{this.manifest, this.progress, this.error, this.canInstall = false});
}
typedef RestartFn = Future<void> Function(String executable, List<String> args);
// Release Hive's box locks before the new instance opens the same boxes.
Future<void> _closeAndRestart(String executable, List<String> args) async {
try {
await Hive.close();
} catch (e) {
_log.warning('closing hive before restart: $e');
}
await restartApp(executable, args);
}
class UpdateController {
static final instance = UpdateController();
final String currentVersion;
final String manifestUrl;
final String? executablePath;
final Directory? installRoot;
final RestartFn restart;
final bool skipDevBuilds;
UpdateController(
{this.currentVersion = appVersion,
this.manifestUrl = updateManifestUrl,
this.executablePath,
this.installRoot,
this.restart = _closeAndRestart,
this.skipDevBuilds = true});
final status = ValueNotifier(const UpdateStatus(UpdatePhase.idle));
/// Outcome of the last finished check, for Settings/About wording
/// ("Up to date", "Check failed: ..."); null until one finishes.
final lastCheck = ValueNotifier<String?>(null);
Future<void>? _check;
late final String _exe = executablePath ?? currentExecutablePath();
late final Directory? _root = installRoot ?? findInstallRoot(_exe);
String get _exeName => _exe.replaceAll('\\', '/').split('/').last;
// Once per process; later calls return the same future.
Future<void> checkOnce() => _check ??= _checkNow();
// User-triggered re-check ("Check now"); joins one already in flight.
Future<void> checkNow() {
final inFlight = _check;
if (inFlight != null && _checking) return inFlight;
return _check = _checkNow();
}
bool _checking = false;
Future<void> _checkNow() async {
_checking = true;
try {
await _runCheck();
} finally {
_checking = false;
}
}
Future<void> _runCheck() async {
if (skipDevBuilds && currentVersion.endsWith('-dev')) {
_log.info('dev build ($currentVersion), skipping update check');
lastCheck.value = 'Dev build — update checks are off';
return;
}
final root = _root;
if (root != null) await cleanupUpdateLeftovers(root);
try {
final m = await fetchManifest(url: manifestUrl);
if (!isNewerVersion(currentVersion, m.version)) {
_log.info('up to date ($currentVersion, latest ${m.version})');
lastCheck.value = 'Up to date';
return;
}
_log.info('update available: ${m.version} (proto ${m.protocol})');
status.value = UpdateStatus(UpdatePhase.available,
manifest: m, canInstall: root != null);
lastCheck.value = 'Update v${m.version} available';
} catch (e) {
_log.warning('update check failed: $e');
lastCheck.value = 'Update check failed: $e';
}
}
void dismiss() {
final s = status.value;
if ((s.phase == UpdatePhase.available || s.phase == UpdatePhase.failed) &&
!(s.manifest?.isRequired ?? false)) {
status.value = const UpdateStatus(UpdatePhase.idle);
}
}
Future<void> install() async {
final s = status.value;
final m = s.manifest;
final root = _root;
if (m == null || root == null) return;
if (s.phase != UpdatePhase.available && s.phase != UpdatePhase.failed) {
return;
}
status.value = UpdateStatus(UpdatePhase.downloading,
manifest: m, progress: 0, canInstall: true);
var lastPct = -1;
try {
await downloadAndApply(m, root,
executableName: installRoot == null ? _exeName : null,
onProgress: (got, total) {
if (total == null || total == 0) return;
final pct = got * 100 ~/ total;
if (pct == lastPct) return;
lastPct = pct;
status.value = UpdateStatus(UpdatePhase.downloading,
manifest: m, progress: got / total, canInstall: true);
if (pct == 100) {
status.value =
UpdateStatus(UpdatePhase.applying, manifest: m, canInstall: true);
}
}, say: (line) => _log.info(line));
} catch (e) {
_log.warning('update failed: $e');
status.value = UpdateStatus(UpdatePhase.failed,
manifest: m, error: '$e', canInstall: true);
return;
}
_log.info('updated to ${m.version}, restarting');
status.value =
UpdateStatus(UpdatePhase.restarting, manifest: m, canInstall: true);
await restart(_exe, launchArgs);
}
}
+413 -82
View File
@@ -6,13 +6,21 @@ import 'package:crypto/crypto.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
// Zero Flutter imports: this file must stay unit-testable with plain // Zero Flutter imports: this file must stay unit-testable with plain
// `flutter test` (no widgets, no media_kit). Linux-first v1: a CLI flag, // `flutter test` (no widgets, no media_kit). One code path for both the
// `watch-together --upgrade`, that checks the manifest, downloads the // CLI (`watch_together --upgrade`) and the in-app banner: fetch manifest,
// tarball, verifies sha256, and extracts it over the live install. // download, verify sha256, apply over the live install.
// Linux allows replacing (unlink/rename) files under a running process — //
// only opening the running binary for writing is refused (ETXTBSY) — so // Apply stages inside the install root (same filesystem, so every move is
// apply is extract-to-temp then delete-first move-over, and the running // a rename — /tmp is often tmpfs and rename across filesystems fails).
// (old) image just prints the result and exits. Next launch is new. // Linux: unlinking/renaming files under a running process is fine, only
// opening the running binary for writing is refused (ETXTBSY), so the
// swap moves each top-level bundle entry aside, moves the new one in,
// and rolls everything back on any failure. Windows (UNTESTED — no
// Windows runner yet): a running exe/dll can be renamed but not deleted,
// so in-use files go to `*.old`, new files move in, and `*.old` is swept
// on the next start (cleanupUpdateLeftovers).
final _log = createLogger('update');
const updateManifestUrl = const updateManifestUrl =
'https://git.senkochan.moe/Foxyz/sync_replay_project/raw/branch/main/updates/manifest.json'; 'https://git.senkochan.moe/Foxyz/sync_replay_project/raw/branch/main/updates/manifest.json';
@@ -20,6 +28,13 @@ const updateManifestUrl =
const appVersion = String.fromEnvironment('APP_VERSION', const appVersion = String.fromEnvironment('APP_VERSION',
defaultValue: '0.0.0-dev'); defaultValue: '0.0.0-dev');
// Process args as main() received them — restart relaunches with these.
List<String> launchArgs = const [];
const _stagingDir = '.update-staging';
const _backupDir = '.update-backup';
const _downloadFile = '.update-download';
class UpdateManifest { class UpdateManifest {
final String version; final String version;
final int protocol; final int protocol;
@@ -52,11 +67,15 @@ class UpdateManifest {
sha256: sha is String && sha.isNotEmpty ? sha : null, sha256: sha is String && sha.isNotEmpty ? sha : null,
notes: (json['notes'] as String?) ?? ''); notes: (json['notes'] as String?) ?? '');
} }
// Newer wire protocol: this build can't join servers running it.
bool get isRequired => protocol > RelayConstants.protoVersion;
} }
List<int> parseVersion(String v) { List<int> parseVersion(String v) {
final core = v.split('+').first.split('-').first;
final out = <int>[]; final out = <int>[];
for (final part in v.split('.')) { for (final part in core.split('.')) {
final m = RegExp(r'\d+').firstMatch(part); final m = RegExp(r'\d+').firstMatch(part);
if (m == null) break; if (m == null) break;
out.add(int.parse(m.group(0)!)); out.add(int.parse(m.group(0)!));
@@ -68,15 +87,48 @@ List<int> parseVersion(String v) {
return out; return out;
} }
bool isNewerVersion(String current, String latest) { // Semver precedence: core numbers, then a prerelease sorts below its
final c = parseVersion(current); // release (1.2.3-rc1 < 1.2.3); prerelease identifiers compare dot by dot,
final l = parseVersion(latest); // numeric ones numerically and below alphanumeric ones. Build metadata
// (+...) is ignored.
int compareVersions(String a, String b) {
final ca = parseVersion(a);
final cb = parseVersion(b);
for (var i = 0; i < 3; i++) { for (var i = 0; i < 3; i++) {
if (l[i] != c[i]) return l[i] > c[i]; if (ca[i] != cb[i]) return ca[i].compareTo(cb[i]);
} }
return false; String? pre(String v) {
final noBuild = v.split('+').first;
final i = noBuild.indexOf('-');
return i < 0 ? null : noBuild.substring(i + 1);
}
final pa = pre(a);
final pb = pre(b);
if (pa == null || pb == null) {
if (pa == pb) return 0;
return pa == null ? 1 : -1;
}
final ia = pa.split('.');
final ib = pb.split('.');
for (var i = 0; i < ia.length && i < ib.length; i++) {
final na = int.tryParse(ia[i]);
final nb = int.tryParse(ib[i]);
final c = na != null && nb != null
? na.compareTo(nb)
: na != null
? -1
: nb != null
? 1
: ia[i].compareTo(ib[i]);
if (c != 0) return c;
}
return ia.length.compareTo(ib.length);
} }
bool isNewerVersion(String current, String latest) =>
compareVersions(latest, current) > 0;
Future<UpdateManifest> fetchManifest( Future<UpdateManifest> fetchManifest(
{HttpClient? http, String url = updateManifestUrl}) async { {HttpClient? http, String url = updateManifestUrl}) async {
final client = http ?? HttpClient(); final client = http ?? HttpClient();
@@ -84,7 +136,7 @@ Future<UpdateManifest> fetchManifest(
final req = await client final req = await client
.getUrl(Uri.parse(url)) .getUrl(Uri.parse(url))
.timeout(const Duration(seconds: 15)); .timeout(const Duration(seconds: 15));
final res = await req.close(); final res = await req.close().timeout(const Duration(seconds: 15));
final body = await res.transform(utf8.decoder).join(); final body = await res.transform(utf8.decoder).join();
if (res.statusCode != 200) { if (res.statusCode != 200) {
throw HttpException('manifest fetch failed: HTTP ${res.statusCode}'); throw HttpException('manifest fetch failed: HTTP ${res.statusCode}');
@@ -95,28 +147,40 @@ Future<UpdateManifest> fetchManifest(
} }
return UpdateManifest.fromJson(decoded); return UpdateManifest.fromJson(decoded);
} finally { } finally {
client.close(); if (http == null) client.close();
} }
} }
Future<void> downloadFile(String url, File dest, {HttpClient? http}) async { typedef DownloadProgress = void Function(int received, int? total);
Future<void> downloadFile(String url, File dest,
{HttpClient? http, DownloadProgress? onProgress}) async {
final client = http ?? HttpClient(); final client = http ?? HttpClient();
try { try {
final req = await client final req = await client
.getUrl(Uri.parse(url)) .getUrl(Uri.parse(url))
.timeout(const Duration(seconds: 15)); .timeout(const Duration(seconds: 15));
final res = await req.close(); final res = await req.close().timeout(const Duration(seconds: 30));
if (res.statusCode != 200) { if (res.statusCode != 200) {
throw HttpException('download failed: HTTP ${res.statusCode}'); throw HttpException('download failed: HTTP ${res.statusCode}');
} }
final total = res.contentLength >= 0 ? res.contentLength : null;
var received = 0;
final sink = dest.openWrite(); final sink = dest.openWrite();
try { try {
await res.pipe(sink); await for (final chunk in res) {
sink.add(chunk);
received += chunk.length;
onProgress?.call(received, total);
}
} finally { } finally {
await sink.close(); await sink.close();
} }
if (total != null && received != total) {
throw HttpException('download truncated: $received of $total bytes');
}
} finally { } finally {
client.close(); if (http == null) client.close();
} }
} }
@@ -128,57 +192,336 @@ Future<void> verifySha256(File file, String want) async {
} }
// Release bundle layout is <root>/<binary> with <root>/data and <root>/lib // Release bundle layout is <root>/<binary> with <root>/data and <root>/lib
// siblings. Anything else (dev runs, dart run) is not a packaged install. // siblings (Windows: data/ next to flutter_windows.dll, no lib/).
Directory? findInstallRoot(String executablePath) { // Anything else (dev runs, dart run) is not a packaged install.
bool _isBundle(String dir, bool windows) =>
Directory('$dir/data').existsSync() &&
(windows
? File('$dir/flutter_windows.dll').existsSync()
: Directory('$dir/lib').existsSync());
Directory? findInstallRoot(String executablePath, {bool? windows}) {
final dir = File(executablePath).parent; final dir = File(executablePath).parent;
if (Directory('${dir.path}/data').existsSync() && return _isBundle(dir.path, windows ?? Platform.isWindows) ? dir : null;
Directory('${dir.path}/lib').existsSync()) {
return dir;
}
return null;
} }
// Extract to temp, then move every file over delete-first: opening the String _basename(String path) => path.replaceAll('\\', '/').split('/').last;
// running executable for writing throws ETXTBSY, delete+rename does not.
Future<void> applyTarball(File tarball, Directory root) async { // Path of the running binary. Read it before applying: after the swap,
final raw = await tarball.readAsBytes(); // /proc/self/exe points at the replaced inode ("... (deleted)").
final archive = TarDecoder().decodeBytes(GZipDecoder().decodeBytes(raw)); String currentExecutablePath() {
final stage = const deleted = ' (deleted)';
await Directory.systemTemp.createTemp('watch-together-update'); final p = Platform.resolvedExecutable;
return p.endsWith(deleted) ? p.substring(0, p.length - deleted.length) : p;
}
// Sweep what a previous apply left behind: staging/backup dirs from an
// interrupted run, and Windows `*.old` files that were still in use.
// The running app is complete (it started), so leftovers are garbage.
Future<void> cleanupUpdateLeftovers(Directory root) async {
for (final name in [_stagingDir, _backupDir, _downloadFile]) {
await _deleteQuietly('${root.path}/$name');
}
if (!await root.exists()) return;
final old = RegExp(r'\.old(\.\d+)?$');
await for (final e in root.list(recursive: true, followLinks: false)) {
if (e is File && old.hasMatch(e.path)) {
try { try {
for (final entry in archive) { await e.delete();
final name = entry.name; } catch (err) {
if (name.isEmpty || name.startsWith('/') || name.contains('..')) { _log.fine('still in use, retry next start: ${e.path} ($err)');
throw FormatException('unsafe tar entry: $name');
} }
final target = File('${stage.path}/$name');
if (entry.isDirectory) {
await Directory(target.path).create(recursive: true);
} else if (entry.isFile) {
await target.parent.create(recursive: true);
await target.writeAsBytes(entry.content as List<int>);
} else {
throw FormatException('unsupported tar entry: $name');
}
}
await for (final entity
in stage.list(recursive: true, followLinks: false)) {
if (entity is! File) continue;
final rel = entity.path.substring(stage.path.length + 1);
final dest = File('${root.path}/$rel');
await dest.parent.create(recursive: true);
if (await dest.exists() || await Link(dest.path).exists()) {
await dest.delete();
}
await entity.rename(dest.path);
}
} finally {
if (await stage.exists()) {
await stage.delete(recursive: true);
} }
} }
} }
List<ArchiveFile> _decodeArchive(List<int> raw) {
if (raw.length > 2 && raw[0] == 0x1f && raw[1] == 0x8b) {
return TarDecoder().decodeBytes(GZipDecoder().decodeBytes(raw)).files;
}
if (raw.length > 2 && raw[0] == 0x50 && raw[1] == 0x4b) {
return ZipDecoder().decodeBytes(raw).files;
}
throw const FormatException('update is neither .tar.gz nor .zip');
}
// Archive-relative path → clean segments; rejects anything that could
// escape the target directory.
List<String> _safeSegments(String name) {
final norm = name.replaceAll('\\', '/');
if (norm.startsWith('/') || RegExp(r'^[A-Za-z]:').hasMatch(norm)) {
throw FormatException('unsafe archive entry: $name');
}
final segs = norm.split('/').where((s) => s.isNotEmpty && s != '.').toList();
if (segs.contains('..')) {
throw FormatException('unsafe archive entry: $name');
}
return segs;
}
// Release archives wrap the bundle in one top-level folder
// (watch-together-<ver>/, see tool/package_linux.sh). Strip it; a flat
// archive (data/, lib/ and the binary at the top) is used as is.
int _wrapperDepth(List<List<String>> entries) {
final tops = entries.where((s) => s.isNotEmpty).map((s) => s.first).toSet();
if (tops.length != 1 || tops.single == 'data' || tops.single == 'lib') {
return 0;
}
return entries.any((s) => s.length > 1) ? 1 : 0;
}
// Symlink targets must stay inside the bundle.
bool _linkStaysInside(List<String> linkDir, String target) {
if (target.startsWith('/') || target.contains('\\')) return false;
final stack = [...linkDir];
for (final s in target.split('/')) {
if (s.isEmpty || s == '.') continue;
if (s == '..') {
if (stack.isEmpty) return false;
stack.removeLast();
} else {
stack.add(s);
}
}
return true;
}
Future<void> _extract(List<int> raw, Directory stage, bool posix) async {
final files = _decodeArchive(raw);
final segs = [for (final f in files) _safeSegments(f.name)];
final depth = _wrapperDepth(segs);
final modes = <int, List<String>>{};
for (var i = 0; i < files.length; i++) {
final entry = files[i];
if (segs[i].length <= depth) continue;
final rel = segs[i].sublist(depth);
final path = '${stage.path}/${rel.join('/')}';
if (entry.isSymbolicLink) {
final target = entry.symbolicLink!;
if (!_linkStaysInside(rel.sublist(0, rel.length - 1), target)) {
throw FormatException('unsafe symlink: ${entry.name} -> $target');
}
await Directory(File(path).parent.path).create(recursive: true);
await Link(path).create(target);
} else if (entry.isDirectory) {
await Directory(path).create(recursive: true);
} else {
await Directory(File(path).parent.path).create(recursive: true);
await File(path).writeAsBytes(entry.content);
final mode = entry.mode & 0x1ff;
if (posix && mode & 0x49 != 0) {
modes.putIfAbsent(mode, () => []).add(path);
}
}
}
// dart:io has no chmod; one call per distinct mode keeps exec bits.
for (final m in modes.entries) {
final r = await Process.run('chmod', [m.key.toRadixString(8), ...m.value]);
if (r.exitCode != 0) {
throw FileSystemException('chmod failed: ${r.stderr}'.trim());
}
}
}
Future<void> _rename(String from, String to) async {
switch (await FileSystemEntity.type(from, followLinks: false)) {
case FileSystemEntityType.directory:
await Directory(from).rename(to);
case FileSystemEntityType.link:
await Link(from).rename(to);
case FileSystemEntityType.notFound:
throw FileSystemException('missing', from);
default:
await File(from).rename(to);
}
}
Future<bool> _exists(String path) async =>
await FileSystemEntity.type(path, followLinks: false) !=
FileSystemEntityType.notFound;
Future<void> _deleteQuietly(String path) async {
try {
switch (await FileSystemEntity.type(path, followLinks: false)) {
case FileSystemEntityType.directory:
await Directory(path).delete(recursive: true);
case FileSystemEntityType.notFound:
break;
default:
await File(path).delete();
}
} catch (e) {
_log.warning('could not remove $path: $e');
}
}
// Test seam: called before each move with a step label; throwing from it
// simulates a mid-swap failure.
typedef ApplyHook = Future<void> Function(String step);
// Unpack [archive] into a staging dir inside [root], then swap it in.
// On failure the install is left exactly as it was.
Future<void> applyUpdate(File archive, Directory root,
{String? executableName, bool? windows, ApplyHook? hook}) async {
final win = windows ?? Platform.isWindows;
final stage = Directory('${root.path}/$_stagingDir');
if (await stage.exists()) await stage.delete(recursive: true);
await stage.create();
try {
await _extract(await archive.readAsBytes(), stage, !win);
if (!_isBundle(stage.path, win) ||
(executableName != null &&
!await File('${stage.path}/$executableName').exists())) {
throw const FormatException(
'archive is not a release bundle (missing data/, lib/ or binary)');
}
if (win) {
await _swapWindows(stage, root, hook);
} else {
await _swapPosix(stage, root, hook);
}
} finally {
await _deleteQuietly(stage.path);
}
}
class _Moved {
final String name;
final bool hadOld;
bool movedIn = false;
_Moved(this.name, this.hadOld);
}
Future<void> _swapPosix(Directory stage, Directory root, ApplyHook? hook) async {
final backup = Directory('${root.path}/$_backupDir');
if (await backup.exists()) await backup.delete(recursive: true);
await backup.create();
final names = [
for (final e in await stage.list(followLinks: false).toList())
e.path.substring(stage.path.length + 1)
]..sort();
final done = <_Moved>[];
try {
for (final name in names) {
final cur = '${root.path}/$name';
final m = _Moved(name, await _exists(cur));
if (m.hadOld) {
await hook?.call('aside:$name');
await _rename(cur, '${backup.path}/$name');
}
done.add(m);
await hook?.call('in:$name');
await _rename('${stage.path}/$name', cur);
m.movedIn = true;
}
} catch (e) {
_log.warning('swap failed, rolling back: $e');
for (final m in done.reversed) {
final cur = '${root.path}/${m.name}';
try {
if (m.movedIn) await _rename(cur, '${stage.path}/${m.name}');
if (m.hadOld) await _rename('${backup.path}/${m.name}', cur);
} catch (re) {
// Leave the backup dir in place so nothing is lost.
_log.severe('rollback of ${m.name} failed: $re '
'(old files kept in ${backup.path})');
rethrow;
}
}
await _deleteQuietly(backup.path);
rethrow;
}
await _deleteQuietly(backup.path);
}
// UNTESTED on Windows. File-level: a running exe/dll can be renamed but
// not deleted or overwritten, so every existing file goes to `<f>.old`
// first; leftovers are swept by cleanupUpdateLeftovers on next start.
Future<void> _swapWindows(Directory stage, Directory root, ApplyHook? hook) async {
final rels = <String>[
await for (final e in stage.list(recursive: true, followLinks: false))
if (e is File) e.path.substring(stage.path.length + 1)
]..sort();
final done = <(String rel, String? old, bool movedIn)>[];
try {
for (final rel in rels) {
final cur = '${root.path}/$rel';
String? old;
if (await File(cur).exists()) {
old = '$cur.old';
if (await File(old).exists()) {
try {
await File(old).delete();
} on FileSystemException {
old = '$cur.old.${DateTime.now().millisecondsSinceEpoch}';
}
}
await hook?.call('aside:$rel');
await File(cur).rename(old);
}
done.add((rel, old, false));
await Directory(File(cur).parent.path).create(recursive: true);
await hook?.call('in:$rel');
await File('${stage.path}/$rel').rename(cur);
done.last = (rel, old, true);
}
} catch (e) {
_log.warning('swap failed, rolling back: $e');
for (final (rel, old, movedIn) in done.reversed) {
final cur = '${root.path}/$rel';
if (movedIn) await File(cur).delete();
if (old != null) await File(old).rename(cur);
}
rethrow;
}
for (final (_, old, _) in done) {
if (old == null) continue;
try {
await File(old).delete();
} on FileSystemException {
// In use (the running exe/dlls) — swept next start.
}
}
}
// Shared by the CLI and the in-app banner: download next to the install
// (same filesystem), verify, apply, clean up.
Future<void> downloadAndApply(UpdateManifest manifest, Directory root,
{HttpClient? http,
DownloadProgress? onProgress,
String? executableName,
void Function(String line)? say}) async {
final file = File('${root.path}/$_downloadFile');
try {
say?.call('downloading ${manifest.url} ...');
await downloadFile(manifest.url, file, http: http, onProgress: onProgress);
if (manifest.sha256 != null) {
await verifySha256(file, manifest.sha256!);
} else {
say?.call('no checksum in manifest — skipping verification '
'(HTTPS transit + gzip CRC still apply).');
}
say?.call('verified, applying over ${root.path} ...');
await applyUpdate(file, root, executableName: executableName);
} finally {
await _deleteQuietly(file.path);
}
}
// Relaunch the (now replaced) binary detached with the same args, then
// exit this process.
Future<Never> restartApp(String executable, List<String> args) async {
final keep = args.where((a) => a != '--upgrade').toList();
await Process.start(executable, keep,
mode: ProcessStartMode.detached,
workingDirectory: Directory.current.path);
exit(0);
}
String requiredUpdateNote(UpdateManifest m) =>
'Required update: servers on ${m.version} speak protocol ${m.protocol}, '
'this build speaks ${RelayConstants.protoVersion} and cannot join them.';
// Returns a process exit code: 0 ok / already current, 1 failure, // Returns a process exit code: 0 ok / already current, 1 failure,
// 2 not a packaged install (update manually). // 2 not a packaged install (update manually).
Future<int> runUpgrade( Future<int> runUpgrade(
@@ -203,36 +546,24 @@ Future<int> runUpgrade(
if (manifest.notes.isNotEmpty) { if (manifest.notes.isNotEmpty) {
stdout.writeln(manifest.notes); stdout.writeln(manifest.notes);
} }
if (manifest.protocol != RelayConstants.protoVersion) { if (manifest.isRequired) {
stdout.writeln('WARNING: protocol ${manifest.protocol} != ${RelayConstants.protoVersion} — ' stdout.writeln(requiredUpdateNote(manifest));
'update together with everyone in the room, mixed versions cannot sync.');
} }
final root = installRoot ?? final exe = executablePath ?? currentExecutablePath();
findInstallRoot(executablePath ?? Platform.resolvedExecutable); final root = installRoot ?? findInstallRoot(exe);
if (root == null) { if (root == null) {
stdout.writeln('not a packaged install — download the tarball and update manually.'); stdout.writeln('not a packaged install — download the tarball and update manually.');
return 2; return 2;
} }
final tmp = await Directory.systemTemp.createTemp('watch-together-dl');
try { try {
final file = File('${tmp.path}/update.tar.gz'); await cleanupUpdateLeftovers(root);
stdout.writeln('downloading ${manifest.url} ...'); await downloadAndApply(manifest, root,
await downloadFile(manifest.url, file, http: http); http: http,
if (manifest.sha256 != null) { executableName: installRoot == null ? _basename(exe) : null,
await verifySha256(file, manifest.sha256!); say: stdout.writeln);
} else {
stdout.writeln('no checksum in manifest — skipping verification '
'(HTTPS transit + gzip CRC still apply).');
}
stdout.writeln('verified, applying over ${root.path} ...');
await applyTarball(file, root);
} catch (e) { } catch (e) {
stdout.writeln('update failed: $e'); stdout.writeln('update failed: $e');
return 1; return 1;
} finally {
if (await tmp.exists()) {
await tmp.delete(recursive: true);
}
} }
stdout.writeln('updated to ${manifest.version} — restart the app to use it.'); stdout.writeln('updated to ${manifest.version} — restart the app to use it.');
return 0; return 0;
@@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
import '../update/update_controller.dart';
import '../update/updater.dart';
// "Update available" card (Home update indicator dialog). Kicks off the one
// background check on first build; renders nothing until there is an
// update to offer. Never shown over playback.
class UpdateBanner extends StatefulWidget {
const UpdateBanner({super.key, this.controller});
final UpdateController? controller;
@override
State<UpdateBanner> createState() => _UpdateBannerState();
}
class _UpdateBannerState extends State<UpdateBanner> {
UpdateController get _c => widget.controller ?? UpdateController.instance;
@override
void initState() {
super.initState();
_c.checkOnce();
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<UpdateStatus>(
valueListenable: _c.status,
builder: (context, s, _) {
final m = s.manifest;
if (s.phase == UpdatePhase.idle || m == null) {
return const SizedBox.shrink();
}
final theme = Theme.of(context);
final required = m.isRequired;
final title = required
? 'Required update: v${m.version}'
: 'Update available: v${m.version}';
final lines = <Widget>[
Text(title, style: theme.textTheme.titleSmall),
if (required)
const Padding(
padding: EdgeInsets.only(top: 4),
child: Text(
'Servers on the new version use a newer protocol — '
'this version cannot join them.',
style: TextStyle(fontSize: 12),
),
),
if (m.notes.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(m.notes,
style: const TextStyle(fontSize: 12, color: Colors.grey)),
),
const SizedBox(height: 8),
..._body(s),
];
return Card(
color: required ? theme.colorScheme.errorContainer : null,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: lines,
),
),
);
},
);
}
List<Widget> _body(UpdateStatus s) {
switch (s.phase) {
case UpdatePhase.downloading:
return [
LinearProgressIndicator(value: s.progress),
const SizedBox(height: 4),
Text('Downloading… ${((s.progress ?? 0) * 100).round()}%',
style: const TextStyle(fontSize: 12)),
];
case UpdatePhase.applying:
return const [
LinearProgressIndicator(),
SizedBox(height: 4),
Text('Installing…', style: TextStyle(fontSize: 12)),
];
case UpdatePhase.restarting:
return const [Text('Restarting…', style: TextStyle(fontSize: 12))];
case UpdatePhase.failed:
return [
Text('Update failed: ${s.error}',
style: const TextStyle(fontSize: 12)),
const SizedBox(height: 8),
_actions(retry: true, manifest: s.manifest!),
];
case UpdatePhase.available:
if (!s.canInstall) {
return const [
Text(
'Not a packaged install — download the new release and '
'replace this folder by hand.',
style: TextStyle(fontSize: 12),
),
];
}
return [_actions(retry: false, manifest: s.manifest!)];
case UpdatePhase.idle:
return const [];
}
}
Widget _actions({required bool retry, required UpdateManifest manifest}) {
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (!manifest.isRequired)
TextButton(onPressed: _c.dismiss, child: const Text('Later')),
const SizedBox(width: 8),
FilledButton(
onPressed: _c.install,
child: Text(retry ? 'Retry' : 'Update and restart'),
),
],
);
}
}
@@ -1,166 +0,0 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import '../state/session_state.dart';
import 'avatar_pipeline.dart';
/// Profile card for the connect screen.
///
/// Lives in a fixed corner card outside the scrolling form, so Host/Join
/// field changes below never move it. Name edits write back to
/// [SessionState.lastForm] on every change (persisted on submit); avatar
/// changes by tapping the circle, validated through [encodeAvatar] before
/// the path is stored, with the encoded bytes shown as the preview.
class ConnectProfileCard extends StatefulWidget {
final TextEditingController name;
const ConnectProfileCard({super.key, required this.name});
@override
State<ConnectProfileCard> createState() => _ConnectProfileCardState();
}
class _ConnectProfileCardState extends State<ConnectProfileCard> {
late String _avatarPath;
Uint8List? _preview;
String? _error;
bool _picking = false;
@override
void initState() {
super.initState();
_avatarPath = SessionState.instance.lastForm.avatarPath;
_refreshPreview();
}
Future<void> _refreshPreview() async {
String b64;
try {
b64 = await encodeAvatar(_avatarPath);
} catch (_) {
return;
}
if (!mounted) return;
setState(() => _preview = b64.isEmpty ? null : base64Decode(b64));
}
Future<void> _persistProfile() async {
try {
await SessionState.instance.saveForm();
} catch (_) {
if (!mounted) return;
setState(() => _error = 'could not save profile — try again');
}
}
Future<void> _pickAvatar() async {
if (_picking) return;
setState(() {
_picking = true;
_error = null;
});
try {
final List<PlatformFile> files;
try {
files = await FilePicker.pickFiles(
dialogTitle: 'Pick avatar image',
type: FileType.custom,
allowedExtensions: const ['png', 'jpg', 'jpeg', 'webp'],
);
} catch (_) {
if (!mounted) return;
setState(() => _error = 'file dialog unavailable — try again');
return;
}
if (files.isEmpty) return;
final path = files.first.path;
if (path == null || !mounted) return;
late final String b64;
try {
b64 = await encodeAvatar(path);
} on FormatException catch (e) {
if (!mounted) return;
setState(() => _error = e.message);
return;
} catch (_) {
if (!mounted) return;
setState(() => _error = 'could not read that image — pick a PNG, JPG or WebP photo');
return;
}
_avatarPath = path;
SessionState.instance.lastForm.avatarPath = path;
await _persistProfile();
if (!mounted) return;
setState(() => _preview = b64.isEmpty ? null : base64Decode(b64));
} finally {
if (mounted) setState(() => _picking = false);
}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: .center,
mainAxisSize: MainAxisSize.min,
spacing: 12,
children: [
ClipOval(
clipBehavior: .antiAlias,
child: Tooltip(
message: 'Change avatar',
child: Stack(
children: [
CircleAvatar(
radius: 64,
backgroundColor: Colors.transparent,
backgroundImage: _preview != null ? MemoryImage(_preview!) : null,
child: _preview != null
? null
: _picking
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: ListenableBuilder(
listenable: widget.name,
builder: (context, _) {
final t = widget.name.text.trim();
return Text(t.isEmpty ? '?' : t[0].toUpperCase());
},
),
),
Positioned.fill(
child: Material(
type: .transparency,
child: InkWell(onTap: _pickAvatar),
),
),
],
),
),
),
SizedBox(
width: 180,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: widget.name,
decoration: const InputDecoration(labelText: 'Display name', isDense: true),
onChanged: (_) {
SessionState.instance.lastForm.name = widget.name.text.trim();
},
onSubmitted: (_) => _persistProfile(),
),
if (_error != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(_error!, style: const TextStyle(fontSize: 11)),
),
],
),
),
],
);
}
}
+417 -125
View File
@@ -4,8 +4,123 @@ import 'dart:io';
import 'package:archive/archive.dart'; import 'package:archive/archive.dart';
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:sync_relay_core/core.dart';
import 'package:watch_together/src/update/update_controller.dart';
import 'package:watch_together/src/update/updater.dart'; import 'package:watch_together/src/update/updater.dart';
// Work dir base: WT_TEST_TMP or the system temp. Staging lives inside
// the install root, so the result doesn't depend on which filesystem
// this is (the old /tmp staging broke when /tmp was tmpfs).
final _tmpBase =
Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
Future<Directory> _work() async {
final base = Directory(_tmpBase);
await base.create(recursive: true);
return base.createTemp('wt-up-test');
}
bool _isExec(String path) => File(path).statSync().mode & 0x40 != 0;
// Builds the release archive exactly like tool/package_linux.sh:
// bundle copied into <stage>/watch-together-<ver>/, then
// `tar -czf out -C <stage> watch-together-<ver>`.
Future<List<int>> _releaseTarball(Directory work,
{String version = '0.2.0', bool windows = false, bool bundle = true}) async {
final stage = Directory('${work.path}/pkg-$version-${windows ? 'win' : 'lin'}');
final folder = Directory('${stage.path}/watch-together-$version');
await Directory('${folder.path}/data/flutter_assets').create(recursive: true);
await File('${folder.path}/data/flutter_assets/fresh.cfg')
.writeAsString('fresh-config');
if (windows) {
await File('${folder.path}/watch_together.exe').writeAsString('binary-v2');
await File('${folder.path}/flutter_windows.dll').writeAsString('dll-v2');
} else {
await File('${folder.path}/watch_together').writeAsString('binary-v2');
if (bundle) {
await Directory('${folder.path}/lib').create();
await File('${folder.path}/lib/libapp.so.1').writeAsString('lib-v2');
await Link('${folder.path}/lib/libapp.so').create('libapp.so.1');
}
await File('${folder.path}/update.sh')
.writeAsString('#!/bin/sh\nexec ./watch_together --upgrade\n');
final r = await Process.run('chmod',
['755', '${folder.path}/watch_together', '${folder.path}/update.sh']);
expect(r.exitCode, 0);
}
final out = File('${work.path}/out-$version.tar.gz');
final r = await Process.run('tar', [
'-czf',
out.path,
'-C',
stage.path,
'watch-together-$version',
]);
expect(r.exitCode, 0, reason: '${r.stderr}');
return out.readAsBytes();
}
Future<Directory> _fakeRoot(Directory work, {bool windows = false}) async {
final root = Directory('${work.path}/install');
await Directory('${root.path}/data').create(recursive: true);
await File('${root.path}/data/old.cfg').writeAsString('old-config');
if (windows) {
await File('${root.path}/watch_together.exe').writeAsString('binary-v1');
await File('${root.path}/flutter_windows.dll').writeAsString('dll-v1');
} else {
await Directory('${root.path}/lib').create();
await File('${root.path}/lib/libgone.so').writeAsString('lib-v1');
await File('${root.path}/watch_together').writeAsString('binary-v1');
await Process.run('chmod', ['755', '${root.path}/watch_together']);
}
await File('${root.path}/user-notes.txt').writeAsString('mine');
return root;
}
List<String> _leftovers(Directory root) => root
.listSync()
.map((e) => e.path.substring(root.path.length + 1))
.where((n) => n.startsWith('.update'))
.toList();
// Serves /manifest.json and the tarball. Fields can be overridden per test.
class _Server {
late HttpServer http;
List<int> tarball;
Map<String, Object?> manifestExtra = {};
bool brokenManifest = false;
int tarHits = 0;
_Server(this.tarball);
String get base => 'http://127.0.0.1:${http.port}';
String get manifestUrl => '$base/manifest.json';
Future<void> start() async {
http = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
http.listen((req) async {
if (req.uri.path == '/manifest.json' && !brokenManifest) {
req.response.write(jsonEncode({
'version': '0.2.0',
'protocol': RelayConstants.protoVersion,
'url': '$base/watch-together-0.2.0-linux-x64.tar.gz',
'sha256': sha256.convert(tarball).toString(),
'notes': 'test build',
...manifestExtra,
}));
} else if (req.uri.path == '/watch-together-0.2.0-linux-x64.tar.gz') {
tarHits++;
req.response.contentLength = tarball.length;
req.response.add(tarball);
} else {
req.response.statusCode = 404;
}
await req.response.close();
});
}
Future<void> stop() => http.close(force: true);
}
void main() { void main() {
test('version compare', () { test('version compare', () {
expect(isNewerVersion('0.1.0', '0.1.1'), isTrue); expect(isNewerVersion('0.1.0', '0.1.1'), isTrue);
@@ -16,6 +131,17 @@ void main() {
expect(isNewerVersion('0.2.0', '0.1.9'), isFalse); expect(isNewerVersion('0.2.0', '0.1.9'), isFalse);
}); });
test('prerelease sorts below its release', () {
expect(isNewerVersion('1.2.3-rc1', '1.2.3'), isTrue);
expect(isNewerVersion('1.2.3', '1.2.3-rc1'), isFalse);
expect(isNewerVersion('1.2.3-rc1', '1.2.3-rc2'), isTrue);
expect(isNewerVersion('1.2.3-rc.2', '1.2.3-rc.10'), isTrue);
expect(isNewerVersion('1.2.3-alpha', '1.2.3-beta'), isTrue);
expect(isNewerVersion('1.2.2', '1.2.3-rc1'), isTrue);
expect(compareVersions('1.2.3+build5', '1.2.3'), 0);
expect(compareVersions('1.2.3-rc1', '1.2.3-rc1'), 0);
});
test('manifest rejects missing fields, sha256 is optional', () { test('manifest rejects missing fields, sha256 is optional', () {
expect(() => UpdateManifest.fromJson({}), throwsFormatException); expect(() => UpdateManifest.fromJson({}), throwsFormatException);
expect( expect(
@@ -26,172 +152,106 @@ void main() {
throwsFormatException); throwsFormatException);
final noSha = UpdateManifest.fromJson({ final noSha = UpdateManifest.fromJson({
'version': '0.2.0', 'version': '0.2.0',
'protocol': 4, 'protocol': RelayConstants.protoVersion,
'url': 'https://x/y.tar.gz', 'url': 'https://x/y.tar.gz',
}); });
expect(noSha.sha256, isNull); expect(noSha.sha256, isNull);
expect(noSha.isRequired, isFalse);
expect(
UpdateManifest.fromJson({
'version': '0.2.0',
'protocol': RelayConstants.protoVersion + 1,
'url': 'https://x/y.tar.gz',
}).isRequired,
isTrue);
}); });
test('findInstallRoot honors bundle layout', () async { test('findInstallRoot honors bundle layout', () async {
final tmp = await Directory.systemTemp.createTemp('wt-root-test'); final work = await _work();
try { try {
final root = Directory('${tmp.path}/root'); final root = await _fakeRoot(work);
await Directory('${root.path}/data').create(recursive: true); expect(findInstallRoot('${root.path}/watch_together', windows: false)?.path,
await Directory('${root.path}/lib').create(recursive: true); root.path);
final exe = File('${root.path}/watch_together')..createSync(); expect(findInstallRoot('${work.path}/stray-binary', windows: false), isNull);
expect(findInstallRoot(exe.path)?.path, root.path); final win = await _fakeRoot(await work.createTemp('w'), windows: true);
expect(findInstallRoot('${tmp.path}/stray-binary'), isNull); expect(findInstallRoot('${win.path}/watch_together.exe', windows: true)?.path,
win.path);
} finally { } finally {
await tmp.delete(recursive: true);
}
});
group('upgrade flow', () {
late HttpServer server;
late Directory work;
late List<int> tarball;
late String tarSha;
var tarHits = 0;
setUp(() async {
tarHits = 0;
work = await Directory.systemTemp.createTemp('wt-up-test');
final archive = Archive();
final v2 = utf8.encode('binary-v2');
archive.addFile(ArchiveFile('watch_together', v2.length, v2));
final cfg = utf8.encode('fresh-config');
archive.addFile(ArchiveFile('data/fresh.cfg', cfg.length, cfg));
tarball = GZipEncoder().encode(TarEncoder().encode(archive));
tarSha = sha256.convert(tarball).toString();
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
server.listen((req) async {
if (req.uri.path == '/manifest.json') {
final manifest = {
'version': '0.2.0',
'protocol': 4,
'url':
'http://127.0.0.1:${server.port}/watch-together-0.2.0.tar.gz',
'sha256': tarSha,
'notes': 'test build',
};
req.response
..statusCode = 200
..write(jsonEncode(manifest));
} else if (req.uri.path == '/watch-together-0.2.0.tar.gz') {
tarHits++;
req.response
..statusCode = 200
..add(tarball);
} else {
req.response.statusCode = 404;
}
await req.response.close();
});
});
tearDown(() async {
await server.close(force: true);
if (await work.exists()) {
await work.delete(recursive: true); await work.delete(recursive: true);
} }
}); });
Future<Directory> fakeRoot() async { group('upgrade flow', () {
final root = Directory('${work.path}/root'); late Directory work;
await Directory('${root.path}/data').create(recursive: true); late _Server server;
await Directory('${root.path}/lib').create(recursive: true);
await File('${root.path}/watch_together')
.writeAsString('binary-v1', flush: true);
return root;
}
String manifestUrl() => setUp(() async {
'http://127.0.0.1:${server.port}/manifest.json'; work = await _work();
server = _Server(await _releaseTarball(work));
await server.start();
});
test('downloads, verifies and applies', () async { tearDown(() async {
final root = await fakeRoot(); await server.stop();
if (await work.exists()) await work.delete(recursive: true);
});
test('real release layout lands at the install root, modes kept',
() async {
final root = await _fakeRoot(work);
final rc = await runUpgrade( final rc = await runUpgrade(
currentVersion: '0.1.0', currentVersion: '0.1.0',
manifestUrl: manifestUrl(), manifestUrl: server.manifestUrl,
installRoot: root); installRoot: root);
expect(rc, 0); expect(rc, 0);
expect(tarHits, 1); expect(server.tarHits, 1);
expect(await File('${root.path}/watch_together').readAsString(), final p = root.path;
'binary-v2'); expect(await File('$p/watch_together').readAsString(), 'binary-v2');
expect(await File('${root.path}/data/fresh.cfg').readAsString(), expect(await File('$p/data/flutter_assets/fresh.cfg').readAsString(),
'fresh-config'); 'fresh-config');
expect(Directory('$p/watch-together-0.2.0').existsSync(), isFalse);
expect(_isExec('$p/watch_together'), isTrue);
expect(_isExec('$p/update.sh'), isTrue);
expect(await Link('$p/lib/libapp.so').target(), 'libapp.so.1');
expect(await File('$p/lib/libapp.so').readAsString(), 'lib-v2');
// Bundle dirs are replaced whole; unrelated top-level files stay.
expect(File('$p/lib/libgone.so').existsSync(), isFalse);
expect(File('$p/data/old.cfg').existsSync(), isFalse);
expect(await File('$p/user-notes.txt').readAsString(), 'mine');
expect(_leftovers(root), isEmpty);
}); });
test('up to date downloads nothing', () async { test('up to date downloads nothing', () async {
final root = await fakeRoot(); final root = await _fakeRoot(work);
final rc = await runUpgrade( final rc = await runUpgrade(
currentVersion: '0.2.0', currentVersion: '0.2.0',
manifestUrl: manifestUrl(), manifestUrl: server.manifestUrl,
installRoot: root); installRoot: root);
expect(rc, 0); expect(rc, 0);
expect(tarHits, 0); expect(server.tarHits, 0);
expect(await File('${root.path}/watch_together').readAsString(), expect(await File('${root.path}/watch_together').readAsString(),
'binary-v1'); 'binary-v1');
}); });
test('bad hash aborts, install untouched', () async { test('bad hash aborts, install untouched', () async {
await server.close(force: true); server.manifestExtra = {'sha256': '0' * 64};
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); final root = await _fakeRoot(work);
server.listen((req) async {
if (req.uri.path == '/manifest.json') {
req.response
..statusCode = 200
..write(jsonEncode({
'version': '0.2.0',
'protocol': 4,
'url':
'http://127.0.0.1:${server.port}/watch-together-0.2.0.tar.gz',
'sha256': '0' * 64,
'notes': '',
}));
} else {
tarHits++;
req.response
..statusCode = 200
..add(tarball);
}
await req.response.close();
});
final root = await fakeRoot();
final rc = await runUpgrade( final rc = await runUpgrade(
currentVersion: '0.1.0', currentVersion: '0.1.0',
manifestUrl: manifestUrl(), manifestUrl: server.manifestUrl,
installRoot: root); installRoot: root);
expect(rc, 1); expect(rc, 1);
expect(await File('${root.path}/watch_together').readAsString(), expect(await File('${root.path}/watch_together').readAsString(),
'binary-v1'); 'binary-v1');
expect(_leftovers(root), isEmpty);
}); });
test('manifest without sha256 still applies', () async { test('manifest without sha256 still applies', () async {
await server.close(force: true); server.manifestExtra = {'sha256': null};
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); final root = await _fakeRoot(work);
server.listen((req) async {
if (req.uri.path == '/manifest.json') {
req.response
..statusCode = 200
..write(jsonEncode({
'version': '0.2.0',
'protocol': 4,
'url':
'http://127.0.0.1:${server.port}/watch-together-0.2.0.tar.gz',
'notes': '',
}));
} else {
req.response
..statusCode = 200
..add(tarball);
}
await req.response.close();
});
final root = await fakeRoot();
final rc = await runUpgrade( final rc = await runUpgrade(
currentVersion: '0.1.0', currentVersion: '0.1.0',
manifestUrl: manifestUrl(), manifestUrl: server.manifestUrl,
installRoot: root); installRoot: root);
expect(rc, 0); expect(rc, 0);
expect(await File('${root.path}/watch_together').readAsString(), expect(await File('${root.path}/watch_together').readAsString(),
@@ -201,9 +261,241 @@ void main() {
test('unknown layout asks for manual update', () async { test('unknown layout asks for manual update', () async {
final rc = await runUpgrade( final rc = await runUpgrade(
currentVersion: '0.1.0', currentVersion: '0.1.0',
manifestUrl: manifestUrl(), manifestUrl: server.manifestUrl,
executablePath: '${work.path}/stray-binary'); executablePath: '${work.path}/stray-binary');
expect(rc, 2); expect(rc, 2);
}); });
test('archive that is not a bundle is refused, install untouched',
() async {
server.tarball = await _releaseTarball(work, version: '9.9.9', bundle: false);
final root = await _fakeRoot(work);
final rc = await runUpgrade(
currentVersion: '0.1.0',
manifestUrl: server.manifestUrl,
installRoot: root);
expect(rc, 1);
expect(await File('${root.path}/watch_together').readAsString(),
'binary-v1');
expect(File('${root.path}/lib/libgone.so').existsSync(), isTrue);
expect(_leftovers(root), isEmpty);
});
});
group('apply', () {
late Directory work;
setUp(() async => work = await _work());
tearDown(() async {
if (await work.exists()) await work.delete(recursive: true);
});
Future<File> archive({bool windows = false}) async {
final f = File('${work.path}/u.tar.gz');
await f.writeAsBytes(await _releaseTarball(work, windows: windows));
return f;
}
Future<void> expectUntouched(Directory root) async {
final p = root.path;
expect(await File('$p/watch_together').readAsString(), 'binary-v1');
expect(_isExec('$p/watch_together'), isTrue);
expect(await File('$p/lib/libgone.so').readAsString(), 'lib-v1');
expect(await File('$p/data/old.cfg').readAsString(), 'old-config');
expect(File('$p/update.sh').existsSync(), isFalse);
expect(_leftovers(root), isEmpty);
}
// Each step of the swap (move-aside / move-in per top-level entry)
// failing must leave the old install fully intact.
for (final step in [
'aside:data',
'in:data',
'aside:lib',
'in:update.sh',
'aside:watch_together',
'in:watch_together',
]) {
test('failure at $step rolls back', () async {
final root = await _fakeRoot(work);
await expectLater(
applyUpdate(await archive(), root, windows: false, hook: (s) async {
if (s == step) throw FileSystemException('injected', s);
}),
throwsA(isA<FileSystemException>()));
await expectUntouched(root);
});
}
test('stages next to the install, never in the system temp', () async {
final root = await _fakeRoot(work);
final seen = <String>[];
await applyUpdate(await archive(), root, windows: false, hook: (s) async {
if (seen.isEmpty) {
seen.add(Directory('${root.path}/.update-staging').existsSync()
? 'staged-in-root'
: 'elsewhere');
}
});
expect(seen, ['staged-in-root']);
});
test('unsafe archive entries are rejected', () async {
final root = await _fakeRoot(work);
final evil = Archive()
..addFile(ArchiveFile('x/../../escape', 3, utf8.encode('bad')));
final f = File('${work.path}/evil.tar.gz')
..writeAsBytesSync(GZipEncoder().encode(TarEncoder().encode(evil)));
await expectLater(
applyUpdate(f, root, windows: false), throwsFormatException);
final link = Archive()
..addFile(ArchiveFile.symlink('w/lib/x.so', '../../../etc/passwd'));
f.writeAsBytesSync(GZipEncoder().encode(TarEncoder().encode(link)));
await expectLater(
applyUpdate(f, root, windows: false), throwsFormatException);
expect(File('${work.path}/escape').existsSync(), isFalse);
await expectUntouched(root);
});
test('leftovers from an interrupted run are swept', () async {
final root = await _fakeRoot(work);
await Directory('${root.path}/.update-staging/x').create(recursive: true);
await Directory('${root.path}/.update-backup').create();
await File('${root.path}/.update-download').writeAsString('partial');
await File('${root.path}/data/thing.dll.old').writeAsString('o');
await cleanupUpdateLeftovers(root);
expect(_leftovers(root), isEmpty);
expect(File('${root.path}/data/thing.dll.old').existsSync(), isFalse);
});
// Windows strategy exercised on Linux: proves the bookkeeping, not the
// Windows file-locking semantics (no Windows runner yet).
test('windows-style swap applies and parks old files', () async {
final root = await _fakeRoot(work, windows: true);
await applyUpdate(await archive(windows: true), root, windows: true);
final p = root.path;
expect(await File('$p/watch_together.exe').readAsString(), 'binary-v2');
expect(await File('$p/flutter_windows.dll').readAsString(), 'dll-v2');
expect(await File('$p/data/flutter_assets/fresh.cfg').readAsString(),
'fresh-config');
// Not in use here, so the .old copies were deleted right away.
expect(File('$p/watch_together.exe.old').existsSync(), isFalse);
expect(_leftovers(root), isEmpty);
});
test('windows-style swap rolls back', () async {
final root = await _fakeRoot(work, windows: true);
await expectLater(
applyUpdate(await archive(windows: true), root, windows: true,
hook: (s) async {
if (s == 'in:watch_together.exe') {
throw const FileSystemException('injected');
}
}),
throwsA(isA<FileSystemException>()));
final p = root.path;
expect(await File('$p/watch_together.exe').readAsString(), 'binary-v1');
expect(await File('$p/flutter_windows.dll').readAsString(), 'dll-v1');
expect(File('$p/data/flutter_assets/fresh.cfg').existsSync(), isFalse);
expect(File('$p/flutter_windows.dll.old').existsSync(), isFalse);
expect(_leftovers(root), isEmpty);
});
});
group('in-app controller', () {
late Directory work;
late _Server server;
setUp(() async {
work = await _work();
server = _Server(await _releaseTarball(work));
await server.start();
});
tearDown(() async {
await server.stop();
if (await work.exists()) await work.delete(recursive: true);
});
test('check, download with progress, apply, restart same binary',
() async {
final root = await _fakeRoot(work);
final restarts = <(String, List<String>)>[];
launchArgs = const ['--some-flag'];
final c = UpdateController(
currentVersion: '0.1.0',
manifestUrl: server.manifestUrl,
executablePath: '${root.path}/watch_together',
restart: (exe, args) async => restarts.add((exe, args)));
await c.checkOnce();
expect(c.status.value.phase, UpdatePhase.available);
expect(c.status.value.canInstall, isTrue);
expect(c.status.value.manifest!.isRequired, isFalse);
final progress = <double>[];
c.status.addListener(() {
final p = c.status.value.progress;
if (p != null) progress.add(p);
});
await c.install();
expect(progress.last, 1.0);
expect(c.status.value.phase, UpdatePhase.restarting);
expect(await File('${root.path}/watch_together').readAsString(),
'binary-v2');
expect(_isExec('${root.path}/watch_together'), isTrue);
expect(restarts.single.$1, '${root.path}/watch_together');
expect(restarts.single.$2, ['--some-flag']);
});
test('newer protocol is a required update, cannot be dismissed',
() async {
server.manifestExtra = {'protocol': RelayConstants.protoVersion + 1};
final c = UpdateController(
currentVersion: '0.1.0',
manifestUrl: server.manifestUrl,
executablePath: '${work.path}/stray-binary',
restart: (_, _) async {});
await c.checkOnce();
expect(c.status.value.manifest!.isRequired, isTrue);
expect(c.status.value.canInstall, isFalse);
c.dismiss();
expect(c.status.value.phase, UpdatePhase.available);
});
test('failed check stays quiet; dev builds skip the check', () async {
server.brokenManifest = true;
final c = UpdateController(
currentVersion: '0.1.0',
manifestUrl: server.manifestUrl,
executablePath: '${work.path}/stray-binary',
restart: (_, _) async {});
await c.checkOnce();
expect(c.status.value.phase, UpdatePhase.idle);
server.brokenManifest = false;
final dev = UpdateController(
currentVersion: '0.0.0-dev',
manifestUrl: server.manifestUrl,
executablePath: '${work.path}/stray-binary',
restart: (_, _) async {});
await dev.checkOnce();
expect(dev.status.value.phase, UpdatePhase.idle);
});
test('failed install shows error, install untouched, retry works',
() async {
server.manifestExtra = {'sha256': '0' * 64};
final root = await _fakeRoot(work);
var restarted = false;
final c = UpdateController(
currentVersion: '0.1.0',
manifestUrl: server.manifestUrl,
executablePath: '${root.path}/watch_together',
restart: (_, _) async => restarted = true);
await c.checkOnce();
await c.install();
expect(c.status.value.phase, UpdatePhase.failed);
expect(c.status.value.error, contains('sha256'));
expect(restarted, isFalse);
expect(await File('${root.path}/watch_together').readAsString(),
'binary-v1');
});
}); });
} }
+4 -1
View File
@@ -3,7 +3,9 @@
# upload it to Discord. # upload it to Discord.
# Usage: tool/package_linux.sh [--skip-build] [--noupload|--no-upload] # Usage: tool/package_linux.sh [--skip-build] [--noupload|--no-upload]
# Output: dist/watch-together-<version>-linux-x64.tar.gz containing one # Output: dist/watch-together-<version>-linux-x64.tar.gz containing one
# top-level folder with the executable, lib/ and data/ as built. # top-level folder with the executable, lib/ and data/ as built, plus
# update.sh (runs the binary with --upgrade). The in-app updater strips
# that top-level folder — keep the layout in sync with updater_test.dart.
# Upload: set DISCORD_WEBHOOK_URL in the environment (never commit it). # Upload: set DISCORD_WEBHOOK_URL in the environment (never commit it).
# The tarball must stay under Discord's upload limit (25 MB free tier). # The tarball must stay under Discord's upload limit (25 MB free tier).
# The upload posts a Components V2 card (flag 1<<15): version header, # The upload posts a Components V2 card (flag 1<<15): version header,
@@ -43,6 +45,7 @@ TARBALL="$DIST/$FOLDER-linux-$ARCH.tar.gz"
rm -rf "$STAGE" rm -rf "$STAGE"
mkdir -p "$STAGE/$FOLDER" "$DIST" mkdir -p "$STAGE/$FOLDER" "$DIST"
cp -r "$BUNDLE/." "$STAGE/$FOLDER/" cp -r "$BUNDLE/." "$STAGE/$FOLDER/"
install -m 755 "$APP/lib/src/update/bundle/update.sh" "$STAGE/$FOLDER/update.sh"
tar -czf "$TARBALL" -C "$STAGE" "$FOLDER" tar -czf "$TARBALL" -C "$STAGE" "$FOLDER"
rm -rf "$STAGE" rm -rf "$STAGE"
ls -la "$TARBALL" ls -la "$TARBALL"