Protocol 11: sit out replaces kicking; nudge

Anyone can sit anyone out (host included); only you bring yourself back.
Out members stay in chat and the party, never block or count, and load no
media; a card with the live position and Join back (Space) replaces the
video. The mini player's x sits you out; optionally idle does too. Nudge:
the server forwards it to the target only (3/s, 20/min per sender); the
target hears the nudge sound, gets a toast and a taskbar attention
request. Kick, its messages, errors and dialog are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 21:05:35 +03:00
1 parent dbfd351ee4
commit 8b81da4069
46 files changed
+855 -599

No files matched your search

@@ -53,8 +53,6 @@ void mountJellyfinRelayRoute(
switch (session.gate(token)) {
case MediaGate.ok:
break;
case MediaGate.forbidden:
return _plain(403);
default:
return _plain(401);
}
@@ -58,8 +58,6 @@ MediaResolution resolveMediaRequest({
return MediaResolution(status: 410, headers: base(contentType), start: 0, end: 0);
case MediaGate.unauthorized:
return MediaResolution(status: 401, headers: base(contentType), start: 0, end: 0);
case MediaGate.forbidden:
return MediaResolution(status: 403, headers: base(contentType), start: 0, end: 0);
case MediaGate.offline:
return MediaResolution(
status: 503,
@@ -139,13 +139,6 @@ class ActiveRelay {
if (ch != null) unawaited(_closeNotSource(ch));
}
/// Source member gone for good (kicked): stop its channel.
void dropChannel() {
final ch = _channel;
_channel = null;
if (ch != null) unawaited(_closeNotSource(ch));
}
void _attachChannel(RelicWebSocket ws, List<int> sizes) {
final old = _channel;
if (old != null && !identical(old, ws)) unawaited(old.tryClose());
@@ -290,8 +283,6 @@ void mountRelayRoutes(
switch (session.gate(token)) {
case MediaGate.ok:
break;
case MediaGate.forbidden:
return _plain(403);
default:
return _plain(401);
}
+22 -40
View File
@@ -66,11 +66,9 @@ class ServerHub {
bool isHostKey(String key) => hostKey.isNotEmpty && key == hostKey;
/// The member a reconnecting client may take over with its old token:
/// still known and not kicked.
/// still known.
EngineMember? resumable(String token) {
if (token.isEmpty) return null;
final viewer = session.tokens[token];
if (viewer == null || viewer.kicked) return null;
if (token.isEmpty || !session.tokens.containsKey(token)) return null;
return engine.members[token];
}
@@ -150,24 +148,25 @@ class ServerHub {
void forgetSocket(String token) {
engine.applyLeave(token);
final viewer = session.tokens[token];
if (viewer != null && !viewer.kicked) session.tokens.remove(token);
session.tokens.remove(token);
sockets.remove(token);
changed();
}
Future<void> kickById(String targetId, String reason) async {
final member = engine.byId(targetId);
if (member == null) throw const AuthException('unknown kick target');
if (session.tokens[member.token]?.isHost ?? false) {
throw const AuthException("can't kick the host");
}
await session.kick(member.token, reason: reason);
/// Passes a nudge from [fromToken] to the member [targetId] only;
/// over the sender's rate cap, to itself or to nobody it is dropped.
void nudge(String fromToken, String targetId) {
final from = engine.members[fromToken];
final target = engine.byId(targetId);
final viewer = session.tokens[fromToken];
if (from == null || target == null || viewer == null || identical(from, target)) return;
if (!viewer.takeNudge(engine.now)) return;
sockets[target.token]?.trySendText(jsonEncode(NudgeNotice(from: from.id, name: from.name).toJson()));
}
}
/// `GET /a/<sid>/<token>/<hash>`: a current member's picture by its
/// content hash, for members (valid, unkicked token) only. The bytes for a
/// content hash, for members (valid token) only. The bytes for a
/// hash never change, so clients may cache them for good.
void mountAvatarRoute(RelicApp app, ServerHub hub) =>
_mountPictureRoute(app, hub, 'a', hub.engine.avatarFor);
@@ -188,8 +187,6 @@ void _mountPictureRoute(RelicApp app, ServerHub hub, String prefix, Uint8List? F
switch (session.gate(token)) {
case MediaGate.unauthorized:
return status(401);
case MediaGate.forbidden:
return status(403);
default:
break;
}
@@ -415,7 +412,7 @@ void mountSyncRoute(
return;
}
final name = intent.name == null ? null : cleanMemberName(intent.name!);
// Chat and kick name the sender by its token's name.
// Chat names the sender by its token's name.
if (name != null) viewer.name = name;
hub.engine.applyProfile(token, name: name, avatar: avatar);
hub.changed();
@@ -441,22 +438,14 @@ void mountSyncRoute(
case 'chat':
final chat = ChatMessage.fromJson(decoded);
hub.broadcastChat(viewer?.name ?? '?', chat.text);
case 'kick':
final kickReq = KickRequest.fromJson(decoded);
final member = hub.engine.byId(kickReq.target);
if (member == null) {
wsErr(ws, 'unknown kick target');
return;
}
if (session.tokens[member.token]?.isHost ?? false) {
wsErr(ws, "can't kick the host");
return;
}
final by = viewer?.name ?? '?';
unawaited(hub.session.kick(member.token,
reason: kickReq.reason.isEmpty
? 'kicked by $by'
: kickReq.reason));
case 'out':
if (token == null) return;
final intent = OutIntent.fromJson(decoded);
hub.engine.applyOut(token, intent.target, intent.out);
hub.changed();
case 'nudge':
if (token == null) return;
hub.nudge(token, NudgeIntent.fromJson(decoded).target);
}
} on ProtocolException catch (e) {
wsErr(ws, 'bad message: ${e.message}');
@@ -544,13 +533,6 @@ void mountSyncRoute(
hub.engine.members[token]?.host = isHost;
final activeToken = token;
hub.sockets[activeToken] = ws;
session.tokens[activeToken]!.onKick = (reason) async {
if (hub.relay?.sourceToken == activeToken) hub.relay!.dropChannel();
ws.trySendText(jsonEncode(KickedMessage(reason: reason).toJson()));
await Future.delayed(const Duration(milliseconds: 200));
await ws.tryClose();
hub.forgetSocketOf(activeToken, ws);
};
final media = session.media;
final relay = hub.relayAvailable;
final relayed = hub.relay;
+13 -11
View File
@@ -3,21 +3,31 @@ import 'dart:math';
import 'package:sync_relay_core/core.dart';
enum MediaGate { ok, gone, unauthorized, forbidden, offline }
enum MediaGate { ok, gone, unauthorized, offline }
class ViewerToken {
final String token;
/// Cleaned ([cleanMemberName]); follows `profile` renames.
String name;
bool kicked = false;
/// Sent the server's host key in hello: the hosting app's own client.
bool isHost = false;
int bytesServed = 0;
DateTime lastSeen;
Future<void> Function(String reason)? onKick;
/// When this member's recent nudges went out, for the rate cap.
final List<DateTime> nudges = [];
ViewerToken({required this.token, required this.name})
: lastSeen = DateTime.now().toUtc();
/// Takes one nudge at [now] if under the caps
/// ([RelayConstants.nudgesPerSec] / [RelayConstants.nudgesPerMin]).
bool takeNudge(DateTime now) {
nudges.removeWhere((t) => now.difference(t) >= const Duration(minutes: 1));
final lastSec = nudges.where((t) => now.difference(t) < const Duration(seconds: 1)).length;
if (nudges.length >= RelayConstants.nudgesPerMin || lastSec >= RelayConstants.nudgesPerSec) return false;
nudges.add(now);
return true;
}
bool isStale(Duration timeout, [DateTime? now]) =>
(now ?? DateTime.now().toUtc()).difference(lastSeen) > timeout;
@@ -66,17 +76,9 @@ class Session {
MediaGate gate(String? token) {
if (token == null || !tokens.containsKey(token)) return MediaGate.unauthorized;
if (tokens[token]!.kicked) return MediaGate.forbidden;
return MediaGate.ok;
}
Future<void> kick(String token, {String reason = ''}) async {
final viewer = tokens[token];
if (viewer == null) throw const AuthException('unknown token');
viewer.kicked = true;
await viewer.onKick?.call(reason);
}
void addBytes(String? token, int n) {
if (token != null) tokens[token]?.bytesServed += n;
}
+41 -14
View File
@@ -123,29 +123,56 @@ void main() {
await a.ws.close();
});
test('host (hostKey) cannot be kicked; loopback without it can', () async {
test('anyone sits anyone out (host too); nudges reach only the target, capped', () async {
await server.stop();
server = SyncServer(AppConfig(port: 0, secret: '', name: 't', hostKey: 'hk'));
await server.start();
final host =
await _Peer.join('127.0.0.1', server.boundPort, 'host', hostKey: 'hk');
final friend = await _Peer.join('127.0.0.1', server.boundPort, 'friend');
final guesser =
await _Peer.join('127.0.0.1', server.boundPort, 'guesser', hostKey: 'nope');
final third = await _Peer.join('127.0.0.1', server.boundPort, 'third');
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');
expect(err['message'], "can't kick the host");
expect(host.closed, isFalse);
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');
EngineMember of(String name) =>
server.hub.engine.members.values.firstWhere((m) => m.name == name);
friend.send(jsonEncode(OutIntent(target: idOf('host'), out: true).toJson()));
var feed = await friend.next('feed');
while (feed['action'] != 'out') {
feed = await friend.next('feed');
}
expect([feed['actor'], feed['target']], ['friend', 'host']);
expect(of('host').out, isTrue);
expect(of('host').inSet, isFalse);
// Only oneself comes back.
friend.send(jsonEncode(OutIntent(target: idOf('host'), out: false).toJson()));
host.send(jsonEncode(OutIntent(target: idOf('host'), out: false).toJson()));
await Future.delayed(const Duration(milliseconds: 200));
expect(of('host').out, isFalse);
// 5 at once: 3 pass (per second), only to the target.
for (var i = 0; i < 5; i++) {
friend.send(jsonEncode(NudgeIntent(target: idOf('host')).toJson()));
}
await Future.delayed(const Duration(milliseconds: 300));
final got = host.msgs.where((m) => m['t'] == 'nudge').toList();
expect(got, hasLength(3));
expect(got.first, {'t': 'nudge', 'from': idOf('friend'), 'name': 'friend'});
expect(third.msgs.where((m) => m['t'] == 'nudge'), isEmpty);
expect(friend.msgs.where((m) => m['t'] == 'nudge'), isEmpty);
await host.ws.close();
await friend.ws.close();
await third.ws.close();
});
test('nudge cap: 20 per minute', () {
final v = ViewerToken(token: 't', name: 'n');
final t0 = DateTime.utc(2026);
var passed = 0;
for (var i = 0; i < 30; i++) {
if (v.takeNudge(t0.add(Duration(milliseconds: 500 * i)))) passed++;
}
expect(passed, 20);
expect(v.takeNudge(t0.add(const Duration(seconds: 61))), isTrue);
});
test('state marks the host member for everyone', () async {
@@ -54,9 +54,6 @@ void main() {
resolveMediaRequest(rangeHeader: null, size: 10, filename: 'a.mkv', gate: MediaGate.unauthorized)
.status,
401);
expect(
resolveMediaRequest(rangeHeader: null, size: 10, filename: 'a.mkv', gate: MediaGate.forbidden).status,
403);
final off =
resolveMediaRequest(rangeHeader: null, size: 10, filename: 'a.mkv', gate: MediaGate.offline);
expect(off.status, 503);
-188
View File
@@ -133,189 +133,6 @@ void main() {
await resp.drain();
});
test('kick: WS drops and media goes 403', () async {
final ch = WebSocketChannel.connect(
Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'));
final firstCompleter = Completer<String>();
var wsClosed = false;
final sub = ch.stream.listen(
(m) {
if (!firstCompleter.isCompleted) firstCompleter.complete('$m');
},
onDone: () => wsClosed = true,
);
final msg = HelloMessage(
proto: RelayConstants.protoVersion,
secret: 'shh',
user: const HelloUser(name: 'mallory'),
media:
const HelloMedia(name: 'f.bin', size: 8192, duration: 10.0)).toJson();
ch.sink.add(jsonEncode(msg));
final first =
await firstCompleter.future.timeout(const Duration(seconds: 5));
final token = (jsonDecode(first) as Map)['token'] as String;
await server.session.kick(token);
final deadline = DateTime.now().add(const Duration(seconds: 5));
while (!wsClosed && DateTime.now().isBefore(deadline)) {
await Future.delayed(const Duration(milliseconds: 50));
}
expect(wsClosed, isTrue);
try {
await sub.cancel();
} catch (_) {}
try {
await ch.sink.close();
} catch (_) {}
final name = file.path.split(Platform.pathSeparator).last;
final resp = await mediaGet('/m/default/$token/$name');
expect(resp.statusCode, 403);
await resp.drain();
});
test('kick delivers reason then drops; instant rejoin works', () async {
final ch = WebSocketChannel.connect(
Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'));
final messages = <String>[];
var wsClosed = false;
final sub = ch.stream.listen(
(m) => messages.add('$m'),
onDone: () => wsClosed = true,
);
HelloMessage helloFor(String who) => HelloMessage(
proto: RelayConstants.protoVersion,
secret: 'shh',
user: HelloUser(name: who),
media: const HelloMedia(
name: 'f.bin', size: 8192, duration: 10.0));
ch.sink.add(jsonEncode(helloFor(' sleepy').toJson()));
Map<String, Object?>? welcome;
final welcomeDeadline = DateTime.now().add(const Duration(seconds: 5));
while (DateTime.now().isBefore(welcomeDeadline)) {
for (final m in messages) {
final decoded = jsonDecode(m) as Map;
if (decoded['t'] == 'welcome') {
welcome = Map<String, Object?>.from(decoded);
}
}
if (welcome != null) break;
await Future.delayed(const Duration(milliseconds: 50));
}
expect(welcome, isNotNull);
final token = welcome!['token'] as String;
await server.session.kick(token, reason: 'idle too long');
final deadline = DateTime.now().add(const Duration(seconds: 5));
Map<String, Object?>? kicked;
while (DateTime.now().isBefore(deadline)) {
for (final m in messages.skip(1)) {
final decoded = jsonDecode(m) as Map;
if (decoded['t'] == 'kicked') {
kicked = Map<String, Object?>.from(decoded);
}
}
if (wsClosed && kicked != null) break;
await Future.delayed(const Duration(milliseconds: 50));
}
expect(wsClosed, isTrue);
expect(kicked, isNotNull);
expect(kicked!['reason'], 'idle too long');
try {
await sub.cancel();
} catch (_) {}
try {
await ch.sink.close();
} catch (_) {}
final rejoin = await hello('shh');
final decoded = jsonDecode(rejoin) as Map;
expect(decoded['t'], 'welcome');
final token2 = decoded['token'] as String;
expect(token2, isNot(token));
final name = file.path.split(Platform.pathSeparator).last;
final resp = await mediaGet('/m/default/$token2/$name');
expect(resp.statusCode, 200);
await resp.drain();
});
test('kick via socket: peer kicked, unknown target errors', () async {
Future<(WebSocketChannel, String, String, List<String>)> join(String who,
[String host = '127.0.0.1']) async {
final ch = WebSocketChannel.connect(
Uri.parse('ws://$host:${server.boundPort}/sync'));
final messages = <String>[];
ch.stream.listen((m) => messages.add('$m'));
final msg = HelloMessage(
proto: RelayConstants.protoVersion,
secret: 'shh',
user: HelloUser(name: who),
media: const HelloMedia(
name: 'f.bin', size: 8192, duration: 10.0)).toJson();
ch.sink.add(jsonEncode(msg));
Map<String, Object?>? welcome;
final deadline = DateTime.now().add(const Duration(seconds: 5));
while (DateTime.now().isBefore(deadline)) {
for (final m in messages) {
final decoded = jsonDecode(m) as Map;
if (decoded['t'] == 'welcome') {
welcome = Map<String, Object?>.from(decoded);
}
}
if (welcome != null) break;
await Future.delayed(const Duration(milliseconds: 50));
}
expect(welcome, isNotNull,
reason: 'no welcome for $who: $messages');
final w = welcome!;
return (ch, w['token'] as String, w['memberId'] as String, messages);
}
Map<String, Object?>? findType(List<String> messages, String type) {
for (final m in messages) {
final decoded = jsonDecode(m) as Map;
if (decoded['t'] == type) return Map<String, Object?>.from(decoded);
}
return null;
}
Future<Map<String, Object?>?> waitType(
List<String> messages, String type, Duration timeout) async {
final deadline = DateTime.now().add(timeout);
while (DateTime.now().isBefore(deadline)) {
final found = findType(messages, type);
if (found != null) return found;
await Future.delayed(const Duration(milliseconds: 50));
}
return findType(messages, type);
}
final (a, _, _, messagesA) = await join('alice');
final (b, tokenB, idB, messagesB) = await join('bob');
try {
a.sink.add(jsonEncode(
KickRequest(target: idB, reason: 'afk?').toJson()));
final kicked = await waitType(
messagesB, 'kicked', const Duration(seconds: 5));
expect(kicked, isNotNull);
expect(kicked!['reason'], 'afk?');
expect(server.session.gate(tokenB), MediaGate.forbidden);
a.sink.add(
jsonEncode(const KickRequest(target: 'm99').toJson()));
final err = await waitType(
messagesA, 'err', const Duration(seconds: 5));
expect(err, isNotNull);
} finally {
try {
await a.sink.close();
} catch (_) {}
try {
await b.sink.close();
} catch (_) {}
}
});
test('silent socket dropped and member removed', () async {
final ch = WebSocketChannel.connect(
Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'));
@@ -557,11 +374,6 @@ void main() {
ProfileIntent(avatar: 'x' * (RelayConstants.maxAvatarBytes + 1)).toJson()));
final err = await waitFor((m) => m['t'] == 'err');
expect(err['message'], 'rejected: avatar too large');
await server.session.kick(token);
final kicked = await avatarGet(token, hash);
expect(kicked.statusCode, 403);
await kicked.drain();
} finally {
await sub.cancel();
try {
-28
View File
@@ -21,22 +21,6 @@ void main() {
expect(s.gate(t), MediaGate.ok);
});
test('kick unknown throws, kick known forbids + drops', () async {
final s = Session(id: 'default', secret: 'shh');
expect(() => s.kick('nope'), throwsA(isA<AuthException>()));
final t = s.issueToken('mallory', '');
var dropped = false;
String? gotReason;
s.tokens[t]!.onKick = (reason) async {
dropped = true;
gotReason = reason;
};
await s.kick(t, reason: 'idle');
expect(s.gate(t), MediaGate.forbidden);
expect(dropped, isTrue);
expect(gotReason, 'idle');
});
test('oversize avatar rejected', () {
final s = Session(id: 'default', secret: 'shh');
final big = 'x' * (RelayConstants.maxAvatarBytes + 1);
@@ -122,18 +106,6 @@ void main() {
expect(() => WelcomeMessage.fromJson({'t': 'hello'}), throwsA(isA<ProtocolException>()));
});
test('kicked + kick request round-trip', () {
const k = KickedMessage(reason: 'idle');
final back = KickedMessage.fromJson(Map<String, Object?>.from(k.toJson()));
expect(back.reason, 'idle');
const r = KickRequest(target: 'abc', reason: 'afk?');
final backR = KickRequest.fromJson(Map<String, Object?>.from(r.toJson()));
expect(backR.target, 'abc');
expect(backR.reason, 'afk?');
expect(() => KickedMessage.fromJson({'t': 'err'}), throwsA(isA<ProtocolException>()));
expect(() => KickRequest.fromJson({'t': 'err'}), throwsA(isA<ProtocolException>()));
});
test('lastSeen touch + stale helper', () {
final s = Session(id: 'default', secret: 'shh');
final t = s.issueToken('alice', '');
+1 -8
View File
@@ -127,17 +127,10 @@ void main() {
await second.ws.close();
});
test('unknown or kicked resume token joins as a new member', () async {
test('unknown resume token joins as a new member', () async {
final (a, wa) = await _Client.join(server.boundPort, 'a', resume: 'nope');
expect(wa.token, isNot('nope'));
final (b, wb) = await _Client.join(server.boundPort, 'b');
await server.hub.kickById(wb.memberId, 'bye');
final (c, wc) = await _Client.join(server.boundPort, 'b', resume: wb.token);
expect(wc.token, isNot(wb.token));
expect(wc.memberId, isNot(wb.memberId));
await a.ws.close();
await b.ws.close();
await c.ws.close();
});
test('ping gets a pong with the client stamp and the server clock', () async {