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