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
+1169 -489

No files matched your search

+3 -4
View File
@@ -7,9 +7,9 @@ import 'package:media_kit/media_kit.dart';
import 'package:sync_relay_core/core.dart';
import 'app.dart';
import 'src/platform/app_dirs.dart';
import 'src/platform/app_log.dart';
import 'src/platform/app_shutdown.dart';
import 'src/platform/desktop/desktop_app_dirs.dart';
import 'src/platform/desktop_entry.dart';
import 'src/screens/home/ui.dart';
import 'src/session/app_events.dart' show UpdateAvailable;
@@ -42,10 +42,9 @@ Future<void> main(List<String> args) async {
),
);
WidgetsFlutterBinding.ensureInitialized();
final dirs = await AppDirs.resolve();
final dirs = await const DesktopAppDirs().resolve();
await startAppLogging(dirs);
// An absolute subDir replaces the documents folder (package:path join).
await Hive.initFlutter(dirs.hive);
Hive.init(dirs.hive);
await AppSettings.instance.ensureLoaded();
// Close button / Alt+F4: the orderly close ([shutDownApp]), so others
// see "left", a hosted server shuts down and no player outlives the
@@ -1,64 +0,0 @@
import 'dart:io';
import 'package:path_provider/path_provider.dart';
/// The environment variable that picks a separate profile (DEVELOPMENT.md
/// "Dev loop": two instances on one machine).
const profileEnv = 'STARSYNC_PROFILE';
/// Where the app keeps its own files, resolved once in `main` ([resolve])
/// and handed on from there.
///
/// Without a profile it is exactly today's layout: Hive's boxes in Hive's
/// default folder (the documents folder) and the logs in
/// `<app support>/logs`. With `STARSYNC_PROFILE=<name>` (letters, digits,
/// `-`, `_`; at most 32) the per-install data moves to
/// `<app support>/profiles/<name>/`: the Hive boxes (settings and so the
/// profile, connect form, window bounds, folders, Jellyfin sign-ins; the
/// client id from restructure step 1 on) under `hive/`, the logs under
/// `logs/`. Downloads and caches stay shared: yt-dlp and its cookies, the
/// extracted shaders, thumbnails, the AniList cache.
class AppDirs {
/// The profile name; null for the normal app.
final String? profile;
/// The app support folder (yt-dlp, shaders; logs without a profile).
final String support;
/// Hive's folder; null: Hive's default (`Hive.initFlutter()`).
final String? hive;
/// The app's log folder.
final String logs;
const AppDirs({required this.profile, required this.support, required this.hive, required this.logs});
/// The layout for [support] (the app support folder) and [env]; an
/// invalid profile name is ignored ([invalidProfile] says which).
static AppDirs fromEnv({required String support, required Map<String, String> env}) {
final sep = Platform.pathSeparator;
final name = validProfile(env[profileEnv]);
if (name == null) return AppDirs(profile: null, support: support, hive: null, logs: '$support${sep}logs');
final root = '$support${sep}profiles$sep$name';
return AppDirs(profile: name, support: support, hive: '$root${sep}hive', logs: '$root${sep}logs');
}
/// [raw] when it is a usable profile name, else null.
static String? validProfile(String? raw) {
final v = raw?.trim() ?? '';
return RegExp(r'^[A-Za-z0-9_-]{1,32}$').hasMatch(v) ? v : null;
}
/// The env value when it is set but not a usable name (for a warning).
static String? invalidProfile(Map<String, String> env) {
final raw = env[profileEnv];
return raw != null && raw.trim().isNotEmpty && validProfile(raw) == null ? raw : null;
}
/// Resolves the layout from the platform and the process environment.
/// Needs Flutter's binding (path_provider).
static Future<AppDirs> resolve() async {
final support = (await getApplicationSupportDirectory()).path;
final dirs = fromEnv(support: support, env: Platform.environment);
final hive = dirs.hive;
if (hive != null) await Directory(hive).create(recursive: true);
return dirs;
}
}
+4 -1
View File
@@ -5,7 +5,7 @@ import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart';
import '../screens/home/ui.dart' show appName, displayVersion;
import 'app_dirs.dart';
import 'ports/app_dirs.dart';
import '../state/session_state.dart';
// The app's log on disk (<app support>/logs/app-YYYY-MM-DD.log; core's
@@ -39,6 +39,9 @@ Future<void> startAppLogging(AppDirs dirs) async {
_appLog.info('$appName ${displayVersion()} on ${Platform.operatingSystem} '
'${Platform.operatingSystemVersion}, pid $pid, Dart ${Platform.version.split(' ').first}'
'${dirs.profile == null ? '' : ', profile ${dirs.profile}'}');
for (final line in dirs.migrated) {
_appLog.info(line);
}
final bad = AppDirs.invalidProfile(Platform.environment);
if (bad != null) _appLog.warning('$profileEnv="$bad" ignored: use letters, digits, - and _ (at most 32)');
}
@@ -0,0 +1,116 @@
import 'dart:convert';
import 'dart:io';
// First start of the renamed app (Starsync, app id dev.foxyz.starsync):
// copy what the old names left, never move, so a 1.2.x install keeps
// working next to it. Runs once (the marker file), then never again.
/// Written into the new support folder once the migration ran (whether or
/// not anything was found): `migratedFrom` (the old support folder or
/// null), `hiveFrom` (the folder the boxes came from or null), `boxes`,
/// `files`.
const migrationMarker = 'migration.json';
/// The Hive boxes the app opens, by name (settings, connect form, Files,
/// Jellyfin sign-ins, track choices). Only these leave the documents
/// folder, which other apps' boxes share.
const appHiveBoxes = ['settings', 'connect_form', 'files', 'jellyfin', 'track_prefs'];
/// Support folders of the old names for the new [support] folder, the
/// most likely first. Linux: `$XDG_DATA_HOME/moe.senkochan.watch_together`
/// (the GTK application id up to 1.2.x), then `watch_together` (the
/// executable name path_provider falls back to). Windows:
/// `%APPDATA%\com.example\watch_together` (company and product name of
/// the exe up to 1.2.x).
List<String> legacySupportDirs(String support, {bool? windows}) {
final win = windows ?? Platform.isWindows;
final sep = win ? r'\' : '/';
String parent(String p) {
final norm = p.endsWith(sep) ? p.substring(0, p.length - 1) : p;
final i = norm.lastIndexOf(sep);
return i <= 0 ? norm : norm.substring(0, i);
}
if (win) return ['${parent(parent(support))}${sep}com.example${sep}watch_together'];
final share = parent(support);
return ['$share${sep}moe.senkochan.watch_together', '$share${sep}watch_together'];
}
/// Copies the first existing [oldSupport] folder into [support] and the
/// [appHiveBoxes] (`.hive` and `.lock`) from [documents] into [hive],
/// skipping anything already there; the originals stay. Once per support
/// folder ([migrationMarker]). Returns one log line per thing copied
/// (paths and counts, never contents); empty when it ran before.
Future<List<String>> migrateLegacyData(
{required String support, required String hive, required List<String> oldSupport, String? documents}) async {
final sep = Platform.pathSeparator;
final marker = File('$support$sep$migrationMarker');
if (marker.existsSync()) return const [];
final lines = <String>[];
String? from;
var files = 0;
for (final old in oldSupport) {
final dir = Directory(old);
if (!dir.existsSync() || _same(old, support)) continue;
files = await _copyTree(dir, Directory(support));
from = old;
lines.add('migration: copied $files files from $old to $support');
break;
}
final boxes = <String>[];
if (documents != null && Directory(documents).existsSync() && !_same(documents, hive)) {
await Directory(hive).create(recursive: true);
for (final box in appHiveBoxes) {
for (final ext in const ['hive', 'lock']) {
final src = File('$documents$sep$box.$ext');
final dst = File('$hive$sep$box.$ext');
if (!src.existsSync() || dst.existsSync()) continue;
await src.copy(dst.path);
if (ext == 'hive') boxes.add(box);
}
}
if (boxes.isNotEmpty) {
lines.add('migration: copied ${boxes.length} Hive boxes (${boxes.join(', ')}) from $documents to $hive');
}
}
if (lines.isEmpty) lines.add('migration: no data from before the rename');
await Directory(support).create(recursive: true);
await marker.writeAsString(jsonEncode({
'migratedFrom': from,
'hiveFrom': boxes.isEmpty ? null : documents,
'boxes': boxes,
'files': files,
}));
return lines;
}
bool _same(String a, String b) {
try {
return FileSystemEntity.identicalSync(a, b);
} catch (_) {
return false;
}
}
// Files (File.copy keeps the mode on POSIX: cookies.txt stays 0600, yt-dlp
// executable) and symlinks as they are; existing targets win.
Future<int> _copyTree(Directory from, Directory to) async {
var n = 0;
await to.create(recursive: true);
await for (final e in from.list(recursive: true, followLinks: false)) {
final target = '${to.path}${e.path.substring(from.path.length)}';
if (e is Directory) {
await Directory(target).create(recursive: true);
} else if (await FileSystemEntity.type(target, followLinks: false) != FileSystemEntityType.notFound) {
continue;
} else if (e is Link) {
await Directory(File(target).parent.path).create(recursive: true);
await Link(target).create(await e.target());
} else if (e is File) {
await Directory(File(target).parent.path).create(recursive: true);
await e.copy(target);
n++;
}
}
return n;
}
@@ -0,0 +1,35 @@
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import '../data_migration.dart';
import '../ports/app_dirs.dart';
/// The desktop's [AppDirsPort]: the app support folder from path_provider
/// (Linux `$XDG_DATA_HOME/dev.foxyz.starsync`, Windows
/// `%APPDATA%\foxyz\Starsync`); on the first start it copies the data of
/// the old names in ([migrateLegacyData]). Needs Flutter's binding.
class DesktopAppDirs implements AppDirsPort {
const DesktopAppDirs();
@override
Future<AppDirs> resolve() async {
final support = (await getApplicationSupportDirectory()).path;
final base = AppDirs.fromEnv(support: support, env: const {});
List<String> migrated;
try {
String? documents;
try {
// Where Hive.initFlutter() kept the boxes up to 1.2.x.
documents = (await getApplicationDocumentsDirectory()).path;
} catch (_) {}
migrated = await migrateLegacyData(
support: support, hive: base.hive, oldSupport: legacySupportDirs(support), documents: documents);
} catch (e) {
migrated = ['migration failed: $e'];
}
final dirs = AppDirs.fromEnv(support: support, env: Platform.environment, migrated: migrated);
await Directory(dirs.hive).create(recursive: true);
return dirs;
}
}
@@ -5,16 +5,24 @@ import 'package:sync_relay_core/core.dart' show findInstallRoot;
/// The app's id: GTK's application id, so the Wayland `app_id` the
/// compositor matches to [desktopFileName] for the window's icon.
const appId = 'moe.senkochan.watch_together';
const appId = 'dev.foxyz.starsync';
const desktopFileName = '$appId.desktop';
/// The app id up to 1.2.x: its entry and icons are removed (only those
/// exact files, which that version wrote).
const legacyAppId = 'moe.senkochan.watch_together';
const _iconSizes = [16, 32, 48, 64, 128, 256, 512];
/// Linux release installs (an unpacked tarball) carry no launcher entry, so
/// Wayland shows a generic icon. On start: write the `.desktop` file into
/// `~/.local/share/applications` (Exec = this binary) and the icon into
/// the hicolor theme, only when something changed (a moved install, a new
/// icon). Never in debug runs; failures only log.
Future<void> installDesktopEntry({String? executable, String? dataHome, String? home}) async {
if (!Platform.isLinux || kDebugMode) return;
/// icon). The entry and icons of [legacyAppId] go. Never in debug runs
/// (unless [force], for tests); failures only log.
Future<void> installDesktopEntry(
{String? executable, String? dataHome, String? home, @visibleForTesting bool force = false}) async {
if (!Platform.isLinux || (kDebugMode && !force)) return;
try {
final exe = executable ?? Platform.resolvedExecutable;
final root = findInstallRoot(exe, windows: false);
@@ -22,7 +30,8 @@ Future<void> installDesktopEntry({String? executable, String? dataHome, String?
final share = dataHome ?? Platform.environment['XDG_DATA_HOME'] ??
'${home ?? Platform.environment['HOME']}/.local/share';
final icons = '${root.path}/data/flutter_assets/assets/icon';
for (final size in const [16, 32, 48, 64, 128, 256, 512]) {
await _removeLegacy(share);
for (final size in _iconSizes) {
final src = File('$icons/icon_$size.png');
if (!src.existsSync()) continue;
final dst = File('$share/icons/hicolor/${size}x$size/apps/$appId.png');
@@ -40,6 +49,21 @@ Future<void> installDesktopEntry({String? executable, String? dataHome, String?
}
}
Future<void> _removeLegacy(String share) async {
for (final f in [
File('$share/applications/$legacyAppId.desktop'),
for (final size in _iconSizes) File('$share/icons/hicolor/${size}x$size/apps/$legacyAppId.png'),
]) {
if (!f.existsSync()) continue;
try {
await f.delete();
debugPrint('desktop entry: removed ${f.path}');
} catch (e) {
debugPrint('desktop entry: cannot remove ${f.path}: $e');
}
}
}
@visibleForTesting
String desktopEntryText(String exe) => '''[Desktop Entry]
Type=Application
@@ -0,0 +1,69 @@
import 'dart:io';
/// The environment variable that picks a separate profile (DEVELOPMENT.md
/// "Dev loop": two instances on one machine).
const profileEnv = 'STARSYNC_PROFILE';
/// Where the app keeps its own files, resolved once in `main` through an
/// [AppDirsPort] and handed on from there.
///
/// Everything lives in the app support folder: Hive's boxes in
/// `<app support>/hive`, the logs in `<app support>/logs`. With
/// `STARSYNC_PROFILE=<name>` (letters, digits, `-`, `_`; at most 32) the
/// per-install data moves to `<app support>/profiles/<name>/`: the Hive
/// boxes (settings and so the profile, connect form, window bounds,
/// folders, Jellyfin sign-ins; the client id from restructure step 1 on)
/// under `hive/`, the logs under `logs/`. Downloads and caches stay
/// shared: yt-dlp and its cookies, the extracted shaders, thumbnails, the
/// AniList cache.
class AppDirs {
/// The profile name; null for the normal app.
final String? profile;
/// The app support folder (yt-dlp, shaders; Hive and logs without a
/// profile).
final String support;
/// Hive's folder.
final String hive;
/// The app's log folder.
final String logs;
/// What resolving copied over from before the rename, one line each
/// (paths and counts), for the log once it is open.
final List<String> migrated;
const AppDirs(
{required this.profile,
required this.support,
required this.hive,
required this.logs,
this.migrated = const []});
/// The layout for [support] (the app support folder) and [env]; an
/// invalid profile name is ignored ([invalidProfile] says which).
static AppDirs fromEnv(
{required String support, required Map<String, String> env, List<String> migrated = const []}) {
final sep = Platform.pathSeparator;
final name = validProfile(env[profileEnv]);
final root = name == null ? support : '$support${sep}profiles$sep$name';
return AppDirs(
profile: name, support: support, hive: '$root${sep}hive', logs: '$root${sep}logs', migrated: migrated);
}
/// [raw] when it is a usable profile name, else null.
static String? validProfile(String? raw) {
final v = raw?.trim() ?? '';
return RegExp(r'^[A-Za-z0-9_-]{1,32}$').hasMatch(v) ? v : null;
}
/// The env value when it is set but not a usable name (for a warning).
static String? invalidProfile(Map<String, String> env) {
final raw = env[profileEnv];
return raw != null && raw.trim().isNotEmpty && validProfile(raw) == null ? raw : null;
}
}
/// Finds the app's folders on this platform (and, the first time, brings
/// over the data of the names before the rename).
abstract interface class AppDirsPort {
/// The layout for this process; its Hive folder exists afterwards.
Future<AppDirs> resolve();
}
@@ -4,6 +4,7 @@ import 'shell_port.dart';
import 'system_notifications.dart';
import 'url_opener.dart';
export 'app_dirs.dart';
export 'folder_access.dart';
export 'link_resolver.dart';
export 'shell_port.dart';
+1 -1
View File
@@ -158,7 +158,7 @@ class AniListClient {
_cacheDir = cacheDir ?? _defaultCacheDir,
_delay = delay ?? Future<void>.delayed,
_now = now ?? DateTime.now,
userAgent = userAgent ?? 'watch-together/$appVersion';
userAgent = userAgent ?? 'Starsync/$appVersion';
final Uri url;
final Duration memoryTtl;
+1 -1
View File
@@ -474,7 +474,7 @@ class AnimeFlowClient {
_ownsHttp = http == null,
_delay = delay ?? Future<void>.delayed,
_now = now ?? DateTime.now,
userAgent = userAgent ?? 'watch-together/$appVersion';
userAgent = userAgent ?? 'Starsync/$appVersion';
/// Authenticated with the linked Jellyfin account on the same domain
/// (see [tokenFor]), looked up on every call so a re-link applies.
+4
View File
@@ -6,6 +6,7 @@ import 'package:signals/signals_flutter.dart';
import '../controls/model/controls_prefs.dart';
import '../shaders/shader_presets.dart';
import 'settings_schema.dart';
// App preferences (Settings page), one Hive box. Signals start at their
// defaults and take the stored values once [AppSettings.ensureLoaded]
@@ -134,6 +135,9 @@ class AppSettings {
Future<void> _loadNow() async {
try {
final box = _box = await Hive.openBox(settingsBoxName);
for (final line in await migrateSettings(box)) {
debugPrint(line);
}
final mode = box.get(_keyHwdecMode);
hwdecMode.value = HwdecMode.values
.where((m) => m.name == mode)
@@ -0,0 +1,46 @@
import 'package:hive_ce/hive.dart';
// The `settings` box's layout version and the ordered steps between
// versions. A step rewrites stored values for a new layout (renamed keys,
// changed formats); the restructure steps add theirs here.
/// The layout this build reads. 1: the layout of 1.2.x, where versioning
/// starts (a box without a version is at 1).
const settingsSchemaVersion = 1;
/// The box key holding the stored version.
const settingsSchemaKey = 'schemaVersion';
/// One step: brings a box at `to - 1` to [to].
class SettingsMigration {
final int to;
/// For the log ("settings schema 2: ...").
final String what;
final Future<void> Function(Box box) apply;
const SettingsMigration(this.to, this.what, this.apply);
}
/// Every step in order of [SettingsMigration.to] (2, 3, ...); empty while
/// version 1 is current.
const settingsMigrations = <SettingsMigration>[];
/// Runs the [steps] past the box's stored version up to [current], in
/// order, storing the version after each, then stores [current]. A box
/// from a newer build (a downgrade) is left as it is. Returns a log line
/// per step run.
Future<List<String>> migrateSettings(Box box,
{List<SettingsMigration> steps = settingsMigrations, int current = settingsSchemaVersion}) async {
final stored = box.get(settingsSchemaKey);
var at = stored is int ? stored : 1;
if (at > current) return ['settings schema $at is newer than this build ($current): left as is'];
final ran = <String>[];
for (final step in [...steps]..sort((a, b) => a.to.compareTo(b.to))) {
if (step.to <= at || step.to > current) continue;
await step.apply(box);
at = step.to;
await box.put(settingsSchemaKey, at);
ran.add('settings schema ${step.to}: ${step.what}');
}
if (stored != current) await box.put(settingsSchemaKey, current);
return ran;
}
@@ -1,4 +1,7 @@
@echo off
rem Update this install in place (same as clicking Update in the app).
"%~dp0watch-together-server.exe" --upgrade %*
rem The old server name is the fallback for installs from before the rename.
set "SERVER=%~dp0starsync-server.exe"
if not exist "%SERVER%" set "SERVER=%~dp0watch-together-server.exe"
"%SERVER%" --upgrade %*
pause
+4 -1
View File
@@ -1,4 +1,7 @@
#!/bin/sh
# Update this install in place (same as clicking Update in the app).
# The server binary runs the updater: it needs no GTK/display (VPS).
cd "$(dirname "$0")" && exec ./watch-together-server --upgrade "$@"
# The old server name is the fallback for installs from before the rename.
cd "$(dirname "$0")" || exit 1
if [ -x ./starsync-server ]; then exec ./starsync-server --upgrade "$@"; fi
exec ./watch-together-server --upgrade "$@"
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
# The dedicated server's name up to 1.2.x: systemd units (ExecStart) and
# old update.sh copies run it.
exec "$(dirname "$(readlink -f "$0")")/starsync-server" "$@"
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
# Starsync's name up to 1.2.x: their updater restarts it after updating to
# this release, and launchers or scripts may still point at it.
exec "$(dirname "$(readlink -f "$0")")/starsync" "$@"
@@ -173,6 +173,8 @@ class UpdateController {
_log.info('updated to ${m.version}, restarting');
status.value =
UpdateStatus(UpdatePhase.restarting, manifest: m, canInstall: true);
await restart(_exe, launchArgs);
// Started by an old name (1.2.x's restart, an old launcher): the new
// binary next to it.
await restart(relaunchExecutable(_exe), launchArgs);
}
}