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:
foxyzandClaude Opus 5.5 committed 2026-10-09 23:53:52 +03:00
1 parent 059b7dd306
commit 2d39018a73
27 files changed
+382 -171

No files matched your search

+5 -2
View File
@@ -55,5 +55,8 @@ jobs:
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/setup_flutter_windows.ps1 run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/setup_flutter_windows.ps1
- name: Updater tests - name: Updater tests
shell: powershell shell: powershell
working-directory: apps/desktop working-directory: packages/core
run: flutter test test/updater_test.dart run: |
dart pub get
if ($LASTEXITCODE) { exit 1 }
dart test test/updater_test.dart
+6 -1
View File
@@ -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, 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 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: 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 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 must run the same protocol version, and a mismatch is refused with a message
telling you to update. 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-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 | | `--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 | | `--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 It prints `READY port=... name=...` once listening and stops cleanly on
Ctrl+C or SIGTERM. A dedicated server has no host. Ctrl+C or SIGTERM. A dedicated server has no host.
+1 -1
View File
@@ -13,7 +13,7 @@ import 'src/platform/desktop_entry.dart';
import 'src/screens/home/ui.dart'; import 'src/screens/home/ui.dart';
import 'src/state/session_state.dart'; import 'src/state/session_state.dart';
import 'src/state/settings.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 { Future<void> main(List<String> args) async {
launchArgs = List.unmodifiable(args); launchArgs = List.unmodifiable(args);
@@ -8,7 +8,6 @@ import 'package:hive_ce/hive.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../screens/home/picked_media.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. // Linked Jellyfin servers, one account per ServerId, in their own Hive box.
// //
@@ -1,8 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart' show findInstallRoot;
import '../update/updater.dart' show findInstallRoot;
/// The app's id: GTK's application id, so the Wayland `app_id` the /// The app's id: GTK's application id, so the Wayland `app_id` the
/// compositor matches to [desktopFileName] for the window's icon. /// 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:path_provider/path_provider.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import '../update/updater.dart' show appVersion;
// AniList airing schedule (public GraphQL, no login): every episode airing // AniList airing schedule (public GraphQL, no login): every episode airing
// in a time range, with covers, banners and popularity; and a title search // 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 'package:sync_relay_core/core.dart';
import '../jellyfin/accounts.dart'; import '../jellyfin/accounts.dart';
import '../update/updater.dart' show appVersion;
// AnimeFlow app API: what the media server tracks, has, downloads and was // AnimeFlow app API: what the media server tracks, has, downloads and was
// asked for. Authenticated with the user's own Jellyfin access token // 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/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:sync_relay_core/core.dart';
import '../../update/update_controller.dart'; import '../../update/update_controller.dart';
import '../../update/updater.dart';
import 'settings_page.dart'; import 'settings_page.dart';
import 'ui.dart'; import 'ui.dart';
import 'update_indicator.dart'; import 'update_indicator.dart';
@@ -1,4 +1,4 @@
@echo off @echo off
rem Update this install in place (same as clicking Update in the app). rem Update this install in place (same as clicking Update in the app).
"%~dp0watch_together.exe" --upgrade %* "%~dp0watch-together-server.exe" --upgrade %*
pause pause
+2 -1
View File
@@ -1,3 +1,4 @@
#!/bin/sh #!/bin/sh
# Update this install in place (same as clicking Update in the app). # 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:hive_ce_flutter/hive_flutter.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'updater.dart';
// In-app update flow behind the Home update indicator. Same code path as // In-app update flow behind the Home update indicator. Same code path as
// `--upgrade` (downloadAndApply); this adds state for the UI and the // `--upgrade` (core's updater, downloadAndApply); this adds state for the
// restart. Check failures are logged only — the banner shows up just when // UI and the restart. Check failures are logged only — the banner shows up just when
// there is something to install. // there is something to install.
final _log = createLogger('update'); final _log = createLogger('update');
@@ -27,6 +25,19 @@ class UpdateStatus {
{this.manifest, this.progress, this.error, this.canInstall = false}); {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); typedef RestartFn = Future<void> Function(String executable, List<String> args);
// Release Hive's box locks before the new instance opens the same boxes. // Release Hive's box locks before the new instance opens the same boxes.
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:sync_relay_core/core.dart';
import '../update/update_controller.dart'; import '../update/update_controller.dart';
import '../update/updater.dart';
// "Update available" card (Home update indicator dialog). Kicks off the one // "Update available" card (Home update indicator dialog). Kicks off the one
// background check on first build; renders nothing until there is an // background check on first build; renders nothing until there is an
+1 -1
View File
@@ -10,7 +10,7 @@ packages:
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
archive: archive:
dependency: "direct main" dependency: transitive
description: description:
name: archive name: archive
sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19 sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19
-1
View File
@@ -23,7 +23,6 @@ dependencies:
hive_ce_flutter: ^2.3.4 hive_ce_flutter: ^2.3.4
image: ^4.9.2 image: ^4.9.2
window_manager: ^0.5.2 window_manager: ^0.5.2
archive: ^4.0.7
crypto: ^3.0.6 crypto: ^3.0.6
# Drag & drop and clipboard files/images (vendored desktop_drop + pasteboard). # Drag & drop and clipboard files/images (vendored desktop_drop + pasteboard).
fox_utils: 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);
});
}
+19 -10
View File
@@ -7,9 +7,9 @@ Plain overview first: [HOW-IT-WORKS.md](HOW-IT-WORKS.md). Wire format:
| Path | What | Rule | | 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 | | `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 | | `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only |
| `deploy` | systemd unit for the dedicated server | | | `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` - **Dedicated server**: `bin/server.dart` compiled with `dart compile exe`
and shipped next to the app as `watch-together-server`. It parses flags and shipped next to the app as `watch-together-server`. It parses flags
(`--port`, `--secret`, `--name`, `--media`, `--advertise-host`, (`--port`, `--secret`, `--name`, `--media`, `--advertise-host`,
`--jellyfin <file>`, `--[no-]yt-dlp`, `--yt-dlp-dir`, `--yt-dlp-cookies`, `--config`; default port 8080) into the same map and calls `serve`; `--jellyfin <file>`, `--[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 SIGINT and SIGTERM stop it. A Flutter binary cannot run without a
display, which is why the server is its own build. 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 ## Updater
`apps/desktop/lib/src/update/updater.dart` (no Flutter imports, unit tested). One implementation, `packages/core/lib/src/updater.dart` (pure Dart, tested
Reads a manifest (`version`, `protocol`, `url`, optional `sha256`, `notes`) 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 from `updateManifestUrl`, compares versions, downloads the tarball, checks
sha256 when present, then extracts into a staging dir inside the install sha256 when present, then extracts into a staging dir inside the install
folder (same filesystem, so moves are renames; the folder must be 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, 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; with rollback on failure (writing the running executable fails with ETXTBSY;
rename works). A packaged install is detected by `data/` and `lib/` next to 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 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 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` / 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 `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 stamped into both binaries with `APP_VERSION` (`--dart-define` for the app,
checks). `watch_together --upgrade` (or the shipped `update.sh`) runs it from `--define` for `dart compile exe`; dev runs report `0.0.0-dev` and the app
the CLI; `UpdateController` runs the same code for the strip's update indicator, skips checks). CLI: `watch-together-server --upgrade` (no display needed;
which opens the update card, and restarts the app ("Update and restart"). the shipped `update.sh` / `update.bat` run it), `--check-update` prints only;
it ends with "restart the server to run <version>". `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 Settings has Check now and an auto-check toggle. The manifest is `updates/manifest.json` in
this repo, written by `tool/release.sh`. this repo, written by `tool/release.sh`.
+3 -3
View File
@@ -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=<x.y.z> cd apps/desktop && flutter build linux --release --dart-define=APP_VERSION=<x.y.z>
tool/package_linux.sh [--skip-build] [--noupload] # dist/*.tar.gz tool/package_linux.sh [--skip-build] [--noupload] # dist/*.tar.gz
powershell -File tool/package_windows.ps1 [-SkipBuild] # dist/*.zip (on Windows) 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=<x.y.z> bin/server.dart -o watch-together-server
``` ```
`APP_VERSION` defaults to `0.0.0-dev`; `package_linux.sh` stamps it from the `APP_VERSION` defaults to `0.0.0-dev`; the packagers stamp it from the
desktop pubspec. Setting `DISCORD_WEBHOOK_URL` makes the packager post 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 tarball to Discord (never commit it). The packager always compiles the
dedicated server (with the Flutter SDK's `dart`) into the tarball as dedicated server (with the Flutter SDK's `dart`) into the tarball as
`watch-together-server`, also with `--skip-build`. `watch-together-server`, also with `--skip-build`.
+1 -2
View File
@@ -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 filter on the Schedule; today's "Following" (AnimeFlow's monitored
shows) becomes "On the server". shows) becomes "On the server".
5. **Ops**: `watch-together-server --upgrade` (a VPS has no GTK for the 5. **Ops**: check shaders on Windows; the relay e2e "source drops
app's updater); check shaders on Windows; the relay e2e "source drops
uncleanly and resumes" fails about half the time under the full suite. uncleanly and resumes" fails about half the time under the full suite.
6. **Windows: first real use.** CI builds and publishes 6. **Windows: first real use.** CI builds and publishes
+1
View File
@@ -11,6 +11,7 @@ export 'src/yt_dlp.dart';
export 'src/cookies.dart'; export 'src/cookies.dart';
export 'src/media_source.dart'; export 'src/media_source.dart';
export 'src/sync_engine.dart'; export 'src/sync_engine.dart';
export 'src/updater.dart';
export 'src/jellyfin/client.dart'; export 'src/jellyfin/client.dart';
export 'src/jellyfin/client_info.dart'; export 'src/jellyfin/client_info.dart';
export 'src/jellyfin/http.dart'; export 'src/jellyfin/http.dart';
@@ -3,12 +3,15 @@ import 'dart:io';
import 'package:archive/archive.dart'; import 'package:archive/archive.dart';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:sync_relay_core/core.dart';
// Zero Flutter imports: this file must stay unit-testable with plain import 'constants.dart';
// `flutter test` (no widgets, no media_kit). One code path for both the import 'logging.dart';
// CLI (`watch_together --upgrade`) and the in-app banner: fetch manifest,
// download, verify sha256, apply over the live install. // 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 // Apply stages inside the install root (same filesystem, so every move is
// a rename — /tmp is often tmpfs and rename across filesystems fails). // a rename — /tmp is often tmpfs and rename across filesystems fails).
@@ -28,9 +31,6 @@ const updateManifestUrl =
const appVersion = String.fromEnvironment('APP_VERSION', const appVersion = String.fromEnvironment('APP_VERSION',
defaultValue: '0.0.0-dev'); defaultValue: '0.0.0-dev');
// Process args as main() received them — restart relaunches with these.
List<String> launchArgs = const [];
// Key into the manifest's `assets` map, e.g. linux-x64, windows-x64. // Key into the manifest's `assets` map, e.g. linux-x64, windows-x64.
final updatePlatform = '${Platform.operatingSystem}-x64'; final updatePlatform = '${Platform.operatingSystem}-x64';
@@ -518,29 +518,24 @@ Future<void> downloadAndApply(UpdateManifest manifest, Directory root,
} }
} }
// 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) => String requiredUpdateNote(UpdateManifest m) =>
'Required update: servers on ${m.version} speak protocol ${m.protocol}, ' 'Required update: servers on ${m.version} speak protocol ${m.protocol}, '
'this build speaks ${RelayConstants.protoVersion} and cannot join them.'; 'this build speaks ${RelayConstants.protoVersion} and cannot join them.';
// Returns a process exit code: 0 ok / already current, 1 failure, // Returns a process exit code: 0 ok / already current, 1 failure,
// 2 not a packaged install (update manually). // 2 not a packaged install (update manually). [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<int> runUpgrade( Future<int> runUpgrade(
{String currentVersion = appVersion, {String currentVersion = appVersion,
String manifestUrl = updateManifestUrl, String manifestUrl = updateManifestUrl,
HttpClient? http, HttpClient? http,
String? executablePath, String? executablePath,
Directory? installRoot}) async { Directory? installRoot,
stdout.writeln('watch-together $currentVersion (proto ${RelayConstants.protoVersion})'); String program = 'watch-together',
String Function(String version)? restartNote,
bool checkOnly = false}) async {
stdout.writeln('$program $currentVersion (proto ${RelayConstants.protoVersion})');
late final UpdateManifest manifest; late final UpdateManifest manifest;
try { try {
manifest = await fetchManifest(http: http, url: manifestUrl); manifest = await fetchManifest(http: http, url: manifestUrl);
@@ -559,6 +554,7 @@ Future<int> runUpgrade(
if (manifest.isRequired) { if (manifest.isRequired) {
stdout.writeln(requiredUpdateNote(manifest)); stdout.writeln(requiredUpdateNote(manifest));
} }
if (checkOnly) return 0;
final exe = executablePath ?? currentExecutablePath(); final exe = executablePath ?? currentExecutablePath();
final root = installRoot ?? findInstallRoot(exe); final root = installRoot ?? findInstallRoot(exe);
if (root == null) { if (root == null) {
@@ -575,6 +571,7 @@ Future<int> runUpgrade(
stdout.writeln('update failed: $e'); stdout.writeln('update failed: $e');
return 1; 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; return 0;
} }
+24
View File
@@ -17,6 +17,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.3.0" version: "14.3.0"
archive:
dependency: "direct main"
description:
name: archive
sha256: "6c5bcd986e06b94e3c40244af471750840a3d2341d1f9763a1100a14add517b4"
url: "https://pub.dev"
source: hosted
version: "4.3.0"
args: args:
dependency: transitive dependency: transitive
description: description:
@@ -81,6 +89,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" version: "3.0.7"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file: file:
dependency: transitive dependency: transitive
description: description:
@@ -201,6 +217,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.3" version: "1.5.3"
posix:
dependency: transitive
description:
name: posix
sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e
url: "https://pub.dev"
source: hosted
version: "6.5.2"
pub_semver: pub_semver:
dependency: transitive dependency: transitive
description: description:
+1
View File
@@ -7,6 +7,7 @@ dependencies:
logging: ^1.2.0 logging: ^1.2.0
yaml: ^3.1.0 yaml: ^3.1.0
path: ^1.9.0 path: ^1.9.0
archive: ^4.0.7
crypto: ^3.0.0 crypto: ^3.0.0
web_socket_channel: ^3.0.0 web_socket_channel: ^3.0.0
dev_dependencies: dev_dependencies:
@@ -3,10 +3,8 @@ import 'dart:io';
import 'package:archive/archive.dart'; import 'package:archive/archive.dart';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/update/update_controller.dart'; import 'package:test/test.dart';
import 'package:watch_together/src/update/updater.dart';
// Work dir base: WT_TEST_TMP or the system temp. Staging lives inside // 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 // the install root, so the result doesn't depend on which filesystem
@@ -460,102 +458,4 @@ void main() {
expect(File('$exe.old').existsSync(), isFalse); expect(File('$exe.old').existsSync(), isFalse);
}, skip: Platform.isWindows ? null : 'needs Windows'); }, 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);
});
} }
+13 -1
View File
@@ -30,6 +30,11 @@ Future<void> main(List<String> args) async {
help: 'cookies.txt or browser-extension JSON export to log that yt-dlp in (sites that refuse guests). ' 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.') '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.') ..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.'); ..addFlag('help', abbr: 'h', negatable: false, help: 'Show this help.');
final ArgResults parsed; final ArgResults parsed;
try { try {
@@ -41,7 +46,14 @@ Future<void> main(List<String> args) async {
} }
if (parsed['help'] as bool) { if (parsed['help'] as bool) {
// ignore: avoid_print // 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; return;
} }
+25 -1
View File
@@ -17,6 +17,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "14.3.0" version: "14.3.0"
archive:
dependency: transitive
description:
name: archive
sha256: "6c5bcd986e06b94e3c40244af471750840a3d2341d1f9763a1100a14add517b4"
url: "https://pub.dev"
source: hosted
version: "4.3.0"
args: args:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -81,6 +89,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" version: "3.0.7"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file: file:
dependency: transitive dependency: transitive
description: description:
@@ -201,6 +217,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.3" version: "1.5.3"
posix:
dependency: transitive
description:
name: posix
sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e
url: "https://pub.dev"
source: hosted
version: "6.5.2"
pub_semver: pub_semver:
dependency: transitive dependency: transitive
description: description:
@@ -319,7 +343,7 @@ packages:
path: "../core" path: "../core"
relative: true relative: true
source: path source: path
version: "0.1.0" version: "1.0.1"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:
+6 -5
View File
@@ -4,10 +4,11 @@
# Usage: tool/package_linux.sh [--skip-build] [--noupload|--no-upload] # Usage: tool/package_linux.sh [--skip-build] [--noupload|--no-upload]
# Output: dist/watch-together-<version>-linux-x64.tar.gz containing one # Output: dist/watch-together-<version>-linux-x64.tar.gz containing one
# top-level folder with the executable, lib/ and data/ as built, plus # top-level folder with the executable, lib/ and data/ as built, plus
# update.sh (runs the binary with --upgrade) and watch-together-server (the # update.sh (runs watch-together-server --upgrade) and watch-together-server
# dedicated server, `dart compile exe` of packages/server/bin/server.dart, # (the dedicated server, `dart compile exe` of packages/server/bin/server.dart,
# built every run, also with --skip-build). The in-app updater strips # built every run, also with --skip-build; both binaries are stamped with
# that top-level folder — keep the layout in sync with updater_test.dart. # 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). # Upload: set DISCORD_WEBHOOK_URL in the environment (never commit it).
# The tarball must stay under Discord's upload limit (25 MB free tier). # The tarball must stay under Discord's upload limit (25 MB free tier).
# The upload posts a Components V2 card (flag 1<<15): version header, # The upload posts a Components V2 card (flag 1<<15): version header,
@@ -55,7 +56,7 @@ if FLUTTER="$(command -v flutter)"; then
[[ -x "$CANDIDATE" ]] && DART="$CANDIDATE" [[ -x "$CANDIDATE" ]] && DART="$CANDIDATE"
fi fi
(cd "$ROOT/packages/server" && "$DART" pub get >/dev/null && (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" tar -czf "$TARBALL" -C "$STAGE" "$FOLDER"
rm -rf "$STAGE" rm -rf "$STAGE"
ls -la "$TARBALL" ls -la "$TARBALL"
+5 -4
View File
@@ -2,10 +2,11 @@
# Usage: powershell -File tool/package_windows.ps1 [-SkipBuild] # Usage: powershell -File tool/package_windows.ps1 [-SkipBuild]
# Output: dist/watch-together-<version>-windows-x64.zip containing one # Output: dist/watch-together-<version>-windows-x64.zip containing one
# top-level folder with watch_together.exe, its DLLs and data/ as built, # 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 # watch-together-server.exe (dedicated server, `dart compile exe` of
# packages/server/bin/server.dart). Same layout as tool/package_linux.sh; # packages/server/bin/server.dart; both binaries stamped with APP_VERSION).
# the in-app updater strips the top-level folder. # 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). # Needs: flutter on PATH, tar.exe (ships with Windows 10+, writes zip).
param([switch]$SkipBuild) param([switch]$SkipBuild)
$ErrorActionPreference = 'Stop' $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') Push-Location (Join-Path $Root 'packages/server')
try { try {
Invoke-Checked 'dart pub get' { dart pub get | Out-Null } 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 } } finally { Pop-Location }
if (Test-Path $Zip) { Remove-Item -Force $Zip } if (Test-Path $Zip) { Remove-Item -Force $Zip }