Restructure step R: the app is Starsync (dev.foxyz.starsync)
ci / core (push) Successful in 31s
ci / server (push) Successful in 1m3s
ci / updater-windows (push) Successful in 6m6s

- Linux app id dev.foxyz.starsync, binary starsync; Windows starsync.exe,
  company foxyz; server starsync-server; Dart package starsync; archives
  starsync-<ver>-*.
- 1.2.x compatibility: watch_together and watch-together-server
  launchers (Linux) / copies (Windows) in the archive; the updater
  accepts both names and relaunches the new binary; update.sh/.bat use
  whichever server exists.
- First start copies (never moves) the old support folder and this
  app's Hive boxes out of ~/Documents into <support>/hive; a marker
  makes it run once; AppDirs is a port; settings schema version with
  ordered migrations.
- Launcher entry dev.foxyz.starsync.desktop; the old one removed.
- Repository name and URLs unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-15 20:21:20 +03:00
1 parent f4ca02c3cf
commit 51c1c5ab78
96 files changed
+1145 -465

No files matched your search

+2 -2
View File
@@ -35,9 +35,9 @@ jobs:
- run: dart test
working-directory: packages/server
# Compiling the dedicated server proves it stays Flutter-free.
- run: mkdir -p build && dart compile exe bin/server.dart -o build/watch-together-server
- run: mkdir -p build && dart compile exe bin/server.dart -o build/starsync-server
working-directory: packages/server
- run: build/watch-together-server --help
- run: build/starsync-server --help
working-directory: packages/server
# The updater's Windows apply path (running exe parked as .old) only
+2 -2
View File
@@ -38,7 +38,7 @@ jobs:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
VER="${GITHUB_REF_NAME#v}"
dart tool/release_publish.dart "$GITHUB_REF_NAME" "dist/watch-together-$VER-linux-x64.tar.gz"
dart tool/release_publish.dart "$GITHUB_REF_NAME" "dist/starsync-$VER-linux-x64.tar.gz"
windows:
# yuuka-windows: host-mode runner (no containers), runs as SYSTEM, has
@@ -73,5 +73,5 @@ jobs:
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"
dart tool/release_publish.dart $env:GITHUB_REF_NAME "dist/starsync-$Ver-windows-x64.zip"
if ($LASTEXITCODE) { exit 1 }
+28 -14
View File
@@ -11,9 +11,9 @@ everyone's play, pause and seek stay together.
Why not Jellyfin SyncPlay: it runs in the browser and its sync is poor.
This is a desktop app that drives mpv.
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
update manifest until that is settled.
Up to 1.2.x the app was called `watch_together` (its files
`watch-together-*`); the repository and the update manifest keep their old
addresses for now.
- Desktop only (Linux and Windows), always mpv. No web clients.
- No transcoding. Everyone plays the original file.
@@ -25,21 +25,33 @@ How it fits together: [docs/HOW-IT-WORKS.md](docs/HOW-IT-WORKS.md).
From the Releases page of this repo:
- Linux: `watch-together-<version>-linux-x64.tar.gz`. Extract, run
`watch_together`. On start it adds itself to your app launcher (and its
- Linux: `starsync-<version>-linux-x64.tar.gz`. Extract, run
`starsync`. On start it adds itself to your app launcher (and its
icon), so the window shows its own icon on Wayland. 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: `starsync-<version>-windows-x64.zip`. Extract, run
`starsync.exe`. mpv is bundled. New and not yet tried on a real
Windows desktop; reports welcome.
The folder also holds `watch_together(.exe)` and
`watch-together-server(.exe)`, the names up to 1.2.x: they start the new
programs, so an update from 1.2.x restarts fine and old shortcuts and
service files keep working.
Your data (settings, sign-ins, folders, logs, yt-dlp and its cookies) lives
in `~/.local/share/dev.foxyz.starsync` on Linux and
`%APPDATA%\foxyz\Starsync` on Windows. The first start after 1.2.x copies
it over from the old places (`~/.local/share/moe.senkochan.watch_together`
or `%APPDATA%\com.example\watch_together`, and the app's files in your
Documents folder) and leaves the originals where they were.
Update from the app: the app looks for a new version at start and every
few hours, and when one exists a band across the top of the window says so
(never over a playing video). "Update and restart" downloads,
verifies, applies in place and restarts; "Later" hides it until the next
check; the install folder must be writable by you. The version you run is
under the Settings tab. Settings has Check now and an auto-check toggle. From a terminal:
`./watch-together-server --upgrade` (no display needed, so it works on a
server box too), `watch_together --upgrade`, or the shipped `update.sh` /
`./starsync-server --upgrade` (no display needed, so it works on a
server box too), `./starsync --upgrade`, or the shipped `update.sh` /
`update.bat` (which run the first). Each updates the whole folder, app and
server; restart whichever is running to use the new version. A
release with a newer protocol is a required update: everyone on a server
@@ -221,11 +233,11 @@ cover; one you started shows how far you got.
## Dedicated server
For an always-on box (no display needed): the release tarball also contains
`watch-together-server`, a small standalone server without the app. Run it,
`starsync-server`, a small standalone server without the app. Run it,
then everyone (you included) uses Join.
```
./watch-together-server --port 8080 --secret <password>
./starsync-server --port 8080 --secret <password>
```
| Flag | Default | Meaning |
@@ -245,9 +257,11 @@ then everyone (you included) uses Join.
It prints `READY port=... name=...` once listening and stops cleanly on
Ctrl+C or SIGTERM. A dedicated server has no host.
To run it as a service, unpack the tarball to `/opt/watch-together` and use
[deploy/watch-together.service](deploy/watch-together.service) (copy it to
`/etc/systemd/system/`, edit the flags, `systemctl enable --now watch-together`).
To run it as a service, unpack the tarball to `/opt/starsync` and use
[deploy/starsync.service](deploy/starsync.service) (copy it to
`/etc/systemd/system/`, edit the flags, `systemctl enable --now starsync`).
A service set up before the rename (`/opt/watch-together`, running
`watch-together-server`) keeps working as it is.
Something not playing? Settings → About → **Copy diagnostics** puts the
app's recent log (passwords, tokens and cookies left out) on the
+3 -4
View File
@@ -7,9 +7,9 @@ import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'app.dart';
import 'src/platform/app_dirs.dart';
import 'src/platform/app_log.dart';
import 'src/platform/app_shutdown.dart';
import 'src/platform/desktop/desktop_app_dirs.dart';
import 'src/platform/desktop_entry.dart';
import 'src/screens/home/ui.dart';
import 'src/session/app_events.dart' show UpdateAvailable;
@@ -42,10 +42,9 @@ Future<void> main(List<String> args) async {
),
);
WidgetsFlutterBinding.ensureInitialized();
final dirs = await AppDirs.resolve();
final dirs = await const DesktopAppDirs().resolve();
await startAppLogging(dirs);
// An absolute subDir replaces the documents folder (package:path join).
await Hive.initFlutter(dirs.hive);
Hive.init(dirs.hive);
await AppSettings.instance.ensureLoaded();
// Close button / Alt+F4: the orderly close ([shutDownApp]), so others
// see "left", a hosted server shuts down and no player outlives the
@@ -1,64 +0,0 @@
import 'dart:io';
import 'package:path_provider/path_provider.dart';
/// The environment variable that picks a separate profile (DEVELOPMENT.md
/// "Dev loop": two instances on one machine).
const profileEnv = 'STARSYNC_PROFILE';
/// Where the app keeps its own files, resolved once in `main` ([resolve])
/// and handed on from there.
///
/// Without a profile it is exactly today's layout: Hive's boxes in Hive's
/// default folder (the documents folder) and the logs in
/// `<app support>/logs`. With `STARSYNC_PROFILE=<name>` (letters, digits,
/// `-`, `_`; at most 32) the per-install data moves to
/// `<app support>/profiles/<name>/`: the Hive boxes (settings and so the
/// profile, connect form, window bounds, folders, Jellyfin sign-ins; the
/// client id from restructure step 1 on) under `hive/`, the logs under
/// `logs/`. Downloads and caches stay shared: yt-dlp and its cookies, the
/// extracted shaders, thumbnails, the AniList cache.
class AppDirs {
/// The profile name; null for the normal app.
final String? profile;
/// The app support folder (yt-dlp, shaders; logs without a profile).
final String support;
/// Hive's folder; null: Hive's default (`Hive.initFlutter()`).
final String? hive;
/// The app's log folder.
final String logs;
const AppDirs({required this.profile, required this.support, required this.hive, required this.logs});
/// The layout for [support] (the app support folder) and [env]; an
/// invalid profile name is ignored ([invalidProfile] says which).
static AppDirs fromEnv({required String support, required Map<String, String> env}) {
final sep = Platform.pathSeparator;
final name = validProfile(env[profileEnv]);
if (name == null) return AppDirs(profile: null, support: support, hive: null, logs: '$support${sep}logs');
final root = '$support${sep}profiles$sep$name';
return AppDirs(profile: name, support: support, hive: '$root${sep}hive', logs: '$root${sep}logs');
}
/// [raw] when it is a usable profile name, else null.
static String? validProfile(String? raw) {
final v = raw?.trim() ?? '';
return RegExp(r'^[A-Za-z0-9_-]{1,32}$').hasMatch(v) ? v : null;
}
/// The env value when it is set but not a usable name (for a warning).
static String? invalidProfile(Map<String, String> env) {
final raw = env[profileEnv];
return raw != null && raw.trim().isNotEmpty && validProfile(raw) == null ? raw : null;
}
/// Resolves the layout from the platform and the process environment.
/// Needs Flutter's binding (path_provider).
static Future<AppDirs> resolve() async {
final support = (await getApplicationSupportDirectory()).path;
final dirs = fromEnv(support: support, env: Platform.environment);
final hive = dirs.hive;
if (hive != null) await Directory(hive).create(recursive: true);
return dirs;
}
}
+4 -1
View File
@@ -5,7 +5,7 @@ import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart';
import '../screens/home/ui.dart' show appName, displayVersion;
import 'app_dirs.dart';
import 'ports/app_dirs.dart';
import '../state/session_state.dart';
// The app's log on disk (<app support>/logs/app-YYYY-MM-DD.log; core's
@@ -39,6 +39,9 @@ Future<void> startAppLogging(AppDirs dirs) async {
_appLog.info('$appName ${displayVersion()} on ${Platform.operatingSystem} '
'${Platform.operatingSystemVersion}, pid $pid, Dart ${Platform.version.split(' ').first}'
'${dirs.profile == null ? '' : ', profile ${dirs.profile}'}');
for (final line in dirs.migrated) {
_appLog.info(line);
}
final bad = AppDirs.invalidProfile(Platform.environment);
if (bad != null) _appLog.warning('$profileEnv="$bad" ignored: use letters, digits, - and _ (at most 32)');
}
@@ -0,0 +1,116 @@
import 'dart:convert';
import 'dart:io';
// First start of the renamed app (Starsync, app id dev.foxyz.starsync):
// copy what the old names left, never move, so a 1.2.x install keeps
// working next to it. Runs once (the marker file), then never again.
/// Written into the new support folder once the migration ran (whether or
/// not anything was found): `migratedFrom` (the old support folder or
/// null), `hiveFrom` (the folder the boxes came from or null), `boxes`,
/// `files`.
const migrationMarker = 'migration.json';
/// The Hive boxes the app opens, by name (settings, connect form, Files,
/// Jellyfin sign-ins, track choices). Only these leave the documents
/// folder, which other apps' boxes share.
const appHiveBoxes = ['settings', 'connect_form', 'files', 'jellyfin', 'track_prefs'];
/// Support folders of the old names for the new [support] folder, the
/// most likely first. Linux: `$XDG_DATA_HOME/moe.senkochan.watch_together`
/// (the GTK application id up to 1.2.x), then `watch_together` (the
/// executable name path_provider falls back to). Windows:
/// `%APPDATA%\com.example\watch_together` (company and product name of
/// the exe up to 1.2.x).
List<String> legacySupportDirs(String support, {bool? windows}) {
final win = windows ?? Platform.isWindows;
final sep = win ? r'\' : '/';
String parent(String p) {
final norm = p.endsWith(sep) ? p.substring(0, p.length - 1) : p;
final i = norm.lastIndexOf(sep);
return i <= 0 ? norm : norm.substring(0, i);
}
if (win) return ['${parent(parent(support))}${sep}com.example${sep}watch_together'];
final share = parent(support);
return ['$share${sep}moe.senkochan.watch_together', '$share${sep}watch_together'];
}
/// Copies the first existing [oldSupport] folder into [support] and the
/// [appHiveBoxes] (`.hive` and `.lock`) from [documents] into [hive],
/// skipping anything already there; the originals stay. Once per support
/// folder ([migrationMarker]). Returns one log line per thing copied
/// (paths and counts, never contents); empty when it ran before.
Future<List<String>> migrateLegacyData(
{required String support, required String hive, required List<String> oldSupport, String? documents}) async {
final sep = Platform.pathSeparator;
final marker = File('$support$sep$migrationMarker');
if (marker.existsSync()) return const [];
final lines = <String>[];
String? from;
var files = 0;
for (final old in oldSupport) {
final dir = Directory(old);
if (!dir.existsSync() || _same(old, support)) continue;
files = await _copyTree(dir, Directory(support));
from = old;
lines.add('migration: copied $files files from $old to $support');
break;
}
final boxes = <String>[];
if (documents != null && Directory(documents).existsSync() && !_same(documents, hive)) {
await Directory(hive).create(recursive: true);
for (final box in appHiveBoxes) {
for (final ext in const ['hive', 'lock']) {
final src = File('$documents$sep$box.$ext');
final dst = File('$hive$sep$box.$ext');
if (!src.existsSync() || dst.existsSync()) continue;
await src.copy(dst.path);
if (ext == 'hive') boxes.add(box);
}
}
if (boxes.isNotEmpty) {
lines.add('migration: copied ${boxes.length} Hive boxes (${boxes.join(', ')}) from $documents to $hive');
}
}
if (lines.isEmpty) lines.add('migration: no data from before the rename');
await Directory(support).create(recursive: true);
await marker.writeAsString(jsonEncode({
'migratedFrom': from,
'hiveFrom': boxes.isEmpty ? null : documents,
'boxes': boxes,
'files': files,
}));
return lines;
}
bool _same(String a, String b) {
try {
return FileSystemEntity.identicalSync(a, b);
} catch (_) {
return false;
}
}
// Files (File.copy keeps the mode on POSIX: cookies.txt stays 0600, yt-dlp
// executable) and symlinks as they are; existing targets win.
Future<int> _copyTree(Directory from, Directory to) async {
var n = 0;
await to.create(recursive: true);
await for (final e in from.list(recursive: true, followLinks: false)) {
final target = '${to.path}${e.path.substring(from.path.length)}';
if (e is Directory) {
await Directory(target).create(recursive: true);
} else if (await FileSystemEntity.type(target, followLinks: false) != FileSystemEntityType.notFound) {
continue;
} else if (e is Link) {
await Directory(File(target).parent.path).create(recursive: true);
await Link(target).create(await e.target());
} else if (e is File) {
await Directory(File(target).parent.path).create(recursive: true);
await e.copy(target);
n++;
}
}
return n;
}
@@ -0,0 +1,35 @@
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import '../data_migration.dart';
import '../ports/app_dirs.dart';
/// The desktop's [AppDirsPort]: the app support folder from path_provider
/// (Linux `$XDG_DATA_HOME/dev.foxyz.starsync`, Windows
/// `%APPDATA%\foxyz\Starsync`); on the first start it copies the data of
/// the old names in ([migrateLegacyData]). Needs Flutter's binding.
class DesktopAppDirs implements AppDirsPort {
const DesktopAppDirs();
@override
Future<AppDirs> resolve() async {
final support = (await getApplicationSupportDirectory()).path;
final base = AppDirs.fromEnv(support: support, env: const {});
List<String> migrated;
try {
String? documents;
try {
// Where Hive.initFlutter() kept the boxes up to 1.2.x.
documents = (await getApplicationDocumentsDirectory()).path;
} catch (_) {}
migrated = await migrateLegacyData(
support: support, hive: base.hive, oldSupport: legacySupportDirs(support), documents: documents);
} catch (e) {
migrated = ['migration failed: $e'];
}
final dirs = AppDirs.fromEnv(support: support, env: Platform.environment, migrated: migrated);
await Directory(dirs.hive).create(recursive: true);
return dirs;
}
}
@@ -5,16 +5,24 @@ import 'package:sync_relay_core/core.dart' show findInstallRoot;
/// The app's id: GTK's application id, so the Wayland `app_id` the
/// compositor matches to [desktopFileName] for the window's icon.
const appId = 'moe.senkochan.watch_together';
const appId = 'dev.foxyz.starsync';
const desktopFileName = '$appId.desktop';
/// The app id up to 1.2.x: its entry and icons are removed (only those
/// exact files, which that version wrote).
const legacyAppId = 'moe.senkochan.watch_together';
const _iconSizes = [16, 32, 48, 64, 128, 256, 512];
/// Linux release installs (an unpacked tarball) carry no launcher entry, so
/// Wayland shows a generic icon. On start: write the `.desktop` file into
/// `~/.local/share/applications` (Exec = this binary) and the icon into
/// the hicolor theme, only when something changed (a moved install, a new
/// icon). Never in debug runs; failures only log.
Future<void> installDesktopEntry({String? executable, String? dataHome, String? home}) async {
if (!Platform.isLinux || kDebugMode) return;
/// icon). The entry and icons of [legacyAppId] go. Never in debug runs
/// (unless [force], for tests); failures only log.
Future<void> installDesktopEntry(
{String? executable, String? dataHome, String? home, @visibleForTesting bool force = false}) async {
if (!Platform.isLinux || (kDebugMode && !force)) return;
try {
final exe = executable ?? Platform.resolvedExecutable;
final root = findInstallRoot(exe, windows: false);
@@ -22,7 +30,8 @@ Future<void> installDesktopEntry({String? executable, String? dataHome, String?
final share = dataHome ?? Platform.environment['XDG_DATA_HOME'] ??
'${home ?? Platform.environment['HOME']}/.local/share';
final icons = '${root.path}/data/flutter_assets/assets/icon';
for (final size in const [16, 32, 48, 64, 128, 256, 512]) {
await _removeLegacy(share);
for (final size in _iconSizes) {
final src = File('$icons/icon_$size.png');
if (!src.existsSync()) continue;
final dst = File('$share/icons/hicolor/${size}x$size/apps/$appId.png');
@@ -40,6 +49,21 @@ Future<void> installDesktopEntry({String? executable, String? dataHome, String?
}
}
Future<void> _removeLegacy(String share) async {
for (final f in [
File('$share/applications/$legacyAppId.desktop'),
for (final size in _iconSizes) File('$share/icons/hicolor/${size}x$size/apps/$legacyAppId.png'),
]) {
if (!f.existsSync()) continue;
try {
await f.delete();
debugPrint('desktop entry: removed ${f.path}');
} catch (e) {
debugPrint('desktop entry: cannot remove ${f.path}: $e');
}
}
}
@visibleForTesting
String desktopEntryText(String exe) => '''[Desktop Entry]
Type=Application
@@ -0,0 +1,69 @@
import 'dart:io';
/// The environment variable that picks a separate profile (DEVELOPMENT.md
/// "Dev loop": two instances on one machine).
const profileEnv = 'STARSYNC_PROFILE';
/// Where the app keeps its own files, resolved once in `main` through an
/// [AppDirsPort] and handed on from there.
///
/// Everything lives in the app support folder: Hive's boxes in
/// `<app support>/hive`, the logs in `<app support>/logs`. With
/// `STARSYNC_PROFILE=<name>` (letters, digits, `-`, `_`; at most 32) the
/// per-install data moves to `<app support>/profiles/<name>/`: the Hive
/// boxes (settings and so the profile, connect form, window bounds,
/// folders, Jellyfin sign-ins; the client id from restructure step 1 on)
/// under `hive/`, the logs under `logs/`. Downloads and caches stay
/// shared: yt-dlp and its cookies, the extracted shaders, thumbnails, the
/// AniList cache.
class AppDirs {
/// The profile name; null for the normal app.
final String? profile;
/// The app support folder (yt-dlp, shaders; Hive and logs without a
/// profile).
final String support;
/// Hive's folder.
final String hive;
/// The app's log folder.
final String logs;
/// What resolving copied over from before the rename, one line each
/// (paths and counts), for the log once it is open.
final List<String> migrated;
const AppDirs(
{required this.profile,
required this.support,
required this.hive,
required this.logs,
this.migrated = const []});
/// The layout for [support] (the app support folder) and [env]; an
/// invalid profile name is ignored ([invalidProfile] says which).
static AppDirs fromEnv(
{required String support, required Map<String, String> env, List<String> migrated = const []}) {
final sep = Platform.pathSeparator;
final name = validProfile(env[profileEnv]);
final root = name == null ? support : '$support${sep}profiles$sep$name';
return AppDirs(
profile: name, support: support, hive: '$root${sep}hive', logs: '$root${sep}logs', migrated: migrated);
}
/// [raw] when it is a usable profile name, else null.
static String? validProfile(String? raw) {
final v = raw?.trim() ?? '';
return RegExp(r'^[A-Za-z0-9_-]{1,32}$').hasMatch(v) ? v : null;
}
/// The env value when it is set but not a usable name (for a warning).
static String? invalidProfile(Map<String, String> env) {
final raw = env[profileEnv];
return raw != null && raw.trim().isNotEmpty && validProfile(raw) == null ? raw : null;
}
}
/// Finds the app's folders on this platform (and, the first time, brings
/// over the data of the names before the rename).
abstract interface class AppDirsPort {
/// The layout for this process; its Hive folder exists afterwards.
Future<AppDirs> resolve();
}
@@ -4,6 +4,7 @@ import 'shell_port.dart';
import 'system_notifications.dart';
import 'url_opener.dart';
export 'app_dirs.dart';
export 'folder_access.dart';
export 'link_resolver.dart';
export 'shell_port.dart';
+1 -1
View File
@@ -158,7 +158,7 @@ class AniListClient {
_cacheDir = cacheDir ?? _defaultCacheDir,
_delay = delay ?? Future<void>.delayed,
_now = now ?? DateTime.now,
userAgent = userAgent ?? 'watch-together/$appVersion';
userAgent = userAgent ?? 'Starsync/$appVersion';
final Uri url;
final Duration memoryTtl;
+1 -1
View File
@@ -474,7 +474,7 @@ class AnimeFlowClient {
_ownsHttp = http == null,
_delay = delay ?? Future<void>.delayed,
_now = now ?? DateTime.now,
userAgent = userAgent ?? 'watch-together/$appVersion';
userAgent = userAgent ?? 'Starsync/$appVersion';
/// Authenticated with the linked Jellyfin account on the same domain
/// (see [tokenFor]), looked up on every call so a re-link applies.
+4
View File
@@ -6,6 +6,7 @@ import 'package:signals/signals_flutter.dart';
import '../controls/model/controls_prefs.dart';
import '../shaders/shader_presets.dart';
import 'settings_schema.dart';
// App preferences (Settings page), one Hive box. Signals start at their
// defaults and take the stored values once [AppSettings.ensureLoaded]
@@ -134,6 +135,9 @@ class AppSettings {
Future<void> _loadNow() async {
try {
final box = _box = await Hive.openBox(settingsBoxName);
for (final line in await migrateSettings(box)) {
debugPrint(line);
}
final mode = box.get(_keyHwdecMode);
hwdecMode.value = HwdecMode.values
.where((m) => m.name == mode)
@@ -0,0 +1,46 @@
import 'package:hive_ce/hive.dart';
// The `settings` box's layout version and the ordered steps between
// versions. A step rewrites stored values for a new layout (renamed keys,
// changed formats); the restructure steps add theirs here.
/// The layout this build reads. 1: the layout of 1.2.x, where versioning
/// starts (a box without a version is at 1).
const settingsSchemaVersion = 1;
/// The box key holding the stored version.
const settingsSchemaKey = 'schemaVersion';
/// One step: brings a box at `to - 1` to [to].
class SettingsMigration {
final int to;
/// For the log ("settings schema 2: ...").
final String what;
final Future<void> Function(Box box) apply;
const SettingsMigration(this.to, this.what, this.apply);
}
/// Every step in order of [SettingsMigration.to] (2, 3, ...); empty while
/// version 1 is current.
const settingsMigrations = <SettingsMigration>[];
/// Runs the [steps] past the box's stored version up to [current], in
/// order, storing the version after each, then stores [current]. A box
/// from a newer build (a downgrade) is left as it is. Returns a log line
/// per step run.
Future<List<String>> migrateSettings(Box box,
{List<SettingsMigration> steps = settingsMigrations, int current = settingsSchemaVersion}) async {
final stored = box.get(settingsSchemaKey);
var at = stored is int ? stored : 1;
if (at > current) return ['settings schema $at is newer than this build ($current): left as is'];
final ran = <String>[];
for (final step in [...steps]..sort((a, b) => a.to.compareTo(b.to))) {
if (step.to <= at || step.to > current) continue;
await step.apply(box);
at = step.to;
await box.put(settingsSchemaKey, at);
ran.add('settings schema ${step.to}: ${step.what}');
}
if (stored != current) await box.put(settingsSchemaKey, current);
return ran;
}
@@ -1,4 +1,7 @@
@echo off
rem Update this install in place (same as clicking Update in the app).
"%~dp0watch-together-server.exe" --upgrade %*
rem The old server name is the fallback for installs from before the rename.
set "SERVER=%~dp0starsync-server.exe"
if not exist "%SERVER%" set "SERVER=%~dp0watch-together-server.exe"
"%SERVER%" --upgrade %*
pause
+4 -1
View File
@@ -1,4 +1,7 @@
#!/bin/sh
# Update this install in place (same as clicking Update in the app).
# The server binary runs the updater: it needs no GTK/display (VPS).
cd "$(dirname "$0")" && exec ./watch-together-server --upgrade "$@"
# The old server name is the fallback for installs from before the rename.
cd "$(dirname "$0")" || exit 1
if [ -x ./starsync-server ]; then exec ./starsync-server --upgrade "$@"; fi
exec ./watch-together-server --upgrade "$@"
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
# The dedicated server's name up to 1.2.x: systemd units (ExecStart) and
# old update.sh copies run it.
exec "$(dirname "$(readlink -f "$0")")/starsync-server" "$@"
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
# Starsync's name up to 1.2.x: their updater restarts it after updating to
# this release, and launchers or scripts may still point at it.
exec "$(dirname "$(readlink -f "$0")")/starsync" "$@"
@@ -173,6 +173,8 @@ class UpdateController {
_log.info('updated to ${m.version}, restarting');
status.value =
UpdateStatus(UpdatePhase.restarting, manifest: m, canInstall: true);
await restart(_exe, launchArgs);
// Started by an old name (1.2.x's restart, an old launcher): the new
// binary next to it.
await restart(relaunchExecutable(_exe), launchArgs);
}
}
+2 -2
View File
@@ -4,10 +4,10 @@ project(runner LANGUAGES CXX)
# The name of the executable created for the application. Change this to change
# the on-disk name of your application.
set(BINARY_NAME "watch_together")
set(BINARY_NAME "starsync")
# The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "moe.senkochan.watch_together")
set(APPLICATION_ID "dev.foxyz.starsync")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.
+2 -2
View File
@@ -1,5 +1,5 @@
name: watch_together
description: Watch-together desktop app (working title, repo sync_relay_project). One binary.
name: starsync
description: Starsync desktop app (repo sync_relay_project). One binary.
publish_to: none
version: 1.2.0
environment:
+127 -5
View File
@@ -1,18 +1,24 @@
// restructure: rewrite in step R (Rename): Hive moves into the support
// folder for the normal app too.
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/platform/app_dirs.dart';
import 'package:starsync/src/files/folder_store.dart' show filesBoxName;
import 'package:starsync/src/jellyfin/accounts.dart' show jellyfinBoxName;
import 'package:starsync/src/jellyfin/track_prefs.dart' show trackPrefsBoxName;
import 'package:starsync/src/platform/data_migration.dart';
import 'package:starsync/src/platform/ports/app_dirs.dart';
import 'package:starsync/src/state/connect_prefs.dart' show connectFormBoxName;
import 'package:starsync/src/state/settings.dart' show settingsBoxName;
void main() {
final sep = Platform.pathSeparator;
const support = '/s';
test('no profile: today\'s layout', () {
group('AppDirs', () {
test('no profile: Hive and logs in the support folder', () {
final d = AppDirs.fromEnv(support: support, env: const {});
expect(d.profile, isNull);
expect(d.hive, isNull, reason: "Hive's default folder");
expect(d.hive, '$support${sep}hive');
expect(d.logs, '$support${sep}logs');
});
@@ -33,4 +39,120 @@ void main() {
expect(AppDirs.invalidProfile(const {profileEnv: ''}), isNull, reason: 'empty is just unset');
expect(AppDirs.invalidProfile(const {profileEnv: 'ok_1-2'}), isNull);
});
});
test('the migrated boxes are the ones the app opens', () {
expect(appHiveBoxes.toSet(),
{settingsBoxName, connectFormBoxName, filesBoxName, jellyfinBoxName, trackPrefsBoxName});
});
test('old support folders next to the new one', () {
expect(legacySupportDirs('/home/u/.local/share/dev.foxyz.starsync', windows: false), [
'/home/u/.local/share/moe.senkochan.watch_together',
'/home/u/.local/share/watch_together',
]);
expect(legacySupportDirs(r'C:\Users\u\AppData\Roaming\foxyz\Starsync', windows: true),
[r'C:\Users\u\AppData\Roaming\com.example\watch_together']);
});
group('migration from before the rename', () {
late Directory home;
late String share, oldSupport, support, docs, hive;
setUp(() async {
home = await Directory.systemTemp.createTemp('starsync-mig');
share = '${home.path}/.local/share';
oldSupport = '$share/moe.senkochan.watch_together';
support = '$share/dev.foxyz.starsync';
docs = '${home.path}/Documents';
hive = '$support/hive';
// The old support folder as 1.2.x left it.
await Directory('$oldSupport/yt-dlp').create(recursive: true);
await File('$oldSupport/yt-dlp/cookies.txt').writeAsString('# Netscape cookies');
await File('$oldSupport/yt-dlp/yt-dlp').writeAsString('bin');
await Directory('$oldSupport/logs').create();
await File('$oldSupport/logs/app-2026-01-01.log').writeAsString('log');
await Directory('$oldSupport/shaders').create();
await File('$oldSupport/shaders/a.glsl').writeAsString('shader');
await Directory('$oldSupport/profiles/b/hive').create(recursive: true);
await File('$oldSupport/profiles/b/hive/settings.hive').writeAsString('b-settings');
if (!Platform.isWindows) {
await Process.run('chmod', ['600', '$oldSupport/yt-dlp/cookies.txt']);
await Process.run('chmod', ['755', '$oldSupport/yt-dlp/yt-dlp']);
await Link('$oldSupport/yt-dlp/current').create('yt-dlp');
}
// The documents folder: this app's boxes and another app's.
await Directory(docs).create(recursive: true);
for (final n in ['settings', 'jellyfin', 'files', 'track_prefs', 'connect_form', 'app', 'credentials']) {
await File('$docs/$n.hive').writeAsString('$n-data');
await File('$docs/$n.lock').writeAsString('');
}
// path_provider creates the new folder before the app looks.
await Directory(support).create(recursive: true);
});
tearDown(() => home.delete(recursive: true));
Future<List<String>> migrate() => migrateLegacyData(
support: support, hive: hive, oldSupport: legacySupportDirs(support, windows: false), documents: docs);
test('copies the support folder and only this app\'s boxes, once', () async {
final lines = await migrate();
expect(lines, hasLength(2));
expect(lines.first, contains(oldSupport));
expect(lines.last, contains('5 Hive boxes'));
expect(lines.join(), isNot(contains('Netscape')), reason: 'paths and counts, never contents');
expect(await File('$support/yt-dlp/cookies.txt').readAsString(), '# Netscape cookies');
expect(await File('$support/logs/app-2026-01-01.log').readAsString(), 'log');
expect(await File('$support/shaders/a.glsl').readAsString(), 'shader');
expect(await File('$support/profiles/b/hive/settings.hive').readAsString(), 'b-settings',
reason: 'profiles come along with the support folder');
if (!Platform.isWindows) {
expect(File('$support/yt-dlp/cookies.txt').statSync().mode & 0x1ff, 0x180, reason: '0600 kept');
expect(File('$support/yt-dlp/yt-dlp').statSync().mode & 0x40, isNonZero, reason: 'exec bit kept');
expect(await Link('$support/yt-dlp/current').target(), 'yt-dlp');
}
for (final n in appHiveBoxes) {
expect(await File('$hive/$n.hive').readAsString(), '$n-data');
expect(File('$hive/$n.lock').existsSync(), isTrue);
}
expect(File('$hive/app.hive').existsSync(), isFalse);
expect(File('$hive/credentials.hive').existsSync(), isFalse);
// Copied, not moved: 1.2.x keeps working.
expect(File('$docs/settings.hive').existsSync(), isTrue);
expect(File('$oldSupport/yt-dlp/cookies.txt').existsSync(), isTrue);
final marker = jsonDecode(File('$support/$migrationMarker').readAsStringSync()) as Map;
expect(marker['migratedFrom'], oldSupport);
expect(marker['hiveFrom'], docs);
expect(marker['files'], greaterThan(0));
// Once: a later change on the old side is not copied again.
await File('$docs/settings.hive').writeAsString('changed by 1.2.x');
await File('$hive/settings.hive').delete();
expect(await migrate(), isEmpty);
expect(File('$hive/settings.hive').existsSync(), isFalse);
});
test('never overwrites what the new folder already has', () async {
await Directory(hive).create(recursive: true);
await File('$hive/settings.hive').writeAsString('new');
await Directory('$support/yt-dlp').create();
await File('$support/yt-dlp/cookies.txt').writeAsString('new cookies');
await migrate();
expect(await File('$hive/settings.hive').readAsString(), 'new');
expect(await File('$support/yt-dlp/cookies.txt').readAsString(), 'new cookies');
expect(await File('$hive/jellyfin.hive').readAsString(), 'jellyfin-data');
});
test('a fresh install finds nothing and still marks it done', () async {
await Directory(oldSupport).delete(recursive: true);
await Directory(docs).delete(recursive: true);
expect(await migrate(), ['migration: no data from before the rename']);
final marker = jsonDecode(File('$support/$migrationMarker').readAsStringSync()) as Map;
expect(marker['migratedFrom'], isNull);
expect(marker['hiveFrom'], isNull);
});
});
}
+5 -5
View File
@@ -2,11 +2,11 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/shell/app_shell.dart';
import 'package:watch_together/src/state/session_state.dart';
import 'package:watch_together/src/update/update_controller.dart';
import 'package:watch_together/src/widgets/app_keys.dart';
import 'package:watch_together/src/widgets/text_focus.dart';
import 'package:starsync/src/shell/app_shell.dart';
import 'package:starsync/src/state/session_state.dart';
import 'package:starsync/src/update/update_controller.dart';
import 'package:starsync/src/widgets/app_keys.dart';
import 'package:starsync/src/widgets/text_focus.dart';
void main() {
final session = SessionState.instance;
+2 -2
View File
@@ -5,8 +5,8 @@ import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:watch_together/src/sync/avatar_cache.dart';
import 'package:watch_together/src/sync/sync_client.dart';
import 'package:starsync/src/sync/avatar_cache.dart';
import 'package:starsync/src/sync/sync_client.dart';
Future<void> _poll(FutureOr<bool> Function() cond, String what) async {
final deadline = DateTime.now().add(const Duration(seconds: 10));
+3 -3
View File
@@ -5,9 +5,9 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:watch_together/src/playback/media_marks.dart';
import 'package:watch_together/src/session/session_coordinator.dart';
import 'package:watch_together/src/sync/state_diff.dart';
import 'package:starsync/src/playback/media_marks.dart';
import 'package:starsync/src/session/session_coordinator.dart';
import 'package:starsync/src/sync/state_diff.dart';
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
+2 -2
View File
@@ -3,8 +3,8 @@ import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:watch_together/src/sync/state_diff.dart';
import 'package:watch_together/src/sync/sync_client.dart';
import 'package:starsync/src/sync/state_diff.dart';
import 'package:starsync/src/sync/sync_client.dart';
Future<void> _poll(FutureOr<bool> Function() cond, String what) async {
final end = DateTime.now().add(const Duration(seconds: 10));
+8 -8
View File
@@ -1,14 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:signals/signals_flutter.dart';
import 'package:watch_together/src/controls/controls_probe.dart';
import 'package:watch_together/src/controls/model/controls_layout.dart';
import 'package:watch_together/src/controls/model/controls_model.dart';
import 'package:watch_together/src/controls/model/controls_prefs.dart';
import 'package:watch_together/src/controls/session_controls.dart';
import 'package:watch_together/src/playback/media_marks.dart';
import 'package:watch_together/src/state/session_state.dart';
import 'package:watch_together/src/state/settings.dart';
import 'package:starsync/src/controls/controls_probe.dart';
import 'package:starsync/src/controls/model/controls_layout.dart';
import 'package:starsync/src/controls/model/controls_model.dart';
import 'package:starsync/src/controls/model/controls_prefs.dart';
import 'package:starsync/src/controls/session_controls.dart';
import 'package:starsync/src/playback/media_marks.dart';
import 'package:starsync/src/state/session_state.dart';
import 'package:starsync/src/state/settings.dart';
// The controls model and its layout spec: which controls the bar has, in
// which order, what goes to ⚙; and both skins drawing the same order.
+4 -4
View File
@@ -8,10 +8,10 @@ import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:hive_ce/hive.dart';
import 'package:watch_together/src/session/session_coordinator.dart';
import 'package:watch_together/src/sfx/sfx.dart';
import 'package:watch_together/src/state/session_state.dart';
import 'package:watch_together/src/sync/sync_client.dart';
import 'package:starsync/src/session/session_coordinator.dart';
import 'package:starsync/src/sfx/sfx.dart';
import 'package:starsync/src/state/session_state.dart';
import 'package:starsync/src/sync/sync_client.dart';
Future<void> _poll(
FutureOr<bool> Function() cond,
+2 -2
View File
@@ -3,8 +3,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/session/delivery.dart';
import 'package:watch_together/src/shell/delivery_line.dart';
import 'package:starsync/src/session/delivery.dart';
import 'package:starsync/src/shell/delivery_line.dart';
MemberView _m(String id, String name, {double buffered = 30, bool host = false, int rate = 0}) =>
MemberView(id: id, name: name, ready: true, inSet: true, buffered: buffered, stale: false, host: host, rate: rate);
+37
View File
@@ -0,0 +1,37 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:starsync/src/platform/desktop_entry.dart';
void main() {
test('writes the Starsync entry, removes only the old id\'s files', () async {
final tmp = await Directory.systemTemp.createTemp('starsync-entry');
try {
final root = '${tmp.path}/install';
await Directory('$root/lib').create(recursive: true);
await Directory('$root/data/flutter_assets/assets/icon').create(recursive: true);
await File('$root/data/flutter_assets/assets/icon/icon_48.png').writeAsString('png');
final share = '${tmp.path}/share';
final apps = Directory('$share/applications')..createSync(recursive: true);
final oldEntry = File('${apps.path}/$legacyAppId.desktop')..writeAsStringSync('old');
final other = File('${apps.path}/other.app.desktop')..writeAsStringSync('keep');
final oldIcon = File('$share/icons/hicolor/48x48/apps/$legacyAppId.png')
..createSync(recursive: true);
final otherIcon = File('$share/icons/hicolor/48x48/apps/other.png')..createSync();
await installDesktopEntry(executable: '$root/starsync', dataHome: share, force: true);
expect(oldEntry.existsSync(), isFalse);
expect(oldIcon.existsSync(), isFalse);
expect(other.existsSync(), isTrue);
expect(otherIcon.existsSync(), isTrue);
final entry = File('${apps.path}/dev.foxyz.starsync.desktop').readAsStringSync();
expect(entry, contains('Exec="$root/starsync"'));
expect(entry, contains('StartupWMClass=dev.foxyz.starsync'));
expect(entry, contains('Icon=dev.foxyz.starsync'));
expect(File('$share/icons/hicolor/48x48/apps/dev.foxyz.starsync.png').existsSync(), isTrue);
} finally {
await tmp.delete(recursive: true);
}
}, skip: Platform.isLinux ? null : 'Linux launcher entry');
}
+1 -1
View File
@@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:watch_together/src/session/session_coordinator.dart';
import 'package:starsync/src/session/session_coordinator.dart';
const _mediaPath = '/home/foxyz/Videos/chiyo.mp4'; // 13.8 s
+5 -5
View File
@@ -7,11 +7,11 @@ import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hive_ce/hive.dart';
import 'package:watch_together/src/files/folder_store.dart';
import 'package:watch_together/src/files/scanner.dart';
import 'package:watch_together/src/files/thumbnails.dart';
import 'package:watch_together/src/files/ui/file_tiles.dart';
import 'package:watch_together/src/files/ui/files_page.dart';
import 'package:starsync/src/files/folder_store.dart';
import 'package:starsync/src/files/scanner.dart';
import 'package:starsync/src/files/thumbnails.dart';
import 'package:starsync/src/files/ui/file_tiles.dart';
import 'package:starsync/src/files/ui/files_page.dart';
// The Files page over a real (tiny) folder tree: the scan runs in-line
// instead of in an isolate and thumbnails come from a fake loader, so
+2 -2
View File
@@ -3,8 +3,8 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/files/name_parser.dart';
import 'package:watch_together/src/files/scanner.dart';
import 'package:starsync/src/files/name_parser.dart';
import 'package:starsync/src/files/scanner.dart';
late Directory _root;
+1 -1
View File
@@ -2,7 +2,7 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:hive_ce/hive.dart';
import 'package:watch_together/src/files/folder_store.dart';
import 'package:starsync/src/files/folder_store.dart';
void main() {
test('folders and recent files persist across a reload', () async {
+1 -1
View File
@@ -3,7 +3,7 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:media_kit/media_kit.dart';
import 'package:watch_together/src/files/thumbnails.dart';
import 'package:starsync/src/files/thumbnails.dart';
// Real libmpv: frames grabbed with vo=null + screenshot-to-file. The test
// media comes from ffmpeg (this machine only; the app never needs it).
+1 -1
View File
@@ -1,7 +1,7 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/shell/handoff.dart';
import 'package:starsync/src/shell/handoff.dart';
// What a drop or a paste is: decided from paths and text alone (the file
// system stands behind a fake).
+10 -10
View File
@@ -5,17 +5,17 @@ import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/files/ui/files_page.dart';
import 'package:watch_together/src/schedule/ui/schedule_page.dart';
import 'package:starsync/src/files/ui/files_page.dart';
import 'package:starsync/src/schedule/ui/schedule_page.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/screens/home/health_probe.dart';
import 'package:watch_together/src/screens/home/media_picker.dart';
import 'package:watch_together/src/screens/home/update_indicator.dart';
import 'package:watch_together/src/shell/app_shell.dart';
import 'package:watch_together/src/shell/library_link.dart' show soloPlayHint;
import 'package:watch_together/src/state/session_state.dart';
import 'package:watch_together/src/state/settings.dart';
import 'package:watch_together/src/update/update_controller.dart';
import 'package:starsync/src/screens/home/health_probe.dart';
import 'package:starsync/src/screens/home/media_picker.dart';
import 'package:starsync/src/screens/home/update_indicator.dart';
import 'package:starsync/src/shell/app_shell.dart';
import 'package:starsync/src/shell/library_link.dart' show soloPlayHint;
import 'package:starsync/src/state/session_state.dart';
import 'package:starsync/src/state/settings.dart';
import 'package:starsync/src/update/update_controller.dart';
RecentServer join(String host, String port, [String secret = '']) =>
RecentServer(hosted: false, host: host, port: port, secret: secret);
@@ -4,10 +4,10 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:hive_ce/hive.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/format.dart';
import 'package:watch_together/src/jellyfin/sign_in.dart';
import 'package:watch_together/src/screens/home/media_picker.dart';
import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:starsync/src/jellyfin/format.dart';
import 'package:starsync/src/jellyfin/sign_in.dart';
import 'package:starsync/src/screens/home/media_picker.dart';
/// Captured Jellyfin 10.11 answers shared with packages/core's client tests.
String jellyfinFixture(String name) =>
+6 -6
View File
@@ -5,12 +5,12 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/accounts_panel.dart';
import 'package:watch_together/src/jellyfin/browser.dart';
import 'package:watch_together/src/jellyfin/browse_scope.dart';
import 'package:watch_together/src/jellyfin/picker_source.dart';
import 'package:watch_together/src/state/settings.dart';
import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:starsync/src/jellyfin/accounts_panel.dart';
import 'package:starsync/src/jellyfin/browser.dart';
import 'package:starsync/src/jellyfin/browse_scope.dart';
import 'package:starsync/src/jellyfin/picker_source.dart';
import 'package:starsync/src/state/settings.dart';
import 'jellyfin_accounts_test.dart'
show FakeJellyfin, arifuretaId, fixtureServerId, linkedAccounts, season2Id;
+6 -6
View File
@@ -8,12 +8,12 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/session_media.dart';
import 'package:watch_together/src/jellyfin/track_prefs.dart';
import 'package:watch_together/src/playback/media_marks.dart';
import 'package:watch_together/src/screens/home/picked_media.dart';
import 'package:watch_together/src/session/session_coordinator.dart';
import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:starsync/src/jellyfin/session_media.dart';
import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:starsync/src/playback/media_marks.dart';
import 'package:starsync/src/screens/home/picked_media.dart';
import 'package:starsync/src/session/session_coordinator.dart';
// Two account holders and a guest watch Jellyfin episodes together over a
// real sync server, real mpv and a local fake Jellyfin (never a real one):
+11 -11
View File
@@ -6,17 +6,17 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/browse_scope.dart';
import 'package:watch_together/src/jellyfin/browser.dart';
import 'package:watch_together/src/jellyfin/progress.dart';
import 'package:watch_together/src/jellyfin/session_media.dart';
import 'package:watch_together/src/jellyfin/track_prefs.dart';
import 'package:watch_together/src/screens/home/media_picker.dart';
import 'package:watch_together/src/screens/session/jellyfin_widgets.dart';
import 'package:watch_together/src/session/delivery.dart';
import 'package:watch_together/src/session/session_coordinator.dart' show AutoAdvance;
import 'package:watch_together/src/shell/library_tab.dart' show putUpJellyfinMedia;
import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:starsync/src/jellyfin/browse_scope.dart';
import 'package:starsync/src/jellyfin/browser.dart';
import 'package:starsync/src/jellyfin/progress.dart';
import 'package:starsync/src/jellyfin/session_media.dart';
import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:starsync/src/screens/home/media_picker.dart';
import 'package:starsync/src/screens/session/jellyfin_widgets.dart';
import 'package:starsync/src/session/delivery.dart';
import 'package:starsync/src/session/session_coordinator.dart' show AutoAdvance;
import 'package:starsync/src/shell/library_tab.dart' show putUpJellyfinMedia;
import 'jellyfin_accounts_test.dart';
import 'jellyfin_browser_test.dart' show app, setSize, settle;
+4 -4
View File
@@ -2,10 +2,10 @@ import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/home_rows.dart';
import 'package:watch_together/src/screens/home/media_picker.dart';
import 'package:watch_together/src/shell/library_link.dart';
import 'package:watch_together/src/shell/library_tab.dart';
import 'package:starsync/src/jellyfin/home_rows.dart';
import 'package:starsync/src/screens/home/media_picker.dart';
import 'package:starsync/src/shell/library_link.dart';
import 'package:starsync/src/shell/library_tab.dart';
final now = DateTime.utc(2026, 10, 8, 12);
DateTime daysAgo(num d) => now.subtract(Duration(minutes: (d * 24 * 60).round()));
+6 -6
View File
@@ -5,12 +5,12 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/browse_scope.dart';
import 'package:watch_together/src/jellyfin/details_panel.dart';
import 'package:watch_together/src/screens/home/media_picker.dart';
import 'package:watch_together/src/shell/library_link.dart';
import 'package:watch_together/src/shell/library_tab.dart';
import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:starsync/src/jellyfin/browse_scope.dart';
import 'package:starsync/src/jellyfin/details_panel.dart';
import 'package:starsync/src/screens/home/media_picker.dart';
import 'package:starsync/src/shell/library_link.dart';
import 'package:starsync/src/shell/library_tab.dart';
import 'jellyfin_accounts_test.dart'
show FakeJellyfin, arifuretaId, fixtureServerId, jellyfinFixture, linkedAccounts, season2Id;
+3 -3
View File
@@ -1,8 +1,8 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/session_media.dart';
import 'package:watch_together/src/jellyfin/track_prefs.dart';
import 'package:watch_together/src/playback/media_marks.dart';
import 'package:starsync/src/jellyfin/session_media.dart';
import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:starsync/src/playback/media_marks.dart';
Duration s(num secs) => Duration(milliseconds: (secs * 1000).round());
Duration m(int min, [int sec = 0]) => Duration(minutes: min, seconds: sec);
+10 -10
View File
@@ -1,16 +1,16 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/controls/frame.dart';
import 'package:watch_together/src/controls/minimal/style.dart';
import 'package:watch_together/src/controls/minimal/timeline.dart';
import 'package:watch_together/src/controls/model/controls_prefs.dart';
import 'package:watch_together/src/controls/volume.dart';
import 'package:watch_together/src/playback/media_marks.dart';
import 'package:watch_together/src/shell/settings_tab.dart';
import 'package:watch_together/src/state/settings.dart';
import 'package:watch_together/src/update/update_controller.dart';
import 'package:watch_together/src/widgets/app_menu.dart';
import 'package:starsync/src/controls/frame.dart';
import 'package:starsync/src/controls/minimal/style.dart';
import 'package:starsync/src/controls/minimal/timeline.dart';
import 'package:starsync/src/controls/model/controls_prefs.dart';
import 'package:starsync/src/controls/volume.dart';
import 'package:starsync/src/playback/media_marks.dart';
import 'package:starsync/src/shell/settings_tab.dart';
import 'package:starsync/src/state/settings.dart';
import 'package:starsync/src/update/update_controller.dart';
import 'package:starsync/src/widgets/app_menu.dart';
// The Minimal skin's pieces and the shared controls frame (never the
// session screen or a media_kit `Video`).
+2 -2
View File
@@ -9,8 +9,8 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart' show FeedMessage;
import 'package:watch_together/src/session/notifier.dart';
import 'package:watch_together/src/sfx/sfx.dart';
import 'package:starsync/src/session/notifier.dart';
import 'package:starsync/src/sfx/sfx.dart';
final _at = DateTime.utc(2026, 1, 1);
+2 -2
View File
@@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/session/now_playing.dart';
import 'package:watch_together/src/session/now_playing_info.dart';
import 'package:starsync/src/session/now_playing.dart';
import 'package:starsync/src/session/now_playing_info.dart';
void main() {
group('jellyfinTitle', () {
+5 -5
View File
@@ -2,11 +2,11 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import 'package:watch_together/src/controls/control_menus.dart';
import 'package:watch_together/src/controls/standard/seek_bar.dart';
import 'package:watch_together/src/jellyfin/track_prefs.dart';
import 'package:watch_together/src/playback/media_marks.dart';
import 'package:watch_together/src/screens/session/player_controls.dart';
import 'package:starsync/src/controls/control_menus.dart';
import 'package:starsync/src/controls/standard/seek_bar.dart';
import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:starsync/src/playback/media_marks.dart';
import 'package:starsync/src/screens/session/player_controls.dart';
// The control-bar pieces alone (never the session screen or a `Video`):
// the Standard seek bar, the skip pill and the shared track / chapter
+12 -12
View File
@@ -6,18 +6,18 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/controls/frame.dart';
import 'package:watch_together/src/controls/model/controls_prefs.dart';
import 'package:watch_together/src/controls/player_settings.dart';
import 'package:watch_together/src/jellyfin/track_prefs.dart';
import 'package:watch_together/src/schedule/animeflow.dart';
import 'package:watch_together/src/shaders/shader_presets.dart';
import 'package:watch_together/src/shell/avatar.dart';
import 'package:watch_together/src/shell/party_strip.dart';
import 'package:watch_together/src/shell/settings_tab.dart';
import 'package:watch_together/src/state/session_state.dart';
import 'package:watch_together/src/state/settings.dart';
import 'package:watch_together/src/update/update_controller.dart';
import 'package:starsync/src/controls/frame.dart';
import 'package:starsync/src/controls/model/controls_prefs.dart';
import 'package:starsync/src/controls/player_settings.dart';
import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:starsync/src/schedule/animeflow.dart';
import 'package:starsync/src/shaders/shader_presets.dart';
import 'package:starsync/src/shell/avatar.dart';
import 'package:starsync/src/shell/party_strip.dart';
import 'package:starsync/src/shell/settings_tab.dart';
import 'package:starsync/src/state/session_state.dart';
import 'package:starsync/src/state/settings.dart';
import 'package:starsync/src/update/update_controller.dart';
// The player settings card (both skins, never a media_kit `Video`), the
// Settings pages, the host flag in the party UI and invite addresses.
+10 -10
View File
@@ -5,16 +5,16 @@ import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/controls/frame.dart';
import 'package:watch_together/src/controls/minimal/style.dart';
import 'package:watch_together/src/controls/minimal/timeline.dart';
import 'package:watch_together/src/session/health.dart';
import 'package:watch_together/src/session/now_playing.dart';
import 'package:watch_together/src/shell/now_playing_title.dart';
import 'package:watch_together/src/shell/player_status.dart';
import 'package:watch_together/src/shell/session_player.dart';
import 'package:watch_together/src/shell/start_banner.dart';
import 'package:watch_together/src/state/session_state.dart';
import 'package:starsync/src/controls/frame.dart';
import 'package:starsync/src/controls/minimal/style.dart';
import 'package:starsync/src/controls/minimal/timeline.dart';
import 'package:starsync/src/session/health.dart';
import 'package:starsync/src/session/now_playing.dart';
import 'package:starsync/src/shell/now_playing_title.dart';
import 'package:starsync/src/shell/player_status.dart';
import 'package:starsync/src/shell/session_player.dart';
import 'package:starsync/src/shell/start_banner.dart';
import 'package:starsync/src/state/session_state.dart';
// The player's title block, start card and status pill (never a media_kit
// `Video`).
+1 -1
View File
@@ -2,7 +2,7 @@
// the janitor become server state.
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/queue/queue_logic.dart';
import 'package:starsync/src/queue/queue_logic.dart';
MemberView member(String id,
{bool stale = false, bool out = false, Map<String, bool> jf = const {}, String name = ''}) =>
+4 -4
View File
@@ -1,10 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/queue/queue_ui.dart';
import 'package:watch_together/src/session/session_coordinator.dart';
import 'package:watch_together/src/shell/library_link.dart' show LinkPreview;
import 'package:watch_together/src/widgets/text_focus.dart';
import 'package:starsync/src/queue/queue_ui.dart';
import 'package:starsync/src/session/session_coordinator.dart';
import 'package:starsync/src/shell/library_link.dart' show LinkPreview;
import 'package:starsync/src/widgets/text_focus.dart';
void main() {
testWidgets('Up next popover: holds the keys, Ctrl+V adds with a preview row, + Add link opens a field',
+8 -8
View File
@@ -8,14 +8,14 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/session_media.dart';
import 'package:watch_together/src/jellyfin/track_prefs.dart';
import 'package:watch_together/src/playback/yt_dlp_app.dart';
import 'package:watch_together/src/queue/queue_controller.dart' show WarmState;
import 'package:watch_together/src/screens/home/picked_media.dart';
import 'package:watch_together/src/session/session_coordinator.dart';
import 'package:watch_together/src/session/switch_trace.dart';
import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:starsync/src/jellyfin/session_media.dart';
import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:starsync/src/playback/yt_dlp_app.dart';
import 'package:starsync/src/queue/queue_controller.dart' show WarmState;
import 'package:starsync/src/screens/home/picked_media.dart';
import 'package:starsync/src/session/session_coordinator.dart';
import 'package:starsync/src/session/switch_trace.dart';
// How long an Up next switch takes, end to end, over real mpv and a real
// sync server: two Jellyfin account holders and a guest (through the
+1 -1
View File
@@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:watch_together/src/playback/playback_controller.dart';
import 'package:starsync/src/playback/playback_controller.dart';
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
+5 -5
View File
@@ -9,11 +9,11 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/track_prefs.dart';
import 'package:watch_together/src/playback/yt_dlp_app.dart';
import 'package:watch_together/src/screens/home/picked_media.dart';
import 'package:watch_together/src/session/session_coordinator.dart';
import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:starsync/src/playback/yt_dlp_app.dart';
import 'package:starsync/src/screens/home/picked_media.dart';
import 'package:starsync/src/session/session_coordinator.dart';
// The relay with real coordinators, real mpv and a dedicated server that
// has no file and no Jellyfin login of its own (the VPS setup): a guest's
+1 -1
View File
@@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/files/name_parser.dart';
import 'package:starsync/src/files/name_parser.dart';
void _expect(
String name,
+4 -4
View File
@@ -3,8 +3,8 @@ import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/schedule/anilist.dart';
import 'package:watch_together/src/session/media_art.dart';
import 'package:starsync/src/schedule/anilist.dart';
import 'package:starsync/src/session/media_art.dart';
import 'schedule_fake_server.dart';
@@ -45,7 +45,7 @@ void main() {
cacheDir: () async => cache,
delay: (d) async => delays.add(d),
now: () => now,
userAgent: 'watch-together/test',
userAgent: 'Starsync/test',
);
setUp(() async {
@@ -73,7 +73,7 @@ void main() {
expect(server.seen, hasLength(2));
final first = jsonDecode(server.seen.first.body) as Map;
expect(first['variables'], {'from': from.millisecondsSinceEpoch ~/ 1000 - 1, 'to': to.millisecondsSinceEpoch ~/ 1000, 'page': 1});
expect(server.seen.first.headers.value(HttpHeaders.userAgentHeader), 'watch-together/test');
expect(server.seen.first.headers.value(HttpHeaders.userAgentHeader), 'Starsync/test');
expect(s.airings.map((a) => a.anilistId), [1, 4]);
final a = s.airings.first;
expect(a.title, 'Show');
@@ -2,8 +2,8 @@ import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/schedule/animeflow.dart';
import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:starsync/src/schedule/animeflow.dart';
import 'schedule_fake_server.dart';
@@ -83,7 +83,7 @@ void main() {
final c = client();
final me = await c.me();
expect(server.seen.single.headers.value('x-emby-token'), token);
expect(server.seen.single.headers.value(HttpHeaders.userAgentHeader), startsWith('watch-together/'));
expect(server.seen.single.headers.value(HttpHeaders.userAgentHeader), startsWith('Starsync/'));
expect(me.userId, 'u1');
expect(me.name, 'Foxyz');
expect(me.scopes, {AnimeFlowScope.view, AnimeFlowScope.request});
+3 -3
View File
@@ -2,9 +2,9 @@ import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/schedule/anilist.dart';
import 'package:watch_together/src/schedule/animeflow.dart';
import 'package:watch_together/src/schedule/schedule_model.dart';
import 'package:starsync/src/schedule/anilist.dart';
import 'package:starsync/src/schedule/animeflow.dart';
import 'package:starsync/src/schedule/schedule_model.dart';
import 'schedule_fake_server.dart';
+7 -7
View File
@@ -6,13 +6,13 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/schedule/anilist.dart';
import 'package:watch_together/src/schedule/animeflow.dart';
import 'package:watch_together/src/schedule/schedule_model.dart';
import 'package:watch_together/src/schedule/ui/schedule_actions.dart';
import 'package:watch_together/src/schedule/ui/schedule_page.dart';
import 'package:watch_together/src/screens/home/picked_media.dart';
import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:starsync/src/schedule/anilist.dart';
import 'package:starsync/src/schedule/animeflow.dart';
import 'package:starsync/src/schedule/schedule_model.dart';
import 'package:starsync/src/schedule/ui/schedule_actions.dart';
import 'package:starsync/src/schedule/ui/schedule_page.dart';
import 'package:starsync/src/screens/home/picked_media.dart';
import 'schedule_fake_server.dart';
+2 -2
View File
@@ -5,8 +5,8 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:watch_together/src/session/session_coordinator.dart';
import 'package:watch_together/src/sync/state_diff.dart';
import 'package:starsync/src/session/session_coordinator.dart';
import 'package:starsync/src/sync/state_diff.dart';
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
+5 -5
View File
@@ -9,11 +9,11 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:hive_ce/hive.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/accounts.dart';
import 'package:watch_together/src/screens/home/picked_media.dart';
import 'package:watch_together/src/session/session_coordinator.dart';
import 'package:watch_together/src/state/server_isolate.dart';
import 'package:watch_together/src/state/session_state.dart';
import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:starsync/src/screens/home/picked_media.dart';
import 'package:starsync/src/session/session_coordinator.dart';
import 'package:starsync/src/state/server_isolate.dart';
import 'package:starsync/src/state/session_state.dart';
Future<void> _poll(
FutureOr<bool> Function() cond,
+2 -2
View File
@@ -2,8 +2,8 @@
// IdleTracker and unreadyForNewMedia give way to auto sit-out;
// linkDisplayName stays.
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/session/idle.dart';
import 'package:watch_together/src/session/session_coordinator.dart';
import 'package:starsync/src/session/idle.dart';
import 'package:starsync/src/session/session_coordinator.dart';
void main() {
group('IdleTracker', () {
@@ -0,0 +1,57 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:hive_ce/hive.dart';
import 'package:starsync/src/state/settings_schema.dart';
void main() {
group('settings schema', () {
late Directory dir;
setUp(() async {
dir = await Directory.systemTemp.createTemp('starsync-schema');
Hive.init(dir.path);
});
tearDown(() async {
await Hive.close();
await dir.delete(recursive: true);
});
test('a box without a version is at 1 and gets stamped', () async {
final box = await Hive.openBox('settings');
expect(await migrateSettings(box), isEmpty);
expect(box.get(settingsSchemaKey), settingsSchemaVersion);
});
test('steps run in order, only past the stored version', () async {
final box = await Hive.openBox('settings');
await box.put(settingsSchemaKey, 1);
await box.put('old', 'x');
final order = <int>[];
final steps = [
SettingsMigration(3, 'c', (b) async {
order.add(3);
await b.put('c', b.get('new'));
}),
SettingsMigration(2, 'rename old to new', (b) async {
order.add(2);
await b.put('new', b.get('old'));
await b.delete('old');
}),
SettingsMigration(4, 'not yet', (b) async => order.add(4)),
];
final ran = await migrateSettings(box, steps: steps, current: 3);
expect(order, [2, 3]);
expect(ran, ['settings schema 2: rename old to new', 'settings schema 3: c']);
expect(box.get('c'), 'x');
expect(box.get(settingsSchemaKey), 3);
expect(await migrateSettings(box, steps: steps, current: 3), isEmpty);
});
test('a box from a newer build is left as it is', () async {
final box = await Hive.openBox('settings');
await box.put(settingsSchemaKey, 9);
expect(await migrateSettings(box), hasLength(1));
expect(box.get(settingsSchemaKey), 9);
});
});
}
+3 -3
View File
@@ -1,9 +1,9 @@
import 'dart:math' as math;
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/sfx/sfx.dart';
import 'package:watch_together/src/sfx/sound_bank.dart';
import 'package:watch_together/src/sfx/synth.dart';
import 'package:starsync/src/sfx/sfx.dart';
import 'package:starsync/src/sfx/sound_bank.dart';
import 'package:starsync/src/sfx/synth.dart';
double _pitch(List<double> x, int from) {
final c = <double>[];
+4 -4
View File
@@ -2,10 +2,10 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:watch_together/src/playback/playback_controller.dart';
import 'package:watch_together/src/shaders/shader_assets.dart';
import 'package:watch_together/src/shaders/shader_presets.dart';
import 'package:watch_together/src/state/settings.dart';
import 'package:starsync/src/playback/playback_controller.dart';
import 'package:starsync/src/shaders/shader_assets.dart';
import 'package:starsync/src/shaders/shader_presets.dart';
import 'package:starsync/src/state/settings.dart';
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
+3 -3
View File
@@ -2,9 +2,9 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:hive_ce/hive.dart';
import 'package:watch_together/src/shaders/shader_assets.dart';
import 'package:watch_together/src/shaders/shader_presets.dart';
import 'package:watch_together/src/state/settings.dart';
import 'package:starsync/src/shaders/shader_assets.dart';
import 'package:starsync/src/shaders/shader_presets.dart';
import 'package:starsync/src/state/settings.dart';
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
+12 -12
View File
@@ -4,18 +4,18 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/screens/home/profile.dart';
import 'package:watch_together/src/shell/app_shell.dart';
import 'package:watch_together/src/shell/avatar.dart';
import 'package:watch_together/src/shell/motion.dart';
import 'package:watch_together/src/shell/party_strip.dart';
import 'package:watch_together/src/shell/profile_card.dart';
import 'package:watch_together/src/shell/session_player.dart';
import 'package:watch_together/src/shell/settings_tab.dart' show SettingsTab;
import 'package:watch_together/src/shell/toasts.dart';
import 'package:watch_together/src/session/session_coordinator.dart' show ToastItem;
import 'package:watch_together/src/state/session_state.dart';
import 'package:watch_together/src/update/update_controller.dart';
import 'package:starsync/src/screens/home/profile.dart';
import 'package:starsync/src/shell/app_shell.dart';
import 'package:starsync/src/shell/avatar.dart';
import 'package:starsync/src/shell/motion.dart';
import 'package:starsync/src/shell/party_strip.dart';
import 'package:starsync/src/shell/profile_card.dart';
import 'package:starsync/src/shell/session_player.dart';
import 'package:starsync/src/shell/settings_tab.dart' show SettingsTab;
import 'package:starsync/src/shell/toasts.dart';
import 'package:starsync/src/session/session_coordinator.dart' show ToastItem;
import 'package:starsync/src/state/session_state.dart';
import 'package:starsync/src/update/update_controller.dart';
/// Stands in for media_kit's `Video` (which hangs flutter_test) and
/// counts how often one is created.
+4 -4
View File
@@ -3,10 +3,10 @@ import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_soloud/flutter_soloud.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/platform/audio/sound_engine.dart';
import 'package:watch_together/src/sfx/sfx.dart';
import 'package:watch_together/src/sfx/sound_bank.dart';
import 'package:watch_together/src/sfx/synth_sfx.dart';
import 'package:starsync/src/platform/audio/sound_engine.dart';
import 'package:starsync/src/sfx/sfx.dart';
import 'package:starsync/src/sfx/sound_bank.dart';
import 'package:starsync/src/sfx/synth_sfx.dart';
// Needs a real audio output: opens the device through flutter_soloud and
// plays every sound of every preset once, quietly (WT_SFX_VOLUME, default
+5 -5
View File
@@ -1,11 +1,11 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/platform/audio/sound_engine.dart';
import 'package:watch_together/src/sfx/sfx.dart';
import 'package:watch_together/src/sfx/sound_bank.dart';
import 'package:watch_together/src/sfx/synth.dart';
import 'package:watch_together/src/sfx/synth_sfx.dart';
import 'package:starsync/src/platform/audio/sound_engine.dart';
import 'package:starsync/src/sfx/sfx.dart';
import 'package:starsync/src/sfx/sound_bank.dart';
import 'package:starsync/src/sfx/synth.dart';
import 'package:starsync/src/sfx/synth_sfx.dart';
class _FakeBackend implements AudioBackend {
final bool failInit;
+1 -1
View File
@@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/sync/state_diff.dart';
import 'package:starsync/src/sync/state_diff.dart';
StateBroadcast _state({
int eventId = 0,
@@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:watch_together/src/session/session_coordinator.dart';
import 'package:starsync/src/session/session_coordinator.dart';
const _port = 18080;
const _secret = 'e2e-secret';
+1 -1
View File
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/widgets/text_focus.dart';
import 'package:starsync/src/widgets/text_focus.dart';
void main() {
// Regression: the old FocusNode-parent walk never met EditableText, so
+49 -35
View File
@@ -4,7 +4,7 @@ import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/update/update_controller.dart';
import 'package:starsync/src/update/update_controller.dart';
// Work dir base: WT_TEST_TMP or the system temp. Staging lives inside
// the install root, so the result doesn't depend on which filesystem
@@ -24,49 +24,39 @@ final _posixOnly = Platform.isWindows ? 'POSIX install layout' : null;
bool _isExec(String path) => File(path).statSync().mode & 0x40 != 0;
// Builds the release archive exactly like tool/package_linux.sh:
// bundle copied into <stage>/watch-together-<ver>/, then
// `tar -czf out -C <stage> watch-together-<ver>`. [zip]: like
// tool/package_windows.ps1 (`tar -a -c -f out.zip`, bsdtar — Windows only).
// bundle copied into <stage>/starsync-<ver>/ with the launcher by the
// old name (lib/src/update/bundle/watch_together, the shipped file), then
// `tar -czf out -C <stage> starsync-<ver>`. [app]: the app binary's
// content.
Future<List<int>> _releaseTarball(Directory work,
{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 folder = Directory('${stage.path}/watch-together-$version');
{String version = '0.2.0', String app = 'binary-v2'}) async {
final stage = Directory('${work.path}/pkg-$version-lin');
final folder = Directory('${stage.path}/starsync-$version');
await Directory('${folder.path}/data/flutter_assets').create(recursive: true);
await File('${folder.path}/data/flutter_assets/fresh.cfg')
.writeAsString('fresh-config');
if (windows) {
await File('${folder.path}/watch_together.exe').writeAsString('binary-v2');
await File('${folder.path}/flutter_windows.dll').writeAsString('dll-v2');
} else {
await File('${folder.path}/watch_together').writeAsString('binary-v2');
if (bundle) {
await File('${folder.path}/starsync').writeAsString(app);
await Directory('${folder.path}/lib').create();
await File('${folder.path}/lib/libapp.so.1').writeAsString('lib-v2');
await Link('${folder.path}/lib/libapp.so').create('libapp.so.1');
for (final f in ['update.sh', 'watch_together', 'watch-together-server']) {
await File('lib/src/update/bundle/$f').copy('${folder.path}/$f');
}
await File('${folder.path}/update.sh')
.writeAsString('#!/bin/sh\nexec ./watch_together --upgrade\n');
if (!Platform.isWindows) {
final r = await Process.run('chmod',
['755', '${folder.path}/watch_together', '${folder.path}/update.sh']);
final r = await Process.run('chmod', [
'755',
for (final f in ['starsync', 'update.sh', 'watch_together', 'watch-together-server'])
'${folder.path}/$f'
]);
expect(r.exitCode, 0);
}
}
final out = File('${work.path}/out-$version.${zip ? 'zip' : 'tar.gz'}');
final r = await Process.run('tar', [
...zip ? ['-a', '-c', '-f'] : ['-czf'],
out.path,
'-C',
stage.path,
'watch-together-$version',
]);
final out = File('${work.path}/out-$version.tar.gz');
final r = await Process.run('tar', ['-czf', out.path, '-C', stage.path, 'starsync-$version']);
expect(r.exitCode, 0, reason: '${r.stderr}');
return out.readAsBytes();
}
// An install as 1.2.x left it: the binary is still called watch_together.
Future<Directory> _fakeRoot(Directory work, {bool windows = false}) async {
final root = Directory('${work.path}/install');
await Directory('${root.path}/data').create(recursive: true);
@@ -105,12 +95,12 @@ class _Server {
req.response.write(jsonEncode({
'version': '0.2.0',
'protocol': RelayConstants.protoVersion,
'url': '$base/watch-together-0.2.0-linux-x64.tar.gz',
'url': '$base/starsync-0.2.0-linux-x64.tar.gz',
'sha256': sha256.convert(tarball).toString(),
'notes': 'test build',
...manifestExtra,
}));
} else if (req.uri.path == '/watch-together-0.2.0-linux-x64.tar.gz') {
} else if (req.uri.path == '/starsync-0.2.0-linux-x64.tar.gz') {
tarHits++;
req.response.contentLength = tarball.length;
req.response.add(tarball);
@@ -145,12 +135,14 @@ void main() {
test('check, download with progress, apply, restart same binary',
() async {
final root = await _fakeRoot(work);
// Already renamed: started as starsync.
await File('${root.path}/watch_together').rename('${root.path}/starsync');
final restarts = <(String, List<String>)>[];
launchArgs = const ['--some-flag'];
final c = UpdateController(
currentVersion: '0.1.0',
manifestUrl: server.manifestUrl,
executablePath: '${root.path}/watch_together',
executablePath: '${root.path}/starsync',
restart: (exe, args) async => restarts.add((exe, args)));
await c.checkOnce();
expect(c.status.value.phase, UpdatePhase.available);
@@ -164,13 +156,35 @@ void main() {
await c.install();
expect(progress.last, 1.0);
expect(c.status.value.phase, UpdatePhase.restarting);
expect(await File('${root.path}/watch_together').readAsString(),
expect(await File('${root.path}/starsync').readAsString(),
'binary-v2');
expect(_isExec('${root.path}/watch_together'), isTrue);
expect(restarts.single.$1, '${root.path}/watch_together');
expect(_isExec('${root.path}/starsync'), isTrue);
expect(restarts.single.$1, '${root.path}/starsync');
expect(restarts.single.$2, ['--some-flag']);
}, skip: _posixOnly);
test('started by the old name: restarts the new binary; the old name launches it',
() async {
server.tarball = await _releaseTarball(work,
version: '0.2.1', app: '#!/bin/sh\necho "starsync \$*"\n');
final root = await _fakeRoot(work);
final restarts = <String>[];
final c = UpdateController(
currentVersion: '0.1.0',
manifestUrl: server.manifestUrl,
executablePath: '${root.path}/watch_together',
restart: (exe, _) async => restarts.add(exe));
await c.checkOnce();
await c.install();
expect(c.status.value.phase, UpdatePhase.restarting);
expect(restarts.single, '${root.path}/starsync');
// What a 1.2.x app restarts after this update, and the VPS server
// name: both launch the new binaries.
final app = await Process.run('${root.path}/watch_together', ['--a']);
expect('${app.stdout}'.trim(), 'starsync --a', reason: '${app.stderr}');
expect(_isExec('${root.path}/watch-together-server'), isTrue);
}, skip: _posixOnly);
test('newer protocol is a required update, cannot be dismissed',
() async {
server.manifestExtra = {'protocol': RelayConstants.protoVersion + 1};
+1 -1
View File
@@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/sync/user_seek.dart';
import 'package:starsync/src/sync/user_seek.dart';
StateBroadcast _state({int eventId = 1, String? seekBy, int? seekSeq}) =>
StateBroadcast(
+2 -2
View File
@@ -6,8 +6,8 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
import 'package:watch_together/src/session/session_coordinator.dart';
import 'package:watch_together/src/sync/state_diff.dart';
import 'package:starsync/src/session/session_coordinator.dart';
import 'package:starsync/src/sync/state_diff.dart';
const _mediaPath = '/home/foxyz/Videos/chiyo.mp4';
+5 -5
View File
@@ -3,11 +3,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart' show VideoState;
import 'package:watch_together/src/controls/model/controls_prefs.dart';
import 'package:watch_together/src/controls/frame.dart';
import 'package:watch_together/src/shell/session_player.dart';
import 'package:watch_together/src/state/session_state.dart';
import 'package:watch_together/src/state/settings.dart';
import 'package:starsync/src/controls/model/controls_prefs.dart';
import 'package:starsync/src/controls/frame.dart';
import 'package:starsync/src/shell/session_player.dart';
import 'package:starsync/src/state/session_state.dart';
import 'package:starsync/src/state/settings.dart';
// The wheel anywhere on the full player flashes the volume readout, in
// both skins, windowed and fullscreen, with the controls up or hidden.
+1 -1
View File
@@ -3,7 +3,7 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart';
import 'package:watch_together/src/playback/playback_controller.dart';
import 'package:starsync/src/playback/playback_controller.dart';
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
const _referer = 'https://www.bilibili.com/';
+2 -2
View File
@@ -1,10 +1,10 @@
# Project-level configuration.
cmake_minimum_required(VERSION 3.14)
project(watch_together LANGUAGES CXX)
project(starsync LANGUAGES CXX)
# The name of the executable created for the application. Change this to change
# the on-disk name of your application.
set(BINARY_NAME "watch_together")
set(BINARY_NAME "starsync")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.
+6 -6
View File
@@ -89,13 +89,13 @@ BEGIN
BEGIN
BLOCK "040904e4"
BEGIN
VALUE "CompanyName", "com.example" "\0"
VALUE "FileDescription", "watch_together" "\0"
VALUE "CompanyName", "foxyz" "\0"
VALUE "FileDescription", "Starsync" "\0"
VALUE "FileVersion", VERSION_AS_STRING "\0"
VALUE "InternalName", "watch_together" "\0"
VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0"
VALUE "OriginalFilename", "watch_together.exe" "\0"
VALUE "ProductName", "watch_together" "\0"
VALUE "InternalName", "Starsync" "\0"
VALUE "LegalCopyright", "Copyright (C) 2026 foxyz. All rights reserved." "\0"
VALUE "OriginalFilename", "starsync.exe" "\0"
VALUE "ProductName", "Starsync" "\0"
VALUE "ProductVersion", VERSION_AS_STRING "\0"
END
END
+1 -1
View File
@@ -16,7 +16,7 @@ namespace {
#define DWMWA_USE_IMMERSIVE_DARK_MODE 20
#endif
constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW";
constexpr const wchar_t kWindowClassName[] = L"STARSYNC_WINDOW";
/// Registry key for app theme preference.
///
@@ -1,7 +1,10 @@
# Dedicated watch-together server (no display needed).
# Install: unpack the release tarball to /opt/watch-together (the unit only
# uses the watch-together-server binary in it), copy this file to
# /etc/systemd/system/, then `systemctl enable --now watch-together`.
# Dedicated Starsync server (no display needed).
# Install: unpack the release tarball to /opt/starsync (the unit only uses
# the starsync-server binary in it), copy this file to
# /etc/systemd/system/, then `systemctl enable --now starsync`.
# An install from before the rename (/opt/watch-together, unit
# watch-together, ExecStart .../watch-together-server) keeps working
# unchanged: the archive still ships watch-together-server as a launcher.
# Flags: --port <n> (default 8080), --secret <password> (empty = open
# server, anyone who can reach the port can join), --name, --media <file>,
# --advertise-host <host>, --no-yt-dlp, --yt-dlp-dir <dir>, --config
@@ -11,15 +14,15 @@
# process lists are readable by other users.
[Unit]
Description=watch-together dedicated server
Description=Starsync dedicated server
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/opt/watch-together/watch-together-server --port 8080 --secret change-me
ExecStart=/opt/starsync/starsync-server --port 8080 --secret change-me
Restart=on-failure
DynamicUser=true
StateDirectory=watch-together
StateDirectory=starsync
NoNewPrivileges=true
[Install]
+71 -19
View File
@@ -9,7 +9,7 @@ Plain overview first: [HOW-IT-WORKS.md](HOW-IT-WORKS.md). Wire format:
|---|---|---|
| `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging, Jellyfin client, updater, watched rule (`WatchSession`); `testing.dart` (test support for all three packages, not in `core.dart`: `FlakyProxy`) | pure Dart, no Flutter |
| `packages/server` | Relic HTTP + WebSocket server (`SyncServer`, `ServerHub`, `Session`, media endpoint, relay buffer, Jellyfin relay), the `serve()` entrypoint, `bin/server.dart` (dedicated server) | pure Dart, no Flutter |
| `apps/desktop` | Flutter app: UI, `SyncClient`, `Connection`, `SessionCoordinator`, `PlaybackController` (media_kit/mpv), `ServerIsolate`, update UI (`UpdateController`) | ships with `watch-together-server` |
| `apps/desktop` | Flutter app: UI, `SyncClient`, `Connection`, `SessionCoordinator`, `PlaybackController` (media_kit/mpv), `ServerIsolate`, update UI (`UpdateController`) | ships with `starsync-server` |
| `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only |
| `deploy` | systemd unit for the dedicated server | |
@@ -58,7 +58,7 @@ size (`ServerIsolate.setFile`): the host's in-session File swap then sends a
cancelled, solo starts again with the same media. Stop leaves without
asking.
- **Dedicated server**: `bin/server.dart` compiled with `dart compile exe`
and shipped next to the app as `watch-together-server`. It parses flags
and shipped next to the app as `starsync-server`. It parses flags
(`--port`, `--secret`, `--name`, `--media`, `--advertise-host`,
`--jellyfin <file>`, `--[no-]yt-dlp`, `--yt-dlp-dir`, `--yt-dlp-cookies`, `--config`; default port 8080) into the same map and calls `serve`
(`--log-dir` only picks where its log files go, see "Logging")
@@ -1174,6 +1174,10 @@ handed on from there (the coordinator gets its `Notifier`, `Connection` and
options, warm-up.
- `SystemNotifications`: an interface only; desktop shows none
(`NoSystemNotifications`).
- `AppDirsPort` (`DesktopAppDirs`): where Hive and the logs live
(`AppDirs`, "Desktop UI"), and the copy of the data from before the
rename; resolved in `main` before the composition root exists, so not a
`Ports` field.
Still direct: the player (`PlaybackController`, media_kit, and its
`AppYtDlp.instance.mpvRawOptions`), the updater's restart
(`Process.start`), the session's own file reads for what it plays or
@@ -1503,11 +1507,38 @@ Settings persist in two Hive boxes: `connect_form` (last form, recent
servers and links, the profile picture and colour; saved even when a join
fails) and
`settings`. Where Hive and the logs live is `AppDirs`
(`platform/app_dirs.dart`, resolved once in `main`): Hive's default folder
(the documents folder) and `<app support>/logs`; with `STARSYNC_PROFILE`
(DEVELOPMENT "Dev loop") `<app support>/profiles/<name>/hive` and `logs`,
and the window title gets ` [<name>]`. yt-dlp, shaders and caches stay
shared. Link media names are a web page's title (see "yt-dlp"), else
(`platform/ports/app_dirs.dart`, resolved once in `main` by the
`AppDirsPort`, `DesktopAppDirs` on the desktop): `<app support>/hive` and
`<app support>/logs`; with `STARSYNC_PROFILE` (DEVELOPMENT "Dev loop")
`<app support>/profiles/<name>/hive` and `logs`, and the window title gets
` [<name>]`. yt-dlp, shaders and caches stay shared. The app support
folder is path_provider's: Linux `~/.local/share/dev.foxyz.starsync` (the
application id), Windows `%APPDATA%\foxyz\Starsync` (the exe's company and
product name).
**Data from before the rename** (`platform/data_migration.dart`, run by
`DesktopAppDirs.resolve` before Hive opens and the log starts; its lines
are logged once the log is open): once per support folder (the marker
`<app support>/migration.json`: `migratedFrom`, `hiveFrom`, `boxes`,
`files`), the first existing old support folder (Linux
`moe.senkochan.watch_together`, then `watch_together`, next to the new one;
Windows `%APPDATA%\com.example\watch_together`) is copied in whole
(profiles, logs, shaders, yt-dlp; modes kept, so `cookies.txt` stays 0600;
symlinks as links), and the app's own Hive boxes (`appHiveBoxes`:
`settings`, `connect_form`, `files`, `jellyfin`, `track_prefs`, `.hive`
and `.lock`, by name only, never other apps' boxes there) from the
documents folder, where `Hive.initFlutter()` kept them up to 1.2.x, into
`<app support>/hive`. Copied, never moved (1.2.x keeps working), never
over a file already there. Lines name paths and counts, never contents.
The caches folder (thumbnails, the AniList schedule) is not copied: it
refills.
**Settings schema** (`state/settings_schema.dart`): the `settings` box
stores `schemaVersion`; `migrateSettings` (run when `AppSettings` loads)
applies the ordered `settingsMigrations` steps past the stored version up
to `settingsSchemaVersion`, stamping the version after each. 1 is the
layout of 1.2.x (a box without a version is at 1, no steps yet); a box
from a newer build is left alone. Link media names are a web page's title (see "yt-dlp"), else
derived from the URL path, never containing a query string.
Jellyfin on the desktop (`lib/src/jellyfin/`, over the core client):
@@ -1697,7 +1728,7 @@ dedicated server (`package:logging` loggers named by area: `relay`,
`app`).
- **Where**: the app writes `<app support>/logs/app-YYYY-MM-DD.log`
(Linux `~/.local/share/moe.senkochan.watch_together/logs`; with a
(Linux `~/.local/share/dev.foxyz.starsync/logs`; with a
profile `<app support>/profiles/<name>/logs`, see `AppDirs` under
"Desktop UI"); its hosted server's records arrive over the isolate's
`events` port and go in the same file as `server.<logger>` (`relogLine`). The dedicated server
@@ -1809,13 +1840,19 @@ status.
`assets/icon/icon.svg` is the source; `icon_<size>.png` and the Windows
`app_icon.ico` are rendered from it (rsvg-convert, magick). The GTK
application id is `moe.senkochan.watch_together`, which Wayland uses as the
window's `app_id`; compositors take the icon from the `.desktop` file of
that id. A tarball install has none, so on start a Linux release build
(`platform/desktop_entry.dart`, skipped in debug) writes
`~/.local/share/applications/moe.senkochan.watch_together.desktop` (Exec =
the running binary) and the icons into the hicolor theme, only when they
changed. The runner also sets the window icon from the bundle (X11).
application id is `dev.foxyz.starsync` (also the X11 `WM_CLASS`, from
`g_set_prgname`), which Wayland uses as the window's `app_id`; compositors
take the icon from the `.desktop` file of that id. A tarball install has
none, so on start a Linux release build (`platform/desktop_entry.dart`,
skipped in debug) writes
`~/.local/share/applications/dev.foxyz.starsync.desktop` (Exec = the
running binary, `StartupWMClass` and `Icon` = the id) and the icons into
the hicolor theme, only when they changed, and deletes the entry and
icons 1.2.x wrote under its id `moe.senkochan.watch_together` (those exact
files only). The runner also sets the window icon from the bundle (X11).
Windows: the exe's version resource (`windows/runner/Runner.rc`) says
company `foxyz`, product `Starsync`; the window class is
`STARSYNC_WINDOW`.
## Updater
@@ -1829,8 +1866,23 @@ folder (same filesystem, so moves are renames; the folder must be
user-writable). Each top-level entry is moved aside and the new one moved in,
with rollback on failure (writing the running executable fails with ETXTBSY;
rename works). A packaged install is detected by `data/` and `lib/` next to
the binary (app or server, same folder); anything else exits with "update
manually". A manifest whose
the binary (app or server, same folder; Windows: `data/` and
`flutter_windows.dll`), whatever the binary is called; anything else exits
with "update manually". The archive must contain the running binary's name;
an old name (`watch_together`, `watch-together-server`, `.exe` on
Windows) is also satisfied by the new one (`starsync`, `starsync-server`).
**Names before the rename**: the archive (`starsync-<ver>-<platform>`,
top folder `starsync-<ver>/`) also carries those old names, because a
1.2.x updater checks that its own name is in the archive and then restarts
it, and VPS units and old `update.sh` copies run `watch-together-server`.
Linux: shell launchers (`lib/src/update/bundle/watch_together`,
`watch-together-server`, `exec` the new binary next to the resolved
script); Windows: copies of `starsync.exe` and `starsync-server.exe` (same
version resource, so the same data folder). After an update the app
restarts `relaunchExecutable(...)`: the running binary, or for an old name
the new one next to it, so a copy started by 1.2.x moves to `starsync.exe`
on its first own update. `update.sh` / `update.bat` run `starsync-server`,
else the old name. A manifest whose
protocol is newer than the local one is a required update. Windows apply
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` /
@@ -1842,9 +1894,9 @@ About and the tooltip of the strip's Settings tab): a release's version,
a test build's with its commit (`GIT_COMMIT`, short hash, `-dirty` with
uncommitted changes, passed by `tool/package_linux.sh`), and for a dev
run the pubspec's version (`FLUTTER_BUILD_NAME`, which the Flutter tool
defines for every build and run) with "dev run". CLI: `watch-together-server --upgrade` (no display needed;
defines for every build and run) with "dev run". CLI: `starsync-server --upgrade` (no display needed;
the shipped `update.sh` / `update.bat` run it), `--check-update` prints only;
it ends with "restart the server to run <version>". `watch_together --upgrade`
it ends with "restart the server to run <version>". `starsync --upgrade`
does the same from the app binary. In the app, `UpdateController`
(`apps/desktop/lib/src/update/`) runs the same code for the update bar
(`UpdateBar`, `screens/home/update_indicator.dart`): a band across the
+11 -5
View File
@@ -21,7 +21,7 @@ cd apps/desktop && flutter pub get && flutter run -d linux
cd apps/desktop && flutter build linux --release --dart-define=APP_VERSION=<x.y.z>
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 --define=APP_VERSION=<x.y.z> bin/server.dart -o watch-together-server
cd packages/server && dart compile exe --define=APP_VERSION=<x.y.z> bin/server.dart -o starsync-server
```
`APP_VERSION` defaults to `0.0.0-dev`; the packagers stamp it from the
@@ -30,7 +30,13 @@ also passes `GIT_COMMIT` (what the app shows: "Updater" in
[ARCHITECTURE.md](ARCHITECTURE.md)). Setting `DISCORD_WEBHOOK_URL` makes the packager post the
tarball to Discord (never commit it). The packager always compiles the
dedicated server (with the Flutter SDK's `dart`) into the tarball as
`watch-together-server`, also with `--skip-build`.
`starsync-server`, also with `--skip-build`. The app binary is `starsync`
(`BINARY_NAME`; Linux application id `dev.foxyz.starsync`), the archive
`dist/starsync-<version>-linux-x64.tar.gz` / `-windows-x64.zip` with one
top folder `starsync-<version>/`; it also carries the names up to 1.2.x
(`watch_together`, `watch-together-server`: launchers on Linux, copies on
Windows; "Updater" in [ARCHITECTURE.md](ARCHITECTURE.md)). The Dart
package of the app is `starsync` (`package:starsync/...`).
## Dev loop
@@ -224,8 +230,8 @@ unexpectedly` line just before it is expected. Without the app:
After a session, read the log files rather than reproducing
("Logging" in [ARCHITECTURE.md](ARCHITECTURE.md) has what goes in them):
the app's are `~/.local/share/moe.senkochan.watch_together/logs/app-*.log`
on Linux (on Windows under `%APPDATA%`; Settings → About → "Open logs
the app's are `~/.local/share/dev.foxyz.starsync/logs/app-*.log`
on Linux (on Windows `%APPDATA%\foxyz\Starsync\logs`; Settings → About → "Open logs
folder" finds it anywhere), with the hosted server's lines
in the same file; a dedicated server prints its folder at start
(`--log-dir`). Users send theirs with Settings → About → "Copy
@@ -259,7 +265,7 @@ rasterizer: missing or blocked GPU drivers, `LIBGL_ALWAYS_SOFTWARE=1`);
no usable EGL context: check `render env:` for an X11 session or a
`GDK_BACKEND` / `FLUTTER_LINUX_RENDERER` override). Its reason is only on
stderr: have the user start the app from a terminal
(`watch-together 2>&1 | grep VideoOutput`). `hwdec-current=no` on the
(`./starsync 2>&1 | grep VideoOutput`). `hwdec-current=no` on the
first-frame line means software decoding (no hwdec for that codec, or it
is off), not software rendering.
+1 -1
View File
@@ -42,7 +42,7 @@ A five-minute tour. Details: [ARCHITECTURE.md](ARCHITECTURE.md), wire format:
- **Host from the app**: the app you click Host in also runs the server.
Simplest. The server lives as long as that app.
- **Dedicated server**: `watch-together-server`, a separate program in the
- **Dedicated server**: `starsync-server`, a separate program in the
release tarball, for an always-on box. See the README.
Friends join either way with Join a friend in the strip at the top of the
+14 -14
View File
@@ -85,15 +85,6 @@ the automatic continuation and countdown, relay state), the device
(client id, library and folders, match corrections, watch history,
settings, notification prefs, what this app is sourcing) and the
integrations (Jellyfin played/resume, AniList list).
R. Rename (before step 1, so dev builds install side by side with 1.2.x):
app id `dev.foxyz.starsync`, binary, Windows company `foxyz`; Hive out
of `~/Documents` into the app's support folder, copied not moved;
a settings schema version with ordered migrations; `AppDirs` (today
Hive and logs, and `STARSYNC_PROFILE`) becomes a port and the default
moves next to the profiles;
the 1.3.0 archive still contains `watch_together(.exe)` so 1.2.x's
self-update restart works; the old `.desktop` entry removed; the
manifest kept at the old URL; VPS `ExecStart`/`update.sh` paths.
1. Connection + Identity + Presence; proto 16 opened (version ranges on
both sides, refusal frame, identity, grace, presence, relay resume,
ids in the feed); later steps add fields, it is frozen at the 1.3.0
@@ -161,9 +152,14 @@ proto 16 together at the 1.3.0 release. Rooms are out (no room id).
No Windows dev builds: restructure testing happens on Linux; Windows
friends stay on 1.2.x until the 1.3.0 release (Windows-only fixes like
P30 reach them then).
Rename to Starsync everywhere (repo, app id, data folder, updater URL
migration) is its own step; app id `dev.foxyz.starsync`, Windows
company name `foxyz` (replacing Flutter's default `com.example`). Auto
Left of the rename (app, binaries, archives and data folder are
Starsync): the repository (`Foxyz/sync_replay_project`) and with it the
manifest and asset URLs (a new manifest URL needs a release that reads
both first); the dedicated server's fallback folder
`~/.cache/watch-together`, the release token file
`~/.config/watch-together-release-token`, Jellyfin's client name "Watch
Together" (devices are listed under it) and `WatchTogetherApp`; dropping
the 1.2.x launchers from the archive once nobody updates from 1.2.x. Auto
sit-out: idle or minimised for a while sets you Away, activity sets you
In; it replaces the 10-minute auto-unready.
@@ -338,8 +334,12 @@ over `wss://`/`https://` (`/health.tls`); apps use `wss://` when offered,
5. **Ops**: Check shaders on Windows; the relay e2e "source drops
uncleanly and resumes" fails about half the time under the full suite.
6. **Windows: first real use.** CI builds and publishes
`watch-together-<ver>-windows-x64.zip` (libmpv bundled) and runs the
6. **Windows: first real use.** Check the rename at 1.3.0: a 1.2.x
install updates and restarts (`watch_together.exe`, a copy of
`starsync.exe`), the data in `%APPDATA%\foxyz\Starsync` came over from
`%APPDATA%\com.example\watch_together` and Documents, the next update
restarts `starsync.exe`. CI builds and publishes
`starsync-<ver>-windows-x64.zip` (libmpv bundled) and runs the
updater tests on NTFS; a friend has joined from it. Still unchecked:
fullscreen controls, hosting (firewall prompt), in-app update from one
release to the next. The rest of the desktop tests are not in CI.
+1 -1
View File
@@ -142,7 +142,7 @@ typedef DiagTarget = ({Uri base, String token, double offsetMs});
/// saying so per connection; problem marks ([markProblem]) go either way.
class DiagUploader {
final DiagTarget? Function() target;
/// The app and its version, e.g. `Watch Together 1.4.0 on linux`.
/// The app and its version, e.g. `Starsync 1.4.0 on linux`.
final String version;
final int bufferBytes;
final int batchBytes;
+48 -5
View File
@@ -7,9 +7,9 @@ import 'package:crypto/crypto.dart';
import 'constants.dart';
import 'logging.dart';
// The one updater for both programs: the app (`watch_together --upgrade`
// The one updater for both programs: the app (`starsync --upgrade`
// and the in-app update card, via UpdateController) and the dedicated
// server (`watch-together-server --upgrade`). Both ship in the same
// server (`starsync-server --upgrade`). Both ship in the same
// release archive, so either one swaps the whole install folder: fetch
// manifest, download, verify sha256, apply over the live install.
//
@@ -39,6 +39,41 @@ const appVersion = String.fromEnvironment('APP_VERSION',
// Key into the manifest's `assets` map, e.g. linux-x64, windows-x64.
final updatePlatform = '${Platform.operatingSystem}-x64';
/// The app's binary in a release bundle (`.exe` on Windows).
const appBinaryName = 'starsync';
/// The dedicated server's binary next to it (`.exe` on Windows).
const serverBinaryName = 'starsync-server';
/// The names up to 1.2.x. The archive still carries launchers by these
/// names (tool/package_linux.sh, package_windows.ps1): a 1.2.x updater
/// checks that its own name is in the archive and restarts it, and VPS
/// units and `update.sh` copies run the old server name.
const legacyAppBinaryName = 'watch_together';
const legacyServerBinaryName = 'watch-together-server';
// For an old binary name, the new one (same extension); else null.
String? _renamedBinary(String name) {
final exe = name.toLowerCase().endsWith('.exe');
final stem = exe ? name.substring(0, name.length - 4) : name;
final renamed = switch (stem) {
legacyAppBinaryName => appBinaryName,
legacyServerBinaryName => serverBinaryName,
_ => null,
};
return renamed == null ? null : '$renamed${exe ? name.substring(name.length - 4) : ''}';
}
/// The binary to start after an update replaced [executablePath]: the
/// same one, except that an old name (a launcher, or on Windows a copy
/// of the new binary) gives way to the new binary next to it.
String relaunchExecutable(String executablePath) {
final norm = executablePath.replaceAll('\\', '/');
final cut = norm.lastIndexOf('/');
final renamed = _renamedBinary(norm.substring(cut + 1));
if (renamed == null) return executablePath;
final next = '${executablePath.substring(0, cut + 1)}$renamed';
return File(next).existsSync() ? next : executablePath;
}
const _stagingDir = '.update-staging';
const _backupDir = '.update-backup';
const _downloadFile = '.update-download';
@@ -303,7 +338,7 @@ List<String> _safeSegments(String name) {
}
// Release archives wrap the bundle in one top-level folder
// (watch-together-<ver>/, see tool/package_linux.sh). Strip it; a flat
// (starsync-<ver>/, see tool/package_linux.sh). Strip it; a flat
// archive (data/, lib/ and the binary at the top) is used as is.
int _wrapperDepth(List<List<String>> entries) {
final tops = entries.where((s) => s.isNotEmpty).map((s) => s.first).toSet();
@@ -414,7 +449,7 @@ Future<void> applyUpdate(File archive, Directory root,
await _extract(await archive.readAsBytes(), stage, !win);
if (!_isBundle(stage.path, win) ||
(executableName != null &&
!await File('${stage.path}/$executableName').exists())) {
!await _hasBinary(stage.path, executableName))) {
throw const FormatException(
'archive is not a release bundle (missing data/, lib/ or binary)');
}
@@ -428,6 +463,14 @@ Future<void> applyUpdate(File archive, Directory root,
}
}
// The running binary's name, or for an old name its new one (an archive
// without the old launchers still updates an install started by one).
Future<bool> _hasBinary(String dir, String name) async {
if (await File('$dir/$name').exists()) return true;
final renamed = _renamedBinary(name);
return renamed != null && await File('$dir/$renamed').exists();
}
class _Moved {
final String name;
final bool hadOld;
@@ -565,7 +608,7 @@ Future<int> runUpgrade(
HttpClient? http,
String? executablePath,
Directory? installRoot,
String program = 'watch-together',
String program = 'starsync',
String Function(String version)? restartNote,
bool checkOnly = false}) async {
stdout.writeln('$program $currentVersion (proto ${RelayConstants.protoVersion})');
+88 -15
View File
@@ -24,34 +24,48 @@ final _posixOnly = Platform.isWindows ? 'POSIX install layout' : null;
bool _isExec(String path) => File(path).statSync().mode & 0x40 != 0;
// Builds the release archive exactly like tool/package_linux.sh:
// bundle copied into <stage>/watch-together-<ver>/, then
// `tar -czf out -C <stage> watch-together-<ver>`. [zip]: like
// bundle copied into <stage>/starsync-<ver>/, then
// `tar -czf out -C <stage> starsync-<ver>`. [zip]: like
// tool/package_windows.ps1 (`tar -a -c -f out.zip`, bsdtar — Windows only).
// [legacy]: with the launchers by the names up to 1.2.x (Linux: the shell
// script of apps/desktop/lib/src/update/bundle/watch_together; Windows: a
// copy of the exe). [app]: the app binary's content (Linux).
Future<List<int>> _releaseTarball(Directory work,
{String version = '0.2.0',
bool windows = false,
bool bundle = true,
bool zip = false}) async {
bool zip = false,
bool legacy = true,
String app = 'binary-v2'}) async {
final stage = Directory('${work.path}/pkg-$version-${windows ? 'win' : 'lin'}');
final folder = Directory('${stage.path}/watch-together-$version');
final folder = Directory('${stage.path}/starsync-$version');
await Directory('${folder.path}/data/flutter_assets').create(recursive: true);
await File('${folder.path}/data/flutter_assets/fresh.cfg')
.writeAsString('fresh-config');
if (windows) {
await File('${folder.path}/starsync.exe').writeAsString('binary-v2');
if (legacy) {
await File('${folder.path}/watch_together.exe').writeAsString('binary-v2');
}
await File('${folder.path}/flutter_windows.dll').writeAsString('dll-v2');
} else {
await File('${folder.path}/watch_together').writeAsString('binary-v2');
await File('${folder.path}/starsync').writeAsString(app);
if (bundle) {
await Directory('${folder.path}/lib').create();
await File('${folder.path}/lib/libapp.so.1').writeAsString('lib-v2');
await Link('${folder.path}/lib/libapp.so').create('libapp.so.1');
}
await File('${folder.path}/update.sh')
.writeAsString('#!/bin/sh\nexec ./watch_together --upgrade\n');
.writeAsString('#!/bin/sh\nexec ./starsync-server --upgrade\n');
final exec = ['starsync', 'update.sh'];
if (legacy) {
await File('${folder.path}/watch_together').writeAsString(
'#!/bin/sh\nexec "\$(dirname "\$(readlink -f "\$0")")/starsync" "\$@"\n');
exec.add('watch_together');
}
if (!Platform.isWindows) {
final r = await Process.run('chmod',
['755', '${folder.path}/watch_together', '${folder.path}/update.sh']);
final r = await Process.run(
'chmod', ['755', for (final f in exec) '${folder.path}/$f']);
expect(r.exitCode, 0);
}
}
@@ -61,12 +75,13 @@ Future<List<int>> _releaseTarball(Directory work,
out.path,
'-C',
stage.path,
'watch-together-$version',
'starsync-$version',
]);
expect(r.exitCode, 0, reason: '${r.stderr}');
return out.readAsBytes();
}
// An install as 1.2.x left it: the binary is still called watch_together.
Future<Directory> _fakeRoot(Directory work, {bool windows = false}) async {
final root = Directory('${work.path}/install');
await Directory('${root.path}/data').create(recursive: true);
@@ -111,12 +126,12 @@ class _Server {
req.response.write(jsonEncode({
'version': '0.2.0',
'protocol': RelayConstants.protoVersion,
'url': '$base/watch-together-0.2.0-linux-x64.tar.gz',
'url': '$base/starsync-0.2.0-linux-x64.tar.gz',
'sha256': sha256.convert(tarball).toString(),
'notes': 'test build',
...manifestExtra,
}));
} else if (req.uri.path == '/watch-together-0.2.0-linux-x64.tar.gz') {
} else if (req.uri.path == '/starsync-0.2.0-linux-x64.tar.gz') {
tarHits++;
req.response.contentLength = tarball.length;
req.response.add(tarball);
@@ -262,11 +277,12 @@ void main() {
expect(rc, 0);
expect(server.tarHits, 1);
final p = root.path;
expect(await File('$p/watch_together').readAsString(), 'binary-v2');
expect(await File('$p/starsync').readAsString(), 'binary-v2');
expect(await File('$p/data/flutter_assets/fresh.cfg').readAsString(),
'fresh-config');
expect(Directory('$p/watch-together-0.2.0').existsSync(), isFalse);
expect(_isExec('$p/watch_together'), isTrue);
expect(Directory('$p/starsync-0.2.0').existsSync(), isFalse);
expect(_isExec('$p/starsync'), isTrue);
expect(_isExec('$p/watch_together'), isTrue, reason: 'the old name, a launcher');
expect(_isExec('$p/update.sh'), isTrue);
expect(await Link('$p/lib/libapp.so').target(), 'libapp.so.1');
expect(await File('$p/lib/libapp.so').readAsString(), 'lib-v2');
@@ -310,7 +326,7 @@ void main() {
manifestUrl: server.manifestUrl,
installRoot: root);
expect(rc, 0);
expect(await File('${root.path}/watch_together').readAsString(),
expect(await File('${root.path}/starsync').readAsString(),
'binary-v2');
}, skip: _posixOnly);
@@ -358,6 +374,7 @@ void main() {
expect(await File('$p/lib/libgone.so').readAsString(), 'lib-v1');
expect(await File('$p/data/old.cfg').readAsString(), 'old-config');
expect(File('$p/update.sh').existsSync(), isFalse);
expect(File('$p/starsync').existsSync(), isFalse);
expect(_leftovers(root), isEmpty);
}
@@ -367,6 +384,7 @@ void main() {
'aside:data',
'in:data',
'aside:lib',
'in:starsync',
'in:update.sh',
'aside:watch_together',
'in:watch_together',
@@ -423,12 +441,66 @@ void main() {
expect(File('${root.path}/data/thing.dll.old').existsSync(), isFalse);
});
// A 1.2.x updater checks its own name is in the archive, applies, then
// restarts that name: the launcher must start the new binary.
test('1.2.x install: the old name is in the archive and starts the new binary',
() async {
final root = await _fakeRoot(work);
final f = File('${work.path}/script.tar.gz')
..writeAsBytesSync(await _releaseTarball(work,
app: '#!/bin/sh\necho "new binary: \$*"\n'));
await applyUpdate(f, root,
windows: false, executableName: legacyAppBinaryName);
final r = await Process.run('${root.path}/watch_together', ['--x']);
expect(r.exitCode, 0, reason: '${r.stderr}');
expect('${r.stdout}'.trim(), 'new binary: --x');
// This build's own restart goes to the new binary directly.
expect(relaunchExecutable('${root.path}/watch_together'),
'${root.path}/starsync');
}, skip: _posixOnly);
test('started by an old name, an archive without it still applies',
() async {
final root = await _fakeRoot(work);
final f = File('${work.path}/plain.tar.gz')
..writeAsBytesSync(await _releaseTarball(work, legacy: false));
await applyUpdate(f, root,
windows: false, executableName: legacyAppBinaryName);
expect(await File('${root.path}/starsync').readAsString(), 'binary-v2');
// Unknown names still need their binary in the archive.
final again = File('${work.path}/plain2.tar.gz')
..writeAsBytesSync(await _releaseTarball(work, version: '0.3.0', legacy: false));
await expectLater(
applyUpdate(again, root, windows: false, executableName: 'other'),
throwsFormatException);
});
test('relaunchExecutable maps old names to the new binary next to them',
() async {
final dir = (await work.createTemp('r')).path;
for (final n in ['starsync', 'starsync.exe', 'starsync-server']) {
await File('$dir/$n').writeAsString('x');
}
expect(relaunchExecutable('$dir/watch_together'), '$dir/starsync');
expect(relaunchExecutable('$dir/watch_together.exe'), '$dir/starsync.exe');
expect(relaunchExecutable('$dir/watch-together-server'),
'$dir/starsync-server');
expect(relaunchExecutable('$dir/starsync'), '$dir/starsync');
expect(relaunchExecutable('$dir/other'), '$dir/other');
// No new binary next to it: the old name stays.
expect(relaunchExecutable('${work.path}/watch_together'),
'${work.path}/watch_together');
expect(relaunchExecutable(r'C:\x\watch_together.exe'),
r'C:\x\watch_together.exe');
});
// Windows strategy exercised on Linux: proves the bookkeeping, not the
// Windows file-locking semantics (no Windows runner yet).
test('windows-style swap applies and parks old files', () async {
final root = await _fakeRoot(work, windows: true);
await applyUpdate(await archive(windows: true), root, windows: true);
final p = root.path;
expect(await File('$p/starsync.exe').readAsString(), 'binary-v2');
expect(await File('$p/watch_together.exe').readAsString(), 'binary-v2');
expect(await File('$p/flutter_windows.dll').readAsString(), 'dll-v2');
expect(await File('$p/data/flutter_assets/fresh.cfg').readAsString(),
@@ -452,6 +524,7 @@ void main() {
expect(await File('$p/watch_together.exe').readAsString(), 'binary-v1');
expect(await File('$p/flutter_windows.dll').readAsString(), 'dll-v1');
expect(File('$p/data/flutter_assets/fresh.cfg').existsSync(), isFalse);
expect(File('$p/starsync.exe').existsSync(), isFalse);
expect(File('$p/flutter_windows.dll.old').existsSync(), isFalse);
expect(_leftovers(root), isEmpty);
});
+5 -4
View File
@@ -8,7 +8,8 @@ import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart';
/// Dedicated server: `dart compile exe` of this file is shipped as
/// `watch-together-server`. Runs [serve] until SIGINT or SIGTERM.
/// `starsync-server` (also started by the `watch-together-server`
/// launcher). Runs [serve] until SIGINT or SIGTERM.
Future<void> main(List<String> args) async {
final parser = ArgParser()
..addOption('port', abbr: 'p', defaultsTo: '8080', help: 'TCP port to listen on (all IPv4 interfaces).')
@@ -50,12 +51,12 @@ Future<void> main(List<String> args) async {
}
if (parsed['help'] as bool) {
// ignore: avoid_print
print('watch-together-server $appVersion (proto ${RelayConstants.protoVersion})\n\n${parser.usage}');
print('starsync-server $appVersion (proto ${RelayConstants.protoVersion})\n\n${parser.usage}');
return;
}
if (parsed['upgrade'] as bool || parsed['check-update'] as bool) {
exitCode = await runUpgrade(
program: 'watch-together-server',
program: 'starsync-server',
checkOnly: !(parsed['upgrade'] as bool),
restartNote: (v) => 'updated to $v — restart the server to run $v.');
return;
@@ -67,7 +68,7 @@ Future<void> main(List<String> args) async {
(configPath != null ? '${File(configPath).absolute.parent.path}${Platform.pathSeparator}logs' : serverDataDir('logs'));
final files = startLogFiles(logDir, prefix: 'server');
if (files != null) stdout.writeln('logs: ${files.dir}');
pkg_logging.Logger('server').info('watch-together-server $appVersion (proto ${RelayConstants.protoVersion}) '
pkg_logging.Logger('server').info('starsync-server $appVersion (proto ${RelayConstants.protoVersion}) '
'on ${Platform.operatingSystem} ${Platform.operatingSystemVersion}, pid $pid');
AppConfig config;
try {
+2 -2
View File
@@ -55,7 +55,7 @@ export VERSION="$NEXT-dev.$N"
echo "building $VERSION (last published: ${LAST:-none})"
"$ROOT/tool/package_linux.sh" --no-upload
TARBALL="$ROOT/dist/watch-together-$VERSION-linux-x64.tar.gz"
TARBALL="$ROOT/dist/starsync-$VERSION-linux-x64.tar.gz"
[[ -f "$TARBALL" ]] || { echo "error: $TARBALL missing" >&2; exit 1; }
NOTES="$(git -C "$ROOT" log -1 --format=%s)"
@@ -90,5 +90,5 @@ cat <<EOF
Published $VERSION. Dev builds pick it up with their next update check.
A stable install moves onto test builds once: with the app open, run (bash)
p=\$(pgrep -xo watch_together) && d=\$(dirname "\$(readlink /proc/\$p/exe)") && t=\$(mktemp -d) && curl -fsSL $BASE_URL/$(basename "$TARBALL") | tar -xz -C "\$t" --strip-components=1 && cp -rf --remove-destination "\$t"/. "\$d"/ && rm -rf "\$t" && echo "Done: restart the app." || echo "Failed: is the app open?"
p=\$(pgrep -xo 'starsync|watch_together') && d=\$(dirname "\$(readlink /proc/\$p/exe)") && t=\$(mktemp -d) && curl -fsSL $BASE_URL/$(basename "$TARBALL") | tar -xz -C "\$t" --strip-components=1 && cp -rf --remove-destination "\$t"/. "\$d"/ && rm -rf "\$t" && echo "Done: restart the app." || echo "Failed: is the app open?"
EOF
+16 -11
View File
@@ -2,13 +2,16 @@
# Package the Linux release bundle into a versioned tar.gz and optionally
# upload it to Discord.
# Usage: tool/package_linux.sh [--skip-build] [--noupload|--no-upload]
# Output: dist/watch-together-<version>-linux-x64.tar.gz containing one
# top-level folder with the executable, lib/ and data/ as built, plus
# update.sh (runs watch-together-server --upgrade) and watch-together-server
# (the dedicated server, `dart compile exe` of packages/server/bin/server.dart,
# built every run, also with --skip-build; both binaries are stamped with
# APP_VERSION from the app's pubspec). The updater strips
# that top-level folder — keep the layout in sync with packages/core/test/updater_test.dart.
# Output: dist/starsync-<version>-linux-x64.tar.gz containing one
# top-level folder starsync-<version>/ with the executable `starsync`, lib/
# and data/ as built, plus update.sh (runs starsync-server --upgrade) and
# starsync-server (the dedicated server, `dart compile exe` of
# packages/server/bin/server.dart, built every run, also with --skip-build;
# both binaries are stamped with APP_VERSION from the app's pubspec), and
# the launchers `watch_together` and `watch-together-server` (the names up
# to 1.2.x: their updater restarts `watch_together`, VPS units run the old
# server name). The updater strips that top-level folder — keep the layout
# in sync with packages/core/test/updater_test.dart.
# Upload: set DISCORD_WEBHOOK_URL in the environment (never commit it).
# The tarball must stay under Discord's upload limit (25 MB free tier).
# The upload posts a Components V2 card (flag 1<<15): version header,
@@ -28,7 +31,7 @@ done
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP="$ROOT/apps/desktop"
NAME="watch-together"
NAME="starsync"
ARCH="x64"
# VERSION in the environment overrides it (tool/dev_release.sh).
@@ -56,7 +59,9 @@ TARBALL="$DIST/$FOLDER-linux-$ARCH.tar.gz"
rm -rf "$STAGE"
mkdir -p "$STAGE/$FOLDER" "$DIST"
cp -r "$BUNDLE/." "$STAGE/$FOLDER/"
install -m 755 "$APP/lib/src/update/bundle/update.sh" "$STAGE/$FOLDER/update.sh"
for f in update.sh watch_together watch-together-server; do
install -m 755 "$APP/lib/src/update/bundle/$f" "$STAGE/$FOLDER/$f"
done
# Prefer the dart bundled with the Flutter SDK that builds the app.
DART="dart"
if FLUTTER="$(command -v flutter)"; then
@@ -64,7 +69,7 @@ if FLUTTER="$(command -v flutter)"; then
[[ -x "$CANDIDATE" ]] && DART="$CANDIDATE"
fi
(cd "$ROOT/packages/server" && "$DART" pub get >/dev/null &&
"$DART" compile exe --define=APP_VERSION="$VERSION" bin/server.dart -o "$STAGE/$FOLDER/watch-together-server")
"$DART" compile exe --define=APP_VERSION="$VERSION" bin/server.dart -o "$STAGE/$FOLDER/starsync-server")
tar -czf "$TARBALL" -C "$STAGE" "$FOLDER"
rm -rf "$STAGE"
ls -la "$TARBALL"
@@ -119,7 +124,7 @@ payload = {
'accent_color': 5793266,
'components': [
{'type': 10, 'content':
f'# 📦 watch-together `{ver}`\n'
f'# 📦 Starsync `{ver}`\n'
f'-# linux · {arch} · release · {size_mb:.1f} MB'},
{'type': 10, 'content': f'`{commit[:140]}`'},
{'type': 10, 'content': block[:1500]},
+14 -10
View File
@@ -1,19 +1,21 @@
# 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 watch-together-server.exe --upgrade) and
# watch-together-server.exe (dedicated server, `dart compile exe` of
# packages/server/bin/server.dart; both binaries stamped with APP_VERSION).
# Same layout as tool/package_linux.sh; the updater strips the top-level
# folder.
# Output: dist/starsync-<version>-windows-x64.zip containing one
# top-level folder starsync-<version>/ with starsync.exe, its DLLs and
# data/ as built, plus update.bat (runs starsync-server.exe --upgrade) and
# starsync-server.exe (dedicated server, `dart compile exe` of
# packages/server/bin/server.dart; both binaries stamped with APP_VERSION),
# and copies of both under the names up to 1.2.x (watch_together.exe,
# watch-together-server.exe: a 1.2.x updater checks for and restarts its
# own name). Same layout as tool/package_linux.sh; the 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'
$Name = 'starsync'
$Arch = 'x64'
$Version = (Select-String -Path "$App/pubspec.yaml" -Pattern '^version: ([0-9.]+)').Matches[0].Groups[1].Value
@@ -58,7 +60,7 @@ if (-not $SkipBuild) {
}
$Bundle = Join-Path $App "build/windows/$Arch/runner/Release"
if (-not (Test-Path "$Bundle/watch_together.exe")) { throw "bundle missing (build first): $Bundle" }
if (-not (Test-Path "$Bundle/starsync.exe")) { throw "bundle missing (build first): $Bundle" }
$Stage = Join-Path $App 'build/package-stage'
$Dist = Join-Path $Root 'dist'
@@ -68,12 +70,14 @@ 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"
Copy-Item "$Stage/$Folder/starsync.exe" "$Stage/$Folder/watch_together.exe"
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 "--define=APP_VERSION=$Version" bin/server.dart -o "$Stage/$Folder/watch-together-server.exe" }
Invoke-Checked 'dart compile exe' { dart compile exe "--define=APP_VERSION=$Version" bin/server.dart -o "$Stage/$Folder/starsync-server.exe" }
} finally { Pop-Location }
Copy-Item "$Stage/$Folder/starsync-server.exe" "$Stage/$Folder/watch-together-server.exe"
if (Test-Path $Zip) { Remove-Item -Force $Zip }
# bsdtar writes forward-slash entry names (Compress-Archive on 5.1 doesn't).
+2 -2
View File
@@ -55,11 +55,11 @@ PROTO="$(sed -n 's/.*protoVersion = \([0-9]*\).*/\1/p' "$ROOT/packages/core/lib/
if [[ -n "$LATEST" && "$LATEST" != "v$VER" ]]; then
NOTES="$(git -C "$ROOT" log -n 20 "$LATEST..HEAD" --format='- %s')"
else
NOTES="Watch together $VER."
NOTES="Starsync $VER."
fi
export MANIFEST_FILE="$ROOT/updates/manifest.json" MANIFEST_VER="$VER" \
MANIFEST_PROTO="$PROTO" MANIFEST_NOTES="$NOTES" \
MANIFEST_BASE="$SERVER/$REPO/releases/download/v$VER/watch-together-$VER"
MANIFEST_BASE="$SERVER/$REPO/releases/download/v$VER/starsync-$VER"
python3 - <<'EOF'
import json, os
path = os.environ['MANIFEST_FILE']
+2 -2
View File
@@ -45,7 +45,7 @@ Future<void> main(List<String> args) async {
final prev = _git(root, ['describe', '--tags', '--abbrev=0', '$tag^'],
allowFail: true);
final notes = prev.isEmpty
? 'Watch together $ver.'
? 'Starsync $ver.'
: _git(root, [
'log',
'$prev..$tag',
@@ -63,7 +63,7 @@ Future<void> main(List<String> args) async {
rel = await c.post('$api/releases', {
'tag_name': tag,
'name': tag,
'body': 'Watch together $ver\n\n$notes',
'body': 'Starsync $ver\n\n$notes',
'draft': false,
'prerelease': false,
});