Restructure step 3: your server, Invite, version gate, AppGraph
- 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:
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;
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user