1.0 shell: one window with a party strip; protocol 9 profile + avatars by hash

The shell (lib/src/shell/) replaces the Home and session screens:
- Party strip: profile, Library / Schedule / Settings tabs, Join and
  Host cards (hosting no longer takes media), status-ring avatar stack
  with the member list, chat & activity panel, Leave.
- The real session player is mounted once and shown full, as a
  draggable corner-snapping mini player, or fullscreen (window_manager,
  party and toasts overlaid); the Video is never mounted twice.
- Profile popover with inline rename, colour and a crop picker (256 px
  PNG), pushed live to the session; avatar toasts; waiting pill.
- Motion kit (fastEaseInToSlowEaseOut, fade-through, popovers,
  mouse-drag/wheel horizontal scrolling).
- Library = the existing picker for now (restyled next round); Schedule
  = the new SchedulePage; Settings = left nav over the existing panels.

Protocol 9 (packages/core, packages/server, sync client):
- {"t":"profile"} renames / changes the picture mid-session ("rename",
  "avatar" feed actions); names are cleaned the same way at hello.
- state.members carry avatarHash; pictures are served once from
  /a/<sid>/<token>/<hash> (PNG/JPEG/WebP/GIF by magic bytes only) and
  cached by hash in the app (AvatarCache).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-09 00:23:46 +03:00
1 parent f790470935
commit d29c12e4f0
62 files changed
+5777 -3572

No files matched your search

