From d77b0e804d80eb8f2923a78ff83287ef4d298319 Mon Sep 17 00:00:00 2001 From: foxyz Date: Sat, 12 Sep 2026 18:12:28 +0300 Subject: [PATCH] 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. --- HANDOFF.md | 43 +++-- apps/desktop/lib/main.dart | 5 + apps/desktop/lib/src/update/updater.dart | 236 +++++++++++++++++++++++ apps/desktop/pubspec.lock | 4 +- apps/desktop/pubspec.yaml | 2 + apps/desktop/test/updater_test.dart | 173 +++++++++++++++++ docs/ROADMAP.md | 136 +++++-------- tool/package_linux.sh | 2 +- updates/manifest.example.json | 7 + 9 files changed, 505 insertions(+), 103 deletions(-) create mode 100644 apps/desktop/lib/src/update/updater.dart create mode 100644 apps/desktop/test/updater_test.dart create mode 100644 updates/manifest.example.json diff --git a/HANDOFF.md b/HANDOFF.md index e943b0c..55e275b 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,7 +1,10 @@ # HANDOFF — sync_relay_project (watch-together app) 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; Visibility keep-mounted) + VERIFIED, incl. over internet. Bisect 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 secret). Kick = transient nudge: revoke + close + `kicked` reason, instant rejoin with fresh token. -- Deferred, do not build: TLS/Caddy, Jellyfin native, auto-updater, - playlist (extension shape reserved in PROTOCOL.md), op roles, sounds +- Deferred, do not build: TLS/Caddy, Jellyfin native, updater + 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). - Standing rules: health reporting (every failure displays, nothing 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; dialogs.dart: kick confirm; media_input.dart: parseMediaInput). - `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; needs mpv + ~/Videos/chiyo.mp4), coord_presence_test (dead server clears + reconnects; needs mpv). - `tool/`: range_logger, shaper (chaos: KB/s cap, latency, --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, normative), ARCHITECTURE.md (+health rule), mpv-http-contract.md. - `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). 4. 1.4 GB real-file media test — user reports DONE 09-12 (details pending; api_key must never land in files). -5. Cache-exhaustion long outage via shaper --kill-after (Phase 5 - chaos assertion, never run). -6. Sounds swap-in (§7) after audition. -7. File-join wart: welcome mediaUrl uses advertiseHost 127.0.0.1, - 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. +5.–8. MIGRATED 09-12 to `docs/ROADMAP.md` backlog (cache-exhaustion, + sounds, file-join wart, /health) — the roadmap is now the single + backlog; this section keeps session history + user-owned pendings + only. 9. FIXED 09-12: auto-unready fired while passively watching — the 10-min check keyed off `_lastInput`, refreshed only by 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 change, still v4. Chat field also keeps focus after send (dedicated FocusNode, re-requested on submit/button). -11. KNOWN (non-breaking, fix later): seeks feel jumpy - (forward-back-forward) — suspect the seek-land retry +11. KNOWN (non-breaking, fix later — roadmap backlog #2): seeks feel + jumpy (forward-back-forward) — suspect the seek-land retry (`_checkSeekLanded`, `session_coordinator.dart:429`) re-applying SeekAction against the 0.5 s deadband / 2 s debounce and the 1 Hz room echo. Needs a live repro with a 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, ~/Videos/chiyo.mp4 sample. Never kill the user's Syncplay mpv instance. Git repo at project root (init 09-12); commit code + docs diff --git a/apps/desktop/lib/main.dart b/apps/desktop/lib/main.dart index 18118fb..b59b1cb 100644 --- a/apps/desktop/lib/main.dart +++ b/apps/desktop/lib/main.dart @@ -8,8 +8,13 @@ import 'package:window_manager/window_manager.dart'; import 'app.dart'; import 'src/state/session_state.dart'; +import 'src/update/updater.dart'; Future main(List args) async { + if (args.contains('--upgrade')) { + exitCode = await runUpgrade(); + exit(exitCode); + } initLogging(); if (args.contains('--headless')) { await runHeadless(args); diff --git a/apps/desktop/lib/src/update/updater.dart b/apps/desktop/lib/src/update/updater.dart new file mode 100644 index 0000000..81f0b0f --- /dev/null +++ b/apps/desktop/lib/src/update/updater.dart @@ -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 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 parseVersion(String v) { + final out = []; + 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 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) { + throw const FormatException('manifest is not a JSON object'); + } + return UpdateManifest.fromJson(decoded); + } finally { + client.close(); + } +} + +Future 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 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 / with /data and /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 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); + } 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 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; +} diff --git a/apps/desktop/pubspec.lock b/apps/desktop/pubspec.lock index d49fc48..f034c5b 100644 --- a/apps/desktop/pubspec.lock +++ b/apps/desktop/pubspec.lock @@ -10,7 +10,7 @@ packages: source: hosted version: "1.1.1" archive: - dependency: transitive + dependency: "direct main" description: name: archive sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19 @@ -90,7 +90,7 @@ packages: source: hosted version: "0.3.5+5" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf diff --git a/apps/desktop/pubspec.yaml b/apps/desktop/pubspec.yaml index 66495db..c404d8c 100644 --- a/apps/desktop/pubspec.yaml +++ b/apps/desktop/pubspec.yaml @@ -23,6 +23,8 @@ dependencies: hive_ce_flutter: ^2.3.4 image: ^4.9.2 window_manager: ^0.5.2 + archive: ^4.0.7 + crypto: ^3.0.6 dev_dependencies: flutter_test: sdk: flutter diff --git a/apps/desktop/test/updater_test.dart b/apps/desktop/test/updater_test.dart new file mode 100644 index 0000000..273d3d6 --- /dev/null +++ b/apps/desktop/test/updater_test.dart @@ -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 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 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); + }); + }); +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 20c588b..a0d879f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 file + `HANDOFF.md` (root) + `PROTOCOL.md` / `ARCHITECTURE.md` -carry state across sessions. Update this file when a phase changes -state. Proto bumps still require PROTOCOL.md same-commit. +This is the durable source of truth for what is built and what is next. +Chat is ephemeral; this file + `HANDOFF.md` (root) + `PROTOCOL.md` / +`ARCHITECTURE.md` carry state across sessions. Check an item off when it +lands, same commit as the code. Proto bumps still require PROTOCOL.md +same-commit. Model: direct-serve (host opens ports, clients open nothing). The brief's old WSS host-upload tunnel is obsolete unless a VPS tier needs remote-host upload. Server = Relic, client WS = 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. -- PARTIAL: shipped + tested, exits still open. -- TODO: not started or explicitly deferred. +Scaffold + CI, mpv Range contract, sessions/tokens/media endpoint, +sync engine + quorum, desktop shell (SyncClient, RoomDiff, media_kit, +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), -logging/config/errors, Flutter-free enforcement tests, relay binary -binds/logs/exits, stub ARCHITECTURE.md. +1. Update endpoint live: publish `updates/manifest.json` + first + release tarball on Forgejo (user-owned step; schema in + `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 -(200 / 206×3 shapes / 416 / 401 / 403 / 410 / 503), byte-exact tests, -real mpv traces in `mpv-http-contract.md`, `tool/range_logger.dart` -+ `tool/shaper.dart` (KB/s cap, latency, --kill-after). +- File relay (original brief, second half): links shipped, relay + unbuilt. Split into sub-tasks when scheduled. +- Jellyfin native — NEXT feature. +- 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, -byte counters, `tool/fake_viewer.dart` ALL PASS live. Proto v2→v3 -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. +P2P/WebRTC, multi-session, Android. Transcode: NEVER (user decision +09-12 — not the age for it anymore). diff --git a/tool/package_linux.sh b/tool/package_linux.sh index 79e393e..572becb 100755 --- a/tool/package_linux.sh +++ b/tool/package_linux.sh @@ -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 [[ "$SKIP_BUILD" != "1" ]]; then - (cd "$APP" && flutter build linux --release) + (cd "$APP" && flutter build linux --release --dart-define=APP_VERSION="$VERSION") fi BUNDLE="$APP/build/linux/$ARCH/release/bundle" diff --git a/updates/manifest.example.json b/updates/manifest.example.json new file mode 100644 index 0000000..5e47177 --- /dev/null +++ b/updates/manifest.example.json @@ -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": "", + "notes": "One-line highlight of what changed." +}