diff --git a/apps/desktop/lib/src/update/bundle/update.bat b/apps/desktop/lib/src/update/bundle/update.bat new file mode 100644 index 0000000..2083958 --- /dev/null +++ b/apps/desktop/lib/src/update/bundle/update.bat @@ -0,0 +1,4 @@ +@echo off +rem Update this install in place (same as clicking Update in the app). +"%~dp0watch_together.exe" --upgrade %* +pause diff --git a/apps/desktop/lib/src/update/bundle/update.sh b/apps/desktop/lib/src/update/bundle/update.sh new file mode 100755 index 0000000..0ce1dab --- /dev/null +++ b/apps/desktop/lib/src/update/bundle/update.sh @@ -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 "$@" diff --git a/apps/desktop/lib/src/update/update_controller.dart b/apps/desktop/lib/src/update/update_controller.dart new file mode 100644 index 0000000..b03ad11 --- /dev/null +++ b/apps/desktop/lib/src/update/update_controller.dart @@ -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 Function(String executable, List args); + +// Release Hive's box locks before the new instance opens the same boxes. +Future _closeAndRestart(String executable, List 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(null); + Future? _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 checkOnce() => _check ??= _checkNow(); + + // User-triggered re-check ("Check now"); joins one already in flight. + Future checkNow() { + final inFlight = _check; + if (inFlight != null && _checking) return inFlight; + return _check = _checkNow(); + } + + bool _checking = false; + + Future _checkNow() async { + _checking = true; + try { + await _runCheck(); + } finally { + _checking = false; + } + } + + Future _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 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); + } +} diff --git a/apps/desktop/lib/src/update/updater.dart b/apps/desktop/lib/src/update/updater.dart index f397399..397f40c 100644 --- a/apps/desktop/lib/src/update/updater.dart +++ b/apps/desktop/lib/src/update/updater.dart @@ -6,13 +6,21 @@ import 'package:crypto/crypto.dart'; import 'package:sync_relay_core/core.dart'; // Zero Flutter imports: this file must stay unit-testable with plain -// `flutter test` (no widgets, no media_kit). Linux-first v1: a CLI flag, -// `watch-together --upgrade`, that checks the manifest, downloads the -// tarball, verifies sha256, and extracts it 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 is extract-to-temp then delete-first move-over, and the running -// (old) image just prints the result and exits. Next launch is new. +// `flutter test` (no widgets, no media_kit). One code path for both the +// CLI (`watch_together --upgrade`) and the in-app banner: fetch manifest, +// download, verify sha256, apply over the live install. +// +// Apply stages inside the install root (same filesystem, so every move is +// a rename — /tmp is often tmpfs and rename across filesystems fails). +// 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 = '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', defaultValue: '0.0.0-dev'); +// Process args as main() received them — restart relaunches with these. +List launchArgs = const []; + +const _stagingDir = '.update-staging'; +const _backupDir = '.update-backup'; +const _downloadFile = '.update-download'; + class UpdateManifest { final String version; final int protocol; @@ -52,11 +67,15 @@ class UpdateManifest { sha256: sha is String && sha.isNotEmpty ? sha : null, notes: (json['notes'] as String?) ?? ''); } + + // Newer wire protocol: this build can't join servers running it. + bool get isRequired => protocol > RelayConstants.protoVersion; } List parseVersion(String v) { + final core = v.split('+').first.split('-').first; final out = []; - for (final part in v.split('.')) { + for (final part in core.split('.')) { final m = RegExp(r'\d+').firstMatch(part); if (m == null) break; out.add(int.parse(m.group(0)!)); @@ -68,15 +87,48 @@ List parseVersion(String v) { return out; } -bool isNewerVersion(String current, String latest) { - final c = parseVersion(current); - final l = parseVersion(latest); +// Semver precedence: core numbers, then a prerelease sorts below its +// release (1.2.3-rc1 < 1.2.3); prerelease identifiers compare dot by dot, +// 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++) { - 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 fetchManifest( {HttpClient? http, String url = updateManifestUrl}) async { final client = http ?? HttpClient(); @@ -84,7 +136,7 @@ Future fetchManifest( final req = await client .getUrl(Uri.parse(url)) .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(); if (res.statusCode != 200) { throw HttpException('manifest fetch failed: HTTP ${res.statusCode}'); @@ -95,28 +147,40 @@ Future fetchManifest( } return UpdateManifest.fromJson(decoded); } finally { - client.close(); + if (http == null) client.close(); } } -Future downloadFile(String url, File dest, {HttpClient? http}) async { +typedef DownloadProgress = void Function(int received, int? total); + +Future downloadFile(String url, File dest, + {HttpClient? http, DownloadProgress? onProgress}) async { final client = http ?? HttpClient(); try { final req = await client .getUrl(Uri.parse(url)) .timeout(const Duration(seconds: 15)); - final res = await req.close(); + final res = await req.close().timeout(const Duration(seconds: 30)); if (res.statusCode != 200) { throw HttpException('download failed: HTTP ${res.statusCode}'); } + final total = res.contentLength >= 0 ? res.contentLength : null; + var received = 0; final sink = dest.openWrite(); try { - await res.pipe(sink); + await for (final chunk in res) { + sink.add(chunk); + received += chunk.length; + onProgress?.call(received, total); + } } finally { await sink.close(); } + if (total != null && received != total) { + throw HttpException('download truncated: $received of $total bytes'); + } } finally { - client.close(); + if (http == null) client.close(); } } @@ -128,57 +192,336 @@ Future verifySha256(File file, String want) async { } // Release bundle layout is / with /data and /lib -// siblings. Anything else (dev runs, dart run) is not a packaged install. -Directory? findInstallRoot(String executablePath) { +// siblings (Windows: data/ next to flutter_windows.dll, no lib/). +// 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; - if (Directory('${dir.path}/data').existsSync() && - Directory('${dir.path}/lib').existsSync()) { - return dir; - } - return null; + return _isBundle(dir.path, windows ?? Platform.isWindows) ? dir : null; } -// Extract to temp, then move every file over delete-first: opening the -// running executable for writing throws ETXTBSY, delete+rename does not. -Future applyTarball(File tarball, Directory root) async { - final raw = await tarball.readAsBytes(); - final archive = TarDecoder().decodeBytes(GZipDecoder().decodeBytes(raw)); - final stage = - await Directory.systemTemp.createTemp('watch-together-update'); - try { - for (final entry in archive) { - final name = entry.name; - if (name.isEmpty || name.startsWith('/') || name.contains('..')) { - throw FormatException('unsafe tar entry: $name'); +String _basename(String path) => path.replaceAll('\\', '/').split('/').last; + +// Path of the running binary. Read it before applying: after the swap, +// /proc/self/exe points at the replaced inode ("... (deleted)"). +String currentExecutablePath() { + const deleted = ' (deleted)'; + 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 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 { + await e.delete(); + } catch (err) { + _log.fine('still in use, retry next start: ${e.path} ($err)'); } - 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); - } 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 _decodeArchive(List 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 _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-/, 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> 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 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 _extract(List 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 = >{}; + 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 _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 _exists(String path) async => + await FileSystemEntity.type(path, followLinks: false) != + FileSystemEntityType.notFound; + +Future _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 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 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 _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 `.old` +// first; leftovers are swept by cleanupUpdateLeftovers on next start. +Future _swapWindows(Directory stage, Directory root, ApplyHook? hook) async { + final rels = [ + 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 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 restartApp(String executable, List 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, // 2 not a packaged install (update manually). Future runUpgrade( @@ -203,36 +546,24 @@ Future runUpgrade( if (manifest.notes.isNotEmpty) { stdout.writeln(manifest.notes); } - if (manifest.protocol != RelayConstants.protoVersion) { - stdout.writeln('WARNING: protocol ${manifest.protocol} != ${RelayConstants.protoVersion} — ' - 'update together with everyone in the room, mixed versions cannot sync.'); + if (manifest.isRequired) { + stdout.writeln(requiredUpdateNote(manifest)); } - final root = installRoot ?? - findInstallRoot(executablePath ?? Platform.resolvedExecutable); + final exe = executablePath ?? currentExecutablePath(); + final root = installRoot ?? findInstallRoot(exe); if (root == null) { stdout.writeln('not a packaged install — download the tarball and update manually.'); return 2; } - final tmp = await Directory.systemTemp.createTemp('watch-together-dl'); try { - final file = File('${tmp.path}/update.tar.gz'); - stdout.writeln('downloading ${manifest.url} ...'); - await downloadFile(manifest.url, file, http: http); - if (manifest.sha256 != null) { - await verifySha256(file, manifest.sha256!); - } 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); + await cleanupUpdateLeftovers(root); + await downloadAndApply(manifest, root, + http: http, + executableName: installRoot == null ? _basename(exe) : null, + say: stdout.writeln); } catch (e) { stdout.writeln('update failed: $e'); return 1; - } finally { - if (await tmp.exists()) { - await tmp.delete(recursive: true); - } } stdout.writeln('updated to ${manifest.version} — restart the app to use it.'); return 0; diff --git a/apps/desktop/lib/src/widgets/update_banner.dart b/apps/desktop/lib/src/widgets/update_banner.dart new file mode 100644 index 0000000..032a1a4 --- /dev/null +++ b/apps/desktop/lib/src/widgets/update_banner.dart @@ -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 createState() => _UpdateBannerState(); +} + +class _UpdateBannerState extends State { + UpdateController get _c => widget.controller ?? UpdateController.instance; + + @override + void initState() { + super.initState(); + _c.checkOnce(); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + 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 = [ + 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 _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'), + ), + ], + ); + } +} diff --git a/apps/desktop/lib/src/widgets/user_section.dart b/apps/desktop/lib/src/widgets/user_section.dart deleted file mode 100644 index 8d33253..0000000 --- a/apps/desktop/lib/src/widgets/user_section.dart +++ /dev/null @@ -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 createState() => _ConnectProfileCardState(); -} - -class _ConnectProfileCardState extends State { - late String _avatarPath; - Uint8List? _preview; - String? _error; - bool _picking = false; - - @override - void initState() { - super.initState(); - _avatarPath = SessionState.instance.lastForm.avatarPath; - _refreshPreview(); - } - - Future _refreshPreview() async { - String b64; - try { - b64 = await encodeAvatar(_avatarPath); - } catch (_) { - return; - } - if (!mounted) return; - setState(() => _preview = b64.isEmpty ? null : base64Decode(b64)); - } - - Future _persistProfile() async { - try { - await SessionState.instance.saveForm(); - } catch (_) { - if (!mounted) return; - setState(() => _error = 'could not save profile — try again'); - } - } - - Future _pickAvatar() async { - if (_picking) return; - setState(() { - _picking = true; - _error = null; - }); - try { - final List 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)), - ), - ], - ), - ), - ], - ); - } -} diff --git a/apps/desktop/test/updater_test.dart b/apps/desktop/test/updater_test.dart index 349267e..30a8514 100644 --- a/apps/desktop/test/updater_test.dart +++ b/apps/desktop/test/updater_test.dart @@ -4,8 +4,123 @@ import 'dart:io'; import 'package:archive/archive.dart'; import 'package:crypto/crypto.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'; +// 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 _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 /watch-together-/, then +// `tar -czf out -C watch-together-`. +Future> _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 _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 _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 tarball; + Map 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 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 stop() => http.close(force: true); +} + void main() { test('version compare', () { expect(isNewerVersion('0.1.0', '0.1.1'), isTrue); @@ -16,6 +131,17 @@ void main() { 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', () { expect(() => UpdateManifest.fromJson({}), throwsFormatException); expect( @@ -26,172 +152,106 @@ void main() { throwsFormatException); final noSha = UpdateManifest.fromJson({ 'version': '0.2.0', - 'protocol': 4, + 'protocol': RelayConstants.protoVersion, 'url': 'https://x/y.tar.gz', }); 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 { - final tmp = await Directory.systemTemp.createTemp('wt-root-test'); + final work = await _work(); try { - final root = Directory('${tmp.path}/root'); - await Directory('${root.path}/data').create(recursive: true); - await Directory('${root.path}/lib').create(recursive: true); - final exe = File('${root.path}/watch_together')..createSync(); - expect(findInstallRoot(exe.path)?.path, root.path); - expect(findInstallRoot('${tmp.path}/stray-binary'), isNull); + final root = await _fakeRoot(work); + expect(findInstallRoot('${root.path}/watch_together', windows: false)?.path, + root.path); + expect(findInstallRoot('${work.path}/stray-binary', windows: false), isNull); + final win = await _fakeRoot(await work.createTemp('w'), windows: true); + expect(findInstallRoot('${win.path}/watch_together.exe', windows: true)?.path, + win.path); } finally { - await tmp.delete(recursive: true); + await work.delete(recursive: true); } }); group('upgrade flow', () { - late HttpServer server; late Directory work; - late List tarball; - late String tarSha; - var tarHits = 0; + late _Server server; 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(); - }); + work = await _work(); + server = _Server(await _releaseTarball(work)); + await server.start(); }); tearDown(() async { - await server.close(force: true); - if (await work.exists()) { - await work.delete(recursive: true); - } + await server.stop(); + if (await work.exists()) await work.delete(recursive: true); }); - Future fakeRoot() async { - final root = Directory('${work.path}/root'); - await Directory('${root.path}/data').create(recursive: true); - await Directory('${root.path}/lib').create(recursive: true); - await File('${root.path}/watch_together') - .writeAsString('binary-v1', flush: true); - return root; - } - - String manifestUrl() => - 'http://127.0.0.1:${server.port}/manifest.json'; - - test('downloads, verifies and applies', () async { - final root = await fakeRoot(); + test('real release layout lands at the install root, modes kept', + () async { + final root = await _fakeRoot(work); final rc = await runUpgrade( currentVersion: '0.1.0', - manifestUrl: manifestUrl(), + manifestUrl: server.manifestUrl, installRoot: root); expect(rc, 0); - expect(tarHits, 1); - expect(await File('${root.path}/watch_together').readAsString(), - 'binary-v2'); - expect(await File('${root.path}/data/fresh.cfg').readAsString(), + expect(server.tarHits, 1); + final p = root.path; + expect(await File('$p/watch_together').readAsString(), 'binary-v2'); + expect(await File('$p/data/flutter_assets/fresh.cfg').readAsString(), '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 { - final root = await fakeRoot(); + final root = await _fakeRoot(work); final rc = await runUpgrade( currentVersion: '0.2.0', - manifestUrl: manifestUrl(), + manifestUrl: server.manifestUrl, installRoot: root); expect(rc, 0); - expect(tarHits, 0); + expect(server.tarHits, 0); expect(await File('${root.path}/watch_together').readAsString(), 'binary-v1'); }); test('bad hash aborts, install untouched', () async { - await server.close(force: true); - server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - 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(); + server.manifestExtra = {'sha256': '0' * 64}; + final root = await _fakeRoot(work); final rc = await runUpgrade( currentVersion: '0.1.0', - manifestUrl: manifestUrl(), + manifestUrl: server.manifestUrl, installRoot: root); expect(rc, 1); expect(await File('${root.path}/watch_together').readAsString(), 'binary-v1'); + expect(_leftovers(root), isEmpty); }); test('manifest without sha256 still applies', () async { - await server.close(force: true); - server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); - 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(); + server.manifestExtra = {'sha256': null}; + final root = await _fakeRoot(work); final rc = await runUpgrade( currentVersion: '0.1.0', - manifestUrl: manifestUrl(), + manifestUrl: server.manifestUrl, installRoot: root); expect(rc, 0); expect(await File('${root.path}/watch_together').readAsString(), @@ -201,9 +261,241 @@ void main() { test('unknown layout asks for manual update', () async { final rc = await runUpgrade( currentVersion: '0.1.0', - manifestUrl: manifestUrl(), + manifestUrl: server.manifestUrl, executablePath: '${work.path}/stray-binary'); 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 archive({bool windows = false}) async { + final f = File('${work.path}/u.tar.gz'); + await f.writeAsBytes(await _releaseTarball(work, windows: windows)); + return f; + } + + Future 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())); + await expectUntouched(root); + }); + } + + test('stages next to the install, never in the system temp', () async { + final root = await _fakeRoot(work); + final seen = []; + 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())); + 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)>[]; + 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 = []; + 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'); + }); }); } diff --git a/tool/package_linux.sh b/tool/package_linux.sh index 572becb..a3af49d 100755 --- a/tool/package_linux.sh +++ b/tool/package_linux.sh @@ -3,7 +3,9 @@ # upload it to Discord. # Usage: tool/package_linux.sh [--skip-build] [--noupload|--no-upload] # Output: dist/watch-together--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). # 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, @@ -43,6 +45,7 @@ TARBALL="$DIST/$FOLDER-linux-$ARCH.tar.gz" rm -rf "$STAGE" mkdir -p "$STAGE/$FOLDER" "$DIST" 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" rm -rf "$STAGE" ls -la "$TARBALL"