3 Commits
Author SHA1 Message Date
foxyz 34e5dfb24c Agent conventions + ignore wishlist drafts
ci / core (push) Successful in 25s
ci / server (push) Successful in 27s
AGENTS.md: wishlist-draft flow, few-commits norm, release flow,
secret handling. .gitignore: drafts stay local-only.
2026-09-12 21:01:39 +03:00
foxyz b0d93758f9 CI release pipeline in .gitea, wishlist into backlog
release.yml (tag v* or manual dispatch): beta Flutter per local
convention, Linux build deps, package script tarball, artifact
upload, then release_publish.sh creates the Forgejo release,
uploads the asset, and commits updates/manifest.json back to
main. Workflows moved .github -> .gitea to match the forge setup.
Wishlist.md consumed into roadmap backlog: EOF stall + tiny buffer
bugs prioritized, recovery controls, link conveniences, time UI,
shortcuts, shaders, avatar serving.
2026-09-12 20:38:17 +03:00
foxyz d77b0e804d 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.
2026-09-12 18:12:28 +03:00
14 changed files with 693 additions and 102 deletions

No files matched your search

File renamed without changes.
+43
View File
@@ -0,0 +1,43 @@
name: release
on:
push:
tags: ['v*']
workflow_dispatch:
jobs:
linux:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Linux build deps
run: |
if command -v apt-get >/dev/null; then
sudo apt-get update
sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev
fi
- name: Setup Flutter (beta)
run: |
git clone https://github.com/flutter/flutter.git -b beta --depth 1 ~/flutter
echo "$HOME/flutter/bin" >> $GITHUB_PATH
- name: Build tarball
run: tool/package_linux.sh
- name: Upload tarball artifact
uses: actions/upload-artifact@v4
with:
name: linux-tarball
path: dist/*.tar.gz
- name: Publish release + manifest
if: startsWith(github.ref, 'refs/tags/v')
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: tool/release_publish.sh "${{ github.ref_name }}"
+5
View File
@@ -23,6 +23,11 @@ app.*.map.json
# Logs # Logs
*.log *.log
# Local-only agent draft lists (raw wishlists; translated into docs by an
# agent, never committed) — see AGENTS.md
docs/Wishlist.md
docs/drafts/
# IDE # IDE
.idea/ .idea/
*.iml *.iml
+34
View File
@@ -0,0 +1,34 @@
# AGENTS.md — sync_relay_project
Project conventions for agents. Global interaction rules live in the
user-level AGENTS.md; this file holds repo-specific facts only.
Source of truth order: codebase > `HANDOFF.md` + `docs/ROADMAP.md` >
chat history.
## Wishlist drafts
The user drops raw idea/bug lists as drafts (gitignored, never
committed: `docs/Wishlist.md`, `docs/drafts/`). Agent duty on sight:
translate each entry into its proper place — `docs/ROADMAP.md`
backlog (ordered, one sitting-sized item each) or Epics, real bugs
with repro detail preserved — then confirm placement with the user
and delete the draft. Drafts must never be committed, even partially.
## Commits
Few commits, one per topic/session — never per fix. Rewriting
unpushed history (amend, squash) is expected, not exceptional.
Commit code + its docs together. No `flutter analyze` unless asked
or diagnosing; scoped `dart analyze` on touched files is the
pre-commit check. Tests run only when the code under test changed.
## Releases (user pushes tags, CI does the rest)
Release flow: release commit bumps `apps/desktop/pubspec.yaml` →
`git tag vX.Y.Z` → push tag → `.gitea/workflows/release.yml`
builds, publishes the Forgejo release, commits
`updates/manifest.json` back. `tool/release_publish.sh` is the same
flow by hand. Workflows live in `.gitea/workflows` (this forge reads
`.gitea` first, `.github` is ignored when it exists).
Never commit secrets: tokens arrive via env (`DISCORD_WEBHOOK_URL`,
`RELEASE_TOKEN`) or Actions-provided (`GITHUB_TOKEN/GITEA_TOKEN`).
+30 -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,26 @@ 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`). Publishing is CI-driven: tag `vX.Y.Z` →
`.gitea/workflows/release.yml` builds, creates the Forgejo
release, uploads the tarball, commits the manifest back
(`tool/release_publish.sh` does the same by hand).
Manual trigger only: bump desktop pubspec in a release commit
first (script enforces tag == pubspec). 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);
});
});
}
+71 -84
View File
@@ -1,99 +1,86 @@
# 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. First real release: release commit bumps desktop pubspec, tag
logging/config/errors, Flutter-free enforcement tests, relay binary `v0.2.0`, CI (`release.yml`) builds the tarball, publishes the
binds/logs/exits, stub ARCHITECTURE.md. Forgejo release, and commits the manifest back. Verifies the
whole `--upgrade` chain end to end. Manual fallback is
`tool/package_linux.sh` + `tool/release_publish.sh <tag>`.
2. Jumpy seek: live repro with timeline log first, then fix the
seek-land retry vs deadband/debounce fight.
3. EOF stall (bug, dogfood 09-12): playback stops just before the
end and gates HELD ("waiting for X (3.9s)"). Suspect the
buffered≥4s readiness gate is unsatisfiable near end of media.
4. Tiny buffer (bug, dogfood 09-12): 1–3 s observed; want ~2 min
for a 1.4 GB / 24 min file (intro skip + surviving short
drops). Investigate demuxer forward-queue props + 64 MB
`bufferSize`, then raise.
5. Recovery controls: reload-current-media button + restart-player
button for when playback wedges.
6. Link conveniences: Ctrl+V pastes clipboard into the media
field; session link swaps update the remembered media (today
only the connect screen persists); session shows the open link
with an open-in-browser button.
7. Time UI: remaining time until end + scrubber preview (target
time while scrubbing).
8. Shortcut parity pass with mpv / mpv.net.
9. Sounds swap-in: replace `NoopSfxService` (flutter_soloud preload,
Kenney/ObsydianX/akx sources in HANDOFF §7); keep seek/pause/
resume cues muted or near-silent.
10. `/health` auth + stop broadcasting the full media URL (api_key
leaks onto the wire).
11. File-join wart: welcome mediaUrl advertises 127.0.0.1, remote file
joiners fail-open then self-heal on first room broadcast.
12. Cache-exhaustion long outage via `tool/shaper.dart --kill-after`.
13. Phase 5 chaos suite (host drop 5 s / 30 s, viewer stall, 20-seek
storm) + OOM / stuck-room / drift-recovery assertions.
14. Windows zip in packaging + Discord card.
15. TLS/Caddy doc.
16. Chat history pane (schema exists, Activity log only for now).
17. Shaders: anime4k upscaling via mpv glsl-shaders + UI toggle.
18. Avatar caching + static serving (worth-it decision open):
serve avatar bytes once over HTTP by hash instead of
base64-in-every-room-broadcast.
19. 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.
## Phase 2 — Sessions + media endpoint (DONE) - Playlist (`queue-add`/`queue-skip`, shape reserved in PROTOCOL.md),
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 sidecar subtitles, local-file fallback (viewer plays matching
size+duration file locally, still synced — saves host uplink), size+duration file locally, still synced), `HttpSource` (host
`HttpSource` (host pastes a direct-stream URL, server fetches + pastes a direct-stream URL, server fetches + re-serves).
re-serves — makes Jellyfin usable through the whitelist today, no
native integration). No code until approved.
## Deferred backlog (needs explicit approval) ## Deferred (needs explicit approval; transcode is never)
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).
+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"
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# Publish a release to Forgejo: create the release, upload the Linux
# tarball built by tool/package_linux.sh, and commit the updated
# updates/manifest.json back to main so --upgrade clients see the new
# version at the stable raw URL.
# Usage: tool/release_publish.sh vX.Y.Z (run after package_linux.sh)
# Env: RELEASE_TOKEN (preferred) or the Actions-provided GITHUB_TOKEN /
# GITEA_TOKEN (needs repo write or the final push is denied — then set a
# RELEASE_TOKEN secret with write access).
set -euo pipefail
TAG="${1:?usage: tool/release_publish.sh vX.Y.Z}"
VER="${TAG#v}"
[[ "$VER" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "error: tag must look like v0.2.0, got $TAG" >&2; exit 1; }
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TARBALL="$ROOT/dist/watch-together-$VER-linux-x64.tar.gz"
[[ -f "$TARBALL" ]] || { echo "error: run tool/package_linux.sh first ($TARBALL missing)" >&2; exit 1; }
PUB="$(sed -n 's/^version: \([0-9.]*\).*/\1/p' "$ROOT/apps/desktop/pubspec.yaml")"
[[ "$PUB" == "$VER" ]] || { echo "error: tag $TAG != desktop pubspec $PUB (bump it in a release commit first)" >&2; exit 1; }
ORIGIN="$(git -C "$ROOT" remote get-url origin)"
SERVER="$(echo "$ORIGIN" | sed -E 's#(https?://[^/]+)/.*#\1#')"
REPO="$(echo "$ORIGIN" | sed -E 's#.*[:/]([^/]+/[^/]+?)(\.git)?$#\1#')"
TOKEN="${RELEASE_TOKEN:-${GITHUB_TOKEN:-${GITEA_TOKEN:-}}}"
[[ -n "$TOKEN" ]] || { echo "error: set RELEASE_TOKEN (or run in Actions with a write token)" >&2; exit 1; }
PROTO="$(sed -n 's/.*protoVersion = \([0-9]*\).*/\1/p' "$ROOT/packages/core/lib/src/constants.dart")"
SHA="$(sha256sum "$TARBALL" | cut -d' ' -f1)"
ASSET="$(basename "$TARBALL")"
PREV="$(git -C "$ROOT" describe --tags --abbrev=0 HEAD 2>/dev/null || true)"
if [[ -n "$PREV" && "$PREV" != "$TAG" ]]; then
NOTES="$(git -C "$ROOT" log "$PREV..HEAD" --format='- %s' | head -n 20)"
else
NOTES="Watch together $VER."
fi
export REL_TAG="$TAG" REL_VER="$VER" REL_NOTES="$NOTES"
echo "creating release $TAG on $REPO ..."
REL_ID="$(curl -fsS -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
-d "$(python3 -c 'import json,os; print(json.dumps({"tag_name": os.environ["REL_TAG"], "name": os.environ["REL_TAG"], "body": "Watch together " + os.environ["REL_VER"] + "\n\n" + os.environ["REL_NOTES"], "draft": False, "prerelease": False}))')" \
"$SERVER/api/v1/repos/$REPO/releases" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
echo "release id: $REL_ID"
echo "uploading $ASSET ..."
curl -fsS -H "Authorization: token $TOKEN" -H 'Content-Type: application/gzip' \
--data-binary "@$TARBALL" \
"$SERVER/api/v1/repos/$REPO/releases/$REL_ID/assets?name=$ASSET" > /dev/null
export MANIFEST_FILE="$ROOT/updates/manifest.json" MANIFEST_VER="$VER" \
MANIFEST_PROTO="$PROTO" MANIFEST_SHA="$SHA" \
MANIFEST_URL="$SERVER/$REPO/releases/download/$TAG/$ASSET"
python3 - <<'EOF'
import json, os
path = os.environ['MANIFEST_FILE']
try:
with open(path) as f:
manifest = json.load(f)
except (FileNotFoundError, ValueError):
manifest = {}
manifest.update({
'version': os.environ['MANIFEST_VER'],
'protocol': int(os.environ['MANIFEST_PROTO']),
'url': os.environ['MANIFEST_URL'],
'sha256': os.environ['MANIFEST_SHA'],
'notes': os.environ['REL_NOTES'].splitlines()[0] if os.environ['REL_NOTES'] else '',
})
with open(path, 'w') as f:
json.dump(manifest, f, indent=2)
f.write('\n')
EOF
echo "committing manifest back to main ..."
git -C "$ROOT" fetch origin main 2>/dev/null || true
git -C "$ROOT" checkout -B main origin/main
git -C "$ROOT" add updates/manifest.json
git -C "$ROOT" -c user.name='watch-together-release' -c user.email='release@localhost' \
commit -m "Release $TAG manifest" -- updates/manifest.json
if ! git -C "$ROOT" push -o ci.skip origin main 2>/dev/null; then
git -C "$ROOT" push origin main
fi
echo "published $TAG — manifest live at updates/manifest.json on main."
+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."
}