Updater v1 --upgrade + roadmap as flat backlog

Updater: watch-together --upgrade checks updates/manifest.json,
downloads the tarball, verifies sha256, applies over the live
install (extract-temp + delete-first move-over; rename-safe on
Linux while running). Zero Flutter imports, 7 tests incl.
stub-server flows. Version stamped via --dart-define=APP_VERSION
in package script. Warns (never silently jumps) on proto mismatch.
Roadmap: phases retired as tracker (history in git log), now flat
Built/Backlog/Epics/Deferred; Jellyfin next feature, transcode
never; updater expansion stays an epic. Endpoint publishing
(manifest + release tarball) is user-owned.
This commit is contained in:
foxyz committed 2026-09-12 18:12:28 +03:00
1 parent b2173e82c8
commit d77b0e804d
9 files changed
+505 -103

No files matched your search

+28 -15
View File
@@ -1,7 +1,10 @@
# HANDOFF — sync_relay_project (watch-together app) # HANDOFF — sync_relay_project (watch-together app)
Date: 2026-09-12 (updated late session). Phases 0–4 + presence hardening Date: 2026-09-12 (updated late session). Phases 0–4 + presence hardening
+ UI redesigns done, all green (core 42, server 40, desktop 17). + UI redesigns done, all green (core 42, server 41, desktop 24).
Phases retired as a tracker 09-12 — `docs/ROADMAP.md` is now a flat
backlog (Built / Backlog / Epics / Deferred); phase text lives in
git history.
Black screen (§8) DIAGNOSED + FIXED (avatar-texture teardown race; Black screen (§8) DIAGNOSED + FIXED (avatar-texture teardown race;
Visibility keep-mounted) + VERIFIED, incl. over internet. Bisect Visibility keep-mounted) + VERIFIED, incl. over internet. Bisect
screens (legacy/minimal/nosheet) REMOVED 09-12 — app.dart routes screens (legacy/minimal/nosheet) REMOVED 09-12 — app.dart routes
@@ -53,8 +56,9 @@ kick kills sync+video, no relay memory growth.
box — accepted). No op roles (deferred: op flag + second hello box — accepted). No op roles (deferred: op flag + second hello
secret). Kick = transient nudge: revoke + close + `kicked` reason, secret). Kick = transient nudge: revoke + close + `kicked` reason,
instant rejoin with fresh token. instant rejoin with fresh token.
- Deferred, do not build: TLS/Caddy, Jellyfin native, auto-updater, - Deferred, do not build: TLS/Caddy, Jellyfin native, updater
playlist (extension shape reserved in PROTOCOL.md), op roles, sounds expansion (v1 `--upgrade` SHIPPED 09-12 — see §9.12; in-app
prompt + Windows helper still deferred), playlist (extension shape reserved in PROTOCOL.md), op roles, sounds
(researched, wired, no assets — see §7). (researched, wired, no assets — see §7).
- Standing rules: health reporting (every failure displays, nothing - Standing rules: health reporting (every failure displays, nothing
stalls silently); proto bump + PROTOCOL.md same-commit on ANY stalls silently); proto bump + PROTOCOL.md same-commit on ANY
@@ -89,12 +93,14 @@ kick kills sync+video, no relay memory growth.
THE avatar — memoized, no bg on images, dark ring+fallback; THE avatar — memoized, no bg on images, dark ring+fallback;
dialogs.dart: kick confirm; media_input.dart: parseMediaInput). dialogs.dart: kick confirm; media_input.dart: parseMediaInput).
- `apps/desktop/test/`: room_diff_test (9), media_input_test (6), - `apps/desktop/test/`: room_diff_test (9), media_input_test (6),
updater_test (7: version/manifest/layout + stub-server flows),
sync_playback_e2e_test (quorum: solo Space holds, both ready plays; sync_playback_e2e_test (quorum: solo Space holds, both ready plays;
needs mpv + ~/Videos/chiyo.mp4), coord_presence_test (dead server needs mpv + ~/Videos/chiyo.mp4), coord_presence_test (dead server
clears + reconnects; needs mpv). clears + reconnects; needs mpv).
- `tool/`: range_logger, shaper (chaos: KB/s cap, latency, - `tool/`: range_logger, shaper (chaos: KB/s cap, latency,
--kill-after), fake_viewer, e2e_check (sim timeline, still ALL PASS --kill-after), fake_viewer, e2e_check (sim timeline, still ALL PASS
post-quorum). post-quorum), package_linux.sh (release tarball + Discord card,
stamps APP_VERSION).
- `docs/`: PROTOCOL.md (v4 + quorum + 15/30 s timeouts + isClosed note, - `docs/`: PROTOCOL.md (v4 + quorum + 15/30 s timeouts + isClosed note,
normative), ARCHITECTURE.md (+health rule), mpv-http-contract.md. normative), ARCHITECTURE.md (+health rule), mpv-http-contract.md.
- `deploy/`: systemd unit. Git repo initialized 09-12 (`git init`, - `deploy/`: systemd unit. Git repo initialized 09-12 (`git init`,
@@ -247,15 +253,10 @@ remain in session_screen.dart.
3. 2-client LAN soak — user reports DONE 09-12 (details pending). 3. 2-client LAN soak — user reports DONE 09-12 (details pending).
4. 1.4 GB real-file media test — user reports DONE 09-12 4. 1.4 GB real-file media test — user reports DONE 09-12
(details pending; api_key must never land in files). (details pending; api_key must never land in files).
5. Cache-exhaustion long outage via shaper --kill-after (Phase 5 5.–8. MIGRATED 09-12 to `docs/ROADMAP.md` backlog (cache-exhaustion,
chaos assertion, never run). sounds, file-join wart, /health) — the roadmap is now the single
6. Sounds swap-in (§7) after audition. backlog; this section keeps session history + user-owned pendings
7. File-join wart: welcome mediaUrl uses advertiseHost 127.0.0.1, only.
remote file joiners fail-open then self-heal on first room
broadcast — fix via Host header, or leave (cosmetic).
8. `/health` is unauthenticated and broadcasts the full media URL
(incl. api_key when the link carries one) — acceptable on LAN,
noted now that 6969 faces the internet.
9. FIXED 09-12: auto-unready fired while passively watching — 9. FIXED 09-12: auto-unready fired while passively watching —
the 10-min check keyed off `_lastInput`, refreshed only by the 10-min check keyed off `_lastInput`, refreshed only by
explicit actions. Steady playback now counts as activity: explicit actions. Steady playback now counts as activity:
@@ -273,12 +274,24 @@ remain in session_screen.dart.
case) — one send, one row, one toast, one sfx. No wire case) — one send, one row, one toast, one sfx. No wire
change, still v4. Chat field also keeps focus after send change, still v4. Chat field also keeps focus after send
(dedicated FocusNode, re-requested on submit/button). (dedicated FocusNode, re-requested on submit/button).
11. KNOWN (non-breaking, fix later): seeks feel jumpy 11. KNOWN (non-breaking, fix later — roadmap backlog #2): seeks feel
(forward-back-forward) — suspect the seek-land retry jumpy (forward-back-forward) — suspect the seek-land retry
(`_checkSeekLanded`, `session_coordinator.dart:429`) (`_checkSeekLanded`, `session_coordinator.dart:429`)
re-applying SeekAction against the 0.5 s deadband / 2 s re-applying SeekAction against the 0.5 s deadband / 2 s
debounce and the 1 Hz room echo. Needs a live repro with a debounce and the 1 Hz room echo. Needs a live repro with a
timeline log before touching. timeline log before touching.
12. Updater v1 SHIPPED 09-12: `watch-together --upgrade`
(`apps/desktop/lib/src/update/updater.dart`, zero Flutter
imports, 7 tests) — manifest check, download, sha256, apply
over live install (extract-temp + delete-first move-over;
ETXTBSY forbids writing the running exe, delete+rename is
fine). Version stamped via `--dart-define=APP_VERSION`
(wired in `tool/package_linux.sh`; dev runs report
`0.0.0-dev`). USER-OWNED PENDING: publish
`updates/manifest.json` + release tarball on Forgejo (schema
in `updates/manifest.example.json`); until then --upgrade
reports failure against the missing manifest. No silent
proto jumps: updater warns when manifest protocol != local.
Environment: dart 3.14 beta, flutter 3.48 beta, mpv+ffmpeg present, Environment: dart 3.14 beta, flutter 3.48 beta, mpv+ffmpeg present,
~/Videos/chiyo.mp4 sample. Never kill the user's Syncplay mpv ~/Videos/chiyo.mp4 sample. Never kill the user's Syncplay mpv
instance. Git repo at project root (init 09-12); commit code + docs instance. Git repo at project root (init 09-12); commit code + docs
+5
View File
@@ -8,8 +8,13 @@ import 'package:window_manager/window_manager.dart';
import 'app.dart'; import 'app.dart';
import 'src/state/session_state.dart'; import 'src/state/session_state.dart';
import 'src/update/updater.dart';
Future<void> main(List<String> args) async { Future<void> main(List<String> args) async {
if (args.contains('--upgrade')) {
exitCode = await runUpgrade();
exit(exitCode);
}
initLogging(); initLogging();
if (args.contains('--headless')) { if (args.contains('--headless')) {
await runHeadless(args); await runHeadless(args);
+236
View File
@@ -0,0 +1,236 @@
import 'dart:convert';
import 'dart:io';
import 'package:archive/archive.dart';
import 'package:crypto/crypto.dart';
import 'package:sync_relay_core/core.dart';
// Zero Flutter imports: this file must stay unit-testable with plain
// `flutter test` (no widgets, no media_kit). Linux-first v1: a CLI flag,
// `watch-together --upgrade`, that checks the manifest, downloads the
// tarball, verifies sha256, and extracts it over the live install.
// Linux allows replacing (unlink/rename) files under a running process —
// only opening the running binary for writing is refused (ETXTBSY) — so
// apply is extract-to-temp then delete-first move-over, and the running
// (old) image just prints the result and exits. Next launch is new.
const updateManifestUrl =
'https://git.senkochan.moe/Foxyz/sync_replay_project/raw/branch/main/updates/manifest.json';
const appVersion = String.fromEnvironment('APP_VERSION',
defaultValue: '0.0.0-dev');
class UpdateManifest {
final String version;
final int protocol;
final String url;
final String sha256;
final String notes;
const UpdateManifest(
{required this.version,
required this.protocol,
required this.url,
required this.sha256,
required this.notes});
factory UpdateManifest.fromJson(Map<String, Object?> json) {
final version = json['version'];
final protocol = json['protocol'];
final url = json['url'];
final sha = json['sha256'];
if (version is! String ||
version.isEmpty ||
protocol is! int ||
url is! String ||
url.isEmpty ||
sha is! String ||
sha.isEmpty) {
throw const FormatException('update manifest missing version/protocol/url/sha256');
}
return UpdateManifest(
version: version,
protocol: protocol,
url: url,
sha256: sha,
notes: (json['notes'] as String?) ?? '');
}
}
List<int> parseVersion(String v) {
final out = <int>[];
for (final part in v.split('.')) {
final m = RegExp(r'\d+').firstMatch(part);
if (m == null) break;
out.add(int.parse(m.group(0)!));
if (out.length == 3) break;
}
while (out.length < 3) {
out.add(0);
}
return out;
}
bool isNewerVersion(String current, String latest) {
final c = parseVersion(current);
final l = parseVersion(latest);
for (var i = 0; i < 3; i++) {
if (l[i] != c[i]) return l[i] > c[i];
}
return false;
}
Future<UpdateManifest> fetchManifest(
{HttpClient? http, String url = updateManifestUrl}) async {
final client = http ?? HttpClient();
try {
final req = await client
.getUrl(Uri.parse(url))
.timeout(const Duration(seconds: 15));
final res = await req.close();
final body = await res.transform(utf8.decoder).join();
if (res.statusCode != 200) {
throw HttpException('manifest fetch failed: HTTP ${res.statusCode}');
}
final decoded = jsonDecode(body);
if (decoded is! Map<String, Object?>) {
throw const FormatException('manifest is not a JSON object');
}
return UpdateManifest.fromJson(decoded);
} finally {
client.close();
}
}
Future<void> downloadFile(String url, File dest, {HttpClient? http}) async {
final client = http ?? HttpClient();
try {
final req = await client
.getUrl(Uri.parse(url))
.timeout(const Duration(seconds: 15));
final res = await req.close();
if (res.statusCode != 200) {
throw HttpException('download failed: HTTP ${res.statusCode}');
}
final sink = dest.openWrite();
try {
await res.pipe(sink);
} finally {
await sink.close();
}
} finally {
client.close();
}
}
Future<void> verifySha256(File file, String want) async {
final digest = await sha256.bind(file.openRead()).first;
if (digest.toString() != want.trim().toLowerCase()) {
throw const FormatException('sha256 mismatch — download is corrupt');
}
}
// Release bundle layout is <root>/<binary> with <root>/data and <root>/lib
// siblings. Anything else (dev runs, dart run) is not a packaged install.
Directory? findInstallRoot(String executablePath) {
final dir = File(executablePath).parent;
if (Directory('${dir.path}/data').existsSync() &&
Directory('${dir.path}/lib').existsSync()) {
return dir;
}
return null;
}
// Extract to temp, then move every file over delete-first: opening the
// running executable for writing throws ETXTBSY, delete+rename does not.
Future<void> applyTarball(File tarball, Directory root) async {
final raw = await tarball.readAsBytes();
final archive = TarDecoder().decodeBytes(GZipDecoder().decodeBytes(raw));
final stage =
await Directory.systemTemp.createTemp('watch-together-update');
try {
for (final entry in archive) {
final name = entry.name;
if (name.isEmpty || name.startsWith('/') || name.contains('..')) {
throw FormatException('unsafe tar entry: $name');
}
final target = File('${stage.path}/$name');
if (entry.isDirectory) {
await Directory(target.path).create(recursive: true);
} else if (entry.isFile) {
await target.parent.create(recursive: true);
await target.writeAsBytes(entry.content as List<int>);
} else {
throw FormatException('unsupported tar entry: $name');
}
}
await for (final entity
in stage.list(recursive: true, followLinks: false)) {
if (entity is! File) continue;
final rel = entity.path.substring(stage.path.length + 1);
final dest = File('${root.path}/$rel');
await dest.parent.create(recursive: true);
if (await dest.exists() || await Link(dest.path).exists()) {
await dest.delete();
}
await entity.rename(dest.path);
}
} finally {
if (await stage.exists()) {
await stage.delete(recursive: true);
}
}
}
// Returns a process exit code: 0 ok / already current, 1 failure,
// 2 not a packaged install (update manually).
Future<int> runUpgrade(
{String currentVersion = appVersion,
String manifestUrl = updateManifestUrl,
HttpClient? http,
String? executablePath,
Directory? installRoot}) async {
stdout.writeln('watch-together $currentVersion (proto ${RelayConstants.protoVersion})');
late final UpdateManifest manifest;
try {
manifest = await fetchManifest(http: http, url: manifestUrl);
} catch (e) {
stdout.writeln('update check failed: $e');
return 1;
}
if (!isNewerVersion(currentVersion, manifest.version)) {
stdout.writeln('already up to date (${manifest.version}).');
return 0;
}
stdout.writeln('update available: ${manifest.version} (proto ${manifest.protocol})');
if (manifest.notes.isNotEmpty) {
stdout.writeln(manifest.notes);
}
if (manifest.protocol != RelayConstants.protoVersion) {
stdout.writeln('WARNING: protocol ${manifest.protocol} != ${RelayConstants.protoVersion} — '
'update together with everyone in the room, mixed versions cannot sync.');
}
final root = installRoot ??
findInstallRoot(executablePath ?? Platform.resolvedExecutable);
if (root == null) {
stdout.writeln('not a packaged install — download the tarball and update manually.');
return 2;
}
final tmp = await Directory.systemTemp.createTemp('watch-together-dl');
try {
final file = File('${tmp.path}/update.tar.gz');
stdout.writeln('downloading ${manifest.url} ...');
await downloadFile(manifest.url, file, http: http);
await verifySha256(file, manifest.sha256);
stdout.writeln('verified, applying over ${root.path} ...');
await applyTarball(file, root);
} catch (e) {
stdout.writeln('update failed: $e');
return 1;
} finally {
if (await tmp.exists()) {
await tmp.delete(recursive: true);
}
}
stdout.writeln('updated to ${manifest.version} — restart the app to use it.');
return 0;
}
+2 -2
View File
@@ -10,7 +10,7 @@ packages:
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
archive: archive:
dependency: transitive dependency: "direct main"
description: description:
name: archive name: archive
sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19 sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19
@@ -90,7 +90,7 @@ packages:
source: hosted source: hosted
version: "0.3.5+5" version: "0.3.5+5"
crypto: crypto:
dependency: transitive dependency: "direct main"
description: description:
name: crypto name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
+2
View File
@@ -23,6 +23,8 @@ 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
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
+173
View File
@@ -0,0 +1,173 @@
import 'dart:convert';
import 'dart:io';
import 'package:archive/archive.dart';
import 'package:crypto/crypto.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/update/updater.dart';
void main() {
test('version compare', () {
expect(isNewerVersion('0.1.0', '0.1.1'), isTrue);
expect(isNewerVersion('0.1.0', '0.2.0'), isTrue);
expect(isNewerVersion('0.1.0', '1.0.0'), isTrue);
expect(isNewerVersion('0.0.0-dev', '0.1.0'), isTrue);
expect(isNewerVersion('0.1.0', '0.1.0'), isFalse);
expect(isNewerVersion('0.2.0', '0.1.9'), isFalse);
});
test('manifest rejects missing fields', () {
expect(() => UpdateManifest.fromJson({}), throwsFormatException);
expect(
() => UpdateManifest.fromJson({
'version': '0.2.0',
'protocol': 4,
'url': 'https://x/y.tar.gz',
}),
throwsFormatException);
});
test('findInstallRoot honors bundle layout', () async {
final tmp = await Directory.systemTemp.createTemp('wt-root-test');
try {
final root = Directory('${tmp.path}/root');
await Directory('${root.path}/data').create(recursive: true);
await Directory('${root.path}/lib').create(recursive: true);
final exe = File('${root.path}/watch_together')..createSync();
expect(findInstallRoot(exe.path)?.path, root.path);
expect(findInstallRoot('${tmp.path}/stray-binary'), isNull);
} finally {
await tmp.delete(recursive: true);
}
});
group('upgrade flow', () {
late HttpServer server;
late Directory work;
late List<int> tarball;
late String tarSha;
var tarHits = 0;
setUp(() async {
tarHits = 0;
work = await Directory.systemTemp.createTemp('wt-up-test');
final archive = Archive();
final v2 = utf8.encode('binary-v2');
archive.addFile(ArchiveFile('watch_together', v2.length, v2));
final cfg = utf8.encode('fresh-config');
archive.addFile(ArchiveFile('data/fresh.cfg', cfg.length, cfg));
tarball = GZipEncoder().encode(TarEncoder().encode(archive));
tarSha = sha256.convert(tarball).toString();
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
server.listen((req) async {
if (req.uri.path == '/manifest.json') {
final manifest = {
'version': '0.2.0',
'protocol': 4,
'url':
'http://127.0.0.1:${server.port}/watch-together-0.2.0.tar.gz',
'sha256': tarSha,
'notes': 'test build',
};
req.response
..statusCode = 200
..write(jsonEncode(manifest));
} else if (req.uri.path == '/watch-together-0.2.0.tar.gz') {
tarHits++;
req.response
..statusCode = 200
..add(tarball);
} else {
req.response.statusCode = 404;
}
await req.response.close();
});
});
tearDown(() async {
await server.close(force: true);
if (await work.exists()) {
await work.delete(recursive: true);
}
});
Future<Directory> fakeRoot() async {
final root = Directory('${work.path}/root');
await Directory('${root.path}/data').create(recursive: true);
await Directory('${root.path}/lib').create(recursive: true);
await File('${root.path}/watch_together')
.writeAsString('binary-v1', flush: true);
return root;
}
String manifestUrl() =>
'http://127.0.0.1:${server.port}/manifest.json';
test('downloads, verifies and applies', () async {
final root = await fakeRoot();
final rc = await runUpgrade(
currentVersion: '0.1.0',
manifestUrl: manifestUrl(),
installRoot: root);
expect(rc, 0);
expect(tarHits, 1);
expect(await File('${root.path}/watch_together').readAsString(),
'binary-v2');
expect(await File('${root.path}/data/fresh.cfg').readAsString(),
'fresh-config');
});
test('up to date downloads nothing', () async {
final root = await fakeRoot();
final rc = await runUpgrade(
currentVersion: '0.2.0',
manifestUrl: manifestUrl(),
installRoot: root);
expect(rc, 0);
expect(tarHits, 0);
expect(await File('${root.path}/watch_together').readAsString(),
'binary-v1');
});
test('bad hash aborts, install untouched', () async {
await server.close(force: true);
server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
server.listen((req) async {
if (req.uri.path == '/manifest.json') {
req.response
..statusCode = 200
..write(jsonEncode({
'version': '0.2.0',
'protocol': 4,
'url':
'http://127.0.0.1:${server.port}/watch-together-0.2.0.tar.gz',
'sha256': '0' * 64,
'notes': '',
}));
} else {
tarHits++;
req.response
..statusCode = 200
..add(tarball);
}
await req.response.close();
});
final root = await fakeRoot();
final rc = await runUpgrade(
currentVersion: '0.1.0',
manifestUrl: manifestUrl(),
installRoot: root);
expect(rc, 1);
expect(await File('${root.path}/watch_together').readAsString(),
'binary-v1');
});
test('unknown layout asks for manual update', () async {
final rc = await runUpgrade(
currentVersion: '0.1.0',
manifestUrl: manifestUrl(),
executablePath: '${work.path}/stray-binary');
expect(rc, 2);
});
});
}
+51 -85
View File
@@ -1,99 +1,65 @@
# ROADMAP — sync_relay_project (canonical phase tracker) # ROADMAP — sync_relay_project (backlog tracker)
This is the durable source of truth for phases. Chat is ephemeral; This is the durable source of truth for what is built and what is next.
this file + `HANDOFF.md` (root) + `PROTOCOL.md` / `ARCHITECTURE.md` Chat is ephemeral; this file + `HANDOFF.md` (root) + `PROTOCOL.md` /
carry state across sessions. Update this file when a phase changes `ARCHITECTURE.md` carry state across sessions. Check an item off when it
state. Proto bumps still require PROTOCOL.md same-commit. lands, same commit as the code. Proto bumps still require PROTOCOL.md
same-commit.
Model: direct-serve (host opens ports, clients open nothing). The Model: direct-serve (host opens ports, clients open nothing). The
brief's old WSS host-upload tunnel is obsolete unless a VPS tier brief's old WSS host-upload tunnel is obsolete unless a VPS tier
needs remote-host upload. Server = Relic, client WS = needs remote-host upload. Server = Relic, client WS =
web_socket_channel. Protocol still v4. web_socket_channel. Protocol still v4.
## Status legend ## Built (phases 0–4 + hardening — done, details in git history)
- DONE: exit criteria met, tests green at the time. Scaffold + CI, mpv Range contract, sessions/tokens/media endpoint,
- PARTIAL: shipped + tested, exits still open. sync engine + quorum, desktop shell (SyncClient, RoomDiff, media_kit,
- TODO: not started or explicitly deferred. avatars, toasts+sheet, reconnect), presence hardening, frameless
chrome, chat/idle fixes, chat double-row fix, avatar 256 KiB cap,
Linux tarball packager + Discord upload card, updater v1
(`--upgrade`: manifest check, download, sha256, apply-over).
WAN-tested 2-client. Counts at last green: core 42, server 41,
desktop 17 + 7 updater.
## Phase 0 — Scaffold (DONE) ## Backlog (priority order, each one sitting-sized)
Monorepo, path deps, CI (`dart analyze` + `dart test` on core/server), 1. Update endpoint live: publish `updates/manifest.json` + first
logging/config/errors, Flutter-free enforcement tests, relay binary release tarball on Forgejo (user-owned step; schema in
binds/logs/exits, stub ARCHITECTURE.md. `updates/manifest.example.json`). Until then `--upgrade` reports
up-to-date against a missing manifest.
2. Jumpy seek: live repro with timeline log first, then fix the
seek-land retry vs deadband/debounce fight.
3. Sounds swap-in: replace `NoopSfxService` (flutter_soloud preload,
Kenney/ObsydianX/akx sources in HANDOFF §7); keep seek/pause/
resume cues muted or near-silent.
4. `/health` auth + stop broadcasting the full media URL (api_key
leaks onto the wire).
5. File-join wart: welcome mediaUrl advertises 127.0.0.1, remote file
joiners fail-open then self-heal on first room broadcast.
6. Cache-exhaustion long outage via `tool/shaper.dart --kill-after`.
7. Phase 5 chaos suite (host drop 5 s / 30 s, viewer stall, 20-seek
storm) + OOM / stuck-room / drift-recovery assertions.
8. Windows zip in `tool/package_linux.sh`'s sibling + Discord card.
9. TLS/Caddy doc.
10. Chat history pane (schema exists, Activity log only for now).
11. Backfill LAN soak + 1.4 GB test details (record-only, user ran
both 09-12).
## Phase 1 — mpv HTTP contract (DONE) ## Epics (multi-sitting, scheduled whole — not before the backlog
friction items above, except Jellyfin which is next feature)
Relic `resolveMediaRequest` + `mountMediaRoute`, full Range table - File relay (original brief, second half): links shipped, relay
(200 / 206×3 shapes / 416 / 401 / 403 / 410 / 503), byte-exact tests, unbuilt. Split into sub-tasks when scheduled.
real mpv traces in `mpv-http-contract.md`, `tool/range_logger.dart` - Jellyfin native — NEXT feature.
+ `tool/shaper.dart` (KB/s cap, latency, --kill-after). - Updater expansion: in-app prompt, Windows helper (locked-exe
dance), auto-restart after apply.
- Playlist (`queue-add`/`queue-skip`, shape reserved in PROTOCOL.md),
sidecar subtitles, local-file fallback (viewer plays matching
size+duration file locally, still synced), `HttpSource` (host
pastes a direct-stream URL, server fetches + re-serves).
## Phase 2 — Sessions + media endpoint (DONE) ## Deferred (needs explicit approval; transcode is never)
Secret → 128-bit token, 401/403/410 gates, kick kills WS+media, P2P/WebRTC, multi-session, Android. Transcode: NEVER (user decision
byte counters, `tool/fake_viewer.dart` ALL PASS live. Proto v2→v3 09-12 — not the age for it anymore).
for `kicked`/`kick`. Media: local-file relay SHIPPED
(`FileMediaSource` + token-gated Range route); links path SHIPPED
(server distributes shared URL, viewers stream direct);
`HttpSource` (server-side URL fetch) DEFERRED to Phase 8; WSS
upload tunnel NOT BUILT (spec reserved in ARCHITECTURE.md).
## Phase 3 — Sync engine + readiness (DONE)
`RoomEngine` (injectable clock): server-owned clock, reports never
move it, stale-eventId guard, free seeks, PLAYING/HELD gate, quorum
(in-set≥2 unless ≤1 fresh), swap reset, force with actor feed.
22+ engine tests, `tool/e2e_check.dart` 15/15 sim. Syncplay audit
done (conscious deviations documented in HANDOFF §2). Proto v4.
Follow-ups landed: NaN guards, name/chat caps, stale vs leave feed,
1 Hz feed flush, silent-drop (15 s stale / 30 s drop + close).
## Phase 4 — Desktop app (PARTIAL)
Shipped: SyncClient framing, RoomDiff (eventId diff, 0.95× latch),
media_kit wiring, Space=ready, avatars, toasts+sheet, health display,
reconnect loop. Counts at last green: core 42, server 40, desktop 17.
Late 09-12 session: internet test PASSED (WAN, port 6969, 2-client);
server logging wired into app (`initLogging` in `main.dart` + hello
join/reject logs in `room_hub.dart`); Cancel aborts a pending
connect (`SyncClient.disconnect` completes the welcome future);
first-join failure shows the dialog and stops (auto-reconnect only
after an established session drops).
Open exits (manual, user-driven): bisect flags REMOVED 09-12;
2-client LAN soak + 1.4 GB real-file test user-reports-done 09-12
(details pending). Still open: cache-exhaustion long outage.
## Phase 5 — Resilience (PARTIAL)
Shipped: isClosed sweep, onError forget, stop() closes sockets,
silence-timeout + reconnect loop. Never run: shaper chaos suite
(host drop 5 s / 30 s, viewer stall, 20-seek storm), OOM / stuck-room
/ drift-recovery assertions, CI wiring.
## Phase 6 — Identity & social (PARTIAL)
Shipped: names (32 cap), avatars (256 KiB cap, 128 px PNG picker, transparency kept), kick nudge
+ reason, feed attribution. Open: real chat pane/history (schema
only), sounds swap-in (wiring + NoopSfxService ready; pick
flutter_soloud or equivalent, Kenney/ObsydianX/akx sources in
HANDOFF §7).
## Phase 7 — Deployment & updates (TODO, deferred)
Thin `bin/server.dart` compiles; systemd unit exists. Missing:
Windows zip, Linux tar/AppDir, TLS/Caddy doc, update manifest +
verifier. Needs explicit user go + code-signing decision.
## Phase 8 — Optional quality (TODO, deferred)
Playlist (`queue-add`/`queue-skip`, shape reserved in PROTOCOL.md),
sidecar subtitles, local-file fallback (viewer plays matching
size+duration file locally, still synced — saves host uplink),
`HttpSource` (host pastes a direct-stream URL, server fetches +
re-serves — makes Jellyfin usable through the whitelist today, no
native integration). No code until approved.
## Deferred backlog (needs explicit approval)
Jellyfin native, P2P/WebRTC, multi-session, transcode, Android.
+1 -1
View File
@@ -30,7 +30,7 @@ VERSION="$(sed -n 's/^version: \([0-9.]*\).*/\1/p' "$APP/pubspec.yaml")"
if [[ -z "$VERSION" ]]; then echo "error: version not found in $APP/pubspec.yaml" >&2; exit 1; fi if [[ -z "$VERSION" ]]; then echo "error: version not found in $APP/pubspec.yaml" >&2; exit 1; fi
if [[ "$SKIP_BUILD" != "1" ]]; then if [[ "$SKIP_BUILD" != "1" ]]; then
(cd "$APP" && flutter build linux --release) (cd "$APP" && flutter build linux --release --dart-define=APP_VERSION="$VERSION")
fi fi
BUNDLE="$APP/build/linux/$ARCH/release/bundle" BUNDLE="$APP/build/linux/$ARCH/release/bundle"
+7
View File
@@ -0,0 +1,7 @@
{
"version": "0.2.0",
"protocol": 4,
"url": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v0.2.0/watch-together-0.2.0-linux-x64.tar.gz",
"sha256": "<sha256 of the tarball, filled in at release time>",
"notes": "One-line highlight of what changed."
}