3 Commits
Author SHA1 Message Date
foxyz 9c5733719b Release v0.2.2
ci / core (push) Successful in 24s
ci / updater-windows (push) Successful in 45s
ci / server (push) Successful in 36s
release / linux (push) Successful in 3m31s
release / windows (push) Successful in 4m49s
2026-10-08 05:30:59 +03:00
foxyzandClaude Opus 5.5 50bcc2e1a2 CI: Windows build + release on yuuka-windows
ci / core (push) Successful in 32s
ci / server (push) Successful in 44s
ci / updater-windows (push) Successful in 1m25s
tool/package_windows.ps1 builds watch-together-<ver>-windows-x64.zip
(same layout as the Linux tarball, libmpv bundled, update.bat and
watch-together-server.exe). The release workflow gets a windows job;
ci runs the updater tests on Windows. Publishing moves to
tool/release_publish.dart (dart: only, no python/bash on Windows), which
tolerates both jobs racing to create the release and replaces an asset
on re-runs. release.sh writes both asset URLs into the manifest.
Windows jobs check out with plain git (the host's cached
actions/checkout clone was corrupt).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:27:38 +03:00
foxyzandClaude Opus 5.5 2c21cfde11 Updater: per-platform manifest assets, Windows apply tested on NTFS
The manifest gains `assets` (linux-x64 / windows-x64 → archive URL);
top-level `url` stays the Linux tarball for 0.2.1 installs. A Windows-
only test applies a bsdtar zip over a running exe (PING.EXE copy) and
checks it is parked as .old and swept next start; Linux-layout tests
(exec bits, symlinks) skip on Windows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:27:38 +03:00
18 changed files with 442 additions and 103 deletions

No files matched your search

