Compare commits
3
Commits
b2173e82c8
...
34e5dfb24c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34e5dfb24c | ||
|
|
b0d93758f9 | ||
|
|
d77b0e804d |
No files matched your search
File renamed without changes.
@@ -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 }}"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
+72
-85
@@ -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.
|
||||||
|
- 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.
|
|
||||||
@@ -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"
|
||||||
|
|||||||
Executable
+84
@@ -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."
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
Reference in new issue
Block a user