Compare commits

...

7 Commits

Author SHA1 Message Date
Timo 79715046a8 fix npm 11 lockfile portability 2026-07-26 01:23:07 +02:00
Timo 2ae79386a0 fix vulnerable build dependencies 2026-07-26 01:18:24 +02:00
Timo 883e49433e Merge branch 'feature/textchat' 2026-07-26 01:04:38 +02:00
Timo 59135b319e Merge remote-tracking branch 'origin/main' into feature/textchat
# Conflicts:
#	website/build.cjs
2026-07-26 01:03:27 +02:00
Timo ae2238e3bc docs: plan modern chat for v3.0 2026-07-26 01:02:33 +02:00
Timo 77790a279c fix(chat): preserve mixed-version compatibility 2026-07-26 01:02:20 +02:00
KoalaDev 2df048621b Centralize KoalaStuff legal notice 2026-07-23 03:04:20 +02:00
31 changed files with 599 additions and 780 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ Please note that by participating in this project, you agree to abide by our [Co
### Prerequisites
- **Node.js** v18+
- **Node.js** v20.9+
- **Docker** (for local relay server testing)
### Quick Start
+13
View File
@@ -67,6 +67,19 @@ the stamped value as AAD.
- 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
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.
## 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.
+15 -4
View File
@@ -54,6 +54,7 @@ Payload:
"password": "string, max 128, optional",
"tabTitle": "string, max 100, optional",
"mediaTitle": "string, max 100, optional",
"clientCapabilities": ["chat-v1"],
"protocolVersion": "string, max 16"
}
```
@@ -82,7 +83,7 @@ Payload:
"hostPeerId": "string or null",
"controlMode": "everyone | host-only",
"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
Relays advertise chat support with `"chat"` in `room_data.capabilities`. Clients
must not infer support from another field.
Relays advertise chat support with `"chat-v1"` in `room_data.capabilities` and keep
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`
@@ -106,7 +116,7 @@ Client to relay:
authentication tag. The relay validates only canonical base64url and byte bounds.
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
{
@@ -413,5 +423,6 @@ If sender and target are still in the same room, the relay emits:
- `host-control`
- `co-host`
- `chat`
- `chat-v1`
Clients should treat a missing or unknown capabilities list as unsupported.
+77 -11
View File
@@ -30,6 +30,83 @@
*Prioritized for upcoming phases.*
### v3.0 — Modern Room Chat
- **Release date:** Not scheduled
- **Priority:** P1
- **Category:** Social / Communication / Privacy
- **Status:** Planned. This is a roadmap target, not a release announcement.
- **Existing foundation:** Opt-in, live-only end-to-end encrypted room chat; a floating
chat bubble over the selected player opens a dockable, detachable, resizable overlay.
The relay stores no chat history and mixed extension versions use capability-gated
delivery.
#### Core experience
- Floating bubble with an accessible unread badge (`1`, `2`, `3`, …, `99+`), cleared
only when the chat is actually viewed.
- Subtle new-message pulse with reduced-motion support; no animation or sound while
muted or during Do Not Disturb.
- Drag-and-snap bubble positioning, remembered per site, with reset-to-default.
- Dock left/right, detached overlay, compact mode, fullscreen-safe placement, responsive
narrow-player layout, and keyboard-only operation.
- Per-room mute, mention-only mode, notification sound controls, browser notifications,
and a visible mute state.
- Unread state survives overlay close/reopen and tab focus changes, but room messages
remain memory-only unless a future storage option is explicitly enabled.
#### Messaging
- Replies with quoted context, emoji reactions, emoji picker, mentions, and typing
indicators.
- Edit and delete own messages with clear local tombstones; no silent mutation.
- Delivery states (`sending`, `sent`, `failed`, `retry`) with idempotent retries and
duplicate suppression after reconnects.
- Message timestamps, date separators, jump-to-latest, unread divider, search within
the current live session, copy, and selectable text.
- Link detection with safe external opening, explicit scheme allowlist, and no automatic
previews or remote-media loading.
- Optional image/GIF/file sharing only after encrypted size limits, malware-risk UX,
relay bandwidth limits, and privacy behavior are designed and tested.
#### Presence, safety, and control
- Online/reconnecting status, join/leave events, and room-member presence without
exposing IP addresses or cross-room identity.
- Local mute/block, host moderation controls, slow mode, spam limits, and configurable
maximum message length.
- Optional read receipts and activity indicators default off; never infer or expose
viewing behavior without explicit consent.
- Clear live-only/no-history explanation, encrypted-chat indicator, room-key fingerprint,
and key-rotation/rejoin UX.
#### Reliability and compatibility
- Versioned chat capabilities for every new wire feature; unknown fields/events remain
harmless to older extensions and old non-chat clients receive no chat traffic.
- Server-first and extension-first rollout tests, including current, first-chat-beta,
old non-chat, reconnect, duplicate-frame, and malformed-capability clients.
- Bounded queues, payloads, unread counters, typing events, reactions, and attachment
metadata; backpressure and rate limits on both client and relay.
- Real Chrome and Firefox extension E2E coverage for bubble, unread count, overlay,
fullscreen, reconnect, and mixed-version rooms before v3.0 can ship.
#### Accessibility and localization
- Screen-reader announcements that do not read every busy-room message by default.
- Complete focus management, logical tab order, Escape behavior, high contrast,
zoom/reflow, reduced motion, and touch target coverage.
- All chat UI, notification text, moderation states, and errors localized across every
supported locale before release.
#### Explicit non-goals for the first v3.0 release
- No server-readable plaintext.
- No mandatory accounts, public room directory, advertising, tracking, or presence
across rooms.
- No unencrypted server-side history. Any future encrypted history requires a separate
threat model, retention controls, export/delete behavior, and explicit opt-in.
### 2. Invite link with target URL for auto-redirect
- **Priority:** P2
@@ -49,17 +126,6 @@
*Ideas and feature requests under evaluation.*
### In-room chat overlay (like TeleParty)
- **Priority:** P3
- **Category:** Social / Communication
- **Background:** A collapsible chat panel to the right of the video (or as an overlay) allowing text-based communication with everyone in the room.
- **Why backlog (still uncertain):**
- **Use case:** No strong personal need — with chat, latency matters less than with voice; async communication tolerates a few seconds of delay.
- **Complexity:** Relatively large feature (UI + message persistence + possibly history).
- **Legal/moderation:** Unclear what moderation requirements would apply if users can exchange chat messages. Could be relevant depending on jurisdiction.
- **Status:** Under evaluation, may come later.
### Cross-frame video detection and control
- **Priority:** P3
+23 -8
View File
@@ -91,6 +91,10 @@ let chatReceiveQueue = Promise.resolve();
const chatSendLimiter = createChatSendLimiter();
const webJoinCoordinator = createLatestTaskQueue();
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() {
chatSessionGeneration++;
@@ -394,7 +398,7 @@ async function getSettings() {
// (username) must NEVER come from storage.sync — syncing them across devices
// 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).
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;
if (!username) {
username = generateUsername();
@@ -412,6 +416,7 @@ async function getSettings() {
roomId,
password: data.password || '',
chatKey,
chatEnabled: data.chatEnabled === true,
username,
sendTabTitle: normalizeSendTabTitle(data.sendTabTitle, legacyTitlePrivacyMode),
mediaTitlePrivacyMode
@@ -748,6 +753,7 @@ async function connect() {
peerId,
username: settings.username,
tabTitle: sharedTitles.tabTitle,
clientCapabilities: CLIENT_CAPABILITIES,
protocolVersion: PROTOCOL_VERSION
});
}
@@ -1193,7 +1199,7 @@ async function handleServerEvent(event, data) {
chrome.runtime.sendMessage({ type: 'ROOM_LIST', rooms: data.rooms }).catch(() => {});
break;
case EVENTS.CHAT_MESSAGE: {
if (!currentRoom || !serverSupports(CAPABILITIES.CHAT) || !currentTabId) break;
if (!currentRoom || !serverSupportsChat() || !currentTabId) break;
const generation = chatSessionGeneration;
const roomId = currentRoom.roomId;
const tabId = Number(currentTabId);
@@ -1203,7 +1209,7 @@ async function handleServerEvent(event, data) {
chatReceiveQueue = chatReceiveQueue.catch(() => {}).then(async () => {
if (!isCurrentSession()) return;
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;
try {
const text = await decryptChatMessage({
@@ -2547,7 +2553,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
});
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);
invalidateChatSession();
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
@@ -2593,6 +2599,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
peerId,
username: settings.username,
tabTitle: sharedTitles.tabTitle,
clientCapabilities: CLIENT_CAPABILITIES,
protocolVersion: PROTOCOL_VERSION
});
}
@@ -2611,6 +2618,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
await retryPendingTarget();
}
const pendingTarget = await readPendingTarget();
const settings = await getSettings();
const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined;
const isReconnecting = !isConnected && reconnectAttempts > 0;
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(),
hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL),
coHostSupported: serverSupports(CAPABILITIES.CO_HOST),
chatSupported: serverSupports(CAPABILITIES.CHAT),
hasChatKey: !!(await getSettings()).chatKey
chatSupported: serverSupportsChat(),
hasChatKey: !!settings.chatKey,
chatEnabled: settings.chatEnabled
});
} else if (message.type === 'GET_CHAT_CONTEXT') {
const senderTabId = sender.tab?.id;
@@ -2658,7 +2667,8 @@ async function handleAsyncMessage(message, sender, sendResponse) {
return value === key ? '' : value;
};
sendResponse({
supported: serverSupports(CAPABILITIES.CHAT),
supported: serverSupportsChat(),
enabled: settings.chatEnabled,
hasKey: !!settings.chatKey,
connected: !!(socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined),
peerId,
@@ -2686,7 +2696,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse({ status: 'invalid_tab' });
return;
}
if (!serverSupports(CAPABILITIES.CHAT)) {
if (!serverSupportsChat()) {
sendResponse({ status: 'unsupported' });
return;
}
@@ -2702,6 +2712,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
currentRoom?.roomId === roomId && Number(currentTabId) === tabId &&
socket === socketSnapshot && socketSnapshot.readyState === WebSocket.OPEN && isNamespaceJoined;
const settings = await getSettings();
if (!settings.chatEnabled) {
sendResponse({ status: 'disabled' });
return;
}
if (!settings.chatKey || settings.roomId !== roomId || !isCurrentSession()) {
sendResponse({ status: settings.chatKey ? 'session_changed' : 'missing_key' });
return;
@@ -2945,6 +2959,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
peerId,
username: settings.username,
tabTitle: sharedTitles.tabTitle,
clientCapabilities: CLIENT_CAPABILITIES,
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 localeDir = path.join(extensionDir, 'locales');
const chatKeys = [
'LABEL_CHAT_ENABLED',
'LABEL_CHAT_ENABLED_TOOLTIP',
'CHAT_TITLE',
'CHAT_LIVE_ONLY',
'CHAT_OPEN',
@@ -60,6 +62,18 @@ describe('chat overlay contract', () => {
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', () => {
expect(overlaySource).toContain("launcher.setAttribute('aria-disabled'");
expect(overlaySource).not.toContain('launcher.disabled =');
+4 -3
View File
@@ -25,7 +25,7 @@
const host = document.createElement('div');
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 style = document.createElement('style');
style.textContent = `
@@ -324,11 +324,12 @@
const previousRoomId = context?.roomId;
context = next || null;
const supported = !!context?.supported;
const optedIn = !!context?.enabled;
const hasKey = !!context?.hasKey;
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();
host.style.display = supported ? '' : 'none';
host.style.display = supported && optedIn ? '' : 'none';
launcher.setAttribute('aria-disabled', String(!context?.enabled));
if (!context?.enabled) setOpened(false);
applyStrings();
+2
View File
@@ -246,6 +246,8 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Ändere deinen Benutzernamen, dein Design und deine Spracheinstellungen.",
"LABEL_SETTINGS_GROUP_SYNC": "Wiedergabe & Sync",
"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_LIVE_ONLY": "Nur live. Kein Verlauf.",
"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_SYNC": "Playback & Sync",
"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_LIVE_ONLY": "Live only. No history.",
"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_SYNC": "Reproducción y Sincronización",
"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_LIVE_ONLY": "Solo en directo. Sin historial.",
"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_SYNC": "Lecture & Sync",
"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_LIVE_ONLY": "En direct uniquement. Aucun historique.",
"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_SYNC": "Riproduzione & Sync",
"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_LIVE_ONLY": "Solo in diretta. Nessuna cronologia.",
"CHAT_OPEN": "Apri la chat della stanza",
+2
View File
@@ -246,6 +246,8 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "ユーザー名、テーマ、言語設定を変更します。",
"LABEL_SETTINGS_GROUP_SYNC": "再生と同期",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "再生、通知、オーディオ設定をカスタマイズします。",
"LABEL_CHAT_ENABLED": "ルームチャットを有効化",
"LABEL_CHAT_ENABLED_TOOLTIP": "選択した動画タブに暗号化されたルームチャットを表示します。",
"CHAT_TITLE": "ルームチャット",
"CHAT_LIVE_ONLY": "ライブのみ。履歴はありません。",
"CHAT_OPEN": "ルームチャットを開く",
+2
View File
@@ -246,6 +246,8 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "사용자 이름, 테마 및 언어 설정을 변경합니다.",
"LABEL_SETTINGS_GROUP_SYNC": "재생 및 동기화",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "재생, 알림 및 오디오 설정을 맞춤설정합니다.",
"LABEL_CHAT_ENABLED": "방 채팅 사용",
"LABEL_CHAT_ENABLED_TOOLTIP": "선택한 동영상 탭에 암호화된 방 채팅을 표시합니다.",
"CHAT_TITLE": "방 채팅",
"CHAT_LIVE_ONLY": "실시간 전용. 기록 없음.",
"CHAT_OPEN": "방 채팅 열기",
+2
View File
@@ -246,6 +246,8 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Wijzig uw gebruikersnaam, thema en taalvoorkeuren.",
"LABEL_SETTINGS_GROUP_SYNC": "Afspelen & Sync",
"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_LIVE_ONLY": "Alleen live. Geen geschiedenis.",
"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_SYNC": "Odtwarzanie i Sync",
"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_LIVE_ONLY": "Tylko na żywo. Bez historii.",
"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_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_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_LIVE_ONLY": "Somente ao vivo. Sem histórico.",
"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_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_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_LIVE_ONLY": "Apenas em direto. Sem histórico.",
"CHAT_OPEN": "Abrir chat da sala",
+2
View File
@@ -246,6 +246,8 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Измените имя пользователя, тему и настройки языка.",
"LABEL_SETTINGS_GROUP_SYNC": "Воспроизведение и Синхронизация",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Настройте воспроизведение, уведомления и параметры звука.",
"LABEL_CHAT_ENABLED": "Включить чат комнаты",
"LABEL_CHAT_ENABLED_TOOLTIP": "Показывать зашифрованный чат комнаты на выбранной вкладке с видео.",
"CHAT_TITLE": "Чат комнаты",
"CHAT_LIVE_ONLY": "Только в реальном времени. Без истории.",
"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_SYNC": "Oynatma ve Eşitleme",
"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_LIVE_ONLY": "Yalnızca canlı. Geçmiş yok.",
"CHAT_OPEN": "Oda sohbetini aç",
+2
View File
@@ -246,6 +246,8 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Змініть ім'я користувача, тему та мовні налаштування.",
"LABEL_SETTINGS_GROUP_SYNC": "Відтворення та Синхронізація",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Налаштуйте відтворення, сповіщення та параметри звуку.",
"LABEL_CHAT_ENABLED": "Увімкнути чат кімнати",
"LABEL_CHAT_ENABLED_TOOLTIP": "Показувати зашифрований чат кімнати на вибраній вкладці з відео.",
"CHAT_TITLE": "Чат кімнати",
"CHAT_LIVE_ONLY": "Лише наживо. Без історії.",
"CHAT_OPEN": "Відкрити чат кімнати",
+2
View File
@@ -246,6 +246,8 @@
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "更改您的用户名、主题和语言首选项。",
"LABEL_SETTINGS_GROUP_SYNC": "播放与同步",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "自定义播放、通知和音频首选项。",
"LABEL_CHAT_ENABLED": "启用房间聊天",
"LABEL_CHAT_ENABLED_TOOLTIP": "在选定的视频标签页中显示加密的房间聊天。",
"CHAT_TITLE": "房间聊天",
"CHAT_LIVE_ONLY": "仅实时显示,无历史记录。",
"CHAT_OPEN": "打开房间聊天",
+7
View File
@@ -1625,6 +1625,13 @@
<span class="slider"></span>
</label>
</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">
<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">
+9 -1
View File
@@ -68,6 +68,7 @@ const elements = {
playBtn: document.getElementById('playBtn'),
pauseBtn: document.getElementById('pauseBtn'),
autoSyncNextEpisode: document.getElementById('autoSyncNextEpisode'),
chatEnabled: document.getElementById('chatEnabled'),
sendTabTitle: document.getElementById('sendTabTitle'),
mediaTitlePrivacyMode: document.getElementById('mediaTitlePrivacyMode'),
episodeLobbyCard: document.getElementById('episodeLobbyCard'),
@@ -337,7 +338,7 @@ function setRoomRefreshCooldown() {
async function init() {
// Local-only by design — settings and room credentials never come from
// 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;
if (!activeLang) {
@@ -368,6 +369,7 @@ async function init() {
syncDevToolsVisibility();
if (elements.filterNoise) elements.filterNoise.checked = localData.filterNoise !== 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 mediaTitlePrivacyMode = Object.values(TITLE_PRIVACY_MODES).includes(localData.mediaTitlePrivacyMode) ? localData.mediaTitlePrivacyMode : 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 });
});
if (elements.chatEnabled) {
elements.chatEnabled.addEventListener('change', () => {
chrome.storage.local.set({ chatEnabled: elements.chatEnabled.checked });
});
}
if (elements.sendTabTitle) {
elements.sendTabTitle.addEventListener('change', () => {
chrome.storage.local.set({ sendTabTitle: elements.sendTabTitle.checked }, () => {
+266 -721
View File
File diff suppressed because it is too large Load Diff
+11 -3
View File
@@ -4,6 +4,9 @@
"description": "KoalaSync Build Scripts",
"private": true,
"type": "module",
"engines": {
"node": ">=20.9.0"
},
"scripts": {
"build:extension": "node scripts/build-extension.cjs",
"indexnow": "node website/submit-indexnow.cjs",
@@ -18,14 +21,19 @@
"@playwright/test": "^1.61.1",
"@vitest/coverage-v8": "^4.1.9",
"addons-linter": "^10.9.0",
"archiver": "^7.0.1",
"archiver": "^8.0.0",
"esbuild": "^0.28.1",
"eslint": "^10.4.0",
"eslint-plugin-no-unsanitized": "^4.1.5",
"fs-extra": "^11.2.0",
"htmlnano": "^3.4.0",
"sharp": "^0.34.5",
"svgo": "^4.0.1",
"sharp": "^0.35.3",
"svgo": "^4.0.2",
"vitest": "^4.1.9"
},
"overrides": {
"brace-expansion": "5.0.8",
"fast-uri": "3.1.4",
"postcss": "8.5.18"
}
}
+8 -9
View File
@@ -1,6 +1,5 @@
const fs = require('fs');
const path = require('path');
const archiver = require('archiver');
const rootDir = path.join(__dirname, '..');
const extDir = path.join(rootDir, 'extension');
@@ -202,18 +201,18 @@ function copyExtensionFiles(targetDir, browserName) {
}
// Helper to zip a directory
function zipDirectory(sourceDir, outPath) {
async function zipDirectory(sourceDir, outPath) {
const { ZipArchive } = await import('archiver');
return new Promise((resolve, reject) => {
const archive = archiver('zip', { zlib: { level: 9 } });
const archive = new ZipArchive({ zlib: { level: 9 } });
const stream = fs.createWriteStream(outPath);
archive
.directory(sourceDir, false)
.on('error', err => reject(err))
.pipe(stream);
archive.on('error', reject);
stream.on('close', () => resolve());
archive.finalize();
stream.on('error', reject);
archive.pipe(stream);
archive.directory(sourceDir, false);
void archive.finalize().catch(reject);
});
}
+37 -2
View File
@@ -23,7 +23,10 @@ async function c() {
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);}); }
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; }
// 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).
@@ -72,6 +75,7 @@ try {
assert.ok(Array.isArray(capData.capabilities) && capData.capabilities.includes('host-control'),
'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-v1'), 'ROOM_DATA advertises the versioned chat capability');
assert.equal(capData.chatHistory, undefined, 'ROOM_DATA never contains chat history');
close();
resetConnectionRate();
@@ -79,7 +83,8 @@ try {
// --- Encrypted chat is a live-only canonical relay ---
const chatRoom = 'chat-'+Date.now();
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;
const ciphertext = Buffer.alloc(64, 7).toString('base64url');
s(chat1, 'chat_message', {
@@ -106,6 +111,36 @@ try {
close();
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) ---
// Confirms that with host-only off, a non-host guest can still drive every
// 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
// 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).
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
// 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 tabTitle = typeof payload.tabTitle === 'string' ? payload.tabTitle.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
@@ -370,6 +390,7 @@ io.on('connection', (socket) => {
// Cleanup old room if re-joining
const oldMapping = socketToRoom.get(socket.id);
if (oldMapping && oldMapping.roomId === roomId && oldMapping.peerId === peerId) {
socket.data.clientCapabilities = clientCapabilities;
return; // Already in this room with same peerId, ignore to prevent spam
}
if (oldMapping && oldMapping.roomId !== roomId) {
@@ -493,6 +514,7 @@ io.on('connection', (socket) => {
}
socket.join(roomId);
socket.data.clientCapabilities = clientCapabilities;
room.peers.add(socket.id);
room.peerIds.set(socket.id, peerId);
room.peerData.set(socket.id, {
@@ -862,8 +884,22 @@ io.on('connection', (socket) => {
const envelope = createChatEnvelope(data, mapping.peerId);
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();
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) {
log('ERROR', `CHAT_MESSAGE handler error: ${err.message}`);
}
+44 -14
View File
@@ -70,20 +70,20 @@
}
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"content-type": "^2.0.0",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.2",
"on-finished": "^2.4.1",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
"qs": "^6.15.2",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
},
"engines": {
"node": ">=18"
@@ -93,6 +93,19 @@
"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": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1025,17 +1038,34 @@
}
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"content-type": "^2.0.0",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"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": {
+2 -1
View File
@@ -81,7 +81,8 @@ export const CONTROL_MODES = {
export const CAPABILITIES = {
HOST_CONTROL: 'host-control',
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