+21
View File
@@ -36,3 +36,24 @@ jobs:
working-directory: packages/server working-directory: packages/server
- run: build/watch-together-server --help - run: build/watch-together-server --help
working-directory: packages/server working-directory: packages/server
# The updater's Windows apply path (running exe parked as .old) only
# means something on real NTFS. Runner notes: see release.yml.
updater-windows:
runs-on: windows-latest
steps:
- name: Checkout
shell: powershell
run: |
git init -q .
git remote add origin "$env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY.git"
git fetch -q origin "$env:GITHUB_SHA"
if ($LASTEXITCODE) { exit 1 }
git checkout -q --force FETCH_HEAD
- name: Setup Flutter
shell: powershell
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/setup_flutter_windows.ps1
- name: Updater tests
shell: powershell
working-directory: apps/desktop
run: flutter test test/updater_test.dart
+39 -1
View File
@@ -36,4 +36,42 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: tool/release_publish.sh "${{ github.ref_name }}" run: |
VER="${GITHUB_REF_NAME#v}"
dart tool/release_publish.dart "$GITHUB_REF_NAME" "dist/watch-together-$VER-linux-x64.tar.gz"
windows:
# yuuka-windows: host-mode runner (no containers), runs as SYSTEM, has
# git + VS 2022 Build Tools but no bash/python — PowerShell and Dart
# only. Checkout is plain git: that host's cached actions/checkout
# clone was corrupt, and no `uses:` means no action cache to rot.
runs-on: windows-latest
steps:
- name: Checkout
shell: powershell
run: |
git init -q .
git remote add origin "$env:GITHUB_SERVER_URL/$env:GITHUB_REPOSITORY.git"
git fetch -q --tags origin "$env:GITHUB_SHA"
if ($LASTEXITCODE) { exit 1 }
git checkout -q --force FETCH_HEAD
- name: Setup Flutter
shell: powershell
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/setup_flutter_windows.ps1
- name: Build zip
shell: powershell
run: powershell -NoProfile -ExecutionPolicy Bypass -File tool/package_windows.ps1
- name: Publish release
if: startsWith(github.ref, 'refs/tags/v')
shell: powershell
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
$Ver = $env:GITHUB_REF_NAME.TrimStart('v')
dart tool/release_publish.dart $env:GITHUB_REF_NAME "dist/watch-together-$Ver-windows-x64.zip"
if ($LASTEXITCODE) { exit 1 }
+10 -5
View File
@@ -11,7 +11,7 @@ Name: `watch-together` is a placeholder and the final name is undecided
(`sync-relay` is the working name). It appears in file names and the (`sync-relay` is the working name). It appears in file names and the
update manifest until that is settled. update manifest until that is settled.
- Desktop only (Linux now, Windows planned), always mpv. No web clients. - Desktop only (Linux and Windows), always mpv. No web clients.
- No transcoding. Everyone plays the original file. - No transcoding. Everyone plays the original file.
- Friends trust model: no accounts, no roles. A blank password means an open server. - Friends trust model: no accounts, no roles. A blank password means an open server.
@@ -19,14 +19,19 @@ How it fits together: [docs/HOW-IT-WORKS.md](docs/HOW-IT-WORKS.md).
## Install and update ## Install and update
Download the latest `watch-together-<version>-linux-x64.tar.gz` from the From the Releases page of this repo:
Releases page of this repo, extract it, run `watch_together`.
- Linux: `watch-together-<version>-linux-x64.tar.gz`. Extract, run
`watch_together`. Needs mpv's library installed (`mpv` or `libmpv2`).
- Windows: `watch-together-<version>-windows-x64.zip`. Extract, run
`watch_together.exe`. mpv is bundled. New and not yet tried on a real
Windows desktop; reports welcome.
Update from the app: when a newer version exists, an update indicator Update from the app: when a newer version exists, an update indicator
appears in Home's top bar and opens the update card. "Update and restart" downloads, appears in Home's top bar and opens the update card. "Update and restart" downloads,
verifies, applies in place and restarts; the install folder must be writable verifies, applies in place and restarts; the install folder must be writable
by you. Settings has Check now and an auto-check toggle. From a terminal: by you. Settings has Check now and an auto-check toggle. From a terminal:
`watch_together --upgrade` or `update.sh` (shipped in the tarball). A `watch_together --upgrade`, or the shipped `update.sh` / `update.bat`. A
release with a newer protocol is a required update: everyone on a server release with a newer protocol is a required update: everyone on a server
must run the same protocol version, and a mismatch is refused with a message must run the same protocol version, and a mismatch is refused with a message
telling you to update. telling you to update.
@@ -214,7 +219,7 @@ fails to load shows a toast and switches shaders off.
- "host runs a different version": update the app on both sides. - "host runs a different version": update the app on both sides.
- Picture frozen but sound fine: Ctrl+R reloads the video; the app also does it automatically. - Picture frozen but sound fine: Ctrl+R reloads the video; the app also does it automatically.
- Everyone waits at "waiting for X": X has not buffered 4 seconds yet or has not pressed Space. - Everyone waits at "waiting for X": X has not buffered 4 seconds yet or has not pressed Space.
- No Windows build yet. - Windows: the build is new; if something is off there, say so.
For contributors: [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md), For contributors: [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md),
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), [docs/PROTOCOL.md](docs/PROTOCOL.md). [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md), [docs/PROTOCOL.md](docs/PROTOCOL.md).
+18 -8
View File
@@ -15,10 +15,10 @@ import 'package:sync_relay_core/core.dart';
// Linux: unlinking/renaming files under a running process is fine, only // Linux: unlinking/renaming files under a running process is fine, only
// opening the running binary for writing is refused (ETXTBSY), so the // opening the running binary for writing is refused (ETXTBSY), so the
// swap moves each top-level bundle entry aside, moves the new one in, // swap moves each top-level bundle entry aside, moves the new one in,
// and rolls everything back on any failure. Windows (UNTESTED — no // and rolls everything back on any failure. Windows: a running exe/dll
// Windows runner yet): a running exe/dll can be renamed but not deleted, // can be renamed but not deleted, so in-use files go to `*.old`, new
// so in-use files go to `*.old`, new files move in, and `*.old` is swept // files move in, and `*.old` is swept on the next start
// on the next start (cleanupUpdateLeftovers). // (cleanupUpdateLeftovers). CI runs that against a live exe on NTFS.
final _log = createLogger('update'); final _log = createLogger('update');
@@ -31,6 +31,9 @@ const appVersion = String.fromEnvironment('APP_VERSION',
// Process args as main() received them — restart relaunches with these. // Process args as main() received them — restart relaunches with these.
List<String> launchArgs = const []; List<String> launchArgs = const [];
// Key into the manifest's `assets` map, e.g. linux-x64, windows-x64.
final updatePlatform = '${Platform.operatingSystem}-x64';
const _stagingDir = '.update-staging'; const _stagingDir = '.update-staging';
const _backupDir = '.update-backup'; const _backupDir = '.update-backup';
const _downloadFile = '.update-download'; const _downloadFile = '.update-download';
@@ -48,16 +51,23 @@ class UpdateManifest {
this.sha256, this.sha256,
required this.notes}); required this.notes});
factory UpdateManifest.fromJson(Map<String, Object?> json) { // `assets` maps platform → archive URL. Top-level `url` is the Linux
// tarball, kept for builds from before `assets` existed (<= 0.2.1).
factory UpdateManifest.fromJson(Map<String, Object?> json,
{String? platform}) {
final version = json['version']; final version = json['version'];
final protocol = json['protocol']; final protocol = json['protocol'];
final url = json['url']; final assets = json['assets'];
final url =
assets is Map ? assets[platform ?? updatePlatform] : json['url'];
if (version is! String || if (version is! String ||
version.isEmpty || version.isEmpty ||
protocol is! int || protocol is! int ||
url is! String || url is! String ||
url.isEmpty) { url.isEmpty) {
throw const FormatException('update manifest missing version/protocol/url'); throw FormatException(assets is Map && version is String
? 'release $version has no ${platform ?? updatePlatform} build'
: 'update manifest missing version/protocol/url');
} }
final sha = json['sha256']; final sha = json['sha256'];
return UpdateManifest( return UpdateManifest(
@@ -434,7 +444,7 @@ Future<void> _swapPosix(Directory stage, Directory root, ApplyHook? hook) async
await _deleteQuietly(backup.path); await _deleteQuietly(backup.path);
} }
// UNTESTED on Windows. File-level: a running exe/dll can be renamed but // File-level: a running exe/dll can be renamed but
// not deleted or overwritten, so every existing file goes to `<f>.old` // not deleted or overwritten, so every existing file goes to `<f>.old`
// first; leftovers are swept by cleanupUpdateLeftovers on next start. // first; leftovers are swept by cleanupUpdateLeftovers on next start.
Future<void> _swapWindows(Directory stage, Directory root, ApplyHook? hook) async { Future<void> _swapWindows(Directory stage, Directory root, ApplyHook? hook) async {
+2 -2
View File
@@ -723,14 +723,14 @@ packages:
path: "../../packages/core" path: "../../packages/core"
relative: true relative: true
source: path source: path
version: "0.1.0" version: "0.2.1"
sync_relay_server: sync_relay_server:
dependency: "direct main" dependency: "direct main"
description: description:
path: "../../packages/server" path: "../../packages/server"
relative: true relative: true
source: path source: path
version: "0.1.0" version: "0.2.1"
synchronized: synchronized:
dependency: transitive dependency: transitive
description: description:
+1 -1
View File
@@ -1,7 +1,7 @@
name: watch_together name: watch_together
description: Watch-together desktop app (working title, repo sync_relay_project). One binary. description: Watch-together desktop app (working title, repo sync_relay_project). One binary.
publish_to: none publish_to: none
version: 0.2.1 version: 0.2.2
environment: environment:
sdk: '>=3.13.0-0 <4.0.0' sdk: '>=3.13.0-0 <4.0.0'
+70 -10
View File
@@ -20,13 +20,20 @@ Future<Directory> _work() async {
return base.createTemp('wt-up-test'); return base.createTemp('wt-up-test');
} }
// Linux-layout tests check exec bits and symlinks, which NTFS lacks.
final _posixOnly = Platform.isWindows ? 'POSIX install layout' : null;
bool _isExec(String path) => File(path).statSync().mode & 0x40 != 0; bool _isExec(String path) => File(path).statSync().mode & 0x40 != 0;
// Builds the release archive exactly like tool/package_linux.sh: // Builds the release archive exactly like tool/package_linux.sh:
// bundle copied into <stage>/watch-together-<ver>/, then // bundle copied into <stage>/watch-together-<ver>/, then
// `tar -czf out -C <stage> watch-together-<ver>`. // `tar -czf out -C <stage> watch-together-<ver>`. [zip]: like
// tool/package_windows.ps1 (`tar -a -c -f out.zip`, bsdtar — Windows only).
Future<List<int>> _releaseTarball(Directory work, Future<List<int>> _releaseTarball(Directory work,
{String version = '0.2.0', bool windows = false, bool bundle = true}) async { {String version = '0.2.0',
bool windows = false,
bool bundle = true,
bool zip = false}) async {
final stage = Directory('${work.path}/pkg-$version-${windows ? 'win' : 'lin'}'); final stage = Directory('${work.path}/pkg-$version-${windows ? 'win' : 'lin'}');
final folder = Directory('${stage.path}/watch-together-$version'); final folder = Directory('${stage.path}/watch-together-$version');
await Directory('${folder.path}/data/flutter_assets').create(recursive: true); await Directory('${folder.path}/data/flutter_assets').create(recursive: true);
@@ -44,13 +51,15 @@ Future<List<int>> _releaseTarball(Directory work,
} }
await File('${folder.path}/update.sh') await File('${folder.path}/update.sh')
.writeAsString('#!/bin/sh\nexec ./watch_together --upgrade\n'); .writeAsString('#!/bin/sh\nexec ./watch_together --upgrade\n');
if (!Platform.isWindows) {
final r = await Process.run('chmod', final r = await Process.run('chmod',
['755', '${folder.path}/watch_together', '${folder.path}/update.sh']); ['755', '${folder.path}/watch_together', '${folder.path}/update.sh']);
expect(r.exitCode, 0); expect(r.exitCode, 0);
} }
final out = File('${work.path}/out-$version.tar.gz'); }
final out = File('${work.path}/out-$version.${zip ? 'zip' : 'tar.gz'}');
final r = await Process.run('tar', [ final r = await Process.run('tar', [
'-czf', ...zip ? ['-a', '-c', '-f'] : ['-czf'],
out.path, out.path,
'-C', '-C',
stage.path, stage.path,
@@ -71,8 +80,10 @@ Future<Directory> _fakeRoot(Directory work, {bool windows = false}) async {
await Directory('${root.path}/lib').create(); await Directory('${root.path}/lib').create();
await File('${root.path}/lib/libgone.so').writeAsString('lib-v1'); await File('${root.path}/lib/libgone.so').writeAsString('lib-v1');
await File('${root.path}/watch_together').writeAsString('binary-v1'); await File('${root.path}/watch_together').writeAsString('binary-v1');
if (!Platform.isWindows) {
await Process.run('chmod', ['755', '${root.path}/watch_together']); await Process.run('chmod', ['755', '${root.path}/watch_together']);
} }
}
await File('${root.path}/user-notes.txt').writeAsString('mine'); await File('${root.path}/user-notes.txt').writeAsString('mine');
return root; return root;
} }
@@ -166,6 +177,24 @@ void main() {
isTrue); isTrue);
}); });
test('manifest picks the asset for this platform', () {
final json = {
'version': '0.2.2',
'protocol': RelayConstants.protoVersion,
'url': 'https://x/linux.tar.gz',
'assets': {
'linux-x64': 'https://x/linux.tar.gz',
'windows-x64': 'https://x/windows.zip',
},
};
expect(UpdateManifest.fromJson(json, platform: 'windows-x64').url,
'https://x/windows.zip');
expect(UpdateManifest.fromJson(json, platform: 'linux-x64').url,
'https://x/linux.tar.gz');
expect(() => UpdateManifest.fromJson(json, platform: 'macos-x64'),
throwsFormatException);
});
test('findInstallRoot honors bundle layout', () async { test('findInstallRoot honors bundle layout', () async {
final work = await _work(); final work = await _work();
try { try {
@@ -219,7 +248,7 @@ void main() {
expect(File('$p/data/old.cfg').existsSync(), isFalse); expect(File('$p/data/old.cfg').existsSync(), isFalse);
expect(await File('$p/user-notes.txt').readAsString(), 'mine'); expect(await File('$p/user-notes.txt').readAsString(), 'mine');
expect(_leftovers(root), isEmpty); expect(_leftovers(root), isEmpty);
}); }, skip: _posixOnly);
test('up to date downloads nothing', () async { test('up to date downloads nothing', () async {
final root = await _fakeRoot(work); final root = await _fakeRoot(work);
@@ -256,7 +285,7 @@ void main() {
expect(rc, 0); expect(rc, 0);
expect(await File('${root.path}/watch_together').readAsString(), expect(await File('${root.path}/watch_together').readAsString(),
'binary-v2'); 'binary-v2');
}); }, skip: _posixOnly);
test('unknown layout asks for manual update', () async { test('unknown layout asks for manual update', () async {
final rc = await runUpgrade( final rc = await runUpgrade(
@@ -323,7 +352,7 @@ void main() {
}), }),
throwsA(isA<FileSystemException>())); throwsA(isA<FileSystemException>()));
await expectUntouched(root); await expectUntouched(root);
}); }, skip: _posixOnly);
} }
test('stages next to the install, never in the system temp', () async { test('stages next to the install, never in the system temp', () async {
@@ -354,7 +383,7 @@ void main() {
applyUpdate(f, root, windows: false), throwsFormatException); applyUpdate(f, root, windows: false), throwsFormatException);
expect(File('${work.path}/escape').existsSync(), isFalse); expect(File('${work.path}/escape').existsSync(), isFalse);
await expectUntouched(root); await expectUntouched(root);
}); }, skip: _posixOnly);
test('leftovers from an interrupted run are swept', () async { test('leftovers from an interrupted run are swept', () async {
final root = await _fakeRoot(work); final root = await _fakeRoot(work);
@@ -399,6 +428,37 @@ void main() {
expect(File('$p/flutter_windows.dll.old').existsSync(), isFalse); expect(File('$p/flutter_windows.dll.old').existsSync(), isFalse);
expect(_leftovers(root), isEmpty); expect(_leftovers(root), isEmpty);
}); });
// The real thing: the release zip (bsdtar, like package_windows.ps1)
// applied over an install whose exe is running. Windows refuses to
// delete a running image but allows renaming it, so it is parked as
// .old and swept on the next start.
test('windows: zip over a running exe', () async {
final root = await _fakeRoot(work, windows: true);
final exe = '${root.path}/watch_together.exe';
await File(r'C:\Windows\System32\PING.EXE').copy(exe);
final zip = File('${work.path}/u.zip')
..writeAsBytesSync(
await _releaseTarball(work, windows: true, zip: true));
final running = await Process.start(exe, ['-n', '60', '127.0.0.1']);
try {
expect(() => File(exe).deleteSync(), throwsA(isA<FileSystemException>()),
reason: 'precondition: a running exe is locked');
await applyUpdate(zip, root, windows: true);
expect(await File(exe).readAsString(), 'binary-v2');
expect(File('$exe.old').existsSync(), isTrue);
expect(
await File('${root.path}/data/flutter_assets/fresh.cfg')
.readAsString(),
'fresh-config');
expect(_leftovers(root), isEmpty);
} finally {
running.kill();
await running.exitCode;
}
await cleanupUpdateLeftovers(root);
expect(File('$exe.old').existsSync(), isFalse);
}, skip: Platform.isWindows ? null : 'needs Windows');
}); });
group('in-app controller', () { group('in-app controller', () {
@@ -443,7 +503,7 @@ void main() {
expect(_isExec('${root.path}/watch_together'), isTrue); expect(_isExec('${root.path}/watch_together'), isTrue);
expect(restarts.single.$1, '${root.path}/watch_together'); expect(restarts.single.$1, '${root.path}/watch_together');
expect(restarts.single.$2, ['--some-flag']); expect(restarts.single.$2, ['--some-flag']);
}); }, skip: _posixOnly);
test('newer protocol is a required update, cannot be dismissed', test('newer protocol is a required update, cannot be dismissed',
() async { () async {
@@ -496,6 +556,6 @@ void main() {
expect(restarted, isFalse); expect(restarted, isFalse);
expect(await File('${root.path}/watch_together').readAsString(), expect(await File('${root.path}/watch_together').readAsString(),
'binary-v1'); 'binary-v1');
}); }, skip: _posixOnly);
}); });
} }
+3 -1
View File
@@ -547,7 +547,9 @@ with rollback on failure (writing the running executable fails with ETXTBSY;
rename works). A packaged install is detected by `data/` and `lib/` next to rename works). A packaged install is detected by `data/` and `lib/` next to
the binary; anything else exits with "update manually". A manifest whose the binary; anything else exits with "update manually". A manifest whose
protocol is newer than the local one is a required update. Windows apply protocol is newer than the local one is a required update. Windows apply
(`*.old` rename, swept on next start) exists but is untested. The version is renames in-use files to `*.old` (swept on next start); CI tests it against a
running exe on NTFS. The manifest's `assets` maps `linux-x64` /
`windows-x64` to archive URLs; top-level `url` (Linux) is for builds <= 0.2.1. The version is
stamped with `--dart-define=APP_VERSION` (dev runs report `0.0.0-dev` and skip stamped with `--dart-define=APP_VERSION` (dev runs report `0.0.0-dev` and skip
checks). `watch_together --upgrade` (or the shipped `update.sh`) runs it from checks). `watch_together --upgrade` (or the shipped `update.sh`) runs it from
the CLI; `UpdateController` runs the same code for Home's update indicator, the CLI; `UpdateController` runs the same code for Home's update indicator,
+9 -4
View File
@@ -12,6 +12,7 @@ mpv libraries come from `media_kit_libs_video`.
cd apps/desktop && flutter pub get && flutter run -d linux cd apps/desktop && flutter pub get && flutter run -d linux
cd apps/desktop && flutter build linux --release --dart-define=APP_VERSION=<x.y.z> cd apps/desktop && flutter build linux --release --dart-define=APP_VERSION=<x.y.z>
tool/package_linux.sh [--skip-build] [--noupload] # dist/*.tar.gz tool/package_linux.sh [--skip-build] [--noupload] # dist/*.tar.gz
powershell -File tool/package_windows.ps1 [-SkipBuild] # dist/*.zip (on Windows)
cd packages/server && dart compile exe bin/server.dart -o watch-together-server cd packages/server && dart compile exe bin/server.dart -o watch-together-server
``` ```
@@ -77,12 +78,16 @@ User pushes tags; CI does the rest.
1. `tool/release.sh [patch|minor|major] [--dry-run]`: derives the next 1. `tool/release.sh [patch|minor|major] [--dry-run]`: derives the next
version from the latest tag, bumps the three pubspecs in lockstep, writes version from the latest tag, bumps the three pubspecs in lockstep, writes
`updates/manifest.json` (asset URL is predictable), commits `Release vX.Y.Z`, `updates/manifest.json` (per-platform `assets` URLs are predictable; `url`
stays the Linux tarball for builds <= 0.2.1), commits `Release vX.Y.Z`,
makes an annotated tag. makes an annotated tag.
2. Review, then `git push --follow-tags`. 2. Review, then `git push --follow-tags`.
3. `.gitea/workflows/release.yml` builds the Linux tarball and runs 3. `.gitea/workflows/release.yml` runs one job per platform: Linux builds
`tool/release_publish.sh vX.Y.Z`, which creates the release and uploads it. with `tool/package_linux.sh`, Windows (runner `yuuka-windows`, host mode,
By hand: `tool/package_linux.sh` then `tool/release_publish.sh vX.Y.Z`. PowerShell only) with `tool/package_windows.ps1`. Each then runs
`dart tool/release_publish.dart vX.Y.Z <archive>`, which creates the
release if the other job hasn't yet and uploads (or replaces) its asset.
By hand: package, then the same `dart tool/release_publish.dart` line.
Tokens come from the environment (`RELEASE_TOKEN`, or Actions-provided Tokens come from the environment (`RELEASE_TOKEN`, or Actions-provided
`GITHUB_TOKEN`/`GITEA_TOKEN`). The manifest has no `sha256` by default; the `GITHUB_TOKEN`/`GITEA_TOKEN`). The manifest has no `sha256` by default; the
+6 -7
View File
@@ -6,13 +6,12 @@ off by deleting it in the same commit as the code. Wire changes also edit
## Backlog ## Backlog
1. **Windows build + CI.** There is no Windows runner. Try 1. **Windows: first real install.** CI builds and publishes
`ubuntu2610/flutter_build` (LLVM-MinGW + Wine `gen_snapshot`) on the Linux `watch-together-<ver>-windows-x64.zip` (libmpv bundled) and runs the
runner, fallback a GitHub Actions mirror. The manifest needs per-platform updater tests on NTFS, but nobody has launched the app on Windows yet.
URLs and `release_publish.sh` must handle a second platform. The updater's Check: playback, fullscreen controls, hosting (firewall prompt), in-app
Windows apply path exists but is untested. The tarball's update from one release to the next. The rest of the desktop tests are
`watch-together-server` needs a Windows `dart compile exe` too. Desktop not in CI.
tests are not in CI either.
2. **Jellyfin** (epic, below). 2. **Jellyfin** (epic, below).
+1 -1
View File
@@ -1,6 +1,6 @@
name: sync_relay_core name: sync_relay_core
description: Pure-Dart protocol, sync engine, media-source abstractions. No Flutter. description: Pure-Dart protocol, sync engine, media-source abstractions. No Flutter.
version: 0.2.1 version: 0.2.2
environment: environment:
sdk: ^3.0.0 sdk: ^3.0.0
dependencies: dependencies:
+1 -1
View File
@@ -1,7 +1,7 @@
name: sync_relay_server name: sync_relay_server
description: Pure-Dart sync + media server. Embeddable, no Flutter. description: Pure-Dart sync + media server. Embeddable, no Flutter.
publish_to: none publish_to: none
version: 0.2.1 version: 0.2.2
environment: environment:
sdk: ^3.0.0 sdk: ^3.0.0
dependencies: dependencies:
+55
View File
@@ -0,0 +1,55 @@
# Package the Windows release bundle into a versioned zip.
# Usage: powershell -File tool/package_windows.ps1 [-SkipBuild]
# Output: dist/watch-together-<version>-windows-x64.zip containing one
# top-level folder with watch_together.exe, its DLLs and data/ as built,
# plus update.bat (runs the exe with --upgrade) and
# watch-together-server.exe (dedicated server, `dart compile exe` of
# packages/server/bin/server.dart). Same layout as tool/package_linux.sh;
# the in-app updater strips the top-level folder.
# Needs: flutter on PATH, tar.exe (ships with Windows 10+, writes zip).
param([switch]$SkipBuild)
$ErrorActionPreference = 'Stop'
$Root = Split-Path -Parent $PSScriptRoot
$App = Join-Path $Root 'apps/desktop'
$Name = 'watch-together'
$Arch = 'x64'
$Version = (Select-String -Path "$App/pubspec.yaml" -Pattern '^version: ([0-9.]+)').Matches[0].Groups[1].Value
if (-not $Version) { throw "version not found in $App/pubspec.yaml" }
function Invoke-Checked([string]$What, [scriptblock]$Cmd) {
& $Cmd
if ($LASTEXITCODE -ne 0) { throw "$What failed (exit $LASTEXITCODE)" }
}
if (-not $SkipBuild) {
Push-Location $App
try {
Invoke-Checked 'flutter build windows' { flutter build windows --release "--dart-define=APP_VERSION=$Version" }
} finally { Pop-Location }
}
$Bundle = Join-Path $App "build/windows/$Arch/runner/Release"
if (-not (Test-Path "$Bundle/watch_together.exe")) { throw "bundle missing (build first): $Bundle" }
$Stage = Join-Path $App 'build/package-stage'
$Dist = Join-Path $Root 'dist'
$Folder = "$Name-$Version"
$Zip = Join-Path $Dist "$Folder-windows-$Arch.zip"
if (Test-Path $Stage) { Remove-Item -Recurse -Force $Stage }
New-Item -ItemType Directory -Force "$Stage/$Folder", $Dist | Out-Null
Copy-Item -Recurse "$Bundle/*" "$Stage/$Folder/"
Copy-Item "$App/lib/src/update/bundle/update.bat" "$Stage/$Folder/update.bat"
Push-Location (Join-Path $Root 'packages/server')
try {
Invoke-Checked 'dart pub get' { dart pub get | Out-Null }
Invoke-Checked 'dart compile exe' { dart compile exe bin/server.dart -o "$Stage/$Folder/watch-together-server.exe" }
} finally { Pop-Location }
if (Test-Path $Zip) { Remove-Item -Force $Zip }
# bsdtar writes forward-slash entry names (Compress-Archive on 5.1 doesn't).
Invoke-Checked 'tar' { tar.exe -a -c -f $Zip -C $Stage $Folder }
Remove-Item -Recurse -Force $Stage
Get-Item $Zip | Format-Table Name, Length
+10 -3
View File
@@ -52,7 +52,6 @@ ORIGIN="$(git -C "$ROOT" remote get-url origin)"
SERVER="$(echo "$ORIGIN" | sed -E 's#(https?://[^/]+)/.*#\1#')" SERVER="$(echo "$ORIGIN" | sed -E 's#(https?://[^/]+)/.*#\1#')"
REPO="$(echo "${ORIGIN%.git}" | sed -E 's#.*[:/]([^/]+/[^/]+)$#\1#')" REPO="$(echo "${ORIGIN%.git}" | sed -E 's#.*[:/]([^/]+/[^/]+)$#\1#')"
PROTO="$(sed -n 's/.*protoVersion = \([0-9]*\).*/\1/p' "$ROOT/packages/core/lib/src/constants.dart")" PROTO="$(sed -n 's/.*protoVersion = \([0-9]*\).*/\1/p' "$ROOT/packages/core/lib/src/constants.dart")"
ASSET="watch-together-$VER-linux-x64.tar.gz"
if [[ -n "$LATEST" && "$LATEST" != "v$VER" ]]; then if [[ -n "$LATEST" && "$LATEST" != "v$VER" ]]; then
NOTES="$(git -C "$ROOT" log "$LATEST..HEAD" --format='- %s' | head -n 20)" NOTES="$(git -C "$ROOT" log "$LATEST..HEAD" --format='- %s' | head -n 20)"
else else
@@ -60,7 +59,7 @@ else
fi fi
export MANIFEST_FILE="$ROOT/updates/manifest.json" MANIFEST_VER="$VER" \ export MANIFEST_FILE="$ROOT/updates/manifest.json" MANIFEST_VER="$VER" \
MANIFEST_PROTO="$PROTO" MANIFEST_NOTES="$NOTES" \ MANIFEST_PROTO="$PROTO" MANIFEST_NOTES="$NOTES" \
MANIFEST_URL="$SERVER/$REPO/releases/download/v$VER/$ASSET" MANIFEST_BASE="$SERVER/$REPO/releases/download/v$VER/watch-together-$VER"
python3 - <<'EOF' python3 - <<'EOF'
import json, os import json, os
path = os.environ['MANIFEST_FILE'] path = os.environ['MANIFEST_FILE']
@@ -69,10 +68,18 @@ try:
manifest = json.load(f) manifest = json.load(f)
except (FileNotFoundError, ValueError): except (FileNotFoundError, ValueError):
manifest = {} manifest = {}
base = os.environ['MANIFEST_BASE']
# Asset names must match tool/package_linux.sh / package_windows.ps1.
# `url` stays the Linux tarball for builds that predate `assets` (<= 0.2.1).
assets = {
'linux-x64': base + '-linux-x64.tar.gz',
'windows-x64': base + '-windows-x64.zip',
}
manifest.update({ manifest.update({
'version': os.environ['MANIFEST_VER'], 'version': os.environ['MANIFEST_VER'],
'protocol': int(os.environ['MANIFEST_PROTO']), 'protocol': int(os.environ['MANIFEST_PROTO']),
'url': os.environ['MANIFEST_URL'], 'url': assets['linux-x64'],
'assets': assets,
'notes': os.environ['MANIFEST_NOTES'].splitlines()[0] if os.environ['MANIFEST_NOTES'] else '', 'notes': os.environ['MANIFEST_NOTES'].splitlines()[0] if os.environ['MANIFEST_NOTES'] else '',
}) })
manifest.pop('sha256', None) manifest.pop('sha256', None)
+174
View File
@@ -0,0 +1,174 @@
// Publish one platform's archive to the Gitea release for a tag: create
// the release if it doesn't exist yet, then upload (or replace) the asset.
// The Linux and Windows CI jobs both run this, possibly at the same time,
// so a lost create race falls back to the release the other job made.
// The manifest is written by tool/release.sh at release time (predictable
// asset URLs), so this never touches git history beyond reading notes.
// Usage: dart tool/release_publish.dart vX.Y.Z <archive>
// (run after tool/package_linux.sh or tool/package_windows.ps1)
// Env: RELEASE_TOKEN (preferred) or the Actions-provided GITHUB_TOKEN /
// GITEA_TOKEN. dart: imports only — runs without `pub get`.
import 'dart:convert';
import 'dart:io';
Future<void> main(List<String> args) async {
if (args.length != 2) {
_die('usage: dart tool/release_publish.dart vX.Y.Z <archive>');
}
final tag = args[0];
final archive = File(args[1]);
final ver = tag.startsWith('v') ? tag.substring(1) : tag;
if (!RegExp(r'^\d+\.\d+\.\d+$').hasMatch(ver)) {
_die('tag must look like v0.2.0, got $tag');
}
if (!archive.existsSync()) _die('archive missing: ${archive.path}');
final root = File.fromUri(Platform.script).parent.parent.path;
final pubspec = File('$root/apps/desktop/pubspec.yaml').readAsStringSync();
final pub = RegExp(r'^version: ([0-9.]+)', multiLine: true)
.firstMatch(pubspec)
?.group(1);
if (pub != ver) {
_die('tag $tag != desktop pubspec $pub (run tool/release.sh first)');
}
final env = Platform.environment;
final token = [env['RELEASE_TOKEN'], env['GITHUB_TOKEN'], env['GITEA_TOKEN']]
.firstWhere((t) => t != null && t.isNotEmpty,
orElse: () => _die('set RELEASE_TOKEN (or run in Actions)'))!;
final origin = _git(root, ['remote', 'get-url', 'origin']);
final server = RegExp(r'^(https?://[^/]+)/').firstMatch(origin)?.group(1) ??
env['GITHUB_SERVER_URL'] ??
_die('cannot derive server from origin $origin');
final repo =
RegExp(r'([^/:]+/[^/]+?)(\.git)?$').firstMatch(origin)!.group(1)!;
final api = '$server/api/v1/repos/$repo';
final prev = _git(root, ['describe', '--tags', '--abbrev=0', '$tag^'],
allowFail: true);
final notes = prev.isEmpty
? 'Watch together $ver.'
: _git(root, [
'log',
'$prev..$tag',
'--format=- %s',
'--invert-grep',
'--grep=^Release v'
]).split('\n').take(20).join('\n');
final http = HttpClient();
try {
final c = _Api(http, token);
var rel = await c.get('$api/releases/tags/$tag');
if (rel == null) {
stdout.writeln('creating release $tag on $repo ...');
rel = await c.post('$api/releases', {
'tag_name': tag,
'name': tag,
'body': 'Watch together $ver\n\n$notes',
'draft': false,
'prerelease': false,
});
// The other platform's job won the race.
rel ??= await c.get('$api/releases/tags/$tag') ??
_die('could not create or find release $tag');
}
final id = rel['id'];
stdout.writeln('release id: $id');
final name = archive.uri.pathSegments.last;
for (final a in (rel['assets'] as List? ?? const [])) {
if (a is Map && a['name'] == name) {
stdout.writeln('replacing existing $name ...');
await c.delete('$api/releases/$id/assets/${a['id']}');
}
}
stdout.writeln('uploading $name ...');
await c.upload(
'$api/releases/$id/assets?name=${Uri.encodeQueryComponent(name)}',
archive,
name);
stdout.writeln('published $name to $tag.');
} finally {
http.close();
}
}
Never _die(String msg) {
stderr.writeln('error: $msg');
exit(1);
}
String _git(String root, List<String> args, {bool allowFail = false}) {
final r = Process.runSync('git', ['-C', root, ...args]);
if (r.exitCode != 0) {
if (allowFail) return '';
_die('git ${args.join(' ')}: ${r.stderr}'.trim());
}
return (r.stdout as String).trim();
}
class _Api {
final HttpClient http;
final String token;
_Api(this.http, this.token);
Future<(int, String)> _send(String method, String url,
{List<int>? body, String? type}) async {
final req = await http.openUrl(method, Uri.parse(url));
req.headers.set('Authorization', 'token $token');
req.headers.set('Accept', 'application/json');
if (body != null) {
req.headers.set('Content-Type', type!);
req.contentLength = body.length;
req.add(body);
}
final res = await req.close();
return (res.statusCode, await res.transform(utf8.decoder).join());
}
// null on 404.
Future<Map<String, Object?>?> get(String url) async {
final (code, text) = await _send('GET', url);
if (code == 404) return null;
_check('GET', url, code, text);
return jsonDecode(text) as Map<String, Object?>;
}
// null on 409 (already exists).
Future<Map<String, Object?>?> post(String url, Object json) async {
final (code, text) = await _send('POST', url,
body: utf8.encode(jsonEncode(json)), type: 'application/json');
if (code == 409) return null;
_check('POST', url, code, text);
return jsonDecode(text) as Map<String, Object?>;
}
Future<void> delete(String url) async {
final (code, text) = await _send('DELETE', url);
_check('DELETE', url, code, text);
}
// Gitea's asset API takes a multipart form with an `attachment` field.
Future<void> upload(String url, File file, String name) async {
final boundary = 'wt-${DateTime.now().microsecondsSinceEpoch}';
final body = <int>[
...utf8.encode('--$boundary\r\n'
'Content-Disposition: form-data; name="attachment"; filename="$name"\r\n'
'Content-Type: application/octet-stream\r\n\r\n'),
...await file.readAsBytes(),
...utf8.encode('\r\n--$boundary--\r\n'),
];
final (code, text) = await _send('POST', url,
body: body, type: 'multipart/form-data; boundary=$boundary');
_check('upload', url, code, text);
}
void _check(String what, String url, int code, String text) {
if (code < 200 || code >= 300) {
_die(
'$what $url → HTTP $code: ${text.length > 300 ? text.substring(0, 300) : text}');
}
}
}
-56
View File
@@ -1,56 +0,0 @@
#!/usr/bin/env bash
# Publish a release to Gitea: create the release and upload the Linux
# tarball built by tool/package_linux.sh. The manifest is written by
# tool/release.sh at release time (predictable asset URL), so this
# script never touches git — no push-back, no write-token problem
# beyond the release API itself.
# 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.
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 (run tool/release.sh first)" >&2; exit 1; }
ORIGIN="$(git -C "$ROOT" remote get-url origin)"
SERVER="$(echo "$ORIGIN" | sed -E 's#(https?://[^/]+)/.*#\1#')"
REPO="$(echo "${ORIGIN%.git}" | sed -E 's#.*[:/]([^/]+/[^/]+)$#\1#')"
TOKEN="${RELEASE_TOKEN:-${GITHUB_TOKEN:-${GITEA_TOKEN:-}}}"
[[ -n "$TOKEN" ]] || { echo "error: set RELEASE_TOKEN (or run in Actions)" >&2; exit 1; }
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"
API="$SERVER/api/v1/repos/$REPO"
# Re-runs reuse the release for this tag instead of failing on a duplicate.
REL_ID="$(curl -fsS -H "Authorization: token $TOKEN" "$API/releases/tags/$TAG" 2>/dev/null \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])' 2>/dev/null || true)"
if [[ -z "$REL_ID" ]]; then
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}))')" \
"$API/releases" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
fi
echo "release id: $REL_ID"
echo "uploading $ASSET ..."
# Gitea's asset API takes a multipart form with an `attachment` field.
curl -fsS -H "Authorization: token $TOKEN" \
-F "attachment=@$TARBALL;type=application/gzip" \
"$API/releases/$REL_ID/assets?name=$ASSET" > /dev/null
echo "published $TAG."
+15
View File
@@ -0,0 +1,15 @@
# CI only (yuuka-windows, host mode): put Flutter stable on PATH for the
# following steps. The SDK is cached on the host in
# %ProgramData%\flutter-ci and fast-forwarded each run; first run clones.
$ErrorActionPreference = 'Stop'
$F = "$env:ProgramData\flutter-ci"
if (Test-Path "$F\.git") {
git -C $F fetch -q --depth 1 origin stable; if ($LASTEXITCODE) { exit 1 }
git -C $F reset -q --hard FETCH_HEAD; if ($LASTEXITCODE) { exit 1 }
} else {
git clone -q https://github.com/flutter/flutter.git -b stable --depth 1 $F; if ($LASTEXITCODE) { exit 1 }
}
Add-Content $env:GITHUB_PATH "$F\bin"
& "$F\bin\flutter.bat" config --no-analytics | Out-Null
& "$F\bin\flutter.bat" --version
if ($LASTEXITCODE) { exit 1 }
+7 -3
View File
@@ -1,6 +1,10 @@
{ {
"version": "0.2.1", "version": "0.2.2",
"protocol": 7, "protocol": 7,
"url": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v0.2.1/watch-together-0.2.1-linux-x64.tar.gz", "url": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v0.2.2/watch-together-0.2.2-linux-x64.tar.gz",
"notes": "- CI: create build/ before compiling the server; drop artifact upload" "notes": "- CI: Windows build + release on yuuka-windows",
"assets": {
"linux-x64": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v0.2.2/watch-together-0.2.2-linux-x64.tar.gz",
"windows-x64": "https://git.senkochan.moe/Foxyz/sync_replay_project/releases/download/v0.2.2/watch-together-0.2.2-windows-x64.zip"
}
} }