One updater for app and server: updater in core; watch-together-server --upgrade / --check-update; update.sh uses the server
The server's version is stamped like the app's (APP_VERSION) when packaged, so it can compare against the manifest; --upgrade swaps the whole install folder (app and server) and asks for a restart. update.sh/.bat no longer need GTK. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
059b7dd306
commit
2d39018a73
27 files changed
+382
-171
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -13,7 +13,7 @@ import 'src/platform/desktop_entry.dart';
|
||||
import 'src/screens/home/ui.dart';
|
||||
import 'src/state/session_state.dart';
|
||||
import 'src/state/settings.dart';
|
||||
import 'src/update/updater.dart';
|
||||
import 'src/update/update_controller.dart' show launchArgs;
|
||||
|
||||
Future<void> main(List<String> args) async {
|
||||
launchArgs = List.unmodifiable(args);
|
||||
|
||||
@@ -8,7 +8,6 @@ import 'package:hive_ce/hive.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../screens/home/picked_media.dart';
|
||||
import '../update/updater.dart' show appVersion;
|
||||
|
||||
// Linked Jellyfin servers, one account per ServerId, in their own Hive box.
|
||||
//
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../update/updater.dart' show findInstallRoot;
|
||||
import 'package:sync_relay_core/core.dart' show findInstallRoot;
|
||||
|
||||
/// The app's id: GTK's application id, so the Wayland `app_id` the
|
||||
/// compositor matches to [desktopFileName] for the window's icon.
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'dart:ui' show Color;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../update/updater.dart' show appVersion;
|
||||
|
||||
// AniList airing schedule (public GraphQL, no login): every episode airing
|
||||
// in a time range, with covers, banners and popularity; and a title search
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'dart:io';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../jellyfin/accounts.dart';
|
||||
import '../update/updater.dart' show appVersion;
|
||||
|
||||
// AnimeFlow app API: what the media server tracks, has, downloads and was
|
||||
// asked for. Authenticated with the user's own Jellyfin access token
|
||||
|
||||
@@ -2,9 +2,9 @@ import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../../update/update_controller.dart';
|
||||
import '../../update/updater.dart';
|
||||
import 'settings_page.dart';
|
||||
import 'ui.dart';
|
||||
import 'update_indicator.dart';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@echo off
|
||||
rem Update this install in place (same as clicking Update in the app).
|
||||
"%~dp0watch_together.exe" --upgrade %*
|
||||
pause
|
||||
@echo off
|
||||
rem Update this install in place (same as clicking Update in the app).
|
||||
"%~dp0watch-together-server.exe" --upgrade %*
|
||||
pause
|
||||
@@ -1,3 +1,4 @@
|
||||
#!/bin/sh
|
||||
# Update this install in place (same as clicking Update in the app).
|
||||
cd "$(dirname "$0")" && exec ./watch_together --upgrade "$@"
|
||||
# The server binary runs the updater: it needs no GTK/display (VPS).
|
||||
cd "$(dirname "$0")" && exec ./watch-together-server --upgrade "$@"
|
||||
@@ -4,11 +4,9 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:hive_ce_flutter/hive_flutter.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'updater.dart';
|
||||
|
||||
// In-app update flow behind the Home update indicator. Same code path as
|
||||
// `--upgrade` (downloadAndApply); this adds state for the UI and the
|
||||
// restart. Check failures are logged only — the banner shows up just when
|
||||
// `--upgrade` (core's updater, downloadAndApply); this adds state for the
|
||||
// UI and the restart. Check failures are logged only — the banner shows up just when
|
||||
// there is something to install.
|
||||
|
||||
final _log = createLogger('update');
|
||||
@@ -27,6 +25,19 @@ class UpdateStatus {
|
||||
{this.manifest, this.progress, this.error, this.canInstall = false});
|
||||
}
|
||||
|
||||
// Process args as main() received them — restart relaunches with these.
|
||||
List<String> launchArgs = const [];
|
||||
|
||||
// Relaunch the (now replaced) binary detached with the same args, then
|
||||
// exit this process.
|
||||
Future<Never> restartApp(String executable, List<String> args) async {
|
||||
final keep = args.where((a) => a != '--upgrade').toList();
|
||||
await Process.start(executable, keep,
|
||||
mode: ProcessStartMode.detached,
|
||||
workingDirectory: Directory.current.path);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
typedef RestartFn = Future<void> Function(String executable, List<String> args);
|
||||
|
||||
// Release Hive's box locks before the new instance opens the same boxes.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import '../update/update_controller.dart';
|
||||
import '../update/updater.dart';
|
||||
|
||||
// "Update available" card (Home update indicator dialog). Kicks off the one
|
||||
// background check on first build; renders nothing until there is an
|
||||
|
||||
@@ -10,7 +10,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
archive:
|
||||
dependency: "direct main"
|
||||
dependency: transitive
|
||||
description:
|
||||
name: archive
|
||||
sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19
|
||||
|
||||
@@ -23,7 +23,6 @@ dependencies:
|
||||
hive_ce_flutter: ^2.3.4
|
||||
image: ^4.9.2
|
||||
window_manager: ^0.5.2
|
||||
archive: ^4.0.7
|
||||
crypto: ^3.0.6
|
||||
# Drag & drop and clipboard files/images (vendored desktop_drop + pasteboard).
|
||||
fox_utils:
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
import 'package:watch_together/src/update/update_controller.dart';
|
||||
|
||||
// Work dir base: WT_TEST_TMP or the system temp. Staging lives inside
|
||||
// the install root, so the result doesn't depend on which filesystem
|
||||
// this is (the old /tmp staging broke when /tmp was tmpfs).
|
||||
final _tmpBase =
|
||||
Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
|
||||
|
||||
Future<Directory> _work() async {
|
||||
final base = Directory(_tmpBase);
|
||||
await base.create(recursive: true);
|
||||
return base.createTemp('wt-up-test');
|
||||
}
|
||||
|
||||
// Linux-layout tests check exec bits and symlinks, which NTFS lacks.
|
||||
final _posixOnly = Platform.isWindows ? 'POSIX install layout' : null;
|
||||
|
||||
bool _isExec(String path) => File(path).statSync().mode & 0x40 != 0;
|
||||
|
||||
// Builds the release archive exactly like tool/package_linux.sh:
|
||||
// bundle copied into <stage>/watch-together-<ver>/, then
|
||||
// `tar -czf out -C <stage> watch-together-<ver>`. [zip]: like
|
||||
// tool/package_windows.ps1 (`tar -a -c -f out.zip`, bsdtar — Windows only).
|
||||
Future<List<int>> _releaseTarball(Directory work,
|
||||
{String version = '0.2.0',
|
||||
bool windows = false,
|
||||
bool bundle = true,
|
||||
bool zip = false}) async {
|
||||
final stage = Directory('${work.path}/pkg-$version-${windows ? 'win' : 'lin'}');
|
||||
final folder = Directory('${stage.path}/watch-together-$version');
|
||||
await Directory('${folder.path}/data/flutter_assets').create(recursive: true);
|
||||
await File('${folder.path}/data/flutter_assets/fresh.cfg')
|
||||
.writeAsString('fresh-config');
|
||||
if (windows) {
|
||||
await File('${folder.path}/watch_together.exe').writeAsString('binary-v2');
|
||||
await File('${folder.path}/flutter_windows.dll').writeAsString('dll-v2');
|
||||
} else {
|
||||
await File('${folder.path}/watch_together').writeAsString('binary-v2');
|
||||
if (bundle) {
|
||||
await Directory('${folder.path}/lib').create();
|
||||
await File('${folder.path}/lib/libapp.so.1').writeAsString('lib-v2');
|
||||
await Link('${folder.path}/lib/libapp.so').create('libapp.so.1');
|
||||
}
|
||||
await File('${folder.path}/update.sh')
|
||||
.writeAsString('#!/bin/sh\nexec ./watch_together --upgrade\n');
|
||||
if (!Platform.isWindows) {
|
||||
final r = await Process.run('chmod',
|
||||
['755', '${folder.path}/watch_together', '${folder.path}/update.sh']);
|
||||
expect(r.exitCode, 0);
|
||||
}
|
||||
}
|
||||
final out = File('${work.path}/out-$version.${zip ? 'zip' : 'tar.gz'}');
|
||||
final r = await Process.run('tar', [
|
||||
...zip ? ['-a', '-c', '-f'] : ['-czf'],
|
||||
out.path,
|
||||
'-C',
|
||||
stage.path,
|
||||
'watch-together-$version',
|
||||
]);
|
||||
expect(r.exitCode, 0, reason: '${r.stderr}');
|
||||
return out.readAsBytes();
|
||||
}
|
||||
|
||||
Future<Directory> _fakeRoot(Directory work, {bool windows = false}) async {
|
||||
final root = Directory('${work.path}/install');
|
||||
await Directory('${root.path}/data').create(recursive: true);
|
||||
await File('${root.path}/data/old.cfg').writeAsString('old-config');
|
||||
if (windows) {
|
||||
await File('${root.path}/watch_together.exe').writeAsString('binary-v1');
|
||||
await File('${root.path}/flutter_windows.dll').writeAsString('dll-v1');
|
||||
} else {
|
||||
await Directory('${root.path}/lib').create();
|
||||
await File('${root.path}/lib/libgone.so').writeAsString('lib-v1');
|
||||
await File('${root.path}/watch_together').writeAsString('binary-v1');
|
||||
if (!Platform.isWindows) {
|
||||
await Process.run('chmod', ['755', '${root.path}/watch_together']);
|
||||
}
|
||||
}
|
||||
await File('${root.path}/user-notes.txt').writeAsString('mine');
|
||||
return root;
|
||||
}
|
||||
|
||||
// Serves /manifest.json and the tarball. Fields can be overridden per test.
|
||||
class _Server {
|
||||
late HttpServer http;
|
||||
List<int> tarball;
|
||||
Map<String, Object?> manifestExtra = {};
|
||||
bool brokenManifest = false;
|
||||
int tarHits = 0;
|
||||
_Server(this.tarball);
|
||||
|
||||
String get base => 'http://127.0.0.1:${http.port}';
|
||||
String get manifestUrl => '$base/manifest.json';
|
||||
|
||||
Future<void> start() async {
|
||||
http = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
http.listen((req) async {
|
||||
if (req.uri.path == '/manifest.json' && !brokenManifest) {
|
||||
req.response.write(jsonEncode({
|
||||
'version': '0.2.0',
|
||||
'protocol': RelayConstants.protoVersion,
|
||||
'url': '$base/watch-together-0.2.0-linux-x64.tar.gz',
|
||||
'sha256': sha256.convert(tarball).toString(),
|
||||
'notes': 'test build',
|
||||
...manifestExtra,
|
||||
}));
|
||||
} else if (req.uri.path == '/watch-together-0.2.0-linux-x64.tar.gz') {
|
||||
tarHits++;
|
||||
req.response.contentLength = tarball.length;
|
||||
req.response.add(tarball);
|
||||
} else {
|
||||
req.response.statusCode = 404;
|
||||
}
|
||||
await req.response.close();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> stop() => http.close(force: true);
|
||||
}
|
||||
|
||||
// The updater itself is tested in packages/core/test/updater_test.dart
|
||||
// (fixtures above mirror it); this covers the app's UpdateController.
|
||||
void main() {
|
||||
group('in-app controller', () {
|
||||
late Directory work;
|
||||
late _Server server;
|
||||
|
||||
setUp(() async {
|
||||
work = await _work();
|
||||
server = _Server(await _releaseTarball(work));
|
||||
await server.start();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await server.stop();
|
||||
if (await work.exists()) await work.delete(recursive: true);
|
||||
});
|
||||
|
||||
test('check, download with progress, apply, restart same binary',
|
||||
() async {
|
||||
final root = await _fakeRoot(work);
|
||||
final restarts = <(String, List<String>)>[];
|
||||
launchArgs = const ['--some-flag'];
|
||||
final c = UpdateController(
|
||||
currentVersion: '0.1.0',
|
||||
manifestUrl: server.manifestUrl,
|
||||
executablePath: '${root.path}/watch_together',
|
||||
restart: (exe, args) async => restarts.add((exe, args)));
|
||||
await c.checkOnce();
|
||||
expect(c.status.value.phase, UpdatePhase.available);
|
||||
expect(c.status.value.canInstall, isTrue);
|
||||
expect(c.status.value.manifest!.isRequired, isFalse);
|
||||
final progress = <double>[];
|
||||
c.status.addListener(() {
|
||||
final p = c.status.value.progress;
|
||||
if (p != null) progress.add(p);
|
||||
});
|
||||
await c.install();
|
||||
expect(progress.last, 1.0);
|
||||
expect(c.status.value.phase, UpdatePhase.restarting);
|
||||
expect(await File('${root.path}/watch_together').readAsString(),
|
||||
'binary-v2');
|
||||
expect(_isExec('${root.path}/watch_together'), isTrue);
|
||||
expect(restarts.single.$1, '${root.path}/watch_together');
|
||||
expect(restarts.single.$2, ['--some-flag']);
|
||||
}, skip: _posixOnly);
|
||||
|
||||
test('newer protocol is a required update, cannot be dismissed',
|
||||
() async {
|
||||
server.manifestExtra = {'protocol': RelayConstants.protoVersion + 1};
|
||||
final c = UpdateController(
|
||||
currentVersion: '0.1.0',
|
||||
manifestUrl: server.manifestUrl,
|
||||
executablePath: '${work.path}/stray-binary',
|
||||
restart: (_, _) async {});
|
||||
await c.checkOnce();
|
||||
expect(c.status.value.manifest!.isRequired, isTrue);
|
||||
expect(c.status.value.canInstall, isFalse);
|
||||
c.dismiss();
|
||||
expect(c.status.value.phase, UpdatePhase.available);
|
||||
});
|
||||
|
||||
test('failed check stays quiet; dev builds skip the check', () async {
|
||||
server.brokenManifest = true;
|
||||
final c = UpdateController(
|
||||
currentVersion: '0.1.0',
|
||||
manifestUrl: server.manifestUrl,
|
||||
executablePath: '${work.path}/stray-binary',
|
||||
restart: (_, _) async {});
|
||||
await c.checkOnce();
|
||||
expect(c.status.value.phase, UpdatePhase.idle);
|
||||
server.brokenManifest = false;
|
||||
final dev = UpdateController(
|
||||
currentVersion: '0.0.0-dev',
|
||||
manifestUrl: server.manifestUrl,
|
||||
executablePath: '${work.path}/stray-binary',
|
||||
restart: (_, _) async {});
|
||||
await dev.checkOnce();
|
||||
expect(dev.status.value.phase, UpdatePhase.idle);
|
||||
});
|
||||
|
||||
test('failed install shows error, install untouched, retry works',
|
||||
() async {
|
||||
server.manifestExtra = {'sha256': '0' * 64};
|
||||
final root = await _fakeRoot(work);
|
||||
var restarted = false;
|
||||
final c = UpdateController(
|
||||
currentVersion: '0.1.0',
|
||||
manifestUrl: server.manifestUrl,
|
||||
executablePath: '${root.path}/watch_together',
|
||||
restart: (_, _) async => restarted = true);
|
||||
await c.checkOnce();
|
||||
await c.install();
|
||||
expect(c.status.value.phase, UpdatePhase.failed);
|
||||
expect(c.status.value.error, contains('sha256'));
|
||||
expect(restarted, isFalse);
|
||||
expect(await File('${root.path}/watch_together').readAsString(),
|
||||
'binary-v1');
|
||||
}, skip: _posixOnly);
|
||||
});
|
||||
}
|
||||
+19
-10
@@ -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 <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
|
||||
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 <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
|
||||
this repo, written by `tool/release.sh`.
|
||||
|
||||
|
||||
+3
-3
@@ -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>
|
||||
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=<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
|
||||
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`.
|
||||
|
||||
+1
-2
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<String> 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<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) =>
|
||||
'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<int> 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<int> 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<int> 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;
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<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);
|
||||
});
|
||||
}
|
||||
@@ -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). '
|
||||
'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<void> main(List<String> 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
# Usage: tool/package_linux.sh [--skip-build] [--noupload|--no-upload]
|
||||
# Output: dist/watch-together-<version>-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"
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
# Usage: powershell -File tool/package_windows.ps1 [-SkipBuild]
|
||||
# Output: dist/watch-together-<version>-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 }
|
||||
|
||||
Reference in new issue
Block a user