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 - run: dart test
working-directory: packages/server working-directory: packages/server
# Compiling the dedicated server proves it stays Flutter-free. # 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 working-directory: packages/server
- run: build/watch-together-server --help - run: build/starsync-server --help
working-directory: packages/server working-directory: packages/server
# The updater's Windows apply path (running exe parked as .old) only # 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 }} RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: | run: |
VER="${GITHUB_REF_NAME#v}" 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: windows:
# yuuka-windows: host-mode runner (no containers), runs as SYSTEM, has # yuuka-windows: host-mode runner (no containers), runs as SYSTEM, has
@@ -73,5 +73,5 @@ jobs:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: | run: |
$Ver = $env:GITHUB_REF_NAME.TrimStart('v') $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 } 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. Why not Jellyfin SyncPlay: it runs in the browser and its sync is poor.
This is a desktop app that drives mpv. This is a desktop app that drives mpv.
Name: `watch-together` is a placeholder and the final name is undecided Up to 1.2.x the app was called `watch_together` (its files
(`sync-relay` is the working name). It appears in file names and the `watch-together-*`); the repository and the update manifest keep their old
update manifest until that is settled. addresses for now.
- Desktop only (Linux and Windows), always mpv. No web clients. - Desktop only (Linux and Windows), always mpv. No web clients.
- No transcoding. Everyone plays the original file. - No transcoding. Everyone plays the original file.
@@ -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: From the Releases page of this repo:
- Linux: `watch-together-<version>-linux-x64.tar.gz`. Extract, run - Linux: `starsync-<version>-linux-x64.tar.gz`. Extract, run
`watch_together`. On start it adds itself to your app launcher (and its `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`). 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 - Windows: `starsync-<version>-windows-x64.zip`. Extract, run
`watch_together.exe`. mpv is bundled. New and not yet tried on a real `starsync.exe`. mpv is bundled. New and not yet tried on a real
Windows desktop; reports welcome. 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 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 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, (never over a playing video). "Update and restart" downloads,
verifies, applies in place and restarts; "Later" hides it until the next 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 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: 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 `./starsync-server --upgrade` (no display needed, so it works on a
server box too), `watch_together --upgrade`, or the shipped `update.sh` / server box too), `./starsync --upgrade`, or the shipped `update.sh` /
`update.bat` (which run the first). Each updates the whole folder, app and `update.bat` (which run the first). Each updates the whole folder, app and
server; restart whichever is running to use the new version. A server; restart whichever is running to use the new version. A
release with a newer protocol is a required update: everyone on a server release with a newer protocol is a required update: everyone on a server
@@ -221,11 +233,11 @@ cover; one you started shows how far you got.
## Dedicated server ## Dedicated server
For an always-on box (no display needed): the release tarball also contains 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. then everyone (you included) uses Join.
``` ```
./watch-together-server --port 8080 --secret <password> ./starsync-server --port 8080 --secret <password>
``` ```
| Flag | Default | Meaning | | Flag | Default | Meaning |
@@ -245,9 +257,11 @@ then everyone (you included) uses Join.
It prints `READY port=... name=...` once listening and stops cleanly on It prints `READY port=... name=...` once listening and stops cleanly on
Ctrl+C or SIGTERM. A dedicated server has no host. 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 To run it as a service, unpack the tarball to `/opt/starsync` and use
[deploy/watch-together.service](deploy/watch-together.service) (copy it to [deploy/starsync.service](deploy/starsync.service) (copy it to
`/etc/systemd/system/`, edit the flags, `systemctl enable --now watch-together`). `/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 Something not playing? Settings → About → **Copy diagnostics** puts the
app's recent log (passwords, tokens and cookies left out) on 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 'package:sync_relay_core/core.dart';
import 'app.dart'; import 'app.dart';
import 'src/platform/app_dirs.dart';
import 'src/platform/app_log.dart'; import 'src/platform/app_log.dart';
import 'src/platform/app_shutdown.dart'; import 'src/platform/app_shutdown.dart';
import 'src/platform/desktop/desktop_app_dirs.dart';
import 'src/platform/desktop_entry.dart'; import 'src/platform/desktop_entry.dart';
import 'src/screens/home/ui.dart'; import 'src/screens/home/ui.dart';
import 'src/session/app_events.dart' show UpdateAvailable; import 'src/session/app_events.dart' show UpdateAvailable;
@@ -42,10 +42,9 @@ Future<void> main(List<String> args) async {
), ),
); );
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
final dirs = await AppDirs.resolve(); final dirs = await const DesktopAppDirs().resolve();
await startAppLogging(dirs); await startAppLogging(dirs);
// An absolute subDir replaces the documents folder (package:path join). Hive.init(dirs.hive);
await Hive.initFlutter(dirs.hive);
await AppSettings.instance.ensureLoaded(); await AppSettings.instance.ensureLoaded();
// Close button / Alt+F4: the orderly close ([shutDownApp]), so others // Close button / Alt+F4: the orderly close ([shutDownApp]), so others
// see "left", a hosted server shuts down and no player outlives the // 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 'package:sync_relay_core/core.dart';
import '../screens/home/ui.dart' show appName, displayVersion; import '../screens/home/ui.dart' show appName, displayVersion;
import 'app_dirs.dart'; import 'ports/app_dirs.dart';
import '../state/session_state.dart'; import '../state/session_state.dart';
// The app's log on disk (<app support>/logs/app-YYYY-MM-DD.log; core's // 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} ' _appLog.info('$appName ${displayVersion()} on ${Platform.operatingSystem} '
'${Platform.operatingSystemVersion}, pid $pid, Dart ${Platform.version.split(' ').first}' '${Platform.operatingSystemVersion}, pid $pid, Dart ${Platform.version.split(' ').first}'
'${dirs.profile == null ? '' : ', profile ${dirs.profile}'}'); '${dirs.profile == null ? '' : ', profile ${dirs.profile}'}');
for (final line in dirs.migrated) {
_appLog.info(line);
}
final bad = AppDirs.invalidProfile(Platform.environment); final bad = AppDirs.invalidProfile(Platform.environment);
if (bad != null) _appLog.warning('$profileEnv="$bad" ignored: use letters, digits, - and _ (at most 32)'); 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 /// The app's id: GTK's application id, so the Wayland `app_id` the
/// compositor matches to [desktopFileName] for the window's icon. /// compositor matches to [desktopFileName] for the window's icon.
const appId = 'moe.senkochan.watch_together'; const appId = 'dev.foxyz.starsync';
const desktopFileName = '$appId.desktop'; 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 /// Linux release installs (an unpacked tarball) carry no launcher entry, so
/// Wayland shows a generic icon. On start: write the `.desktop` file into /// Wayland shows a generic icon. On start: write the `.desktop` file into
/// `~/.local/share/applications` (Exec = this binary) and the icon into /// `~/.local/share/applications` (Exec = this binary) and the icon into
/// the hicolor theme, only when something changed (a moved install, a new /// the hicolor theme, only when something changed (a moved install, a new
/// icon). Never in debug runs; failures only log. /// icon). The entry and icons of [legacyAppId] go. Never in debug runs
Future<void> installDesktopEntry({String? executable, String? dataHome, String? home}) async { /// (unless [force], for tests); failures only log.
if (!Platform.isLinux || kDebugMode) return; Future<void> installDesktopEntry(
{String? executable, String? dataHome, String? home, @visibleForTesting bool force = false}) async {
if (!Platform.isLinux || (kDebugMode && !force)) return;
try { try {
final exe = executable ?? Platform.resolvedExecutable; final exe = executable ?? Platform.resolvedExecutable;
final root = findInstallRoot(exe, windows: false); 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'] ?? final share = dataHome ?? Platform.environment['XDG_DATA_HOME'] ??
'${home ?? Platform.environment['HOME']}/.local/share'; '${home ?? Platform.environment['HOME']}/.local/share';
final icons = '${root.path}/data/flutter_assets/assets/icon'; 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'); final src = File('$icons/icon_$size.png');
if (!src.existsSync()) continue; if (!src.existsSync()) continue;
final dst = File('$share/icons/hicolor/${size}x$size/apps/$appId.png'); 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 @visibleForTesting
String desktopEntryText(String exe) => '''[Desktop Entry] String desktopEntryText(String exe) => '''[Desktop Entry]
Type=Application 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 'system_notifications.dart';
import 'url_opener.dart'; import 'url_opener.dart';
export 'app_dirs.dart';
export 'folder_access.dart'; export 'folder_access.dart';
export 'link_resolver.dart'; export 'link_resolver.dart';
export 'shell_port.dart'; export 'shell_port.dart';
+1 -1
View File
@@ -158,7 +158,7 @@ class AniListClient {
_cacheDir = cacheDir ?? _defaultCacheDir, _cacheDir = cacheDir ?? _defaultCacheDir,
_delay = delay ?? Future<void>.delayed, _delay = delay ?? Future<void>.delayed,
_now = now ?? DateTime.now, _now = now ?? DateTime.now,
userAgent = userAgent ?? 'watch-together/$appVersion'; userAgent = userAgent ?? 'Starsync/$appVersion';
final Uri url; final Uri url;
final Duration memoryTtl; final Duration memoryTtl;
+1 -1
View File
@@ -474,7 +474,7 @@ class AnimeFlowClient {
_ownsHttp = http == null, _ownsHttp = http == null,
_delay = delay ?? Future<void>.delayed, _delay = delay ?? Future<void>.delayed,
_now = now ?? DateTime.now, _now = now ?? DateTime.now,
userAgent = userAgent ?? 'watch-together/$appVersion'; userAgent = userAgent ?? 'Starsync/$appVersion';
/// Authenticated with the linked Jellyfin account on the same domain /// Authenticated with the linked Jellyfin account on the same domain
/// (see [tokenFor]), looked up on every call so a re-link applies. /// (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 '../controls/model/controls_prefs.dart';
import '../shaders/shader_presets.dart'; import '../shaders/shader_presets.dart';
import 'settings_schema.dart';
// App preferences (Settings page), one Hive box. Signals start at their // App preferences (Settings page), one Hive box. Signals start at their
// defaults and take the stored values once [AppSettings.ensureLoaded] // defaults and take the stored values once [AppSettings.ensureLoaded]
@@ -134,6 +135,9 @@ class AppSettings {
Future<void> _loadNow() async { Future<void> _loadNow() async {
try { try {
final box = _box = await Hive.openBox(settingsBoxName); final box = _box = await Hive.openBox(settingsBoxName);
for (final line in await migrateSettings(box)) {
debugPrint(line);
}
final mode = box.get(_keyHwdecMode); final mode = box.get(_keyHwdecMode);
hwdecMode.value = HwdecMode.values hwdecMode.value = HwdecMode.values
.where((m) => m.name == mode) .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 @echo off
rem Update this install in place (same as clicking Update in the app). 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 pause
+4 -1
View File
@@ -1,4 +1,7 @@
#!/bin/sh #!/bin/sh
# Update this install in place (same as clicking Update in the app). # Update this install in place (same as clicking Update in the app).
# The server binary runs the updater: it needs no GTK/display (VPS). # 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'); _log.info('updated to ${m.version}, restarting');
status.value = status.value =
UpdateStatus(UpdatePhase.restarting, manifest: m, canInstall: true); 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 name of the executable created for the application. Change this to change
# the on-disk name of your application. # 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: # The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID # 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 # Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake. # versions of CMake.
+2 -2
View File
@@ -1,5 +1,5 @@
name: watch_together name: starsync
description: Watch-together desktop app (working title, repo sync_relay_project). One binary. description: Starsync desktop app (repo sync_relay_project). One binary.
publish_to: none publish_to: none
version: 1.2.0 version: 1.2.0
environment: environment:
+127 -5
View File
@@ -1,18 +1,24 @@
// restructure: rewrite in step R (Rename): Hive moves into the support import 'dart:convert';
// folder for the normal app too.
import 'dart:io'; import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; 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() { void main() {
final sep = Platform.pathSeparator; final sep = Platform.pathSeparator;
const support = '/s'; 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 {}); final d = AppDirs.fromEnv(support: support, env: const {});
expect(d.profile, isNull); 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'); 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: ''}), isNull, reason: 'empty is just unset');
expect(AppDirs.invalidProfile(const {profileEnv: 'ok_1-2'}), isNull); 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/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/shell/app_shell.dart'; import 'package:starsync/src/shell/app_shell.dart';
import 'package:watch_together/src/state/session_state.dart'; import 'package:starsync/src/state/session_state.dart';
import 'package:watch_together/src/update/update_controller.dart'; import 'package:starsync/src/update/update_controller.dart';
import 'package:watch_together/src/widgets/app_keys.dart'; import 'package:starsync/src/widgets/app_keys.dart';
import 'package:watch_together/src/widgets/text_focus.dart'; import 'package:starsync/src/widgets/text_focus.dart';
void main() { void main() {
final session = SessionState.instance; 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:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart'; import 'package:sync_relay_server/server.dart';
import 'package:watch_together/src/sync/avatar_cache.dart'; import 'package:starsync/src/sync/avatar_cache.dart';
import 'package:watch_together/src/sync/sync_client.dart'; import 'package:starsync/src/sync/sync_client.dart';
Future<void> _poll(FutureOr<bool> Function() cond, String what) async { Future<void> _poll(FutureOr<bool> Function() cond, String what) async {
final deadline = DateTime.now().add(const Duration(seconds: 10)); 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:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart'; import 'package:sync_relay_server/server.dart';
import 'package:watch_together/src/playback/media_marks.dart'; import 'package:starsync/src/playback/media_marks.dart';
import 'package:watch_together/src/session/session_coordinator.dart'; import 'package:starsync/src/session/session_coordinator.dart';
import 'package:watch_together/src/sync/state_diff.dart'; import 'package:starsync/src/sync/state_diff.dart';
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path; 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:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart'; import 'package:sync_relay_server/server.dart';
import 'package:watch_together/src/sync/state_diff.dart'; import 'package:starsync/src/sync/state_diff.dart';
import 'package:watch_together/src/sync/sync_client.dart'; import 'package:starsync/src/sync/sync_client.dart';
Future<void> _poll(FutureOr<bool> Function() cond, String what) async { Future<void> _poll(FutureOr<bool> Function() cond, String what) async {
final end = DateTime.now().add(const Duration(seconds: 10)); 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/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:signals/signals_flutter.dart'; import 'package:signals/signals_flutter.dart';
import 'package:watch_together/src/controls/controls_probe.dart'; import 'package:starsync/src/controls/controls_probe.dart';
import 'package:watch_together/src/controls/model/controls_layout.dart'; import 'package:starsync/src/controls/model/controls_layout.dart';
import 'package:watch_together/src/controls/model/controls_model.dart'; import 'package:starsync/src/controls/model/controls_model.dart';
import 'package:watch_together/src/controls/model/controls_prefs.dart'; import 'package:starsync/src/controls/model/controls_prefs.dart';
import 'package:watch_together/src/controls/session_controls.dart'; import 'package:starsync/src/controls/session_controls.dart';
import 'package:watch_together/src/playback/media_marks.dart'; import 'package:starsync/src/playback/media_marks.dart';
import 'package:watch_together/src/state/session_state.dart'; import 'package:starsync/src/state/session_state.dart';
import 'package:watch_together/src/state/settings.dart'; import 'package:starsync/src/state/settings.dart';
// The controls model and its layout spec: which controls the bar has, in // 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. // 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_core/core.dart';
import 'package:sync_relay_server/server.dart'; import 'package:sync_relay_server/server.dart';
import 'package:hive_ce/hive.dart'; import 'package:hive_ce/hive.dart';
import 'package:watch_together/src/session/session_coordinator.dart'; import 'package:starsync/src/session/session_coordinator.dart';
import 'package:watch_together/src/sfx/sfx.dart'; import 'package:starsync/src/sfx/sfx.dart';
import 'package:watch_together/src/state/session_state.dart'; import 'package:starsync/src/state/session_state.dart';
import 'package:watch_together/src/sync/sync_client.dart'; import 'package:starsync/src/sync/sync_client.dart';
Future<void> _poll( Future<void> _poll(
FutureOr<bool> Function() cond, FutureOr<bool> Function() cond,
+2 -2
View File
@@ -3,8 +3,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/session/delivery.dart'; import 'package:starsync/src/session/delivery.dart';
import 'package:watch_together/src/shell/delivery_line.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 _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); 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:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.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 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/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:hive_ce/hive.dart'; import 'package:hive_ce/hive.dart';
import 'package:watch_together/src/files/folder_store.dart'; import 'package:starsync/src/files/folder_store.dart';
import 'package:watch_together/src/files/scanner.dart'; import 'package:starsync/src/files/scanner.dart';
import 'package:watch_together/src/files/thumbnails.dart'; import 'package:starsync/src/files/thumbnails.dart';
import 'package:watch_together/src/files/ui/file_tiles.dart'; import 'package:starsync/src/files/ui/file_tiles.dart';
import 'package:watch_together/src/files/ui/files_page.dart'; import 'package:starsync/src/files/ui/files_page.dart';
// The Files page over a real (tiny) folder tree: the scan runs in-line // 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 // 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 'dart:io';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/files/name_parser.dart'; import 'package:starsync/src/files/name_parser.dart';
import 'package:watch_together/src/files/scanner.dart'; import 'package:starsync/src/files/scanner.dart';
late Directory _root; late Directory _root;
+1 -1
View File
@@ -2,7 +2,7 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:hive_ce/hive.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() { void main() {
test('folders and recent files persist across a reload', () async { 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:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import 'package:media_kit/media_kit.dart'; 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 // Real libmpv: frames grabbed with vo=null + screenshot-to-file. The test
// media comes from ffmpeg (this machine only; the app never needs it). // media comes from ffmpeg (this machine only; the app never needs it).
+1 -1
View File
@@ -1,7 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; 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 // What a drop or a paste is: decided from paths and text alone (the file
// system stands behind a fake). // 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/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/files/ui/files_page.dart'; import 'package:starsync/src/files/ui/files_page.dart';
import 'package:watch_together/src/schedule/ui/schedule_page.dart'; import 'package:starsync/src/schedule/ui/schedule_page.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/screens/home/health_probe.dart'; import 'package:starsync/src/screens/home/health_probe.dart';
import 'package:watch_together/src/screens/home/media_picker.dart'; import 'package:starsync/src/screens/home/media_picker.dart';
import 'package:watch_together/src/screens/home/update_indicator.dart'; import 'package:starsync/src/screens/home/update_indicator.dart';
import 'package:watch_together/src/shell/app_shell.dart'; import 'package:starsync/src/shell/app_shell.dart';
import 'package:watch_together/src/shell/library_link.dart' show soloPlayHint; import 'package:starsync/src/shell/library_link.dart' show soloPlayHint;
import 'package:watch_together/src/state/session_state.dart'; import 'package:starsync/src/state/session_state.dart';
import 'package:watch_together/src/state/settings.dart'; import 'package:starsync/src/state/settings.dart';
import 'package:watch_together/src/update/update_controller.dart'; import 'package:starsync/src/update/update_controller.dart';
RecentServer join(String host, String port, [String secret = '']) => RecentServer join(String host, String port, [String secret = '']) =>
RecentServer(hosted: false, host: host, port: port, secret: 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:flutter_test/flutter_test.dart';
import 'package:hive_ce/hive.dart'; import 'package:hive_ce/hive.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/accounts.dart'; import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/format.dart'; import 'package:starsync/src/jellyfin/format.dart';
import 'package:watch_together/src/jellyfin/sign_in.dart'; import 'package:starsync/src/jellyfin/sign_in.dart';
import 'package:watch_together/src/screens/home/media_picker.dart'; import 'package:starsync/src/screens/home/media_picker.dart';
/// Captured Jellyfin 10.11 answers shared with packages/core's client tests. /// Captured Jellyfin 10.11 answers shared with packages/core's client tests.
String jellyfinFixture(String name) => 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/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/accounts.dart'; import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/accounts_panel.dart'; import 'package:starsync/src/jellyfin/accounts_panel.dart';
import 'package:watch_together/src/jellyfin/browser.dart'; import 'package:starsync/src/jellyfin/browser.dart';
import 'package:watch_together/src/jellyfin/browse_scope.dart'; import 'package:starsync/src/jellyfin/browse_scope.dart';
import 'package:watch_together/src/jellyfin/picker_source.dart'; import 'package:starsync/src/jellyfin/picker_source.dart';
import 'package:watch_together/src/state/settings.dart'; import 'package:starsync/src/state/settings.dart';
import 'jellyfin_accounts_test.dart' import 'jellyfin_accounts_test.dart'
show FakeJellyfin, arifuretaId, fixtureServerId, linkedAccounts, season2Id; 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:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart'; import 'package:sync_relay_server/server.dart';
import 'package:watch_together/src/jellyfin/accounts.dart'; import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/session_media.dart'; import 'package:starsync/src/jellyfin/session_media.dart';
import 'package:watch_together/src/jellyfin/track_prefs.dart'; import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:watch_together/src/playback/media_marks.dart'; import 'package:starsync/src/playback/media_marks.dart';
import 'package:watch_together/src/screens/home/picked_media.dart'; import 'package:starsync/src/screens/home/picked_media.dart';
import 'package:watch_together/src/session/session_coordinator.dart'; import 'package:starsync/src/session/session_coordinator.dart';
// Two account holders and a guest watch Jellyfin episodes together over a // 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): // 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/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/accounts.dart'; import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/browse_scope.dart'; import 'package:starsync/src/jellyfin/browse_scope.dart';
import 'package:watch_together/src/jellyfin/browser.dart'; import 'package:starsync/src/jellyfin/browser.dart';
import 'package:watch_together/src/jellyfin/progress.dart'; import 'package:starsync/src/jellyfin/progress.dart';
import 'package:watch_together/src/jellyfin/session_media.dart'; import 'package:starsync/src/jellyfin/session_media.dart';
import 'package:watch_together/src/jellyfin/track_prefs.dart'; import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:watch_together/src/screens/home/media_picker.dart'; import 'package:starsync/src/screens/home/media_picker.dart';
import 'package:watch_together/src/screens/session/jellyfin_widgets.dart'; import 'package:starsync/src/screens/session/jellyfin_widgets.dart';
import 'package:watch_together/src/session/delivery.dart'; import 'package:starsync/src/session/delivery.dart';
import 'package:watch_together/src/session/session_coordinator.dart' show AutoAdvance; import 'package:starsync/src/session/session_coordinator.dart' show AutoAdvance;
import 'package:watch_together/src/shell/library_tab.dart' show putUpJellyfinMedia; import 'package:starsync/src/shell/library_tab.dart' show putUpJellyfinMedia;
import 'jellyfin_accounts_test.dart'; import 'jellyfin_accounts_test.dart';
import 'jellyfin_browser_test.dart' show app, setSize, settle; 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:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/home_rows.dart'; import 'package:starsync/src/jellyfin/home_rows.dart';
import 'package:watch_together/src/screens/home/media_picker.dart'; import 'package:starsync/src/screens/home/media_picker.dart';
import 'package:watch_together/src/shell/library_link.dart'; import 'package:starsync/src/shell/library_link.dart';
import 'package:watch_together/src/shell/library_tab.dart'; import 'package:starsync/src/shell/library_tab.dart';
final now = DateTime.utc(2026, 10, 8, 12); final now = DateTime.utc(2026, 10, 8, 12);
DateTime daysAgo(num d) => now.subtract(Duration(minutes: (d * 24 * 60).round())); 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/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/accounts.dart'; import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/browse_scope.dart'; import 'package:starsync/src/jellyfin/browse_scope.dart';
import 'package:watch_together/src/jellyfin/details_panel.dart'; import 'package:starsync/src/jellyfin/details_panel.dart';
import 'package:watch_together/src/screens/home/media_picker.dart'; import 'package:starsync/src/screens/home/media_picker.dart';
import 'package:watch_together/src/shell/library_link.dart'; import 'package:starsync/src/shell/library_link.dart';
import 'package:watch_together/src/shell/library_tab.dart'; import 'package:starsync/src/shell/library_tab.dart';
import 'jellyfin_accounts_test.dart' import 'jellyfin_accounts_test.dart'
show FakeJellyfin, arifuretaId, fixtureServerId, jellyfinFixture, linkedAccounts, season2Id; show FakeJellyfin, arifuretaId, fixtureServerId, jellyfinFixture, linkedAccounts, season2Id;
+3 -3
View File
@@ -1,8 +1,8 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/session_media.dart'; import 'package:starsync/src/jellyfin/session_media.dart';
import 'package:watch_together/src/jellyfin/track_prefs.dart'; import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:watch_together/src/playback/media_marks.dart'; import 'package:starsync/src/playback/media_marks.dart';
Duration s(num secs) => Duration(milliseconds: (secs * 1000).round()); Duration s(num secs) => Duration(milliseconds: (secs * 1000).round());
Duration m(int min, [int sec = 0]) => Duration(minutes: min, seconds: sec); 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/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/controls/frame.dart'; import 'package:starsync/src/controls/frame.dart';
import 'package:watch_together/src/controls/minimal/style.dart'; import 'package:starsync/src/controls/minimal/style.dart';
import 'package:watch_together/src/controls/minimal/timeline.dart'; import 'package:starsync/src/controls/minimal/timeline.dart';
import 'package:watch_together/src/controls/model/controls_prefs.dart'; import 'package:starsync/src/controls/model/controls_prefs.dart';
import 'package:watch_together/src/controls/volume.dart'; import 'package:starsync/src/controls/volume.dart';
import 'package:watch_together/src/playback/media_marks.dart'; import 'package:starsync/src/playback/media_marks.dart';
import 'package:watch_together/src/shell/settings_tab.dart'; import 'package:starsync/src/shell/settings_tab.dart';
import 'package:watch_together/src/state/settings.dart'; import 'package:starsync/src/state/settings.dart';
import 'package:watch_together/src/update/update_controller.dart'; import 'package:starsync/src/update/update_controller.dart';
import 'package:watch_together/src/widgets/app_menu.dart'; import 'package:starsync/src/widgets/app_menu.dart';
// The Minimal skin's pieces and the shared controls frame (never the // The Minimal skin's pieces and the shared controls frame (never the
// session screen or a media_kit `Video`). // 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:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart' show FeedMessage; import 'package:sync_relay_core/core.dart' show FeedMessage;
import 'package:watch_together/src/session/notifier.dart'; import 'package:starsync/src/session/notifier.dart';
import 'package:watch_together/src/sfx/sfx.dart'; import 'package:starsync/src/sfx/sfx.dart';
final _at = DateTime.utc(2026, 1, 1); final _at = DateTime.utc(2026, 1, 1);
+2 -2
View File
@@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/session/now_playing.dart'; import 'package:starsync/src/session/now_playing.dart';
import 'package:watch_together/src/session/now_playing_info.dart'; import 'package:starsync/src/session/now_playing_info.dart';
void main() { void main() {
group('jellyfinTitle', () { group('jellyfinTitle', () {
+5 -5
View File
@@ -2,11 +2,11 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart' show JellyfinStreamType; import 'package:sync_relay_core/core.dart' show JellyfinStreamType;
import 'package:watch_together/src/controls/control_menus.dart'; import 'package:starsync/src/controls/control_menus.dart';
import 'package:watch_together/src/controls/standard/seek_bar.dart'; import 'package:starsync/src/controls/standard/seek_bar.dart';
import 'package:watch_together/src/jellyfin/track_prefs.dart'; import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:watch_together/src/playback/media_marks.dart'; import 'package:starsync/src/playback/media_marks.dart';
import 'package:watch_together/src/screens/session/player_controls.dart'; import 'package:starsync/src/screens/session/player_controls.dart';
// The control-bar pieces alone (never the session screen or a `Video`): // The control-bar pieces alone (never the session screen or a `Video`):
// the Standard seek bar, the skip pill and the shared track / chapter // 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/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/controls/frame.dart'; import 'package:starsync/src/controls/frame.dart';
import 'package:watch_together/src/controls/model/controls_prefs.dart'; import 'package:starsync/src/controls/model/controls_prefs.dart';
import 'package:watch_together/src/controls/player_settings.dart'; import 'package:starsync/src/controls/player_settings.dart';
import 'package:watch_together/src/jellyfin/track_prefs.dart'; import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:watch_together/src/schedule/animeflow.dart'; import 'package:starsync/src/schedule/animeflow.dart';
import 'package:watch_together/src/shaders/shader_presets.dart'; import 'package:starsync/src/shaders/shader_presets.dart';
import 'package:watch_together/src/shell/avatar.dart'; import 'package:starsync/src/shell/avatar.dart';
import 'package:watch_together/src/shell/party_strip.dart'; import 'package:starsync/src/shell/party_strip.dart';
import 'package:watch_together/src/shell/settings_tab.dart'; import 'package:starsync/src/shell/settings_tab.dart';
import 'package:watch_together/src/state/session_state.dart'; import 'package:starsync/src/state/session_state.dart';
import 'package:watch_together/src/state/settings.dart'; import 'package:starsync/src/state/settings.dart';
import 'package:watch_together/src/update/update_controller.dart'; import 'package:starsync/src/update/update_controller.dart';
// The player settings card (both skins, never a media_kit `Video`), the // The player settings card (both skins, never a media_kit `Video`), the
// Settings pages, the host flag in the party UI and invite addresses. // 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/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/controls/frame.dart'; import 'package:starsync/src/controls/frame.dart';
import 'package:watch_together/src/controls/minimal/style.dart'; import 'package:starsync/src/controls/minimal/style.dart';
import 'package:watch_together/src/controls/minimal/timeline.dart'; import 'package:starsync/src/controls/minimal/timeline.dart';
import 'package:watch_together/src/session/health.dart'; import 'package:starsync/src/session/health.dart';
import 'package:watch_together/src/session/now_playing.dart'; import 'package:starsync/src/session/now_playing.dart';
import 'package:watch_together/src/shell/now_playing_title.dart'; import 'package:starsync/src/shell/now_playing_title.dart';
import 'package:watch_together/src/shell/player_status.dart'; import 'package:starsync/src/shell/player_status.dart';
import 'package:watch_together/src/shell/session_player.dart'; import 'package:starsync/src/shell/session_player.dart';
import 'package:watch_together/src/shell/start_banner.dart'; import 'package:starsync/src/shell/start_banner.dart';
import 'package:watch_together/src/state/session_state.dart'; import 'package:starsync/src/state/session_state.dart';
// The player's title block, start card and status pill (never a media_kit // The player's title block, start card and status pill (never a media_kit
// `Video`). // `Video`).
+1 -1
View File
@@ -2,7 +2,7 @@
// the janitor become server state. // the janitor become server state.
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.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, MemberView member(String id,
{bool stale = false, bool out = false, Map<String, bool> jf = const {}, String name = ''}) => {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/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/queue/queue_ui.dart'; import 'package:starsync/src/queue/queue_ui.dart';
import 'package:watch_together/src/session/session_coordinator.dart'; import 'package:starsync/src/session/session_coordinator.dart';
import 'package:watch_together/src/shell/library_link.dart' show LinkPreview; import 'package:starsync/src/shell/library_link.dart' show LinkPreview;
import 'package:watch_together/src/widgets/text_focus.dart'; import 'package:starsync/src/widgets/text_focus.dart';
void main() { void main() {
testWidgets('Up next popover: holds the keys, Ctrl+V adds with a preview row, + Add link opens a field', 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:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart'; import 'package:sync_relay_server/server.dart';
import 'package:watch_together/src/jellyfin/accounts.dart'; import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/session_media.dart'; import 'package:starsync/src/jellyfin/session_media.dart';
import 'package:watch_together/src/jellyfin/track_prefs.dart'; import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:watch_together/src/playback/yt_dlp_app.dart'; import 'package:starsync/src/playback/yt_dlp_app.dart';
import 'package:watch_together/src/queue/queue_controller.dart' show WarmState; import 'package:starsync/src/queue/queue_controller.dart' show WarmState;
import 'package:watch_together/src/screens/home/picked_media.dart'; import 'package:starsync/src/screens/home/picked_media.dart';
import 'package:watch_together/src/session/session_coordinator.dart'; import 'package:starsync/src/session/session_coordinator.dart';
import 'package:watch_together/src/session/switch_trace.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 // 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 // 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:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.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; 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:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.dart'; import 'package:sync_relay_server/server.dart';
import 'package:watch_together/src/jellyfin/accounts.dart'; import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:watch_together/src/jellyfin/track_prefs.dart'; import 'package:starsync/src/jellyfin/track_prefs.dart';
import 'package:watch_together/src/playback/yt_dlp_app.dart'; import 'package:starsync/src/playback/yt_dlp_app.dart';
import 'package:watch_together/src/screens/home/picked_media.dart'; import 'package:starsync/src/screens/home/picked_media.dart';
import 'package:watch_together/src/session/session_coordinator.dart'; import 'package:starsync/src/session/session_coordinator.dart';
// The relay with real coordinators, real mpv and a dedicated server that // 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 // 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:flutter_test/flutter_test.dart';
import 'package:watch_together/src/files/name_parser.dart'; import 'package:starsync/src/files/name_parser.dart';
void _expect( void _expect(
String name, String name,
+4 -4
View File
@@ -3,8 +3,8 @@ import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/schedule/anilist.dart'; import 'package:starsync/src/schedule/anilist.dart';
import 'package:watch_together/src/session/media_art.dart'; import 'package:starsync/src/session/media_art.dart';
import 'schedule_fake_server.dart'; import 'schedule_fake_server.dart';
@@ -45,7 +45,7 @@ void main() {
cacheDir: () async => cache, cacheDir: () async => cache,
delay: (d) async => delays.add(d), delay: (d) async => delays.add(d),
now: () => now, now: () => now,
userAgent: 'watch-together/test', userAgent: 'Starsync/test',
); );
setUp(() async { setUp(() async {
@@ -73,7 +73,7 @@ void main() {
expect(server.seen, hasLength(2)); expect(server.seen, hasLength(2));
final first = jsonDecode(server.seen.first.body) as Map; final first = jsonDecode(server.seen.first.body) as Map;
expect(first['variables'], {'from': from.millisecondsSinceEpoch ~/ 1000 - 1, 'to': to.millisecondsSinceEpoch ~/ 1000, 'page': 1}); 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]); expect(s.airings.map((a) => a.anilistId), [1, 4]);
final a = s.airings.first; final a = s.airings.first;
expect(a.title, 'Show'); expect(a.title, 'Show');
@@ -2,8 +2,8 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/jellyfin/accounts.dart'; import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:watch_together/src/schedule/animeflow.dart'; import 'package:starsync/src/schedule/animeflow.dart';
import 'schedule_fake_server.dart'; import 'schedule_fake_server.dart';
@@ -83,7 +83,7 @@ void main() {
final c = client(); final c = client();
final me = await c.me(); final me = await c.me();
expect(server.seen.single.headers.value('x-emby-token'), token); 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.userId, 'u1');
expect(me.name, 'Foxyz'); expect(me.name, 'Foxyz');
expect(me.scopes, {AnimeFlowScope.view, AnimeFlowScope.request}); expect(me.scopes, {AnimeFlowScope.view, AnimeFlowScope.request});
+3 -3
View File
@@ -2,9 +2,9 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/schedule/anilist.dart'; import 'package:starsync/src/schedule/anilist.dart';
import 'package:watch_together/src/schedule/animeflow.dart'; import 'package:starsync/src/schedule/animeflow.dart';
import 'package:watch_together/src/schedule/schedule_model.dart'; import 'package:starsync/src/schedule/schedule_model.dart';
import 'schedule_fake_server.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/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/accounts.dart'; import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:watch_together/src/schedule/anilist.dart'; import 'package:starsync/src/schedule/anilist.dart';
import 'package:watch_together/src/schedule/animeflow.dart'; import 'package:starsync/src/schedule/animeflow.dart';
import 'package:watch_together/src/schedule/schedule_model.dart'; import 'package:starsync/src/schedule/schedule_model.dart';
import 'package:watch_together/src/schedule/ui/schedule_actions.dart'; import 'package:starsync/src/schedule/ui/schedule_actions.dart';
import 'package:watch_together/src/schedule/ui/schedule_page.dart'; import 'package:starsync/src/schedule/ui/schedule_page.dart';
import 'package:watch_together/src/screens/home/picked_media.dart'; import 'package:starsync/src/screens/home/picked_media.dart';
import 'schedule_fake_server.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:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.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';
import 'package:watch_together/src/sync/state_diff.dart'; import 'package:starsync/src/sync/state_diff.dart';
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path; 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:hive_ce/hive.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/jellyfin/accounts.dart'; import 'package:starsync/src/jellyfin/accounts.dart';
import 'package:watch_together/src/screens/home/picked_media.dart'; import 'package:starsync/src/screens/home/picked_media.dart';
import 'package:watch_together/src/session/session_coordinator.dart'; import 'package:starsync/src/session/session_coordinator.dart';
import 'package:watch_together/src/state/server_isolate.dart'; import 'package:starsync/src/state/server_isolate.dart';
import 'package:watch_together/src/state/session_state.dart'; import 'package:starsync/src/state/session_state.dart';
Future<void> _poll( Future<void> _poll(
FutureOr<bool> Function() cond, FutureOr<bool> Function() cond,
+2 -2
View File
@@ -2,8 +2,8 @@
// IdleTracker and unreadyForNewMedia give way to auto sit-out; // IdleTracker and unreadyForNewMedia give way to auto sit-out;
// linkDisplayName stays. // linkDisplayName stays.
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/session/idle.dart'; import 'package:starsync/src/session/idle.dart';
import 'package:watch_together/src/session/session_coordinator.dart'; import 'package:starsync/src/session/session_coordinator.dart';
void main() { void main() {
group('IdleTracker', () { 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 'dart:math' as math;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/sfx/sfx.dart'; import 'package:starsync/src/sfx/sfx.dart';
import 'package:watch_together/src/sfx/sound_bank.dart'; import 'package:starsync/src/sfx/sound_bank.dart';
import 'package:watch_together/src/sfx/synth.dart'; import 'package:starsync/src/sfx/synth.dart';
double _pitch(List<double> x, int from) { double _pitch(List<double> x, int from) {
final c = <double>[]; final c = <double>[];
+4 -4
View File
@@ -2,10 +2,10 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.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';
import 'package:watch_together/src/shaders/shader_assets.dart'; import 'package:starsync/src/shaders/shader_assets.dart';
import 'package:watch_together/src/shaders/shader_presets.dart'; import 'package:starsync/src/shaders/shader_presets.dart';
import 'package:watch_together/src/state/settings.dart'; import 'package:starsync/src/state/settings.dart';
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path; 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:flutter_test/flutter_test.dart';
import 'package:hive_ce/hive.dart'; import 'package:hive_ce/hive.dart';
import 'package:watch_together/src/shaders/shader_assets.dart'; import 'package:starsync/src/shaders/shader_assets.dart';
import 'package:watch_together/src/shaders/shader_presets.dart'; import 'package:starsync/src/shaders/shader_presets.dart';
import 'package:watch_together/src/state/settings.dart'; import 'package:starsync/src/state/settings.dart';
final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path; 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/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:watch_together/src/screens/home/profile.dart'; import 'package:starsync/src/screens/home/profile.dart';
import 'package:watch_together/src/shell/app_shell.dart'; import 'package:starsync/src/shell/app_shell.dart';
import 'package:watch_together/src/shell/avatar.dart'; import 'package:starsync/src/shell/avatar.dart';
import 'package:watch_together/src/shell/motion.dart'; import 'package:starsync/src/shell/motion.dart';
import 'package:watch_together/src/shell/party_strip.dart'; import 'package:starsync/src/shell/party_strip.dart';
import 'package:watch_together/src/shell/profile_card.dart'; import 'package:starsync/src/shell/profile_card.dart';
import 'package:watch_together/src/shell/session_player.dart'; import 'package:starsync/src/shell/session_player.dart';
import 'package:watch_together/src/shell/settings_tab.dart' show SettingsTab; import 'package:starsync/src/shell/settings_tab.dart' show SettingsTab;
import 'package:watch_together/src/shell/toasts.dart'; import 'package:starsync/src/shell/toasts.dart';
import 'package:watch_together/src/session/session_coordinator.dart' show ToastItem; import 'package:starsync/src/session/session_coordinator.dart' show ToastItem;
import 'package:watch_together/src/state/session_state.dart'; import 'package:starsync/src/state/session_state.dart';
import 'package:watch_together/src/update/update_controller.dart'; import 'package:starsync/src/update/update_controller.dart';
/// Stands in for media_kit's `Video` (which hangs flutter_test) and /// Stands in for media_kit's `Video` (which hangs flutter_test) and
/// counts how often one is created. /// 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/services.dart';
import 'package:flutter_soloud/flutter_soloud.dart'; import 'package:flutter_soloud/flutter_soloud.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/platform/audio/sound_engine.dart'; import 'package:starsync/src/platform/audio/sound_engine.dart';
import 'package:watch_together/src/sfx/sfx.dart'; import 'package:starsync/src/sfx/sfx.dart';
import 'package:watch_together/src/sfx/sound_bank.dart'; import 'package:starsync/src/sfx/sound_bank.dart';
import 'package:watch_together/src/sfx/synth_sfx.dart'; import 'package:starsync/src/sfx/synth_sfx.dart';
// Needs a real audio output: opens the device through flutter_soloud and // Needs a real audio output: opens the device through flutter_soloud and
// plays every sound of every preset once, quietly (WT_SFX_VOLUME, default // 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 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:watch_together/src/platform/audio/sound_engine.dart'; import 'package:starsync/src/platform/audio/sound_engine.dart';
import 'package:watch_together/src/sfx/sfx.dart'; import 'package:starsync/src/sfx/sfx.dart';
import 'package:watch_together/src/sfx/sound_bank.dart'; import 'package:starsync/src/sfx/sound_bank.dart';
import 'package:watch_together/src/sfx/synth.dart'; import 'package:starsync/src/sfx/synth.dart';
import 'package:watch_together/src/sfx/synth_sfx.dart'; import 'package:starsync/src/sfx/synth_sfx.dart';
class _FakeBackend implements AudioBackend { class _FakeBackend implements AudioBackend {
final bool failInit; final bool failInit;
+1 -1
View File
@@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.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({ StateBroadcast _state({
int eventId = 0, int eventId = 0,
@@ -4,7 +4,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.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 _port = 18080;
const _secret = 'e2e-secret'; const _secret = 'e2e-secret';
+1 -1
View File
@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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() { void main() {
// Regression: the old FocusNode-parent walk never met EditableText, so // 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:crypto/crypto.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.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 // 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 // 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; bool _isExec(String path) => File(path).statSync().mode & 0x40 != 0;
// Builds the release archive exactly like tool/package_linux.sh: // Builds the release archive exactly like tool/package_linux.sh:
// bundle copied into <stage>/watch-together-<ver>/, then // bundle copied into <stage>/starsync-<ver>/ with the launcher by the
// `tar -czf out -C <stage> watch-together-<ver>`. [zip]: like // old name (lib/src/update/bundle/watch_together, the shipped file), then
// tool/package_windows.ps1 (`tar -a -c -f out.zip`, bsdtar — Windows only). // `tar -czf out -C <stage> starsync-<ver>`. [app]: the app binary's
// content.
Future<List<int>> _releaseTarball(Directory work, Future<List<int>> _releaseTarball(Directory work,
{String version = '0.2.0', {String version = '0.2.0', String app = 'binary-v2'}) async {
bool windows = false, final stage = Directory('${work.path}/pkg-$version-lin');
bool bundle = true, final folder = Directory('${stage.path}/starsync-$version');
bool zip = false}) async {
final stage = Directory('${work.path}/pkg-$version-${windows ? 'win' : 'lin'}');
final folder = Directory('${stage.path}/watch-together-$version');
await Directory('${folder.path}/data/flutter_assets').create(recursive: true); await Directory('${folder.path}/data/flutter_assets').create(recursive: true);
await File('${folder.path}/data/flutter_assets/fresh.cfg') await File('${folder.path}/data/flutter_assets/fresh.cfg')
.writeAsString('fresh-config'); .writeAsString('fresh-config');
if (windows) { await File('${folder.path}/starsync').writeAsString(app);
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 Directory('${folder.path}/lib').create(); await Directory('${folder.path}/lib').create();
await File('${folder.path}/lib/libapp.so.1').writeAsString('lib-v2'); await File('${folder.path}/lib/libapp.so.1').writeAsString('lib-v2');
await Link('${folder.path}/lib/libapp.so').create('libapp.so.1'); 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) { if (!Platform.isWindows) {
final r = await Process.run('chmod', final r = await Process.run('chmod', [
['755', '${folder.path}/watch_together', '${folder.path}/update.sh']); '755',
for (final f in ['starsync', 'update.sh', 'watch_together', 'watch-together-server'])
'${folder.path}/$f'
]);
expect(r.exitCode, 0); expect(r.exitCode, 0);
} }
} final out = File('${work.path}/out-$version.tar.gz');
final out = File('${work.path}/out-$version.${zip ? 'zip' : 'tar.gz'}'); final r = await Process.run('tar', ['-czf', out.path, '-C', stage.path, 'starsync-$version']);
final r = await Process.run('tar', [
...zip ? ['-a', '-c', '-f'] : ['-czf'],
out.path,
'-C',
stage.path,
'watch-together-$version',
]);
expect(r.exitCode, 0, reason: '${r.stderr}'); expect(r.exitCode, 0, reason: '${r.stderr}');
return out.readAsBytes(); 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 { Future<Directory> _fakeRoot(Directory work, {bool windows = false}) async {
final root = Directory('${work.path}/install'); final root = Directory('${work.path}/install');
await Directory('${root.path}/data').create(recursive: true); await Directory('${root.path}/data').create(recursive: true);
@@ -105,12 +95,12 @@ class _Server {
req.response.write(jsonEncode({ req.response.write(jsonEncode({
'version': '0.2.0', 'version': '0.2.0',
'protocol': RelayConstants.protoVersion, '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(), 'sha256': sha256.convert(tarball).toString(),
'notes': 'test build', 'notes': 'test build',
...manifestExtra, ...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++; tarHits++;
req.response.contentLength = tarball.length; req.response.contentLength = tarball.length;
req.response.add(tarball); req.response.add(tarball);
@@ -145,12 +135,14 @@ void main() {
test('check, download with progress, apply, restart same binary', test('check, download with progress, apply, restart same binary',
() async { () async {
final root = await _fakeRoot(work); 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>)>[]; final restarts = <(String, List<String>)>[];
launchArgs = const ['--some-flag']; launchArgs = const ['--some-flag'];
final c = UpdateController( final c = UpdateController(
currentVersion: '0.1.0', currentVersion: '0.1.0',
manifestUrl: server.manifestUrl, manifestUrl: server.manifestUrl,
executablePath: '${root.path}/watch_together', executablePath: '${root.path}/starsync',
restart: (exe, args) async => restarts.add((exe, args))); restart: (exe, args) async => restarts.add((exe, args)));
await c.checkOnce(); await c.checkOnce();
expect(c.status.value.phase, UpdatePhase.available); expect(c.status.value.phase, UpdatePhase.available);
@@ -164,13 +156,35 @@ void main() {
await c.install(); await c.install();
expect(progress.last, 1.0); expect(progress.last, 1.0);
expect(c.status.value.phase, UpdatePhase.restarting); expect(c.status.value.phase, UpdatePhase.restarting);
expect(await File('${root.path}/watch_together').readAsString(), expect(await File('${root.path}/starsync').readAsString(),
'binary-v2'); 'binary-v2');
expect(_isExec('${root.path}/watch_together'), isTrue); expect(_isExec('${root.path}/starsync'), isTrue);
expect(restarts.single.$1, '${root.path}/watch_together'); expect(restarts.single.$1, '${root.path}/starsync');
expect(restarts.single.$2, ['--some-flag']); expect(restarts.single.$2, ['--some-flag']);
}, skip: _posixOnly); }, 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', test('newer protocol is a required update, cannot be dismissed',
() async { () async {
server.manifestExtra = {'protocol': RelayConstants.protoVersion + 1}; server.manifestExtra = {'protocol': RelayConstants.protoVersion + 1};
+1 -1
View File
@@ -1,6 +1,6 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:sync_relay_core/core.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 _state({int eventId = 1, String? seekBy, int? seekSeq}) =>
StateBroadcast( 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:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart'; import 'package:sync_relay_core/core.dart';
import 'package:sync_relay_server/server.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';
import 'package:watch_together/src/sync/state_diff.dart'; import 'package:starsync/src/sync/state_diff.dart';
const _mediaPath = '/home/foxyz/Videos/chiyo.mp4'; 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:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart' show VideoState; import 'package:media_kit_video/media_kit_video.dart' show VideoState;
import 'package:watch_together/src/controls/model/controls_prefs.dart'; import 'package:starsync/src/controls/model/controls_prefs.dart';
import 'package:watch_together/src/controls/frame.dart'; import 'package:starsync/src/controls/frame.dart';
import 'package:watch_together/src/shell/session_player.dart'; import 'package:starsync/src/shell/session_player.dart';
import 'package:watch_together/src/state/session_state.dart'; import 'package:starsync/src/state/session_state.dart';
import 'package:watch_together/src/state/settings.dart'; import 'package:starsync/src/state/settings.dart';
// The wheel anywhere on the full player flashes the volume readout, in // The wheel anywhere on the full player flashes the volume readout, in
// both skins, windowed and fullscreen, with the controls up or hidden. // 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:flutter_test/flutter_test.dart';
import 'package:media_kit/media_kit.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; final _tmp = Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path;
const _referer = 'https://www.bilibili.com/'; const _referer = 'https://www.bilibili.com/';
+2 -2
View File
@@ -1,10 +1,10 @@
# Project-level configuration. # Project-level configuration.
cmake_minimum_required(VERSION 3.14) 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 name of the executable created for the application. Change this to change
# the on-disk name of your application. # 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 # Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake. # versions of CMake.
+6 -6
View File
@@ -89,13 +89,13 @@ BEGIN
BEGIN BEGIN
BLOCK "040904e4" BLOCK "040904e4"
BEGIN BEGIN
VALUE "CompanyName", "com.example" "\0" VALUE "CompanyName", "foxyz" "\0"
VALUE "FileDescription", "watch_together" "\0" VALUE "FileDescription", "Starsync" "\0"
VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "FileVersion", VERSION_AS_STRING "\0"
VALUE "InternalName", "watch_together" "\0" VALUE "InternalName", "Starsync" "\0"
VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0" VALUE "LegalCopyright", "Copyright (C) 2026 foxyz. All rights reserved." "\0"
VALUE "OriginalFilename", "watch_together.exe" "\0" VALUE "OriginalFilename", "starsync.exe" "\0"
VALUE "ProductName", "watch_together" "\0" VALUE "ProductName", "Starsync" "\0"
VALUE "ProductVersion", VERSION_AS_STRING "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0"
END END
END END
+1 -1
View File
@@ -16,7 +16,7 @@ namespace {
#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 #define DWMWA_USE_IMMERSIVE_DARK_MODE 20
#endif #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. /// Registry key for app theme preference.
/// ///
@@ -1,7 +1,10 @@
# Dedicated watch-together server (no display needed). # Dedicated Starsync server (no display needed).
# Install: unpack the release tarball to /opt/watch-together (the unit only # Install: unpack the release tarball to /opt/starsync (the unit only uses
# uses the watch-together-server binary in it), copy this file to # the starsync-server binary in it), copy this file to
# /etc/systemd/system/, then `systemctl enable --now watch-together`. # /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 # Flags: --port <n> (default 8080), --secret <password> (empty = open
# server, anyone who can reach the port can join), --name, --media <file>, # server, anyone who can reach the port can join), --name, --media <file>,
# --advertise-host <host>, --no-yt-dlp, --yt-dlp-dir <dir>, --config # --advertise-host <host>, --no-yt-dlp, --yt-dlp-dir <dir>, --config
@@ -11,15 +14,15 @@
# process lists are readable by other users. # process lists are readable by other users.
[Unit] [Unit]
Description=watch-together dedicated server Description=Starsync dedicated server
After=network-online.target After=network-online.target
Wants=network-online.target Wants=network-online.target
[Service] [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 Restart=on-failure
DynamicUser=true DynamicUser=true
StateDirectory=watch-together StateDirectory=starsync
NoNewPrivileges=true NoNewPrivileges=true
[Install] [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/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 | | `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 | | `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only |
| `deploy` | systemd unit for the dedicated server | | | `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 cancelled, solo starts again with the same media. Stop leaves without
asking. asking.
- **Dedicated server**: `bin/server.dart` compiled with `dart compile exe` - **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`, (`--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` `--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") (`--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. options, warm-up.
- `SystemNotifications`: an interface only; desktop shows none - `SystemNotifications`: an interface only; desktop shows none
(`NoSystemNotifications`). (`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 Still direct: the player (`PlaybackController`, media_kit, and its
`AppYtDlp.instance.mpvRawOptions`), the updater's restart `AppYtDlp.instance.mpvRawOptions`), the updater's restart
(`Process.start`), the session's own file reads for what it plays or (`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 servers and links, the profile picture and colour; saved even when a join
fails) and fails) and
`settings`. Where Hive and the logs live is `AppDirs` `settings`. Where Hive and the logs live is `AppDirs`
(`platform/app_dirs.dart`, resolved once in `main`): Hive's default folder (`platform/ports/app_dirs.dart`, resolved once in `main` by the
(the documents folder) and `<app support>/logs`; with `STARSYNC_PROFILE` `AppDirsPort`, `DesktopAppDirs` on the desktop): `<app support>/hive` and
(DEVELOPMENT "Dev loop") `<app support>/profiles/<name>/hive` and `logs`, `<app support>/logs`; with `STARSYNC_PROFILE` (DEVELOPMENT "Dev loop")
and the window title gets ` [<name>]`. yt-dlp, shaders and caches stay `<app support>/profiles/<name>/hive` and `logs`, and the window title gets
shared. Link media names are a web page's title (see "yt-dlp"), else ` [<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. derived from the URL path, never containing a query string.
Jellyfin on the desktop (`lib/src/jellyfin/`, over the core client): 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`). `app`).
- **Where**: the app writes `<app support>/logs/app-YYYY-MM-DD.log` - **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 profile `<app support>/profiles/<name>/logs`, see `AppDirs` under
"Desktop UI"); its hosted server's records arrive over the isolate's "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 `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 `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 `app_icon.ico` are rendered from it (rsvg-convert, magick). The GTK
application id is `moe.senkochan.watch_together`, which Wayland uses as the application id is `dev.foxyz.starsync` (also the X11 `WM_CLASS`, from
window's `app_id`; compositors take the icon from the `.desktop` file of `g_set_prgname`), which Wayland uses as the window's `app_id`; compositors
that id. A tarball install has none, so on start a Linux release build take the icon from the `.desktop` file of that id. A tarball install has
(`platform/desktop_entry.dart`, skipped in debug) writes none, so on start a Linux release build (`platform/desktop_entry.dart`,
`~/.local/share/applications/moe.senkochan.watch_together.desktop` (Exec = skipped in debug) writes
the running binary) and the icons into the hicolor theme, only when they `~/.local/share/applications/dev.foxyz.starsync.desktop` (Exec = the
changed. The runner also sets the window icon from the bundle (X11). 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 ## 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, 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; with rollback on failure (writing the running executable fails with ETXTBSY;
rename works). A packaged install is detected by `data/` and `lib/` next to rename works). A packaged install is detected by `data/` and `lib/` next to
the binary (app or server, same folder); anything else exits with "update the binary (app or server, same folder; Windows: `data/` and
manually". A manifest whose `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 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 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` / 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 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 uncommitted changes, passed by `tool/package_linux.sh`), and for a dev
run the pubspec's version (`FLUTTER_BUILD_NAME`, which the Flutter tool 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; 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` does the same from the app binary. In the app, `UpdateController`
(`apps/desktop/lib/src/update/`) runs the same code for the update bar (`apps/desktop/lib/src/update/`) runs the same code for the update bar
(`UpdateBar`, `screens/home/update_indicator.dart`): a band across the (`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> cd apps/desktop && flutter build linux --release --dart-define=APP_VERSION=<x.y.z>
tool/package_linux.sh [--skip-build] [--noupload] # dist/*.tar.gz tool/package_linux.sh [--skip-build] [--noupload] # dist/*.tar.gz
powershell -File tool/package_windows.ps1 [-SkipBuild] # dist/*.zip (on Windows) 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 `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 [ARCHITECTURE.md](ARCHITECTURE.md)). Setting `DISCORD_WEBHOOK_URL` makes the packager post the
tarball to Discord (never commit it). The packager always compiles the tarball to Discord (never commit it). The packager always compiles the
dedicated server (with the Flutter SDK's `dart`) into the tarball as 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 ## 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 After a session, read the log files rather than reproducing
("Logging" in [ARCHITECTURE.md](ARCHITECTURE.md) has what goes in them): ("Logging" in [ARCHITECTURE.md](ARCHITECTURE.md) has what goes in them):
the app's are `~/.local/share/moe.senkochan.watch_together/logs/app-*.log` the app's are `~/.local/share/dev.foxyz.starsync/logs/app-*.log`
on Linux (on Windows under `%APPDATA%`; Settings → About → "Open logs on Linux (on Windows `%APPDATA%\foxyz\Starsync\logs`; Settings → About → "Open logs
folder" finds it anywhere), with the hosted server's lines folder" finds it anywhere), with the hosted server's lines
in the same file; a dedicated server prints its folder at start in the same file; a dedicated server prints its folder at start
(`--log-dir`). Users send theirs with Settings → About → "Copy (`--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 no usable EGL context: check `render env:` for an X11 session or a
`GDK_BACKEND` / `FLUTTER_LINUX_RENDERER` override). Its reason is only on `GDK_BACKEND` / `FLUTTER_LINUX_RENDERER` override). Its reason is only on
stderr: have the user start the app from a terminal 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 first-frame line means software decoding (no hwdec for that codec, or it
is off), not software rendering. 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. - **Host from the app**: the app you click Host in also runs the server.
Simplest. The server lives as long as that app. 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. 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 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, (client id, library and folders, match corrections, watch history,
settings, notification prefs, what this app is sourcing) and the settings, notification prefs, what this app is sourcing) and the
integrations (Jellyfin played/resume, AniList list). 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 1. Connection + Identity + Presence; proto 16 opened (version ranges on
both sides, refusal frame, identity, grace, presence, relay resume, 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 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 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 friends stay on 1.2.x until the 1.3.0 release (Windows-only fixes like
P30 reach them then). P30 reach them then).
Rename to Starsync everywhere (repo, app id, data folder, updater URL Left of the rename (app, binaries, archives and data folder are
migration) is its own step; app id `dev.foxyz.starsync`, Windows Starsync): the repository (`Foxyz/sync_replay_project`) and with it the
company name `foxyz` (replacing Flutter's default `com.example`). Auto 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 sit-out: idle or minimised for a while sets you Away, activity sets you
In; it replaces the 10-minute auto-unready. 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 5. **Ops**: Check shaders on Windows; the relay e2e "source drops
uncleanly and resumes" fails about half the time under the full suite. uncleanly and resumes" fails about half the time under the full suite.
6. **Windows: first real use.** CI builds and publishes 6. **Windows: first real use.** Check the rename at 1.3.0: a 1.2.x
`watch-together-<ver>-windows-x64.zip` (libmpv bundled) and runs the 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: updater tests on NTFS; a friend has joined from it. Still unchecked:
fullscreen controls, hosting (firewall prompt), in-app update from one fullscreen controls, hosting (firewall prompt), in-app update from one
release to the next. The rest of the desktop tests are not in CI. 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. /// saying so per connection; problem marks ([markProblem]) go either way.
class DiagUploader { class DiagUploader {
final DiagTarget? Function() target; 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 String version;
final int bufferBytes; final int bufferBytes;
final int batchBytes; final int batchBytes;
+48 -5
View File
@@ -7,9 +7,9 @@ import 'package:crypto/crypto.dart';
import 'constants.dart'; import 'constants.dart';
import 'logging.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 // 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 // release archive, so either one swaps the whole install folder: fetch
// manifest, download, verify sha256, apply over the live install. // 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. // Key into the manifest's `assets` map, e.g. linux-x64, windows-x64.
final updatePlatform = '${Platform.operatingSystem}-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 _stagingDir = '.update-staging';
const _backupDir = '.update-backup'; const _backupDir = '.update-backup';
const _downloadFile = '.update-download'; const _downloadFile = '.update-download';
@@ -303,7 +338,7 @@ List<String> _safeSegments(String name) {
} }
// Release archives wrap the bundle in one top-level folder // 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. // archive (data/, lib/ and the binary at the top) is used as is.
int _wrapperDepth(List<List<String>> entries) { int _wrapperDepth(List<List<String>> entries) {
final tops = entries.where((s) => s.isNotEmpty).map((s) => s.first).toSet(); 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); await _extract(await archive.readAsBytes(), stage, !win);
if (!_isBundle(stage.path, win) || if (!_isBundle(stage.path, win) ||
(executableName != null && (executableName != null &&
!await File('${stage.path}/$executableName').exists())) { !await _hasBinary(stage.path, executableName))) {
throw const FormatException( throw const FormatException(
'archive is not a release bundle (missing data/, lib/ or binary)'); '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 { class _Moved {
final String name; final String name;
final bool hadOld; final bool hadOld;
@@ -565,7 +608,7 @@ Future<int> runUpgrade(
HttpClient? http, HttpClient? http,
String? executablePath, String? executablePath,
Directory? installRoot, Directory? installRoot,
String program = 'watch-together', String program = 'starsync',
String Function(String version)? restartNote, String Function(String version)? restartNote,
bool checkOnly = false}) async { bool checkOnly = false}) async {
stdout.writeln('$program $currentVersion (proto ${RelayConstants.protoVersion})'); 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; bool _isExec(String path) => File(path).statSync().mode & 0x40 != 0;
// Builds the release archive exactly like tool/package_linux.sh: // Builds the release archive exactly like tool/package_linux.sh:
// bundle copied into <stage>/watch-together-<ver>/, then // bundle copied into <stage>/starsync-<ver>/, then
// `tar -czf out -C <stage> watch-together-<ver>`. [zip]: like // `tar -czf out -C <stage> starsync-<ver>`. [zip]: like
// tool/package_windows.ps1 (`tar -a -c -f out.zip`, bsdtar — Windows only). // 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, Future<List<int>> _releaseTarball(Directory work,
{String version = '0.2.0', {String version = '0.2.0',
bool windows = false, bool windows = false,
bool bundle = true, 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 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 Directory('${folder.path}/data/flutter_assets').create(recursive: true);
await File('${folder.path}/data/flutter_assets/fresh.cfg') await File('${folder.path}/data/flutter_assets/fresh.cfg')
.writeAsString('fresh-config'); .writeAsString('fresh-config');
if (windows) { 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}/watch_together.exe').writeAsString('binary-v2');
}
await File('${folder.path}/flutter_windows.dll').writeAsString('dll-v2'); await File('${folder.path}/flutter_windows.dll').writeAsString('dll-v2');
} else { } else {
await File('${folder.path}/watch_together').writeAsString('binary-v2'); await File('${folder.path}/starsync').writeAsString(app);
if (bundle) { if (bundle) {
await Directory('${folder.path}/lib').create(); await Directory('${folder.path}/lib').create();
await File('${folder.path}/lib/libapp.so.1').writeAsString('lib-v2'); await File('${folder.path}/lib/libapp.so.1').writeAsString('lib-v2');
await Link('${folder.path}/lib/libapp.so').create('libapp.so.1'); await Link('${folder.path}/lib/libapp.so').create('libapp.so.1');
} }
await File('${folder.path}/update.sh') 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) { if (!Platform.isWindows) {
final r = await Process.run('chmod', final r = await Process.run(
['755', '${folder.path}/watch_together', '${folder.path}/update.sh']); 'chmod', ['755', for (final f in exec) '${folder.path}/$f']);
expect(r.exitCode, 0); expect(r.exitCode, 0);
} }
} }
@@ -61,12 +75,13 @@ Future<List<int>> _releaseTarball(Directory work,
out.path, out.path,
'-C', '-C',
stage.path, stage.path,
'watch-together-$version', 'starsync-$version',
]); ]);
expect(r.exitCode, 0, reason: '${r.stderr}'); expect(r.exitCode, 0, reason: '${r.stderr}');
return out.readAsBytes(); 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 { Future<Directory> _fakeRoot(Directory work, {bool windows = false}) async {
final root = Directory('${work.path}/install'); final root = Directory('${work.path}/install');
await Directory('${root.path}/data').create(recursive: true); await Directory('${root.path}/data').create(recursive: true);
@@ -111,12 +126,12 @@ class _Server {
req.response.write(jsonEncode({ req.response.write(jsonEncode({
'version': '0.2.0', 'version': '0.2.0',
'protocol': RelayConstants.protoVersion, '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(), 'sha256': sha256.convert(tarball).toString(),
'notes': 'test build', 'notes': 'test build',
...manifestExtra, ...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++; tarHits++;
req.response.contentLength = tarball.length; req.response.contentLength = tarball.length;
req.response.add(tarball); req.response.add(tarball);
@@ -262,11 +277,12 @@ void main() {
expect(rc, 0); expect(rc, 0);
expect(server.tarHits, 1); expect(server.tarHits, 1);
final p = root.path; 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(), expect(await File('$p/data/flutter_assets/fresh.cfg').readAsString(),
'fresh-config'); 'fresh-config');
expect(Directory('$p/watch-together-0.2.0').existsSync(), isFalse); expect(Directory('$p/starsync-0.2.0').existsSync(), isFalse);
expect(_isExec('$p/watch_together'), isTrue); expect(_isExec('$p/starsync'), isTrue);
expect(_isExec('$p/watch_together'), isTrue, reason: 'the old name, a launcher');
expect(_isExec('$p/update.sh'), isTrue); expect(_isExec('$p/update.sh'), isTrue);
expect(await Link('$p/lib/libapp.so').target(), 'libapp.so.1'); expect(await Link('$p/lib/libapp.so').target(), 'libapp.so.1');
expect(await File('$p/lib/libapp.so').readAsString(), 'lib-v2'); expect(await File('$p/lib/libapp.so').readAsString(), 'lib-v2');
@@ -310,7 +326,7 @@ void main() {
manifestUrl: server.manifestUrl, manifestUrl: server.manifestUrl,
installRoot: root); installRoot: root);
expect(rc, 0); expect(rc, 0);
expect(await File('${root.path}/watch_together').readAsString(), expect(await File('${root.path}/starsync').readAsString(),
'binary-v2'); 'binary-v2');
}, skip: _posixOnly); }, skip: _posixOnly);
@@ -358,6 +374,7 @@ void main() {
expect(await File('$p/lib/libgone.so').readAsString(), 'lib-v1'); expect(await File('$p/lib/libgone.so').readAsString(), 'lib-v1');
expect(await File('$p/data/old.cfg').readAsString(), 'old-config'); expect(await File('$p/data/old.cfg').readAsString(), 'old-config');
expect(File('$p/update.sh').existsSync(), isFalse); expect(File('$p/update.sh').existsSync(), isFalse);
expect(File('$p/starsync').existsSync(), isFalse);
expect(_leftovers(root), isEmpty); expect(_leftovers(root), isEmpty);
} }
@@ -367,6 +384,7 @@ void main() {
'aside:data', 'aside:data',
'in:data', 'in:data',
'aside:lib', 'aside:lib',
'in:starsync',
'in:update.sh', 'in:update.sh',
'aside:watch_together', 'aside:watch_together',
'in:watch_together', 'in:watch_together',
@@ -423,12 +441,66 @@ void main() {
expect(File('${root.path}/data/thing.dll.old').existsSync(), isFalse); 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 strategy exercised on Linux: proves the bookkeeping, not the
// Windows file-locking semantics (no Windows runner yet). // Windows file-locking semantics (no Windows runner yet).
test('windows-style swap applies and parks old files', () async { test('windows-style swap applies and parks old files', () async {
final root = await _fakeRoot(work, windows: true); final root = await _fakeRoot(work, windows: true);
await applyUpdate(await archive(windows: true), root, windows: true); await applyUpdate(await archive(windows: true), root, windows: true);
final p = root.path; 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/watch_together.exe').readAsString(), 'binary-v2');
expect(await File('$p/flutter_windows.dll').readAsString(), 'dll-v2'); expect(await File('$p/flutter_windows.dll').readAsString(), 'dll-v2');
expect(await File('$p/data/flutter_assets/fresh.cfg').readAsString(), 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/watch_together.exe').readAsString(), 'binary-v1');
expect(await File('$p/flutter_windows.dll').readAsString(), 'dll-v1'); expect(await File('$p/flutter_windows.dll').readAsString(), 'dll-v1');
expect(File('$p/data/flutter_assets/fresh.cfg').existsSync(), isFalse); 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(File('$p/flutter_windows.dll.old').existsSync(), isFalse);
expect(_leftovers(root), isEmpty); 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'; import 'package:sync_relay_server/server.dart';
/// Dedicated server: `dart compile exe` of this file is shipped as /// 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 { Future<void> main(List<String> args) async {
final parser = ArgParser() final parser = ArgParser()
..addOption('port', abbr: 'p', defaultsTo: '8080', help: 'TCP port to listen on (all IPv4 interfaces).') ..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) { if (parsed['help'] as bool) {
// ignore: avoid_print // 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; return;
} }
if (parsed['upgrade'] as bool || parsed['check-update'] as bool) { if (parsed['upgrade'] as bool || parsed['check-update'] as bool) {
exitCode = await runUpgrade( exitCode = await runUpgrade(
program: 'watch-together-server', program: 'starsync-server',
checkOnly: !(parsed['upgrade'] as bool), checkOnly: !(parsed['upgrade'] as bool),
restartNote: (v) => 'updated to $v — restart the server to run $v.'); restartNote: (v) => 'updated to $v — restart the server to run $v.');
return; return;
@@ -67,7 +68,7 @@ Future<void> main(List<String> args) async {
(configPath != null ? '${File(configPath).absolute.parent.path}${Platform.pathSeparator}logs' : serverDataDir('logs')); (configPath != null ? '${File(configPath).absolute.parent.path}${Platform.pathSeparator}logs' : serverDataDir('logs'));
final files = startLogFiles(logDir, prefix: 'server'); final files = startLogFiles(logDir, prefix: 'server');
if (files != null) stdout.writeln('logs: ${files.dir}'); 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'); 'on ${Platform.operatingSystem} ${Platform.operatingSystemVersion}, pid $pid');
AppConfig config; AppConfig config;
try { try {
+2 -2
View File
@@ -55,7 +55,7 @@ export VERSION="$NEXT-dev.$N"
echo "building $VERSION (last published: ${LAST:-none})" echo "building $VERSION (last published: ${LAST:-none})"
"$ROOT/tool/package_linux.sh" --no-upload "$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; } [[ -f "$TARBALL" ]] || { echo "error: $TARBALL missing" >&2; exit 1; }
NOTES="$(git -C "$ROOT" log -1 --format=%s)" 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. 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) 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 EOF
+16 -11
View File
@@ -2,13 +2,16 @@
# Package the Linux release bundle into a versioned tar.gz and optionally # Package the Linux release bundle into a versioned tar.gz and optionally
# upload it to Discord. # upload it to Discord.
# Usage: tool/package_linux.sh [--skip-build] [--noupload|--no-upload] # Usage: tool/package_linux.sh [--skip-build] [--noupload|--no-upload]
# Output: dist/watch-together-<version>-linux-x64.tar.gz containing one # Output: dist/starsync-<version>-linux-x64.tar.gz containing one
# top-level folder with the executable, lib/ and data/ as built, plus # top-level folder starsync-<version>/ with the executable `starsync`, lib/
# update.sh (runs watch-together-server --upgrade) and watch-together-server # and data/ as built, plus update.sh (runs starsync-server --upgrade) and
# (the dedicated server, `dart compile exe` of packages/server/bin/server.dart, # starsync-server (the dedicated server, `dart compile exe` of
# built every run, also with --skip-build; both binaries are stamped with # packages/server/bin/server.dart, built every run, also with --skip-build;
# APP_VERSION from the app's pubspec). The updater strips # both binaries are stamped with APP_VERSION from the app's pubspec), and
# that top-level folder — keep the layout in sync with packages/core/test/updater_test.dart. # 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). # 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 tarball must stay under Discord's upload limit (25 MB free tier).
# The upload posts a Components V2 card (flag 1<<15): version header, # The upload posts a Components V2 card (flag 1<<15): version header,
@@ -28,7 +31,7 @@ done
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP="$ROOT/apps/desktop" APP="$ROOT/apps/desktop"
NAME="watch-together" NAME="starsync"
ARCH="x64" ARCH="x64"
# VERSION in the environment overrides it (tool/dev_release.sh). # VERSION in the environment overrides it (tool/dev_release.sh).
@@ -56,7 +59,9 @@ TARBALL="$DIST/$FOLDER-linux-$ARCH.tar.gz"
rm -rf "$STAGE" rm -rf "$STAGE"
mkdir -p "$STAGE/$FOLDER" "$DIST" mkdir -p "$STAGE/$FOLDER" "$DIST"
cp -r "$BUNDLE/." "$STAGE/$FOLDER/" 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. # Prefer the dart bundled with the Flutter SDK that builds the app.
DART="dart" DART="dart"
if FLUTTER="$(command -v flutter)"; then if FLUTTER="$(command -v flutter)"; then
@@ -64,7 +69,7 @@ if FLUTTER="$(command -v flutter)"; then
[[ -x "$CANDIDATE" ]] && DART="$CANDIDATE" [[ -x "$CANDIDATE" ]] && DART="$CANDIDATE"
fi fi
(cd "$ROOT/packages/server" && "$DART" pub get >/dev/null && (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" tar -czf "$TARBALL" -C "$STAGE" "$FOLDER"
rm -rf "$STAGE" rm -rf "$STAGE"
ls -la "$TARBALL" ls -la "$TARBALL"
@@ -119,7 +124,7 @@ payload = {
'accent_color': 5793266, 'accent_color': 5793266,
'components': [ 'components': [
{'type': 10, 'content': {'type': 10, 'content':
f'# 📦 watch-together `{ver}`\n' f'# 📦 Starsync `{ver}`\n'
f'-# linux · {arch} · release · {size_mb:.1f} MB'}, f'-# linux · {arch} · release · {size_mb:.1f} MB'},
{'type': 10, 'content': f'`{commit[:140]}`'}, {'type': 10, 'content': f'`{commit[:140]}`'},
{'type': 10, 'content': block[:1500]}, {'type': 10, 'content': block[:1500]},
+14 -10
View File
@@ -1,19 +1,21 @@
# Package the Windows release bundle into a versioned zip. # Package the Windows release bundle into a versioned zip.
# Usage: powershell -File tool/package_windows.ps1 [-SkipBuild] # Usage: powershell -File tool/package_windows.ps1 [-SkipBuild]
# Output: dist/watch-together-<version>-windows-x64.zip containing one # Output: dist/starsync-<version>-windows-x64.zip containing one
# top-level folder with watch_together.exe, its DLLs and data/ as built, # top-level folder starsync-<version>/ with starsync.exe, its DLLs and
# plus update.bat (runs watch-together-server.exe --upgrade) and # data/ as built, plus update.bat (runs starsync-server.exe --upgrade) and
# watch-together-server.exe (dedicated server, `dart compile exe` of # starsync-server.exe (dedicated server, `dart compile exe` of
# packages/server/bin/server.dart; both binaries stamped with APP_VERSION). # packages/server/bin/server.dart; both binaries stamped with APP_VERSION),
# Same layout as tool/package_linux.sh; the updater strips the top-level # and copies of both under the names up to 1.2.x (watch_together.exe,
# folder. # 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). # Needs: flutter on PATH, tar.exe (ships with Windows 10+, writes zip).
param([switch]$SkipBuild) param([switch]$SkipBuild)
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$Root = Split-Path -Parent $PSScriptRoot $Root = Split-Path -Parent $PSScriptRoot
$App = Join-Path $Root 'apps/desktop' $App = Join-Path $Root 'apps/desktop'
$Name = 'watch-together' $Name = 'starsync'
$Arch = 'x64' $Arch = 'x64'
$Version = (Select-String -Path "$App/pubspec.yaml" -Pattern '^version: ([0-9.]+)').Matches[0].Groups[1].Value $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" $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' $Stage = Join-Path $App 'build/package-stage'
$Dist = Join-Path $Root 'dist' $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 New-Item -ItemType Directory -Force "$Stage/$Folder", $Dist | Out-Null
Copy-Item -Recurse "$Bundle/*" "$Stage/$Folder/" Copy-Item -Recurse "$Bundle/*" "$Stage/$Folder/"
Copy-Item "$App/lib/src/update/bundle/update.bat" "$Stage/$Folder/update.bat" 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') Push-Location (Join-Path $Root 'packages/server')
try { try {
Invoke-Checked 'dart pub get' { dart pub get | Out-Null } 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 } } finally { Pop-Location }
Copy-Item "$Stage/$Folder/starsync-server.exe" "$Stage/$Folder/watch-together-server.exe"
if (Test-Path $Zip) { Remove-Item -Force $Zip } if (Test-Path $Zip) { Remove-Item -Force $Zip }
# bsdtar writes forward-slash entry names (Compress-Archive on 5.1 doesn't). # 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 if [[ -n "$LATEST" && "$LATEST" != "v$VER" ]]; then
NOTES="$(git -C "$ROOT" log -n 20 "$LATEST..HEAD" --format='- %s')" NOTES="$(git -C "$ROOT" log -n 20 "$LATEST..HEAD" --format='- %s')"
else else
NOTES="Watch together $VER." NOTES="Starsync $VER."
fi fi
export MANIFEST_FILE="$ROOT/updates/manifest.json" MANIFEST_VER="$VER" \ export MANIFEST_FILE="$ROOT/updates/manifest.json" MANIFEST_VER="$VER" \
MANIFEST_PROTO="$PROTO" MANIFEST_NOTES="$NOTES" \ MANIFEST_PROTO="$PROTO" MANIFEST_NOTES="$NOTES" \
MANIFEST_BASE="$SERVER/$REPO/releases/download/v$VER/watch-together-$VER" MANIFEST_BASE="$SERVER/$REPO/releases/download/v$VER/starsync-$VER"
python3 - <<'EOF' python3 - <<'EOF'
import json, os import json, os
path = os.environ['MANIFEST_FILE'] 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^'], final prev = _git(root, ['describe', '--tags', '--abbrev=0', '$tag^'],
allowFail: true); allowFail: true);
final notes = prev.isEmpty final notes = prev.isEmpty
? 'Watch together $ver.' ? 'Starsync $ver.'
: _git(root, [ : _git(root, [
'log', 'log',
'$prev..$tag', '$prev..$tag',
@@ -63,7 +63,7 @@ Future<void> main(List<String> args) async {
rel = await c.post('$api/releases', { rel = await c.post('$api/releases', {
'tag_name': tag, 'tag_name': tag,
'name': tag, 'name': tag,
'body': 'Watch together $ver\n\n$notes', 'body': 'Starsync $ver\n\n$notes',
'draft': false, 'draft': false,
'prerelease': false, 'prerelease': false,
}); });