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:
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

+5 -5
View File
@@ -6,7 +6,7 @@ import 'package:sync_relay_core/core.dart';
import '../screens/home/ui.dart' show appName, displayVersion;
import 'ports/app_dirs.dart';
import '../state/session_state.dart';
import '../app/app_graph.dart';
// The app's log on disk (<app support>/logs/app-YYYY-MM-DD.log; core's
// logging.dart writes, rotates and redacts it), debugPrint routed into it,
@@ -50,19 +50,19 @@ Future<void> startAppLogging(AppDirs dirs) async {
/// the app log (its hosted server's records included), already redacted;
/// with [sessionLog], the server's session log of the same hours after it
/// (every member's shared lines and the server's, merged).
Future<String> diagnosticsText({Duration since = const Duration(hours: 6), bool sessionLog = false}) async {
Future<String> diagnosticsText(AppGraph graph, {Duration since = const Duration(hours: 6), bool sessionLog = false}) async {
final b = StringBuffer()
..writeln('$appName ${displayVersion()}')
..writeln('OS: ${Platform.operatingSystem} ${Platform.operatingSystemVersion}')
..writeln('Dart: ${Platform.version.split(' ').first}');
try {
final mpv = await SessionState.instance.coordinator.playback.mpvVersion().timeout(const Duration(seconds: 2));
final mpv = await graph.coordinator.playback.mpvVersion().timeout(const Duration(seconds: 2));
b.writeln('mpv: ${mpv ?? 'no player yet'}');
} catch (_) {
b.writeln('mpv: unknown');
}
try {
final y = await SessionState.instance.ports.links.ytDlp.timeout(const Duration(seconds: 2));
final y = await graph.ports.links.ytDlp.timeout(const Duration(seconds: 2));
final cookies = y.cookies.read(count: false);
b.writeln('yt-dlp: ${y.version ?? 'not ready'}${y.lastError == null ? '' : ' (${y.lastError})'}, '
'cookies: ${cookies == null ? 'none' : 'set'}');
@@ -74,7 +74,7 @@ Future<String> diagnosticsText({Duration since = const Duration(hours: 6), bool
b.writeln('--- last ${since.inHours} h of the log ---');
b.write(files?.recent(since) ?? '');
if (sessionLog) {
final server = await SessionState.instance.coordinator.sessionLog(hours: since.inHours);
final server = await graph.coordinator.sessionLog(hours: since.inHours);
b
..writeln()
..writeln("--- last ${since.inHours} h of the server's session log ---")
+21 -11
View File
@@ -4,7 +4,7 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import '../files/thumbnails.dart';
import '../state/session_state.dart';
import '../app/app_graph.dart';
// The one way out of the app (close button, Alt+F4, Update and restart).
// Order matters: every mpv player goes before the engine does (a live one
@@ -18,33 +18,43 @@ bool _shuttingDown = false;
/// True once [shutDownApp] started.
bool get shuttingDown => _shuttingDown;
/// The systems the close stops; `main` hands them over once.
AppGraph? _graph;
/// Makes [graph] the one [shutDownApp] stops and returns the close
/// request handler for the window.
Future<void> Function() closeAppWith(AppGraph graph) {
_graph = graph;
return shutDownApp;
}
/// Hides the window, releases every player and the sound device, leaves
/// (the `bye` goes out first, so others see "left" at once; a hosted
/// (the `bye` goes out first, so others see "left" at once; then your
/// server stops), runs [beforeQuit], then closes the window. [restart]
/// (Update and restart) leaves without the `bye`: the member stays
/// `gone`, and the restarted app, same client id, takes it back within
/// the grace. Every step is bounded; a second call does nothing.
Future<void> shutDownApp({Future<void> Function()? beforeQuit, bool restart = false}) async {
if (_shuttingDown) return;
final graph = _graph;
if (_shuttingDown || graph == null) return;
_shuttingDown = true;
final session = SessionState.instance;
final shell = session.ports.shell;
final shell = graph.ports.shell;
final total = Stopwatch()..start();
await _step('window save', shell.saveWindow(), const Duration(milliseconds: 500));
await _step('hide', shell.hide(), const Duration(milliseconds: 500));
await Future.wait([
_step('players', session.coordinator.releasePlayers(), const Duration(seconds: 3)),
_step('players', graph.coordinator.releasePlayers(), const Duration(seconds: 3)),
_step('thumbnails', cancelAllThumbnails(), const Duration(milliseconds: 1500)),
_step('sounds', session.sfx.dispose(), const Duration(milliseconds: 500)),
_step('sounds', graph.sfx.dispose(), const Duration(milliseconds: 500)),
]);
// Windows: media_kit_video frees a player's video output on a thread of
// its own after dispose returns; it must be done before the engine
// stops, or the plugin's teardown waits on it for good.
if (Platform.isWindows) await Future.delayed(const Duration(milliseconds: 300));
// Jellyfin stop report, last diagnostics, the bye (flushed), then the
// hosted server: 3 s in all.
await _step('leave', session.leave(bye: !restart), const Duration(milliseconds: 2500));
await _step('server stop', session.stopServer(), const Duration(milliseconds: 500));
// Jellyfin stop report, last diagnostics, the bye (flushed), then your
// server: 3 s in all.
await _step('leave', graph.navigation.leave(bye: !restart), const Duration(milliseconds: 2500));
await _step('server stop', graph.yourServer.stop(), const Duration(milliseconds: 500));
if (beforeQuit != null) await _step('before quit', beforeQuit(), const Duration(seconds: 3));
debugPrint('close: ready to quit after ${total.elapsedMilliseconds} ms');
await shell.quit();
@@ -12,7 +12,7 @@ export 'system_notifications.dart';
export 'url_opener.dart';
/// The platform as the app sees it: one set, made in the composition
/// root ([SessionState]) and handed on from there.
/// root ([AppGraph]) and handed on from there.
class Ports {
final ShellPort shell;
final UrlOpener urls;