Server on an isolate, dedicated server binary, room -> server (proto 5)

- serve(Map config, events) is the one server entrypoint: the app
  hosts it via Isolate.spawn (crash surfaced on the Host card, logs
  forwarded as [server]); bin/server.dart is the dedicated server,
  compiled into the bundle as watch-together-server. --headless gone.
- Host identity via a per-hosting hostKey in hello, replacing the
  loopback heuristic.
- Rename room -> server across code, files and wire (t:"state",
  /health name); protocol bumped to 5.
- CI compiles the server binary; isolate smoke tests in server and
  desktop suites. Docs updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-07 00:55:14 +03:00
1 parent d8a0729029
commit 81cac1214d
48 files changed
+1073 -423

No files matched your search

@@ -39,6 +39,12 @@ class ConnectFlow extends ChangeNotifier {
if (!_disposed) notifyListeners();
}
void showError(ConnectMode mode, CardError error) {
errors[mode] = error;
notices[mode] = null;
_notify();
}
void clearError(ConnectMode mode) {
if (errors[mode] == null && notices[mode] == null) return;
errors[mode] = null;
@@ -33,6 +33,7 @@ class _HomePageState extends State<HomePage> {
late final _media =
TextEditingController(text: _f.linkUrl.isNotEmpty ? _f.linkUrl : _f.mediaPath);
late final void Function() _stopKickWatch;
late final void Function() _stopCrashWatch;
bool _dialogOpen = false;
String? _lastKickShown;
@@ -49,11 +50,21 @@ class _HomePageState extends State<HomePage> {
}
WidgetsBinding.instance.addPostFrameCallback((_) => _showKickedDialog(kick));
});
// Back from a session whose hosted server crashed: say so on the Host card.
_stopCrashWatch = effect(() {
final crash = SessionState.instance.serverCrash.value;
if (crash == null) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
SessionState.instance.serverCrash.value = null;
if (mounted) _flow.showError(ConnectMode.host, CardError('Server crashed', crash));
});
});
}
@override
void dispose() {
_stopKickWatch();
_stopCrashWatch();
_flow.dispose();
for (final c in [_joinHost, _joinPort, _joinSecret, _hostPort, _hostSecret, _media]) {
c.dispose();
@@ -354,7 +354,7 @@ class _SessionScreenState extends State<SessionScreen> {
final coord = SessionState.instance.coordinator;
return SignalBuilder(
builder: (context) {
final r = coord.room.value;
final r = coord.serverState.value;
final waiting = [
for (final m in r?.members ?? [])
if (!m.ready) m,
@@ -648,7 +648,7 @@ class _SheetPanel extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: SignalBuilder(
builder: (context) {
final r = coord.room.value;
final r = coord.serverState.value;
final log = coord.feedLog.value;
final theme = Theme.of(context);
final sectionStyle = theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold);
@@ -669,7 +669,7 @@ class _SheetPanel extends StatelessWidget {
children: [
Text('Server', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(width: 8),
_StatusChip(playing: playing, hasRoom: r != null),
_StatusChip(playing: playing, hasState: r != null),
],
),
const SizedBox(height: 2),
@@ -780,14 +780,14 @@ class _SheetPanel extends StatelessWidget {
class _StatusChip extends StatelessWidget {
final bool playing;
final bool hasRoom;
const _StatusChip({required this.playing, required this.hasRoom});
final bool hasState;
const _StatusChip({required this.playing, required this.hasState});
@override
Widget build(BuildContext context) {
final Color dot;
final String label;
if (!hasRoom) {
if (!hasState) {
dot = Colors.grey;
label = '…';
} else if (playing) {