diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index d4bbc2c..f256090 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -55,5 +55,8 @@ jobs: run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/setup_flutter_windows.ps1 - name: Updater tests shell: powershell - working-directory: apps/desktop - run: flutter test test/updater_test.dart + working-directory: packages/core + run: | + dart pub get + if ($LASTEXITCODE) { exit 1 } + dart test test/updater_test.dart diff --git a/README.md b/README.md index b764801..521c508 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,10 @@ Update from the app: when a newer version exists, an update indicator appears in the strip at the top of the window and opens the update card. "Update and restart" downloads, verifies, applies in place and restarts; the install folder must be writable by you. Settings has Check now and an auto-check toggle. From a terminal: -`watch_together --upgrade`, or the shipped `update.sh` / `update.bat`. A +`./watch-together-server --upgrade` (no display needed, so it works on a +server box too), `watch_together --upgrade`, or the shipped `update.sh` / +`update.bat` (which run the first). Each updates the whole folder, app and +server; restart whichever is running to use the new version. A release with a newer protocol is a required update: everyone on a server must run the same protocol version, and a mismatch is refused with a message telling you to update. @@ -170,6 +173,8 @@ then everyone (you included) uses Join. | `--yt-dlp-dir` | next to the binary | writable folder for the server's yt-dlp (systemd: the unit's `StateDirectory`) | | `--yt-dlp-cookies` | none | cookies.txt or cookie-extension JSON export for that yt-dlp (sites that refuse guests); copied into the yt-dlp folder at start, removed from it when not given. **An account login**: keep it private, use a throwaway account for YouTube | | `--config` | none | YAML or JSON file with the same settings (`port`, `secret`, `name`, `mediaPath`, `advertiseHost`, `ytDlp`, `ytDlpDir`, `ytDlpCookies`); replaces the flags | +| `--upgrade` | | update the install folder to the latest release ([Install and update](#install-and-update)), then exit | +| `--check-update` | | print whether a newer release exists, then exit | It prints `READY port=... name=...` once listening and stops cleanly on Ctrl+C or SIGTERM. A dedicated server has no host. diff --git a/apps/desktop/lib/main.dart b/apps/desktop/lib/main.dart index 61f28ac..32e4bc5 100644 --- a/apps/desktop/lib/main.dart +++ b/apps/desktop/lib/main.dart @@ -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 main(List args) async { launchArgs = List.unmodifiable(args); diff --git a/apps/desktop/lib/src/jellyfin/accounts.dart b/apps/desktop/lib/src/jellyfin/accounts.dart index 809799c..4f4615a 100644 --- a/apps/desktop/lib/src/jellyfin/accounts.dart +++ b/apps/desktop/lib/src/jellyfin/accounts.dart @@ -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. // diff --git a/apps/desktop/lib/src/platform/desktop_entry.dart b/apps/desktop/lib/src/platform/desktop_entry.dart index 6b49c35..e1a3365 100644 --- a/apps/desktop/lib/src/platform/desktop_entry.dart +++ b/apps/desktop/lib/src/platform/desktop_entry.dart @@ -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. diff --git a/apps/desktop/lib/src/schedule/anilist.dart b/apps/desktop/lib/src/schedule/anilist.dart index 247d09c..112e0ae 100644 --- a/apps/desktop/lib/src/schedule/anilist.dart +++ b/apps/desktop/lib/src/schedule/anilist.dart @@ -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 diff --git a/apps/desktop/lib/src/schedule/animeflow.dart b/apps/desktop/lib/src/schedule/animeflow.dart index 71483fb..a9d6def 100644 --- a/apps/desktop/lib/src/schedule/animeflow.dart +++ b/apps/desktop/lib/src/schedule/animeflow.dart @@ -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 diff --git a/apps/desktop/lib/src/screens/home/about_page.dart b/apps/desktop/lib/src/screens/home/about_page.dart index 03394e3..1191fc5 100644 --- a/apps/desktop/lib/src/screens/home/about_page.dart +++ b/apps/desktop/lib/src/screens/home/about_page.dart @@ -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'; diff --git a/apps/desktop/lib/src/update/bundle/update.bat b/apps/desktop/lib/src/update/bundle/update.bat index 2083958..2430180 100644 --- a/apps/desktop/lib/src/update/bundle/update.bat +++ b/apps/desktop/lib/src/update/bundle/update.bat @@ -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 diff --git a/apps/desktop/lib/src/update/bundle/update.sh b/apps/desktop/lib/src/update/bundle/update.sh index 0ce1dab..eddaeb0 100755 --- a/apps/desktop/lib/src/update/bundle/update.sh +++ b/apps/desktop/lib/src/update/bundle/update.sh @@ -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 "$@" diff --git a/apps/desktop/lib/src/update/update_controller.dart b/apps/desktop/lib/src/update/update_controller.dart index b03ad11..a94548e 100644 --- a/apps/desktop/lib/src/update/update_controller.dart +++ b/apps/desktop/lib/src/update/update_controller.dart @@ -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 launchArgs = const []; + +// 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); +} + typedef RestartFn = Future Function(String executable, List args); // Release Hive's box locks before the new instance opens the same boxes. diff --git a/apps/desktop/lib/src/widgets/update_banner.dart b/apps/desktop/lib/src/widgets/update_banner.dart index 032a1a4..d8ccc11 100644 --- a/apps/desktop/lib/src/widgets/update_banner.dart +++ b/apps/desktop/lib/src/widgets/update_banner.dart @@ -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 diff --git a/apps/desktop/pubspec.lock b/apps/desktop/pubspec.lock index 3ef066b..f386deb 100644 --- a/apps/desktop/pubspec.lock +++ b/apps/desktop/pubspec.lock @@ -10,7 +10,7 @@ packages: source: hosted version: "1.1.1" archive: - dependency: "direct main" + dependency: transitive description: name: archive sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19 diff --git a/apps/desktop/pubspec.yaml b/apps/desktop/pubspec.yaml index 51580b9..fc265c9 100644 --- a/apps/desktop/pubspec.yaml +++ b/apps/desktop/pubspec.yaml @@ -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: diff --git a/apps/desktop/test/update_controller_test.dart b/apps/desktop/test/update_controller_test.dart new file mode 100644 index 0000000..837ea56 --- /dev/null +++ b/apps/desktop/test/update_controller_test.dart @@ -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 _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 /watch-together-/, then +// `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, + 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 _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 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); +} + +// 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)>[]; + 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']); + }, 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); + }); +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 88faa83..8f6bd11 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -7,9 +7,9 @@ Plain overview first: [HOW-IT-WORKS.md](HOW-IT-WORKS.md). Wire format: | Path | What | Rule | |---|---|---| -| `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging, Jellyfin client | pure Dart, no Flutter | +| `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging, Jellyfin client, updater | pure Dart, no Flutter | | `packages/server` | Relic HTTP + WebSocket server (`SyncServer`, `ServerHub`, `Session`, media endpoint, relay buffer, Jellyfin relay), the `serve()` entrypoint, `bin/server.dart` (dedicated server) | pure Dart, no Flutter | -| `apps/desktop` | Flutter app: UI, `SyncClient`, `SessionCoordinator`, `PlaybackController` (media_kit/mpv), `ServerIsolate`, updater | ships with `watch-together-server` | +| `apps/desktop` | Flutter app: UI, `SyncClient`, `SessionCoordinator`, `PlaybackController` (media_kit/mpv), `ServerIsolate`, update UI (`UpdateController`) | ships with `watch-together-server` | | `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only | | `deploy` | systemd unit for the dedicated server | | @@ -60,7 +60,8 @@ size (`ServerIsolate.setFile`): the host's in-session File swap then sends a - **Dedicated server**: `bin/server.dart` compiled with `dart compile exe` and shipped next to the app as `watch-together-server`. It parses flags (`--port`, `--secret`, `--name`, `--media`, `--advertise-host`, - `--jellyfin `, `--[no-]yt-dlp`, `--yt-dlp-dir`, `--yt-dlp-cookies`, `--config`; default port 8080) into the same map and calls `serve`; + `--jellyfin `, `--[no-]yt-dlp`, `--yt-dlp-dir`, `--yt-dlp-cookies`, `--config`; default port 8080) into the same map and calls `serve` + (`--upgrade` / `--check-update` run the [updater](#updater) instead); SIGINT and SIGTERM stop it. A Flutter binary cannot run without a display, which is why the server is its own build. @@ -1056,23 +1057,31 @@ changed. The runner also sets the window icon from the bundle (X11). ## Updater -`apps/desktop/lib/src/update/updater.dart` (no Flutter imports, unit tested). -Reads a manifest (`version`, `protocol`, `url`, optional `sha256`, `notes`) +One implementation, `packages/core/lib/src/updater.dart` (pure Dart, tested +in `packages/core/test/updater_test.dart`), with two front ends: the app and +the dedicated server. Both binaries ship in the same archive, so either one +updates the whole install folder. Reads a manifest (`version`, `protocol`, `url`, optional `sha256`, `notes`) from `updateManifestUrl`, compares versions, downloads the tarball, checks sha256 when present, then extracts into a staging dir inside the install folder (same filesystem, so moves are renames; the folder must be user-writable). Each top-level entry is moved aside and the new one moved in, with rollback on failure (writing the running executable fails with ETXTBSY; rename works). A packaged install is detected by `data/` and `lib/` next to -the binary; anything else exits with "update manually". A manifest whose +the binary (app or server, same folder); anything else exits with "update +manually". A manifest whose protocol is newer than the local one is a required update. Windows apply renames in-use files to `*.old` (swept on next start); CI tests it against a running exe on NTFS. The manifest's `assets` maps `linux-x64` / `windows-x64` to archive URLs; top-level `url` (Linux) is for builds <= 0.2.1. The version is -stamped with `--dart-define=APP_VERSION` (dev runs report `0.0.0-dev` and skip -checks). `watch_together --upgrade` (or the shipped `update.sh`) runs it from -the CLI; `UpdateController` runs the same code for the strip's update indicator, -which opens the update card, and restarts the app ("Update and restart"). +stamped into both binaries with `APP_VERSION` (`--dart-define` for the app, +`--define` for `dart compile exe`; dev runs report `0.0.0-dev` and the app +skips checks). CLI: `watch-together-server --upgrade` (no display needed; +the shipped `update.sh` / `update.bat` run it), `--check-update` prints only; +it ends with "restart the server to run ". `watch_together --upgrade` +does the same from the app binary. In the app, `UpdateController` +(`apps/desktop/lib/src/update/`) runs the same code for the strip's update +indicator, which opens the update card, and restarts the app ("Update and +restart", closing Hive first). Settings has Check now and an auto-check toggle. The manifest is `updates/manifest.json` in this repo, written by `tool/release.sh`. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 067a36c..64127ff 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -17,11 +17,11 @@ cd apps/desktop && flutter pub get && flutter run -d linux cd apps/desktop && flutter build linux --release --dart-define=APP_VERSION= tool/package_linux.sh [--skip-build] [--noupload] # dist/*.tar.gz powershell -File tool/package_windows.ps1 [-SkipBuild] # dist/*.zip (on Windows) -cd packages/server && dart compile exe bin/server.dart -o watch-together-server +cd packages/server && dart compile exe --define=APP_VERSION= bin/server.dart -o watch-together-server ``` -`APP_VERSION` defaults to `0.0.0-dev`; `package_linux.sh` stamps it from the -desktop pubspec. Setting `DISCORD_WEBHOOK_URL` makes the packager post the +`APP_VERSION` defaults to `0.0.0-dev`; the packagers stamp it from the +desktop pubspec into both the app and the server. Setting `DISCORD_WEBHOOK_URL` makes the packager post the tarball to Discord (never commit it). The packager always compiles the dedicated server (with the Flutter SDK's `dart`) into the tarball as `watch-together-server`, also with `--skip-build`. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a32ea03..05ab175 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -36,8 +36,7 @@ off by deleting it in the same commit as the code. Wire changes also edit filter on the Schedule; today's "Following" (AnimeFlow's monitored shows) becomes "On the server". -5. **Ops**: `watch-together-server --upgrade` (a VPS has no GTK for the - app's updater); check shaders on Windows; the relay e2e "source drops +5. **Ops**: check shaders on Windows; the relay e2e "source drops uncleanly and resumes" fails about half the time under the full suite. 6. **Windows: first real use.** CI builds and publishes diff --git a/packages/core/lib/core.dart b/packages/core/lib/core.dart index 572c04d..847b478 100644 --- a/packages/core/lib/core.dart +++ b/packages/core/lib/core.dart @@ -11,6 +11,7 @@ export 'src/yt_dlp.dart'; export 'src/cookies.dart'; export 'src/media_source.dart'; export 'src/sync_engine.dart'; +export 'src/updater.dart'; export 'src/jellyfin/client.dart'; export 'src/jellyfin/client_info.dart'; export 'src/jellyfin/http.dart'; diff --git a/apps/desktop/lib/src/update/updater.dart b/packages/core/lib/src/updater.dart similarity index 94% rename from apps/desktop/lib/src/update/updater.dart rename to packages/core/lib/src/updater.dart index 27e20fd..13bbe4a 100644 --- a/apps/desktop/lib/src/update/updater.dart +++ b/packages/core/lib/src/updater.dart @@ -3,12 +3,15 @@ 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. +import 'constants.dart'; +import 'logging.dart'; + +// The one updater for both programs: the app (`watch_together --upgrade` +// and the in-app update card, via UpdateController) and the dedicated +// server (`watch-together-server --upgrade`). Both ship in the same +// release archive, so either one swaps the whole install folder: 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). @@ -28,9 +31,6 @@ 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 []; - // Key into the manifest's `assets` map, e.g. linux-x64, windows-x64. final updatePlatform = '${Platform.operatingSystem}-x64'; @@ -518,29 +518,24 @@ Future downloadAndApply(UpdateManifest manifest, Directory root, } } -// 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). +// 2 not a packaged install (update manually). [program] names the front +// end in the first line; [restartNote] is the last line after an update. +// [checkOnly] reports what is available and changes nothing. Future runUpgrade( {String currentVersion = appVersion, String manifestUrl = updateManifestUrl, HttpClient? http, String? executablePath, - Directory? installRoot}) async { - stdout.writeln('watch-together $currentVersion (proto ${RelayConstants.protoVersion})'); + Directory? installRoot, + String program = 'watch-together', + String Function(String version)? restartNote, + bool checkOnly = false}) async { + stdout.writeln('$program $currentVersion (proto ${RelayConstants.protoVersion})'); late final UpdateManifest manifest; try { manifest = await fetchManifest(http: http, url: manifestUrl); @@ -559,6 +554,7 @@ Future runUpgrade( if (manifest.isRequired) { stdout.writeln(requiredUpdateNote(manifest)); } + if (checkOnly) return 0; final exe = executablePath ?? currentExecutablePath(); final root = installRoot ?? findInstallRoot(exe); if (root == null) { @@ -575,6 +571,7 @@ Future runUpgrade( stdout.writeln('update failed: $e'); return 1; } - stdout.writeln('updated to ${manifest.version} — restart the app to use it.'); + stdout.writeln(restartNote?.call(manifest.version) ?? + 'updated to ${manifest.version} — restart the app to use it.'); return 0; } diff --git a/packages/core/pubspec.lock b/packages/core/pubspec.lock index 176d789..da3f86a 100644 --- a/packages/core/pubspec.lock +++ b/packages/core/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "14.3.0" + archive: + dependency: "direct main" + description: + name: archive + sha256: "6c5bcd986e06b94e3c40244af471750840a3d2341d1f9763a1100a14add517b4" + url: "https://pub.dev" + source: hosted + version: "4.3.0" args: dependency: transitive description: @@ -81,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" file: dependency: transitive description: @@ -201,6 +217,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.3" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" pub_semver: dependency: transitive description: diff --git a/packages/core/pubspec.yaml b/packages/core/pubspec.yaml index 8911835..53d932b 100644 --- a/packages/core/pubspec.yaml +++ b/packages/core/pubspec.yaml @@ -7,6 +7,7 @@ dependencies: logging: ^1.2.0 yaml: ^3.1.0 path: ^1.9.0 + archive: ^4.0.7 crypto: ^3.0.0 web_socket_channel: ^3.0.0 dev_dependencies: diff --git a/apps/desktop/test/updater_test.dart b/packages/core/test/updater_test.dart similarity index 82% rename from apps/desktop/test/updater_test.dart rename to packages/core/test/updater_test.dart index e88da1f..cdd07df 100644 --- a/apps/desktop/test/updater_test.dart +++ b/packages/core/test/updater_test.dart @@ -3,10 +3,8 @@ 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'; +import 'package:test/test.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 @@ -460,102 +458,4 @@ void main() { 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)>[]; - 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']); - }, 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); - }); } diff --git a/packages/server/bin/server.dart b/packages/server/bin/server.dart index cc427a8..d85f34c 100644 --- a/packages/server/bin/server.dart +++ b/packages/server/bin/server.dart @@ -30,6 +30,11 @@ Future main(List args) async { help: 'cookies.txt or browser-extension JSON export to log that yt-dlp in (sites that refuse guests). ' 'An account login: keep it private. Copied into --yt-dlp-dir at start.') ..addOption('config', help: 'YAML or JSON config file (keys as in AppConfig); overrides the other flags.') + ..addFlag('upgrade', + negatable: false, + help: 'Update this install (the whole folder, app included) to the latest release, then exit. ' + 'Restart the server afterwards to run it.') + ..addFlag('check-update', negatable: false, help: 'Print whether a newer release exists, then exit.') ..addFlag('help', abbr: 'h', negatable: false, help: 'Show this help.'); final ArgResults parsed; try { @@ -41,7 +46,14 @@ Future main(List args) async { } if (parsed['help'] as bool) { // ignore: avoid_print - print('watch-together-server (proto ${RelayConstants.protoVersion})\n\n${parser.usage}'); + print('watch-together-server $appVersion (proto ${RelayConstants.protoVersion})\n\n${parser.usage}'); + return; + } + if (parsed['upgrade'] as bool || parsed['check-update'] as bool) { + exitCode = await runUpgrade( + program: 'watch-together-server', + checkOnly: !(parsed['upgrade'] as bool), + restartNote: (v) => 'updated to $v — restart the server to run $v.'); return; } diff --git a/packages/server/pubspec.lock b/packages/server/pubspec.lock index ce3a4e4..d623e52 100644 --- a/packages/server/pubspec.lock +++ b/packages/server/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "14.3.0" + archive: + dependency: transitive + description: + name: archive + sha256: "6c5bcd986e06b94e3c40244af471750840a3d2341d1f9763a1100a14add517b4" + url: "https://pub.dev" + source: hosted + version: "4.3.0" args: dependency: "direct main" description: @@ -81,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" file: dependency: transitive description: @@ -201,6 +217,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.3" + posix: + dependency: transitive + description: + name: posix + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e + url: "https://pub.dev" + source: hosted + version: "6.5.2" pub_semver: dependency: transitive description: @@ -319,7 +343,7 @@ packages: path: "../core" relative: true source: path - version: "0.1.0" + version: "1.0.1" term_glyph: dependency: transitive description: diff --git a/tool/package_linux.sh b/tool/package_linux.sh index 6418246..13d1334 100755 --- a/tool/package_linux.sh +++ b/tool/package_linux.sh @@ -4,10 +4,11 @@ # 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, plus -# update.sh (runs the binary with --upgrade) and watch-together-server (the -# dedicated server, `dart compile exe` of packages/server/bin/server.dart, -# built every run, also with --skip-build). The in-app updater strips -# that top-level folder — keep the layout in sync with updater_test.dart. +# update.sh (runs watch-together-server --upgrade) and watch-together-server +# (the dedicated server, `dart compile exe` of packages/server/bin/server.dart, +# built every run, also with --skip-build; both binaries are stamped with +# APP_VERSION from the app's pubspec). The updater strips +# that top-level folder — keep the layout in sync with packages/core/test/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, @@ -55,7 +56,7 @@ if FLUTTER="$(command -v flutter)"; then [[ -x "$CANDIDATE" ]] && DART="$CANDIDATE" fi (cd "$ROOT/packages/server" && "$DART" pub get >/dev/null && - "$DART" compile exe bin/server.dart -o "$STAGE/$FOLDER/watch-together-server") + "$DART" compile exe --define=APP_VERSION="$VERSION" bin/server.dart -o "$STAGE/$FOLDER/watch-together-server") tar -czf "$TARBALL" -C "$STAGE" "$FOLDER" rm -rf "$STAGE" ls -la "$TARBALL" diff --git a/tool/package_windows.ps1 b/tool/package_windows.ps1 index 872bbe7..7da051e 100644 --- a/tool/package_windows.ps1 +++ b/tool/package_windows.ps1 @@ -2,10 +2,11 @@ # Usage: powershell -File tool/package_windows.ps1 [-SkipBuild] # Output: dist/watch-together--windows-x64.zip containing one # top-level folder with watch_together.exe, its DLLs and data/ as built, -# plus update.bat (runs the exe with --upgrade) and +# plus update.bat (runs watch-together-server.exe --upgrade) and # watch-together-server.exe (dedicated server, `dart compile exe` of -# packages/server/bin/server.dart). Same layout as tool/package_linux.sh; -# the in-app updater strips the top-level folder. +# packages/server/bin/server.dart; both binaries stamped with APP_VERSION). +# Same layout as tool/package_linux.sh; the updater strips the top-level +# folder. # Needs: flutter on PATH, tar.exe (ships with Windows 10+, writes zip). param([switch]$SkipBuild) $ErrorActionPreference = 'Stop' @@ -45,7 +46,7 @@ Copy-Item "$App/lib/src/update/bundle/update.bat" "$Stage/$Folder/update.bat" Push-Location (Join-Path $Root 'packages/server') try { Invoke-Checked 'dart pub get' { dart pub get | Out-Null } - Invoke-Checked 'dart compile exe' { dart compile exe bin/server.dart -o "$Stage/$Folder/watch-together-server.exe" } + Invoke-Checked 'dart compile exe' { dart compile exe "--define=APP_VERSION=$Version" bin/server.dart -o "$Stage/$Folder/watch-together-server.exe" } } finally { Pop-Location } if (Test-Path $Zip) { Remove-Item -Force $Zip }