fix(chat): preserve mixed-version compatibility

This commit is contained in:
Timo
2026-07-26 01:02:20 +02:00
parent 17c3ea295a
commit 77790a279c
26 changed files with 236 additions and 35 deletions
+13
View File
@@ -67,6 +67,19 @@ the stamped value as AAD.
- It follows all eucalyptus, cyber, and graphite light/dark theme combinations. - It follows all eucalyptus, cyber, and graphite light/dark theme combinations.
- Without a key, the panel stays closed and a disabled chat control explains that a - Without a key, the panel stays closed and a disabled chat control explains that a
current invite link is required. current invite link is required.
- Chat display is a local option and defaults to off. Enabling or disabling it never
deletes the room chat secret, so it can be enabled later without creating a room.
- Without the relay `chat` capability, no chat control is shown. - Without the relay `chat` capability, no chat control is shown.
## Mixed-version rollout
- New extensions announce `chat-v1` in `join_room.clientCapabilities`.
- Old non-chat extensions omit the optional field and receive no `chat_message`
events. Their playback and room protocol remains unchanged.
- The first chat beta omitted the capability. When it sends one valid v1 chat
frame, the relay treats that socket as chat-capable for the rest of the
connection.
- New extensions accept the first beta's unversioned server `chat` capability as
well as `chat-v1`, so a server-first or extension-first rollout degrades safely.
Peer removal and role management are outside chat scope. Peer removal and role management are outside chat scope.
+15 -4
View File
@@ -54,6 +54,7 @@ Payload:
"password": "string, max 128, optional", "password": "string, max 128, optional",
"tabTitle": "string, max 100, optional", "tabTitle": "string, max 100, optional",
"mediaTitle": "string, max 100, optional", "mediaTitle": "string, max 100, optional",
"clientCapabilities": ["chat-v1"],
"protocolVersion": "string, max 16" "protocolVersion": "string, max 16"
} }
``` ```
@@ -82,7 +83,7 @@ Payload:
"hostPeerId": "string or null", "hostPeerId": "string or null",
"controlMode": "everyone | host-only", "controlMode": "everyone | host-only",
"controllers": ["peerId"], "controllers": ["peerId"],
"capabilities": ["host-control", "co-host", "chat"] "capabilities": ["host-control", "co-host", "chat", "chat-v1"]
} }
``` ```
@@ -91,8 +92,17 @@ for every later room update.
## Ephemeral encrypted chat ## Ephemeral encrypted chat
Relays advertise chat support with `"chat"` in `room_data.capabilities`. Clients Relays advertise chat support with `"chat-v1"` in `room_data.capabilities` and keep
must not infer support from another field. the initial beta's `"chat"` flag during the transition. New clients announce
`"chat-v1"` in optional `join_room.clientCapabilities` (at most the first 16 entries
are inspected; unknown or malformed values are ignored). Old clients omit the field
and continue using the pre-chat protocol unchanged.
The relay sends `chat_message` only to sockets that announced `"chat-v1"`. As a
transition for the first chat beta, a socket that sends a valid v1 ciphertext is
marked capable for the rest of that connection. This prevents old non-chat
extensions from receiving unknown events while preserving the first beta's send
path.
### `chat_message` ### `chat_message`
@@ -106,7 +116,7 @@ Client to relay:
authentication tag. The relay validates only canonical base64url and byte bounds. authentication tag. The relay validates only canonical base64url and byte bounds.
It cannot inspect plaintext. It cannot inspect plaintext.
Relay to every current room peer, including the sender: Relay to every chat-capable current room peer, including the sender:
```json ```json
{ {
@@ -413,5 +423,6 @@ If sender and target are still in the same room, the relay emits:
- `host-control` - `host-control`
- `co-host` - `co-host`
- `chat` - `chat`
- `chat-v1`
Clients should treat a missing or unknown capabilities list as unsupported. Clients should treat a missing or unknown capabilities list as unsupported.
+23 -8
View File
@@ -91,6 +91,10 @@ let chatReceiveQueue = Promise.resolve();
const chatSendLimiter = createChatSendLimiter(); const chatSendLimiter = createChatSendLimiter();
const webJoinCoordinator = createLatestTaskQueue(); const webJoinCoordinator = createLatestTaskQueue();
function serverSupports(cap) { return Array.isArray(serverCapabilities) && serverCapabilities.includes(cap); } function serverSupports(cap) { return Array.isArray(serverCapabilities) && serverCapabilities.includes(cap); }
function serverSupportsChat() {
return serverSupports(CAPABILITIES.CHAT_V1) || serverSupports(CAPABILITIES.CHAT);
}
const CLIENT_CAPABILITIES = Object.freeze([CAPABILITIES.CHAT_V1]);
function invalidateChatSession() { function invalidateChatSession() {
chatSessionGeneration++; chatSessionGeneration++;
@@ -394,7 +398,7 @@ async function getSettings() {
// (username) must NEVER come from storage.sync — syncing them across devices // (username) must NEVER come from storage.sync — syncing them across devices
// both leaks them and resurrects dead rooms on reinstall (a fresh install // both leaks them and resurrects dead rooms on reinstall (a fresh install
// has empty local storage but sync survives in the user's Google account). // has empty local storage but sync survives in the user's Google account).
const data = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'username', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode']); const data = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'chatEnabled', 'username', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode']);
let username = data.username; let username = data.username;
if (!username) { if (!username) {
username = generateUsername(); username = generateUsername();
@@ -412,6 +416,7 @@ async function getSettings() {
roomId, roomId,
password: data.password || '', password: data.password || '',
chatKey, chatKey,
chatEnabled: data.chatEnabled === true,
username, username,
sendTabTitle: normalizeSendTabTitle(data.sendTabTitle, legacyTitlePrivacyMode), sendTabTitle: normalizeSendTabTitle(data.sendTabTitle, legacyTitlePrivacyMode),
mediaTitlePrivacyMode mediaTitlePrivacyMode
@@ -748,6 +753,7 @@ async function connect() {
peerId, peerId,
username: settings.username, username: settings.username,
tabTitle: sharedTitles.tabTitle, tabTitle: sharedTitles.tabTitle,
clientCapabilities: CLIENT_CAPABILITIES,
protocolVersion: PROTOCOL_VERSION protocolVersion: PROTOCOL_VERSION
}); });
} }
@@ -1193,7 +1199,7 @@ async function handleServerEvent(event, data) {
chrome.runtime.sendMessage({ type: 'ROOM_LIST', rooms: data.rooms }).catch(() => {}); chrome.runtime.sendMessage({ type: 'ROOM_LIST', rooms: data.rooms }).catch(() => {});
break; break;
case EVENTS.CHAT_MESSAGE: { case EVENTS.CHAT_MESSAGE: {
if (!currentRoom || !serverSupports(CAPABILITIES.CHAT) || !currentTabId) break; if (!currentRoom || !serverSupportsChat() || !currentTabId) break;
const generation = chatSessionGeneration; const generation = chatSessionGeneration;
const roomId = currentRoom.roomId; const roomId = currentRoom.roomId;
const tabId = Number(currentTabId); const tabId = Number(currentTabId);
@@ -1203,7 +1209,7 @@ async function handleServerEvent(event, data) {
chatReceiveQueue = chatReceiveQueue.catch(() => {}).then(async () => { chatReceiveQueue = chatReceiveQueue.catch(() => {}).then(async () => {
if (!isCurrentSession()) return; if (!isCurrentSession()) return;
const settings = await getSettings(); const settings = await getSettings();
if (!settings.chatKey || settings.roomId !== roomId || !isCurrentSession()) return; if (!settings.chatEnabled || !settings.chatKey || settings.roomId !== roomId || !isCurrentSession()) return;
const chatKey = settings.chatKey; const chatKey = settings.chatKey;
try { try {
const text = await decryptChatMessage({ const text = await decryptChatMessage({
@@ -2547,7 +2553,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
}); });
chrome.storage.onChanged.addListener((changes, area) => { chrome.storage.onChanged.addListener((changes, area) => {
if (area !== 'local' || (!changes.roomId && !changes.chatKey)) return; if (area !== 'local' || (!changes.roomId && !changes.chatKey && !changes.chatEnabled)) return;
if (changes.chatKey) chatSecretGuard = validateChatSecret(changes.chatKey.newValue); if (changes.chatKey) chatSecretGuard = validateChatSecret(changes.chatKey.newValue);
invalidateChatSession(); invalidateChatSession();
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
@@ -2593,6 +2599,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
peerId, peerId,
username: settings.username, username: settings.username,
tabTitle: sharedTitles.tabTitle, tabTitle: sharedTitles.tabTitle,
clientCapabilities: CLIENT_CAPABILITIES,
protocolVersion: PROTOCOL_VERSION protocolVersion: PROTOCOL_VERSION
}); });
} }
@@ -2611,6 +2618,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
await retryPendingTarget(); await retryPendingTarget();
} }
const pendingTarget = await readPendingTarget(); const pendingTarget = await readPendingTarget();
const settings = await getSettings();
const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined; const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined;
const isReconnecting = !isConnected && reconnectAttempts > 0; const isReconnecting = !isConnected && reconnectAttempts > 0;
let status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : (isReconnecting ? 'reconnecting' : 'disconnected')); let status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : (isReconnecting ? 'reconnecting' : 'disconnected'));
@@ -2641,8 +2649,9 @@ async function handleAsyncMessage(message, sender, sendResponse) {
amController: amController(), amController: amController(),
hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL), hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL),
coHostSupported: serverSupports(CAPABILITIES.CO_HOST), coHostSupported: serverSupports(CAPABILITIES.CO_HOST),
chatSupported: serverSupports(CAPABILITIES.CHAT), chatSupported: serverSupportsChat(),
hasChatKey: !!(await getSettings()).chatKey hasChatKey: !!settings.chatKey,
chatEnabled: settings.chatEnabled
}); });
} else if (message.type === 'GET_CHAT_CONTEXT') { } else if (message.type === 'GET_CHAT_CONTEXT') {
const senderTabId = sender.tab?.id; const senderTabId = sender.tab?.id;
@@ -2658,7 +2667,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
return value === key ? '' : value; return value === key ? '' : value;
}; };
sendResponse({ sendResponse({
supported: serverSupports(CAPABILITIES.CHAT), supported: serverSupportsChat(),
enabled: settings.chatEnabled,
hasKey: !!settings.chatKey, hasKey: !!settings.chatKey,
connected: !!(socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined), connected: !!(socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined),
peerId, peerId,
@@ -2686,7 +2696,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse({ status: 'invalid_tab' }); sendResponse({ status: 'invalid_tab' });
return; return;
} }
if (!serverSupports(CAPABILITIES.CHAT)) { if (!serverSupportsChat()) {
sendResponse({ status: 'unsupported' }); sendResponse({ status: 'unsupported' });
return; return;
} }
@@ -2702,6 +2712,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
currentRoom?.roomId === roomId && Number(currentTabId) === tabId && currentRoom?.roomId === roomId && Number(currentTabId) === tabId &&
socket === socketSnapshot && socketSnapshot.readyState === WebSocket.OPEN && isNamespaceJoined; socket === socketSnapshot && socketSnapshot.readyState === WebSocket.OPEN && isNamespaceJoined;
const settings = await getSettings(); const settings = await getSettings();
if (!settings.chatEnabled) {
sendResponse({ status: 'disabled' });
return;
}
if (!settings.chatKey || settings.roomId !== roomId || !isCurrentSession()) { if (!settings.chatKey || settings.roomId !== roomId || !isCurrentSession()) {
sendResponse({ status: settings.chatKey ? 'session_changed' : 'missing_key' }); sendResponse({ status: settings.chatKey ? 'session_changed' : 'missing_key' });
return; return;
@@ -2945,6 +2959,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
peerId, peerId,
username: settings.username, username: settings.username,
tabTitle: sharedTitles.tabTitle, tabTitle: sharedTitles.tabTitle,
clientCapabilities: CLIENT_CAPABILITIES,
protocolVersion: PROTOCOL_VERSION protocolVersion: PROTOCOL_VERSION
}); });
} }
+14
View File
@@ -9,6 +9,8 @@ const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'
const popupSource = fs.readFileSync(path.join(extensionDir, 'popup.js'), 'utf8'); const popupSource = fs.readFileSync(path.join(extensionDir, 'popup.js'), 'utf8');
const localeDir = path.join(extensionDir, 'locales'); const localeDir = path.join(extensionDir, 'locales');
const chatKeys = [ const chatKeys = [
'LABEL_CHAT_ENABLED',
'LABEL_CHAT_ENABLED_TOOLTIP',
'CHAT_TITLE', 'CHAT_TITLE',
'CHAT_LIVE_ONLY', 'CHAT_LIVE_ONLY',
'CHAT_OPEN', 'CHAT_OPEN',
@@ -60,6 +62,18 @@ describe('chat overlay contract', () => {
expect(backgroundSource).toContain("status: 'rate_limited'"); expect(backgroundSource).toContain("status: 'rate_limited'");
}); });
it('keeps chat hidden by default without discarding the room chat key', () => {
expect(popupSource).toContain('localData.chatEnabled === true');
expect(backgroundSource).toContain('chatEnabled: data.chatEnabled === true');
expect(backgroundSource).toContain('clientCapabilities: CLIENT_CAPABILITIES');
expect(overlaySource).toContain('all:initial;display:none;position:fixed');
expect(overlaySource).toContain("host.style.display = supported && optedIn ? '' : 'none'");
expect(overlaySource).toContain('supported && optedIn && hasKey && connected');
expect(popupSource).toContain("chrome.storage.local.set({ chatEnabled: elements.chatEnabled.checked })");
expect(popupSource).toMatch(/if \(isCreating\) \{[\s\S]*?type: 'CREATE_CHAT_KEY'[\s\S]*?chatKey = normalizeChatKey/);
expect(popupSource).not.toMatch(/chatEnabled[\s\S]{0,120}chatKey:\s*''/);
});
it('keeps unavailable chat controls discoverable to assistive technology', () => { it('keeps unavailable chat controls discoverable to assistive technology', () => {
expect(overlaySource).toContain("launcher.setAttribute('aria-disabled'"); expect(overlaySource).toContain("launcher.setAttribute('aria-disabled'");
expect(overlaySource).not.toContain('launcher.disabled ='); expect(overlaySource).not.toContain('launcher.disabled =');
+4 -3
View File
@@ -25,7 +25,7 @@
const host = document.createElement('div'); const host = document.createElement('div');
host.id = 'koalasync-chat-overlay-host'; host.id = 'koalasync-chat-overlay-host';
host.style.cssText = 'all:initial;position:fixed;inset:0;z-index:2147483647;pointer-events:none;contain:layout style paint;transform:translateZ(0);'; host.style.cssText = 'all:initial;display:none;position:fixed;inset:0;z-index:2147483647;pointer-events:none;contain:layout style paint;transform:translateZ(0);';
const shadow = host.attachShadow({ mode: 'open' }); const shadow = host.attachShadow({ mode: 'open' });
const style = document.createElement('style'); const style = document.createElement('style');
style.textContent = ` style.textContent = `
@@ -324,11 +324,12 @@
const previousRoomId = context?.roomId; const previousRoomId = context?.roomId;
context = next || null; context = next || null;
const supported = !!context?.supported; const supported = !!context?.supported;
const optedIn = !!context?.enabled;
const hasKey = !!context?.hasKey; const hasKey = !!context?.hasKey;
const connected = !!context?.connected; const connected = !!context?.connected;
context = context ? { ...context, enabled: supported && hasKey && connected } : null; context = context ? { ...context, enabled: supported && optedIn && hasKey && connected } : null;
if (previousRoomId && previousRoomId !== context?.roomId) clearMessages(); if (previousRoomId && previousRoomId !== context?.roomId) clearMessages();
host.style.display = supported ? '' : 'none'; host.style.display = supported && optedIn ? '' : 'none';
launcher.setAttribute('aria-disabled', String(!context?.enabled)); launcher.setAttribute('aria-disabled', String(!context?.enabled));
if (!context?.enabled) setOpened(false); if (!context?.enabled) setOpened(false);
applyStrings(); applyStrings();
+2
View File
@@ -246,6 +246,8 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Ändere deinen Benutzernamen, dein Design und deine Spracheinstellungen.", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Ändere deinen Benutzernamen, dein Design und deine Spracheinstellungen.",
"LABEL_SETTINGS_GROUP_SYNC": "Wiedergabe & Sync", "LABEL_SETTINGS_GROUP_SYNC": "Wiedergabe & Sync",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Passe Wiedergabe-, Benachrichtigungs- und Audioeinstellungen an.", "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Passe Wiedergabe-, Benachrichtigungs- und Audioeinstellungen an.",
"LABEL_CHAT_ENABLED": "Raum-Chat aktivieren",
"LABEL_CHAT_ENABLED_TOOLTIP": "Verschlüsselten Raum-Chat im ausgewählten Video-Tab anzeigen.",
"CHAT_TITLE": "Raum-Chat", "CHAT_TITLE": "Raum-Chat",
"CHAT_LIVE_ONLY": "Nur live. Kein Verlauf.", "CHAT_LIVE_ONLY": "Nur live. Kein Verlauf.",
"CHAT_OPEN": "Raum-Chat öffnen", "CHAT_OPEN": "Raum-Chat öffnen",
+2
View File
@@ -246,6 +246,8 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Change your username, theme, and language preferences.", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Change your username, theme, and language preferences.",
"LABEL_SETTINGS_GROUP_SYNC": "Playback & Sync", "LABEL_SETTINGS_GROUP_SYNC": "Playback & Sync",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Customize playback, notifications, and audio preferences.", "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Customize playback, notifications, and audio preferences.",
"LABEL_CHAT_ENABLED": "Enable Room Chat",
"LABEL_CHAT_ENABLED_TOOLTIP": "Show encrypted room chat on the selected video tab.",
"CHAT_TITLE": "Room Chat", "CHAT_TITLE": "Room Chat",
"CHAT_LIVE_ONLY": "Live only. No history.", "CHAT_LIVE_ONLY": "Live only. No history.",
"CHAT_OPEN": "Open room chat", "CHAT_OPEN": "Open room chat",
+2
View File
@@ -246,6 +246,8 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Cambia tu nombre de usuario, tema y preferencias de idioma.", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Cambia tu nombre de usuario, tema y preferencias de idioma.",
"LABEL_SETTINGS_GROUP_SYNC": "Reproducción y Sincronización", "LABEL_SETTINGS_GROUP_SYNC": "Reproducción y Sincronización",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personaliza la reproducción, las notificaciones y las preferencias de audio.", "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personaliza la reproducción, las notificaciones y las preferencias de audio.",
"LABEL_CHAT_ENABLED": "Activar chat de la sala",
"LABEL_CHAT_ENABLED_TOOLTIP": "Mostrar el chat cifrado de la sala en la pestaña de vídeo seleccionada.",
"CHAT_TITLE": "Chat de la sala", "CHAT_TITLE": "Chat de la sala",
"CHAT_LIVE_ONLY": "Solo en directo. Sin historial.", "CHAT_LIVE_ONLY": "Solo en directo. Sin historial.",
"CHAT_OPEN": "Abrir chat de la sala", "CHAT_OPEN": "Abrir chat de la sala",
+2
View File
@@ -246,6 +246,8 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Modifiez votre nom d'utilisateur, votre thème et vos préférences linguistiques.", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Modifiez votre nom d'utilisateur, votre thème et vos préférences linguistiques.",
"LABEL_SETTINGS_GROUP_SYNC": "Lecture & Sync", "LABEL_SETTINGS_GROUP_SYNC": "Lecture & Sync",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personnalisez la lecture, les notifications et les préférences audio.", "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personnalisez la lecture, les notifications et les préférences audio.",
"LABEL_CHAT_ENABLED": "Activer le chat du salon",
"LABEL_CHAT_ENABLED_TOOLTIP": "Afficher le chat chiffré du salon dans l'onglet vidéo sélectionné.",
"CHAT_TITLE": "Chat du salon", "CHAT_TITLE": "Chat du salon",
"CHAT_LIVE_ONLY": "En direct uniquement. Aucun historique.", "CHAT_LIVE_ONLY": "En direct uniquement. Aucun historique.",
"CHAT_OPEN": "Ouvrir le chat du salon", "CHAT_OPEN": "Ouvrir le chat du salon",
+2
View File
@@ -246,6 +246,8 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Modifica il tuo nome utente, tema e preferenze di lingua.", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Modifica il tuo nome utente, tema e preferenze di lingua.",
"LABEL_SETTINGS_GROUP_SYNC": "Riproduzione & Sync", "LABEL_SETTINGS_GROUP_SYNC": "Riproduzione & Sync",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalizza la riproduzione, le notifiche e le preferenze audio.", "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalizza la riproduzione, le notifiche e le preferenze audio.",
"LABEL_CHAT_ENABLED": "Attiva chat della stanza",
"LABEL_CHAT_ENABLED_TOOLTIP": "Mostra la chat crittografata della stanza nella scheda video selezionata.",
"CHAT_TITLE": "Chat della stanza", "CHAT_TITLE": "Chat della stanza",
"CHAT_LIVE_ONLY": "Solo in diretta. Nessuna cronologia.", "CHAT_LIVE_ONLY": "Solo in diretta. Nessuna cronologia.",
"CHAT_OPEN": "Apri la chat della stanza", "CHAT_OPEN": "Apri la chat della stanza",
+2
View File
@@ -246,6 +246,8 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "ユーザー名、テーマ、言語設定を変更します。", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "ユーザー名、テーマ、言語設定を変更します。",
"LABEL_SETTINGS_GROUP_SYNC": "再生と同期", "LABEL_SETTINGS_GROUP_SYNC": "再生と同期",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "再生、通知、オーディオ設定をカスタマイズします。", "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "再生、通知、オーディオ設定をカスタマイズします。",
"LABEL_CHAT_ENABLED": "ルームチャットを有効化",
"LABEL_CHAT_ENABLED_TOOLTIP": "選択した動画タブに暗号化されたルームチャットを表示します。",
"CHAT_TITLE": "ルームチャット", "CHAT_TITLE": "ルームチャット",
"CHAT_LIVE_ONLY": "ライブのみ。履歴はありません。", "CHAT_LIVE_ONLY": "ライブのみ。履歴はありません。",
"CHAT_OPEN": "ルームチャットを開く", "CHAT_OPEN": "ルームチャットを開く",
+2
View File
@@ -246,6 +246,8 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "사용자 이름, 테마 및 언어 설정을 변경합니다.", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "사용자 이름, 테마 및 언어 설정을 변경합니다.",
"LABEL_SETTINGS_GROUP_SYNC": "재생 및 동기화", "LABEL_SETTINGS_GROUP_SYNC": "재생 및 동기화",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "재생, 알림 및 오디오 설정을 맞춤설정합니다.", "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "재생, 알림 및 오디오 설정을 맞춤설정합니다.",
"LABEL_CHAT_ENABLED": "방 채팅 사용",
"LABEL_CHAT_ENABLED_TOOLTIP": "선택한 동영상 탭에 암호화된 방 채팅을 표시합니다.",
"CHAT_TITLE": "방 채팅", "CHAT_TITLE": "방 채팅",
"CHAT_LIVE_ONLY": "실시간 전용. 기록 없음.", "CHAT_LIVE_ONLY": "실시간 전용. 기록 없음.",
"CHAT_OPEN": "방 채팅 열기", "CHAT_OPEN": "방 채팅 열기",
+2
View File
@@ -246,6 +246,8 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Wijzig uw gebruikersnaam, thema en taalvoorkeuren.", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Wijzig uw gebruikersnaam, thema en taalvoorkeuren.",
"LABEL_SETTINGS_GROUP_SYNC": "Afspelen & Sync", "LABEL_SETTINGS_GROUP_SYNC": "Afspelen & Sync",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Pas afspelen, meldingen en audiovoorkeuren aan.", "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Pas afspelen, meldingen en audiovoorkeuren aan.",
"LABEL_CHAT_ENABLED": "Kamerchat inschakelen",
"LABEL_CHAT_ENABLED_TOOLTIP": "Versleutelde kamerchat tonen in het geselecteerde videotabblad.",
"CHAT_TITLE": "Kamerchat", "CHAT_TITLE": "Kamerchat",
"CHAT_LIVE_ONLY": "Alleen live. Geen geschiedenis.", "CHAT_LIVE_ONLY": "Alleen live. Geen geschiedenis.",
"CHAT_OPEN": "Kamerchat openen", "CHAT_OPEN": "Kamerchat openen",
+2
View File
@@ -246,6 +246,8 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Zmień swoją nazwę użytkownika, motyw i preferencje językowe.", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Zmień swoją nazwę użytkownika, motyw i preferencje językowe.",
"LABEL_SETTINGS_GROUP_SYNC": "Odtwarzanie i Sync", "LABEL_SETTINGS_GROUP_SYNC": "Odtwarzanie i Sync",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Dostosuj odtwarzanie, powiadomienia i preferencje audio.", "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Dostosuj odtwarzanie, powiadomienia i preferencje audio.",
"LABEL_CHAT_ENABLED": "Włącz czat pokoju",
"LABEL_CHAT_ENABLED_TOOLTIP": "Pokaż zaszyfrowany czat pokoju na wybranej karcie wideo.",
"CHAT_TITLE": "Czat pokoju", "CHAT_TITLE": "Czat pokoju",
"CHAT_LIVE_ONLY": "Tylko na żywo. Bez historii.", "CHAT_LIVE_ONLY": "Tylko na żywo. Bez historii.",
"CHAT_OPEN": "Otwórz czat pokoju", "CHAT_OPEN": "Otwórz czat pokoju",
+2
View File
@@ -246,6 +246,8 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Altere o seu nome de usuário, tema e preferências de idioma.", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Altere o seu nome de usuário, tema e preferências de idioma.",
"LABEL_SETTINGS_GROUP_SYNC": "Reprodução e Sincronização", "LABEL_SETTINGS_GROUP_SYNC": "Reprodução e Sincronização",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalize a reprodução, as notificações e as preferências de áudio.", "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalize a reprodução, as notificações e as preferências de áudio.",
"LABEL_CHAT_ENABLED": "Ativar chat da sala",
"LABEL_CHAT_ENABLED_TOOLTIP": "Mostrar o chat criptografado da sala na guia de vídeo selecionada.",
"CHAT_TITLE": "Chat da sala", "CHAT_TITLE": "Chat da sala",
"CHAT_LIVE_ONLY": "Somente ao vivo. Sem histórico.", "CHAT_LIVE_ONLY": "Somente ao vivo. Sem histórico.",
"CHAT_OPEN": "Abrir chat da sala", "CHAT_OPEN": "Abrir chat da sala",
+2
View File
@@ -246,6 +246,8 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Altere o seu nome de utilizador, tema e preferências de idioma.", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Altere o seu nome de utilizador, tema e preferências de idioma.",
"LABEL_SETTINGS_GROUP_SYNC": "Reprodução e Sincronização", "LABEL_SETTINGS_GROUP_SYNC": "Reprodução e Sincronização",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalize a reprodução, as notificações e as preferências de áudio.", "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalize a reprodução, as notificações e as preferências de áudio.",
"LABEL_CHAT_ENABLED": "Ativar chat da sala",
"LABEL_CHAT_ENABLED_TOOLTIP": "Mostrar o chat encriptado da sala no separador de vídeo selecionado.",
"CHAT_TITLE": "Chat da sala", "CHAT_TITLE": "Chat da sala",
"CHAT_LIVE_ONLY": "Apenas em direto. Sem histórico.", "CHAT_LIVE_ONLY": "Apenas em direto. Sem histórico.",
"CHAT_OPEN": "Abrir chat da sala", "CHAT_OPEN": "Abrir chat da sala",
+2
View File
@@ -246,6 +246,8 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Измените имя пользователя, тему и настройки языка.", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Измените имя пользователя, тему и настройки языка.",
"LABEL_SETTINGS_GROUP_SYNC": "Воспроизведение и Синхронизация", "LABEL_SETTINGS_GROUP_SYNC": "Воспроизведение и Синхронизация",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Настройте воспроизведение, уведомления и параметры звука.", "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Настройте воспроизведение, уведомления и параметры звука.",
"LABEL_CHAT_ENABLED": "Включить чат комнаты",
"LABEL_CHAT_ENABLED_TOOLTIP": "Показывать зашифрованный чат комнаты на выбранной вкладке с видео.",
"CHAT_TITLE": "Чат комнаты", "CHAT_TITLE": "Чат комнаты",
"CHAT_LIVE_ONLY": "Только в реальном времени. Без истории.", "CHAT_LIVE_ONLY": "Только в реальном времени. Без истории.",
"CHAT_OPEN": "Открыть чат комнаты", "CHAT_OPEN": "Открыть чат комнаты",
+2
View File
@@ -246,6 +246,8 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Kullanıcı adınızı, temanızı ve dil tercihlerinizi değiştirin.", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Kullanıcı adınızı, temanızı ve dil tercihlerinizi değiştirin.",
"LABEL_SETTINGS_GROUP_SYNC": "Oynatma ve Eşitleme", "LABEL_SETTINGS_GROUP_SYNC": "Oynatma ve Eşitleme",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Oynatma, bildirim ve ses tercihlerini özelleştirin.", "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Oynatma, bildirim ve ses tercihlerini özelleştirin.",
"LABEL_CHAT_ENABLED": "Oda sohbetini etkinleştir",
"LABEL_CHAT_ENABLED_TOOLTIP": "Şifreli oda sohbetini seçili video sekmesinde göster.",
"CHAT_TITLE": "Oda Sohbeti", "CHAT_TITLE": "Oda Sohbeti",
"CHAT_LIVE_ONLY": "Yalnızca canlı. Geçmiş yok.", "CHAT_LIVE_ONLY": "Yalnızca canlı. Geçmiş yok.",
"CHAT_OPEN": "Oda sohbetini aç", "CHAT_OPEN": "Oda sohbetini aç",
+2
View File
@@ -246,6 +246,8 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Змініть ім'я користувача, тему та мовні налаштування.", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Змініть ім'я користувача, тему та мовні налаштування.",
"LABEL_SETTINGS_GROUP_SYNC": "Відтворення та Синхронізація", "LABEL_SETTINGS_GROUP_SYNC": "Відтворення та Синхронізація",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Налаштуйте відтворення, сповіщення та параметри звуку.", "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Налаштуйте відтворення, сповіщення та параметри звуку.",
"LABEL_CHAT_ENABLED": "Увімкнути чат кімнати",
"LABEL_CHAT_ENABLED_TOOLTIP": "Показувати зашифрований чат кімнати на вибраній вкладці з відео.",
"CHAT_TITLE": "Чат кімнати", "CHAT_TITLE": "Чат кімнати",
"CHAT_LIVE_ONLY": "Лише наживо. Без історії.", "CHAT_LIVE_ONLY": "Лише наживо. Без історії.",
"CHAT_OPEN": "Відкрити чат кімнати", "CHAT_OPEN": "Відкрити чат кімнати",
+2
View File
@@ -246,6 +246,8 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "更改您的用户名、主题和语言首选项。", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "更改您的用户名、主题和语言首选项。",
"LABEL_SETTINGS_GROUP_SYNC": "播放与同步", "LABEL_SETTINGS_GROUP_SYNC": "播放与同步",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "自定义播放、通知和音频首选项。", "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "自定义播放、通知和音频首选项。",
"LABEL_CHAT_ENABLED": "启用房间聊天",
"LABEL_CHAT_ENABLED_TOOLTIP": "在选定的视频标签页中显示加密的房间聊天。",
"CHAT_TITLE": "房间聊天", "CHAT_TITLE": "房间聊天",
"CHAT_LIVE_ONLY": "仅实时显示,无历史记录。", "CHAT_LIVE_ONLY": "仅实时显示,无历史记录。",
"CHAT_OPEN": "打开房间聊天", "CHAT_OPEN": "打开房间聊天",
+7
View File
@@ -1625,6 +1625,13 @@
<span class="slider"></span> <span class="slider"></span>
</label> </label>
</div> </div>
<div class="settings-row">
<label for="chatEnabled" title="Show encrypted room chat on the selected video tab." data-i18n="LABEL_CHAT_ENABLED" data-i18n-title="LABEL_CHAT_ENABLED_TOOLTIP">Enable Room Chat</label>
<label class="toggle-switch">
<input type="checkbox" id="chatEnabled">
<span class="slider"></span>
</label>
</div>
<div class="settings-row"> <div class="settings-row">
<label for="filterNoise" title="Filters out non-video tabs and unrelated domains to keep the list clean" data-i18n="LABEL_HIDE_CLUTTER" data-i18n-title="LABEL_HIDE_CLUTTER_TOOLTIP">Hide Clutter Tabs</label> <label for="filterNoise" title="Filters out non-video tabs and unrelated domains to keep the list clean" data-i18n="LABEL_HIDE_CLUTTER" data-i18n-title="LABEL_HIDE_CLUTTER_TOOLTIP">Hide Clutter Tabs</label>
<label class="toggle-switch"> <label class="toggle-switch">
+9 -1
View File
@@ -68,6 +68,7 @@ const elements = {
playBtn: document.getElementById('playBtn'), playBtn: document.getElementById('playBtn'),
pauseBtn: document.getElementById('pauseBtn'), pauseBtn: document.getElementById('pauseBtn'),
autoSyncNextEpisode: document.getElementById('autoSyncNextEpisode'), autoSyncNextEpisode: document.getElementById('autoSyncNextEpisode'),
chatEnabled: document.getElementById('chatEnabled'),
sendTabTitle: document.getElementById('sendTabTitle'), sendTabTitle: document.getElementById('sendTabTitle'),
mediaTitlePrivacyMode: document.getElementById('mediaTitlePrivacyMode'), mediaTitlePrivacyMode: document.getElementById('mediaTitlePrivacyMode'),
episodeLobbyCard: document.getElementById('episodeLobbyCard'), episodeLobbyCard: document.getElementById('episodeLobbyCard'),
@@ -337,7 +338,7 @@ function setRoomRefreshCooldown() {
async function init() { async function init() {
// Local-only by design — settings and room credentials never come from // Local-only by design — settings and room credentials never come from
// storage.sync (only onboardingComplete + dismissedHints live there). // storage.sync (only onboardingComplete + dismissedHints live there).
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab', 'themeMode', 'themePalette']); const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'chatEnabled', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab', 'themeMode', 'themePalette']);
let activeLang = localData.locale; let activeLang = localData.locale;
if (!activeLang) { if (!activeLang) {
@@ -368,6 +369,7 @@ async function init() {
syncDevToolsVisibility(); syncDevToolsVisibility();
if (elements.filterNoise) elements.filterNoise.checked = localData.filterNoise !== false; if (elements.filterNoise) elements.filterNoise.checked = localData.filterNoise !== false;
if (elements.autoSyncNextEpisode) elements.autoSyncNextEpisode.checked = localData.autoSyncNextEpisode !== false; if (elements.autoSyncNextEpisode) elements.autoSyncNextEpisode.checked = localData.autoSyncNextEpisode !== false;
if (elements.chatEnabled) elements.chatEnabled.checked = localData.chatEnabled === true;
const legacyTitlePrivacyMode = Object.values(TITLE_PRIVACY_MODES).includes(localData.titlePrivacyMode) ? localData.titlePrivacyMode : TITLE_PRIVACY_MODES.FULL; const legacyTitlePrivacyMode = Object.values(TITLE_PRIVACY_MODES).includes(localData.titlePrivacyMode) ? localData.titlePrivacyMode : TITLE_PRIVACY_MODES.FULL;
const mediaTitlePrivacyMode = Object.values(TITLE_PRIVACY_MODES).includes(localData.mediaTitlePrivacyMode) ? localData.mediaTitlePrivacyMode : legacyTitlePrivacyMode; const mediaTitlePrivacyMode = Object.values(TITLE_PRIVACY_MODES).includes(localData.mediaTitlePrivacyMode) ? localData.mediaTitlePrivacyMode : legacyTitlePrivacyMode;
if (elements.sendTabTitle) elements.sendTabTitle.checked = normalizeSendTabTitle(localData.sendTabTitle, legacyTitlePrivacyMode); if (elements.sendTabTitle) elements.sendTabTitle.checked = normalizeSendTabTitle(localData.sendTabTitle, legacyTitlePrivacyMode);
@@ -1456,6 +1458,12 @@ elements.autoSyncNextEpisode.addEventListener('change', () => {
chrome.storage.local.set({ autoSyncNextEpisode: elements.autoSyncNextEpisode.checked }); chrome.storage.local.set({ autoSyncNextEpisode: elements.autoSyncNextEpisode.checked });
}); });
if (elements.chatEnabled) {
elements.chatEnabled.addEventListener('change', () => {
chrome.storage.local.set({ chatEnabled: elements.chatEnabled.checked });
});
}
if (elements.sendTabTitle) { if (elements.sendTabTitle) {
elements.sendTabTitle.addEventListener('change', () => { elements.sendTabTitle.addEventListener('change', () => {
chrome.storage.local.set({ sendTabTitle: elements.sendTabTitle.checked }, () => { chrome.storage.local.set({ sendTabTitle: elements.sendTabTitle.checked }, () => {
+37 -2
View File
@@ -23,7 +23,10 @@ async function c() {
function s(ws, evt, d={}) { ws.send(`42${JSON.stringify([evt,d])}`); } function s(ws, evt, d={}) { ws.send(`42${JSON.stringify([evt,d])}`); }
function a(ws) { if (ws._m.length) { const r=ws._m.shift(); return r.startsWith('42') ? JSON.parse(r.substring(2)) : r; } return new Promise((resolve, reject) => { const t=setTimeout(()=>reject(Error('timeout')),3e3); const h=(d)=>{clearTimeout(t);ws.removeListener('message',h);const r=d.toString();resolve(r.startsWith('42')?JSON.parse(r.substring(2)):r);};ws.on('message',h);}); } function a(ws) { if (ws._m.length) { const r=ws._m.shift(); return r.startsWith('42') ? JSON.parse(r.substring(2)) : r; } return new Promise((resolve, reject) => { const t=setTimeout(()=>reject(Error('timeout')),3e3); const h=(d)=>{clearTimeout(t);ws.removeListener('message',h);const r=d.toString();resolve(r.startsWith('42')?JSON.parse(r.substring(2)):r);};ws.on('message',h);}); }
async function w(ws, evt, ms=3000) { const st=Date.now(); while(Date.now()-st<ms) { for(let i=0;i<ws._m.length;i++){const r=ws._m[i];ws._m.splice(i,1);if(r.startsWith('42')){try{const[e]=JSON.parse(r.substring(2));if(e===evt)return e}catch{/* skip */}}} await new Promise(r=>setTimeout(r,50));} throw Error(`wait:${evt}`); } async function w(ws, evt, ms=3000) { const st=Date.now(); while(Date.now()-st<ms) { for(let i=0;i<ws._m.length;i++){const r=ws._m[i];ws._m.splice(i,1);if(r.startsWith('42')){try{const[e]=JSON.parse(r.substring(2));if(e===evt)return e}catch{/* skip */}}} await new Promise(r=>setTimeout(r,50));} throw Error(`wait:${evt}`); }
async function j(ws, rid, pid, pw=null) { s(ws,'join_room',{roomId:rid,peerId:pid,password:pw,protocolVersion:'1.0.0'}); assert.equal((await a(ws))[0],'room_data'); } async function j(ws, rid, pid, pw=null, clientCapabilities=undefined) {
s(ws,'join_room',{roomId:rid,peerId:pid,password:pw,protocolVersion:'1.0.0',clientCapabilities});
assert.equal((await a(ws))[0],'room_data');
}
function close() { clients.forEach(w=>{try{w.close()}catch{/* ignore */}}); clients.length=0; } function close() { clients.forEach(w=>{try{w.close()}catch{/* ignore */}}); clients.length=0; }
// Test suite opens >10 connections/min — clear the IP connection counter so the // Test suite opens >10 connections/min — clear the IP connection counter so the
// connection rate limiter doesn't mask test failures (test-only, never at runtime). // connection rate limiter doesn't mask test failures (test-only, never at runtime).
@@ -72,6 +75,7 @@ try {
assert.ok(Array.isArray(capData.capabilities) && capData.capabilities.includes('host-control'), assert.ok(Array.isArray(capData.capabilities) && capData.capabilities.includes('host-control'),
'ROOM_DATA advertises the host-control capability'); 'ROOM_DATA advertises the host-control capability');
assert.ok(capData.capabilities.includes('chat'), 'ROOM_DATA advertises the chat capability'); assert.ok(capData.capabilities.includes('chat'), 'ROOM_DATA advertises the chat capability');
assert.ok(capData.capabilities.includes('chat-v1'), 'ROOM_DATA advertises the versioned chat capability');
assert.equal(capData.chatHistory, undefined, 'ROOM_DATA never contains chat history'); assert.equal(capData.chatHistory, undefined, 'ROOM_DATA never contains chat history');
close(); close();
resetConnectionRate(); resetConnectionRate();
@@ -79,7 +83,8 @@ try {
// --- Encrypted chat is a live-only canonical relay --- // --- Encrypted chat is a live-only canonical relay ---
const chatRoom = 'chat-'+Date.now(); const chatRoom = 'chat-'+Date.now();
const chat1 = await c(), chat2 = await c(); const chat1 = await c(), chat2 = await c();
await j(chat1, chatRoom, 'alice'); await j(chat2, chatRoom, 'bob'); await j(chat1, chatRoom, 'alice', null, ['chat-v1']);
await j(chat2, chatRoom, 'bob', null, ['chat-v1']);
chat1._m.length = chat2._m.length = 0; chat1._m.length = chat2._m.length = 0;
const ciphertext = Buffer.alloc(64, 7).toString('base64url'); const ciphertext = Buffer.alloc(64, 7).toString('base64url');
s(chat1, 'chat_message', { s(chat1, 'chat_message', {
@@ -106,6 +111,36 @@ try {
close(); close();
resetConnectionRate(); resetConnectionRate();
// --- Mixed rollout: old non-chat clients never receive unknown chat events ---
const mixedChatRoom = 'chat-mixed-'+Date.now();
const newChat = await c(), oldNoChat = await c();
await j(newChat, mixedChatRoom, 'new-chat', null, ['chat-v1', 'chat-v1', 'future-chat', 42]);
await j(oldNoChat, mixedChatRoom, 'old-no-chat', null, ['chat-v2']);
newChat._m.length = oldNoChat._m.length = 0;
s(newChat, 'chat_message', { ciphertext });
await w(newChat, 'chat_message');
let oldReceivedChat = false;
try { await w(oldNoChat, 'chat_message', 500); oldReceivedChat = true; } catch { /* expected */ }
assert.equal(oldReceivedChat, false, 'old client receives no unknown chat event');
// The same old client remains fully usable for the pre-chat protocol.
newChat._m.length = oldNoChat._m.length = 0;
s(oldNoChat, 'play', { currentTime: 12 });
await w(newChat, 'play');
s(newChat, 'pause', { currentTime: 13 });
await w(oldNoChat, 'pause');
// First chat beta compatibility: sending a valid chat frame dynamically
// proves receive support even when that beta omitted clientCapabilities.
const firstBeta = await c();
await j(firstBeta, mixedChatRoom, 'first-beta');
firstBeta._m.length = newChat._m.length = 0;
s(firstBeta, 'chat_message', { ciphertext });
await w(firstBeta, 'chat_message');
await w(newChat, 'chat_message');
close();
resetConnectionRate();
// --- Default 'everyone' mode does NOT gate anyone (host-control OFF = unchanged) --- // --- Default 'everyone' mode does NOT gate anyone (host-control OFF = unchanged) ---
// Confirms that with host-only off, a non-host guest can still drive every // Confirms that with host-only off, a non-host guest can still drive every
// room-moving event exactly like before the feature existed. // room-moving event exactly like before the feature existed.
+38 -2
View File
@@ -174,7 +174,26 @@ const HOST_ONLY_GATED_EVENTS = new Set([
// Features this relay supports, advertised to clients in ROOM_DATA so they can // Features this relay supports, advertised to clients in ROOM_DATA so they can
// enable matching UI/behavior only when the server actually backs it. Append a // enable matching UI/behavior only when the server actually backs it. Append a
// flag here when a new server-gated feature ships (e.g. co-host promotion). // flag here when a new server-gated feature ships (e.g. co-host promotion).
const SERVER_CAPABILITIES = [CAPABILITIES.HOST_CONTROL, CAPABILITIES.CO_HOST, CAPABILITIES.CHAT]; const SERVER_CAPABILITIES = [
CAPABILITIES.HOST_CONTROL,
CAPABILITIES.CO_HOST,
CAPABILITIES.CHAT,
CAPABILITIES.CHAT_V1
];
function normalizeClientCapabilities(value) {
if (!Array.isArray(value)) return [];
return [...new Set(value.slice(0, 16)
.filter(capability => typeof capability === 'string')
.map(capability => capability.substring(0, 32))
.filter(capability => capability === CAPABILITIES.CHAT_V1)
)];
}
function clientSupportsChat(socket) {
return Array.isArray(socket?.data?.clientCapabilities) &&
socket.data.clientCapabilities.includes(CAPABILITIES.CHAT_V1);
}
// M-4: minimum interval between CONTROL_MODE changes per room. Stops a rapidly // M-4: minimum interval between CONTROL_MODE changes per room. Stops a rapidly
// toggling host from thrashing every guest's UI (locked/unlocked/locked...) and // toggling host from thrashing every guest's UI (locked/unlocked/locked...) and
@@ -356,6 +375,7 @@ io.on('connection', (socket) => {
const username = typeof payload.username === 'string' ? payload.username.substring(0, 30) : null; const username = typeof payload.username === 'string' ? payload.username.substring(0, 30) : null;
const tabTitle = typeof payload.tabTitle === 'string' ? payload.tabTitle.substring(0, 100) : null; const tabTitle = typeof payload.tabTitle === 'string' ? payload.tabTitle.substring(0, 100) : null;
const mediaTitle = typeof payload.mediaTitle === 'string' ? payload.mediaTitle.substring(0, 100) : null; const mediaTitle = typeof payload.mediaTitle === 'string' ? payload.mediaTitle.substring(0, 100) : null;
const clientCapabilities = normalizeClientCapabilities(payload.clientCapabilities);
if (!roomId || !peerId) return; // Guard: empty or invalid after sanitization if (!roomId || !peerId) return; // Guard: empty or invalid after sanitization
@@ -370,6 +390,7 @@ io.on('connection', (socket) => {
// Cleanup old room if re-joining // Cleanup old room if re-joining
const oldMapping = socketToRoom.get(socket.id); const oldMapping = socketToRoom.get(socket.id);
if (oldMapping && oldMapping.roomId === roomId && oldMapping.peerId === peerId) { if (oldMapping && oldMapping.roomId === roomId && oldMapping.peerId === peerId) {
socket.data.clientCapabilities = clientCapabilities;
return; // Already in this room with same peerId, ignore to prevent spam return; // Already in this room with same peerId, ignore to prevent spam
} }
if (oldMapping && oldMapping.roomId !== roomId) { if (oldMapping && oldMapping.roomId !== roomId) {
@@ -493,6 +514,7 @@ io.on('connection', (socket) => {
} }
socket.join(roomId); socket.join(roomId);
socket.data.clientCapabilities = clientCapabilities;
room.peers.add(socket.id); room.peers.add(socket.id);
room.peerIds.set(socket.id, peerId); room.peerIds.set(socket.id, peerId);
room.peerData.set(socket.id, { room.peerData.set(socket.id, {
@@ -862,8 +884,22 @@ io.on('connection', (socket) => {
const envelope = createChatEnvelope(data, mapping.peerId); const envelope = createChatEnvelope(data, mapping.peerId);
if (!envelope) return; if (!envelope) return;
// Transitional compatibility: the first chat beta did not announce
// clientCapabilities. Successfully sending a canonical chat frame is
// sufficient proof that this socket understands the v1 receive event.
if (!clientSupportsChat(socket)) {
socket.data.clientCapabilities = [
...(socket.data.clientCapabilities || []),
CAPABILITIES.CHAT_V1
];
}
room.lastActivity = Date.now(); room.lastActivity = Date.now();
io.to(mapping.roomId).emit(EVENTS.CHAT_MESSAGE, envelope); for (const socketId of room.peers) {
const recipient = io.sockets.sockets.get(socketId);
if (clientSupportsChat(recipient)) {
recipient.emit(EVENTS.CHAT_MESSAGE, envelope);
}
}
} catch (err) { } catch (err) {
log('ERROR', `CHAT_MESSAGE handler error: ${err.message}`); log('ERROR', `CHAT_MESSAGE handler error: ${err.message}`);
} }
+44 -14
View File
@@ -70,20 +70,20 @@
} }
}, },
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "2.2.2", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bytes": "^3.1.2", "bytes": "^3.1.2",
"content-type": "^1.0.5", "content-type": "^2.0.0",
"debug": "^4.4.3", "debug": "^4.4.3",
"http-errors": "^2.0.0", "http-errors": "^2.0.1",
"iconv-lite": "^0.7.0", "iconv-lite": "^0.7.2",
"on-finished": "^2.4.1", "on-finished": "^2.4.1",
"qs": "^6.14.1", "qs": "^6.15.2",
"raw-body": "^3.0.1", "raw-body": "^3.0.2",
"type-is": "^2.0.1" "type-is": "^2.1.0"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -93,6 +93,19 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/body-parser/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1025,17 +1038,34 @@
} }
}, },
"node_modules/type-is": { "node_modules/type-is": {
"version": "2.0.1", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"content-type": "^1.0.5", "content-type": "^2.0.0",
"media-typer": "^1.1.0", "media-typer": "^1.1.0",
"mime-types": "^3.0.0" "mime-types": "^3.0.0"
}, },
"engines": { "engines": {
"node": ">= 0.6" "node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
+2 -1
View File
@@ -81,7 +81,8 @@ export const CONTROL_MODES = {
export const CAPABILITIES = { export const CAPABILITIES = {
HOST_CONTROL: 'host-control', HOST_CONTROL: 'host-control',
CO_HOST: 'co-host', // owner promotes guests to additional controllers CO_HOST: 'co-host', // owner promotes guests to additional controllers
CHAT: 'chat' CHAT: 'chat', // legacy server capability used by the first chat beta
CHAT_V1: 'chat-v1' // versioned client/server chat wire contract
}; };
export const HEARTBEAT_INTERVAL = 15000; // 15s export const HEARTBEAT_INTERVAL = 15000; // 15s