+45 -1
View File
@@ -159,6 +159,37 @@ class ServerHub {
}
}
/// `GET /a/<sid>/<token>/<hash>`: a current member's picture by its
/// content hash, for members (valid, unkicked token) only. The bytes for a
/// hash never change, so clients may cache them for good.
void mountAvatarRoute(RelicApp app, ServerHub hub) {
Response status(int code) => Response(code, body: Body.fromString('status $code'));
app.get('/a/:sid/:token/:hash', (final req) {
final sid = req.pathParameters.raw[#sid] ?? '';
final token = req.pathParameters.raw[#token] ?? '';
final hash = req.pathParameters.raw[#hash] ?? '';
final session = hub.sessions.get(sid);
if (session == null) return status(410);
switch (session.gate(token)) {
case MediaGate.unauthorized:
return status(401);
case MediaGate.forbidden:
return status(403);
default:
break;
}
final bytes = hub.engine.avatarFor(hash);
final type = bytes == null ? null : avatarImageType(bytes);
if (bytes == null || type == null) return status(404);
return Response.ok(
body: Body.fromData(bytes, mimeType: MimeType(type.$1, type.$2)),
headers: Headers.build((h) {
h['cache-control'] = ['private, max-age=31536000, immutable'];
}),
);
});
}
/// Media names are shown to everyone and must never carry a URL (api_key etc.).
/// A URL-looking name is reduced to its last path segment, no query/fragment.
String sanitizeMediaName(String name) {
@@ -358,6 +389,19 @@ void mountSyncRoute(
mediaId: hub.engine.mediaId);
}
hub.changed();
case 'profile':
if (token == null || viewer == null) return;
final intent = ProfileIntent.fromJson(decoded);
final avatar = intent.avatar;
if (avatar != null && avatar.length > RelayConstants.maxAvatarBytes) {
wsErr(ws, 'rejected: avatar too large');
return;
}
final name = intent.name == null ? null : cleanMemberName(intent.name!);
// Chat and kick name the sender by its token's name.
if (name != null) viewer.name = name;
hub.engine.applyProfile(token, name: name, avatar: avatar);
hub.changed();
case 'report':
if (token == null) return;
final report = ReportMessage.fromJson(decoded);
@@ -470,7 +514,7 @@ void mountSyncRoute(
await close();
return;
}
memberId = hub.engine.applyJoin(token, hello.user.name, hello.user.avatar);
memberId = hub.engine.applyJoin(token, session.tokens[token]!.name, hello.user.avatar);
}
session.tokens[token]!.isHost = hub.isHostKey(hello.hostKey);
final activeToken = token;
+5 -9
View File
@@ -7,15 +7,15 @@ enum MediaGate { ok, gone, unauthorized, forbidden, offline }
class ViewerToken {
final String token;
final String name;
final String avatar;
/// 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;
ViewerToken({required this.token, required this.name, this.avatar = ''})
ViewerToken({required this.token, required this.name})
: lastSeen = DateTime.now().toUtc();
bool isStale(Duration timeout, [DateTime? now]) =>
@@ -53,18 +53,14 @@ class Session {
Session({required this.id, required this.secret});
/// The avatar is only checked here; the engine keeps the picture.
String issueToken(String name, String avatar) {
if (avatar.length > RelayConstants.maxAvatarBytes) {
throw const AuthException('avatar too large');
}
final trimmed = name.trim();
final cleanName = trimmed.isEmpty
? '?'
: trimmed.substring(
0, trimmed.length.clamp(0, RelayConstants.maxNameLength));
final bytes = List<int>.generate(16, (_) => _random.nextInt(256));
final token = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
tokens[token] = ViewerToken(token: token, name: cleanName, avatar: avatar);
tokens[token] = ViewerToken(token: token, name: cleanMemberName(name));
return token;
}
+1
View File
@@ -64,6 +64,7 @@ class SyncServer {
});
mountSessionMediaRoute(_app, sessions);
mountRelayRoutes(_app, sessions, () => hub.relay);
mountAvatarRoute(_app, hub);
mountJellyfinRelayRoute(
_app, sessions, () => hub.jellyfin, () => _relayClient ??= HttpClient());
// advertiseHost is only an explicit override; otherwise echo what the client dialled.
@@ -389,5 +389,93 @@ void main() {
await ch.sink.close();
} catch (_) {}
});
test('profile: rename + avatar feed, state carries hash, /a gated', () async {
final png = Uint8List.fromList([0x89, 0x50, 0x4E, 0x47, ...List<int>.generate(296, (i) => (i * 7) % 256)]);
final ch = WebSocketChannel.connect(
Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'));
final messages = <Map<String, Object?>>[];
final sub = ch.stream.listen(
(m) => messages.add(Map<String, Object?>.from(jsonDecode('$m') as Map)));
Future<Map<String, Object?>> waitFor(bool Function(Map<String, Object?>) test) async {
final deadline = DateTime.now().add(const Duration(seconds: 5));
while (DateTime.now().isBefore(deadline)) {
for (final m in messages) {
if (test(m)) return m;
}
await Future.delayed(const Duration(milliseconds: 20));
}
fail('timed out; got $messages');
}
Map<String, Object?> member(Map<String, Object?> state) =>
Map<String, Object?>.from((state['members'] as List).single as Map);
Future<HttpClientResponse> avatarGet(String token, String hash) async =>
(await http.get('127.0.0.1', server.boundPort, '/a/default/$token/$hash')).close();
try {
ch.sink.add(jsonEncode(HelloMessage(
proto: RelayConstants.protoVersion,
secret: 'shh',
user: HelloUser(name: 'alice', avatar: base64Encode(png)),
media: const HelloMedia(name: 'f.bin', size: 8192, duration: 10.0)).toJson()));
final welcome = await waitFor((m) => m['t'] == 'welcome');
final token = welcome['token'] as String;
final first = member(Map<String, Object?>.from(welcome['state'] as Map));
expect(first.containsKey('avatar'), isFalse, reason: 'no base64 in state');
final hash = first['avatarHash'] as String;
expect(hash, avatarHashOf(png));
final ok = await avatarGet(token, hash);
expect(ok.statusCode, 200);
expect(ok.headers.contentType?.mimeType, 'image/png');
expect(ok.headers.value('cache-control'), contains('max-age'));
expect(await ok.expand((c) => c).toList(), png);
final unknown = await avatarGet(token, '0123456789abcdef');
expect(unknown.statusCode, 404);
await unknown.drain();
final noToken = await avatarGet('nope', hash);
expect(noToken.statusCode, 401);
await noToken.drain();
// Rename: feed rename (actor new, target old); chat uses the new name.
messages.clear();
ch.sink.add(jsonEncode(const ProfileIntent(name: ' bob ').toJson()));
final rename = await waitFor((m) => m['t'] == 'feed' && m['action'] == 'rename');
expect(rename['actor'], 'bob');
expect(rename['target'], 'alice');
final renamed = await waitFor((m) => m['t'] == 'state' && member(m)['name'] == 'bob');
expect(member(renamed)['avatarHash'], hash, reason: 'picture unchanged');
ch.sink.add(jsonEncode(const ChatMessage(text: 'hi').toJson()));
expect((await waitFor((m) => m['t'] == 'chat'))['from'], 'bob');
// Picture only: feed avatar; removing it drops the hash and the bytes.
messages.clear();
ch.sink.add(jsonEncode(const ProfileIntent(avatar: '').toJson()));
final pic = await waitFor((m) => m['t'] == 'feed' && m['action'] == 'avatar');
expect(pic['actor'], 'bob');
await waitFor((m) => m['t'] == 'state' && !member(m).containsKey('avatarHash'));
final gone = await avatarGet(token, hash);
expect(gone.statusCode, 404);
await gone.drain();
// Too large: rejected, nothing changes.
messages.clear();
ch.sink.add(jsonEncode(
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 {
await ch.sink.close();
} catch (_) {}
}
});
});
}
+16 -3
View File
@@ -47,7 +47,7 @@ void main() {
final s = Session(id: 'default', secret: 'shh');
final png = 'x' * (64 * 1024);
expect(s.issueToken('alice', png), isNotEmpty);
expect(s.tokens.values.single.avatar, hasLength(64 * 1024));
expect(s.tokens, hasLength(1));
});
test('names truncated to cap, blank becomes ?', () {
@@ -80,8 +80,21 @@ void main() {
});
group('core welcome + proto', () {
test('proto 8: relayed media', () {
expect(RelayConstants.protoVersion, 8);
test('proto 9: avatars by hash, profile intent', () {
expect(RelayConstants.protoVersion, 9);
const v = MemberView(id: 'm1', name: 'a', ready: false, inSet: false, buffered: 0, stale: false);
expect(v.toJson().containsKey('avatarHash'), isFalse);
expect(v.toJson().containsKey('avatar'), isFalse);
const h = MemberView(
id: 'm1', name: 'a', avatarHash: 'abc', ready: false, inSet: false, buffered: 0, stale: false);
expect(MemberView.fromJson(Map<String, Object?>.from(h.toJson())).avatarHash, 'abc');
expect(const ProfileIntent(name: 'b').toJson(), {'t': 'profile', 'name': 'b'});
final p = ProfileIntent.fromJson({'t': 'profile', 'avatar': ''});
expect(p.name, isNull);
expect(p.avatar, '');
});
test('relayed media', () {
const m = HelloMedia(name: 'a', size: 1, duration: 0, relay: MediaRelay(source: 'm1', live: true));
final back = HelloMedia.fromJson(Map<String, Object?>.from(m.toJson()));
expect(back.relay!.source, 'm1');