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;
}