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:
1 parent
d8a0729029
commit
81cac1214d
48 files changed
+1061
-411
No files matched your search
@@ -31,3 +31,8 @@ jobs:
|
|||||||
working-directory: packages/server
|
working-directory: packages/server
|
||||||
- run: dart test
|
- run: dart test
|
||||||
working-directory: packages/server
|
working-directory: packages/server
|
||||||
|
# Compiling the dedicated server proves it stays Flutter-free.
|
||||||
|
- run: dart compile exe bin/server.dart -o build/watch-together-server
|
||||||
|
working-directory: packages/server
|
||||||
|
- run: build/watch-together-server --help
|
||||||
|
working-directory: packages/server
|
||||||
@@ -15,9 +15,7 @@ AGENTS.md. Source of truth: the code, then the docs below.
|
|||||||
- Each fact lives in exactly one file; others link to it.
|
- Each fact lives in exactly one file; others link to it.
|
||||||
- Code and its docs change in the same commit. A wire change also bumps
|
- Code and its docs change in the same commit. A wire change also bumps
|
||||||
`RelayConstants.protoVersion` and edits PROTOCOL.md.
|
`RelayConstants.protoVersion` and edits PROTOCOL.md.
|
||||||
- User-facing wording says "server" (there is only one), never "room".
|
- Say "server" (there is only one), never "room", in UI, code and docs.
|
||||||
Code identifiers and wire names keep their real names until the rename
|
|
||||||
in ROADMAP item 1.
|
|
||||||
|
|
||||||
## Wishlist drafts
|
## Wishlist drafts
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,8 @@ telling you to update.
|
|||||||
Home, Host card. Pick a port, an optional password (blank = anyone can
|
Home, Host card. Pick a port, an optional password (blank = anyone can
|
||||||
join), and optionally a video: a link or a file path. Your name and avatar
|
join), and optionally a video: a link or a file path. Your name and avatar
|
||||||
come from your profile. The app starts a
|
come from your profile. The app starts a
|
||||||
server inside itself and joins it. Friends need to reach your machine: open
|
server inside itself and joins it; you are the host and nobody can kick you.
|
||||||
|
Leaving or closing the app stops the server. Friends need to reach your machine: open
|
||||||
the TCP port on your router (or use a LAN or VPN address). Friends open nothing.
|
the TCP port on your router (or use a LAN or VPN address). Friends open nothing.
|
||||||
|
|
||||||
If you pick a local file, the server streams it to everyone from your disk.
|
If you pick a local file, the server streams it to everyone from your disk.
|
||||||
@@ -44,9 +45,28 @@ Nothing is uploaded or stored anywhere.
|
|||||||
|
|
||||||
## Dedicated server
|
## Dedicated server
|
||||||
|
|
||||||
Being reworked: today the server is started by the app (host) and the
|
For an always-on box (no display needed): the release tarball also contains
|
||||||
standalone server is a dev tool. The planned dedicated server is a separate
|
`watch-together-server`, a small standalone server without the app. Run it,
|
||||||
binary built from `packages/server`. See [docs/ROADMAP.md](docs/ROADMAP.md).
|
then everyone (you included) uses Join.
|
||||||
|
|
||||||
|
```
|
||||||
|
./watch-together-server --port 8080 --secret <password>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Flag | Default | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `--port`, `-p` | 8080 | TCP port, all IPv4 interfaces |
|
||||||
|
| `--secret` | empty | password; empty = open server (a warning is printed) |
|
||||||
|
| `--name` | `server` | name reported by `/health` |
|
||||||
|
| `--media` | none | local file to serve as the starting video |
|
||||||
|
| `--advertise-host` | none | host put in file URLs instead of the one each viewer dialled |
|
||||||
|
| `--config` | none | YAML or JSON file with the same settings (`port`, `secret`, `name`, `mediaPath`, `advertiseHost`); replaces the flags |
|
||||||
|
|
||||||
|
It prints `READY port=... name=...` once listening and stops cleanly on
|
||||||
|
Ctrl+C or SIGTERM. A dedicated server has no host: anyone can kick anyone.
|
||||||
|
To run it as a service, unpack the tarball to `/opt/watch-together` and use
|
||||||
|
[deploy/watch-together.service](deploy/watch-together.service) (copy it to
|
||||||
|
`/etc/systemd/system/`, edit the flags, `systemctl enable --now watch-together`).
|
||||||
|
|
||||||
## Join
|
## Join
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# apps/desktop
|
# apps/desktop
|
||||||
|
|
||||||
The Flutter desktop app (the only binary users run). Layout and rules:
|
The Flutter desktop app (what users run; hosting runs the server from
|
||||||
|
`packages/server` on an isolate). Layout and rules:
|
||||||
[../../docs/ARCHITECTURE.md](../../docs/ARCHITECTURE.md). Build and test:
|
[../../docs/ARCHITECTURE.md](../../docs/ARCHITECTURE.md). Build and test:
|
||||||
[../../docs/DEVELOPMENT.md](../../docs/DEVELOPMENT.md).
|
[../../docs/DEVELOPMENT.md](../../docs/DEVELOPMENT.md).
|
||||||
@@ -19,10 +19,6 @@ Future<void> main(List<String> args) async {
|
|||||||
exit(exitCode);
|
exit(exitCode);
|
||||||
}
|
}
|
||||||
initLogging();
|
initLogging();
|
||||||
if (args.contains('--headless')) {
|
|
||||||
await runHeadless(args);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
FlutterError.onError = (details) {
|
FlutterError.onError = (details) {
|
||||||
debugPrint('FLUTTER ERROR: ${details.exceptionAsString()}\n${details.stack}');
|
debugPrint('FLUTTER ERROR: ${details.exceptionAsString()}\n${details.stack}');
|
||||||
FlutterError.presentError(details);
|
FlutterError.presentError(details);
|
||||||
@@ -67,13 +63,6 @@ Future<void> main(List<String> args) async {
|
|||||||
runApp(const WatchTogetherApp());
|
runApp(const WatchTogetherApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> runHeadless(List<String> args) async {
|
|
||||||
final session = SessionState.instance;
|
|
||||||
await session.startHeadless(args);
|
|
||||||
await ProcessSignal.sigint.watch().first;
|
|
||||||
await session.stopServer();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Close button / Alt+F4: leave gracefully first (others see "left", a
|
/// Close button / Alt+F4: leave gracefully first (others see "left", a
|
||||||
/// hosted server shuts down), bounded so a hung socket can't keep the
|
/// hosted server shuts down), bounded so a hung socket can't keep the
|
||||||
/// window open.
|
/// window open.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import 'package:media_kit_video/media_kit_video.dart';
|
|||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
import '../state/settings.dart';
|
import '../state/settings.dart';
|
||||||
import '../sync/room_diff.dart';
|
import '../sync/state_diff.dart';
|
||||||
import 'frame_watchdog.dart';
|
import 'frame_watchdog.dart';
|
||||||
|
|
||||||
/// Owns one mpv [Player] (and its [VideoController]) per session: created
|
/// Owns one mpv [Player] (and its [VideoController]) per session: created
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ class ConnectFlow extends ChangeNotifier {
|
|||||||
if (!_disposed) notifyListeners();
|
if (!_disposed) notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void showError(ConnectMode mode, CardError error) {
|
||||||
|
errors[mode] = error;
|
||||||
|
notices[mode] = null;
|
||||||
|
_notify();
|
||||||
|
}
|
||||||
|
|
||||||
void clearError(ConnectMode mode) {
|
void clearError(ConnectMode mode) {
|
||||||
if (errors[mode] == null && notices[mode] == null) return;
|
if (errors[mode] == null && notices[mode] == null) return;
|
||||||
errors[mode] = null;
|
errors[mode] = null;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class _HomePageState extends State<HomePage> {
|
|||||||
late final _media =
|
late final _media =
|
||||||
TextEditingController(text: _f.linkUrl.isNotEmpty ? _f.linkUrl : _f.mediaPath);
|
TextEditingController(text: _f.linkUrl.isNotEmpty ? _f.linkUrl : _f.mediaPath);
|
||||||
late final void Function() _stopKickWatch;
|
late final void Function() _stopKickWatch;
|
||||||
|
late final void Function() _stopCrashWatch;
|
||||||
bool _dialogOpen = false;
|
bool _dialogOpen = false;
|
||||||
String? _lastKickShown;
|
String? _lastKickShown;
|
||||||
|
|
||||||
@@ -49,11 +50,21 @@ class _HomePageState extends State<HomePage> {
|
|||||||
}
|
}
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _showKickedDialog(kick));
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_stopKickWatch();
|
_stopKickWatch();
|
||||||
|
_stopCrashWatch();
|
||||||
_flow.dispose();
|
_flow.dispose();
|
||||||
for (final c in [_joinHost, _joinPort, _joinSecret, _hostPort, _hostSecret, _media]) {
|
for (final c in [_joinHost, _joinPort, _joinSecret, _hostPort, _hostSecret, _media]) {
|
||||||
c.dispose();
|
c.dispose();
|
||||||
|
|||||||
@@ -354,7 +354,7 @@ class _SessionScreenState extends State<SessionScreen> {
|
|||||||
final coord = SessionState.instance.coordinator;
|
final coord = SessionState.instance.coordinator;
|
||||||
return SignalBuilder(
|
return SignalBuilder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final r = coord.room.value;
|
final r = coord.serverState.value;
|
||||||
final waiting = [
|
final waiting = [
|
||||||
for (final m in r?.members ?? [])
|
for (final m in r?.members ?? [])
|
||||||
if (!m.ready) m,
|
if (!m.ready) m,
|
||||||
@@ -648,7 +648,7 @@ class _SheetPanel extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||||
child: SignalBuilder(
|
child: SignalBuilder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final r = coord.room.value;
|
final r = coord.serverState.value;
|
||||||
final log = coord.feedLog.value;
|
final log = coord.feedLog.value;
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final sectionStyle = theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold);
|
final sectionStyle = theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold);
|
||||||
@@ -669,7 +669,7 @@ class _SheetPanel extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text('Server', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
Text('Server', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_StatusChip(playing: playing, hasRoom: r != null),
|
_StatusChip(playing: playing, hasState: r != null),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
@@ -780,14 +780,14 @@ class _SheetPanel extends StatelessWidget {
|
|||||||
|
|
||||||
class _StatusChip extends StatelessWidget {
|
class _StatusChip extends StatelessWidget {
|
||||||
final bool playing;
|
final bool playing;
|
||||||
final bool hasRoom;
|
final bool hasState;
|
||||||
const _StatusChip({required this.playing, required this.hasRoom});
|
const _StatusChip({required this.playing, required this.hasState});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final Color dot;
|
final Color dot;
|
||||||
final String label;
|
final String label;
|
||||||
if (!hasRoom) {
|
if (!hasState) {
|
||||||
dot = Colors.grey;
|
dot = Colors.grey;
|
||||||
label = '…';
|
label = '…';
|
||||||
} else if (playing) {
|
} else if (playing) {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class IdleTracker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Called once per tick. Warn and unready only matter while self is
|
/// Called once per tick. Warn and unready only matter while self is
|
||||||
/// ready; while the room plays the ready flag gates nothing.
|
/// ready; while playing the ready flag gates nothing.
|
||||||
IdleEvent tick(DateTime now, {required bool playing, required bool selfReady}) {
|
IdleEvent tick(DateTime now, {required bool playing, required bool selfReady}) {
|
||||||
if (playing) {
|
if (playing) {
|
||||||
activity(now);
|
activity(now);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import 'package:sync_relay_core/core.dart';
|
|||||||
|
|
||||||
import '../playback/playback_controller.dart';
|
import '../playback/playback_controller.dart';
|
||||||
import '../sfx/sfx.dart';
|
import '../sfx/sfx.dart';
|
||||||
import '../sync/room_diff.dart';
|
import '../sync/state_diff.dart';
|
||||||
import '../sync/sync_client.dart';
|
import '../sync/sync_client.dart';
|
||||||
import 'health.dart';
|
import 'health.dart';
|
||||||
import 'idle.dart';
|
import 'idle.dart';
|
||||||
@@ -42,6 +42,8 @@ class ConnectParams {
|
|||||||
final String avatar;
|
final String avatar;
|
||||||
final String mediaPath;
|
final String mediaPath;
|
||||||
final String linkUrl;
|
final String linkUrl;
|
||||||
|
/// Sent in hello by the hosting app's own client (see AppConfig.hostKey).
|
||||||
|
final String hostKey;
|
||||||
const ConnectParams({
|
const ConnectParams({
|
||||||
required this.host,
|
required this.host,
|
||||||
required this.port,
|
required this.port,
|
||||||
@@ -50,6 +52,7 @@ class ConnectParams {
|
|||||||
this.avatar = '',
|
this.avatar = '',
|
||||||
this.mediaPath = '',
|
this.mediaPath = '',
|
||||||
this.linkUrl = '',
|
this.linkUrl = '',
|
||||||
|
this.hostKey = '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,8 +65,8 @@ class ToastItem {
|
|||||||
|
|
||||||
const _toastActions = {'join', 'leave', 'kick', 'swap', 'chat'};
|
const _toastActions = {'join', 'leave', 'kick', 'swap', 'chat'};
|
||||||
|
|
||||||
String? roomMediaUrl(RoomBroadcast room) {
|
String? stateMediaUrl(StateBroadcast state) {
|
||||||
final media = room.media;
|
final media = state.media;
|
||||||
if (media == null) return null;
|
if (media == null) return null;
|
||||||
final url = media['url'];
|
final url = media['url'];
|
||||||
if (url is String && url.isNotEmpty) return url;
|
if (url is String && url.isNotEmpty) return url;
|
||||||
@@ -74,11 +77,11 @@ class SessionCoordinator {
|
|||||||
final SfxService sfx;
|
final SfxService sfx;
|
||||||
final SyncClient sync = SyncClient();
|
final SyncClient sync = SyncClient();
|
||||||
final PlaybackController playback = PlaybackController();
|
final PlaybackController playback = PlaybackController();
|
||||||
final RoomDiff diff = RoomDiff();
|
final StateDiff diff = StateDiff();
|
||||||
|
|
||||||
final health =
|
final health =
|
||||||
signal(const HealthStatus(HealthSeverity.info, 'idle'));
|
signal(const HealthStatus(HealthSeverity.info, 'idle'));
|
||||||
final room = signal<RoomBroadcast?>(null);
|
final serverState = signal<StateBroadcast?>(null);
|
||||||
final feedLog = signal<List<FeedMessage>>([]);
|
final feedLog = signal<List<FeedMessage>>([]);
|
||||||
final toasts = signal<List<ToastItem>>([]);
|
final toasts = signal<List<ToastItem>>([]);
|
||||||
final reconnecting = signal(false);
|
final reconnecting = signal(false);
|
||||||
@@ -86,7 +89,7 @@ class SessionCoordinator {
|
|||||||
final starving = signal<String?>(null);
|
final starving = signal<String?>(null);
|
||||||
|
|
||||||
late final Computed<MemberView?> selfMember = computed(() {
|
late final Computed<MemberView?> selfMember = computed(() {
|
||||||
final r = room.value;
|
final r = serverState.value;
|
||||||
if (r == null || sync.memberId.isEmpty) return null;
|
if (r == null || sync.memberId.isEmpty) return null;
|
||||||
for (final m in r.members) {
|
for (final m in r.members) {
|
||||||
if (m.id == sync.memberId) return m;
|
if (m.id == sync.memberId) return m;
|
||||||
@@ -95,7 +98,7 @@ class SessionCoordinator {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Timer? _ticker;
|
Timer? _ticker;
|
||||||
StreamSubscription? _roomSub;
|
StreamSubscription? _stateSub;
|
||||||
StreamSubscription? _feedSub;
|
StreamSubscription? _feedSub;
|
||||||
StreamSubscription? _kickSub;
|
StreamSubscription? _kickSub;
|
||||||
StreamSubscription? _errSub;
|
StreamSubscription? _errSub;
|
||||||
@@ -122,7 +125,7 @@ class SessionCoordinator {
|
|||||||
double? _pendingSeekTarget;
|
double? _pendingSeekTarget;
|
||||||
DateTime? _pendingSeekAt;
|
DateTime? _pendingSeekAt;
|
||||||
int _seekRetries = 0;
|
int _seekRetries = 0;
|
||||||
DateTime? _lastRoomAt;
|
DateTime? _lastStateAt;
|
||||||
final IdleTracker _idle = IdleTracker(DateTime.now().toUtc());
|
final IdleTracker _idle = IdleTracker(DateTime.now().toUtc());
|
||||||
int _toastId = 0;
|
int _toastId = 0;
|
||||||
ConnectParams? _params;
|
ConnectParams? _params;
|
||||||
@@ -224,13 +227,14 @@ class SessionCoordinator {
|
|||||||
secret: params.secret,
|
secret: params.secret,
|
||||||
name: params.name,
|
name: params.name,
|
||||||
avatar: params.avatar,
|
avatar: params.avatar,
|
||||||
|
hostKey: params.hostKey,
|
||||||
);
|
);
|
||||||
// Whoever bumped the epoch (cancel/leave) also closed the socket.
|
// Whoever bumped the epoch (cancel/leave) also closed the socket.
|
||||||
if (epoch != _epoch) throw const ConnectCancelled();
|
if (epoch != _epoch) throw const ConnectCancelled();
|
||||||
reconnecting.value = false;
|
reconnecting.value = false;
|
||||||
_everConnected = true;
|
_everConnected = true;
|
||||||
health.value = const HealthStatus(HealthSeverity.ok, 'connected');
|
health.value = const HealthStatus(HealthSeverity.ok, 'connected');
|
||||||
_lastRoomAt = DateTime.now().toUtc();
|
_lastStateAt = DateTime.now().toUtc();
|
||||||
final url = welcome.mediaUrl;
|
final url = welcome.mediaUrl;
|
||||||
if (url != null && url.isNotEmpty) {
|
if (url != null && url.isNotEmpty) {
|
||||||
await _openMedia(url);
|
await _openMedia(url);
|
||||||
@@ -257,7 +261,7 @@ class SessionCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _subscribe() {
|
void _subscribe() {
|
||||||
_roomSub ??= sync.rooms.listen(_onRoom);
|
_stateSub ??= sync.states.listen(_onState);
|
||||||
_feedSub ??= sync.feeds.listen(_onFeed);
|
_feedSub ??= sync.feeds.listen(_onFeed);
|
||||||
_kickSub ??= sync.kicked.listen(_onKicked);
|
_kickSub ??= sync.kicked.listen(_onKicked);
|
||||||
_errSub ??= sync.errors.listen((msg) {
|
_errSub ??= sync.errors.listen((msg) {
|
||||||
@@ -291,14 +295,14 @@ class SessionCoordinator {
|
|||||||
return 'http://$_wsHost:$_wsPort/m/default/${sync.token}/${Uri.encodeComponent(name)}';
|
return 'http://$_wsHost:$_wsPort/m/default/${sync.token}/${Uri.encodeComponent(name)}';
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onRoom(RoomBroadcast r) async {
|
Future<void> _onState(StateBroadcast r) async {
|
||||||
final now = DateTime.now().toUtc();
|
final now = DateTime.now().toUtc();
|
||||||
_lastRoomAt = now;
|
_lastStateAt = now;
|
||||||
room.value = r;
|
serverState.value = r;
|
||||||
_trackSelfReady(r, now);
|
_trackSelfReady(r, now);
|
||||||
if (r.mediaId != _mediaId) {
|
if (r.mediaId != _mediaId) {
|
||||||
_mediaId = r.mediaId;
|
_mediaId = r.mediaId;
|
||||||
var url = roomMediaUrl(r);
|
var url = stateMediaUrl(r);
|
||||||
if ((url == null || url.isEmpty) && sync.token.isNotEmpty) {
|
if ((url == null || url.isEmpty) && sync.token.isNotEmpty) {
|
||||||
final name = (r.media?['name'] as String?) ?? '';
|
final name = (r.media?['name'] as String?) ?? '';
|
||||||
if (name.isNotEmpty) url = _fileUrlFor(name);
|
if (name.isNotEmpty) url = _fileUrlFor(name);
|
||||||
@@ -325,7 +329,7 @@ class SessionCoordinator {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (allowed && !_wasPlayAllowed && r.transport == 'playing') {
|
if (allowed && !_wasPlayAllowed && r.transport == 'playing') {
|
||||||
final edge = roomPositionAt(r, now);
|
final edge = serverPositionAt(r, now);
|
||||||
actions = [
|
actions = [
|
||||||
for (final a in actions) if (a is! TransportAction) a,
|
for (final a in actions) if (a is! TransportAction) a,
|
||||||
SeekAction(edge),
|
SeekAction(edge),
|
||||||
@@ -347,7 +351,7 @@ class SessionCoordinator {
|
|||||||
|
|
||||||
/// Mirrors the server's ready flag for self into [_lastReady] (force
|
/// Mirrors the server's ready flag for self into [_lastReady] (force
|
||||||
/// by others included) and re-enters the set after a stale spell.
|
/// by others included) and re-enters the set after a stale spell.
|
||||||
void _trackSelfReady(RoomBroadcast r, DateTime now) {
|
void _trackSelfReady(StateBroadcast r, DateTime now) {
|
||||||
final self = selfMember.peek();
|
final self = selfMember.peek();
|
||||||
if (self == null) return;
|
if (self == null) return;
|
||||||
if (r.mediaId != _mediaId) _restoringReady = false;
|
if (r.mediaId != _mediaId) _restoringReady = false;
|
||||||
@@ -364,7 +368,7 @@ class SessionCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateStarving(RoomBroadcast r, double bufferedAhead) {
|
void _updateStarving(StateBroadcast r, double bufferedAhead) {
|
||||||
if (r.transport == 'playing') {
|
if (r.transport == 'playing') {
|
||||||
starving.value = null;
|
starving.value = null;
|
||||||
return;
|
return;
|
||||||
@@ -418,7 +422,7 @@ class SessionCoordinator {
|
|||||||
if (kind != null) {
|
if (kind != null) {
|
||||||
var readyIndex = 0;
|
var readyIndex = 0;
|
||||||
if (f.action == 'ready') {
|
if (f.action == 'ready') {
|
||||||
readyIndex = room.peek()?.members.where((m) => m.ready).length ?? 0;
|
readyIndex = serverState.peek()?.members.where((m) => m.ready).length ?? 0;
|
||||||
}
|
}
|
||||||
unawaited(sfx.play(SfxCue(kind, readyIndex: readyIndex)));
|
unawaited(sfx.play(SfxCue(kind, readyIndex: readyIndex)));
|
||||||
}
|
}
|
||||||
@@ -469,7 +473,7 @@ class SessionCoordinator {
|
|||||||
final now = DateTime.now().toUtc();
|
final now = DateTime.now().toUtc();
|
||||||
toasts.value =
|
toasts.value =
|
||||||
toasts.peek().where((t) => now.difference(t.at).inSeconds < 4).toList();
|
toasts.peek().where((t) => now.difference(t.at).inSeconds < 4).toList();
|
||||||
final r = room.peek();
|
final r = serverState.peek();
|
||||||
if (sync.token.isNotEmpty && r != null && _playerReady) {
|
if (sync.token.isNotEmpty && r != null && _playerReady) {
|
||||||
sync.sendReport(
|
sync.sendReport(
|
||||||
position: playback.positionSecs,
|
position: playback.positionSecs,
|
||||||
@@ -481,10 +485,10 @@ class SessionCoordinator {
|
|||||||
}
|
}
|
||||||
if (!_manualClose &&
|
if (!_manualClose &&
|
||||||
kickReason.peek() == null &&
|
kickReason.peek() == null &&
|
||||||
_lastRoomAt != null &&
|
_lastStateAt != null &&
|
||||||
now.difference(_lastRoomAt!).inSeconds >= 5) {
|
now.difference(_lastStateAt!).inSeconds >= 5) {
|
||||||
if (room.peek() != null) {
|
if (serverState.peek() != null) {
|
||||||
room.value = null;
|
serverState.value = null;
|
||||||
starving.value = null;
|
starving.value = null;
|
||||||
_wasPlayAllowed = false;
|
_wasPlayAllowed = false;
|
||||||
_mediaId = -1;
|
_mediaId = -1;
|
||||||
@@ -502,7 +506,7 @@ class SessionCoordinator {
|
|||||||
// PLAYING: the ready flag gates nothing then, and watching counts as
|
// PLAYING: the ready flag gates nothing then, and watching counts as
|
||||||
// activity so a pause after a long watch starts a fresh idle clock.
|
// activity so a pause after a long watch starts a fresh idle clock.
|
||||||
final event = _idle.tick(now,
|
final event = _idle.tick(now,
|
||||||
playing: room.peek()?.transport == 'playing',
|
playing: serverState.peek()?.transport == 'playing',
|
||||||
selfReady: selfMember.peek()?.ready ?? false);
|
selfReady: selfMember.peek()?.ready ?? false);
|
||||||
switch (event) {
|
switch (event) {
|
||||||
case IdleEvent.warn:
|
case IdleEvent.warn:
|
||||||
@@ -523,7 +527,7 @@ class SessionCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _detectUserSeek(RoomBroadcast r) {
|
void _detectUserSeek(StateBroadcast r) {
|
||||||
if (_openUrl == null) {
|
if (_openUrl == null) {
|
||||||
_lastPos = playback.positionSecs;
|
_lastPos = playback.positionSecs;
|
||||||
return;
|
return;
|
||||||
@@ -533,7 +537,7 @@ class SessionCoordinator {
|
|||||||
final jumped = (pos - _lastPos).abs();
|
final jumped = (pos - _lastPos).abs();
|
||||||
_lastPos = pos;
|
_lastPos = pos;
|
||||||
if (jumped <= 2.0) return;
|
if (jumped <= 2.0) return;
|
||||||
if ((pos - roomPositionAt(r, now)).abs() < 2.0) return;
|
if ((pos - serverPositionAt(r, now)).abs() < 2.0) return;
|
||||||
final recentSync = _pendingSeekAt != null &&
|
final recentSync = _pendingSeekAt != null &&
|
||||||
now.difference(_pendingSeekAt!).inMilliseconds < 1000;
|
now.difference(_pendingSeekAt!).inMilliseconds < 1000;
|
||||||
if (recentSync) return;
|
if (recentSync) return;
|
||||||
@@ -541,7 +545,7 @@ class SessionCoordinator {
|
|||||||
sync.sendSeek(pos);
|
sync.sendSeek(pos);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _checkSeekLanded(RoomBroadcast r) async {
|
Future<void> _checkSeekLanded(StateBroadcast r) async {
|
||||||
final target = _pendingSeekTarget;
|
final target = _pendingSeekTarget;
|
||||||
final at = _pendingSeekAt;
|
final at = _pendingSeekAt;
|
||||||
if (target == null || at == null) return;
|
if (target == null || at == null) return;
|
||||||
@@ -589,10 +593,10 @@ class SessionCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void nudge(double deltaSecs) {
|
void nudge(double deltaSecs) {
|
||||||
final r = room.peek();
|
final r = serverState.peek();
|
||||||
if (r == null || _openUrl == null) return;
|
if (r == null || _openUrl == null) return;
|
||||||
final target =
|
final target =
|
||||||
roomPositionAt(r, DateTime.now().toUtc()) + deltaSecs;
|
serverPositionAt(r, DateTime.now().toUtc()) + deltaSecs;
|
||||||
if (target.isNaN || target < 0) return;
|
if (target.isNaN || target < 0) return;
|
||||||
markInput();
|
markInput();
|
||||||
sync.sendSeek(target);
|
sync.sendSeek(target);
|
||||||
@@ -651,12 +655,13 @@ class SessionCoordinator {
|
|||||||
secret: params.secret,
|
secret: params.secret,
|
||||||
name: params.name,
|
name: params.name,
|
||||||
avatar: params.avatar,
|
avatar: params.avatar,
|
||||||
|
hostKey: params.hostKey,
|
||||||
);
|
);
|
||||||
// Whoever bumped the epoch also closed (or replaced) the socket.
|
// Whoever bumped the epoch also closed (or replaced) the socket.
|
||||||
if (stale()) return;
|
if (stale()) return;
|
||||||
reconnecting.value = false;
|
reconnecting.value = false;
|
||||||
_everConnected = true;
|
_everConnected = true;
|
||||||
_lastRoomAt = DateTime.now().toUtc();
|
_lastStateAt = DateTime.now().toUtc();
|
||||||
health.value = const HealthStatus(HealthSeverity.ok, 'connected');
|
health.value = const HealthStatus(HealthSeverity.ok, 'connected');
|
||||||
final url = welcome.mediaUrl;
|
final url = welcome.mediaUrl;
|
||||||
if (url != null && url.isNotEmpty && url != _openUrl) {
|
if (url != null && url.isNotEmpty && url != _openUrl) {
|
||||||
@@ -743,7 +748,7 @@ class SessionCoordinator {
|
|||||||
await sync.disconnect();
|
await sync.disconnect();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
room.value = null;
|
serverState.value = null;
|
||||||
starving.value = null;
|
starving.value = null;
|
||||||
health.value = const HealthStatus(HealthSeverity.info, 'idle');
|
health.value = const HealthStatus(HealthSeverity.info, 'idle');
|
||||||
}
|
}
|
||||||
@@ -751,7 +756,7 @@ class SessionCoordinator {
|
|||||||
|
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
await leave(silent: true);
|
await leave(silent: true);
|
||||||
await _roomSub?.cancel();
|
await _stateSub?.cancel();
|
||||||
await _feedSub?.cancel();
|
await _feedSub?.cancel();
|
||||||
await _kickSub?.cancel();
|
await _kickSub?.cancel();
|
||||||
await _errSub?.cancel();
|
await _errSub?.cancel();
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:isolate';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
import 'package:sync_relay_server/server.dart';
|
||||||
|
|
||||||
|
Future<void> _serverMain((Map<String, Object?>, SendPort) args) =>
|
||||||
|
serve(args.$1, events: args.$2);
|
||||||
|
|
||||||
|
/// The hosted server, running [serve] on its own isolate. Its log lines
|
||||||
|
/// go to [onLog]; if the isolate dies without being asked to stop,
|
||||||
|
/// [onCrash] gets the cause once.
|
||||||
|
class ServerIsolate {
|
||||||
|
final Isolate _isolate;
|
||||||
|
final SendPort _stop;
|
||||||
|
final int port;
|
||||||
|
final ReceivePort _events;
|
||||||
|
final ReceivePort _errors;
|
||||||
|
final ReceivePort _exit;
|
||||||
|
final Completer<void> _exited = Completer<void>();
|
||||||
|
bool _stopping = false;
|
||||||
|
String? _lastError;
|
||||||
|
void Function(String error)? _onCrash;
|
||||||
|
|
||||||
|
ServerIsolate._(this._isolate, this._stop, this.port, this._events,
|
||||||
|
this._errors, this._exit);
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
Isolate get isolate => _isolate;
|
||||||
|
|
||||||
|
bool get isRunning => !_exited.isCompleted;
|
||||||
|
|
||||||
|
/// Spawns the server and waits until it listens. Throws a
|
||||||
|
/// [StateError] with the server's message if it cannot start.
|
||||||
|
static Future<ServerIsolate> start(
|
||||||
|
AppConfig config, {
|
||||||
|
required void Function(String line) onLog,
|
||||||
|
required void Function(String error) onCrash,
|
||||||
|
Duration timeout = const Duration(seconds: 15),
|
||||||
|
}) async {
|
||||||
|
final events = ReceivePort('server events');
|
||||||
|
final errors = ReceivePort('server errors');
|
||||||
|
final exit = ReceivePort('server exit');
|
||||||
|
final startup = Completer<(int, SendPort)>();
|
||||||
|
String? lastError;
|
||||||
|
var exited = false;
|
||||||
|
ServerIsolate? self;
|
||||||
|
|
||||||
|
void fail(String message) {
|
||||||
|
if (!startup.isCompleted) startup.completeError(StateError(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
events.listen((msg) {
|
||||||
|
if (msg is! Map) return;
|
||||||
|
switch (msg['t']) {
|
||||||
|
case 'log':
|
||||||
|
final err = msg['error'];
|
||||||
|
onLog('${msg['level']} ${msg['logger']}: ${msg['message']}'
|
||||||
|
'${err == null ? '' : ' ($err)'}');
|
||||||
|
case 'ready':
|
||||||
|
if (!startup.isCompleted) {
|
||||||
|
startup.complete((msg['port'] as int, msg['stop'] as SendPort));
|
||||||
|
}
|
||||||
|
case 'error':
|
||||||
|
fail('${msg['message']}');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// errorsAreFatal is off: a stray async error is logged and the server
|
||||||
|
// keeps going, as it did in-process. Only a dead isolate is a crash.
|
||||||
|
errors.listen((msg) {
|
||||||
|
final text = msg is List && msg.isNotEmpty ? '${msg.first}' : '$msg';
|
||||||
|
lastError = text;
|
||||||
|
self?._lastError = text;
|
||||||
|
onLog('SEVERE uncaught: $text');
|
||||||
|
});
|
||||||
|
exit.listen((_) {
|
||||||
|
exited = true;
|
||||||
|
fail(lastError ?? 'server exited before it was ready');
|
||||||
|
self?._onExit();
|
||||||
|
});
|
||||||
|
|
||||||
|
final Isolate isolate;
|
||||||
|
try {
|
||||||
|
isolate = await Isolate.spawn(
|
||||||
|
_serverMain, (config.toMap(), events.sendPort),
|
||||||
|
errorsAreFatal: false,
|
||||||
|
onError: errors.sendPort,
|
||||||
|
onExit: exit.sendPort,
|
||||||
|
debugName: 'server');
|
||||||
|
} catch (_) {
|
||||||
|
events.close();
|
||||||
|
errors.close();
|
||||||
|
exit.close();
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
final (int, SendPort) ready;
|
||||||
|
try {
|
||||||
|
ready = await startup.future.timeout(timeout);
|
||||||
|
} catch (e) {
|
||||||
|
if (!exited) isolate.kill(priority: Isolate.immediate);
|
||||||
|
events.close();
|
||||||
|
errors.close();
|
||||||
|
exit.close();
|
||||||
|
if (e is TimeoutException) {
|
||||||
|
throw StateError('server did not start within ${timeout.inSeconds} s');
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
final s = ServerIsolate._(isolate, ready.$2, ready.$1, events, errors, exit)
|
||||||
|
.._onCrash = onCrash
|
||||||
|
.._lastError = lastError;
|
||||||
|
self = s;
|
||||||
|
if (exited) {
|
||||||
|
// Died between ready and here: a failed start, not a crash.
|
||||||
|
s._stopping = true;
|
||||||
|
s._onExit();
|
||||||
|
throw StateError(lastError ?? 'server exited right after starting');
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onExit() {
|
||||||
|
if (_exited.isCompleted) return;
|
||||||
|
_exited.complete();
|
||||||
|
_events.close();
|
||||||
|
_errors.close();
|
||||||
|
_exit.close();
|
||||||
|
final crash = _onCrash;
|
||||||
|
_onCrash = null;
|
||||||
|
if (!_stopping) crash?.call(_lastError ?? 'the server stopped unexpectedly');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks the server to close its sockets and exit; kills the isolate if it
|
||||||
|
/// has not exited within [timeout].
|
||||||
|
Future<void> stop({Duration timeout = const Duration(seconds: 3)}) async {
|
||||||
|
if (_exited.isCompleted) return;
|
||||||
|
_stopping = true;
|
||||||
|
_stop.send({'t': 'stop'});
|
||||||
|
try {
|
||||||
|
await _exited.future.timeout(timeout);
|
||||||
|
} on TimeoutException {
|
||||||
|
_isolate.kill(priority: Isolate.immediate);
|
||||||
|
await _exited.future.timeout(const Duration(seconds: 2),
|
||||||
|
onTimeout: _onExit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:signals/signals_flutter.dart';
|
import 'package:signals/signals_flutter.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:sync_relay_server/server.dart';
|
|
||||||
|
|
||||||
import '../sfx/sfx.dart';
|
import '../sfx/sfx.dart';
|
||||||
import '../session/health.dart';
|
import '../session/health.dart';
|
||||||
import '../session/session_coordinator.dart';
|
import '../session/session_coordinator.dart';
|
||||||
import '../widgets/avatar_pipeline.dart';
|
import '../widgets/avatar_pipeline.dart';
|
||||||
import 'connect_prefs.dart';
|
import 'connect_prefs.dart';
|
||||||
|
import 'server_isolate.dart';
|
||||||
|
|
||||||
enum AppRoute {
|
enum AppRoute {
|
||||||
home,
|
home,
|
||||||
@@ -128,6 +129,21 @@ List<RecentServer> migrateRecents(
|
|||||||
return [RecentServer(hosted: false, host: host.trim(), port: port, secret: secret)];
|
return [RecentServer(hosted: false, host: host.trim(), port: port, secret: secret)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The hosted server died during setup; shown on the Host card.
|
||||||
|
class ServerCrashed implements Exception {
|
||||||
|
final String cause;
|
||||||
|
const ServerCrashed(this.cause);
|
||||||
|
@override
|
||||||
|
String toString() => 'server crashed: $cause';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fresh per-hosting key: the app's own client sends it in hello to be
|
||||||
|
/// recognised as the (unkickable) host.
|
||||||
|
String newHostKey() {
|
||||||
|
final r = Random.secure();
|
||||||
|
return base64Url.encode([for (var i = 0; i < 24; i++) r.nextInt(256)]);
|
||||||
|
}
|
||||||
|
|
||||||
class SessionState {
|
class SessionState {
|
||||||
static final SessionState instance = SessionState._();
|
static final SessionState instance = SessionState._();
|
||||||
SessionState._();
|
SessionState._();
|
||||||
@@ -135,11 +151,17 @@ class SessionState {
|
|||||||
final route = signal(AppRoute.home);
|
final route = signal(AppRoute.home);
|
||||||
final recents = signal<List<RecentServer>>(const []);
|
final recents = signal<List<RecentServer>>(const []);
|
||||||
final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
|
final status = signal(const HealthStatus(HealthSeverity.info, 'idle'));
|
||||||
final room = signal<RoomBroadcast?>(null);
|
|
||||||
final SfxService sfx = const NoopSfxService();
|
final SfxService sfx = const NoopSfxService();
|
||||||
late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx);
|
late final SessionCoordinator coordinator = SessionCoordinator(sfx: sfx);
|
||||||
final lastForm = ConnectForm();
|
final lastForm = ConnectForm();
|
||||||
RoomServer? _server;
|
/// Set when the hosted server died mid-session; Home shows it on the
|
||||||
|
/// Host card and clears it.
|
||||||
|
final serverCrash = signal<String?>(null);
|
||||||
|
ServerIsolate? _server;
|
||||||
|
String? _setupCrash;
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
ServerIsolate? get hostedServer => _server;
|
||||||
void Function()? _stopKickWatch;
|
void Function()? _stopKickWatch;
|
||||||
// Bumped by host/join/cancelPending: a cancelled setup notices at its
|
// Bumped by host/join/cancelPending: a cancelled setup notices at its
|
||||||
// next step and unwinds instead of connecting.
|
// next step and unwinds instead of connecting.
|
||||||
@@ -208,18 +230,25 @@ class SessionState {
|
|||||||
const HealthStatus(HealthSeverity.info, 'starting server…');
|
const HealthStatus(HealthSeverity.info, 'starting server…');
|
||||||
await stopServer();
|
await stopServer();
|
||||||
if (op != _op) throw const ConnectCancelled();
|
if (op != _op) throw const ConnectCancelled();
|
||||||
final server = RoomServer(AppConfig(
|
final hostKey = newHostKey();
|
||||||
|
_setupCrash = null;
|
||||||
|
late final ServerIsolate server;
|
||||||
|
try {
|
||||||
|
server = await ServerIsolate.start(
|
||||||
|
AppConfig(
|
||||||
port: params.port,
|
port: params.port,
|
||||||
secret: params.secret,
|
secret: params.secret,
|
||||||
name: 'room',
|
name: 'server',
|
||||||
mediaPath: params.mediaPath,
|
mediaPath: params.mediaPath,
|
||||||
));
|
hostKey: hostKey,
|
||||||
try {
|
),
|
||||||
await server.start();
|
onLog: (line) => debugPrint('[server] $line'),
|
||||||
|
onCrash: (cause) => _onServerCrash(server, cause),
|
||||||
|
);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
debugPrint('server start failed: $e\n$st');
|
debugPrint('server start failed: $e\n$st');
|
||||||
status.value = HealthStatus(
|
status.value = HealthStatus(
|
||||||
HealthSeverity.error, 'cannot bind port ${params.port}: $e');
|
HealthSeverity.error, 'cannot start server on port ${params.port}: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
if (op != _op) {
|
if (op != _op) {
|
||||||
@@ -228,20 +257,23 @@ class SessionState {
|
|||||||
}
|
}
|
||||||
_server = server;
|
_server = server;
|
||||||
status.value =
|
status.value =
|
||||||
HealthStatus(HealthSeverity.ok, 'hosting on port ${server.boundPort}');
|
HealthStatus(HealthSeverity.ok, 'hosting on port ${server.port}');
|
||||||
try {
|
try {
|
||||||
await coordinator.connect(ConnectParams(
|
await coordinator.connect(ConnectParams(
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: server.boundPort,
|
port: server.port,
|
||||||
secret: params.secret,
|
secret: params.secret,
|
||||||
name: params.name,
|
name: params.name,
|
||||||
avatar: params.avatar,
|
avatar: params.avatar,
|
||||||
linkUrl: params.linkUrl,
|
linkUrl: params.linkUrl,
|
||||||
|
hostKey: hostKey,
|
||||||
));
|
));
|
||||||
if (op != _op) throw const ConnectCancelled();
|
if (op != _op) throw const ConnectCancelled();
|
||||||
} catch (_) {
|
} catch (e) {
|
||||||
// Own join failed or was cancelled: don't leave the port open.
|
// Own join failed or was cancelled: don't leave the port open.
|
||||||
if (identical(_server, server)) await stopServer();
|
if (identical(_server, server)) await stopServer();
|
||||||
|
final crash = _setupCrash;
|
||||||
|
if (crash != null && e is! ConnectCancelled) throw ServerCrashed(crash);
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
_watchKick();
|
_watchKick();
|
||||||
@@ -251,6 +283,21 @@ class SessionState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The server isolate died without being stopped. In a session: show it
|
||||||
|
/// and leave. During setup the own join fails and [host] reports it.
|
||||||
|
void _onServerCrash(ServerIsolate server, String cause) {
|
||||||
|
if (!identical(_server, server)) return;
|
||||||
|
final message = 'server crashed: $cause';
|
||||||
|
debugPrint('[server] $message');
|
||||||
|
if (route.peek() != AppRoute.session) {
|
||||||
|
_setupCrash = cause;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
coordinator.health.value = HealthStatus(HealthSeverity.error, message);
|
||||||
|
serverCrash.value = message;
|
||||||
|
unawaited(leave().catchError((Object e) => debugPrint('leave after crash failed: $e')));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> join(ConnectParams params, ConnectForm form) async {
|
Future<void> join(ConnectParams params, ConnectForm form) async {
|
||||||
final op = ++_op;
|
final op = ++_op;
|
||||||
_rememberForm(form);
|
_rememberForm(form);
|
||||||
@@ -305,15 +352,6 @@ class SessionState {
|
|||||||
..linkUrl = form.linkUrl;
|
..linkUrl = form.linkUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> startHeadless(List<String> args) async {
|
|
||||||
int port = 8080;
|
|
||||||
for (var i = 0; i < args.length - 1; i++) {
|
|
||||||
if (args[i] == '--port') port = int.tryParse(args[i + 1]) ?? port;
|
|
||||||
}
|
|
||||||
_server = RoomServer(AppConfig(port: port, secret: '', name: 'room'));
|
|
||||||
await _server!.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> stopServer() async {
|
Future<void> stopServer() async {
|
||||||
_stopKickWatch?.call();
|
_stopKickWatch?.call();
|
||||||
_stopKickWatch = null;
|
_stopKickWatch = null;
|
||||||
|
|||||||
+32
-32
@@ -19,9 +19,9 @@ class RateAction extends PlayerAction {
|
|||||||
const RateAction(this.rate);
|
const RateAction(this.rate);
|
||||||
}
|
}
|
||||||
|
|
||||||
double roomPositionAt(RoomBroadcast room, DateTime now) {
|
double serverPositionAt(StateBroadcast state, DateTime now) {
|
||||||
if (room.transport != 'playing') return room.position;
|
if (state.transport != 'playing') return state.position;
|
||||||
return room.position + now.difference(room.serverTime).inMilliseconds / 1000.0;
|
return state.position + now.difference(state.serverTime).inMilliseconds / 1000.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool seekLanded({required double ownPosition, required double target}) {
|
bool seekLanded({required double ownPosition, required double target}) {
|
||||||
@@ -31,9 +31,9 @@ bool seekLanded({required double ownPosition, required double target}) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool localPlayAllowed(RoomBroadcast room, String memberId) {
|
bool localPlayAllowed(StateBroadcast state, String memberId) {
|
||||||
if (memberId.isEmpty) return false;
|
if (memberId.isEmpty) return false;
|
||||||
for (final m in room.members) {
|
for (final m in state.members) {
|
||||||
if (m.id == memberId) return m.inSet && m.ready;
|
if (m.id == memberId) return m.inSet && m.ready;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -45,7 +45,7 @@ bool localPlayAllowed(RoomBroadcast room, String memberId) {
|
|||||||
bool shouldReassertReady(MemberView? self) =>
|
bool shouldReassertReady(MemberView? self) =>
|
||||||
self != null && self.ready && !self.inSet && !self.stale;
|
self != null && self.ready && !self.inSet && !self.stale;
|
||||||
|
|
||||||
class RoomDiff {
|
class StateDiff {
|
||||||
static const debounceWindow = Duration(seconds: 2);
|
static const debounceWindow = Duration(seconds: 2);
|
||||||
|
|
||||||
int _lastEventId = -1;
|
int _lastEventId = -1;
|
||||||
@@ -57,14 +57,14 @@ class RoomDiff {
|
|||||||
|
|
||||||
bool get slowing => _slowing;
|
bool get slowing => _slowing;
|
||||||
|
|
||||||
List<PlayerAction> diff(RoomBroadcast room, double ownPosition, DateTime now) {
|
List<PlayerAction> diff(StateBroadcast state, double ownPosition, DateTime now) {
|
||||||
final out = <PlayerAction>[];
|
final out = <PlayerAction>[];
|
||||||
final roomPos = roomPositionAt(room, now);
|
final serverPos = serverPositionAt(state, now);
|
||||||
|
|
||||||
if (room.mediaId != _lastMediaId && _lastMediaId != -1) {
|
if (state.mediaId != _lastMediaId && _lastMediaId != -1) {
|
||||||
_lastEventId = room.eventId;
|
_lastEventId = state.eventId;
|
||||||
_lastTransport = room.transport;
|
_lastTransport = state.transport;
|
||||||
_lastMediaId = room.mediaId;
|
_lastMediaId = state.mediaId;
|
||||||
_slowing = false;
|
_slowing = false;
|
||||||
_deviatingSince = null;
|
_deviatingSince = null;
|
||||||
out.add(const SeekAction(0));
|
out.add(const SeekAction(0));
|
||||||
@@ -74,39 +74,39 @@ class RoomDiff {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_lastEventId == -1) {
|
if (_lastEventId == -1) {
|
||||||
_lastEventId = room.eventId;
|
_lastEventId = state.eventId;
|
||||||
_lastTransport = room.transport;
|
_lastTransport = state.transport;
|
||||||
_lastMediaId = room.mediaId;
|
_lastMediaId = state.mediaId;
|
||||||
_deviatingSince = null;
|
_deviatingSince = null;
|
||||||
out.add(SeekAction(roomPos));
|
out.add(SeekAction(serverPos));
|
||||||
out.add(TransportAction(room.transport == 'playing'));
|
out.add(TransportAction(state.transport == 'playing'));
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (room.eventId != _lastEventId) {
|
if (state.eventId != _lastEventId) {
|
||||||
_lastEventId = room.eventId;
|
_lastEventId = state.eventId;
|
||||||
_slowing = false;
|
_slowing = false;
|
||||||
_deviatingSince = null;
|
_deviatingSince = null;
|
||||||
out.add(const RateAction(1.0));
|
out.add(const RateAction(1.0));
|
||||||
out.add(SeekAction(roomPos));
|
out.add(SeekAction(serverPos));
|
||||||
if (room.transport != _lastTransport) {
|
if (state.transport != _lastTransport) {
|
||||||
_lastTransport = room.transport;
|
_lastTransport = state.transport;
|
||||||
out.add(TransportAction(room.transport == 'playing'));
|
out.add(TransportAction(state.transport == 'playing'));
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (room.transport != _lastTransport) {
|
if (state.transport != _lastTransport) {
|
||||||
_lastTransport = room.transport;
|
_lastTransport = state.transport;
|
||||||
_slowing = false;
|
_slowing = false;
|
||||||
_deviatingSince = null;
|
_deviatingSince = null;
|
||||||
out.add(const RateAction(1.0));
|
out.add(const RateAction(1.0));
|
||||||
out.add(TransportAction(room.transport == 'playing'));
|
out.add(TransportAction(state.transport == 'playing'));
|
||||||
out.add(SeekAction(roomPos));
|
out.add(SeekAction(serverPos));
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
final drift = ownPosition - roomPos;
|
final drift = ownPosition - serverPos;
|
||||||
if (_slowing) {
|
if (_slowing) {
|
||||||
if (drift.abs() < 0.1) {
|
if (drift.abs() < 0.1) {
|
||||||
_slowing = false;
|
_slowing = false;
|
||||||
@@ -114,10 +114,10 @@ class RoomDiff {
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
switch (correctionFor(ownPosition: ownPosition, roomPosition: roomPos)) {
|
switch (correctionFor(ownPosition: ownPosition, serverPosition: serverPos)) {
|
||||||
case Correction.seek:
|
case Correction.seek:
|
||||||
if (_deviatingEvent != room.eventId || _deviatingSince == null) {
|
if (_deviatingEvent != state.eventId || _deviatingSince == null) {
|
||||||
_deviatingEvent = room.eventId;
|
_deviatingEvent = state.eventId;
|
||||||
_deviatingSince = now;
|
_deviatingSince = now;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -125,7 +125,7 @@ class RoomDiff {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
_deviatingSince = null;
|
_deviatingSince = null;
|
||||||
out.add(SeekAction(roomPos));
|
out.add(SeekAction(serverPos));
|
||||||
case Correction.slowDown:
|
case Correction.slowDown:
|
||||||
_deviatingSince = null;
|
_deviatingSince = null;
|
||||||
_slowing = true;
|
_slowing = true;
|
||||||
@@ -9,13 +9,13 @@ enum SyncStatus { idle, connecting, open, kicked, closed, error }
|
|||||||
class SyncClient {
|
class SyncClient {
|
||||||
WebSocketChannel? _channel;
|
WebSocketChannel? _channel;
|
||||||
StreamSubscription? _sub;
|
StreamSubscription? _sub;
|
||||||
final StreamController<RoomBroadcast> _rooms = StreamController.broadcast();
|
final StreamController<StateBroadcast> _states = StreamController.broadcast();
|
||||||
final StreamController<FeedMessage> _feeds = StreamController.broadcast();
|
final StreamController<FeedMessage> _feeds = StreamController.broadcast();
|
||||||
final StreamController<String> _kicked = StreamController.broadcast();
|
final StreamController<String> _kicked = StreamController.broadcast();
|
||||||
final StreamController<String> _errors = StreamController.broadcast();
|
final StreamController<String> _errors = StreamController.broadcast();
|
||||||
final StreamController<SyncStatus> _status = StreamController.broadcast();
|
final StreamController<SyncStatus> _status = StreamController.broadcast();
|
||||||
|
|
||||||
Stream<RoomBroadcast> get rooms => _rooms.stream;
|
Stream<StateBroadcast> get states => _states.stream;
|
||||||
Stream<FeedMessage> get feeds => _feeds.stream;
|
Stream<FeedMessage> get feeds => _feeds.stream;
|
||||||
Stream<String> get kicked => _kicked.stream;
|
Stream<String> get kicked => _kicked.stream;
|
||||||
Stream<String> get errors => _errors.stream;
|
Stream<String> get errors => _errors.stream;
|
||||||
@@ -24,7 +24,7 @@ class SyncClient {
|
|||||||
String token = '';
|
String token = '';
|
||||||
String memberId = '';
|
String memberId = '';
|
||||||
String? mediaUrl;
|
String? mediaUrl;
|
||||||
RoomBroadcast? lastRoom;
|
StateBroadcast? lastState;
|
||||||
Completer<WelcomeMessage>? _pendingWelcome;
|
Completer<WelcomeMessage>? _pendingWelcome;
|
||||||
|
|
||||||
Future<WelcomeMessage> connect({
|
Future<WelcomeMessage> connect({
|
||||||
@@ -33,6 +33,7 @@ class SyncClient {
|
|||||||
required String name,
|
required String name,
|
||||||
String avatar = '',
|
String avatar = '',
|
||||||
HelloMedia? media,
|
HelloMedia? media,
|
||||||
|
String hostKey = '',
|
||||||
}) async {
|
}) async {
|
||||||
await disconnect();
|
await disconnect();
|
||||||
_status.add(SyncStatus.connecting);
|
_status.add(SyncStatus.connecting);
|
||||||
@@ -61,6 +62,7 @@ class SyncClient {
|
|||||||
secret: secret,
|
secret: secret,
|
||||||
user: HelloUser(name: name, avatar: avatar),
|
user: HelloUser(name: name, avatar: avatar),
|
||||||
media: media ?? const HelloMedia(name: '', size: 0, duration: 0),
|
media: media ?? const HelloMedia(name: '', size: 0, duration: 0),
|
||||||
|
hostKey: hostKey,
|
||||||
);
|
);
|
||||||
ch.sink.add(jsonEncode(hello.toJson()));
|
ch.sink.add(jsonEncode(hello.toJson()));
|
||||||
_pendingWelcome = welcomeCompleter;
|
_pendingWelcome = welcomeCompleter;
|
||||||
@@ -74,8 +76,8 @@ class SyncClient {
|
|||||||
mediaUrl = welcome.mediaUrl;
|
mediaUrl = welcome.mediaUrl;
|
||||||
if (welcome.state != null) {
|
if (welcome.state != null) {
|
||||||
try {
|
try {
|
||||||
lastRoom = RoomBroadcast.fromJson(welcome.state!);
|
lastState = StateBroadcast.fromJson(welcome.state!);
|
||||||
_rooms.add(lastRoom!);
|
_states.add(lastState!);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
_status.add(SyncStatus.open);
|
_status.add(SyncStatus.open);
|
||||||
@@ -113,11 +115,11 @@ class SyncClient {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
switch (t) {
|
switch (t) {
|
||||||
case 'room':
|
case 'state':
|
||||||
try {
|
try {
|
||||||
final room = RoomBroadcast.fromJson(decoded);
|
final state = StateBroadcast.fromJson(decoded);
|
||||||
lastRoom = room;
|
lastState = state;
|
||||||
_rooms.add(room);
|
_states.add(state);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
case 'feed':
|
case 'feed':
|
||||||
try {
|
try {
|
||||||
@@ -219,12 +221,12 @@ class SyncClient {
|
|||||||
token = '';
|
token = '';
|
||||||
memberId = '';
|
memberId = '';
|
||||||
mediaUrl = null;
|
mediaUrl = null;
|
||||||
lastRoom = null;
|
lastState = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
await disconnect();
|
await disconnect();
|
||||||
await _rooms.close();
|
await _states.close();
|
||||||
await _feeds.close();
|
await _feeds.close();
|
||||||
await _kicked.close();
|
await _kicked.close();
|
||||||
await _errors.close();
|
await _errors.close();
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ Future<void> _poll(
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test(
|
test(
|
||||||
'dead server clears room state and reconnects instead of freezing',
|
'dead server clears server state and reconnects instead of freezing',
|
||||||
timeout: const Timeout(Duration(minutes: 2)),
|
timeout: const Timeout(Duration(minutes: 2)),
|
||||||
() async {
|
() async {
|
||||||
MediaKit.ensureInitialized();
|
MediaKit.ensureInitialized();
|
||||||
final server = RoomServer(
|
final server = SyncServer(
|
||||||
const AppConfig(port: 0, secret: 'presence', name: 'presence'));
|
const AppConfig(port: 0, secret: 'presence', name: 'presence'));
|
||||||
await server.start();
|
await server.start();
|
||||||
final coord = SessionCoordinator();
|
final coord = SessionCoordinator();
|
||||||
@@ -41,7 +41,7 @@ void main() {
|
|||||||
name: 'watcher',
|
name: 'watcher',
|
||||||
));
|
));
|
||||||
await coord.playback.player.setVolume(0);
|
await coord.playback.player.setVolume(0);
|
||||||
await _poll(() => coord.room.peek() != null, 'room state to arrive');
|
await _poll(() => coord.serverState.peek() != null, 'server state to arrive');
|
||||||
expect(coord.reconnecting.peek(), isFalse);
|
expect(coord.reconnecting.peek(), isFalse);
|
||||||
|
|
||||||
await server.stop();
|
await server.stop();
|
||||||
@@ -50,8 +50,8 @@ void main() {
|
|||||||
'reconnect loop to engage',
|
'reconnect loop to engage',
|
||||||
);
|
);
|
||||||
await _poll(
|
await _poll(
|
||||||
() => coord.room.peek() == null,
|
() => coord.serverState.peek() == null,
|
||||||
'stale room (ghost members) to clear',
|
'stale state (ghost members) to clear',
|
||||||
);
|
);
|
||||||
coord.cancelReconnect();
|
coord.cancelReconnect();
|
||||||
expect(coord.reconnecting.peek(), isFalse);
|
expect(coord.reconnecting.peek(), isFalse);
|
||||||
@@ -66,12 +66,12 @@ void main() {
|
|||||||
timeout: const Timeout(Duration(minutes: 2)),
|
timeout: const Timeout(Duration(minutes: 2)),
|
||||||
() async {
|
() async {
|
||||||
MediaKit.ensureInitialized();
|
MediaKit.ensureInitialized();
|
||||||
final first = RoomServer(
|
final first = SyncServer(
|
||||||
const AppConfig(port: 0, secret: 'race', name: 'race'));
|
const AppConfig(port: 0, secret: 'race', name: 'race'));
|
||||||
await first.start();
|
await first.start();
|
||||||
final port = first.boundPort;
|
final port = first.boundPort;
|
||||||
final coord = SessionCoordinator();
|
final coord = SessionCoordinator();
|
||||||
RoomServer? second;
|
SyncServer? second;
|
||||||
final observer = SyncClient();
|
final observer = SyncClient();
|
||||||
try {
|
try {
|
||||||
await coord.connect(ConnectParams(
|
await coord.connect(ConnectParams(
|
||||||
@@ -79,7 +79,7 @@ void main() {
|
|||||||
await coord.playback.player.setVolume(0);
|
await coord.playback.player.setVolume(0);
|
||||||
await first.stop();
|
await first.stop();
|
||||||
await _poll(() => coord.reconnecting.peek(), 'reconnect to engage');
|
await _poll(() => coord.reconnecting.peek(), 'reconnect to engage');
|
||||||
second = RoomServer(
|
second = SyncServer(
|
||||||
AppConfig(port: port, secret: 'race', name: 'race'));
|
AppConfig(port: port, secret: 'race', name: 'race'));
|
||||||
await second.start();
|
await second.start();
|
||||||
var joins = 0;
|
var joins = 0;
|
||||||
@@ -93,7 +93,7 @@ void main() {
|
|||||||
// Within the 1.5 s delay: the first attempt must die with Cancel.
|
// Within the 1.5 s delay: the first attempt must die with Cancel.
|
||||||
coord.cancelReconnect();
|
coord.cancelReconnect();
|
||||||
coord.retry();
|
coord.retry();
|
||||||
await _poll(() => !coord.reconnecting.peek() && coord.room.peek() != null,
|
await _poll(() => !coord.reconnecting.peek() && coord.serverState.peek() != null,
|
||||||
'reconnect to land');
|
'reconnect to land');
|
||||||
await Future.delayed(const Duration(seconds: 4));
|
await Future.delayed(const Duration(seconds: 4));
|
||||||
await sub.cancel();
|
await sub.cancel();
|
||||||
@@ -116,13 +116,13 @@ void main() {
|
|||||||
timeout: const Timeout(Duration(minutes: 2)),
|
timeout: const Timeout(Duration(minutes: 2)),
|
||||||
() async {
|
() async {
|
||||||
MediaKit.ensureInitialized();
|
MediaKit.ensureInitialized();
|
||||||
final first = RoomServer(
|
final first = SyncServer(
|
||||||
const AppConfig(port: 0, secret: 'force', name: 'force'));
|
const AppConfig(port: 0, secret: 'force', name: 'force'));
|
||||||
await first.start();
|
await first.start();
|
||||||
final port = first.boundPort;
|
final port = first.boundPort;
|
||||||
final coord = SessionCoordinator();
|
final coord = SessionCoordinator();
|
||||||
final friend = SyncClient();
|
final friend = SyncClient();
|
||||||
RoomServer? second;
|
SyncServer? second;
|
||||||
try {
|
try {
|
||||||
await coord.connect(ConnectParams(
|
await coord.connect(ConnectParams(
|
||||||
host: '127.0.0.1', port: port, secret: 'force', name: 'watcher'));
|
host: '127.0.0.1', port: port, secret: 'force', name: 'watcher'));
|
||||||
@@ -138,7 +138,7 @@ void main() {
|
|||||||
await friend.disconnect();
|
await friend.disconnect();
|
||||||
await first.stop();
|
await first.stop();
|
||||||
await _poll(() => coord.reconnecting.peek(), 'reconnect to engage');
|
await _poll(() => coord.reconnecting.peek(), 'reconnect to engage');
|
||||||
second = RoomServer(
|
second = SyncServer(
|
||||||
AppConfig(port: port, secret: 'force', name: 'force'));
|
AppConfig(port: port, secret: 'force', name: 'force'));
|
||||||
await second.start();
|
await second.start();
|
||||||
await _poll(
|
await _poll(
|
||||||
@@ -158,7 +158,7 @@ void main() {
|
|||||||
timeout: const Timeout(Duration(minutes: 1)),
|
timeout: const Timeout(Duration(minutes: 1)),
|
||||||
() async {
|
() async {
|
||||||
MediaKit.ensureInitialized();
|
MediaKit.ensureInitialized();
|
||||||
final server = RoomServer(
|
final server = SyncServer(
|
||||||
const AppConfig(port: 0, secret: '', name: 'cancel'));
|
const AppConfig(port: 0, secret: '', name: 'cancel'));
|
||||||
await server.start();
|
await server.start();
|
||||||
final coord = SessionCoordinator();
|
final coord = SessionCoordinator();
|
||||||
@@ -185,7 +185,8 @@ void main() {
|
|||||||
timeout: const Timeout(Duration(minutes: 1)),
|
timeout: const Timeout(Duration(minutes: 1)),
|
||||||
() async {
|
() async {
|
||||||
MediaKit.ensureInitialized();
|
MediaKit.ensureInitialized();
|
||||||
Hive.init('/home/foxyz/.claude/jobs/4898998e/tmp/hive_coord_test');
|
Hive.init(
|
||||||
|
'${Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path}/hive_coord_test');
|
||||||
final probe = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
|
final probe = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
|
||||||
final port = probe.port;
|
final port = probe.port;
|
||||||
await probe.close();
|
await probe.close();
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:isolate';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:hive_ce/hive.dart';
|
||||||
|
import 'package:media_kit/media_kit.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
import 'package:watch_together/src/session/session_coordinator.dart';
|
||||||
|
import 'package:watch_together/src/state/server_isolate.dart';
|
||||||
|
import 'package:watch_together/src/state/session_state.dart';
|
||||||
|
|
||||||
|
Future<void> _poll(
|
||||||
|
FutureOr<bool> Function() cond,
|
||||||
|
String what, {
|
||||||
|
Duration timeout = const Duration(seconds: 15),
|
||||||
|
}) async {
|
||||||
|
final deadline = DateTime.now().add(timeout);
|
||||||
|
while (DateTime.now().isBefore(deadline)) {
|
||||||
|
if (await cond()) return;
|
||||||
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
fail('timed out waiting for $what');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bare WebSocket viewer: no player, just the wire.
|
||||||
|
class _Raw {
|
||||||
|
final WebSocket ws;
|
||||||
|
final msgs = <Map<String, Object?>>[];
|
||||||
|
_Raw(this.ws) {
|
||||||
|
ws.listen((m) => msgs.add(Map<String, Object?>.from(jsonDecode('$m') as Map)));
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<_Raw> join(int port, String name, {String secret = ''}) async {
|
||||||
|
final r = _Raw(await WebSocket.connect('ws://127.0.0.1:$port/sync'));
|
||||||
|
r.ws.add(jsonEncode(HelloMessage(
|
||||||
|
proto: RelayConstants.protoVersion,
|
||||||
|
secret: secret,
|
||||||
|
user: HelloUser(name: name),
|
||||||
|
media: const HelloMedia(name: '', size: 0, duration: 0))
|
||||||
|
.toJson()));
|
||||||
|
await r.next('welcome');
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, Object?>> next(String type, {bool Function(Map<String, Object?>)? where}) async {
|
||||||
|
late Map<String, Object?> found;
|
||||||
|
await _poll(() {
|
||||||
|
for (final m in msgs) {
|
||||||
|
if (m['t'] == type && (where == null || where(m))) {
|
||||||
|
found = m;
|
||||||
|
msgs.remove(m);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, '$type from server');
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> _freePort() async {
|
||||||
|
final s = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
|
||||||
|
final port = s.port;
|
||||||
|
await s.close();
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('server isolate: logs, raw join, crash reported, stop frees port',
|
||||||
|
timeout: const Timeout(Duration(minutes: 1)), () async {
|
||||||
|
final logs = <String>[];
|
||||||
|
final crashes = <String>[];
|
||||||
|
final server = await ServerIsolate.start(
|
||||||
|
const AppConfig(port: 0, secret: '', name: 'iso'),
|
||||||
|
onLog: logs.add,
|
||||||
|
onCrash: crashes.add);
|
||||||
|
final viewer = await _Raw.join(server.port, 'viewer');
|
||||||
|
await viewer.next('state');
|
||||||
|
await _poll(() => logs.any((l) => l.contains('join viewer')), 'join log');
|
||||||
|
await viewer.ws.close();
|
||||||
|
server.isolate.kill(priority: Isolate.immediate);
|
||||||
|
await _poll(() => crashes.isNotEmpty, 'crash callback');
|
||||||
|
expect(server.isRunning, isFalse);
|
||||||
|
await server.stop(); // no-op on a dead isolate
|
||||||
|
|
||||||
|
final clean = await ServerIsolate.start(
|
||||||
|
const AppConfig(port: 0, secret: '', name: 'iso'),
|
||||||
|
onLog: (_) {},
|
||||||
|
onCrash: crashes.add);
|
||||||
|
final port = clean.port;
|
||||||
|
await clean.stop();
|
||||||
|
expect(crashes, hasLength(1), reason: 'a requested stop is not a crash');
|
||||||
|
final rebind = await ServerSocket.bind(InternetAddress.anyIPv4, port);
|
||||||
|
await rebind.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bind failure surfaces as a start error',
|
||||||
|
timeout: const Timeout(Duration(seconds: 30)), () async {
|
||||||
|
final blocker = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
|
||||||
|
try {
|
||||||
|
await expectLater(
|
||||||
|
ServerIsolate.start(AppConfig(port: blocker.port, secret: '', name: 'x'),
|
||||||
|
onLog: (_) {}, onCrash: (_) {}),
|
||||||
|
throwsA(isA<StateError>()));
|
||||||
|
} finally {
|
||||||
|
await blocker.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('host path: isolate server, host unkickable, crash leaves the session',
|
||||||
|
timeout: const Timeout(Duration(minutes: 2)), () async {
|
||||||
|
MediaKit.ensureInitialized();
|
||||||
|
Hive.init(
|
||||||
|
'${Platform.environment['WT_TEST_TMP'] ?? Directory.systemTemp.path}/hive_isolate_test');
|
||||||
|
final session = SessionState.instance;
|
||||||
|
final port = await _freePort();
|
||||||
|
_Raw? friend;
|
||||||
|
try {
|
||||||
|
await session.host(
|
||||||
|
ConnectParams(host: '', port: port, secret: 'pw', name: 'host'),
|
||||||
|
ConnectForm());
|
||||||
|
expect(session.route.peek(), AppRoute.session);
|
||||||
|
await session.coordinator.playback.player.setVolume(0);
|
||||||
|
|
||||||
|
friend = await _Raw.join(port, 'friend', secret: 'pw');
|
||||||
|
final state = await friend.next('state',
|
||||||
|
where: (m) => (m['members'] as List).any((x) => (x as Map)['name'] == 'host'));
|
||||||
|
final hostId = ((state['members'] as List)
|
||||||
|
.cast<Map>()
|
||||||
|
.firstWhere((m) => m['name'] == 'host'))['id'];
|
||||||
|
friend.ws.add(jsonEncode(KickRequest(target: '$hostId').toJson()));
|
||||||
|
final err = await friend.next('err');
|
||||||
|
expect(err['message'], "can't kick the host");
|
||||||
|
|
||||||
|
// The host can kick the friend (plain loopback peers are not hosts).
|
||||||
|
final friendId = ((state['members'] as List)
|
||||||
|
.cast<Map>()
|
||||||
|
.firstWhere((m) => m['name'] == 'friend'))['id'];
|
||||||
|
session.coordinator.kick('$friendId');
|
||||||
|
await friend.next('kicked');
|
||||||
|
|
||||||
|
session.hostedServer!.isolate.kill(priority: Isolate.immediate);
|
||||||
|
await _poll(() => session.route.peek() == AppRoute.home, 'leave after crash');
|
||||||
|
expect(session.serverCrash.peek(), startsWith('server crashed'));
|
||||||
|
expect(session.hostedServer, isNull);
|
||||||
|
// The killed isolate's socket stays bound in-process; hosting again on
|
||||||
|
// the same port must still work.
|
||||||
|
session.serverCrash.value = null;
|
||||||
|
await session.host(
|
||||||
|
ConnectParams(host: '', port: port, secret: 'pw', name: 'host'),
|
||||||
|
ConnectForm());
|
||||||
|
expect(session.route.peek(), AppRoute.session);
|
||||||
|
final again = await _Raw.join(port, 'again', secret: 'pw');
|
||||||
|
await again.ws.close();
|
||||||
|
await session.leave();
|
||||||
|
expect(session.hostedServer, isNull);
|
||||||
|
} finally {
|
||||||
|
await friend?.ws.close();
|
||||||
|
await session.coordinator.leave(silent: true);
|
||||||
|
await session.stopServer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:watch_together/src/session/idle.dart';
|
import 'package:watch_together/src/session/idle.dart';
|
||||||
import 'package:watch_together/src/session/session_coordinator.dart';
|
import 'package:watch_together/src/session/session_coordinator.dart';
|
||||||
import 'package:watch_together/src/sync/room_diff.dart';
|
import 'package:watch_together/src/sync/state_diff.dart';
|
||||||
|
|
||||||
MemberView _self({bool ready = true, bool inSet = true, bool stale = false}) =>
|
MemberView _self({bool ready = true, bool inSet = true, bool stale = false}) =>
|
||||||
MemberView(
|
MemberView(
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:watch_together/src/sync/room_diff.dart';
|
import 'package:watch_together/src/sync/state_diff.dart';
|
||||||
|
|
||||||
RoomBroadcast _room({
|
StateBroadcast _state({
|
||||||
int eventId = 0,
|
int eventId = 0,
|
||||||
String transport = 'held',
|
String transport = 'held',
|
||||||
double position = 10.0,
|
double position = 10.0,
|
||||||
int mediaId = 1,
|
int mediaId = 1,
|
||||||
}) {
|
}) {
|
||||||
final now = DateTime.now().toUtc();
|
final now = DateTime.now().toUtc();
|
||||||
return RoomBroadcast(
|
return StateBroadcast(
|
||||||
eventId: eventId,
|
eventId: eventId,
|
||||||
transport: transport,
|
transport: transport,
|
||||||
position: position,
|
position: position,
|
||||||
@@ -19,10 +19,10 @@ RoomBroadcast _room({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
RoomBroadcast _roomWithMembers(List<MemberView> members,
|
StateBroadcast _stateWithMembers(List<MemberView> members,
|
||||||
{String transport = 'playing'}) {
|
{String transport = 'playing'}) {
|
||||||
final now = DateTime.now().toUtc();
|
final now = DateTime.now().toUtc();
|
||||||
return RoomBroadcast(
|
return StateBroadcast(
|
||||||
eventId: 3,
|
eventId: 3,
|
||||||
transport: transport,
|
transport: transport,
|
||||||
position: 10.0,
|
position: 10.0,
|
||||||
@@ -33,50 +33,50 @@ RoomBroadcast _roomWithMembers(List<MemberView> members,
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test('initial diff seeks to room edge and sets transport', () {
|
test('initial diff seeks to server edge and sets transport', () {
|
||||||
final diff = RoomDiff();
|
final diff = StateDiff();
|
||||||
final actions = diff.diff(_room(), 0, DateTime.now().toUtc());
|
final actions = diff.diff(_state(), 0, DateTime.now().toUtc());
|
||||||
expect(actions.whereType<SeekAction>().single.position, closeTo(10.0, 1.0));
|
expect(actions.whereType<SeekAction>().single.position, closeTo(10.0, 1.0));
|
||||||
expect(actions.whereType<TransportAction>().single.playing, isFalse);
|
expect(actions.whereType<TransportAction>().single.playing, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('same event small drift does nothing', () {
|
test('same event small drift does nothing', () {
|
||||||
final diff = RoomDiff();
|
final diff = StateDiff();
|
||||||
final now = DateTime.now().toUtc();
|
final now = DateTime.now().toUtc();
|
||||||
diff.diff(_room(), 0, now);
|
diff.diff(_state(), 0, now);
|
||||||
final actions = diff.diff(_room(), 10.5, now);
|
final actions = diff.diff(_state(), 10.5, now);
|
||||||
expect(actions, isEmpty);
|
expect(actions, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ahead drift latches slowdown, resets inside 0.1', () {
|
test('ahead drift latches slowdown, resets inside 0.1', () {
|
||||||
final diff = RoomDiff();
|
final diff = StateDiff();
|
||||||
final now = DateTime.now().toUtc();
|
final now = DateTime.now().toUtc();
|
||||||
diff.diff(_room(), 0, now);
|
diff.diff(_state(), 0, now);
|
||||||
var actions = diff.diff(_room(), 13.0, now);
|
var actions = diff.diff(_state(), 13.0, now);
|
||||||
expect(actions.whereType<RateAction>().single.rate, 0.95);
|
expect(actions.whereType<RateAction>().single.rate, 0.95);
|
||||||
actions = diff.diff(_room(), 12.0, now);
|
actions = diff.diff(_state(), 12.0, now);
|
||||||
expect(actions, isEmpty);
|
expect(actions, isEmpty);
|
||||||
actions = diff.diff(_room(), 10.05, now);
|
actions = diff.diff(_state(), 10.05, now);
|
||||||
expect(actions.whereType<RateAction>().single.rate, 1.0);
|
expect(actions.whereType<RateAction>().single.rate, 1.0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('eventId jump seeks and resets rate', () {
|
test('eventId jump seeks and resets rate', () {
|
||||||
final diff = RoomDiff();
|
final diff = StateDiff();
|
||||||
final now = DateTime.now().toUtc();
|
final now = DateTime.now().toUtc();
|
||||||
diff.diff(_room(), 0, now);
|
diff.diff(_state(), 0, now);
|
||||||
diff.diff(_room(), 13.0, now);
|
diff.diff(_state(), 13.0, now);
|
||||||
final actions =
|
final actions =
|
||||||
diff.diff(_room(eventId: 1, position: 90.0), 13.0, now);
|
diff.diff(_state(eventId: 1, position: 90.0), 13.0, now);
|
||||||
expect(actions.whereType<SeekAction>().single.position, closeTo(90.0, 1.0));
|
expect(actions.whereType<SeekAction>().single.position, closeTo(90.0, 1.0));
|
||||||
expect(actions.whereType<RateAction>().single.rate, 1.0);
|
expect(actions.whereType<RateAction>().single.rate, 1.0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('media swap seeks zero and pauses', () {
|
test('media swap seeks zero and pauses', () {
|
||||||
final diff = RoomDiff();
|
final diff = StateDiff();
|
||||||
final now = DateTime.now().toUtc();
|
final now = DateTime.now().toUtc();
|
||||||
diff.diff(_room(transport: 'playing'), 10.0, now);
|
diff.diff(_state(transport: 'playing'), 10.0, now);
|
||||||
final actions = diff.diff(
|
final actions = diff.diff(
|
||||||
_room(eventId: 2, transport: 'held', position: 0, mediaId: 2),
|
_state(eventId: 2, transport: 'held', position: 0, mediaId: 2),
|
||||||
50.0,
|
50.0,
|
||||||
now);
|
now);
|
||||||
expect(actions.whereType<SeekAction>().single.position, 0);
|
expect(actions.whereType<SeekAction>().single.position, 0);
|
||||||
@@ -84,25 +84,25 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('seek debounce while playing', () {
|
group('seek debounce while playing', () {
|
||||||
RoomBroadcast playing({int eventId = 5, double position = 100.0}) => _room(
|
StateBroadcast playing({int eventId = 5, double position = 100.0}) => _state(
|
||||||
eventId: eventId, transport: 'playing', position: position);
|
eventId: eventId, transport: 'playing', position: position);
|
||||||
|
|
||||||
test('fresh big drift is swallowed first, seeks after window', () {
|
test('fresh big drift is swallowed first, seeks after window', () {
|
||||||
final diff = RoomDiff();
|
final diff = StateDiff();
|
||||||
final t0 = DateTime.now().toUtc();
|
final t0 = DateTime.now().toUtc();
|
||||||
diff.diff(playing(), 100.0, t0);
|
diff.diff(playing(), 100.0, t0);
|
||||||
var actions = diff.diff(playing(), 120.0, t0);
|
var actions = diff.diff(playing(), 120.0, t0);
|
||||||
expect(actions.whereType<SeekAction>(), isEmpty,
|
expect(actions.whereType<SeekAction>(), isEmpty,
|
||||||
reason: 'user scrub must not be yanked back before detector runs');
|
reason: 'user scrub must not be yanked back before detector runs');
|
||||||
actions = diff.diff(
|
actions = diff.diff(
|
||||||
playing(), 120.0, t0.add(RoomDiff.debounceWindow + const Duration(milliseconds: 100)));
|
playing(), 120.0, t0.add(StateDiff.debounceWindow + const Duration(milliseconds: 100)));
|
||||||
expect(actions.whereType<SeekAction>().single.position,
|
expect(actions.whereType<SeekAction>().single.position,
|
||||||
closeTo(102.0, 3.0),
|
closeTo(102.0, 3.0),
|
||||||
reason: 'genuine drift still converges after the window');
|
reason: 'genuine drift still converges after the window');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('new eventId seeks immediately (echo of promoted seek)', () {
|
test('new eventId seeks immediately (echo of promoted seek)', () {
|
||||||
final diff = RoomDiff();
|
final diff = StateDiff();
|
||||||
final t0 = DateTime.now().toUtc();
|
final t0 = DateTime.now().toUtc();
|
||||||
diff.diff(playing(), 100.0, t0);
|
diff.diff(playing(), 100.0, t0);
|
||||||
diff.diff(playing(), 120.0, t0);
|
diff.diff(playing(), 120.0, t0);
|
||||||
@@ -112,13 +112,13 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('held transport debounces the same way (detector promotes)', () {
|
test('held transport debounces the same way (detector promotes)', () {
|
||||||
final diff = RoomDiff();
|
final diff = StateDiff();
|
||||||
final t0 = DateTime.now().toUtc();
|
final t0 = DateTime.now().toUtc();
|
||||||
diff.diff(_room(), 10.0, t0);
|
diff.diff(_state(), 10.0, t0);
|
||||||
var actions = diff.diff(_room(), 60.0, t0);
|
var actions = diff.diff(_state(), 60.0, t0);
|
||||||
expect(actions.whereType<SeekAction>(), isEmpty);
|
expect(actions.whereType<SeekAction>(), isEmpty);
|
||||||
actions = diff.diff(_room(), 60.0,
|
actions = diff.diff(_state(), 60.0,
|
||||||
t0.add(RoomDiff.debounceWindow + const Duration(milliseconds: 100)));
|
t0.add(StateDiff.debounceWindow + const Duration(milliseconds: 100)));
|
||||||
expect(actions.whereType<SeekAction>().single.position,
|
expect(actions.whereType<SeekAction>().single.position,
|
||||||
closeTo(10.0, 3.0));
|
closeTo(10.0, 3.0));
|
||||||
});
|
});
|
||||||
@@ -148,12 +148,12 @@ void main() {
|
|||||||
stale: false);
|
stale: false);
|
||||||
|
|
||||||
test('only in-set ready members may follow transport to playing', () {
|
test('only in-set ready members may follow transport to playing', () {
|
||||||
final room = _roomWithMembers([ready, catchingUp, unreadyInSet]);
|
final state = _stateWithMembers([ready, catchingUp, unreadyInSet]);
|
||||||
expect(localPlayAllowed(room, 'm1'), isTrue);
|
expect(localPlayAllowed(state, 'm1'), isTrue);
|
||||||
expect(localPlayAllowed(room, 'm2'), isFalse);
|
expect(localPlayAllowed(state, 'm2'), isFalse);
|
||||||
expect(localPlayAllowed(room, 'm3'), isFalse);
|
expect(localPlayAllowed(state, 'm3'), isFalse);
|
||||||
expect(localPlayAllowed(room, 'nope'), isFalse);
|
expect(localPlayAllowed(state, 'nope'), isFalse);
|
||||||
expect(localPlayAllowed(room, ''), isFalse);
|
expect(localPlayAllowed(state, ''), isFalse);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -37,10 +37,10 @@ void main() {
|
|||||||
timeout: const Timeout(Duration(minutes: 4)),
|
timeout: const Timeout(Duration(minutes: 4)),
|
||||||
() async {
|
() async {
|
||||||
MediaKit.ensureInitialized();
|
MediaKit.ensureInitialized();
|
||||||
final server = RoomServer(const AppConfig(
|
final server = SyncServer(const AppConfig(
|
||||||
port: _port,
|
port: _port,
|
||||||
secret: _secret,
|
secret: _secret,
|
||||||
name: 'room',
|
name: 'server',
|
||||||
mediaPath: _mediaPath,
|
mediaPath: _mediaPath,
|
||||||
));
|
));
|
||||||
await server.start();
|
await server.start();
|
||||||
@@ -67,10 +67,10 @@ void main() {
|
|||||||
reason: 'media must open paused, not autoplay');
|
reason: 'media must open paused, not autoplay');
|
||||||
expect(viewer.playback.player.state.playing, isFalse);
|
expect(viewer.playback.player.state.playing, isFalse);
|
||||||
|
|
||||||
await _poll(() => host.room.peek() != null, 'host room state');
|
await _poll(() => host.serverState.peek() != null, 'host server state');
|
||||||
host.toggleReady();
|
host.toggleReady();
|
||||||
await Future.delayed(const Duration(seconds: 5));
|
await Future.delayed(const Duration(seconds: 5));
|
||||||
expect(host.room.peek()?.transport, 'held',
|
expect(host.serverState.peek()?.transport, 'held',
|
||||||
reason: 'one Space with company present must not solo-play');
|
reason: 'one Space with company present must not solo-play');
|
||||||
expect(host.playback.player.state.playing, isFalse);
|
expect(host.playback.player.state.playing, isFalse);
|
||||||
expect(viewer.playback.player.state.playing, isFalse);
|
expect(viewer.playback.player.state.playing, isFalse);
|
||||||
@@ -78,7 +78,7 @@ void main() {
|
|||||||
viewer.toggleReady();
|
viewer.toggleReady();
|
||||||
await _poll(
|
await _poll(
|
||||||
() =>
|
() =>
|
||||||
host.room.peek()?.transport == 'playing' &&
|
host.serverState.peek()?.transport == 'playing' &&
|
||||||
viewer.playback.player.state.playing &&
|
viewer.playback.player.state.playing &&
|
||||||
host.playback.player.state.playing,
|
host.playback.player.state.playing,
|
||||||
'both players playing once quorum readies',
|
'both players playing once quorum readies',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import 'package:media_kit/media_kit.dart';
|
|||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:sync_relay_server/server.dart';
|
import 'package:sync_relay_server/server.dart';
|
||||||
import 'package:watch_together/src/session/session_coordinator.dart';
|
import 'package:watch_together/src/session/session_coordinator.dart';
|
||||||
import 'package:watch_together/src/sync/room_diff.dart';
|
import 'package:watch_together/src/sync/state_diff.dart';
|
||||||
|
|
||||||
const _mediaPath = '/home/foxyz/Videos/chiyo.mp4';
|
const _mediaPath = '/home/foxyz/Videos/chiyo.mp4';
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ void main() {
|
|||||||
timeout: const Timeout(Duration(minutes: 2)),
|
timeout: const Timeout(Duration(minutes: 2)),
|
||||||
() async {
|
() async {
|
||||||
MediaKit.ensureInitialized();
|
MediaKit.ensureInitialized();
|
||||||
final server = RoomServer(const AppConfig(
|
final server = SyncServer(const AppConfig(
|
||||||
port: 0, secret: 'reload', name: 'reload', mediaPath: _mediaPath));
|
port: 0, secret: 'reload', name: 'reload', mediaPath: _mediaPath));
|
||||||
await server.start();
|
await server.start();
|
||||||
final c = SessionCoordinator();
|
final c = SessionCoordinator();
|
||||||
@@ -40,7 +40,7 @@ void main() {
|
|||||||
secret: 'reload',
|
secret: 'reload',
|
||||||
name: 'solo'));
|
name: 'solo'));
|
||||||
await c.playback.player.setVolume(0);
|
await c.playback.player.setVolume(0);
|
||||||
await _poll(() => c.room.peek() != null, 'room state');
|
await _poll(() => c.serverState.peek() != null, 'server state');
|
||||||
c.toggleReady();
|
c.toggleReady();
|
||||||
await _poll(
|
await _poll(
|
||||||
() => c.playback.player.state.playing && c.playback.positionSecs > 2,
|
() => c.playback.player.state.playing && c.playback.positionSecs > 2,
|
||||||
@@ -54,14 +54,14 @@ void main() {
|
|||||||
expect(c.selfMember.peek()?.ready, isTrue);
|
expect(c.selfMember.peek()?.ready, isTrue);
|
||||||
|
|
||||||
await _poll(() {
|
await _poll(() {
|
||||||
final r = c.room.peek();
|
final r = c.serverState.peek();
|
||||||
if (r == null || !c.playback.player.state.playing) return false;
|
if (r == null || !c.playback.player.state.playing) return false;
|
||||||
final want = roomPositionAt(r, DateTime.now().toUtc());
|
final want = serverPositionAt(r, DateTime.now().toUtc());
|
||||||
return (c.playback.positionSecs - want).abs() < 1.5 &&
|
return (c.playback.positionSecs - want).abs() < 1.5 &&
|
||||||
c.playback.positionSecs > 2;
|
c.playback.positionSecs > 2;
|
||||||
}, 'reloaded player playing at the synced position');
|
}, 'reloaded player playing at the synced position');
|
||||||
expect(c.selfMember.peek()?.ready, isTrue);
|
expect(c.selfMember.peek()?.ready, isTrue);
|
||||||
expect(c.room.peek()?.transport, 'playing');
|
expect(c.serverState.peek()?.transport, 'playing');
|
||||||
|
|
||||||
await c.leave();
|
await c.leave();
|
||||||
expect(c.playback.alive.peek(), isFalse);
|
expect(c.playback.alive.peek(), isFalse);
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
|
# Dedicated watch-together server (no display needed).
|
||||||
|
# Install: unpack the release tarball to /opt/watch-together (the unit only
|
||||||
|
# uses the watch-together-server binary in it), copy this file to
|
||||||
|
# /etc/systemd/system/, then `systemctl enable --now watch-together`.
|
||||||
|
# Flags: --port <n> (default 8080), --secret <password> (empty = open
|
||||||
|
# server, anyone who can reach the port can join), --name, --media <file>,
|
||||||
|
# --advertise-host <host>, --config <file.yaml|json>. See README.
|
||||||
|
# Prefer --config with a root-only file over --secret here: unit files and
|
||||||
|
# process lists are readable by other users.
|
||||||
|
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=watch-together headless server
|
Description=watch-together dedicated server
|
||||||
After=network.target
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
ExecStart=/opt/watch-together/server --port 8080
|
ExecStart=/opt/watch-together/watch-together-server --port 8080 --secret change-me
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
|
DynamicUser=true
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
+56
-37
@@ -7,40 +7,56 @@ Plain overview first: [HOW-IT-WORKS.md](HOW-IT-WORKS.md). Wire format:
|
|||||||
|
|
||||||
| Path | What | Rule |
|
| Path | What | Rule |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `packages/core` | protocol messages, sync engine (`RoomEngine`), constants, config, Range parsing, logging | pure Dart, no Flutter |
|
| `packages/core` | protocol messages, sync engine (`SyncEngine`), constants, config, Range parsing, logging | pure Dart, no Flutter |
|
||||||
| `packages/server` | Relic HTTP + WebSocket server (`RoomServer`, `RoomHub`, `Session`, media endpoint), `bin/server.dart` | pure Dart, no Flutter |
|
| `packages/server` | Relic HTTP + WebSocket server (`SyncServer`, `ServerHub`, `Session`, media endpoint), the `serve()` entrypoint, `bin/server.dart` (dedicated server) | pure Dart, no Flutter |
|
||||||
| `apps/desktop` | Flutter app: UI, `SyncClient`, `SessionCoordinator`, `PlaybackController` (media_kit/mpv), updater | the only shipped binary |
|
| `apps/desktop` | Flutter app: UI, `SyncClient`, `SessionCoordinator`, `PlaybackController` (media_kit/mpv), `ServerIsolate`, updater | ships with `watch-together-server` |
|
||||||
| `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only |
|
| `tool` | harnesses: `range_logger`, `shaper`, `fake_viewer`, `e2e_check` | dev only |
|
||||||
| `deploy` | systemd unit for the standalone server (stale until the dedicated server exists) | |
|
| `deploy` | systemd unit for the dedicated server | |
|
||||||
|
|
||||||
Guard: `packages/core/test/flutter_free_test.dart` fails if anything under
|
Guard: `packages/core/test/flutter_free_test.dart` fails if anything under
|
||||||
`core/lib` imports Flutter, `dart:ui` or `signals_flutter`; CI runs
|
`core/lib` imports Flutter, `dart:ui` or `signals_flutter`; CI runs
|
||||||
analyze + tests for core and server on every push. Server has no such test but
|
analyze + tests for core and server on every push. Server has no such test but
|
||||||
the same rule; it is checked by CI building without Flutter. The client
|
the same rule; CI checks it with `dart compile exe` of `bin/server.dart`. The client
|
||||||
WebSocket (`web_socket_channel`) lives in `apps/desktop`; Relic has no client.
|
WebSocket (`web_socket_channel`) lives in `apps/desktop`; Relic has no client.
|
||||||
|
|
||||||
## Server lifecycle
|
## Server lifecycle
|
||||||
|
|
||||||
Today (in-process): `SessionState.host()` creates a `RoomServer`, starts it
|
One entrypoint: `serve(config, events:)` in `packages/server`. `config` is
|
||||||
on the chosen port (bound to all IPv4 interfaces), then the app joins it over
|
an `AppConfig.toMap()` (primitives only, so it crosses isolates). It starts a
|
||||||
`127.0.0.1` like any other client. Leaving or closing the window stops it.
|
`SyncServer` (bound to all IPv4 interfaces) and reports on the `events` port:
|
||||||
`RoomServer` runs a 1 s heartbeat: stale sweep, closed-socket sweep, silent
|
`ready` (with the port and a `stop` SendPort), `error` (start failed), and
|
||||||
socket drop, room + feed broadcast. There is one session, id `default`.
|
every log record as `log`. Sending `{'t':'stop'}` to the `stop` port closes
|
||||||
|
all sockets and returns, and the isolate exits.
|
||||||
|
|
||||||
`--headless` does not work: the Flutter binary needs a display and exits with
|
- **Hosting from the app**: `SessionState.host()` spawns an isolate running
|
||||||
"cannot open display" on a headless box. `packages/server/bin/server.dart` is
|
`serve` (`ServerIsolate`), waits for `ready`, then joins it over
|
||||||
the only working standalone server (flags `--port`, `--secret`, `--name`,
|
`127.0.0.1` like any other client. Server logs go to the app's log with a
|
||||||
`--media`, `--advertise-host`, `--config`; default port 8080).
|
`[server]` prefix. The isolate runs with `errorsAreFatal: false`: an
|
||||||
|
uncaught async error is logged and the server keeps going. If the isolate
|
||||||
|
dies anyway, the session shows "server crashed: ..." and leaves; Home
|
||||||
|
shows it on the Host card (during setup the host attempt fails with it).
|
||||||
|
Stop (leave, close, cancel during setup, a failed own join, being kicked)
|
||||||
|
sends `stop`, waits up to 3 s for exit, then kills the isolate.
|
||||||
|
- **Dedicated server**: `bin/server.dart` compiled with `dart compile exe`
|
||||||
|
and shipped next to the app as `watch-together-server`. It parses flags
|
||||||
|
(`--port`, `--secret`, `--name`, `--media`, `--advertise-host`,
|
||||||
|
`--config`; default port 8080) into the same map and calls `serve`;
|
||||||
|
SIGINT and SIGTERM stop it. A Flutter binary cannot run without a
|
||||||
|
display, which is why the server is its own build.
|
||||||
|
|
||||||
Planned: hosting from the app runs the server on a Dart isolate (ports and
|
`SyncServer` runs a 1 s heartbeat: stale sweep, closed-socket sweep, silent
|
||||||
sockets stay in the same process, UI stays responsive); the dedicated server
|
socket drop, state + feed broadcast. There is one session, id `default`.
|
||||||
is a separate `dart compile exe` of `packages/server`. See [ROADMAP.md](ROADMAP.md).
|
|
||||||
|
|
||||||
Passwords: the default secret is empty, which means an open server (a
|
Passwords: the default secret is empty, which means an open server (a
|
||||||
warning is logged). Otherwise one shared secret, compared at hello.
|
warning is logged). Otherwise one shared secret, compared at hello.
|
||||||
The host is the member that connected from a loopback address; it cannot be
|
|
||||||
kicked. Kick = revoke token, send `kicked`, close; the
|
Host: when hosting, the app generates a random `hostKey` per hosting, puts
|
||||||
person may rejoin at once with a new token.
|
it in the server config and sends it in its own `hello`. The member whose
|
||||||
|
key matches is the host and cannot be kicked. The address a connection
|
||||||
|
comes from plays no part (so a reverse proxy or tunnel on the same machine
|
||||||
|
does not make everyone the host). The dedicated server has no key, so it
|
||||||
|
has no host. Kick = revoke token, send `kicked`, close; the person may
|
||||||
|
rejoin at once with a new token.
|
||||||
|
|
||||||
## Sync model
|
## Sync model
|
||||||
|
|
||||||
@@ -63,20 +79,20 @@ Loosely modeled on Syncplay, with a different readiness design.
|
|||||||
4 s buffered or stale flips the server to HELD (feed action `pause` by `server`).
|
4 s buffered or stale flips the server to HELD (feed action `pause` by `server`).
|
||||||
Recovery resumes by itself when the gate passes.
|
Recovery resumes by itself when the gate passes.
|
||||||
- **Seek**: allowed from any member in any transport; the server writes the
|
- **Seek**: allowed from any member in any transport; the server writes the
|
||||||
position and bumps `eventId`; flags are untouched. Clients diff each `room`
|
position and bumps `eventId`; flags are untouched. Clients diff each `state`
|
||||||
broadcast by `eventId` (`RoomDiff`): new event means seek to the computed
|
broadcast by `eventId` (`StateDiff`): new event means seek to the computed
|
||||||
room position, transport change means play/pause. Between events: drift
|
server position, transport change means play/pause. Between events: drift
|
||||||
over 5 s for more than 2 s seeks, ahead by more than 1.5 s sets rate 0.95
|
over 5 s for more than 2 s seeks, ahead by more than 1.5 s sets rate 0.95
|
||||||
until level, otherwise nothing. Local seeks are detected by the coordinator
|
until level, otherwise nothing. Local seeks are detected by the coordinator
|
||||||
polling mpv position once per second (`_detectUserSeek`, jump over 2 s and
|
polling mpv position once per second (`_detectUserSeek`, jump over 2 s and
|
||||||
more than 2 s from the room) and sent as a `seek` intent; a retry
|
more than 2 s from the server position) and sent as a `seek` intent; a retry
|
||||||
(`_checkSeekLanded`) re-applies a room seek that has not landed after 1.5 s.
|
(`_checkSeekLanded`) re-applies a server seek that has not landed after 1.5 s.
|
||||||
Known roughness: see ROADMAP.
|
Known roughness: see ROADMAP.
|
||||||
- **Liveness**: WS ping every 5 s; silent over 15 s means stale (out of the
|
- **Liveness**: WS ping every 5 s; silent over 15 s means stale (out of the
|
||||||
gate, `stale` feed); over 30 s the socket is closed and the member removed.
|
gate, `stale` feed); over 30 s the socket is closed and the member removed.
|
||||||
Closed sockets are polled with `isClosed` every second: Relic's server-side
|
Closed sockets are polled with `isClosed` every second: Relic's server-side
|
||||||
`onDone` does not fire for dead peers, so presence never relies on it.
|
`onDone` does not fire for dead peers, so presence never relies on it.
|
||||||
- **Client policies**: no room broadcast for 5 s means connection lost, clear
|
- **Client policies**: no state broadcast for 5 s means connection lost, clear
|
||||||
the server state and reconnect every 1.5 s until cancelled (an epoch counter makes
|
the server state and reconnect every 1.5 s until cancelled (an epoch counter makes
|
||||||
a cancelled attempt stale) or kicked. After a reconnect the ready flag is
|
a cancelled attempt stale) or kicked. After a reconnect the ready flag is
|
||||||
restored from the server's state. Cancel during setup aborts the join.
|
restored from the server's state. Cancel during setup aborts the join.
|
||||||
@@ -96,14 +112,14 @@ Additive wire changes only; bump `proto` and PROTOCOL.md in the same commit.
|
|||||||
Two sources, same sync.
|
Two sources, same sync.
|
||||||
|
|
||||||
- **Link**: the media field holds an http(s) URL. It travels as `media.url`
|
- **Link**: the media field holds an http(s) URL. It travels as `media.url`
|
||||||
in `swap` and `room`, and as `mediaUrl` in `welcome`. Each app plays it
|
in `swap` and `state`, and as `mediaUrl` in `welcome`. Each app plays it
|
||||||
directly; the server is only the clock.
|
directly; the server is only the clock.
|
||||||
- **Host local file**: `RoomServer.setLocalFile` / `mediaPath` makes the file
|
- **Host local file**: `SyncServer.setLocalFile` / `mediaPath` makes the file
|
||||||
the session media. `GET /m/<sid>/<token>/<name>` serves it with a Range
|
the session media. `GET /m/<sid>/<token>/<name>` serves it with a Range
|
||||||
table, gated by the viewer's token. The server builds the URL per viewer
|
table, gated by the viewer's token. The server builds the URL per viewer
|
||||||
(`http://<host>:<port>/m/default/<token>/<name>`), so tokens never reach
|
(`http://<host>:<port>/m/default/<token>/<name>`), so tokens never reach
|
||||||
other viewers. `<host>` is the authority the client dialled; `advertiseHost`
|
other viewers. `<host>` is the authority the client dialled; `advertiseHost`
|
||||||
(default empty) only overrides it when set explicitly. When a `room`
|
(default empty) only overrides it when set explicitly. When a `state`
|
||||||
broadcast has no `media.url`, the client builds the URL itself from the host
|
broadcast has no `media.url`, the client builds the URL itself from the host
|
||||||
it connected to. Bytes stream file to socket and are counted as streamed
|
it connected to. Bytes stream file to socket and are counted as streamed
|
||||||
(`bytesServed`); nothing is stored. The host plays through loopback like
|
(`bytesServed`); nothing is stored. The host plays through loopback like
|
||||||
@@ -146,10 +162,10 @@ Reload video.
|
|||||||
Player lifecycle: the `Player` and its `VideoController` live for one
|
Player lifecycle: the `Player` and its `VideoController` live for one
|
||||||
session: created on join, disposed on leave, replaced by `recreate()` for
|
session: created on join, disposed on leave, replaced by `recreate()` for
|
||||||
"Reload video" (button or Ctrl+R; volume kept). `openUrl` opens with `play: false`; play/pause/seek/rate come only from
|
"Reload video" (button or Ctrl+R; volume kept). `openUrl` opens with `play: false`; play/pause/seek/rate come only from
|
||||||
`RoomDiff` actions or the play gate (`localPlayAllowed`: self in-set and
|
`StateDiff` actions or the play gate (`localPlayAllowed`: self in-set and
|
||||||
ready). A room seek within 0.5 s of the current position is skipped. `leave()`
|
ready). A server seek within 0.5 s of the current position is skipped. `leave()`
|
||||||
stops the player (otherwise audio keeps playing). On media change
|
stops the player (otherwise audio keeps playing). On media change
|
||||||
(`mediaId` bump) the coordinator opens the new URL and `RoomDiff` seeks to 0,
|
(`mediaId` bump) the coordinator opens the new URL and `StateDiff` seeks to 0,
|
||||||
holds and resets rate.
|
holds and resets rate.
|
||||||
|
|
||||||
Frozen picture: `FrameWatchdog` samples about once a second. It hashes a
|
Frozen picture: `FrameWatchdog` samples about once a second. It hashes a
|
||||||
@@ -186,7 +202,7 @@ stalls silently. The app has a `HealthStatus` signal (severity + message)
|
|||||||
for connecting, reconnecting, wrong password, version mismatch, kicked,
|
for connecting, reconnecting, wrong password, version mismatch, kicked,
|
||||||
host offline, and starvation ("waiting for X - N.N s buffered").
|
host offline, and starvation ("waiting for X - N.N s buffered").
|
||||||
Errors are logged with a stack trace at the boundary, then turned into a
|
Errors are logged with a stack trace at the boundary, then turned into a
|
||||||
displayable state. The server's `GET /health` returns JSON (`ok`, `room`,
|
displayable state. The server's `GET /health` returns JSON (`ok`, `name`,
|
||||||
`proto`, `viewers`, `bytesServed`, `uptimeSecs`); it is unauthenticated and
|
`proto`, `viewers`, `bytesServed`, `uptimeSecs`); it is unauthenticated and
|
||||||
the app does not read it.
|
the app does not read it.
|
||||||
|
|
||||||
@@ -217,9 +233,12 @@ this repo, written by `tool/release.sh`.
|
|||||||
- No roles, friends trust model: simplest thing that fits the use.
|
- No roles, friends trust model: simplest thing that fits the use.
|
||||||
- Blank password = open server: the owner chooses between open and private.
|
- Blank password = open server: the owner chooses between open and private.
|
||||||
- The host cannot be kicked: the server dies with the host anyway.
|
- The host cannot be kicked: the server dies with the host anyway.
|
||||||
- Server ships inside the app (and, planned, as a separate compiled server):
|
- Server runs inside the app on an isolate, and ships as a separate compiled
|
||||||
a Flutter binary cannot run headless, so a dedicated server needs its own
|
server: a Flutter binary cannot run headless, so a dedicated server needs
|
||||||
build of `packages/server`.
|
its own build of `packages/server`. An isolate keeps the UI responsive and
|
||||||
|
shares the process (sockets, file paths) without a second executable.
|
||||||
|
- Host by key, not by address: loopback detection broke behind a
|
||||||
|
same-machine reverse proxy or tunnel.
|
||||||
- Server owns the clock; reports never move it: avoids ping-pong between clients.
|
- Server owns the clock; reports never move it: avoids ping-pong between clients.
|
||||||
- Quorum gate: a lone viewer must be able to watch; two or more must start together.
|
- Quorum gate: a lone viewer must be able to watch; two or more must start together.
|
||||||
- Relic for the server, `web_socket_channel` for the client; packages are
|
- Relic for the server, `web_socket_channel` for the client; packages are
|
||||||
@@ -228,5 +247,5 @@ this repo, written by `tool/release.sh`.
|
|||||||
(e.g. a reverse proxy) is a documentation item.
|
(e.g. a reverse proxy) is a documentation item.
|
||||||
- One session per server, id `default`: the map is keyed for later but unused.
|
- One session per server, id `default`: the map is keyed for later but unused.
|
||||||
- No server storage: config and logs only; media never touches the server disk.
|
- No server storage: config and logs only; media never touches the server disk.
|
||||||
- Avatars ride in the 1 Hz `room` broadcast (cap 256 KiB base64 per avatar):
|
- Avatars ride in the 1 Hz `state` broadcast (cap 256 KiB base64 per avatar):
|
||||||
simple, costs host uplink; hash-based serving is on the roadmap.
|
simple, costs host uplink; hash-based serving is on the roadmap.
|
||||||
+31
-6
@@ -12,11 +12,14 @@ mpv libraries come from `media_kit_libs_video`.
|
|||||||
cd apps/desktop && flutter pub get && flutter run -d linux
|
cd apps/desktop && flutter pub get && flutter run -d linux
|
||||||
cd apps/desktop && flutter build linux --release --dart-define=APP_VERSION=<x.y.z>
|
cd apps/desktop && flutter build linux --release --dart-define=APP_VERSION=<x.y.z>
|
||||||
tool/package_linux.sh [--skip-build] [--noupload] # dist/*.tar.gz
|
tool/package_linux.sh [--skip-build] [--noupload] # dist/*.tar.gz
|
||||||
|
cd packages/server && dart compile exe bin/server.dart -o watch-together-server
|
||||||
```
|
```
|
||||||
|
|
||||||
`APP_VERSION` defaults to `0.0.0-dev`; `package_linux.sh` stamps it from the
|
`APP_VERSION` defaults to `0.0.0-dev`; `package_linux.sh` stamps it from the
|
||||||
desktop pubspec. Setting `DISCORD_WEBHOOK_URL` makes the packager post the
|
desktop pubspec. Setting `DISCORD_WEBHOOK_URL` makes the packager post the
|
||||||
tarball to Discord (never commit it).
|
tarball to Discord (never commit it). The packager always compiles the
|
||||||
|
dedicated server (with the Flutter SDK's `dart`) into the tarball as
|
||||||
|
`watch-together-server`, also with `--skip-build`.
|
||||||
|
|
||||||
## Dev loop
|
## Dev loop
|
||||||
|
|
||||||
@@ -26,8 +29,10 @@ Run a server without the app, then point apps at it:
|
|||||||
dart run packages/server/bin/server.dart --port 8080 --secret x [--media FILE]
|
dart run packages/server/bin/server.dart --port 8080 --secret x [--media FILE]
|
||||||
```
|
```
|
||||||
|
|
||||||
Prints `READY port=... room=...` and stops on SIGINT. Then Join from one or
|
This is the same `serve()` the app runs when hosting and the same code as
|
||||||
more apps at `127.0.0.1:8080`. Or just Host from the app. mpv env override:
|
the dedicated binary. Prints `READY port=... name=...` and stops on SIGINT
|
||||||
|
or SIGTERM. Then Join from one or more apps at `127.0.0.1:8080`. Or just
|
||||||
|
Host from the app. mpv env override:
|
||||||
`SYNC_RELAY_HWDEC` (wins over the Settings choice). `SYNC_RELAY_VO` has no
|
`SYNC_RELAY_HWDEC` (wins over the Settings choice). `SYNC_RELAY_VO` has no
|
||||||
effect with embedded video: media_kit forces `vo=libmpv`.
|
effect with embedded video: media_kit forces `vo=libmpv`.
|
||||||
|
|
||||||
@@ -43,13 +48,18 @@ and `e2e_check` (simulated viewers against a live server).
|
|||||||
- `sync_playback_e2e_test.dart` and `coord_presence_test.dart` use real mpv;
|
- `sync_playback_e2e_test.dart` and `coord_presence_test.dart` use real mpv;
|
||||||
the e2e one also expects `~/Videos/chiyo.mp4`. Run only tests related to
|
the e2e one also expects `~/Videos/chiyo.mp4`. Run only tests related to
|
||||||
what you changed.
|
what you changed.
|
||||||
- CI (`.gitea/workflows/ci.yml`): analyze + test for core and server only.
|
- CI (`.gitea/workflows/ci.yml`): analyze + test for core and server, plus
|
||||||
Desktop tests are not in CI.
|
`dart compile exe` of the dedicated server (fails if Flutter creeps into
|
||||||
|
`packages/server`). Desktop tests are not in CI.
|
||||||
|
- `packages/server/test/serve_isolate_test.dart` runs `serve` in a real
|
||||||
|
isolate; `apps/desktop/test/server_isolate_test.dart` covers the app's
|
||||||
|
host path (isolate server, raw WebSocket join, crash, rehost).
|
||||||
|
- Desktop tests that write files use `WT_TEST_TMP` (default: system temp).
|
||||||
|
|
||||||
Traps:
|
Traps:
|
||||||
- Never pump the session screen or media_kit `Video` in `flutter test`: the
|
- Never pump the session screen or media_kit `Video` in `flutter test`: the
|
||||||
runner hangs (kill the `flutter_tester` by PID). Test pure logic
|
runner hangs (kill the `flutter_tester` by PID). Test pure logic
|
||||||
(`RoomDiff`, coordinator without widgets) instead.
|
(`StateDiff`, coordinator without widgets) instead.
|
||||||
- Never depend on a server-side WebSocket `onDone`; assert presence with
|
- Never depend on a server-side WebSocket `onDone`; assert presence with
|
||||||
deadline polling.
|
deadline polling.
|
||||||
- `sub.asFuture()` can resolve before `onDone`; poll with a deadline.
|
- `sub.asFuture()` can resolve before `onDone`; poll with a deadline.
|
||||||
@@ -73,9 +83,24 @@ Tokens come from the environment (`RELEASE_TOKEN`, or Actions-provided
|
|||||||
updater skips the check when absent. Workflows live in `.gitea/workflows`
|
updater skips the check when absent. Workflows live in `.gitea/workflows`
|
||||||
(the forge reads `.gitea` before `.github`).
|
(the forge reads `.gitea` before `.github`).
|
||||||
|
|
||||||
|
## Debugging the hosted server
|
||||||
|
|
||||||
|
When hosting, the server runs on its own isolate (`ServerIsolate`,
|
||||||
|
`apps/desktop/lib/src/state/server_isolate.dart`), in the same process.
|
||||||
|
Breakpoints in `packages/server` work under `flutter run`: the debugger
|
||||||
|
attaches to spawned isolates too (it shows as `server`). Its log lines are
|
||||||
|
printed with a `[server]` prefix. Hot reload reloads the server isolate's
|
||||||
|
code too, but its state (members, clock, sockets) survives until hosting
|
||||||
|
restarts; changes to startup code (`SyncServer.start`, routes) need a new
|
||||||
|
Host. Without the app: `dart run packages/server/bin/server.dart`.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- Relic gives Range as a typed `RangeHeader`; convert from unit + first range.
|
- Relic gives Range as a typed `RangeHeader`; convert from unit + first range.
|
||||||
|
- A killed isolate (`Isolate.kill`) does not close its listening socket:
|
||||||
|
the port stays bound in the process until exit. `SyncServer` binds with
|
||||||
|
`shared: true` so a new hosted server in the same process can take it
|
||||||
|
over; a normal stop closes it.
|
||||||
- Relic server-side `onDone` does not fire for dead peers. Presence is the
|
- Relic server-side `onDone` does not fire for dead peers. Presence is the
|
||||||
`isClosed` sweep + `onError` forget + `lastSeen` timeouts.
|
`isClosed` sweep + `onError` forget + `lastSeen` timeouts.
|
||||||
- `web_socket_channel` 3.x: `channel.ready` errors are unhandled on refused
|
- `web_socket_channel` 3.x: `channel.ready` errors are unhandled on refused
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ A five-minute tour. Details: [ARCHITECTURE.md](ARCHITECTURE.md), wire format:
|
|||||||
|
|
||||||
- **Host from the app**: the app you click Host in also runs the server.
|
- **Host from the app**: the app you click Host in also runs the server.
|
||||||
Simplest. The server lives as long as that app.
|
Simplest. The server lives as long as that app.
|
||||||
- **Dedicated server**: a separate server program on an always-on box.
|
- **Dedicated server**: `watch-together-server`, a separate program in the
|
||||||
Planned, see [ROADMAP.md](ROADMAP.md).
|
release tarball, for an always-on box. See the README.
|
||||||
|
|
||||||
Friends join either way with the Join card on Home.
|
Friends join either way with the Join card on Home.
|
||||||
|
|
||||||
|
|||||||
+19
-14
@@ -1,31 +1,32 @@
|
|||||||
# Protocol (proto = 4)
|
# Protocol (proto = 5)
|
||||||
|
|
||||||
Wire format only. Behavior (gate, clock, timeouts) is in
|
Wire format only. Behavior (gate, clock, timeouts) is in
|
||||||
[ARCHITECTURE.md](ARCHITECTURE.md). Source of truth:
|
[ARCHITECTURE.md](ARCHITECTURE.md). Source of truth:
|
||||||
`packages/core/lib/src/protocol.dart` and `packages/server/lib/src/room_hub.dart`.
|
`packages/core/lib/src/protocol.dart` and `packages/server/lib/src/server_hub.dart`.
|
||||||
|
|
||||||
Any wire change bumps `RelayConstants.protoVersion` and edits this file in
|
Any wire change bumps `RelayConstants.protoVersion` and edits this file in
|
||||||
the same commit. Changes are additive.
|
the same commit. Changes are additive where possible; a client and server
|
||||||
|
with different `proto` refuse each other at `hello` (`proto mismatch`).
|
||||||
|
|
||||||
## Transport
|
## Transport
|
||||||
|
|
||||||
- WebSocket at `/sync`, JSON text frames, each an object with a `t` field.
|
- WebSocket at `/sync`, JSON text frames, each an object with a `t` field.
|
||||||
- The first client frame must be `hello` within 10 s. Server pings every 5 s.
|
- The first client frame must be `hello` within 10 s. Server pings every 5 s.
|
||||||
- Match incoming frames by `t`, never by order: `welcome` can race `room`.
|
- Match incoming frames by `t`, never by order: `welcome` can race `state`.
|
||||||
- Failures: server sends `{"t":"err","message":...}` and closes.
|
- Failures: server sends `{"t":"err","message":...}` and closes.
|
||||||
Messages: `bad hello: ...`, `proto mismatch: got X, want Y`, `wrong secret`,
|
Messages: `bad hello: ...`, `proto mismatch: got X, want Y`, `wrong secret`,
|
||||||
`rejected: <reason>` (e.g. avatar too large), `bad message: ...` (a frame
|
`rejected: <reason>` (e.g. avatar too large), `bad message: ...` (a frame
|
||||||
with a non-finite number, e.g. NaN position), `bad swap media`,
|
with a non-finite number, e.g. NaN position), `bad swap media`,
|
||||||
`swap needs a url or the hosted file` (empty swap).
|
`swap needs a url or the hosted file` (empty swap).
|
||||||
- `GET /health` returns JSON (`ok`, `room`, `proto`, `viewers`, `bytesServed`,
|
- `GET /health` returns JSON (`ok`, `name`, `proto`, `viewers`, `bytesServed`,
|
||||||
`uptimeSecs`; unauthenticated). `GET /m/<sid>/<token>/<name>`
|
`uptimeSecs`; unauthenticated). `GET /m/<sid>/<token>/<name>`
|
||||||
serves the host's file (Range table in ARCHITECTURE.md).
|
serves the host's file (Range table in ARCHITECTURE.md).
|
||||||
|
|
||||||
## Client to server
|
## Client to server
|
||||||
|
|
||||||
```
|
```
|
||||||
{"t":"hello","proto":4,"secret":"","user":{"name":"","avatar":""},
|
{"t":"hello","proto":5,"secret":"","user":{"name":"","avatar":""},
|
||||||
"media":{"name":"","size":0,"duration":0,"url":null}}
|
"media":{"name":"","size":0,"duration":0,"url":null},"hostKey":"..."}
|
||||||
{"t":"ready","ready":true,"source":"manual"} source: manual | auto
|
{"t":"ready","ready":true,"source":"manual"} source: manual | auto
|
||||||
{"t":"force","target":"all"|"<memberId>","ready":true}
|
{"t":"force","target":"all"|"<memberId>","ready":true}
|
||||||
{"t":"seek","position":123.4}
|
{"t":"seek","position":123.4}
|
||||||
@@ -37,8 +38,12 @@ the same commit. Changes are additive.
|
|||||||
|
|
||||||
Field notes:
|
Field notes:
|
||||||
- `hello.secret`: the server password; a server with an empty secret is open.
|
- `hello.secret`: the server password; a server with an empty secret is open.
|
||||||
The member connecting from a loopback address is the host.
|
|
||||||
`hello.media` is carried but not used by the server today.
|
`hello.media` is carried but not used by the server today.
|
||||||
|
- `hello.hostKey` (optional, omitted when empty): the hosting app
|
||||||
|
generates a random key per hosting, gives it to its server and sends it
|
||||||
|
from its own client. The member whose key equals the server's is the host
|
||||||
|
and cannot be kicked. A server without a key (the dedicated server) has no
|
||||||
|
host. Where the connection comes from does not matter.
|
||||||
- `user.avatar`: base64 image, at most 256 KiB of characters. `name` is
|
- `user.avatar`: base64 image, at most 256 KiB of characters. `name` is
|
||||||
trimmed, empty becomes `?`, cut to 32 characters.
|
trimmed, empty becomes `?`, cut to 32 characters.
|
||||||
- `chat.text` is cut to 500 characters.
|
- `chat.text` is cut to 500 characters.
|
||||||
@@ -49,8 +54,8 @@ Field notes:
|
|||||||
## Server to client
|
## Server to client
|
||||||
|
|
||||||
```
|
```
|
||||||
{"t":"welcome","token":"...","memberId":"m1","mediaUrl":"...","state":{<room>}}
|
{"t":"welcome","token":"...","memberId":"m1","mediaUrl":"...","state":{<state>}}
|
||||||
{"t":"room","eventId":7,"transport":"playing"|"held","position":12.3,
|
{"t":"state","eventId":7,"transport":"playing"|"held","position":12.3,
|
||||||
"serverTime":"<ISO-8601 UTC>","mediaId":1,
|
"serverTime":"<ISO-8601 UTC>","mediaId":1,
|
||||||
"media":{name,size,duration,url?}|absent,
|
"media":{name,size,duration,url?}|absent,
|
||||||
"members":[{"id","name","avatar","ready","inSet","buffered","stale"}]}
|
"members":[{"id","name","avatar","ready","inSet","buffered","stale"}]}
|
||||||
@@ -64,8 +69,8 @@ Field notes:
|
|||||||
media URL. `mediaUrl` is omitted when there is no media; it is a built
|
media URL. `mediaUrl` is omitted when there is no media; it is a built
|
||||||
`/m/...` URL for a host file (host part = what the client dialled), otherwise
|
`/m/...` URL for a host file (host part = what the client dialled), otherwise
|
||||||
the shared link.
|
the shared link.
|
||||||
`welcome.state` has the same shape as `room`.
|
`welcome.state` has the same shape as `state`.
|
||||||
- `room` is sent every second and on every change. `position` is the value at
|
- `state` is sent every second and on every change. `position` is the value at
|
||||||
`serverTime`; clients extrapolate while `transport` is `playing`.
|
`serverTime`; clients extrapolate while `transport` is `playing`.
|
||||||
`eventId` increases on seek, swap and transport changes.
|
`eventId` increases on seek, swap and transport changes.
|
||||||
`mediaId` increases on swap.
|
`mediaId` increases on swap.
|
||||||
@@ -80,7 +85,7 @@ Field notes:
|
|||||||
|
|
||||||
## Join and reconnect
|
## Join and reconnect
|
||||||
|
|
||||||
`hello` then `welcome`, then the app opens `mediaUrl`, seeks to the room
|
`hello` then `welcome`, then the app opens `mediaUrl`, seeks to the server
|
||||||
position and buffers. A joining member is `ready=false, inSet=false` until
|
position and buffers. A joining member is `ready=false, inSet=false` until
|
||||||
the first `ready`. A socket that dies loses its token; reconnect is a fresh
|
the first `ready`. A socket that dies loses its token; reconnect is a fresh
|
||||||
`hello`, after which the client re-sends its prior ready flag.
|
`hello`, after which the client re-sends its prior ready flag.
|
||||||
@@ -90,4 +95,4 @@ the first `ready`. A socket that dies loses its token; reconnect is a fresh
|
|||||||
Server-held queue (`queue-add`, `queue-skip`) would auto-advance like a swap.
|
Server-held queue (`queue-add`, `queue-skip`) would auto-advance like a swap.
|
||||||
`RelayConstants.chunkBytes`, `creditWindowChunks`, `maxConcurrentOpen` and the
|
`RelayConstants.chunkBytes`, `creditWindowChunks`, `maxConcurrentOpen` and the
|
||||||
`open`, `ack`, `cancel`, `eof`, `buffered`, `state`, `event` kinds in
|
`open`, `ack`, `cancel`, `eof`, `buffered`, `state`, `event` kinds in
|
||||||
`MessageKind` are unused leftovers.
|
`MessageKind` are unused leftovers (the `state` frame does not use the enum).
|
||||||
+10
-19
@@ -6,17 +6,7 @@ off by deleting it in the same commit as the code. Wire changes also edit
|
|||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
1. **Isolate server + dedicated server.** Replace host detection by loopback
|
1. **Sync fixes.**
|
||||||
peer (breaks behind a same-machine reverse proxy or tunnel) with an explicit
|
|
||||||
host token. Run the server on a Dart isolate
|
|
||||||
when hosting from the app; build the dedicated server as a separate
|
|
||||||
`dart compile exe` of `packages/server` (a Flutter binary cannot be
|
|
||||||
headless); drop `--headless`; rename "room" to "server" in code
|
|
||||||
(`RoomServer`, `RoomHub`, `RoomBroadcast`, wire `t:'room'`, `room` in
|
|
||||||
`/health`, config defaults) with the proto bump that implies; refresh
|
|
||||||
`deploy/` and the packaging.
|
|
||||||
|
|
||||||
2. **Sync fixes.**
|
|
||||||
- Jumpy seek. Root cause: the media_kit seek bar calls `player.seek`
|
- Jumpy seek. Root cause: the media_kit seek bar calls `player.seek`
|
||||||
continuously while dragging; the coordinator only notices through its
|
continuously while dragging; the coordinator only notices through its
|
||||||
1 s position poll (`_detectUserSeek`), so it fights the server echo and
|
1 s position poll (`_detectUserSeek`), so it fights the server echo and
|
||||||
@@ -34,22 +24,23 @@ off by deleting it in the same commit as the code. Wire changes also edit
|
|||||||
a workaround (remove it then).
|
a workaround (remove it then).
|
||||||
- Ghost member after an unclean drop until `dropAfter`.
|
- Ghost member after an unclean drop until `dropAfter`.
|
||||||
|
|
||||||
3. **Rendering, then shaders.** media_kit falls back to software rendering
|
2. **Rendering, then shaders.** media_kit falls back to software rendering
|
||||||
on the dev machine. Suspect Impeller on Linux against media_kit's GL
|
on the dev machine. Suspect Impeller on Linux against media_kit's GL
|
||||||
texture path; investigate first, since shaders need hardware rendering and
|
texture path; investigate first, since shaders need hardware rendering and
|
||||||
it may also cause the video freeze. Then shaders: per-viewer local setting
|
it may also cause the video freeze. Then shaders: per-viewer local setting
|
||||||
using mpv `glsl-shaders`, shipping Anime4K presets (bloc97, MIT).
|
using mpv `glsl-shaders`, shipping Anime4K presets (bloc97, MIT).
|
||||||
|
|
||||||
4. **Windows build + CI.** There is no Windows runner. Try
|
3. **Windows build + CI.** There is no Windows runner. Try
|
||||||
`ubuntu2610/flutter_build` (LLVM-MinGW + Wine `gen_snapshot`) on the Linux
|
`ubuntu2610/flutter_build` (LLVM-MinGW + Wine `gen_snapshot`) on the Linux
|
||||||
runner, fallback a GitHub Actions mirror. The manifest needs per-platform
|
runner, fallback a GitHub Actions mirror. The manifest needs per-platform
|
||||||
URLs and `release_publish.sh` must handle a second platform. The updater's
|
URLs and `release_publish.sh` must handle a second platform. The updater's
|
||||||
Windows apply path exists but is untested. Desktop tests are not in CI
|
Windows apply path exists but is untested. The tarball's
|
||||||
either.
|
`watch-together-server` needs a Windows `dart compile exe` too. Desktop
|
||||||
|
tests are not in CI either.
|
||||||
|
|
||||||
5. **Jellyfin** (epic, below).
|
4. **Jellyfin** (epic, below).
|
||||||
|
|
||||||
6. **Smaller items**
|
5. **Smaller items**
|
||||||
- Link conveniences: Ctrl+V pastes into the media field; a link swap in
|
- Link conveniences: Ctrl+V pastes into the media field; a link swap in
|
||||||
session updates the remembered media; show the open link with an
|
session updates the remembered media; show the open link with an
|
||||||
open-in-browser button.
|
open-in-browser button.
|
||||||
@@ -63,8 +54,8 @@ off by deleting it in the same commit as the code. Wire changes also edit
|
|||||||
OOM, no stuck session, drift recovers.
|
OOM, no stuck session, drift recovers.
|
||||||
- Chat history pane (today: activity log only).
|
- Chat history pane (today: activity log only).
|
||||||
- Avatar caching: serve avatar bytes once by hash instead of base64 in
|
- Avatar caching: serve avatar bytes once by hash instead of base64 in
|
||||||
every `room` broadcast.
|
every `state` broadcast.
|
||||||
- The `room` broadcast carries the full media URL, which can include an
|
- The `state` broadcast carries the full media URL, which can include an
|
||||||
`api_key`.
|
`api_key`.
|
||||||
- TLS: document a reverse proxy in front of the server.
|
- TLS: document a reverse proxy in front of the server.
|
||||||
- Playlist: `queue-add`, `queue-skip`, auto-advance like a swap.
|
- Playlist: `queue-add`, `queue-skip`, auto-advance like a swap.
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ class AppConfig {
|
|||||||
/// Optional explicit host for file-media URLs; empty means use the host the
|
/// Optional explicit host for file-media URLs; empty means use the host the
|
||||||
/// client dialled.
|
/// client dialled.
|
||||||
final String advertiseHost;
|
final String advertiseHost;
|
||||||
|
/// Secret the hosting app's own client sends in hello to be marked host
|
||||||
|
/// (unkickable). Empty means no member is the host (dedicated server).
|
||||||
|
final String hostKey;
|
||||||
|
|
||||||
const AppConfig({
|
const AppConfig({
|
||||||
required this.port,
|
required this.port,
|
||||||
@@ -23,20 +26,22 @@ class AppConfig {
|
|||||||
this.mediaPath = '',
|
this.mediaPath = '',
|
||||||
this.logLevel = 'INFO',
|
this.logLevel = 'INFO',
|
||||||
this.advertiseHost = '',
|
this.advertiseHost = '',
|
||||||
|
this.hostKey = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
factory AppConfig.defaults() =>
|
factory AppConfig.defaults() =>
|
||||||
const AppConfig(port: 8080, secret: '', name: 'room');
|
const AppConfig(port: 8080, secret: '', name: 'server');
|
||||||
|
|
||||||
factory AppConfig.fromMap(Map<String, Object?> map) {
|
factory AppConfig.fromMap(Map<String, Object?> map) {
|
||||||
try {
|
try {
|
||||||
return AppConfig(
|
return AppConfig(
|
||||||
port: (map['port'] as num?)?.toInt() ?? 8080,
|
port: (map['port'] as num?)?.toInt() ?? 8080,
|
||||||
secret: (map['secret'] as String?) ?? '',
|
secret: (map['secret'] as String?) ?? '',
|
||||||
name: (map['name'] as String?) ?? 'room',
|
name: (map['name'] as String?) ?? 'server',
|
||||||
mediaPath: (map['mediaPath'] as String?) ?? '',
|
mediaPath: (map['mediaPath'] as String?) ?? '',
|
||||||
logLevel: (map['logLevel'] as String?) ?? 'INFO',
|
logLevel: (map['logLevel'] as String?) ?? 'INFO',
|
||||||
advertiseHost: (map['advertiseHost'] as String?) ?? '',
|
advertiseHost: (map['advertiseHost'] as String?) ?? '',
|
||||||
|
hostKey: (map['hostKey'] as String?) ?? '',
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw ConfigException('invalid config map: $e');
|
throw ConfigException('invalid config map: $e');
|
||||||
@@ -50,6 +55,7 @@ class AppConfig {
|
|||||||
'mediaPath': mediaPath,
|
'mediaPath': mediaPath,
|
||||||
'logLevel': logLevel,
|
'logLevel': logLevel,
|
||||||
'advertiseHost': advertiseHost,
|
'advertiseHost': advertiseHost,
|
||||||
|
'hostKey': hostKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
static Future<AppConfig> loadFile(String path) async {
|
static Future<AppConfig> loadFile(String path) async {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
abstract class RelayConstants {
|
abstract class RelayConstants {
|
||||||
static const int protoVersion = 4;
|
static const int protoVersion = 5;
|
||||||
|
|
||||||
static const int chunkBytes = 256 * 1024;
|
static const int chunkBytes = 256 * 1024;
|
||||||
static const int creditWindowChunks = 8;
|
static const int creditWindowChunks = 8;
|
||||||
@@ -19,7 +19,7 @@ abstract class RelayConstants {
|
|||||||
// leaves wide headroom over any 128x128 pipeline PNG (raw bitmap
|
// leaves wide headroom over any 128x128 pipeline PNG (raw bitmap
|
||||||
// <= 64 KiB, worst-case base64 ~95 KiB chars) — deliberate over-size
|
// <= 64 KiB, worst-case base64 ~95 KiB chars) — deliberate over-size
|
||||||
// for private-server use. PNG keeps transparency; JPEG did not.
|
// for private-server use. PNG keeps transparency; JPEG did not.
|
||||||
// Trade: avatars ride in the 1 Hz room broadcast, so worst-case host
|
// Trade: avatars ride in the 1 Hz state broadcast, so worst-case host
|
||||||
// uplink scales with this cap. Follow-up (not now): send avatar bytes
|
// uplink scales with this cap. Follow-up (not now): send avatar bytes
|
||||||
// once, reference by hash afterwards.
|
// once, reference by hash afterwards.
|
||||||
static const int maxAvatarBytes = 256 * 1024;
|
static const int maxAvatarBytes = 256 * 1024;
|
||||||
|
|||||||
@@ -67,7 +67,15 @@ class HelloMessage {
|
|||||||
final String secret;
|
final String secret;
|
||||||
final HelloUser user;
|
final HelloUser user;
|
||||||
final HelloMedia media;
|
final HelloMedia media;
|
||||||
const HelloMessage({required this.proto, required this.secret, required this.user, required this.media});
|
/// The hosting app's own client proves it is the host with this; empty
|
||||||
|
/// for everyone else.
|
||||||
|
final String hostKey;
|
||||||
|
const HelloMessage(
|
||||||
|
{required this.proto,
|
||||||
|
required this.secret,
|
||||||
|
required this.user,
|
||||||
|
required this.media,
|
||||||
|
this.hostKey = ''});
|
||||||
|
|
||||||
Map<String, Object?> toJson() => {
|
Map<String, Object?> toJson() => {
|
||||||
't': 'hello',
|
't': 'hello',
|
||||||
@@ -75,6 +83,7 @@ class HelloMessage {
|
|||||||
'secret': secret,
|
'secret': secret,
|
||||||
'user': user.toJson(),
|
'user': user.toJson(),
|
||||||
'media': media.toJson(),
|
'media': media.toJson(),
|
||||||
|
if (hostKey.isNotEmpty) 'hostKey': hostKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
factory HelloMessage.fromJson(Map<String, Object?> json) {
|
factory HelloMessage.fromJson(Map<String, Object?> json) {
|
||||||
@@ -87,16 +96,17 @@ class HelloMessage {
|
|||||||
secret: (json['secret'] as String?) ?? '',
|
secret: (json['secret'] as String?) ?? '',
|
||||||
user: HelloUser.fromJson(Map<String, Object?>.from(user)),
|
user: HelloUser.fromJson(Map<String, Object?>.from(user)),
|
||||||
media: HelloMedia.fromJson(Map<String, Object?>.from(media)),
|
media: HelloMedia.fromJson(Map<String, Object?>.from(media)),
|
||||||
|
hostKey: (json['hostKey'] as String?) ?? '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class RoomState {
|
class SyncState {
|
||||||
final double position;
|
final double position;
|
||||||
final bool paused;
|
final bool paused;
|
||||||
final int mediaId;
|
final int mediaId;
|
||||||
final DateTime serverTime;
|
final DateTime serverTime;
|
||||||
const RoomState({required this.position, required this.paused, required this.mediaId, required this.serverTime});
|
const SyncState({required this.position, required this.paused, required this.mediaId, required this.serverTime});
|
||||||
|
|
||||||
Map<String, Object?> toJson() => {
|
Map<String, Object?> toJson() => {
|
||||||
'position': position,
|
'position': position,
|
||||||
@@ -105,7 +115,7 @@ class RoomState {
|
|||||||
'serverTime': serverTime.toIso8601String(),
|
'serverTime': serverTime.toIso8601String(),
|
||||||
};
|
};
|
||||||
|
|
||||||
factory RoomState.fromJson(Map<String, Object?> json) => RoomState(
|
factory SyncState.fromJson(Map<String, Object?> json) => SyncState(
|
||||||
position: finiteDouble(json['position'], 'position'),
|
position: finiteDouble(json['position'], 'position'),
|
||||||
paused: (json['paused'] as bool?) ?? true,
|
paused: (json['paused'] as bool?) ?? true,
|
||||||
mediaId: finiteInt(json['mediaId'], 'mediaId', fallback: 0),
|
mediaId: finiteInt(json['mediaId'], 'mediaId', fallback: 0),
|
||||||
@@ -296,7 +306,7 @@ class MemberView {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class RoomBroadcast {
|
class StateBroadcast {
|
||||||
final int eventId;
|
final int eventId;
|
||||||
final String transport;
|
final String transport;
|
||||||
final double position;
|
final double position;
|
||||||
@@ -304,7 +314,7 @@ class RoomBroadcast {
|
|||||||
final int mediaId;
|
final int mediaId;
|
||||||
final Map<String, Object?>? media;
|
final Map<String, Object?>? media;
|
||||||
final List<MemberView> members;
|
final List<MemberView> members;
|
||||||
const RoomBroadcast({
|
const StateBroadcast({
|
||||||
required this.eventId,
|
required this.eventId,
|
||||||
required this.transport,
|
required this.transport,
|
||||||
required this.position,
|
required this.position,
|
||||||
@@ -315,7 +325,7 @@ class RoomBroadcast {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Map<String, Object?> toJson() => {
|
Map<String, Object?> toJson() => {
|
||||||
't': 'room',
|
't': 'state',
|
||||||
'eventId': eventId,
|
'eventId': eventId,
|
||||||
'transport': transport,
|
'transport': transport,
|
||||||
'position': position,
|
'position': position,
|
||||||
@@ -325,11 +335,11 @@ class RoomBroadcast {
|
|||||||
'members': members.map((m) => m.toJson()).toList(),
|
'members': members.map((m) => m.toJson()).toList(),
|
||||||
};
|
};
|
||||||
|
|
||||||
factory RoomBroadcast.fromJson(Map<String, Object?> json) {
|
factory StateBroadcast.fromJson(Map<String, Object?> json) {
|
||||||
if (json['t'] != 'room') throw const ProtocolException('not a room broadcast');
|
if (json['t'] != 'state') throw const ProtocolException('not a state broadcast');
|
||||||
final members = json['members'];
|
final members = json['members'];
|
||||||
final media = json['media'];
|
final media = json['media'];
|
||||||
return RoomBroadcast(
|
return StateBroadcast(
|
||||||
eventId: finiteInt(json['eventId'], 'eventId', fallback: 0),
|
eventId: finiteInt(json['eventId'], 'eventId', fallback: 0),
|
||||||
transport: (json['transport'] as String?) ?? 'held',
|
transport: (json['transport'] as String?) ?? 'held',
|
||||||
position: finiteDouble(json['position'], 'position'),
|
position: finiteDouble(json['position'], 'position'),
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import 'constants.dart';
|
import 'constants.dart';
|
||||||
import 'protocol.dart';
|
import 'protocol.dart';
|
||||||
|
|
||||||
typedef RoomClock = DateTime Function();
|
typedef SyncClock = DateTime Function();
|
||||||
|
|
||||||
enum Transport { playing, held }
|
enum Transport { playing, held }
|
||||||
|
|
||||||
enum Correction { none, slowDown, seek }
|
enum Correction { none, slowDown, seek }
|
||||||
|
|
||||||
Correction correctionFor({required double ownPosition, required double roomPosition}) {
|
Correction correctionFor({required double ownPosition, required double serverPosition}) {
|
||||||
final drift = ownPosition - roomPosition;
|
final drift = ownPosition - serverPosition;
|
||||||
if (drift.abs() > RelayConstants.driftSeekThresholdSecs) return Correction.seek;
|
if (drift.abs() > RelayConstants.driftSeekThresholdSecs) return Correction.seek;
|
||||||
if (drift > RelayConstants.slowDownThresholdSecs) return Correction.slowDown;
|
if (drift > RelayConstants.slowDownThresholdSecs) return Correction.slowDown;
|
||||||
return Correction.none;
|
return Correction.none;
|
||||||
@@ -52,8 +52,8 @@ class EngineFeed {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class RoomEngine {
|
class SyncEngine {
|
||||||
final RoomClock clock;
|
final SyncClock clock;
|
||||||
final Duration staleAfter;
|
final Duration staleAfter;
|
||||||
final Duration dropAfter;
|
final Duration dropAfter;
|
||||||
final EngineFeed feed = EngineFeed();
|
final EngineFeed feed = EngineFeed();
|
||||||
@@ -67,7 +67,7 @@ class RoomEngine {
|
|||||||
HelloMedia? media;
|
HelloMedia? media;
|
||||||
int _nextId = 1;
|
int _nextId = 1;
|
||||||
|
|
||||||
RoomEngine({RoomClock? clock, Duration? staleAfter, Duration? dropAfter})
|
SyncEngine({SyncClock? clock, Duration? staleAfter, Duration? dropAfter})
|
||||||
: clock = clock ?? (() => DateTime.now().toUtc()),
|
: clock = clock ?? (() => DateTime.now().toUtc()),
|
||||||
staleAfter = staleAfter ?? const Duration(seconds: 15),
|
staleAfter = staleAfter ?? const Duration(seconds: 15),
|
||||||
dropAfter = dropAfter ?? const Duration(seconds: 30),
|
dropAfter = dropAfter ?? const Duration(seconds: 30),
|
||||||
@@ -249,7 +249,7 @@ class RoomEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RoomBroadcast broadcast() => RoomBroadcast(
|
StateBroadcast broadcast() => StateBroadcast(
|
||||||
eventId: eventId,
|
eventId: eventId,
|
||||||
transport: transport == Transport.playing ? 'playing' : 'held',
|
transport: transport == Transport.playing ? 'playing' : 'held',
|
||||||
position: position,
|
position: position,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ void main() {
|
|||||||
test('fromMap tolerates missing keys', () {
|
test('fromMap tolerates missing keys', () {
|
||||||
final c = AppConfig.fromMap({});
|
final c = AppConfig.fromMap({});
|
||||||
expect(c.port, 8080);
|
expect(c.port, 8080);
|
||||||
expect(c.name, 'room');
|
expect(c.name, 'server');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('round-trips through map', () {
|
test('round-trips through map', () {
|
||||||
@@ -24,8 +24,10 @@ void main() {
|
|||||||
secret: 's',
|
secret: 's',
|
||||||
name: 'n',
|
name: 'n',
|
||||||
mediaPath: 'm',
|
mediaPath: 'm',
|
||||||
advertiseHost: '1.2.3.4');
|
advertiseHost: '1.2.3.4',
|
||||||
|
hostKey: 'hk');
|
||||||
final back = AppConfig.fromMap(c.toMap());
|
final back = AppConfig.fromMap(c.toMap());
|
||||||
|
expect(back.hostKey, 'hk');
|
||||||
expect(back.port, 1234);
|
expect(back.port, 1234);
|
||||||
expect(back.secret, 's');
|
expect(back.secret, 's');
|
||||||
expect(back.mediaPath, 'm');
|
expect(back.mediaPath, 'm');
|
||||||
@@ -64,15 +66,29 @@ void main() {
|
|||||||
final back = HelloMessage.fromJson(Map<String, Object?>.from(msg.toJson()));
|
final back = HelloMessage.fromJson(Map<String, Object?>.from(msg.toJson()));
|
||||||
expect(back.user.name, 'alice');
|
expect(back.user.name, 'alice');
|
||||||
expect(back.media.size, 1500000000);
|
expect(back.media.size, 1500000000);
|
||||||
|
expect(back.hostKey, isEmpty);
|
||||||
|
expect(msg.toJson().containsKey('hostKey'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hello carries hostKey when set', () {
|
||||||
|
const msg = HelloMessage(
|
||||||
|
proto: 1,
|
||||||
|
secret: '',
|
||||||
|
user: HelloUser(name: 'host'),
|
||||||
|
media: HelloMedia(name: '', size: 0, duration: 0),
|
||||||
|
hostKey: 'hk',
|
||||||
|
);
|
||||||
|
final back = HelloMessage.fromJson(Map<String, Object?>.from(msg.toJson()));
|
||||||
|
expect(back.hostKey, 'hk');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('hello rejects wrong type', () {
|
test('hello rejects wrong type', () {
|
||||||
expect(() => HelloMessage.fromJson({'t': 'bye'}), throwsA(isA<ProtocolException>()));
|
expect(() => HelloMessage.fromJson({'t': 'bye'}), throwsA(isA<ProtocolException>()));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('room state round-trips', () {
|
test('sync state round-trips', () {
|
||||||
final s = RoomState(position: 12.5, paused: false, mediaId: 3, serverTime: DateTime.utc(2026, 1, 1));
|
final s = SyncState(position: 12.5, paused: false, mediaId: 3, serverTime: DateTime.utc(2026, 1, 1));
|
||||||
final back = RoomState.fromJson(Map<String, Object?>.from(s.toJson()));
|
final back = SyncState.fromJson(Map<String, Object?>.from(s.toJson()));
|
||||||
expect(back.position, 12.5);
|
expect(back.position, 12.5);
|
||||||
expect(back.paused, isFalse);
|
expect(back.paused, isFalse);
|
||||||
expect(back.mediaId, 3);
|
expect(back.mediaId, 3);
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import 'package:test/test.dart';
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late DateTime t;
|
late DateTime t;
|
||||||
late RoomEngine engine;
|
late SyncEngine engine;
|
||||||
DateTime clock() => t;
|
DateTime clock() => t;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
t = DateTime.utc(2026, 1, 1);
|
t = DateTime.utc(2026, 1, 1);
|
||||||
engine = RoomEngine(clock: clock);
|
engine = SyncEngine(clock: clock);
|
||||||
});
|
});
|
||||||
|
|
||||||
String join(String token, String name) => engine.applyJoin(token, name, '');
|
String join(String token, String name) => engine.applyJoin(token, name, '');
|
||||||
@@ -37,12 +37,12 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('membership + gate', () {
|
group('membership + gate', () {
|
||||||
test('join lands out-of-set, room stays held', () {
|
test('join lands out-of-set, playback stays held', () {
|
||||||
join('a', 'alice');
|
join('a', 'alice');
|
||||||
expect(engine.transport, Transport.held);
|
expect(engine.transport, Transport.held);
|
||||||
expect(engine.members['a']!.inSet, isFalse);
|
expect(engine.members['a']!.inSet, isFalse);
|
||||||
final room = engine.broadcast();
|
final state = engine.broadcast();
|
||||||
expect(room.members.single.inSet, isFalse);
|
expect(state.members.single.inSet, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('single ready + buffered auto-plays', () {
|
test('single ready + buffered auto-plays', () {
|
||||||
@@ -54,7 +54,7 @@ void main() {
|
|||||||
expect(engine.transport, Transport.playing);
|
expect(engine.transport, Transport.playing);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('late joiner does not halt a playing room', () {
|
test('late joiner does not halt playback', () {
|
||||||
join('a', 'alice');
|
join('a', 'alice');
|
||||||
engine.applyReady('a', true);
|
engine.applyReady('a', true);
|
||||||
buffer('a', 4.0);
|
buffer('a', 4.0);
|
||||||
@@ -77,7 +77,7 @@ void main() {
|
|||||||
expect(engine.members['b']!.inSet, isTrue);
|
expect(engine.members['b']!.inSet, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('disconnect removes from quorum, room resumes', () {
|
test('disconnect removes from quorum, playback resumes', () {
|
||||||
join('a', 'alice');
|
join('a', 'alice');
|
||||||
join('b', 'bob');
|
join('b', 'bob');
|
||||||
engine.applyReady('a', true);
|
engine.applyReady('a', true);
|
||||||
@@ -218,7 +218,7 @@ void main() {
|
|||||||
expect(engine.members['a']!.ready, isTrue);
|
expect(engine.members['a']!.ready, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('unpause uses room clock, laggards seek forward', () {
|
test('unpause uses server clock, laggards seek forward', () {
|
||||||
join('a', 'alice');
|
join('a', 'alice');
|
||||||
join('b', 'bob');
|
join('b', 'bob');
|
||||||
engine.applyReady('a', true);
|
engine.applyReady('a', true);
|
||||||
@@ -355,29 +355,29 @@ void main() {
|
|||||||
|
|
||||||
group('tiered correction', () {
|
group('tiered correction', () {
|
||||||
test('big drift seeks either direction', () {
|
test('big drift seeks either direction', () {
|
||||||
expect(correctionFor(ownPosition: 100, roomPosition: 0), Correction.seek);
|
expect(correctionFor(ownPosition: 100, serverPosition: 0), Correction.seek);
|
||||||
expect(correctionFor(ownPosition: 0, roomPosition: 100), Correction.seek);
|
expect(correctionFor(ownPosition: 0, serverPosition: 100), Correction.seek);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ahead beyond 1.5s slows to 0.95x', () {
|
test('ahead beyond 1.5s slows to 0.95x', () {
|
||||||
expect(correctionFor(ownPosition: 3, roomPosition: 0), Correction.slowDown);
|
expect(correctionFor(ownPosition: 3, serverPosition: 0), Correction.slowDown);
|
||||||
expect(correctionFor(ownPosition: 1.6, roomPosition: 0), Correction.slowDown);
|
expect(correctionFor(ownPosition: 1.6, serverPosition: 0), Correction.slowDown);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deadzone does nothing', () {
|
test('deadzone does nothing', () {
|
||||||
expect(correctionFor(ownPosition: 1.0, roomPosition: 0), Correction.none);
|
expect(correctionFor(ownPosition: 1.0, serverPosition: 0), Correction.none);
|
||||||
expect(correctionFor(ownPosition: 0, roomPosition: 3.0), Correction.none);
|
expect(correctionFor(ownPosition: 0, serverPosition: 3.0), Correction.none);
|
||||||
expect(correctionFor(ownPosition: 5.1, roomPosition: 0), Correction.seek);
|
expect(correctionFor(ownPosition: 5.1, serverPosition: 0), Correction.seek);
|
||||||
expect(correctionFor(ownPosition: 5.0, roomPosition: 0), Correction.slowDown);
|
expect(correctionFor(ownPosition: 5.0, serverPosition: 0), Correction.slowDown);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('broadcast shape', () {
|
group('broadcast shape', () {
|
||||||
test('room JSON round-trips with member views', () {
|
test('state JSON round-trips with member views', () {
|
||||||
join('a', 'alice');
|
join('a', 'alice');
|
||||||
final room = engine.broadcast();
|
final state = engine.broadcast();
|
||||||
final back =
|
final back =
|
||||||
RoomBroadcast.fromJson(Map<String, Object?>.from(room.toJson()));
|
StateBroadcast.fromJson(Map<String, Object?>.from(state.toJson()));
|
||||||
expect(back.transport, 'held');
|
expect(back.transport, 'held');
|
||||||
expect(back.members.single.name, 'alice');
|
expect(back.members.single.name, 'alice');
|
||||||
expect(back.members.single.id, 'm1');
|
expect(back.members.single.id, 'm1');
|
||||||
|
|||||||
@@ -1,47 +1,91 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:isolate';
|
||||||
|
|
||||||
import 'package:args/args.dart';
|
import 'package:args/args.dart';
|
||||||
import 'package:logging/logging.dart' as pkg_logging;
|
import 'package:logging/logging.dart' as pkg_logging;
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
import 'package:sync_relay_server/server.dart';
|
import 'package:sync_relay_server/server.dart';
|
||||||
|
|
||||||
|
/// Dedicated server: `dart compile exe` of this file is shipped as
|
||||||
|
/// `watch-together-server`. Runs [serve] until SIGINT or SIGTERM.
|
||||||
Future<void> main(List<String> args) async {
|
Future<void> main(List<String> args) async {
|
||||||
final parser = ArgParser()
|
final parser = ArgParser()
|
||||||
..addOption('port', abbr: 'p', defaultsTo: '8080')
|
..addOption('port', abbr: 'p', defaultsTo: '8080', help: 'TCP port to listen on (all IPv4 interfaces).')
|
||||||
..addOption('secret', defaultsTo: '')
|
..addOption('secret', defaultsTo: '', help: 'Shared password. Empty means an open server: anyone who can reach it can join.')
|
||||||
..addOption('name', defaultsTo: 'room')
|
..addOption('name', defaultsTo: 'server', help: 'Server name, reported by /health.')
|
||||||
..addOption('media', defaultsTo: '')
|
..addOption('media', defaultsTo: '', help: 'Local file to serve as the starting media.')
|
||||||
..addOption('advertise-host', defaultsTo: '')
|
..addOption('advertise-host', defaultsTo: '', help: 'Host put in file-media URLs instead of the one each client dialled.')
|
||||||
..addOption('config')
|
..addOption('config', help: 'YAML or JSON config file (keys as in AppConfig); overrides the other flags.')
|
||||||
..addFlag('help', abbr: 'h', negatable: false);
|
..addFlag('help', abbr: 'h', negatable: false, help: 'Show this help.');
|
||||||
final parsed = parser.parse(args);
|
final ArgResults parsed;
|
||||||
|
try {
|
||||||
|
parsed = parser.parse(args);
|
||||||
|
} on FormatException catch (e) {
|
||||||
|
stderr.writeln('${e.message}\n\n${parser.usage}');
|
||||||
|
exitCode = 64;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (parsed['help'] as bool) {
|
if (parsed['help'] as bool) {
|
||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
print(parser.usage);
|
print('watch-together-server (proto ${RelayConstants.protoVersion})\n\n${parser.usage}');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
initLogging(level: pkg_logging.Level.INFO);
|
initLogging(level: pkg_logging.Level.INFO);
|
||||||
AppConfig config;
|
AppConfig config;
|
||||||
final configPath = parsed['config'] as String?;
|
final configPath = parsed['config'] as String?;
|
||||||
|
try {
|
||||||
if (configPath != null) {
|
if (configPath != null) {
|
||||||
config = await AppConfig.loadFile(configPath);
|
config = await AppConfig.loadFile(configPath);
|
||||||
} else {
|
} else {
|
||||||
|
final port = int.tryParse('${parsed['port']}');
|
||||||
|
if (port == null || port < 0 || port > 65535) {
|
||||||
|
throw ConfigException('bad --port: ${parsed['port']}');
|
||||||
|
}
|
||||||
config = AppConfig(
|
config = AppConfig(
|
||||||
port: int.tryParse('${parsed['port']}') ?? 8080,
|
port: port,
|
||||||
secret: '${parsed['secret']}',
|
secret: '${parsed['secret']}',
|
||||||
name: '${parsed['name']}',
|
name: '${parsed['name']}',
|
||||||
mediaPath: '${parsed['media']}',
|
mediaPath: '${parsed['media']}',
|
||||||
advertiseHost: '${parsed['advertise-host']}',
|
advertiseHost: '${parsed['advertise-host']}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} on ConfigException catch (e) {
|
||||||
|
stderr.writeln(e.message);
|
||||||
|
exitCode = 64;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (config.secret.isEmpty) {
|
||||||
|
stderr.writeln(
|
||||||
|
'WARNING: no --secret set: this is an open server, anyone who can reach port ${config.port} can join.');
|
||||||
|
}
|
||||||
|
|
||||||
final server = RoomServer(config);
|
final events = ReceivePort('server events');
|
||||||
await server.start();
|
final done = serve(config.toMap(), events: events.sendPort);
|
||||||
|
final signals = <StreamSubscription<ProcessSignal>>[];
|
||||||
|
await for (final msg in events) {
|
||||||
|
if (msg is! Map) continue;
|
||||||
|
if (msg['t'] == 'error') {
|
||||||
|
stderr.writeln('server failed to start: ${msg['message']}');
|
||||||
|
exitCode = 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (msg['t'] == 'ready') {
|
||||||
|
final stop = msg['stop'] as SendPort;
|
||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
print('READY port=${server.boundPort} room=${config.name}');
|
print('READY port=${msg['port']} name=${config.name}');
|
||||||
|
void onSignal(ProcessSignal _) => stop.send({'t': 'stop'});
|
||||||
await ProcessSignal.sigint.watch().first;
|
signals.add(ProcessSignal.sigint.watch().listen(onSignal));
|
||||||
await server.stop();
|
if (!Platform.isWindows) {
|
||||||
|
signals.add(ProcessSignal.sigterm.watch().listen(onSignal));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await done;
|
||||||
|
events.close();
|
||||||
|
for (final s in signals) {
|
||||||
|
await s.cancel();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export 'src/media_endpoint.dart';
|
export 'src/media_endpoint.dart';
|
||||||
export 'src/room_hub.dart';
|
export 'src/serve.dart';
|
||||||
export 'src/room_server.dart';
|
export 'src/server_hub.dart';
|
||||||
export 'src/session.dart';
|
export 'src/session.dart';
|
||||||
|
export 'src/sync_server.dart';
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:isolate';
|
||||||
|
|
||||||
|
import 'package:logging/logging.dart' as pkg_logging;
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
|
import 'sync_server.dart';
|
||||||
|
|
||||||
|
/// The one server entrypoint: the dedicated binary and the app's hosting
|
||||||
|
/// isolate both run this. [config] holds primitives only (an
|
||||||
|
/// [AppConfig.toMap]) so it can cross an isolate boundary.
|
||||||
|
///
|
||||||
|
/// On [events] it reports `{'t':'ready','port':p,'stop':SendPort}` or
|
||||||
|
/// `{'t':'error','message':m}`, and forwards log records as
|
||||||
|
/// `{'t':'log','level','logger','message'[,'error']}`. Sending
|
||||||
|
/// `{'t':'stop'}` to the `stop` port stops the server; the returned future
|
||||||
|
/// completes once it is down. Without [events] a start failure throws.
|
||||||
|
Future<void> serve(Map<String, Object?> config, {SendPort? events}) async {
|
||||||
|
StreamSubscription<pkg_logging.LogRecord>? logs;
|
||||||
|
if (events != null) {
|
||||||
|
logs = pkg_logging.Logger.root.onRecord.listen((r) => events.send({
|
||||||
|
't': 'log',
|
||||||
|
'level': r.level.name,
|
||||||
|
'logger': r.loggerName,
|
||||||
|
'message': r.message,
|
||||||
|
if (r.error != null) 'error': '${r.error}',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
final SyncServer server;
|
||||||
|
try {
|
||||||
|
server = SyncServer(AppConfig.fromMap(config));
|
||||||
|
await server.start();
|
||||||
|
} catch (e) {
|
||||||
|
await logs?.cancel();
|
||||||
|
if (events == null) rethrow;
|
||||||
|
events.send({'t': 'error', 'message': '$e'});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final control = ReceivePort('serve control');
|
||||||
|
events?.send({'t': 'ready', 'port': server.boundPort, 'stop': control.sendPort});
|
||||||
|
await for (final msg in control) {
|
||||||
|
if (msg is Map && msg['t'] == 'stop') break;
|
||||||
|
}
|
||||||
|
control.close();
|
||||||
|
await server.stop();
|
||||||
|
await logs?.cancel();
|
||||||
|
}
|
||||||
@@ -7,20 +7,25 @@ import 'package:web_socket/web_socket.dart';
|
|||||||
|
|
||||||
import 'session.dart';
|
import 'session.dart';
|
||||||
|
|
||||||
final _hubLog = createLogger('room_hub');
|
final _hubLog = createLogger('server_hub');
|
||||||
|
|
||||||
class RoomHub {
|
class ServerHub {
|
||||||
final SessionMap sessions;
|
final SessionMap sessions;
|
||||||
final String expectedSecret;
|
final String expectedSecret;
|
||||||
final RoomEngine engine;
|
/// See [AppConfig.hostKey]; empty means nobody is the host.
|
||||||
|
final String hostKey;
|
||||||
|
final SyncEngine engine;
|
||||||
final Map<String, RelicWebSocket> sockets = {};
|
final Map<String, RelicWebSocket> sockets = {};
|
||||||
|
|
||||||
RoomHub(this.sessions, this.expectedSecret, {RoomEngine? engine})
|
ServerHub(this.sessions, this.expectedSecret,
|
||||||
: engine = engine ?? RoomEngine();
|
{SyncEngine? engine, this.hostKey = ''})
|
||||||
|
: engine = engine ?? SyncEngine();
|
||||||
|
|
||||||
|
bool isHostKey(String key) => hostKey.isNotEmpty && key == hostKey;
|
||||||
|
|
||||||
Session get session => sessions.getOrCreate('default', expectedSecret);
|
Session get session => sessions.getOrCreate('default', expectedSecret);
|
||||||
|
|
||||||
void broadcastRoom() {
|
void broadcastState() {
|
||||||
final raw = jsonEncode(engine.broadcast().toJson());
|
final raw = jsonEncode(engine.broadcast().toJson());
|
||||||
for (final ws in sockets.values.toList()) {
|
for (final ws in sockets.values.toList()) {
|
||||||
ws.trySendText(raw);
|
ws.trySendText(raw);
|
||||||
@@ -81,7 +86,7 @@ class RoomHub {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void changed() {
|
void changed() {
|
||||||
broadcastRoom();
|
broadcastState();
|
||||||
broadcastFeed();
|
broadcastFeed();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,18 +119,13 @@ String sanitizeMediaName(String name) {
|
|||||||
return 'media';
|
return 'media';
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isLoopback(String address) =>
|
|
||||||
address == '::1' ||
|
|
||||||
address.startsWith('127.') ||
|
|
||||||
address.startsWith('::ffff:127.');
|
|
||||||
|
|
||||||
void wsErr(RelicWebSocket ws, String message) {
|
void wsErr(RelicWebSocket ws, String message) {
|
||||||
ws.trySendText(jsonEncode({'t': 'err', 'message': message}));
|
ws.trySendText(jsonEncode({'t': 'err', 'message': message}));
|
||||||
}
|
}
|
||||||
|
|
||||||
void mountSyncRoute(
|
void mountSyncRoute(
|
||||||
RelicApp app,
|
RelicApp app,
|
||||||
RoomHub hub,
|
ServerHub hub,
|
||||||
String Function(String sid, String token, SessionMedia? media, String host)
|
String Function(String sid, String token, SessionMedia? media, String host)
|
||||||
urlBuilder,
|
urlBuilder,
|
||||||
) {
|
) {
|
||||||
@@ -134,7 +134,6 @@ void mountSyncRoute(
|
|||||||
final dialledHost = req.url.hasPort
|
final dialledHost = req.url.hasPort
|
||||||
? '${req.url.host.contains(':') ? '[${req.url.host}]' : req.url.host}:${req.url.port}'
|
? '${req.url.host.contains(':') ? '[${req.url.host}]' : req.url.host}:${req.url.port}'
|
||||||
: (req.url.host.contains(':') ? '[${req.url.host}]' : req.url.host);
|
: (req.url.host.contains(':') ? '[${req.url.host}]' : req.url.host);
|
||||||
final fromLoopback = _isLoopback(req.connectionInfo.remote.address.toString());
|
|
||||||
return WebSocketUpgrade((final ws) async {
|
return WebSocketUpgrade((final ws) async {
|
||||||
ws.pingInterval = Duration(seconds: RelayConstants.pingIntervalSecs);
|
ws.pingInterval = Duration(seconds: RelayConstants.pingIntervalSecs);
|
||||||
final firstText = Completer<String>();
|
final firstText = Completer<String>();
|
||||||
@@ -221,7 +220,7 @@ void mountSyncRoute(
|
|||||||
final report = ReportMessage.fromJson(decoded);
|
final report = ReportMessage.fromJson(decoded);
|
||||||
hub.engine.applyReport(
|
hub.engine.applyReport(
|
||||||
token, report.position, report.buffered, report.eventId);
|
token, report.position, report.buffered, report.eventId);
|
||||||
hub.broadcastRoom();
|
hub.broadcastState();
|
||||||
case 'chat':
|
case 'chat':
|
||||||
final chat = ChatMessage.fromJson(decoded);
|
final chat = ChatMessage.fromJson(decoded);
|
||||||
hub.broadcastChat(viewer?.name ?? '?', chat.text);
|
hub.broadcastChat(viewer?.name ?? '?', chat.text);
|
||||||
@@ -309,7 +308,7 @@ void mountSyncRoute(
|
|||||||
await close();
|
await close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
session.tokens[token]!.isHost = fromLoopback;
|
session.tokens[token]!.isHost = hub.isHostKey(hello.hostKey);
|
||||||
final memberId = hub.engine.applyJoin(token, hello.user.name, hello.user.avatar);
|
final memberId = hub.engine.applyJoin(token, hello.user.name, hello.user.avatar);
|
||||||
final activeToken = token;
|
final activeToken = token;
|
||||||
hub.sockets[activeToken] = ws;
|
hub.sockets[activeToken] = ws;
|
||||||
@@ -10,7 +10,7 @@ class ViewerToken {
|
|||||||
final String name;
|
final String name;
|
||||||
final String avatar;
|
final String avatar;
|
||||||
bool kicked = false;
|
bool kicked = false;
|
||||||
/// Connected from a loopback address: the host's own in-process client.
|
/// Sent the server's host key in hello: the hosting app's own client.
|
||||||
bool isHost = false;
|
bool isHost = false;
|
||||||
int bytesServed = 0;
|
int bytesServed = 0;
|
||||||
DateTime lastSeen;
|
DateTime lastSeen;
|
||||||
|
|||||||
+15
-10
@@ -7,22 +7,22 @@ import 'package:relic/relic.dart';
|
|||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import 'media_endpoint.dart';
|
import 'media_endpoint.dart';
|
||||||
import 'room_hub.dart';
|
import 'server_hub.dart';
|
||||||
import 'session.dart';
|
import 'session.dart';
|
||||||
|
|
||||||
class RoomServer {
|
class SyncServer {
|
||||||
final AppConfig config;
|
final AppConfig config;
|
||||||
final pkg_logging.Logger _log = createLogger('room_server');
|
final pkg_logging.Logger _log = createLogger('sync_server');
|
||||||
final RelicApp _app = RelicApp();
|
final RelicApp _app = RelicApp();
|
||||||
final SessionMap sessions = SessionMap();
|
final SessionMap sessions = SessionMap();
|
||||||
late final RoomHub hub;
|
late final ServerHub hub;
|
||||||
RelicServer? _server;
|
RelicServer? _server;
|
||||||
DateTime? _startedAt;
|
DateTime? _startedAt;
|
||||||
Timer? _heartbeat;
|
Timer? _heartbeat;
|
||||||
bool _routesMounted = false;
|
bool _routesMounted = false;
|
||||||
|
|
||||||
RoomServer(this.config) {
|
SyncServer(this.config) {
|
||||||
hub = RoomHub(sessions, config.secret);
|
hub = ServerHub(sessions, config.secret, hostKey: config.hostKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get isRunning => _server != null;
|
bool get isRunning => _server != null;
|
||||||
@@ -50,7 +50,7 @@ class RoomServer {
|
|||||||
return Response.ok(
|
return Response.ok(
|
||||||
body: Body.fromString(jsonEncode({
|
body: Body.fromString(jsonEncode({
|
||||||
'ok': true,
|
'ok': true,
|
||||||
'room': config.name,
|
'name': config.name,
|
||||||
'proto': RelayConstants.protoVersion,
|
'proto': RelayConstants.protoVersion,
|
||||||
'viewers': viewers,
|
'viewers': viewers,
|
||||||
'bytesServed': bytes,
|
'bytesServed': bytes,
|
||||||
@@ -93,16 +93,21 @@ class RoomServer {
|
|||||||
_server = await _app.serve(
|
_server = await _app.serve(
|
||||||
address: InternetAddress.anyIPv4,
|
address: InternetAddress.anyIPv4,
|
||||||
port: config.port,
|
port: config.port,
|
||||||
|
// A killed isolate never closes its listening socket, so the port stays
|
||||||
|
// bound in this process. Shared lets the next server in the same
|
||||||
|
// process bind it again (it then gets all connections); another
|
||||||
|
// process is still refused.
|
||||||
|
shared: true,
|
||||||
);
|
);
|
||||||
_startedAt = DateTime.now();
|
_startedAt = DateTime.now();
|
||||||
_heartbeat = Timer.periodic(const Duration(seconds: 1), (_) {
|
_heartbeat = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
hub.engine.sweepStale();
|
hub.engine.sweepStale();
|
||||||
hub.sweepClosedSockets();
|
hub.sweepClosedSockets();
|
||||||
hub.dropSilentSockets();
|
hub.dropSilentSockets();
|
||||||
hub.broadcastRoom();
|
hub.broadcastState();
|
||||||
hub.broadcastFeed();
|
hub.broadcastFeed();
|
||||||
});
|
});
|
||||||
_log.info('room "${config.name}" listening on $boundPort');
|
_log.info('server "${config.name}" listening on $boundPort');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> stop() async {
|
Future<void> stop() async {
|
||||||
@@ -112,6 +117,6 @@ class RoomServer {
|
|||||||
await hub.closeAllSockets();
|
await hub.closeAllSockets();
|
||||||
await _app.close();
|
await _app.close();
|
||||||
_server = null;
|
_server = null;
|
||||||
_log.info('room "${config.name}" stopped');
|
_log.info('server "${config.name}" stopped');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
name: sync_relay_server
|
name: sync_relay_server
|
||||||
description: Pure-Dart room + media server. Embeddable, no Flutter.
|
description: Pure-Dart sync + media server. Embeddable, no Flutter.
|
||||||
publish_to: none
|
publish_to: none
|
||||||
version: 0.1.0
|
version: 0.1.0
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -16,14 +16,15 @@ class _Peer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static Future<_Peer> join(String host, int port, String name,
|
static Future<_Peer> join(String host, int port, String name,
|
||||||
{String secret = ''}) async {
|
{String secret = '', String hostKey = ''}) async {
|
||||||
final ws = await WebSocket.connect('ws://$host:$port/sync');
|
final ws = await WebSocket.connect('ws://$host:$port/sync');
|
||||||
final p = _Peer(ws);
|
final p = _Peer(ws);
|
||||||
ws.add(jsonEncode(HelloMessage(
|
ws.add(jsonEncode(HelloMessage(
|
||||||
proto: RelayConstants.protoVersion,
|
proto: RelayConstants.protoVersion,
|
||||||
secret: secret,
|
secret: secret,
|
||||||
user: HelloUser(name: name),
|
user: HelloUser(name: name),
|
||||||
media: const HelloMedia(name: '', size: 0, duration: 0))
|
media: const HelloMedia(name: '', size: 0, duration: 0),
|
||||||
|
hostKey: hostKey)
|
||||||
.toJson()));
|
.toJson()));
|
||||||
await p.next('welcome');
|
await p.next('welcome');
|
||||||
return p;
|
return p;
|
||||||
@@ -57,13 +58,13 @@ Future<String?> _lan() async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late RoomServer server;
|
late SyncServer server;
|
||||||
late File file;
|
late File file;
|
||||||
|
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
file = File('${Directory.systemTemp.path}/hard_${DateTime.now().microsecondsSinceEpoch}.bin')
|
file = File('${Directory.systemTemp.path}/hard_${DateTime.now().microsecondsSinceEpoch}.bin')
|
||||||
..writeAsBytesSync(List.filled(64, 1));
|
..writeAsBytesSync(List.filled(64, 1));
|
||||||
server = RoomServer(AppConfig(port: 0, secret: '', name: 't', mediaPath: file.path));
|
server = SyncServer(AppConfig(port: 0, secret: '', name: 't', mediaPath: file.path));
|
||||||
await server.start();
|
await server.start();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,22 +123,37 @@ void main() {
|
|||||||
await a.ws.close();
|
await a.ws.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('host (loopback) cannot be kicked; others can', () async {
|
test('host (hostKey) cannot be kicked; loopback without it can', () async {
|
||||||
final lan = await _lan();
|
await server.stop();
|
||||||
if (lan == null) {
|
server = SyncServer(AppConfig(port: 0, secret: '', name: 't', hostKey: 'hk'));
|
||||||
markTestSkipped('no LAN address');
|
await server.start();
|
||||||
return;
|
final host =
|
||||||
}
|
await _Peer.join('127.0.0.1', server.boundPort, 'host', hostKey: 'hk');
|
||||||
final host = await _Peer.join('127.0.0.1', server.boundPort, 'host');
|
final friend = await _Peer.join('127.0.0.1', server.boundPort, 'friend');
|
||||||
final friend = await _Peer.join(lan, server.boundPort, 'friend');
|
final guesser =
|
||||||
final hostId = server.hub.engine.members.values.firstWhere((m) => m.name == 'host').id;
|
await _Peer.join('127.0.0.1', server.boundPort, 'guesser', hostKey: 'nope');
|
||||||
friend.send(jsonEncode(KickRequest(target: hostId).toJson()));
|
String idOf(String name) =>
|
||||||
|
server.hub.engine.members.values.firstWhere((m) => m.name == name).id;
|
||||||
|
friend.send(jsonEncode(KickRequest(target: idOf('host')).toJson()));
|
||||||
final err = await friend.next('err');
|
final err = await friend.next('err');
|
||||||
expect(err['message'], "can't kick the host");
|
expect(err['message'], "can't kick the host");
|
||||||
expect(host.closed, isFalse);
|
expect(host.closed, isFalse);
|
||||||
await expectLater(server.hub.kickById(hostId, 'x'), throwsA(isA<AuthException>()));
|
await expectLater(
|
||||||
|
server.hub.kickById(idOf('host'), 'x'), throwsA(isA<AuthException>()));
|
||||||
|
// Same machine, no key (or a wrong one): an ordinary member.
|
||||||
|
host.send(jsonEncode(KickRequest(target: idOf('guesser')).toJson()));
|
||||||
|
await guesser.next('kicked');
|
||||||
|
await server.hub.kickById(idOf('friend'), 'x');
|
||||||
|
await friend.next('kicked');
|
||||||
await host.ws.close();
|
await host.ws.close();
|
||||||
await friend.ws.close();
|
});
|
||||||
|
|
||||||
|
test('no hostKey configured: nobody is the host', () async {
|
||||||
|
final a = await _Peer.join('127.0.0.1', server.boundPort, 'a', hostKey: '');
|
||||||
|
final b = await _Peer.join('127.0.0.1', server.boundPort, 'b', hostKey: 'x');
|
||||||
|
expect(server.session.tokens.values.where((t) => t.isHost), isEmpty);
|
||||||
|
await a.ws.close();
|
||||||
|
await b.ws.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('empty secret accepts any hello secret', () async {
|
test('empty secret accepts any hello secret', () async {
|
||||||
|
|||||||
@@ -60,10 +60,10 @@ Future<void> _pollBool(
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('presence lifecycle live', () {
|
group('presence lifecycle live', () {
|
||||||
late RoomServer server;
|
late SyncServer server;
|
||||||
|
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
server = RoomServer(
|
server = SyncServer(
|
||||||
const AppConfig(port: 0, secret: 'cycle', name: 'cycle'));
|
const AppConfig(port: 0, secret: 'cycle', name: 'cycle'));
|
||||||
await server.start();
|
await server.start();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:isolate';
|
||||||
|
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
import 'package:sync_relay_server/server.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
Future<void> _run((Map<String, Object?>, SendPort) args) =>
|
||||||
|
serve(args.$1, events: args.$2);
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('serve in a real isolate: ready, /health, stop, clean exit', () async {
|
||||||
|
final events = ReceivePort();
|
||||||
|
final exit = ReceivePort();
|
||||||
|
final errors = ReceivePort();
|
||||||
|
final config = const AppConfig(port: 0, secret: '', name: 'iso', hostKey: 'hk').toMap();
|
||||||
|
await Isolate.spawn(_run, (config, events.sendPort),
|
||||||
|
onExit: exit.sendPort, onError: errors.sendPort);
|
||||||
|
final crashes = <Object?>[];
|
||||||
|
errors.listen(crashes.add);
|
||||||
|
final logs = <Map>[];
|
||||||
|
final ready = Completer<Map>();
|
||||||
|
events.listen((m) {
|
||||||
|
if (m is! Map) return;
|
||||||
|
if (m['t'] == 'log') logs.add(m);
|
||||||
|
if (m['t'] == 'ready' && !ready.isCompleted) ready.complete(m);
|
||||||
|
if (m['t'] == 'error' && !ready.isCompleted) ready.completeError('${m['message']}');
|
||||||
|
});
|
||||||
|
final r = await ready.future.timeout(const Duration(seconds: 10));
|
||||||
|
final port = r['port'] as int;
|
||||||
|
expect(port, greaterThan(0));
|
||||||
|
|
||||||
|
final http = HttpClient();
|
||||||
|
final resp = await (await http.get('127.0.0.1', port, '/health')).close();
|
||||||
|
final body = jsonDecode(await resp.transform(utf8.decoder).join()) as Map;
|
||||||
|
http.close();
|
||||||
|
expect(body['ok'], isTrue);
|
||||||
|
expect(body['name'], 'iso');
|
||||||
|
expect(body['proto'], RelayConstants.protoVersion);
|
||||||
|
expect(logs.any((l) => '${l['message']}'.contains('listening on $port')), isTrue);
|
||||||
|
|
||||||
|
(r['stop'] as SendPort).send({'t': 'stop'});
|
||||||
|
await exit.first.timeout(const Duration(seconds: 10));
|
||||||
|
expect(crashes, isEmpty);
|
||||||
|
// The port is free again.
|
||||||
|
final again = await ServerSocket.bind(InternetAddress.anyIPv4, port);
|
||||||
|
await again.close();
|
||||||
|
events.close();
|
||||||
|
errors.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('serve reports a bind failure as an error event', () async {
|
||||||
|
final blocker = await ServerSocket.bind(InternetAddress.anyIPv4, 0);
|
||||||
|
final events = ReceivePort();
|
||||||
|
final exit = ReceivePort();
|
||||||
|
await Isolate.spawn(
|
||||||
|
_run,
|
||||||
|
(AppConfig(port: blocker.port, secret: '', name: 'x').toMap(),
|
||||||
|
events.sendPort),
|
||||||
|
onExit: exit.sendPort);
|
||||||
|
final first = await events
|
||||||
|
.firstWhere((m) => m is Map && m['t'] != 'log')
|
||||||
|
.timeout(const Duration(seconds: 10)) as Map;
|
||||||
|
expect(first['t'], 'error');
|
||||||
|
await exit.first.timeout(const Duration(seconds: 10));
|
||||||
|
await blocker.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
+7
-22
@@ -8,28 +8,19 @@ import 'package:sync_relay_server/server.dart';
|
|||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
|
|
||||||
Future<String?> _lanAddress() async {
|
|
||||||
for (final i in await NetworkInterface.list(type: InternetAddressType.IPv4)) {
|
|
||||||
for (final a in i.addresses) {
|
|
||||||
if (!a.isLoopback) return a.address;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('room auth live', () {
|
group('server auth live', () {
|
||||||
late File file;
|
late File file;
|
||||||
late Uint8List bytes;
|
late Uint8List bytes;
|
||||||
late RoomServer server;
|
late SyncServer server;
|
||||||
late HttpClient http;
|
late HttpClient http;
|
||||||
|
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
bytes = Uint8List.fromList(List<int>.generate(8192, (i) => i % 251));
|
bytes = Uint8List.fromList(List<int>.generate(8192, (i) => i % 251));
|
||||||
file = File(
|
file = File(
|
||||||
'${Directory.systemTemp.path}/room_test_${DateTime.now().microsecondsSinceEpoch}.bin');
|
'${Directory.systemTemp.path}/server_test_${DateTime.now().microsecondsSinceEpoch}.bin');
|
||||||
await file.writeAsBytes(bytes);
|
await file.writeAsBytes(bytes);
|
||||||
server = RoomServer(AppConfig(
|
server = SyncServer(AppConfig(
|
||||||
port: 0, secret: 'shh', name: 'test', mediaPath: file.path));
|
port: 0, secret: 'shh', name: 'test', mediaPath: file.path));
|
||||||
await server.start();
|
await server.start();
|
||||||
http = HttpClient();
|
http = HttpClient();
|
||||||
@@ -71,14 +62,14 @@ void main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test('health reports room, proto, viewers, bytes', () async {
|
test('health reports name, proto, viewers, bytes', () async {
|
||||||
final req = await http.get('127.0.0.1', server.boundPort, '/health');
|
final req = await http.get('127.0.0.1', server.boundPort, '/health');
|
||||||
final resp = await req.close();
|
final resp = await req.close();
|
||||||
expect(resp.statusCode, 200);
|
expect(resp.statusCode, 200);
|
||||||
final body = await resp.transform(utf8.decoder).join();
|
final body = await resp.transform(utf8.decoder).join();
|
||||||
final decoded = jsonDecode(body) as Map;
|
final decoded = jsonDecode(body) as Map;
|
||||||
expect(decoded['ok'], isTrue);
|
expect(decoded['ok'], isTrue);
|
||||||
expect(decoded['room'], 'test');
|
expect(decoded['name'], 'test');
|
||||||
expect(decoded['proto'], RelayConstants.protoVersion);
|
expect(decoded['proto'], RelayConstants.protoVersion);
|
||||||
expect(decoded['viewers'], 0);
|
expect(decoded['viewers'], 0);
|
||||||
expect(decoded['bytesServed'], 0);
|
expect(decoded['bytesServed'], 0);
|
||||||
@@ -247,12 +238,6 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('kick via socket: peer kicked, unknown target errors', () async {
|
test('kick via socket: peer kicked, unknown target errors', () async {
|
||||||
final lan = await _lanAddress();
|
|
||||||
if (lan == null) {
|
|
||||||
markTestSkipped('no non-loopback IPv4 to act as a non-host peer');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Loopback peers are the host and unkickable, so bob dials the LAN address.
|
|
||||||
Future<(WebSocketChannel, String, String, List<String>)> join(String who,
|
Future<(WebSocketChannel, String, String, List<String>)> join(String who,
|
||||||
[String host = '127.0.0.1']) async {
|
[String host = '127.0.0.1']) async {
|
||||||
final ch = WebSocketChannel.connect(
|
final ch = WebSocketChannel.connect(
|
||||||
@@ -304,7 +289,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final (a, _, _, messagesA) = await join('alice');
|
final (a, _, _, messagesA) = await join('alice');
|
||||||
final (b, tokenB, idB, messagesB) = await join('bob', lan);
|
final (b, tokenB, idB, messagesB) = await join('bob');
|
||||||
try {
|
try {
|
||||||
a.sink.add(jsonEncode(
|
a.sink.add(jsonEncode(
|
||||||
KickRequest(target: idB, reason: 'afk?').toJson()));
|
KickRequest(target: idB, reason: 'afk?').toJson()));
|
||||||
@@ -23,7 +23,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('server starts, answers /health, stops cleanly', () async {
|
test('server starts, answers /health, stops cleanly', () async {
|
||||||
final server = RoomServer(const AppConfig(port: 0, secret: 't', name: 'test'));
|
final server = SyncServer(const AppConfig(port: 0, secret: 't', name: 'test'));
|
||||||
await server.start();
|
await server.start();
|
||||||
expect(server.isRunning, isTrue);
|
expect(server.isRunning, isTrue);
|
||||||
final port = server.boundPort;
|
final port = server.boundPort;
|
||||||
@@ -45,7 +45,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('unknown path returns 404', () async {
|
test('unknown path returns 404', () async {
|
||||||
final server = RoomServer(const AppConfig(port: 0, secret: 't', name: 'test'));
|
final server = SyncServer(const AppConfig(port: 0, secret: 't', name: 'test'));
|
||||||
await server.start();
|
await server.start();
|
||||||
final client = HttpClient();
|
final client = HttpClient();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -80,8 +80,8 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('core welcome + proto', () {
|
group('core welcome + proto', () {
|
||||||
test('proto bumped to 4 for readiness + room fan-out', () {
|
test('proto 5: state broadcast + hello hostKey', () {
|
||||||
expect(RelayConstants.protoVersion, 4);
|
expect(RelayConstants.protoVersion, 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('welcome round-trips with token + member id', () {
|
test('welcome round-trips with token + member id', () {
|
||||||
|
|||||||
+6
-6
@@ -11,7 +11,7 @@ void check(String name, bool ok, [String detail = '']) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class FakeViewer {
|
class FakeViewer {
|
||||||
final RoomEngine engine;
|
final SyncEngine engine;
|
||||||
final String token;
|
final String token;
|
||||||
double ownPosition;
|
double ownPosition;
|
||||||
double buffered;
|
double buffered;
|
||||||
@@ -37,7 +37,7 @@ class FakeViewer {
|
|||||||
|
|
||||||
Future<void> main(List<String> args) async {
|
Future<void> main(List<String> args) async {
|
||||||
var simTime = DateTime.utc(2026, 1, 1);
|
var simTime = DateTime.utc(2026, 1, 1);
|
||||||
final engine = RoomEngine(clock: () => simTime);
|
final engine = SyncEngine(clock: () => simTime);
|
||||||
engine.media = const HelloMedia(name: 'Movie.mkv', size: 4000000000, duration: 7200.0);
|
engine.media = const HelloMedia(name: 'Movie.mkv', size: 4000000000, duration: 7200.0);
|
||||||
|
|
||||||
void step([int seconds = 1]) => simTime = simTime.add(Duration(seconds: seconds));
|
void step([int seconds = 1]) => simTime = simTime.add(Duration(seconds: seconds));
|
||||||
@@ -49,7 +49,7 @@ Future<void> main(List<String> args) async {
|
|||||||
engine.applyReady(t, true);
|
engine.applyReady(t, true);
|
||||||
engine.applyReport(t, 0, 6.0, engine.eventId);
|
engine.applyReport(t, 0, 6.0, engine.eventId);
|
||||||
}
|
}
|
||||||
check('room playing after all ready+buffered', engine.transport == Transport.playing);
|
check('playing after all ready+buffered', engine.transport == Transport.playing);
|
||||||
|
|
||||||
final alice = FakeViewer(engine, 'a');
|
final alice = FakeViewer(engine, 'a');
|
||||||
final bob = FakeViewer(engine, 'b');
|
final bob = FakeViewer(engine, 'b');
|
||||||
@@ -83,7 +83,7 @@ Future<void> main(List<String> args) async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
engine.applyJoin('d', 'dan', '');
|
engine.applyJoin('d', 'dan', '');
|
||||||
check('mid-play join does not halt room', engine.transport == Transport.playing);
|
check('mid-play join does not halt playback', engine.transport == Transport.playing);
|
||||||
final dan = FakeViewer(engine, 'd');
|
final dan = FakeViewer(engine, 'd');
|
||||||
dan.ownPosition = engine.positionAt();
|
dan.ownPosition = engine.positionAt();
|
||||||
dan.tick();
|
dan.tick();
|
||||||
@@ -101,7 +101,7 @@ Future<void> main(List<String> args) async {
|
|||||||
}
|
}
|
||||||
engine.applyReady('b', false);
|
engine.applyReady('b', false);
|
||||||
final frozen = engine.positionAt();
|
final frozen = engine.positionAt();
|
||||||
check('unready pauses room', engine.transport == Transport.held);
|
check('unready pauses playback', engine.transport == Transport.held);
|
||||||
for (var s = 0; s < 30; s++) {
|
for (var s = 0; s < 30; s++) {
|
||||||
step();
|
step();
|
||||||
engine.applyReport('a', frozen, 6.0, engine.eventId);
|
engine.applyReport('a', frozen, 6.0, engine.eventId);
|
||||||
@@ -122,7 +122,7 @@ Future<void> main(List<String> args) async {
|
|||||||
engine.applyReady('c', true);
|
engine.applyReady('c', true);
|
||||||
engine.applyReport('c', engine.positionAt(), 6.0, engine.eventId);
|
engine.applyReport('c', engine.positionAt(), 6.0, engine.eventId);
|
||||||
}
|
}
|
||||||
check('post-flap room converges to playing', engine.transport == Transport.playing);
|
check('post-flap converges to playing', engine.transport == Transport.playing);
|
||||||
|
|
||||||
engine.applySwap('a', const HelloMedia(name: 'Ep02.mkv', size: 100, duration: 1200.0));
|
engine.applySwap('a', const HelloMedia(name: 'Ep02.mkv', size: 100, duration: 1200.0));
|
||||||
check('swap holds + resets edge', engine.transport == Transport.held && engine.positionAt() == 0);
|
check('swap holds + resets edge', engine.transport == Transport.held && engine.positionAt() == 0);
|
||||||
|
|||||||
+11
-1
@@ -4,7 +4,9 @@
|
|||||||
# Usage: tool/package_linux.sh [--skip-build] [--noupload|--no-upload]
|
# Usage: tool/package_linux.sh [--skip-build] [--noupload|--no-upload]
|
||||||
# Output: dist/watch-together-<version>-linux-x64.tar.gz containing one
|
# Output: dist/watch-together-<version>-linux-x64.tar.gz containing one
|
||||||
# top-level folder with the executable, lib/ and data/ as built, plus
|
# top-level folder with the executable, lib/ and data/ as built, plus
|
||||||
# update.sh (runs the binary with --upgrade). The in-app updater strips
|
# update.sh (runs the binary with --upgrade) and watch-together-server (the
|
||||||
|
# dedicated server, `dart compile exe` of packages/server/bin/server.dart,
|
||||||
|
# built every run, also with --skip-build). The in-app updater strips
|
||||||
# that top-level folder — keep the layout in sync with updater_test.dart.
|
# that top-level folder — keep the layout in sync with updater_test.dart.
|
||||||
# Upload: set DISCORD_WEBHOOK_URL in the environment (never commit it).
|
# Upload: set DISCORD_WEBHOOK_URL in the environment (never commit it).
|
||||||
# The tarball must stay under Discord's upload limit (25 MB free tier).
|
# The tarball must stay under Discord's upload limit (25 MB free tier).
|
||||||
@@ -46,6 +48,14 @@ rm -rf "$STAGE"
|
|||||||
mkdir -p "$STAGE/$FOLDER" "$DIST"
|
mkdir -p "$STAGE/$FOLDER" "$DIST"
|
||||||
cp -r "$BUNDLE/." "$STAGE/$FOLDER/"
|
cp -r "$BUNDLE/." "$STAGE/$FOLDER/"
|
||||||
install -m 755 "$APP/lib/src/update/bundle/update.sh" "$STAGE/$FOLDER/update.sh"
|
install -m 755 "$APP/lib/src/update/bundle/update.sh" "$STAGE/$FOLDER/update.sh"
|
||||||
|
# Prefer the dart bundled with the Flutter SDK that builds the app.
|
||||||
|
DART="dart"
|
||||||
|
if FLUTTER="$(command -v flutter)"; then
|
||||||
|
CANDIDATE="$(dirname "$(readlink -f "$FLUTTER")")/dart"
|
||||||
|
[[ -x "$CANDIDATE" ]] && DART="$CANDIDATE"
|
||||||
|
fi
|
||||||
|
(cd "$ROOT/packages/server" && "$DART" pub get >/dev/null &&
|
||||||
|
"$DART" compile exe bin/server.dart -o "$STAGE/$FOLDER/watch-together-server")
|
||||||
tar -czf "$TARBALL" -C "$STAGE" "$FOLDER"
|
tar -czf "$TARBALL" -C "$STAGE" "$FOLDER"
|
||||||
rm -rf "$STAGE"
|
rm -rf "$STAGE"
|
||||||
ls -la "$TARBALL"
|
ls -la "$TARBALL"
|
||||||
|
|||||||
Reference in new issue
Block a user