One updater for app and server: updater in core; watch-together-server --upgrade / --check-update; update.sh uses the server
The server's version is stamped like the app's (APP_VERSION) when packaged, so it can compare against the manifest; --upgrade swaps the whole install folder (app and server) and asks for a restart. update.sh/.bat no longer need GTK. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
059b7dd306
commit
2d39018a73
27 files changed
+382
-171
No files matched your search
@@ -13,7 +13,7 @@ import 'src/platform/desktop_entry.dart';
|
||||
import 'src/screens/home/ui.dart';
|
||||
import 'src/state/session_state.dart';
|
||||
import 'src/state/settings.dart';
|
||||
import 'src/update/updater.dart';
|
||||
import 'src/update/update_controller.dart' show launchArgs;
|
||||
|
||||
Future<void> main(List<String> args) async {
|
||||
launchArgs = List.unmodifiable(args);
|
||||
|
||||
@@ -8,7 +8,6 @@ import 'package:hive_ce/hive.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../screens/home/picked_media.dart';
|
||||
import '../update/updater.dart' show appVersion;
|
||||
|
||||
// Linked Jellyfin servers, one account per ServerId, in their own Hive box.
|
||||
//
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../update/updater.dart' show findInstallRoot;
|
||||
import 'package:sync_relay_core/core.dart' show findInstallRoot;
|
||||
|
||||
/// The app's id: GTK's application id, so the Wayland `app_id` the
|
||||
/// compositor matches to [desktopFileName] for the window's icon.
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'dart:ui' show Color;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../update/updater.dart' show appVersion;
|
||||
|
||||
// AniList airing schedule (public GraphQL, no login): every episode airing
|
||||
// in a time range, with covers, banners and popularity; and a title search
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'dart:io';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../update/updater.dart' show appVersion;
|
||||
|
||||
// AnimeFlow app API: what the media server tracks, has, downloads and was
|
||||
// asked for. Authenticated with the user's own Jellyfin access token
|
||||
|
||||
@@ -2,9 +2,9 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../../update/update_controller.dart';
|
||||
import '../../update/updater.dart';
|
||||
import 'settings_page.dart';
|
||||
import 'ui.dart';
|
||||
import 'update_indicator.dart';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@echo off
|
||||
rem Update this install in place (same as clicking Update in the app).
|
||||
"%~dp0watch_together.exe" --upgrade %*
|
||||
pause
|
||||
@echo off
|
||||
rem Update this install in place (same as clicking Update in the app).
|
||||
"%~dp0watch-together-server.exe" --upgrade %*
|
||||
pause
|
||||
@@ -1,3 +1,4 @@
|
||||
#!/bin/sh
|
||||
# Update this install in place (same as clicking Update in the app).
|
||||
cd "$(dirname "$0")" && exec ./watch_together --upgrade "$@"
|
||||
# The server binary runs the updater: it needs no GTK/display (VPS).
|
||||
cd "$(dirname "$0")" && exec ./watch-together-server --upgrade "$@"
|
||||
@@ -4,11 +4,9 @@ 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
|
||||
// `--upgrade` (core's updater, 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');
|
||||
@@ -27,6 +25,19 @@ class UpdateStatus {
|
||||
{this.manifest, this.progress, this.error, this.canInstall = false});
|
||||
}
|
||||
|
||||
// Process args as main() received them — restart relaunches with these.
|
||||
List<String> launchArgs = const [];
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
typedef RestartFn = Future<void> Function(String executable, List<String> args);
|
||||
|
||||
// Release Hive's box locks before the new instance opens the same boxes.
|
||||
|
||||
@@ -1,580 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:archive/archive.dart';
|
||||
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). 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: 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). CI runs that against a live exe on NTFS.
|
||||
|
||||
final _log = createLogger('update');
|
||||
|
||||
const updateManifestUrl =
|
||||
'https://git.senkochan.moe/Foxyz/sync_replay_project/raw/branch/main/updates/manifest.json';
|
||||
|
||||
const appVersion = String.fromEnvironment('APP_VERSION',
|
||||
defaultValue: '0.0.0-dev');
|
||||
|
||||
// Process args as main() received them — restart relaunches with these.
|
||||
List<String> launchArgs = const [];
|
||||
|
||||
// Key into the manifest's `assets` map, e.g. linux-x64, windows-x64.
|
||||
final updatePlatform = '${Platform.operatingSystem}-x64';
|
||||
|
||||
const _stagingDir = '.update-staging';
|
||||
const _backupDir = '.update-backup';
|
||||
const _downloadFile = '.update-download';
|
||||
|
||||
class UpdateManifest {
|
||||
final String version;
|
||||
final int protocol;
|
||||
final String url;
|
||||
final String? sha256;
|
||||
final String notes;
|
||||
const UpdateManifest(
|
||||
{required this.version,
|
||||
required this.protocol,
|
||||
required this.url,
|
||||
this.sha256,
|
||||
required this.notes});
|
||||
|
||||
// `assets` maps platform → archive URL. Top-level `url` is the Linux
|
||||
// tarball, kept for builds from before `assets` existed (<= 0.2.1).
|
||||
factory UpdateManifest.fromJson(Map<String, Object?> json,
|
||||
{String? platform}) {
|
||||
final version = json['version'];
|
||||
final protocol = json['protocol'];
|
||||
final assets = json['assets'];
|
||||
final url =
|
||||
assets is Map ? assets[platform ?? updatePlatform] : json['url'];
|
||||
if (version is! String ||
|
||||
version.isEmpty ||
|
||||
protocol is! int ||
|
||||
url is! String ||
|
||||
url.isEmpty) {
|
||||
throw FormatException(assets is Map && version is String
|
||||
? 'release $version has no ${platform ?? updatePlatform} build'
|
||||
: 'update manifest missing version/protocol/url');
|
||||
}
|
||||
final sha = json['sha256'];
|
||||
return UpdateManifest(
|
||||
version: version,
|
||||
protocol: protocol,
|
||||
url: url,
|
||||
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<int> parseVersion(String v) {
|
||||
final core = v.split('+').first.split('-').first;
|
||||
final out = <int>[];
|
||||
for (final part in core.split('.')) {
|
||||
final m = RegExp(r'\d+').firstMatch(part);
|
||||
if (m == null) break;
|
||||
out.add(int.parse(m.group(0)!));
|
||||
if (out.length == 3) break;
|
||||
}
|
||||
while (out.length < 3) {
|
||||
out.add(0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 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 (ca[i] != cb[i]) return ca[i].compareTo(cb[i]);
|
||||
}
|
||||
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(
|
||||
{HttpClient? http, String url = updateManifestUrl}) async {
|
||||
final client = http ?? HttpClient();
|
||||
try {
|
||||
final req = await client
|
||||
.getUrl(Uri.parse(url))
|
||||
.timeout(const Duration(seconds: 15));
|
||||
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}');
|
||||
}
|
||||
final decoded = jsonDecode(body);
|
||||
if (decoded is! Map<String, Object?>) {
|
||||
throw const FormatException('manifest is not a JSON object');
|
||||
}
|
||||
return UpdateManifest.fromJson(decoded);
|
||||
} finally {
|
||||
if (http == null) client.close();
|
||||
}
|
||||
}
|
||||
|
||||
typedef DownloadProgress = void Function(int received, int? total);
|
||||
|
||||
Future<void> 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().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 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 {
|
||||
if (http == null) client.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> verifySha256(File file, String want) async {
|
||||
final digest = await sha256.bind(file.openRead()).first;
|
||||
if (digest.toString() != want.trim().toLowerCase()) {
|
||||
throw const FormatException('sha256 mismatch — download is corrupt');
|
||||
}
|
||||
}
|
||||
|
||||
// Release bundle layout is <root>/<binary> with <root>/data and <root>/lib
|
||||
// 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;
|
||||
return _isBundle(dir.path, windows ?? Platform.isWindows) ? dir : null;
|
||||
}
|
||||
|
||||
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<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 {
|
||||
await e.delete();
|
||||
} catch (err) {
|
||||
_log.fine('still in use, retry next start: ${e.path} ($err)');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// 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,
|
||||
// 2 not a packaged install (update manually).
|
||||
Future<int> runUpgrade(
|
||||
{String currentVersion = appVersion,
|
||||
String manifestUrl = updateManifestUrl,
|
||||
HttpClient? http,
|
||||
String? executablePath,
|
||||
Directory? installRoot}) async {
|
||||
stdout.writeln('watch-together $currentVersion (proto ${RelayConstants.protoVersion})');
|
||||
late final UpdateManifest manifest;
|
||||
try {
|
||||
manifest = await fetchManifest(http: http, url: manifestUrl);
|
||||
} catch (e) {
|
||||
stdout.writeln('update check failed: $e');
|
||||
return 1;
|
||||
}
|
||||
if (!isNewerVersion(currentVersion, manifest.version)) {
|
||||
stdout.writeln('already up to date (${manifest.version}).');
|
||||
return 0;
|
||||
}
|
||||
stdout.writeln('update available: ${manifest.version} (proto ${manifest.protocol})');
|
||||
if (manifest.notes.isNotEmpty) {
|
||||
stdout.writeln(manifest.notes);
|
||||
}
|
||||
if (manifest.isRequired) {
|
||||
stdout.writeln(requiredUpdateNote(manifest));
|
||||
}
|
||||
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;
|
||||
}
|
||||
try {
|
||||
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;
|
||||
}
|
||||
stdout.writeln('updated to ${manifest.version} — restart the app to use it.');
|
||||
return 0;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sync_relay_core/core.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
|
||||
|
||||
@@ -10,7 +10,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
archive:
|
||||
dependency: "direct main"
|
||||
dependency: transitive
|
||||
description:
|
||||
name: archive
|
||||
sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19
|
||||
|
||||
@@ -23,7 +23,6 @@ dependencies:
|
||||
hive_ce_flutter: ^2.3.4
|
||||
image: ^4.9.2
|
||||
window_manager: ^0.5.2
|
||||
archive: ^4.0.7
|
||||
crypto: ^3.0.6
|
||||
# Drag & drop and clipboard files/images (vendored desktop_drop + pasteboard).
|
||||
fox_utils:
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
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';
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
// Linux-layout tests check exec bits and symlinks, which NTFS lacks.
|
||||
final _posixOnly = Platform.isWindows ? 'POSIX install layout' : null;
|
||||
|
||||
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>`. [zip]: like
|
||||
// tool/package_windows.ps1 (`tar -a -c -f out.zip`, bsdtar — Windows only).
|
||||
Future<List<int>> _releaseTarball(Directory work,
|
||||
{String version = '0.2.0',
|
||||
bool windows = false,
|
||||
bool bundle = true,
|
||||
bool zip = false}) 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');
|
||||
if (!Platform.isWindows) {
|
||||
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.${zip ? 'zip' : 'tar.gz'}');
|
||||
final r = await Process.run('tar', [
|
||||
...zip ? ['-a', '-c', '-f'] : ['-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');
|
||||
if (!Platform.isWindows) {
|
||||
await Process.run('chmod', ['755', '${root.path}/watch_together']);
|
||||
}
|
||||
}
|
||||
await File('${root.path}/user-notes.txt').writeAsString('mine');
|
||||
return root;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// The updater itself is tested in packages/core/test/updater_test.dart
|
||||
// (fixtures above mirror it); this covers the app's UpdateController.
|
||||
void main() {
|
||||
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']);
|
||||
}, skip: _posixOnly);
|
||||
|
||||
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');
|
||||
}, skip: _posixOnly);
|
||||
});
|
||||
}
|
||||
@@ -1,561 +0,0 @@
|
||||
import 'dart:convert';
|
||||
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<Directory> _work() async {
|
||||
final base = Directory(_tmpBase);
|
||||
await base.create(recursive: true);
|
||||
return base.createTemp('wt-up-test');
|
||||
}
|
||||
|
||||
// Linux-layout tests check exec bits and symlinks, which NTFS lacks.
|
||||
final _posixOnly = Platform.isWindows ? 'POSIX install layout' : null;
|
||||
|
||||
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>`. [zip]: like
|
||||
// tool/package_windows.ps1 (`tar -a -c -f out.zip`, bsdtar — Windows only).
|
||||
Future<List<int>> _releaseTarball(Directory work,
|
||||
{String version = '0.2.0',
|
||||
bool windows = false,
|
||||
bool bundle = true,
|
||||
bool zip = false}) 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');
|
||||
if (!Platform.isWindows) {
|
||||
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.${zip ? 'zip' : 'tar.gz'}');
|
||||
final r = await Process.run('tar', [
|
||||
...zip ? ['-a', '-c', '-f'] : ['-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');
|
||||
if (!Platform.isWindows) {
|
||||
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() {
|
||||
test('version compare', () {
|
||||
expect(isNewerVersion('0.1.0', '0.1.1'), isTrue);
|
||||
expect(isNewerVersion('0.1.0', '0.2.0'), isTrue);
|
||||
expect(isNewerVersion('0.1.0', '1.0.0'), isTrue);
|
||||
expect(isNewerVersion('0.0.0-dev', '0.1.0'), isTrue);
|
||||
expect(isNewerVersion('0.1.0', '0.1.0'), 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', () {
|
||||
expect(() => UpdateManifest.fromJson({}), throwsFormatException);
|
||||
expect(
|
||||
() => UpdateManifest.fromJson({
|
||||
'version': '0.2.0',
|
||||
'protocol': 4,
|
||||
}),
|
||||
throwsFormatException);
|
||||
final noSha = UpdateManifest.fromJson({
|
||||
'version': '0.2.0',
|
||||
'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('manifest picks the asset for this platform', () {
|
||||
final json = {
|
||||
'version': '0.2.2',
|
||||
'protocol': RelayConstants.protoVersion,
|
||||
'url': 'https://x/linux.tar.gz',
|
||||
'assets': {
|
||||
'linux-x64': 'https://x/linux.tar.gz',
|
||||
'windows-x64': 'https://x/windows.zip',
|
||||
},
|
||||
};
|
||||
expect(UpdateManifest.fromJson(json, platform: 'windows-x64').url,
|
||||
'https://x/windows.zip');
|
||||
expect(UpdateManifest.fromJson(json, platform: 'linux-x64').url,
|
||||
'https://x/linux.tar.gz');
|
||||
expect(() => UpdateManifest.fromJson(json, platform: 'macos-x64'),
|
||||
throwsFormatException);
|
||||
});
|
||||
|
||||
test('findInstallRoot honors bundle layout', () async {
|
||||
final work = await _work();
|
||||
try {
|
||||
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 work.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
group('upgrade flow', () {
|
||||
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('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: server.manifestUrl,
|
||||
installRoot: root);
|
||||
expect(rc, 0);
|
||||
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);
|
||||
}, skip: _posixOnly);
|
||||
|
||||
test('up to date downloads nothing', () async {
|
||||
final root = await _fakeRoot(work);
|
||||
final rc = await runUpgrade(
|
||||
currentVersion: '0.2.0',
|
||||
manifestUrl: server.manifestUrl,
|
||||
installRoot: root);
|
||||
expect(rc, 0);
|
||||
expect(server.tarHits, 0);
|
||||
expect(await File('${root.path}/watch_together').readAsString(),
|
||||
'binary-v1');
|
||||
});
|
||||
|
||||
test('bad hash aborts, install untouched', () async {
|
||||
server.manifestExtra = {'sha256': '0' * 64};
|
||||
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(_leftovers(root), isEmpty);
|
||||
});
|
||||
|
||||
test('manifest without sha256 still applies', () async {
|
||||
server.manifestExtra = {'sha256': null};
|
||||
final root = await _fakeRoot(work);
|
||||
final rc = await runUpgrade(
|
||||
currentVersion: '0.1.0',
|
||||
manifestUrl: server.manifestUrl,
|
||||
installRoot: root);
|
||||
expect(rc, 0);
|
||||
expect(await File('${root.path}/watch_together').readAsString(),
|
||||
'binary-v2');
|
||||
}, skip: _posixOnly);
|
||||
|
||||
test('unknown layout asks for manual update', () async {
|
||||
final rc = await runUpgrade(
|
||||
currentVersion: '0.1.0',
|
||||
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<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);
|
||||
}, skip: _posixOnly);
|
||||
}
|
||||
|
||||
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);
|
||||
}, skip: _posixOnly);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
// The real thing: the release zip (bsdtar, like package_windows.ps1)
|
||||
// applied over an install whose exe is running. Windows refuses to
|
||||
// delete a running image but allows renaming it, so it is parked as
|
||||
// .old and swept on the next start.
|
||||
test('windows: zip over a running exe', () async {
|
||||
final root = await _fakeRoot(work, windows: true);
|
||||
final exe = '${root.path}/watch_together.exe';
|
||||
await File(r'C:\Windows\System32\PING.EXE').copy(exe);
|
||||
final zip = File('${work.path}/u.zip')
|
||||
..writeAsBytesSync(
|
||||
await _releaseTarball(work, windows: true, zip: true));
|
||||
final running = await Process.start(exe, ['-n', '60', '127.0.0.1']);
|
||||
try {
|
||||
expect(() => File(exe).deleteSync(), throwsA(isA<FileSystemException>()),
|
||||
reason: 'precondition: a running exe is locked');
|
||||
await applyUpdate(zip, root, windows: true);
|
||||
expect(await File(exe).readAsString(), 'binary-v2');
|
||||
expect(File('$exe.old').existsSync(), isTrue);
|
||||
expect(
|
||||
await File('${root.path}/data/flutter_assets/fresh.cfg')
|
||||
.readAsString(),
|
||||
'fresh-config');
|
||||
expect(_leftovers(root), isEmpty);
|
||||
} finally {
|
||||
running.kill();
|
||||
await running.exitCode;
|
||||
}
|
||||
await cleanupUpdateLeftovers(root);
|
||||
expect(File('$exe.old').existsSync(), isFalse);
|
||||
}, skip: Platform.isWindows ? null : 'needs Windows');
|
||||
});
|
||||
|
||||
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']);
|
||||
}, skip: _posixOnly);
|
||||
|
||||
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');
|
||||
}, skip: _posixOnly);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user