Restructure step 3: your server, Invite, version gate, AppGraph
ci / core (push) Successful in 27s
ci / server (push) Successful in 1m7s
ci / updater-windows (push) Failing after 6m4s

- One server per app run (loopback, no password); Invite attaches an
  all-interfaces listener to the same server and Stop sharing detaches
  it (guests told with 4016); members, media and Up next never reset.
  No solo mode: you're home on your own server unless in a friend's.
- Navigation: none / yours / friend; a join passes a version gate on
  /health first (clear "they/you need to update", update offer).
- Invite as a value (host, port, name, password flag, version, fp
  reserved); addresses filtered (loopback, link-local, 198.18/15,
  bridges) and ranked; Sharing chip, Copy invite, Stop sharing.
- Alone looks like a plain player (alone replaces solo).
- AppGraph builds and disposes systems; widgets use AppScope;
  SessionState deleted; a test caps the remaining singletons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-15 22:52:52 +03:00
1 parent 629be0d9ed
commit 4d00b7455e
86 files changed
+2993 -2071

No files matched your search

@@ -89,7 +89,7 @@ class AutoSitOut {
/// Once a second. [self]: this member's presence in the latest state
/// (null: not in one); [alone]: nobody else connected ([isAlone]);
/// [enabled]: the setting (and not a solo session).
/// [enabled]: the setting.
AutoSitOutStep tick(DateTime now,
{required Presence? self, required bool playing, required bool alone, required bool enabled}) {
if (playing) _lastActive = now;
+8 -1
View File
@@ -142,7 +142,8 @@ final class ConnReconnecting extends ConnState {
}
/// The server refused this app; nothing retries. [code]: `version` or
/// `secret` when the server said so, else empty (offline, timeout,
/// `secret` when the server said so, `closed` when the host stopped
/// sharing (close 4016), else empty (offline, timeout,
/// anything else on the first dial); [message]: in plain words
/// ([connectErrorText]).
final class ConnRefused extends ConnState {
@@ -166,6 +167,10 @@ final class ConnReplaced extends ConnState {
/// [ConnReplaced] in plain words.
const replacedText = 'Starsync was opened in another window';
/// `ConnRefused('closed')`: the host closed their server to friends
/// (close 4016).
const stoppedSharingText = 'the host stopped sharing their server';
/// A version refusal in plain words: [err] is the server's `err`
/// `version` (its range in `min`/`max`), or a proto 15 server's codeless
/// `proto mismatch`, which is always an older host.
@@ -518,6 +523,8 @@ class Connection {
_end(ConnRefused('version', versionText(null)));
case CloseCodes.secret:
_end(const ConnRefused('secret', 'wrong server password (401)'));
case CloseCodes.closed:
_end(const ConnRefused('closed', stoppedSharingText));
default:
_dropped(now());
}
+9 -15
View File
@@ -9,7 +9,6 @@ import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart' show validClientId;
import '../state/connect_prefs.dart';
import '../state/session_state.dart';
import '../state/settings.dart' show settingsBoxName;
import '../widgets/avatar_pipeline.dart';
@@ -96,17 +95,11 @@ const _keyColor = 'profileColor';
/// avatar = removed). Swappable so tests can watch it.
typedef ProfilePush = void Function({String? name, String? avatar});
/// The one place a profile change reaches the server.
void pushProfileToSession({String? name, String? avatar}) {
final session = SessionState.instance;
if (session.route.peek() != AppRoute.session) return;
session.coordinator.updateProfile(name: name, avatar: avatar);
}
/// Display name, picture and colour of this app's user. Backed by
/// [SessionState.lastForm] (name) and the connect-form box (the cropped
/// Display name, picture and colour of this app's user. Backed by the
/// connect form ([forms]: the name) and the connect-form box (the cropped
/// 256 px PNG and the colour); every change is persisted, and while in a
/// server it is pushed through [push] so everyone sees it at once.
/// server it is pushed through [push] so everyone sees it at once. The
/// composition root sets both.
class Profile {
static final Profile instance = Profile._();
Profile._();
@@ -116,7 +109,8 @@ class Profile {
final picture = signal<Uint8List?>(null);
final color = signal<Color>(profileColors.first);
final error = signal<String?>(null);
ProfilePush push = pushProfileToSession;
ProfilePush push = ({String? name, String? avatar}) {};
ConnectStore forms = ConnectStore();
bool _loaded = false;
Timer? _nameTimer;
@@ -131,7 +125,7 @@ class Profile {
}
Future<void> _load() async {
final form = SessionState.instance.lastForm;
final form = forms.form;
name.value = form.name;
try {
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
@@ -162,7 +156,7 @@ class Profile {
final v = value.trim();
if (v.isEmpty || v == name.peek()) return;
name.value = v;
SessionState.instance.lastForm.name = v;
forms.form.name = v;
unawaited(_save());
// Typing renames once it settles, not on every key.
_nameTimer?.cancel();
@@ -187,7 +181,7 @@ class Profile {
Future<void> _save() async {
try {
await SessionState.instance.saveForm();
await forms.save();
final box = connectFormBoxIfOpen() ?? await openConnectFormBox();
final png = picture.peek();
await box.putAll({
+123
View File
@@ -0,0 +1,123 @@
import 'dart:io';
import 'package:flutter/foundation.dart' show debugPrint;
/// What a friend needs to join your server: where ([host], [port]), what
/// it is called ([name]), whether it asks a password ([password]; the
/// password itself is never in it) and which Starsync runs it
/// ([version]). [fp]: reserved for a pinned certificate fingerprint
/// (`wss://` with a self-signed certificate); always null for now.
class Invite {
final String host;
final int port;
final String name;
final bool password;
final String version;
final String? fp;
const Invite({
required this.host,
required this.port,
this.name = '',
this.password = false,
this.version = '',
this.fp,
});
/// What goes in the Join card's address and port.
String get address => '${host.contains(':') ? '[$host]' : host}:$port';
/// The text Invite copies: the address, whether to ask for the password,
/// the version to match.
String get text => [
'Join me on Starsync: $address',
if (password) '(ask me for the password)',
if (version.isNotEmpty) '· Starsync $version',
].join(' ');
@override
bool operator ==(Object other) =>
other is Invite &&
other.host == host &&
other.port == port &&
other.name == name &&
other.password == password &&
other.version == version &&
other.fp == fp;
@override
int get hashCode => Object.hash(host, port, name, password, version, fp);
@override
String toString() => 'Invite($address${password ? ', password' : ''})';
}
/// The `host:port` in a pasted invite (or a bare address); null when
/// there is none.
({String host, String port})? addressIn(String text) {
final m = RegExp(r'(\[[0-9a-fA-F:]+\]|[A-Za-z0-9.-]+):(\d{1,5})\b').firstMatch(text);
if (m == null) return null;
final host = m.group(1)!;
return (host: host.startsWith('[') ? host.substring(1, host.length - 1) : host, port: m.group(2)!);
}
/// Interface names of container and VM bridges, never a way in for a
/// friend (Docker, Podman, libvirt, LXC, VirtualBox, VMware, WSL/Hyper-V).
final _virtualName = RegExp(
r'^(docker|br-|veth|virbr|vmnet|vboxnet|cni|flannel|podman|lxcbr|lxdbr|vEthernet|VirtualBox|VMware)',
caseSensitive: false);
/// The proxy fake-IP range (198.18.0.0/15, benchmarking space that TUN
/// proxies like Clash hand out): this machine's address on it is useless
/// to anyone else.
bool isFakeIp(List<int> b) => b[0] == 198 && (b[1] == 18 || b[1] == 19);
bool _isPrivate(List<int> b) =>
b[0] == 10 || (b[0] == 192 && b[1] == 168) || (b[0] == 172 && b[1] >= 16 && b[1] < 32);
/// Shared address space (100.64.0.0/10): mesh VPNs like Tailscale.
bool _isShared(List<int> b) => b[0] == 100 && b[1] >= 64 && b[1] < 128;
/// The addresses of [interfaces] a friend could dial, best first: IPv4,
/// no loopback, link-local or fake-IP address, no container or VM bridge;
/// home networks (192.168, then 10, then 172.16/12) first, then a mesh
/// VPN's, then anything else (a public address). No repeats.
List<String> inviteAddresses(Iterable<({String name, InternetAddress address})> interfaces) {
int rank(List<int> b) => b[0] == 192 && b[1] == 168
? 0
: _isPrivate(b)
? 1
: _isShared(b)
? 2
: 3;
final usable = [
for (final i in interfaces)
if (i.address.type == InternetAddressType.IPv4 &&
!i.address.isLoopback &&
!i.address.isLinkLocal &&
!(i.address.rawAddress[0] == 169 && i.address.rawAddress[1] == 254) &&
!isFakeIp(i.address.rawAddress) &&
!_virtualName.hasMatch(i.name))
i.address,
];
final indexed = [for (var n = 0; n < usable.length; n++) (n, usable[n])];
indexed.sort((a, b) {
final r = rank(a.$2.rawAddress).compareTo(rank(b.$2.rawAddress));
return r != 0 ? r : a.$1.compareTo(b.$1);
});
return {for (final (_, a) in indexed) a.address}.toList();
}
/// This machine's interfaces as [inviteAddresses] takes them (empty when
/// they can't be listed).
Future<List<({String name, InternetAddress address})>> machineInterfaces() async {
try {
final list = await NetworkInterface.list(type: InternetAddressType.IPv4);
return [
for (final i in list)
for (final a in i.addresses) (name: i.name, address: a),
];
} catch (e) {
debugPrint('list network interfaces failed: $e');
return const [];
}
}
@@ -0,0 +1,194 @@
import 'dart:async';
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:signals/signals.dart';
import 'package:sync_relay_core/core.dart';
import '../screens/home/health_probe.dart';
import '../state/connect_prefs.dart';
import 'app_events.dart' show ActionFailed, ServerRefused;
import 'connection.dart';
import 'identity.dart' show Profile;
import 'session_coordinator.dart';
import 'your_server.dart';
/// Which server this app is in.
enum Place {
/// None yet (starting, or between two servers).
none,
/// Your own server ([YourServer]): home.
yours,
/// A friend's (joined from the Join card).
friend,
}
/// A join that did not get as far as connecting, in plain words.
class JoinFailed implements Exception {
final String message;
const JoinFailed(this.message);
@override
String toString() => message;
}
/// Where this app is: home on your own server, or in a friend's. It
/// starts the app at home, switches to a friend's server on a join (after
/// the version gate) and back home when you leave theirs, a join fails or
/// they stop sharing. Your server stays up all the while; you leave it
/// (with `bye`) while you are away. Made by the composition root.
class Navigation {
Navigation({
required this.coordinator,
required this.yourServer,
required this.forms,
this.profile,
this.healthGet = defaultHealthGet,
}) {
yourServer.onCrash = () {
if (place.peek() != Place.friend) unawaited(goHome());
};
// A friend closed their server to friends: back home, and say so.
_stops.add(effect(() {
final s = coordinator.connection.state.value;
if (s is ConnRefused && s.code == 'closed' && untracked(() => place.value) == Place.friend) {
// Said once home (a connect clears the toasts).
scheduleMicrotask(() => unawaited(goHome().then(
(_) => coordinator.notify(const ServerRefused('The host stopped sharing their server')))));
}
}));
}
final SessionCoordinator coordinator;
final YourServer yourServer;
final ConnectStore forms;
/// Name and picture for your own connection (default: the app's).
final Profile? profile;
final HealthGetter healthGet;
final _stops = <void Function()>[];
Profile get _me => profile ?? Profile.instance;
final place = signal(Place.none);
/// In a server (yours or a friend's).
late final Computed<bool> online = computed(() => place.value != Place.none);
/// On your own server.
late final Computed<bool> home = computed(() => place.value == Place.yours);
/// A friend's server's own name (`/health`), '' for yours, until known
/// or when it only has the default name.
final serverLabel = signal('');
/// The server's Starsync version: yours is this app's; a friend's
/// comes from its `/health`; '' until known.
final serverVersion = signal('');
/// The address dialled for a friend's server ("host:port"), '' at home.
final address = signal('');
// Bumped by every move; a superseded one stops at its next step.
int _op = 0;
/// The app starts: home, on your own server.
Future<void> start() => goHome();
/// To your own server (started if it is not running). False when it
/// could not start or the move was superseded; the reason is a notice.
Future<bool> goHome() async {
final op = ++_op;
_arrive(Place.none);
try {
await _me.ensureLoaded();
final name = _me.name.peek().trim();
final port = await yourServer.start(name: name);
if (op != _op) return false;
coordinator.hostFile = yourServer.setFile;
await coordinator.connect(ConnectParams(
host: '127.0.0.1',
port: port,
secret: '',
name: name.isEmpty ? 'Me' : name,
avatar: _me.avatarBase64(),
hostKey: yourServer.hostKey,
));
if (op != _op) return false;
serverVersion.value = appVersion;
_arrive(Place.yours);
return true;
} on ConnectCancelled {
return false;
} catch (e, st) {
debugPrint('your server: $e\n$st');
if (op == _op) coordinator.notify(ActionFailed("couldn't start your server: $e"));
return false;
}
}
void _arrive(Place p) {
if (p != Place.yours) coordinator.hostFile = null;
if (p == Place.none) {
serverLabel.value = '';
serverVersion.value = '';
address.value = '';
}
place.value = p;
}
/// Joins a friend's server. The version gate first (`/health`): an
/// unreachable server or one without a version in common stops here,
/// still at home ([JoinFailed], [VersionGateRefused]). Then you leave
/// your server and connect; when that fails you are back home and the
/// error is rethrown (a refusal: [HelloRefused]).
Future<void> join(ConnectParams params) async {
final op = ++_op;
final health = await probeHealth(params.host, '${params.port}', healthGet);
if (op != _op) throw const ConnectCancelled();
if (!health.online) throw const JoinFailed('host offline — check address and port');
final fit = health.fit;
if (fit != null && fit != VersionFit.ok) {
throw VersionGateRefused(versionGateText(health), youUpdate: fit == VersionFit.youUpdate);
}
_arrive(Place.none);
try {
await coordinator.connect(params);
if (op != _op) throw const ConnectCancelled();
} catch (_) {
if (op == _op) unawaited(goHome());
rethrow;
}
serverLabel.value = health.label;
serverVersion.value = health.version;
address.value = '${params.host}:${params.port}';
_arrive(Place.friend);
await forms.remember(RecentServer(host: params.host, port: '${params.port}', secret: params.secret));
}
/// The Join card's Cancel: stops a join under way; when it had already
/// left home, back home.
Future<void> cancelPending() async {
_op++;
try {
await coordinator.cancelConnect();
} catch (_) {}
if (place.peek() != Place.yours) await goHome();
}
/// Out of a friend's server (Leave): back home.
Future<bool> leaveFriend() => goHome();
/// The app closes: out of whichever server ([bye] false: Update and
/// restart, the member stays `gone` for the restarted app).
Future<void> leave({bool bye = true}) async {
_op++;
await coordinator.leave(bye: bye);
_arrive(Place.none);
}
void dispose() {
for (final stop in _stops) {
stop();
}
}
}
@@ -268,9 +268,12 @@ class SessionCoordinator {
StreamSubscription? _nudgeSub;
StreamSubscription? _errSub;
/// A solo session (set by SessionState): no auto sit-out, there is
/// nobody to hold up.
bool solo = false;
/// Nobody else is in the server (D9): a plain player, Play rather than
/// Ready, links go up direct. Also true before the first state.
late final Computed<bool> alone = computed(() {
final r = serverState.value;
return r == null || aloneIn(r.members, connection.memberId);
});
bool _playerReady = false;
bool _wasPlayAllowed = false;
// Last ready flag the server reported for self; sent again when a
@@ -316,10 +319,6 @@ class SessionCoordinator {
final memberNames = MemberNames();
// The media this member was told its upload is too slow for.
int _slowWarnedFor = -1;
/// What this client put up last in this connection (a solo session
/// carries it over when it becomes a hosted server).
PickedMedia? get lastPick => _lastPick;
PickedMedia? _lastPick;
// Server seek applied to the player (event id and when), checked once it
// should have landed.
int? _pendingSeekEvent;
@@ -548,7 +547,6 @@ class SessionCoordinator {
_seekGate.reset();
_clearPendingSeek();
_lastEventId = -1;
_lastPick = null;
_resetJellyfin();
_jfToastShown = false;
if (!_jellyfinListening) {
@@ -1152,7 +1150,7 @@ class SessionCoordinator {
self: online ? selfMember.peek()?.presence : null,
playing: r?.transport == 'playing',
alone: r == null || aloneIn(r.members, connection.memberId),
enabled: !solo && AppSettings.instance.autoSitOut.peek());
enabled: AppSettings.instance.autoSitOut.peek());
switch (step) {
case AutoAway(:final reason):
_log.info('auto sit-out: away (${reason.name})');
@@ -1475,7 +1473,6 @@ class SessionCoordinator {
Future<String?> swapLink(String url,
{LinkRelay relay = LinkRelay.direct, bool anyway = false, String? queueId}) async {
markInput();
_lastPick = LinkMedia(url, relay: relay, anyway: anyway);
final media = await mediaForLink(jellyfin, url);
if (media.jellyfin != null) {
_pendingOrigin = _JellyfinOrigin(url);
@@ -1557,7 +1554,6 @@ class SessionCoordinator {
/// relayed by this member for viewers without an account.
void swapJellyfin(JellyfinMedia media, {String? queueId}) {
markInput();
_lastPick = media;
_pendingOrigin = const _JellyfinOrigin(null);
_swap(mediaForPick(media).copyWith(relay: const MediaRelay()), queueId: queueId);
}
@@ -1567,7 +1563,6 @@ class SessionCoordinator {
/// when sent.
Future<String?> swapFile(String path, {String? queueId}) async {
markInput();
_lastPick = FileMedia(path);
final host = hostFile;
if (host != null) {
final file = await host(path);
@@ -0,0 +1,207 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io' show InternetAddress;
import 'dart:math';
import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting;
import 'package:signals/signals.dart';
import 'package:sync_relay_core/core.dart';
import '../jellyfin/accounts.dart';
import '../jellyfin/session_media.dart' show relayCredentialFor;
import '../state/connect_prefs.dart';
import '../state/server_isolate.dart';
import 'invite.dart';
/// A fresh key per app run: this app's own client sends it in hello to be
/// recognised as the host (`members[].host`; it also stands in for the
/// password).
String newHostKey() {
final r = Random.secure();
return base64Url.encode([for (var i = 0; i < 24; i++) r.nextInt(256)]);
}
/// Starts the server isolate (tests may pass their own).
typedef ServerStarter = Future<ServerIsolate> Function(
AppConfig config, {
bool loopbackOnly,
String? logDir,
required void Function(String line) onLog,
required void Function(String error) onCrash,
});
Future<ServerIsolate> _startIsolate(
AppConfig config, {
bool loopbackOnly = false,
String? logDir,
required void Function(String line) onLog,
required void Function(String error) onCrash,
}) =>
ServerIsolate.start(config, loopbackOnly: loopbackOnly, logDir: logDir, onLog: onLog, onCrash: onCrash);
/// "Your server": the one server this app runs for its whole life (a
/// [ServerIsolate] running `serve`). It starts on 127.0.0.1 and a free
/// port, so only this app reaches it; [invite] attaches a second
/// listener on every interface at the configured port (Settings → Party
/// & network: port and password) and [stopSharing] detaches it. Members,
/// what plays and Up next never restart or reconnect for either. Made
/// by the composition root.
class YourServer {
YourServer({
required this.forms,
this.jellyfin,
Future<String> Function()? ytDlpDir,
String? Function()? logDir,
ServerStarter? starter,
Future<List<({String name, InternetAddress address})>> Function()? interfaces,
}) : _ytDlpDir = ytDlpDir ?? (() async => ''),
_logDir = logDir ?? (() => null),
_starter = starter ?? _startIsolate,
_interfaces = interfaces ?? machineInterfaces;
final ConnectStore forms;
/// Linked Jellyfin servers (default: the app's): the relay login.
final JellyfinAccounts? jellyfin;
final Future<String> Function() _ytDlpDir;
final String? Function() _logDir;
final ServerStarter _starter;
final Future<List<({String name, InternetAddress address})>> Function() _interfaces;
/// Sent by this app's own client (see [newHostKey]).
final String hostKey = newHostKey();
/// The loopback port while it runs; null before [start] and after
/// [stop] or a crash.
final port = signal<int?>(null);
/// The invite while shared (the listener attached); null when only this
/// app can reach it.
final shared = signal<Invite?>(null);
/// An [invite] or [stopSharing] is under way.
final busy = signal(false);
/// Set when the server died without being stopped; the shell says so
/// and clears it. The next [start] makes a new one.
final crash = signal<String?>(null);
/// Called after a crash (the navigation reconnects home).
void Function()? onCrash;
ServerIsolate? _server;
Future<int>? _starting;
@visibleForTesting
ServerIsolate? get server => _server;
JellyfinAccounts get _accounts => jellyfin ?? JellyfinAccounts.instance;
/// Starts it once (later calls get the same port); a new one after a
/// crash. [name]: what `/health` calls it ("Bob's server").
Future<int> start({String name = ''}) {
final running = _server;
if (running != null && running.isRunning) return Future.value(running.port);
return _starting ??= _start(name).whenComplete(() => _starting = null);
}
Future<int> _start(String name) async {
final who = name.trim();
late final ServerIsolate server;
server = await _starter(
AppConfig(
port: 0,
secret: '',
name: who.isEmpty ? 'server' : "$who's server",
hostKey: hostKey,
ytDlpDir: await _ytDlpDir(),
),
loopbackOnly: true,
// Its session log (every member's shared diagnostics and its own
// records) goes next to the app's.
logDir: _logDir(),
// Its records join the app's log as `server.<logger>`.
onLog: (line) => relogLine('server', line),
onCrash: (cause) => _onCrash(server, cause),
);
_server = server;
port.value = server.port;
return server.port;
}
void _onCrash(ServerIsolate server, String cause) {
if (!identical(_server, server)) return;
_server = null;
port.value = null;
shared.value = null;
final message = 'server crashed: $cause';
debugPrint('[server] $message');
crash.value = message;
onCrash?.call();
}
/// Opens it to friends (attaching the network listener if it is not
/// yet) and returns the invite to hand out. Throws when the port can't
/// be opened (in use) or the server is not running.
Future<Invite> invite() async {
final server = _server;
if (server == null || !server.isRunning) throw StateError('your server is not running');
final current = shared.peek();
if (current != null) return current;
busy.value = true;
try {
final form = forms.form;
final want = int.tryParse(form.hostPort.trim()) ?? 8080;
await _accounts.ensureLoaded(validate: false);
final bound = await server.share(
port: want,
secret: form.hostSecret,
// Guests without an account watch Jellyfin items through the
// server, with this app's own sign-in.
jellyfin: relayCredentialFor(_accounts),
);
final invite = await inviteFor(bound, password: form.hostSecret.isNotEmpty);
shared.value = invite;
return invite;
} finally {
busy.value = false;
}
}
/// The invite for [port] from this machine's best address (see
/// [inviteAddresses]); 127.0.0.1 when there is none.
Future<Invite> inviteFor(int port, {required bool password}) async {
final addresses = inviteAddresses(await _interfaces());
return Invite(
host: addresses.isEmpty ? '127.0.0.1' : addresses.first,
port: port,
password: password,
version: appVersion,
);
}
/// Closes it to friends again: everyone but you leaves (told, so their
/// apps do not retry); you, what plays and Up next stay.
Future<void> stopSharing() async {
final server = _server;
if (server == null || shared.peek() == null) return;
busy.value = true;
try {
await server.unshare();
shared.value = null;
} finally {
busy.value = false;
}
}
/// Makes [path] the hosted file (a File put up here; `/m` serves it).
Future<({String name, int size})?> setFile(String path) async => _server?.setFile(path);
/// The app closes: the server stops (sockets closed, port freed).
Future<void> stop() async {
final server = _server;
_server = null;
port.value = null;
shared.value = null;
await server?.stop();
}
}