diff --git a/apps/desktop/lib/src/update/updater.dart b/apps/desktop/lib/src/update/updater.dart index 397f40c..27e20fd 100644 --- a/apps/desktop/lib/src/update/updater.dart +++ b/apps/desktop/lib/src/update/updater.dart @@ -15,10 +15,10 @@ import 'package:sync_relay_core/core.dart'; // 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). +// 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'); @@ -31,6 +31,9 @@ const appVersion = String.fromEnvironment('APP_VERSION', // Process args as main() received them — restart relaunches with these. List 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'; @@ -48,16 +51,23 @@ class UpdateManifest { this.sha256, required this.notes}); - factory UpdateManifest.fromJson(Map json) { + // `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 json, + {String? platform}) { final version = json['version']; final protocol = json['protocol']; - final url = json['url']; + 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 const FormatException('update manifest missing version/protocol/url'); + 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( @@ -434,7 +444,7 @@ Future _swapPosix(Directory stage, Directory root, ApplyHook? hook) async await _deleteQuietly(backup.path); } -// UNTESTED on Windows. File-level: a running exe/dll can be renamed but +// 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 { diff --git a/apps/desktop/pubspec.lock b/apps/desktop/pubspec.lock index f034c5b..d85d3e3 100644 --- a/apps/desktop/pubspec.lock +++ b/apps/desktop/pubspec.lock @@ -723,14 +723,14 @@ packages: path: "../../packages/core" relative: true source: path - version: "0.1.0" + version: "0.2.1" sync_relay_server: dependency: "direct main" description: path: "../../packages/server" relative: true source: path - version: "0.1.0" + version: "0.2.1" synchronized: dependency: transitive description: diff --git a/apps/desktop/test/updater_test.dart b/apps/desktop/test/updater_test.dart index 30a8514..e88da1f 100644 --- a/apps/desktop/test/updater_test.dart +++ b/apps/desktop/test/updater_test.dart @@ -20,13 +20,20 @@ Future _work() async { 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 /watch-together-/, then -// `tar -czf out -C watch-together-`. +// `tar -czf out -C watch-together-`. [zip]: like +// tool/package_windows.ps1 (`tar -a -c -f out.zip`, bsdtar — Windows only). Future> _releaseTarball(Directory work, - {String version = '0.2.0', bool windows = false, bool bundle = true}) async { + {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); @@ -44,13 +51,15 @@ Future> _releaseTarball(Directory work, } 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); + 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.tar.gz'); + final out = File('${work.path}/out-$version.${zip ? 'zip' : 'tar.gz'}'); final r = await Process.run('tar', [ - '-czf', + ...zip ? ['-a', '-c', '-f'] : ['-czf'], out.path, '-C', stage.path, @@ -71,7 +80,9 @@ Future _fakeRoot(Directory work, {bool windows = false}) async { 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']); + if (!Platform.isWindows) { + await Process.run('chmod', ['755', '${root.path}/watch_together']); + } } await File('${root.path}/user-notes.txt').writeAsString('mine'); return root; @@ -166,6 +177,24 @@ void main() { 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 { @@ -219,7 +248,7 @@ void main() { 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); @@ -256,7 +285,7 @@ void main() { 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( @@ -323,7 +352,7 @@ void main() { }), throwsA(isA())); await expectUntouched(root); - }); + }, skip: _posixOnly); } test('stages next to the install, never in the system temp', () async { @@ -354,7 +383,7 @@ void main() { 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); @@ -399,6 +428,37 @@ void main() { 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()), + 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', () { @@ -443,7 +503,7 @@ void main() { 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 { @@ -496,6 +556,6 @@ void main() { expect(restarted, isFalse); expect(await File('${root.path}/watch_together').readAsString(), 'binary-v1'); - }); + }, skip: _posixOnly); }); } diff --git a/tool/release_publish.sh b/tool/release_publish.sh deleted file mode 100755 index ede5fc4..0000000 --- a/tool/release_publish.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env bash -# Publish a release to Gitea: create the release and upload the Linux -# tarball built by tool/package_linux.sh. The manifest is written by -# tool/release.sh at release time (predictable asset URL), so this -# script never touches git — no push-back, no write-token problem -# beyond the release API itself. -# Usage: tool/release_publish.sh vX.Y.Z (run after package_linux.sh) -# Env: RELEASE_TOKEN (preferred) or the Actions-provided GITHUB_TOKEN / -# GITEA_TOKEN. -set -euo pipefail - -TAG="${1:?usage: tool/release_publish.sh vX.Y.Z}" -VER="${TAG#v}" -[[ "$VER" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "error: tag must look like v0.2.0, got $TAG" >&2; exit 1; } - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -TARBALL="$ROOT/dist/watch-together-$VER-linux-x64.tar.gz" -[[ -f "$TARBALL" ]] || { echo "error: run tool/package_linux.sh first ($TARBALL missing)" >&2; exit 1; } - -PUB="$(sed -n 's/^version: \([0-9.]*\).*/\1/p' "$ROOT/apps/desktop/pubspec.yaml")" -[[ "$PUB" == "$VER" ]] || { echo "error: tag $TAG != desktop pubspec $PUB (run tool/release.sh first)" >&2; exit 1; } - -ORIGIN="$(git -C "$ROOT" remote get-url origin)" -SERVER="$(echo "$ORIGIN" | sed -E 's#(https?://[^/]+)/.*#\1#')" -REPO="$(echo "${ORIGIN%.git}" | sed -E 's#.*[:/]([^/]+/[^/]+)$#\1#')" -TOKEN="${RELEASE_TOKEN:-${GITHUB_TOKEN:-${GITEA_TOKEN:-}}}" -[[ -n "$TOKEN" ]] || { echo "error: set RELEASE_TOKEN (or run in Actions)" >&2; exit 1; } - -ASSET="$(basename "$TARBALL")" -PREV="$(git -C "$ROOT" describe --tags --abbrev=0 HEAD 2>/dev/null || true)" -if [[ -n "$PREV" && "$PREV" != "$TAG" ]]; then - NOTES="$(git -C "$ROOT" log "$PREV..HEAD" --format='- %s' | head -n 20)" -else - NOTES="Watch together $VER." -fi -export REL_TAG="$TAG" REL_VER="$VER" REL_NOTES="$NOTES" - -API="$SERVER/api/v1/repos/$REPO" -# Re-runs reuse the release for this tag instead of failing on a duplicate. -REL_ID="$(curl -fsS -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" 2>/dev/null \ - | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' 2>/dev/null || true)" -if [[ -z "$REL_ID" ]]; then - echo "creating release $TAG on $REPO ..." - REL_ID="$(curl -fsS -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \ - -d "$(python3 -c 'import json,os; print(json.dumps({"tag_name": os.environ["REL_TAG"], "name": os.environ["REL_TAG"], "body": "Watch together " + os.environ["REL_VER"] + "\n\n" + os.environ["REL_NOTES"], "draft": False, "prerelease": False}))')" \ - "$API/releases" \ - | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')" -fi -echo "release id: $REL_ID" - -echo "uploading $ASSET ..." -# Gitea's asset API takes a multipart form with an `attachment` field. -curl -fsS -H "Authorization: token $TOKEN" \ - -F "attachment=@$TARBALL;type=application/gzip" \ - "$API/releases/$REL_ID/assets?name=$ASSET" > /dev/null -echo "published $TAG."