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
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user