diff --git a/.github/workflows/beta-server-image.yml b/.github/workflows/beta-server-image.yml index 25ae743..1b70b9b 100644 --- a/.github/workflows/beta-server-image.yml +++ b/.github/workflows/beta-server-image.yml @@ -6,9 +6,9 @@ name: Beta Server Image # # Tags produced (on ghcr.io//): # - beta moving channel pointer to the newest build -# - e.g. feature-host-control-mode +# - e.g. feature-textchat # - sha- immutable, pin to an exact build -# - only on manual run, e.g. hostcontrol01 +# - only on manual run, e.g. chatbeta01 # Never ':latest' (flavor: latest=false). # # Remove this workflow once the feature is merged & released the normal way. @@ -16,11 +16,11 @@ name: Beta Server Image on: push: branches: - - feature/host-control-mode + - feature/textchat workflow_dispatch: inputs: tag: - description: 'Extra immutable tag for this build (e.g. hostcontrol01). Optional.' + description: 'Extra immutable tag for this build (e.g. chatbeta01). Optional.' required: false default: '' @@ -37,6 +37,27 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '24' + cache: 'npm' + cache-dependency-path: | + package-lock.json + server/package-lock.json + + - name: Install root dependencies + run: npm ci + + - name: Install server dependencies + run: npm ci + working-directory: server + + - name: Run verification suite + run: npm run verify - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14bd8e9..8e4d203 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v7 + with: + fetch-depth: 0 - name: Set up Node.js uses: actions/setup-node@v6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a64c5af..5352bdd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -72,6 +72,8 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v7 + with: + fetch-depth: 0 - name: Set up Node.js uses: actions/setup-node@v6 @@ -109,7 +111,11 @@ jobs: sed -i "s/\"softwareVersion\": \".*\"/\"softwareVersion\": \"$VERSION\"/" website/template.html echo " ✓ website/template.html -> softwareVersion $VERSION" - # 6. README.md — version badge & banner + # 6. website/llms.txt — machine-readable release metadata + sed -i "s/Current website release: .*/Current website release: $VERSION/" website/llms.txt + echo " ✓ website/llms.txt -> $VERSION" + + # 7. README.md — version badge & banner sed -i "s|Release-v[0-9]\+\.[0-9]\+\.[0-9]\+-blue|Release-v$VERSION-blue|g" README.md sed -i "s/New v[0-9]\+\.[0-9]\+\.[0-9]\+ Release/New v$VERSION Release/g" README.md echo " ✓ README.md -> v$VERSION" @@ -120,7 +126,7 @@ jobs: run: | git config --local user.email "action@github.com" git config --local user.name "GitHub Action" - git add extension/manifest.base.json shared/constants.js package.json website/version.json website/template.html README.md + git add extension/manifest.base.json shared/constants.js package.json website/version.json website/template.html website/llms.txt README.md git commit -m "chore(release): update versions to $GITHUB_REF_NAME [skip ci]" || echo "No changes to commit" git push origin HEAD:main env: diff --git a/docs/CHAT.md b/docs/CHAT.md new file mode 100644 index 0000000..4b0f6b4 --- /dev/null +++ b/docs/CHAT.md @@ -0,0 +1,85 @@ +# Ephemeral end-to-end encrypted chat + +KoalaSync chat is an optional, live-only text channel rendered on the selected +streaming tab. It is not a messenger and does not add chat UI to the extension +popup. + +## Security boundary + +- The relay receives ciphertext only and never receives the chat secret. +- The relay stores no messages in RAM or on disk and sends no backlog. +- A late joiner sees only messages sent after joining. +- Room authentication is unchanged. The room password remains separate from the + chat secret and continues to be sent to the relay during `join_room`. +- Message timing and ciphertext length remain visible to the relay. Same-room + replay is accepted by the threat model. + +## Invite format and key lifecycle + +New invitations use a named fragment format: + +```text +#j2:r=&p=&k=[&u=] +``` + +The room creator generates 16 random bytes and encodes them as 22 unpadded +base64url characters. URL fragments are not sent in HTTP requests. The website +parses the fragment and passes structured fields through `bridge.js` to the +extension. `chatKey` must never be included in a relay event. + +Legacy `#join:` links remain supported and join normally without chat. Manual +room/password entry also joins without chat. The extension clears any prior chat +secret when joining without a key, switching rooms, or leaving. + +Deployment order is website first, extension second. An old extension ignores the +additional structured `chatKey` field and continues joining normally. + +## Cryptography + +- Secret: 16 cryptographically random bytes. +- KDF: HKDF-SHA256 with `roomId` as salt and a fixed KoalaSync chat info label. +- Encryption: AES-256-GCM using WebCrypto. +- IV: fresh random 12-byte value for every message, prepended to the ciphertext. +- AAD: `${roomId}|${senderId}`. +- The derived `CryptoKey` is cached once per active room. + +The relay stamps `id`, `senderId`, and `timestamp` on the ciphertext envelope. +Changing `senderId` causes AES-GCM authentication to fail because the receiver uses +the stamped value as AAD. + +## Client policy + +- Maximum plaintext length: 500 Unicode code points. +- Decrypted text is untrusted. Escape HTML before applying the supported limited + Markdown formatting. +- No read receipts and no typing indicators. +- The local message DOM is bounded; this is presentation state, not server history. + +## Overlay behavior + +- Default dock: right. +- Live modes: right, left, and detached. +- Detached mode is draggable and moderately resizable. Size and position are stored + per origin and clamped to the current viewport. +- The overlay uses a Shadow DOM and never changes host-page layout. +- On `fullscreenchange`, its host moves into `document.fullscreenElement` and remains + visible. +- 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. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index ae01122..9d119eb 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -11,6 +11,19 @@ the event names defined in `shared/constants.js`. object payload. - The relay caps incoming Socket.IO message size at 4 KB. +## Invite fragments + +Current invitations use named URL-fragment fields: + +```text +#j2:r=&p=&k=[&u=] +``` + +The fragment is parsed by the website and forwarded to the extension as structured +fields. `k` is client-only and must never appear in a relay payload. Legacy +`#join::[:1:]` fragments remain valid but provide no chat +secret. + ## Connection Handshake The Socket.IO handshake must include: @@ -41,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" } ``` @@ -69,13 +83,56 @@ Payload: "hostPeerId": "string or null", "controlMode": "everyone | host-only", "controllers": ["peerId"], - "capabilities": ["host-control", "co-host"] + "capabilities": ["host-control", "co-host", "chat", "chat-v1"] } ``` `room_data` is sent to the joining socket. It is not the general broadcast used for every later room update. +## Ephemeral encrypted chat + +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` + +Client to relay: + +```json +{ "ciphertext": "" } +``` + +`ciphertext` contains a 12-byte AES-GCM IV followed by ciphertext and the 16-byte +authentication tag. The relay validates only canonical base64url and byte bounds. +It cannot inspect plaintext. + +Relay to every chat-capable current room peer, including the sender: + +```json +{ + "id": "", + "senderId": "", + "timestamp": 1710000000000, + "ciphertext": "" +} +``` + +Client-provided `id`, `senderId`, `timestamp`, or plaintext fields are discarded. +The relay keeps no message collection and `room_data` contains no chat history. +Messages are limited to 10 per socket per 10 seconds in addition to the global event +budget. There are no typing, read-receipt, history, or chat-specific peer-management +events. + ## Room Leave ### `leave_room` (client -> server) @@ -365,5 +422,7 @@ 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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d55cba6..8d346b3 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 diff --git a/docs/chat-e2e-prompt.md b/docs/chat-e2e-prompt.md new file mode 100644 index 0000000..849578d --- /dev/null +++ b/docs/chat-e2e-prompt.md @@ -0,0 +1,346 @@ +# E2E-verschlüsselter In-Page-Chat für KoalaSync + +## Auftrag + +Baue einen privacy-first, Ende-zu-Ende-verschlüsselten Text-Chat, der direkt auf der +Streaming-Seite neben dem Video sitzt. Neuer Branch von `main`, Vorschlag +`feature/chat-e2e`. + +### Harte Anforderungen + +1. **Der Server darf Nachrichten nie lesen können.** Schlüssel nur clientseitig, der + Relay sieht ausschließlich Ciphertext. +2. **Der Server speichert keine Nachrichten.** Reines Live-Relay, kein RAM-Backlog, + keine History. Wer zu spät kommt, verpasst den bisherigen Chat. Das ist gewollt. +3. **Chat läuft auf der Streaming-Seite**, nicht im Extension-Popup. +4. **Themes werden respektiert** (3 Paletten x light/dark). +5. **Keine Read Receipts.** Bewusst gestrichen. +6. **Volle Abwärtskompatibilität**: alte Extensions müssen weiter normal joinen können. + +### Bedrohungsmodell (bestimmt alle Krypto-Entscheidungen) + +Das hier ist eine Video-Sync-Extension mit optionalem, flüchtigem Chat, **keine +sicherheitskritische Messenger-App**. Ziel ist: ein bösartiger Betreiber eines +Custom-Relays soll nicht einfach mitlesen können. Ob etwas mit sehr viel Aufwand +theoretisch knackbar wäre, ist egal. + +Daraus folgt, und das ist bindend: +- **Usability und Performance haben Vorrang** vor kryptografischer Maximalhärte. +- Raum-Passwörter sind kurz, zufällig generiert (z.B. `0XUK3C`) und ständig neu. Wer + eines knackt, kann trollen (`play`/`pause` senden). Nervig, nicht kritisch. Deshalb + nutzt der Server bewusst simples SHA256/HMAC statt bcrypt. **Diese Linie beibehalten, + nicht "verbessern".** +- Der globale `SERVER_SALT` ist bekannt und kein Issue. Räume sind flüchtig, es gibt + keine Datenbank. + +### Explizite Nicht-Ziele + +- Kein Chat-Tab im Popup. Nicht als Fallback, nicht übergangsweise. +- Keine serverseitige `chatHistory` in irgendeiner Form. +- **Die Auth nicht anfassen.** Das Klartext-Passwort an den Relay ist bewusst so und + wird nicht umgebaut (Begründung unten). +- `website/` Theme-Redesign auf `main` nicht anfassen. + +--- + +## Krypto-Design (entschieden, nicht neu diskutieren) + +### Woher der Schlüssel kommt + +Das Raum-Passwort ist **unbrauchbar** als Schlüsselmaterial: der Client sendet es im +Klartext an den Relay (`join_room` -> `payload.password`, `server/index.js:349`), der +Server HMACt es erst selbst (`hashPassword`, Zeile 52-55). Der Server könnte jeden +daraus abgeleiteten Schlüssel mitberechnen. + +Es zu ändern würde auch nichts bringen: Raum-Passwörter haben ~31 Bit Entropie +(6 Zeichen A-Z0-9). Ein bösartiger Server könnte sie offline durchprobieren und den +Chat-Schlüssel ableiten. + +**Lösung: ein eigenes, zufälliges Secret im URL-Fragment.** Fragmente werden nie an +einen Server gesendet, und das Secret hat volle Entropie per Konstruktion. Der +Einladungslink transportiert bereits heute das Passwort im Fragment, der Mechanismus +existiert also schon. + +### Primitive + +- **16 zufällige Bytes** (128 Bit), base64url, 22 Zeichen. Beispiel: + `R5Ti1nxp0crfAFHf3gVncw` +- Ableitung: **HKDF-SHA256**(secret, salt=roomId) -> AES-256-GCM-Key. + **Kein PBKDF2, kein Stretching.** Slow KDFs existieren nur, um schwache + Menschen-Passwörter zu strecken. Das Secret ist zufällig mit voller Entropie, HKDF + ist ein einziger HMAC (Mikrosekunden). Das ist dieselbe Logik wie sha256-statt-bcrypt + beim Server. +- **Key genau einmal pro Raum ableiten und den `CryptoKey` cachen.** Nicht pro + Nachricht neu ableiten oder importieren. +- Pro Nachricht: **zufälliger 12-Byte-IV**, dem Ciphertext vorangestellt. +- Alles über **WebCrypto**, keine Fremdabhängigkeit. + +### AAD: senderId gegen Umetikettierung binden (Pflicht) + +`senderId` wird vom Server gestempelt und liegt damit **außerhalb** des Ciphertexts. +Ohne Gegenmaßnahme kann ein bösartiger Relay Alices Ciphertext als Bob weiterreichen. +Das ist exakt der Angreifer, gegen den dieses Feature gebaut wird. + +Deshalb: **AES-GCM mit AAD** verschlüsseln. +``` +AAD = `${roomId}|${senderId}` // senderId = eigene peerId beim Verschlüsseln, + // envelope.senderId beim Entschlüsseln +``` +Etikettiert der Server um, schlägt die Auth-Tag-Prüfung fehl und die Nachricht wird +verworfen. Kostet null Performance und bindet die Nachricht zusätzlich an den Raum +(kein Cross-Room-Replay). + +Bewusst **nicht** abgedeckt und akzeptiert: Nachrichtenlängen und Timing bleiben für den +Server sichtbar (kein Padding). Ein Relay kann eine Nachricht innerhalb desselben Raums +erneut abspielen. Für das Bedrohungsmodell irrelevant. + +### Sanitization wandert auf den Client (Pflicht) + +Der Server sieht nur noch Ciphertext und **kann Text nicht mehr prüfen**. Die Bedrohung +verschwindet dadurch nicht, sie verschiebt sich: entschlüsselter Text stammt von einem +**Peer, der den Key besitzt**. + +- Entschlüsselter Text ist **untrusted input**. Vor dem Rendern zwingend durch + `escapeChatHtml`/`formatChatText` (escapen, dann Markdown). Niemals rohes `innerHTML`. +- Die Längengrenze (500 Codepoints) muss der **Client** durchsetzen, vor dem + Verschlüsseln. Der Server kann nur noch Bytes zählen. +- **Bestehende Schranke beachten:** `maxHttpBufferSize: 4096` (`server/index.js:142`) + gilt global pro Socket-Nachricht. Nachgerechnet: 500 Codepoints als 4-Byte-Emoji + ergeben 2000 B Klartext, +16 B GCM-Tag +12 B IV = 2028 B, base64 = 2704 Zeichen, + socket.io-Frame = **2731 B**. Passt, Headroom 1365 B. Bei 700 Codepoints wären es + 3799 B. Wer die Zeichengrenze anhebt, muss diese Rechnung neu machen, sonst reißt das + Limit. + +--- + +## Link-Format und Abwärtskompatibilität (entschieden) + +### Warum das alte Format nicht erweiterbar ist + +Aktuell (`popup.js:559-563`): +``` +offiziell: #join:: +custom: #join:::1: +``` + +Der Extension-Parser (`popup.js:1294-1313`) nimmt `roomId` von vorne, das Paar +`(flag, url)` von hinten, und **alles dazwischen ist das Passwort** (`parts.join(':')`). +Es gibt keine Position, die er verwirft. Verifiziert durch Ausführen der echten Parser +gegen echte Links: + +| Link | Alte Extension | Alte Website | +|---|---|---| +| `#join:SILENT-EAGLE-90:30PXPD:1:wss%3A%2F%2F…` (Kontrolle) | `password:"30PXPD"` OK | `serverFlag:"1"` OK | +| Key inline: `…:30PXPD::1:wss%3A%2F%2F…` | `password:"30PXPD:R5Ti1nxp…"` **korrumpiert** | `serverFlag:"R5Ti…"`, `serverUrl:"1"` -> **falscher Server** | + +Zwei verschiedene, irreführende Fehler aus derselben Zeile. Deshalb: neues Präfix. + +### Neues Format + +``` +#j2:r=&p=&k=[&u=] +``` + +- `URLSearchParams`, kein positionales Parsen. Behebt nebenbei einen bestehenden Bug: + heute zerschießt ein Doppelpunkt im Passwort den Website-Parser (`parts[2]`), während + der Extension-Parser (`parts.join(':')`) damit klarkommt. +- **`s=1` entfällt.** Es existierte nur wegen des positionalen Parsens. Mit benannten + Parametern gilt: `u` vorhanden bedeutet Custom Relay. +- Das Präfix darf `#join:` **nicht als Teilstring enthalten**, sonst greift + `includes('#join:')` in alten Extensions doch. + +Beispiele: +``` +offiziell: …/join.html#j2:r=SAPPHIRE-DUCK-49&p=0XUK3C&k=R5Ti1nxp0crfAFHf3gVncw +custom: …/join.html#j2:r=SILENT-EAGLE-90&p=30PXPD&k=R5Ti1nxp0crfAFHf3gVncw + &u=wss%3A%2F%2Fsync.shik3i.net +``` + +### Warum das alte Extensions nicht bricht + +**Die Website ist der Hauptpfad, nicht die Extension.** `website/app.js:452-458` parst +das Fragment selbst, prüft via `document.documentElement.dataset.koalasyncInstalled` +(gesetzt von `bridge.js:9`), ob die Extension da ist, und dispatcht dann automatisch +(`app.js:515-526`, "AUTO-TRIGGER JOIN") ein `KOALASYNC_JOIN_REQUEST` mit fertig +geparsten, **strukturierten Feldern**. `bridge.js` reicht es als `WEB_JOIN_REQUEST` an +den background weiter. In diesem Pfad liest die Extension die URL nie an. + +Altes `bridge.js` destrukturiert nur, was es kennt: +```js +const { roomId, password, useCustomServer, serverUrl } = e.detail; +``` +Ein zusätzliches `chatKey` fällt dort stillschweigend auf den Boden. + +**Daraus folgt die Deploy-Reihenfolge: Website zuerst.** Sie ist die einzige Stelle, die +das neue Format kennen muss, und sie ist sofort deploybar. Die Extension darf beliebig +hinterherhinken (Store-Review, Update-Zyklen der Nutzer). + +| Kombination | Ergebnis | +|---|---| +| Neue Website + neue Ext | Join + Chat | +| Neue Website + alte Ext | Join normal, kein Chat, `chatKey` ignoriert | +| Neue Ext + alter `#join:`-Link | Join, kein Key, Chat aus. Legacy-Parser bleibt erhalten | +| Alte Website + neuer Link | Präfix unbekannt, Join-Seite tot. **Existiert nach dem Website-Deploy nicht mehr** | + +`checkInviteLink()` (`popup.js:1289`) ist nur der Komfort-Pfad "Popup auf der Join-Seite +öffnen und Felder vorausfüllen". Bei `#j2:` greift er in alten Extensions nicht mehr, +das Feld bleibt leer statt falsch befüllt. Die neue Extension muss dort **beide** +Formate parsen (`#j2:` und Legacy `#join:`). + +**`history.replaceState` auf das Legacy-Format: nicht tun.** Naheliegende Idee, um das +Autofill alter Extensions zu retten, aber ein Eigentor: es entfernt `k` aus der +Adressleiste. Nutzer kopieren die URL aus der Adressleiste, um sie weiterzuteilen. Der +weitergegebene Link joint dann zwar, hat aber stillschweigend keinen Chat mehr. Der +Fragment-Inhalt muss unangetastet bleiben. + +### Lebenszyklus des Keys + +- **Erzeugt wird er vom Raum-Ersteller**, einmal, beim Anlegen des Raums. Er lebt im + background neben `roomId`/`password` und geht in den Invite-Link. +- **Er darf niemals in einem Relay-Payload landen.** Nicht in `join_room`, nirgends. + Dafür einen Test schreiben, der alle ausgehenden Events gegen den Key prüft. +- **Krypto gehört in den background**, nicht in den content script. Der Key wird damit + gar nicht erst in den Kontext einer fremden Seite ausgeliefert. Das Overlay schickt + Klartext an den background und bekommt Klartext zurück, verschlüsselt wird + ausschließlich dort. +- **Kanten, die korrekt fallen müssen:** + - Raum-Ersteller hat eine **alte** Extension: es existiert kein Key, niemand chattet. + Korrektes Verhalten, kein Fehlerfall. + - Ein Peer mit **alter** Extension teilt den Invite aus seinem eigenen Popup: der Link + trägt keinen Key. Wer darüber joint, chattet nicht, während die anderen chatten. + - Jemand tippt Raum und Passwort **manuell**: kein Key, kein Chat. + - Relay **ohne** Chat-Capability: Chat-UI gar nicht erst anzeigen. + +--- + +## Vorgeschichte + +Vorgängerbranch `feature/soonTMChat` (Stand `c706513`) ist **verworfen**, bleibt als +Referenz liegen. Gründe: + +- Chat lag nur im Popup. Ein Chrome-Popup schließt beim Fokusverlust, man kann nicht + gleichzeitig Video schauen und chatten. Strukturell unbrauchbar. +- Der Server speicherte bis zu 500 Klartext-Nachrichten pro Raum und schickte jedem + Joiner das Backlog. +- Verifizierter Folgebug: wer einen Raum mit >120 Nachrichten Historie betrat, wurde + **rausgeworfen**. `renderChatHistory()` feuerte eine ungebündelte `chat_read`-Quittung + pro Nachricht und riss `CHAT_READ_RATE_LIMIT` (120/10s). Reproduziert bei + `CHAT_HISTORY_LIMIT=200`, dokumentiert erlaubt sind 500. + +**Lehre: kein ungebündeltes Event-pro-Nachricht-Muster. Jedes Client-Verhalten gegen die +Rate-Limits gegenrechnen, bevor es eingebaut wird.** + +### Wiederverwendbar (per `git checkout feature/soonTMChat -- `, kritisch prüfen) + +| Datei | Was | Einschränkung | +|---|---|---| +| `extension/chat.js` | `escapeChatHtml`, `formatChatText`, `insertEmoji`, `createTypingTracker`, `createRemoteTypingTracker` | reine Funktionen, storage-frei, getestet. `createReceiptTracker` weglassen | +| `extension/chat.test.mjs` | Unit-Tests | Receipt-Tests weg | +| `extension/locales/*.json` | `CHAT_*`-Keys, 15 Sprachen | Receipt-Keys weg, neue Keys für Overlay nötig | +| `server/chat.js` | `sanitizeChatUsername`, `canKickPeer` | `sanitizeChatText`/`createChatMessage` greifen auf Klartext zu, bei E2E unmöglich. `parseChatHistoryLimit`, `appendChatHistory`, `canRelayReadReceipt` entfallen | +| `server/rate-limiter.js` | `checkChatMessageRate` (10/10s) | `checkChatReadRate` entfällt | +| `shared/constants.js` | `CAPABILITIES.CHAT`, Event-Namen | **muss neu hinzu.** main kennt nur `HOST_CONTROL` und `CO_HOST` (`shared/constants.js:78`, `server/index.js:174`) | + +**Server-Design, das überleben soll:** Der Server vergab Message-ID, `senderId` und +`timestamp` selbst und ignorierte Client-Angaben (Spoofing-Schutz). Bleibt richtig, auch +wenn der Text jetzt Ciphertext ist. + +--- + +## Technische Randbedingungen + +### Overlay-Kontext + +- `content.js` wird **programmatisch** injiziert: + `chrome.scripting.executeScript({ target: {tabId}, files: ['content.js'] })` + (`background.js:1775`). Nicht über `manifest.content_scripts`, dort steht nur + `bridge.js` für `https://sync.koalastuff.net/*`. +- `host_permissions: [""]`, MV3. +- Das Overlay lebt im DOM fremder Seiten (Netflix, YouTube, Emby, Jellyfin). + **Shadow DOM ist Pflicht**, sonst bluten fremde Styles rein und umgekehrt. +- Die Theme-Variablen aus `popup.html` existieren im Page-Kontext **nicht** und müssen + in den Shadow Root injiziert werden. +- **Risiko, vorab prüfen:** Netflix-Vollbild ist ein eigener Fullscreen-Element-Kontext. + Ein Overlay im normalen DOM verschwindet dort möglicherweise. Das kann die + Positionierung grundlegend beeinflussen. + +### Performance auf der Host-Seite + +Das Video ist das Produkt, der Chat ist Beiwerk. Ein Overlay, das die Wiedergabe ruckeln +lässt, ist schlechter als kein Overlay. + +- Nur im **Ziel-Tab** injizieren (`currentTabId` im background), nicht auf jeder Seite. +- `position: fixed`, eigener Compositing-Layer, **kein Eingriff in das Layout der + Host-Seite**. Kein Layout-Thrashing, keine erzwungenen Reflows während der Wiedergabe. +- Nachrichtenliste beim Rendern nicht unbegrenzt wachsen lassen (DOM-Knoten deckeln). + +### Firefox + +`scripts/build-extension.cjs:199` baut ein eigenes **Firefox-Target**, und `bridge.js` +enthält bereits Firefox-spezifisches `cloneInto`-Handling für CustomEvent-Details +(isolierte Welten in FF MV3). Overlay, Shadow DOM und WebCrypto müssen dort ebenfalls +laufen. Neue Website-zu-Extension-Felder (`chatKey`) gehen durch dieselbe +`cloneInto`-Stelle. + +### Theme-System (aus `main`, verbindlich) + +- `extension/theme-init.js` setzt auf ``: `data-theme` (`light`/`dark`), + `data-palette` (`eucalyptus`/`cyber`/`graphite`), Klasse `theme-light`. +- Quelle: `chrome.storage.local`, Keys `themeMode` und `themePalette`, plus + `chrome.storage.onChanged` für Live-Updates. Das Overlay nutzt denselben Mechanismus + im Shadow Root. +- **Kein `prefers-color-scheme`** für Theme-Farben. Das System ist explizit, nur der + Modus `system` liest die OS-Präferenz und das erledigt `theme-init.js`. +- Tokens: `--bg`, `--card`, `--surface-alt`, `--surface-deep`, `--accent`, `--text`, + `--text-muted`, `--border-soft`, `--border-strong`, `--text-on-green`. + Text auf `--accent` immer `--text-on-green`, nie `white`. +- Getönte `oklch`-Schatten sind pro Palette hartcodiert und brauchen + `html[data-palette=...]`-Overrides. Neutrale Schwarz-Alpha-Schatten sind + palettenunabhängig und der einfachere Weg. +- Bekannte Alt-Last, nicht Aufgabe dieses Branches: `--text-on-green` auf `--accent` + erreicht in eucalyptus/light 3.76 und cyber/dark 4.24, unter WCAG AA. + +### Qualitäts-Gates + +- `npm run lint`, `npx vitest run`, `npm run verify` müssen grün sein. +- i18n: alle 15 Sprachen in `extension/locales/`, `en` ist Baseline-Fallback. Keine + hartcodierten UI-Strings, `getMessage(key, { placeholder })` aus `i18n.js`. +- Bei einem Release-Tag: Eintrag in `docs/CHANGELOG.md`. +- Keine Em-Dashes in nutzersichtbaren Texten. + +--- + +## Offene Punkte (mit dem Nutzer klären, nicht selbst entscheiden) + +1. **Overlay-Positionierung**: fest andockbar rechts/links, oder frei verschiebbar? + Position pro Seite in `chrome.storage.local` merken? Verhalten im Vollbild? +2. **Peers ohne Key**: Chat-UI ganz ausblenden mit Hinweis, oder anzeigen und + Nachrichten als "nicht lesbar" markieren? (Die Fälle, in denen das eintritt, stehen + oben unter Lebenszyklus.) +3. **Metadaten**: Username bleibt heute für den Server sichtbar (er sanitized ihn). + Mitverschlüsseln? Typing-Indikatoren verraten dem Server, wer wann aktiv ist. + Behalten, verschlüsseln oder streichen? +4. **Kick-Funktion**: `canKickPeer` existiert (Host/Controller-Rechte). Behalten? +5. **Nachrichtenlängen-Limit**: 500 Codepoints beibehalten, oder angesichts des + 4096-B-Frames anders wählen? + +--- + +## Vorgehen + +1. Offene Punkte klären. +2. Krypto- und Linkformat-Design in `docs/CHAT.md` festhalten. Das bestehende Dokument + beschreibt das verworfene Konzept und wird neu geschrieben. Chat-Events in + `docs/PROTOCOL.md` ergänzen. +3. Website-Parser (`#j2:`, Legacy `#join:` weiter unterstützen) und Bridge-Feld + `chatKey` zuerst, weil davon die Abwärtskompatibilität hängt. +4. Dann Relay (reines Relay ohne Storage), dann Krypto im background, dann Overlay, dann + i18n, dann Tests. +5. **Verifizieren, nicht annehmen:** + - Overlay auf mindestens zwei echten Seiten in allen 6 Theme-Kombinationen, nicht nur + in einer isolierten HTML-Datei. + - Chrome **und** Firefox-Build. + - Ein Test, der beweist, dass der Key in keinem ausgehenden Relay-Payload vorkommt. + - Ein Test, der eine umetikettierte Nachricht (fremde `senderId`) als ungültig + zurückweist (AAD-Bindung). + - Alte Extension gegen neue Website: Join funktioniert weiter, Chat ist stumm. diff --git a/extension/background.js b/extension/background.js index 150a872..3403995 100644 --- a/extension/background.js +++ b/extension/background.js @@ -4,6 +4,9 @@ import { loadLocale, getMessage, getSystemLanguage } from './i18n.js'; import { sameEpisode, extractEpisodeId } from './episode-utils.js'; import { applyTitlePrivacyToPayload, sanitizeSharedTitle, sanitizeTabTitle, normalizeSendTabTitle, normalizeTitlePrivacyMode } from './title-privacy.js'; import { initTabManager } from './modules/tab-manager.js'; +import { clearChatKeyCache, decryptChatMessage, encryptChatMessage, generateChatSecret, validateChatSecret } from './chat-crypto.js'; +import { buildChatRelayPayload, encodeSocketEvent } from './chat-wire.js'; +import { createChatSendLimiter, createLatestTaskQueue, normalizeRoomId } from './chat-session.js'; import { HOST_ACCESS_REQUIRED_STATUS, normalizeTabId, inspectTabHostAccess, isHostAccessError, addTabHostAccessRequest, removeTabHostAccessRequest } from './host-access.js'; import './page-api-seek-overrides.js'; @@ -82,7 +85,31 @@ let hostPeerId = null; // peerId of the room host (creator / // Features the connected relay advertises in ROOM_DATA. Empty against an older // relay (no capabilities field) → host-control UI/behavior stays unavailable. let serverCapabilities = []; +let chatSecretGuard = ''; +let chatSessionGeneration = 0; +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++; + chatReceiveQueue = Promise.resolve(); + chatSendLimiter.reset(); + clearChatKeyCache(); +} + +async function clearFailedJoinCredentials() { + webJoinCoordinator.invalidate(); + connectIntent = false; + chatSecretGuard = ''; + invalidateChatSession(); + await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {}); +} // Local peer's desync state (content.js reports it via HCM_DESYNC_STATE). Relayed // in heartbeats so the host's popup UI can show "Solo" instead of silently // appearing un-ACK'd. @@ -371,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', '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(); @@ -379,11 +406,17 @@ async function getSettings() { } const legacyTitlePrivacyMode = normalizeTitlePrivacyMode(data.titlePrivacyMode); const mediaTitlePrivacyMode = normalizeTitlePrivacyMode(data.mediaTitlePrivacyMode || legacyTitlePrivacyMode); + const chatKey = validateChatSecret(data.chatKey); + chatSecretGuard = chatKey; + const roomId = normalizeRoomId(data.roomId); + if (data.roomId && data.roomId !== roomId) chrome.storage.local.set({ roomId }).catch(() => {}); return { serverUrl: data.serverUrl || '', useCustomServer: data.useCustomServer || false, - roomId: data.roomId || '', + roomId, password: data.password || '', + chatKey, + chatEnabled: data.chatEnabled === true, username, sendTabTitle: normalizeSendTabTitle(data.sendTabTitle, legacyTitlePrivacyMode), mediaTitlePrivacyMode @@ -419,7 +452,7 @@ function emitEpisodeLobbyForCurrentPrivacy() { // removes legacy keys that older versions wrote to sync (and that would // otherwise be redistributed across devices and resurrected on reinstall). const LEGACY_SYNC_KEYS = [ - 'serverUrl', 'useCustomServer', 'roomId', 'password', 'username', + 'serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'username', 'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'titlePrivacyMode', 'sendTabTitle', 'mediaTitlePrivacyMode' @@ -475,6 +508,7 @@ function forceDisconnect() { currentServerUrl = null; isConnecting = false; isNamespaceJoined = false; + invalidateChatSession(); isForceSyncInitiator = false; expectedAcksCount = 0; roomIdleSince = null; @@ -545,6 +579,7 @@ function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) completeForceSyncBeforeTargetChange(null); invalidateTargetActivations(); clearPendingTarget().catch(() => {}); + if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {}); currentTabId = null; currentTabTitle = null; lastContentHeartbeatAt = null; @@ -579,6 +614,7 @@ async function leaveRoomAfterIdleGrace(reason) { // Notify content.js/popup BEFORE currentTabId is cleared so they can reset // any stale guest-side HCM state (dialog/badge/desync) — H-2. broadcastControlMode(); + if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {}); invalidateTargetActivations(); currentTabId = null; currentTabTitle = null; @@ -595,7 +631,9 @@ async function leaveRoomAfterIdleGrace(reason) { episodeLobby: null, hcmDesynced: false }).catch(() => {}); - await chrome.storage.local.set({ roomId: '', password: '' }).catch(() => {}); + chatSecretGuard = ''; + invalidateChatSession(); + await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {}); addLog(reason, 'info'); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {}); updateBadgeStatus(); @@ -715,6 +753,7 @@ async function connect() { peerId, username: settings.username, tabTitle: sharedTitles.tabTitle, + clientCapabilities: CLIENT_CAPABILITIES, protocolVersion: PROTOCOL_VERSION }); } @@ -723,7 +762,7 @@ async function connect() { try { const payload = JSON.parse(msg.substring(2)); try { - handleServerEvent(payload[0], payload[1]); + await handleServerEvent(payload[0], payload[1]); } catch (handlerErr) { addLog(`Handler error for ${payload[0]}: ${handlerErr.message}`, 'error'); } @@ -736,6 +775,7 @@ async function connect() { socket.onclose = () => { isConnecting = false; isNamespaceJoined = false; + invalidateChatSession(); stopPing(); if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } @@ -817,6 +857,7 @@ function broadcastConnectionStatus(status) { status = 'idle'; } chrome.runtime.sendMessage({ type: 'CONNECTION_STATUS', status }).catch(() => {}); + if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CONNECTION_STATUS', status }).catch(() => {}); updateBadgeStatus(); } @@ -917,10 +958,14 @@ function scheduleReconnect() { function emit(event, data) { if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) { - const msg = `42${JSON.stringify([event, data])}`; try { + const msg = encodeSocketEvent(event, data, chatSecretGuard); socket.send(msg); } catch (e) { + if (e.message === 'Refusing to send chat secret to relay') { + addLog(e.message, 'error'); + return; + } // The socket can close between the readyState check and send() // (race with a server-side disconnect). Re-queue so the event is // retried on the next successful (re)connect instead of being lost. @@ -932,6 +977,17 @@ function emit(event, data) { } } +function emitLive(event, data) { + if (!socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) return false; + try { + socket.send(encodeSocketEvent(event, data, chatSecretGuard)); + return true; + } catch (e) { + addLog(e.message === 'Refusing to send chat secret to relay' ? e.message : `Live send failed for ${event}: ${e.message}`, 'error'); + return false; + } +} + function queueEvent(event, data) { eventQueue.push({ event, data }); if (eventQueue.length > 50) { @@ -1037,7 +1093,7 @@ function stopPing() { } // --- Event Handlers --- -function handleServerEvent(event, data) { +async function handleServerEvent(event, data) { if (!data) { addLog(`Ignored server event ${event} due to empty payload`, 'warn'); return; @@ -1057,12 +1113,14 @@ function handleServerEvent(event, data) { } switch (event) { case EVENTS.ROOM_DATA: + if (currentRoom?.roomId !== data.roomId) invalidateChatSession(); currentRoom = data; // Host Control Mode: adopt room role/mode on (re)join. controlMode = data.controlMode || CONTROL_MODES.EVERYONE; hostPeerId = data.hostPeerId || null; controllers = Array.isArray(data.controllers) ? data.controllers : []; serverCapabilities = Array.isArray(data.capabilities) ? data.capabilities : []; + if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); hcmEnforceDesyncInvariant(); broadcastControlMode(); markRoomPotentiallyIdle(); @@ -1140,12 +1198,56 @@ function handleServerEvent(event, data) { case EVENTS.ROOM_LIST: chrome.runtime.sendMessage({ type: 'ROOM_LIST', rooms: data.rooms }).catch(() => {}); break; + case EVENTS.CHAT_MESSAGE: { + if (!currentRoom || !serverSupportsChat() || !currentTabId) break; + const generation = chatSessionGeneration; + const roomId = currentRoom.roomId; + const tabId = Number(currentTabId); + const received = { ...data }; + const isCurrentSession = () => generation === chatSessionGeneration && + currentRoom?.roomId === roomId && Number(currentTabId) === tabId; + chatReceiveQueue = chatReceiveQueue.catch(() => {}).then(async () => { + if (!isCurrentSession()) return; + const settings = await getSettings(); + if (!settings.chatEnabled || !settings.chatKey || settings.roomId !== roomId || !isCurrentSession()) return; + const chatKey = settings.chatKey; + try { + const text = await decryptChatMessage({ + ciphertext: received.ciphertext, + roomId, + senderId: received.senderId, + secret: chatKey + }); + if (!isCurrentSession() || chatSecretGuard !== chatKey) return; + const senderPeer = currentRoom.peers?.find(candidate => + (typeof candidate === 'object' ? candidate.peerId : candidate) === received.senderId + ); + if (Number.isInteger(tabId)) { + chrome.tabs.sendMessage(tabId, { + type: 'CHAT_MESSAGE', + message: { + id: received.id, + senderId: received.senderId, + username: typeof senderPeer === 'object' ? senderPeer.username : null, + timestamp: received.timestamp, + text + } + }).catch(() => {}); + } + } catch (_) { + if (isCurrentSession()) addLog('Discarded chat message that failed authentication', 'warn'); + } + }); + await chatReceiveQueue; + break; + } case EVENTS.ERROR: isConnecting = false; // If we get a server error before successfully joining a room, - // clear connectIntent to prevent an infinite reconnect loop. - if (!currentRoom) { - connectIntent = false; + // clear persisted credentials as well, otherwise service-worker + // restart would immediately retry the rejected room. + if (!currentRoom && connectIntent) { + await clearFailedJoinCredentials(); if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } reconnectAttempts = 0; reconnectFailed = false; @@ -1860,7 +1962,7 @@ async function injectContentScript(tabId, { requestHostAccess = true } = {}) { }); return await chrome.scripting.executeScript({ target: { tabId }, - files: ['content.js'] + files: ['chat-format.js', 'chat-overlay.js', 'content.js'] }); } catch (error) { // A temporary activeTab grant is intentionally allowed to win: even if @@ -2071,6 +2173,7 @@ async function activateTargetTab(tabId, tabTitle, { if (currentRoom) roomIdleSince = Date.now(); if (previousTabId) { resetAudioProcessingInTab(previousTabId); + chrome.tabs.sendMessage(previousTabId, { type: 'CHAT_DESTROY' }).catch(() => {}); } await chrome.storage.session.set({ currentTabId: null, @@ -2133,6 +2236,7 @@ async function activateTargetTab(tabId, tabTitle, { if (currentRoom) roomIdleSince = Date.now(); if (previousTabId && previousTabId !== selectedTabId) { resetAudioProcessingInTab(previousTabId); + chrome.tabs.sendMessage(previousTabId, { type: 'CHAT_DESTROY' }).catch(() => {}); } await chrome.storage.session.set({ currentTabId, @@ -2391,6 +2495,7 @@ chrome.alarms.onAlarm.addListener(async (alarm) => { function leaveOldRoomIfSwitching(newRoomId) { if (currentRoom && currentRoom.roomId !== newRoomId) { + if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_RESET' }).catch(() => {}); addLog(`Switching rooms: leaving ${currentRoom.roomId} to join ${newRoomId}`, 'info'); forceDisconnect(); currentRoom = null; @@ -2398,6 +2503,7 @@ function leaveOldRoomIfSwitching(newRoomId) { hostPeerId = null; controllers = []; serverCapabilities = []; + invalidateChatSession(); hcmDesynced = false; // Notify content.js/popup so they drop any guest-side HCM state from the // previous room (badge/dialog/desync) — H-2/H-3. @@ -2439,21 +2545,33 @@ async function applyAudioSettingsToTab(tabId) { // --- Extension Message Listeners --- chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { - handleAsyncMessage(message, sender, sendResponse); + handleAsyncMessage(message, sender, sendResponse).catch(error => { + addLog(`Message handler failed for ${message?.type || 'unknown'}: ${error.message}`, 'error'); + try { sendResponse({ status: 'error' }); } catch (_) { /* channel already closed */ } + }); return true; // Keep channel open for async responses }); +chrome.storage.onChanged.addListener((changes, area) => { + 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(() => {}); +}); + async function handleAsyncMessage(message, sender, sendResponse) { if (!message) return; await ensureState(); if (message.type === 'CONNECT') { + webJoinCoordinator.invalidate(); const settings = await getSettings(); connectIntent = !!settings.roomId; const desiredUrl = resolveServerUrl(settings); if (settings.roomId && currentRoom && currentRoom.roomId === settings.roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) { broadcastConnectionStatus('connected'); + if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve)); for (const tab of tabs) { chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {}); @@ -2481,6 +2599,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { peerId, username: settings.username, tabTitle: sharedTitles.tabTitle, + clientCapabilities: CLIENT_CAPABILITIES, protocolVersion: PROTOCOL_VERSION }); } @@ -2499,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')); @@ -2528,8 +2648,106 @@ async function handleAsyncMessage(message, sender, sendResponse) { amHost: amHost(), amController: amController(), hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL), - coHostSupported: serverSupports(CAPABILITIES.CO_HOST) + coHostSupported: serverSupports(CAPABILITIES.CO_HOST), + chatSupported: serverSupportsChat(), + hasChatKey: !!settings.chatKey, + chatEnabled: settings.chatEnabled }); + } else if (message.type === 'GET_CHAT_CONTEXT') { + const senderTabId = sender.tab?.id; + if (!currentRoom || !currentTabId || senderTabId !== Number(currentTabId)) { + sendResponse({ supported: false, hasKey: false }); + return; + } + const settings = await getSettings(); + const localeData = await chrome.storage.local.get(['locale']); + await loadLocale(localeData.locale || getSystemLanguage()); + const translated = key => { + const value = getMessage(key); + return value === key ? '' : value; + }; + sendResponse({ + supported: serverSupportsChat(), + enabled: settings.chatEnabled, + hasKey: !!settings.chatKey, + connected: !!(socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined), + peerId, + roomId: currentRoom.roomId, + strings: { + title: translated('CHAT_TITLE'), + liveOnly: translated('CHAT_LIVE_ONLY'), + open: translated('CHAT_OPEN'), + close: translated('CHAT_CLOSE'), + dockLeft: translated('CHAT_DOCK_LEFT'), + dockRight: translated('CHAT_DOCK_RIGHT'), + detached: translated('CHAT_DETACHED'), + placeholder: translated('CHAT_PLACEHOLDER'), + send: translated('CHAT_SEND'), + missingKey: translated('CHAT_MISSING_KEY'), + tooLong: translated('CHAT_TOO_LONG'), + sendFailed: translated('CHAT_SEND_FAILED'), + empty: translated('CHAT_EMPTY'), + you: translated('LABEL_YOU') + } + }); + } else if (message.type === 'CHAT_SEND') { + const senderTabId = sender.tab?.id; + if (!currentRoom || !currentTabId || senderTabId !== Number(currentTabId)) { + sendResponse({ status: 'invalid_tab' }); + return; + } + if (!serverSupportsChat()) { + sendResponse({ status: 'unsupported' }); + return; + } + if (!socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) { + sendResponse({ status: 'disconnected' }); + return; + } + const generation = chatSessionGeneration; + const roomId = currentRoom.roomId; + const tabId = Number(currentTabId); + const socketSnapshot = socket; + const isCurrentSession = () => generation === chatSessionGeneration && + 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; + } + const chatKey = settings.chatKey; + const rateLimit = chatSendLimiter.take(); + if (!rateLimit.allowed) { + sendResponse({ status: 'rate_limited', retryAfterMs: rateLimit.retryAfterMs }); + return; + } + try { + const ciphertext = await encryptChatMessage({ + text: message.text, + roomId, + senderId: peerId, + secret: chatKey + }); + if (!isCurrentSession() || chatSecretGuard !== chatKey) { + sendResponse({ status: 'session_changed' }); + return; + } + const sent = emitLive(EVENTS.CHAT_MESSAGE, buildChatRelayPayload(ciphertext)); + sendResponse({ status: sent ? 'ok' : 'disconnected' }); + } catch (err) { + sendResponse({ status: err instanceof RangeError ? 'too_long' : 'invalid_message' }); + } + } else if (message.type === 'CREATE_CHAT_KEY') { + const chatKey = generateChatSecret(); + chatSecretGuard = chatKey; + invalidateChatSession(); + await chrome.storage.local.set({ chatKey }); + sendResponse({ status: 'ok', chatKey }); } else if (message.type === 'SET_CONTROL_MODE') { // Popup (host) toggles the room control mode. Server validates host authority // and broadcasts CONTROL_MODE back, which updates our local state + UI. @@ -2603,6 +2821,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { if (storageInitialized) chrome.storage.session.set({ hcmDesynced }); sendResponse({ status: 'ok' }); } else if (message.type === 'LEAVE_ROOM') { + webJoinCoordinator.invalidate(); completeForceSyncBeforeTargetChange(null); connectIntent = false; reconnectFailed = false; @@ -2619,6 +2838,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { // Notify content.js/popup BEFORE currentTabId is cleared so they drop any // stale guest-side HCM state (dialog/badge/desync) — H-2/H-3. broadcastControlMode(); + if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {}); invalidateTargetActivations(); currentTabId = null; currentTabTitle = null; @@ -2649,7 +2869,9 @@ async function handleAsyncMessage(message, sender, sendResponse) { expectedAcksCount: 0, hcmDesynced: false }); - chrome.storage.local.set({ roomId: '', password: '' }).catch(() => {}); + chatSecretGuard = ''; + invalidateChatSession(); + chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {}); addLog('Left Room', 'info'); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {}); forceDisconnect(); @@ -2665,8 +2887,9 @@ async function handleAsyncMessage(message, sender, sendResponse) { emit(EVENTS.GET_ROOMS, {}); sendResponse({ status: 'ok' }); } else if (message.type === 'WEB_JOIN_REQUEST') { - const { roomId: rawRoomId, password, useCustomServer, serverUrl } = message; - const roomId = typeof rawRoomId === 'string' ? rawRoomId.replace(/[^a-zA-Z0-9\-]/g, '') : ''; + const { roomId: rawRoomId, password, chatKey: rawChatKey, useCustomServer, serverUrl } = message; + const roomId = normalizeRoomId(rawRoomId); + const chatKey = validateChatSecret(rawChatKey); if (!roomId) { const errMsg = { type: 'JOIN_STATUS', success: false, message: 'Invalid room ID' }; chrome.runtime.sendMessage(errMsg).catch(() => {}); @@ -2676,49 +2899,78 @@ async function handleAsyncMessage(message, sender, sendResponse) { sendResponse({ status: 'invalid_room_id' }); return; } - connectIntent = true; - chrome.storage.local.set({ - roomId, - password, - useCustomServer: !!useCustomServer, - serverUrl: serverUrl || '' - }, async () => { - const settings = await getSettings(); - const desiredUrl = resolveServerUrl(settings); - - if (roomId && currentRoom && currentRoom.roomId === roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) { - broadcastConnectionStatus('connected'); - const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve)); - for (const tab of tabs) { - chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {}); - } - sendResponse({ status: 'already_joined' }); - return; + await webJoinCoordinator.run(async isCurrentJoin => { + if (!isCurrentJoin()) { + sendResponse({ status: 'superseded' }); + return { status: 'superseded' }; } - - reconnectFailed = false; - reconnectStartTime = null; - reconnectAttempts = 0; - chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null }); - broadcastConnectionStatus('connecting'); - leaveOldRoomIfSwitching(roomId); - - if (desiredUrl !== currentServerUrl || !socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) { - if (desiredUrl !== currentServerUrl) forceDisconnect(); - connect(); - } else if (roomId) { - const sharedTitles = getSharedTitleFields(settings); - emit(EVENTS.JOIN_ROOM, { - roomId, - password, - peerId, - username: settings.username, - tabTitle: sharedTitles.tabTitle, - protocolVersion: PROTOCOL_VERSION + try { + connectIntent = true; + await chrome.storage.local.set({ + roomId, + password: typeof password === 'string' ? password : '', + chatKey, + useCustomServer: !!useCustomServer, + serverUrl: typeof serverUrl === 'string' ? serverUrl : '' }); + if (!isCurrentJoin()) { + sendResponse({ status: 'superseded' }); + return { status: 'superseded' }; + } + chatSecretGuard = chatKey; + invalidateChatSession(); + const settings = await getSettings(); + if (!isCurrentJoin()) { + sendResponse({ status: 'superseded' }); + return { status: 'superseded' }; + } + const desiredUrl = resolveServerUrl(settings); + + if (roomId && currentRoom && currentRoom.roomId === roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) { + broadcastConnectionStatus('connected'); + if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {}); + const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve)); + if (!isCurrentJoin()) { + sendResponse({ status: 'superseded' }); + return { status: 'superseded' }; + } + for (const tab of tabs) { + chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {}); + } + sendResponse({ status: 'already_joined' }); + return { status: 'already_joined' }; + } + + reconnectFailed = false; + reconnectStartTime = null; + reconnectAttempts = 0; + chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null }); + broadcastConnectionStatus('connecting'); + leaveOldRoomIfSwitching(roomId); + + if (desiredUrl !== currentServerUrl || !socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) { + if (desiredUrl !== currentServerUrl) forceDisconnect(); + connect(); + } else if (roomId) { + const sharedTitles = getSharedTitleFields(settings); + emit(EVENTS.JOIN_ROOM, { + roomId, + password, + peerId, + username: settings.username, + tabTitle: sharedTitles.tabTitle, + clientCapabilities: CLIENT_CAPABILITIES, + protocolVersion: PROTOCOL_VERSION + }); + } + addLog(`Joining room via link: ${roomId}`, 'info'); + sendResponse({ status: 'ok' }); + return { status: 'ok' }; + } catch (_) { + if (isCurrentJoin()) await clearFailedJoinCredentials(); + sendResponse({ status: 'storage_error' }); + return { status: 'storage_error' }; } - addLog(`Joining room via link: ${roomId}`, 'info'); - sendResponse({ status: 'ok' }); }); } else if (message.type === 'REGENERATE_ID') { // Match getPeerId()'s 16-hex-char generation — see comment there. @@ -3036,7 +3288,10 @@ async function handleAsyncMessage(message, sender, sendResponse) { currentTabTitle = null; lastContentHeartbeatAt = null; if (currentRoom) roomIdleSince = Date.now(); - if (previousTabId) resetAudioProcessingInTab(previousTabId); + if (previousTabId) { + resetAudioProcessingInTab(previousTabId); + chrome.tabs.sendMessage(Number(previousTabId), { type: 'CHAT_DESTROY' }).catch(() => {}); + } await clearPendingTarget(); await chrome.storage.session.set({ currentTabId: null, diff --git a/extension/bridge.js b/extension/bridge.js index d4584cb..38a26f6 100644 --- a/extension/bridge.js +++ b/extension/bridge.js @@ -11,11 +11,12 @@ document.documentElement.dataset.koalasyncInstalled = 'true'; // 2. Listen for Join Requests from the Website window.addEventListener('KOALASYNC_JOIN_REQUEST', (e) => { if (!e || !e.detail) return; - const { roomId, password, useCustomServer, serverUrl } = e.detail; + const { roomId, password, chatKey, useCustomServer, serverUrl } = e.detail; chrome.runtime.sendMessage({ type: 'WEB_JOIN_REQUEST', roomId, password, + chatKey, useCustomServer, serverUrl }).catch(() => {}); diff --git a/extension/chat-crypto.js b/extension/chat-crypto.js new file mode 100644 index 0000000..9129884 --- /dev/null +++ b/extension/chat-crypto.js @@ -0,0 +1,122 @@ +const CHAT_INFO = 'KoalaSync E2E Chat v1'; +const SECRET_BYTES = 16; +const IV_BYTES = 12; +const MIN_ENCRYPTED_BYTES = 29; +const MAX_ENCRYPTED_BYTES = 2028; + +let cachedKeyPromise = null; +let cachedRoomId = ''; +let cachedSecret = ''; +let cacheGeneration = 0; + +function bytesToBase64Url(bytes) { + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return globalThis.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +} + +function base64UrlToBytes(value) { + if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value) || value.length % 4 === 1) return null; + const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4); + try { + const binary = globalThis.atob(padded); + const bytes = Uint8Array.from(binary, char => char.charCodeAt(0)); + return bytesToBase64Url(bytes) === value ? bytes : null; + } catch (_) { + return null; + } +} + +export function generateChatSecret(cryptoImpl = globalThis.crypto) { + const bytes = new Uint8Array(SECRET_BYTES); + cryptoImpl.getRandomValues(bytes); + return bytesToBase64Url(bytes); +} + +export function validateChatSecret(value) { + const bytes = base64UrlToBytes(value); + return bytes?.length === SECRET_BYTES ? value : ''; +} + +export function countCodePoints(value) { + return [...String(value ?? '')].length; +} + +export function normalizeOutgoingChatText(value) { + if (typeof value !== 'string') throw new TypeError('Chat message must be text'); + const text = value.trim(); + if (!text) throw new TypeError('Chat message must not be empty'); + if (countCodePoints(text) > 500) throw new RangeError('Chat message exceeds 500 Unicode code points'); + return text; +} + +export function clearChatKeyCache() { + cacheGeneration++; + cachedKeyPromise = null; + cachedRoomId = ''; + cachedSecret = ''; +} + +export async function deriveChatKey(roomId, secret, cryptoImpl = globalThis.crypto) { + const validSecret = validateChatSecret(secret); + if (!roomId || !validSecret) throw new TypeError('Valid roomId and chat secret are required'); + if (cachedKeyPromise && cachedRoomId === roomId && cachedSecret === validSecret) return cachedKeyPromise; + + const encoder = new globalThis.TextEncoder(); + const generation = cacheGeneration; + const keyPromise = (async () => { + const material = await cryptoImpl.subtle.importKey( + 'raw', base64UrlToBytes(validSecret), { name: 'HKDF' }, false, ['deriveKey'] + ); + return cryptoImpl.subtle.deriveKey({ + name: 'HKDF', + hash: 'SHA-256', + salt: encoder.encode(roomId), + info: encoder.encode(CHAT_INFO) + }, material, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); + })(); + cachedKeyPromise = keyPromise; + cachedRoomId = roomId; + cachedSecret = validSecret; + try { + return await keyPromise; + } catch (error) { + if (generation === cacheGeneration && cachedKeyPromise === keyPromise) clearChatKeyCache(); + throw error; + } +} + +export async function encryptChatMessage({ text, roomId, senderId, secret }, cryptoImpl = globalThis.crypto) { + const plaintext = normalizeOutgoingChatText(text); + if (!senderId) throw new TypeError('senderId is required'); + const key = await deriveChatKey(roomId, secret, cryptoImpl); + const encoder = new globalThis.TextEncoder(); + const iv = new Uint8Array(IV_BYTES); + cryptoImpl.getRandomValues(iv); + const encrypted = new Uint8Array(await cryptoImpl.subtle.encrypt({ + name: 'AES-GCM', + iv, + additionalData: encoder.encode(`${roomId}|${senderId}`) + }, key, encoder.encode(plaintext))); + const envelope = new Uint8Array(iv.length + encrypted.length); + envelope.set(iv); + envelope.set(encrypted, iv.length); + return bytesToBase64Url(envelope); +} + +export async function decryptChatMessage({ ciphertext, roomId, senderId, secret }, cryptoImpl = globalThis.crypto) { + if (!senderId) throw new TypeError('senderId is required'); + const bytes = base64UrlToBytes(ciphertext); + if (!bytes || bytes.length < MIN_ENCRYPTED_BYTES || bytes.length > MAX_ENCRYPTED_BYTES) { + throw new TypeError('Invalid chat ciphertext'); + } + const key = await deriveChatKey(roomId, secret, cryptoImpl); + const encoder = new globalThis.TextEncoder(); + const plaintext = await cryptoImpl.subtle.decrypt({ + name: 'AES-GCM', + iv: bytes.slice(0, IV_BYTES), + additionalData: encoder.encode(`${roomId}|${senderId}`) + }, key, bytes.slice(IV_BYTES)); + const text = new globalThis.TextDecoder('utf-8', { fatal: true }).decode(plaintext); + return normalizeOutgoingChatText(text); +} diff --git a/extension/chat-crypto.test.mjs b/extension/chat-crypto.test.mjs new file mode 100644 index 0000000..a2e205d --- /dev/null +++ b/extension/chat-crypto.test.mjs @@ -0,0 +1,114 @@ +import { webcrypto } from 'node:crypto'; +import { Buffer } from 'node:buffer'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + clearChatKeyCache, + countCodePoints, + decryptChatMessage, + deriveChatKey, + encryptChatMessage, + generateChatSecret, + normalizeOutgoingChatText, + validateChatSecret +} from './chat-crypto.js'; + +afterEach(clearChatKeyCache); + +describe('chat crypto', () => { + it('generates canonical 128-bit secrets', () => { + const secret = generateChatSecret(webcrypto); + expect(secret).toMatch(/^[A-Za-z0-9_-]{22}$/); + expect(validateChatSecret(secret)).toBe(secret); + expect(validateChatSecret(`${secret}=`)).toBe(''); + }); + + it('round-trips Unicode with a cached room key and fresh IVs', async () => { + const secret = generateChatSecret(webcrypto); + const firstKey = await deriveChatKey('ROOM-1', secret, webcrypto); + const secondKey = await deriveChatKey('ROOM-1', secret, webcrypto); + expect(secondKey).toBe(firstKey); + + const input = { text: 'Hello 🐨 **world**', roomId: 'ROOM-1', senderId: 'alice', secret }; + const first = await encryptChatMessage(input, webcrypto); + const second = await encryptChatMessage(input, webcrypto); + expect(second).not.toBe(first); + await expect(decryptChatMessage({ ciphertext: first, ...input }, webcrypto)).resolves.toBe(input.text); + }); + + it('deduplicates in-flight key derivations without restoring a cleared cache', async () => { + const secret = generateChatSecret(webcrypto); + let deriveCalls = 0; + let releaseDerive; + const delayedCrypto = { + ...webcrypto, + subtle: { + importKey: (...args) => webcrypto.subtle.importKey(...args), + deriveKey: async (...args) => { + deriveCalls++; + await new Promise(resolve => { releaseDerive = resolve; }); + return webcrypto.subtle.deriveKey(...args); + } + } + }; + const first = deriveChatKey('ROOM-1', secret, delayedCrypto); + const second = deriveChatKey('ROOM-1', secret, delayedCrypto); + await Promise.resolve(); + expect(deriveCalls).toBe(1); + clearChatKeyCache(); + releaseDerive(); + await Promise.all([first, second]); + const fresh = deriveChatKey('ROOM-1', secret, webcrypto); + expect(fresh).not.toBe(first); + await fresh; + }); + + it('rejects relabeling, cross-room replay, and the wrong secret', async () => { + const secret = generateChatSecret(webcrypto); + const ciphertext = await encryptChatMessage({ text: 'secret', roomId: 'ROOM-1', senderId: 'alice', secret }, webcrypto); + clearChatKeyCache(); + await expect(decryptChatMessage({ ciphertext, roomId: 'ROOM-1', senderId: 'bob', secret }, webcrypto)).rejects.toThrow(); + clearChatKeyCache(); + await expect(decryptChatMessage({ ciphertext, roomId: 'ROOM-2', senderId: 'alice', secret }, webcrypto)).rejects.toThrow(); + clearChatKeyCache(); + await expect(decryptChatMessage({ ciphertext, roomId: 'ROOM-1', senderId: 'alice', secret: generateChatSecret(webcrypto) }, webcrypto)).rejects.toThrow(); + }); + + it('enforces 500 Unicode code points before encryption', () => { + expect(countCodePoints('😀'.repeat(500))).toBe(500); + expect(normalizeOutgoingChatText(` ${'😀'.repeat(500)} `)).toBe('😀'.repeat(500)); + expect(() => normalizeOutgoingChatText('😀'.repeat(501))).toThrow(RangeError); + expect(() => normalizeOutgoingChatText(' ')).toThrow(TypeError); + }); + + it('keeps the worst-case 500-codepoint payload within the relay byte bound', async () => { + const secret = generateChatSecret(webcrypto); + const ciphertext = await encryptChatMessage({ + text: '😀'.repeat(500), roomId: 'ROOM-1', senderId: 'alice', secret + }, webcrypto); + const bytes = Buffer.from(ciphertext, 'base64url'); + expect(bytes).toHaveLength(2028); + }); + + it('rejects non-canonical plaintext and malformed UTF-8 after authentication', async () => { + const secret = generateChatSecret(webcrypto); + const roomId = 'ROOM-1'; + const senderId = 'alice'; + const key = await deriveChatKey(roomId, secret, webcrypto); + const encoder = new globalThis.TextEncoder(); + + async function encryptRaw(plaintext) { + const iv = webcrypto.getRandomValues(new Uint8Array(12)); + const encrypted = new Uint8Array(await webcrypto.subtle.encrypt({ + name: 'AES-GCM', + iv, + additionalData: encoder.encode(`${roomId}|${senderId}`) + }, key, plaintext)); + return Buffer.concat([Buffer.from(iv), Buffer.from(encrypted)]).toString('base64url'); + } + + const whitespace = await encryptRaw(encoder.encode(' ')); + await expect(decryptChatMessage({ ciphertext: whitespace, roomId, senderId, secret }, webcrypto)).rejects.toThrow(TypeError); + const malformed = await encryptRaw(Uint8Array.of(0xff)); + await expect(decryptChatMessage({ ciphertext: malformed, roomId, senderId, secret }, webcrypto)).rejects.toThrow(); + }); +}); diff --git a/extension/chat-format.js b/extension/chat-format.js new file mode 100644 index 0000000..766eb40 --- /dev/null +++ b/extension/chat-format.js @@ -0,0 +1,33 @@ +(function exposeChatFormat(root) { + function escapeChatHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function formatChatText(value) { + return escapeChatHtml(value) + .replace(/\*\*([^*\n]+)\*\*/g, '$1') + .replace(/\*([^*\n]+)\*/g, '$1'); + } + + function tokenizeChatText(value) { + const text = String(value ?? ''); + const tokens = []; + const pattern = /\*\*([^*\n]+)\*\*|\*([^*\n]+)\*/g; + let cursor = 0; + let match; + while ((match = pattern.exec(text)) !== null) { + if (match.index > cursor) tokens.push({ type: 'text', text: text.slice(cursor, match.index) }); + tokens.push({ type: match[1] !== undefined ? 'strong' : 'em', text: match[1] ?? match[2] }); + cursor = pattern.lastIndex; + } + if (cursor < text.length) tokens.push({ type: 'text', text: text.slice(cursor) }); + return tokens; + } + + root.KoalaSyncChatFormat = Object.freeze({ escapeChatHtml, formatChatText, tokenizeChatText }); +})(globalThis); diff --git a/extension/chat-format.test.mjs b/extension/chat-format.test.mjs new file mode 100644 index 0000000..ece1295 --- /dev/null +++ b/extension/chat-format.test.mjs @@ -0,0 +1,22 @@ +import { beforeAll, describe, expect, it } from 'vitest'; + +beforeAll(async () => { + await import('./chat-format.js'); +}); + +describe('chat formatting', () => { + it('escapes executable HTML before applying limited Markdown', () => { + const result = globalThis.KoalaSyncChatFormat.formatChatText(' **bold** *italic*'); + expect(result).toBe('<img src=x onerror=alert(1)> bold italic'); + expect(result).not.toContain(' { + expect(globalThis.KoalaSyncChatFormat.tokenizeChatText(' **bold** *italic*')).toEqual([ + { type: 'text', text: ' ' }, + { type: 'strong', text: 'bold' }, + { type: 'text', text: ' ' }, + { type: 'em', text: 'italic' } + ]); + }); +}); diff --git a/extension/chat-overlay-contract.test.mjs b/extension/chat-overlay-contract.test.mjs new file mode 100644 index 0000000..8559857 --- /dev/null +++ b/extension/chat-overlay-contract.test.mjs @@ -0,0 +1,104 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const extensionDir = path.dirname(fileURLToPath(import.meta.url)); +const overlaySource = fs.readFileSync(path.join(extensionDir, 'chat-overlay.js'), 'utf8'); +const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8'); +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', + 'CHAT_CLOSE', + 'CHAT_DOCK_LEFT', + 'CHAT_DOCK_RIGHT', + 'CHAT_DETACHED', + 'CHAT_PLACEHOLDER', + 'CHAT_SEND', + 'CHAT_MISSING_KEY', + 'CHAT_TOO_LONG', + 'CHAT_SEND_FAILED', + 'CHAT_EMPTY' +]; + +describe('chat overlay contract', () => { + it('isolates the overlay and never renders markup as HTML', () => { + expect(overlaySource).toContain("attachShadow({ mode: 'open' })"); + expect(overlaySource).not.toMatch(/\.innerHTML\s*=|insertAdjacentHTML|\.outerHTML\s*=/); + expect(overlaySource).toContain('document.createTextNode(token.text)'); + }); + + it('injects formatting and overlay code only with the selected-tab content script', () => { + expect(backgroundSource).toContain("files: ['chat-format.js', 'chat-overlay.js', 'content.js']"); + expect(overlaySource).toContain("const storageKey = `chatOverlayLayout:${location.origin}`"); + expect(overlaySource).toContain('document.fullscreenElement || document.documentElement'); + }); + + it('supports all layout and theme combinations with bounded message DOM', () => { + expect(overlaySource).toContain("['left', 'right', 'detached']"); + expect(overlaySource).toContain('!layout.detachedInitialized'); + expect(overlaySource).toContain('const rect = panel.getBoundingClientRect()'); + expect(overlaySource).not.toContain('contentRect'); + expect(overlaySource).toContain("#app[data-palette=\"cyber\"][data-theme=\"light\"]"); + expect(overlaySource).toContain("#app[data-palette=\"graphite\"][data-theme=\"light\"]"); + expect(overlaySource).toContain('const MAX_MESSAGES = 200'); + expect(overlaySource).toContain('while (messages.querySelectorAll(\'.message\').length > MAX_MESSAGES)'); + expect(overlaySource).toContain('Math.max(1, window.innerWidth)'); + expect(overlaySource).toContain('min(${MIN_WIDTH}px, calc(100vw - 16px))'); + }); + + it('guards async refresh/send work and clears all composer state on room reset', () => { + expect(overlaySource).toContain('generation !== refreshGeneration'); + expect(overlaySource).toContain('if (sending || !context?.enabled) return'); + expect(overlaySource).toContain('textarea.value === submittedValue'); + expect(overlaySource).toMatch(/CHAT_RESET[\s\S]*resetComposer\(\)/); + expect(overlaySource).toContain('setTimeout(() => finish(null), timeoutMs)'); + expect(backgroundSource).toContain('chatReceiveQueue = chatReceiveQueue.catch(() => {}).then'); + 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 ='); + expect(overlaySource).toContain("launcher.setAttribute('aria-describedby', launcherHint.id)"); + expect(overlaySource).toContain("textarea.setAttribute('aria-describedby', 'chat-composer-count chat-composer-status')"); + expect(overlaySource).toContain("status.setAttribute('role', 'status')"); + }); + + it('creates a chat key for both generated-room entry points', () => { + expect(popupSource).toContain('let pendingRoomCreation = false'); + expect(popupSource).toContain('const isCreating = pendingRoomCreation || !roomIdInput'); + expect(popupSource).toMatch(/function handleCreateRoom\(\)[\s\S]*pendingRoomCreation = true[\s\S]*elements\.joinBtn\.click\(\)/); + expect(popupSource).toContain("type: 'CREATE_CHAT_KEY'"); + }); + + it('contains every chat string in all 15 extension locales', () => { + const localeFiles = fs.readdirSync(localeDir).filter(file => file.endsWith('.json')).sort(); + expect(localeFiles).toHaveLength(15); + for (const file of localeFiles) { + const messages = JSON.parse(fs.readFileSync(path.join(localeDir, file), 'utf8')); + for (const key of chatKeys) { + expect(messages[key], `${file}: ${key}`).toBeTypeOf('string'); + expect(messages[key], `${file}: ${key}`).not.toBe(''); + expect(messages[key], `${file}: ${key}`).not.toMatch(/[—–]/); + } + } + }); +}); diff --git a/extension/chat-overlay.js b/extension/chat-overlay.js new file mode 100644 index 0000000..0870bd3 --- /dev/null +++ b/extension/chat-overlay.js @@ -0,0 +1,531 @@ +(function initKoalaSyncChatOverlay() { + if (window.koalaSyncChatOverlay?.refresh) { + window.koalaSyncChatOverlay.refresh(); + return; + } + + const MAX_MESSAGES = 200; + const MIN_WIDTH = 300; + const MIN_HEIGHT = 320; + const DEFAULT_WIDTH = 360; + const DEFAULT_HEIGHT = 520; + const storageKey = `chatOverlayLayout:${location.origin}`; + const systemTheme = window.matchMedia('(prefers-color-scheme: light)'); + let context = null; + let opened = false; + let destroyed = false; + let saveTimer = null; + let applyingLayout = false; + let refreshGeneration = 0; + let sendGeneration = 0; + let sending = false; + let layout = { mode: 'right', x: 24, y: 72, width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, detachedInitialized: false }; + let themeMode = 'system'; + let themePalette = 'eucalyptus'; + + const host = document.createElement('div'); + host.id = 'koalasync-chat-overlay-host'; + 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 = ` + :host { all: initial; } + * { box-sizing: border-box; } + #app { + --bg: oklch(0.205 0.025 155); --card: oklch(0.30 0.035 150); + --surface-alt: oklch(0.265 0.032 150); --surface-deep: oklch(0.205 0.025 155); + --accent: oklch(0.73 0.13 155); --accent-hover: oklch(0.79 0.12 155); + --text: oklch(0.96 0.01 90); --text-muted: oklch(0.78 0.02 90); + --border-soft: oklch(0.88 0.025 145 / 0.09); --border-strong: oklch(0.39 0.038 145); + --text-on-green: oklch(0.14 0.02 90); --danger: oklch(0.63 0.18 25); + position: fixed; inset: 0; pointer-events: none; color: var(--text); + font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + } + #app[data-theme="light"] { + --bg: oklch(0.935 0.018 110); --card: oklch(0.995 0.006 100); + --surface-alt: oklch(0.91 0.022 115); --surface-deep: oklch(0.935 0.018 110); + --accent: oklch(0.52 0.13 155); --accent-hover: oklch(0.45 0.12 155); + --text: oklch(0.21 0.03 140); --text-muted: oklch(0.43 0.03 135); + --border-soft: oklch(0.28 0.035 140 / 0.10); --border-strong: oklch(0.73 0.035 125); + --text-on-green: oklch(0.16 0.03 140); + } + #app[data-palette="cyber"] { + --bg: oklch(0.185 0.04 265); --card: oklch(0.285 0.05 265); + --surface-alt: oklch(0.25 0.048 265); --surface-deep: oklch(0.185 0.04 265); + --accent: oklch(0.59 0.20 275); --accent-hover: oklch(0.69 0.17 275); + --text: oklch(0.97 0.012 265); --text-muted: oklch(0.74 0.03 265); + --border-soft: oklch(0.9 0.03 265 / 0.10); --border-strong: oklch(0.42 0.05 265); + --text-on-green: oklch(0.99 0.006 275); + } + #app[data-palette="cyber"][data-theme="light"] { + --bg: oklch(0.945 0.014 265); --card: oklch(0.995 0.005 265); + --surface-alt: oklch(0.915 0.02 265); --surface-deep: oklch(0.945 0.014 265); + --accent: oklch(0.53 0.21 275); --accent-hover: oklch(0.46 0.20 275); + --text: oklch(0.26 0.05 265); --text-muted: oklch(0.48 0.045 265); + --border-soft: oklch(0.30 0.05 265 / 0.12); --border-strong: oklch(0.79 0.03 265); + --text-on-green: oklch(0.99 0.005 275); + } + #app[data-palette="graphite"] { + --bg: oklch(0.165 0.004 260); --card: oklch(0.255 0.005 260); + --surface-alt: oklch(0.225 0.005 260); --surface-deep: oklch(0.165 0.004 260); + --accent: oklch(0.93 0.006 260); --accent-hover: oklch(0.99 0.003 260); + --text: oklch(0.97 0.003 260); --text-muted: oklch(0.72 0.005 260); + --border-soft: oklch(0.92 0.008 260 / 0.11); --border-strong: oklch(0.42 0.006 260); + --text-on-green: oklch(0.20 0.004 260); + } + #app[data-palette="graphite"][data-theme="light"] { + --bg: oklch(0.928 0.003 260); --card: oklch(1 0 0); + --surface-alt: oklch(0.90 0.004 260); --surface-deep: oklch(0.928 0.003 260); + --accent: oklch(0.28 0.006 260); --accent-hover: oklch(0.18 0.005 260); + --text: oklch(0.22 0.005 260); --text-muted: oklch(0.46 0.006 260); + --border-soft: oklch(0.20 0.006 260 / 0.13); --border-strong: oklch(0.77 0.004 260); + --text-on-green: oklch(0.98 0.002 260); + } + button, textarea { font: inherit; } + button { color: inherit; } + .launcher { + position: fixed; width: 48px; height: 48px; border: 1px solid var(--border-strong); + border-radius: 16px; background: var(--card); color: var(--text); cursor: pointer; + pointer-events: auto; box-shadow: 0 10px 28px rgb(0 0 0 / .32); font-size: 22px; + } + .launcher:hover:not([aria-disabled="true"]) { border-color: var(--accent); transform: translateY(-1px); } + .launcher[aria-disabled="true"] { cursor: not-allowed; opacity: .58; } + .panel { + position: fixed; display: none; flex-direction: column; overflow: hidden; + min-width: min(${MIN_WIDTH}px, calc(100vw - 16px)); min-height: min(${MIN_HEIGHT}px, calc(100vh - 16px)); max-width: calc(100vw - 16px); + max-height: calc(100vh - 16px); pointer-events: auto; color: var(--text); + background: var(--card); border: 1px solid var(--border-strong); border-radius: 18px; + box-shadow: 0 18px 55px rgb(0 0 0 / .38); transform: translateZ(0); + } + .panel.open { display: flex; } + .header { display: flex; align-items: center; gap: 10px; padding: 12px; border-bottom: 1px solid var(--border-soft); user-select: none; } + .header.detached { cursor: move; } + .heading { min-width: 0; flex: 1; } + .title { font-size: 14px; font-weight: 760; } + .subtitle { color: var(--text-muted); font-size: 11px; } + .modes { display: flex; gap: 4px; } + .icon-button { width: 30px; height: 30px; border: 1px solid var(--border-soft); border-radius: 9px; background: var(--surface-alt); cursor: pointer; } + .icon-button:hover, .icon-button.active { border-color: var(--accent); color: var(--accent); } + .messages { flex: 1; min-height: 0; overflow-y: auto; padding: 12px; background: var(--bg); overscroll-behavior: contain; } + .empty { color: var(--text-muted); text-align: center; padding: 32px 12px; font-size: 12px; } + .message { margin-bottom: 11px; overflow-wrap: anywhere; } + .meta { display: flex; align-items: baseline; gap: 7px; margin-bottom: 2px; } + .sender { color: var(--accent); font-weight: 700; font-size: 12px; } + .time { color: var(--text-muted); font-size: 10px; } + .body { white-space: pre-wrap; color: var(--text); } + .composer { padding: 10px; border-top: 1px solid var(--border-soft); background: var(--card); } + textarea { width: 100%; min-height: 62px; max-height: 132px; resize: vertical; border: 1px solid var(--border-strong); border-radius: 11px; padding: 9px; background: var(--surface-deep); color: var(--text); outline: none; } + textarea:focus { border-color: var(--accent); } + textarea::placeholder { color: var(--text-muted); } + .composer-row { display: flex; align-items: center; gap: 8px; margin-top: 7px; } + .count { flex: 1; color: var(--text-muted); font-size: 10px; } + .status { color: var(--danger); font-size: 10px; min-height: 14px; } + .send { border: 0; border-radius: 10px; padding: 8px 13px; background: var(--accent); color: var(--text-on-green); font-weight: 750; cursor: pointer; } + .send:hover:not(:disabled) { background: var(--accent-hover); } + .send:disabled { opacity: .55; cursor: wait; } + .visually-hidden { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } + @media (max-width: 520px) { .panel { max-width: calc(100vw - 12px); } } + @media (prefers-reduced-motion: reduce) { * { transition: none !important; } } + `; + + function element(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + } + + const app = element('div'); + app.id = 'app'; + const launcher = element('button', 'launcher', '💬'); + launcher.type = 'button'; + const launcherHint = element('span', 'visually-hidden'); + launcherHint.id = 'chat-launcher-hint'; + const panel = element('section', 'panel'); + panel.setAttribute('role', 'dialog'); + const header = element('header', 'header'); + const heading = element('div', 'heading'); + const title = element('div', 'title'); + const subtitle = element('div', 'subtitle'); + heading.append(title, subtitle); + const modes = element('div', 'modes'); + const leftButton = element('button', 'icon-button', '←'); + const detachedButton = element('button', 'icon-button', '↗'); + const rightButton = element('button', 'icon-button', '→'); + const closeButton = element('button', 'icon-button', '×'); + for (const button of [leftButton, detachedButton, rightButton, closeButton]) button.type = 'button'; + modes.append(leftButton, detachedButton, rightButton, closeButton); + header.append(heading, modes); + const messages = element('div', 'messages'); + messages.setAttribute('aria-live', 'polite'); + const empty = element('div', 'empty'); + messages.append(empty); + const composer = element('form', 'composer'); + const textareaLabel = element('label', 'visually-hidden'); + textareaLabel.htmlFor = 'chat-composer-input'; + const textarea = element('textarea'); + textarea.id = 'chat-composer-input'; + textarea.setAttribute('aria-describedby', 'chat-composer-count chat-composer-status'); + const composerRow = element('div', 'composer-row'); + const count = element('span', 'count', '0/500'); + count.id = 'chat-composer-count'; + const sendButton = element('button', 'send'); + sendButton.type = 'submit'; + composerRow.append(count, sendButton); + const status = element('div', 'status'); + status.id = 'chat-composer-status'; + status.setAttribute('role', 'status'); + composer.append(textareaLabel, textarea, composerRow, status); + panel.append(header, messages, composer); + app.append(launcherHint, launcher, panel); + shadow.append(style, app); + document.documentElement.append(host); + + function messageRuntime(payload, timeoutMs = 5000) { + return new Promise(resolve => { + let settled = false; + const finish = response => { + if (settled) return; + settled = true; + clearTimeout(timeout); + resolve(response || null); + }; + const timeout = setTimeout(() => finish(null), timeoutMs); + try { + chrome.runtime.sendMessage(payload, response => { + if (chrome.runtime.lastError) finish(null); + else finish(response); + }); + } catch (_) { + finish(null); + } + }); + } + + function strings() { + return context?.strings || {}; + } + + function applyStrings() { + const text = strings(); + title.textContent = text.title || ''; + subtitle.textContent = text.liveOnly || ''; + launcher.setAttribute('aria-label', text.open || ''); + const unavailableHint = context?.supported && !context?.hasKey + ? (text.missingKey || '') + : context?.supported && !context?.connected ? (text.sendFailed || '') : ''; + launcher.title = unavailableHint || text.open || ''; + launcherHint.textContent = unavailableHint; + if (unavailableHint) launcher.setAttribute('aria-describedby', launcherHint.id); + else launcher.removeAttribute('aria-describedby'); + panel.setAttribute('aria-label', text.title || ''); + textarea.placeholder = text.placeholder || ''; + textareaLabel.textContent = text.placeholder || text.title || ''; + sendButton.textContent = text.send || ''; + empty.textContent = text.empty || ''; + leftButton.title = text.dockLeft || ''; + detachedButton.title = text.detached || ''; + rightButton.title = text.dockRight || ''; + closeButton.title = text.close || ''; + leftButton.setAttribute('aria-label', text.dockLeft || ''); + detachedButton.setAttribute('aria-label', text.detached || ''); + rightButton.setAttribute('aria-label', text.dockRight || ''); + closeButton.setAttribute('aria-label', text.close || ''); + } + + function applyTheme() { + const light = themeMode === 'light' || (themeMode === 'system' && systemTheme.matches); + app.dataset.theme = light ? 'light' : 'dark'; + app.dataset.palette = ['eucalyptus', 'cyber', 'graphite'].includes(themePalette) ? themePalette : 'eucalyptus'; + } + + function viewportBounds() { + return { width: Math.max(1, window.innerWidth), height: Math.max(1, window.innerHeight) }; + } + + function clampDetached() { + const viewport = viewportBounds(); + const maxWidth = Math.max(1, Math.min(600, viewport.width - 16)); + const maxHeight = Math.max(1, viewport.height - 16); + const horizontalInset = Math.min(8, Math.max(0, Math.floor((viewport.width - 1) / 2))); + const verticalInset = Math.min(8, Math.max(0, Math.floor((viewport.height - 1) / 2))); + layout.width = Math.min(Math.max(Number(layout.width) || DEFAULT_WIDTH, Math.min(MIN_WIDTH, maxWidth)), maxWidth); + layout.height = Math.min(Math.max(Number(layout.height) || DEFAULT_HEIGHT, Math.min(MIN_HEIGHT, maxHeight)), maxHeight); + layout.x = Math.min(Math.max(Number(layout.x) || horizontalInset, horizontalInset), Math.max(horizontalInset, viewport.width - layout.width - horizontalInset)); + layout.y = Math.min(Math.max(Number(layout.y) || verticalInset, verticalInset), Math.max(verticalInset, viewport.height - layout.height - verticalInset)); + } + + function saveLayout() { + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(() => { + saveTimer = null; + chrome.storage.local.set({ [storageKey]: layout }).catch(() => {}); + }, 150); + } + + function applyLayout() { + applyingLayout = true; + panel.style.right = 'auto'; + panel.style.left = 'auto'; + panel.style.bottom = 'auto'; + panel.style.resize = 'none'; + header.classList.toggle('detached', layout.mode === 'detached'); + leftButton.classList.toggle('active', layout.mode === 'left'); + detachedButton.classList.toggle('active', layout.mode === 'detached'); + rightButton.classList.toggle('active', layout.mode === 'right'); + if (layout.mode === 'detached') { + clampDetached(); + panel.style.left = `${layout.x}px`; + panel.style.top = `${layout.y}px`; + panel.style.width = `${layout.width}px`; + panel.style.height = `${layout.height}px`; + panel.style.resize = 'both'; + launcher.style.left = `${layout.x}px`; + launcher.style.right = 'auto'; + launcher.style.top = `${layout.y}px`; + } else { + const viewport = viewportBounds(); + const gutter = Math.min(16, Math.max(0, Math.floor((viewport.width - 1) / 2))); + const panelTop = viewport.height < MIN_HEIGHT + 80 ? Math.min(8, Math.max(0, viewport.height - 1)) : 64; + panel.style.top = `${panelTop}px`; + panel.style.width = `${Math.max(1, Math.min(DEFAULT_WIDTH, viewport.width - gutter * 2))}px`; + panel.style.height = `${Math.max(1, Math.min(560, viewport.height - panelTop - Math.min(16, Math.max(0, viewport.height - panelTop - 1))))}px`; + panel.style[layout.mode] = `${gutter}px`; + launcher.style.top = `${Math.max(0, Math.min(viewport.height - 48, viewport.height / 2 - 24))}px`; + launcher.style.left = layout.mode === 'left' ? `${gutter}px` : 'auto'; + launcher.style.right = layout.mode === 'right' ? `${gutter}px` : 'auto'; + } + globalThis.queueMicrotask(() => { applyingLayout = false; }); + } + + function setMode(mode) { + if (!['left', 'right', 'detached'].includes(mode)) return; + if (mode === 'detached' && !layout.detachedInitialized) { + layout.x = Math.max(8, Math.round((window.innerWidth - DEFAULT_WIDTH) / 2)); + layout.y = Math.max(8, Math.round((window.innerHeight - DEFAULT_HEIGHT) / 2)); + layout.detachedInitialized = true; + } + layout.mode = mode; + applyLayout(); + saveLayout(); + } + + function setOpened(next) { + opened = !!next && !!context?.enabled; + panel.classList.toggle('open', opened); + launcher.style.display = opened ? 'none' : ''; + if (opened) { + textarea.focus(); + messages.scrollTop = messages.scrollHeight; + } + } + + function applyContext(next) { + 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 && optedIn && hasKey && connected } : null; + if (previousRoomId && previousRoomId !== context?.roomId) clearMessages(); + host.style.display = supported && optedIn ? '' : 'none'; + launcher.setAttribute('aria-disabled', String(!context?.enabled)); + if (!context?.enabled) setOpened(false); + applyStrings(); + applyLayout(); + } + + async function refresh() { + if (destroyed) return; + const generation = ++refreshGeneration; + const next = await messageRuntime({ type: 'GET_CHAT_CONTEXT' }); + if (destroyed || generation !== refreshGeneration) return; + applyContext(next); + } + + function appendMessage(message) { + if (!context?.enabled || !message || typeof message.text !== 'string') return; + if (empty.isConnected) empty.remove(); + const wrapper = element('article', 'message'); + const meta = element('div', 'meta'); + const own = message.senderId === context.peerId; + const sender = element('span', 'sender', own ? (strings().you || '') : (message.username || message.senderId || '')); + const time = element('time', 'time'); + const date = new Date(message.timestamp); + time.textContent = Number.isFinite(date.getTime()) ? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''; + meta.append(sender, time); + const body = element('div', 'body'); + const tokens = globalThis.KoalaSyncChatFormat?.tokenizeChatText(message.text) || [{ type: 'text', text: message.text }]; + for (const token of tokens) { + const node = token.type === 'strong' ? document.createElement('strong') : token.type === 'em' ? document.createElement('em') : document.createTextNode(token.text); + if (node.nodeType === 1) node.textContent = token.text; + body.append(node); + } + wrapper.append(meta, body); + messages.append(wrapper); + while (messages.querySelectorAll('.message').length > MAX_MESSAGES) messages.querySelector('.message')?.remove(); + messages.scrollTop = messages.scrollHeight; + } + + function clearMessages() { + messages.replaceChildren(empty); + } + + function resetComposer() { + sendGeneration++; + sending = false; + textarea.value = ''; + count.textContent = '0/500'; + count.style.color = ''; + status.textContent = ''; + sendButton.disabled = false; + } + + launcher.addEventListener('click', () => setOpened(true)); + closeButton.addEventListener('click', () => setOpened(false)); + leftButton.addEventListener('click', () => setMode('left')); + detachedButton.addEventListener('click', () => setMode('detached')); + rightButton.addEventListener('click', () => setMode('right')); + + textarea.addEventListener('input', () => { + const length = [...textarea.value].length; + count.textContent = `${length}/500`; + count.style.color = length > 500 ? 'var(--danger)' : ''; + status.textContent = length > 500 ? (strings().tooLong || '') : ''; + }); + textarea.addEventListener('keydown', event => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + if (!sending) composer.requestSubmit(); + } + }); + composer.addEventListener('submit', async event => { + event.preventDefault(); + if (sending || !context?.enabled) return; + const submittedValue = textarea.value; + const text = textarea.value.trim(); + if (!text) return; + if ([...text].length > 500) { + status.textContent = strings().tooLong || ''; + return; + } + const generation = ++sendGeneration; + sending = true; + sendButton.disabled = true; + status.textContent = ''; + const response = await messageRuntime({ type: 'CHAT_SEND', text }); + if (destroyed || generation !== sendGeneration) return; + sending = false; + sendButton.disabled = false; + if (response?.status === 'ok') { + if (textarea.value === submittedValue) { + textarea.value = ''; + count.textContent = '0/500'; + } + } else { + status.textContent = response?.status === 'too_long' ? (strings().tooLong || '') : (strings().sendFailed || ''); + } + }); + + let drag = null; + header.addEventListener('pointerdown', event => { + if (layout.mode !== 'detached' || event.target.closest('button')) return; + drag = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, x: layout.x, y: layout.y }; + header.setPointerCapture(event.pointerId); + }); + header.addEventListener('pointermove', event => { + if (!drag || event.pointerId !== drag.pointerId) return; + layout.x = drag.x + event.clientX - drag.startX; + layout.y = drag.y + event.clientY - drag.startY; + clampDetached(); + applyLayout(); + }); + header.addEventListener('pointerup', event => { + if (!drag || event.pointerId !== drag.pointerId) return; + drag = null; + saveLayout(); + }); + + const resizeObserver = new globalThis.ResizeObserver(() => { + if (applyingLayout || layout.mode !== 'detached') return; + const rect = panel.getBoundingClientRect(); + if (!opened || !rect?.width || !rect.height) return; + layout.width = rect.width; + layout.height = rect.height; + clampDetached(); + saveLayout(); + }); + resizeObserver.observe(panel); + + function moveIntoFullscreen() { + const parent = document.fullscreenElement || document.documentElement; + if (parent && host.parentNode !== parent) parent.append(host); + globalThis.requestAnimationFrame(applyLayout); + } + + function handleResize() { + if (layout.mode === 'detached') { + clampDetached(); + applyLayout(); + saveLayout(); + } + } + + function handleSystemTheme() { + if (themeMode === 'system') applyTheme(); + } + + function handleStorage(changes, area) { + if (area !== 'local') return; + if (changes.themeMode) themeMode = changes.themeMode.newValue || 'system'; + if (changes.themePalette) themePalette = changes.themePalette.newValue || 'eucalyptus'; + if (changes.themeMode || changes.themePalette) applyTheme(); + if (changes.locale) refresh(); + } + + function handleRuntime(message) { + if (message?.type === 'CHAT_MESSAGE') appendMessage(message.message); + if (message?.type === 'CHAT_CONTEXT_UPDATE' || message?.type === 'CONNECTION_STATUS') refresh(); + if (message?.type === 'CHAT_RESET') { + clearMessages(); + resetComposer(); + refresh(); + } + if (message?.type === 'CHAT_DESTROY') destroy(); + } + + function destroy() { + if (destroyed) return; + destroyed = true; + refreshGeneration++; + sendGeneration++; + if (saveTimer) clearTimeout(saveTimer); + resizeObserver.disconnect(); + document.removeEventListener('fullscreenchange', moveIntoFullscreen); + window.removeEventListener('resize', handleResize); + systemTheme.removeEventListener('change', handleSystemTheme); + chrome.storage.onChanged.removeListener(handleStorage); + chrome.runtime.onMessage.removeListener(handleRuntime); + host.remove(); + window.koalaSyncChatOverlay = null; + } + + document.addEventListener('fullscreenchange', moveIntoFullscreen); + window.addEventListener('resize', handleResize, { passive: true }); + systemTheme.addEventListener('change', handleSystemTheme); + chrome.storage.onChanged.addListener(handleStorage); + chrome.runtime.onMessage.addListener(handleRuntime); + chrome.storage.local.get([storageKey, 'themeMode', 'themePalette'], data => { + if (data[storageKey] && typeof data[storageKey] === 'object') layout = { ...layout, ...data[storageKey] }; + if (!['left', 'right', 'detached'].includes(layout.mode)) layout.mode = 'right'; + if (layout.mode === 'detached') layout.detachedInitialized = true; + themeMode = data.themeMode || 'system'; + themePalette = data.themePalette || 'eucalyptus'; + applyTheme(); + applyLayout(); + }); + + window.koalaSyncChatOverlay = { refresh, destroy }; + refresh(); +})(); diff --git a/extension/chat-session.js b/extension/chat-session.js new file mode 100644 index 0000000..1458904 --- /dev/null +++ b/extension/chat-session.js @@ -0,0 +1,58 @@ +export const MAX_ROOM_ID_LENGTH = 64; +export const CHAT_SEND_LIMIT = 9; +export const CHAT_SEND_WINDOW_MS = 10000; + +export function normalizeRoomId(value) { + if (typeof value !== 'string') return ''; + return value.trim().replace(/[^a-zA-Z0-9\-]/g, '').slice(0, MAX_ROOM_ID_LENGTH); +} + +export function createChatSendLimiter({ + limit = CHAT_SEND_LIMIT, + windowMs = CHAT_SEND_WINDOW_MS, + now = () => Date.now() +} = {}) { + let timestamps = []; + + return { + take() { + const current = now(); + timestamps = timestamps.filter(timestamp => current - timestamp < windowMs); + if (timestamps.length >= limit) { + return { + allowed: false, + retryAfterMs: Math.max(1, windowMs - (current - timestamps[0])) + }; + } + timestamps.push(current); + return { allowed: true, retryAfterMs: 0 }; + }, + reset() { + timestamps = []; + } + }; +} + +export function createLatestTaskQueue() { + let generation = 0; + let tail = Promise.resolve(); + + return { + invalidate() { + generation++; + }, + async run(task) { + const requestGeneration = ++generation; + const previous = tail; + let release; + tail = new Promise(resolve => { release = resolve; }); + await previous.catch(() => {}); + const isCurrent = () => requestGeneration === generation; + try { + return await task(isCurrent); + } finally { + release(); + } + } + }; +} diff --git a/extension/chat-session.test.mjs b/extension/chat-session.test.mjs new file mode 100644 index 0000000..3c5d5b2 --- /dev/null +++ b/extension/chat-session.test.mjs @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { + CHAT_SEND_LIMIT, + createChatSendLimiter, + createLatestTaskQueue, + MAX_ROOM_ID_LENGTH, + normalizeRoomId +} from './chat-session.js'; + +describe('chat session boundaries', () => { + it('matches the relay room ID sanitizer and length limit', () => { + expect(normalizeRoomId(' ROOM_!42 ')).toBe('ROOM42'); + expect(normalizeRoomId('A'.repeat(MAX_ROOM_ID_LENGTH + 10))).toBe('A'.repeat(MAX_ROOM_ID_LENGTH)); + expect(normalizeRoomId(null)).toBe(''); + }); + + it('keeps client chat bursts below the relay disconnect threshold', () => { + let current = 1000; + const limiter = createChatSendLimiter({ now: () => current }); + for (let index = 0; index < CHAT_SEND_LIMIT; index++) { + expect(limiter.take()).toEqual({ allowed: true, retryAfterMs: 0 }); + } + expect(limiter.take()).toEqual({ allowed: false, retryAfterMs: 10000 }); + current += 10000; + expect(limiter.take()).toEqual({ allowed: true, retryAfterMs: 0 }); + limiter.reset(); + expect(limiter.take()).toEqual({ allowed: true, retryAfterMs: 0 }); + }); + + it('serializes join work and prevents an older request from winning storage races', async () => { + const queue = createLatestTaskQueue(); + let releaseFirst; + const writes = []; + const first = queue.run(async isCurrent => { + await new Promise(resolve => { releaseFirst = resolve; }); + if (isCurrent()) writes.push('first'); + return { status: isCurrent() ? 'ok' : 'superseded' }; + }); + await new Promise(resolve => setTimeout(resolve, 0)); + const second = queue.run(async isCurrent => { + if (isCurrent()) writes.push('second'); + return { status: 'ok' }; + }); + releaseFirst(); + await expect(first).resolves.toEqual({ status: 'superseded' }); + await expect(second).resolves.toEqual({ status: 'ok' }); + expect(writes).toEqual(['second']); + }); +}); diff --git a/extension/chat-wire.js b/extension/chat-wire.js new file mode 100644 index 0000000..94b13d1 --- /dev/null +++ b/extension/chat-wire.js @@ -0,0 +1,11 @@ +export function buildChatRelayPayload(ciphertext) { + return { ciphertext }; +} + +export function encodeSocketEvent(event, data, forbiddenSecret = '') { + const payload = JSON.stringify([event, data]); + if (forbiddenSecret && payload.includes(forbiddenSecret)) { + throw new Error('Refusing to send chat secret to relay'); + } + return `42${payload}`; +} diff --git a/extension/chat-wire.test.mjs b/extension/chat-wire.test.mjs new file mode 100644 index 0000000..508d40a --- /dev/null +++ b/extension/chat-wire.test.mjs @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { buildChatRelayPayload, encodeSocketEvent } from './chat-wire.js'; + +describe('chat wire boundary', () => { + const secret = 'R5Ti1nxp0crfAFHf3gVncw'; + + it('sends only ciphertext for chat messages', () => { + expect(buildChatRelayPayload('ciphertext')).toEqual({ ciphertext: 'ciphertext' }); + }); + + it('rejects the secret anywhere in any outgoing relay event', () => { + for (const [event, payload] of [ + ['join_room', { roomId: 'ROOM', password: 'PASS', chatKey: secret }], + ['peer_status', { status: 'heartbeat', nested: { secret } }], + ['chat_message', { ciphertext: `prefix-${secret}-suffix` }] + ]) { + expect(() => encodeSocketEvent(event, payload, secret)).toThrow('Refusing to send chat secret'); + } + expect(encodeSocketEvent('join_room', { roomId: 'ROOM', password: 'PASS' }, secret)).not.toContain(secret); + }); +}); diff --git a/extension/locales/de.json b/extension/locales/de.json index 80877ae..bbbd366 100644 --- a/extension/locales/de.json +++ b/extension/locales/de.json @@ -245,5 +245,20 @@ "LABEL_SETTINGS_GROUP_PROFILE": "Profil & Aussehen", "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_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", + "CHAT_CLOSE": "Chat schließen", + "CHAT_DOCK_LEFT": "Chat links andocken", + "CHAT_DOCK_RIGHT": "Chat rechts andocken", + "CHAT_DETACHED": "Chat frei positionieren", + "CHAT_PLACEHOLDER": "Nachricht schreiben...", + "CHAT_SEND": "Senden", + "CHAT_MISSING_KEY": "Chat ist nur über einen aktuellen Einladungslink verfügbar.", + "CHAT_TOO_LONG": "Maximal 500 Zeichen.", + "CHAT_SEND_FAILED": "Nachricht konnte nicht gesendet werden.", + "CHAT_EMPTY": "Noch keine Nachrichten. Der Chat ist nur live." } diff --git a/extension/locales/en.json b/extension/locales/en.json index b4274c2..6d7e478 100644 --- a/extension/locales/en.json +++ b/extension/locales/en.json @@ -245,5 +245,20 @@ "LABEL_SETTINGS_GROUP_PROFILE": "Profile & Appearance", "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_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", + "CHAT_CLOSE": "Close chat", + "CHAT_DOCK_LEFT": "Dock chat left", + "CHAT_DOCK_RIGHT": "Dock chat right", + "CHAT_DETACHED": "Detach chat", + "CHAT_PLACEHOLDER": "Write a message...", + "CHAT_SEND": "Send", + "CHAT_MISSING_KEY": "Chat is available only through a current invite link.", + "CHAT_TOO_LONG": "Maximum 500 characters.", + "CHAT_SEND_FAILED": "Message could not be sent.", + "CHAT_EMPTY": "No messages yet. Chat is live only." } diff --git a/extension/locales/es.json b/extension/locales/es.json index dca55b0..a88b151 100644 --- a/extension/locales/es.json +++ b/extension/locales/es.json @@ -245,5 +245,20 @@ "LABEL_SETTINGS_GROUP_PROFILE": "Perfil y Apariencia", "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_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", + "CHAT_CLOSE": "Cerrar chat", + "CHAT_DOCK_LEFT": "Acoplar chat a la izquierda", + "CHAT_DOCK_RIGHT": "Acoplar chat a la derecha", + "CHAT_DETACHED": "Separar chat", + "CHAT_PLACEHOLDER": "Escribe un mensaje...", + "CHAT_SEND": "Enviar", + "CHAT_MISSING_KEY": "El chat solo está disponible mediante un enlace de invitación actual.", + "CHAT_TOO_LONG": "Máximo 500 caracteres.", + "CHAT_SEND_FAILED": "No se pudo enviar el mensaje.", + "CHAT_EMPTY": "Aún no hay mensajes. El chat solo es en directo." } diff --git a/extension/locales/fr.json b/extension/locales/fr.json index 9f8ea18..b8c63f2 100644 --- a/extension/locales/fr.json +++ b/extension/locales/fr.json @@ -245,5 +245,20 @@ "LABEL_SETTINGS_GROUP_PROFILE": "Profil & Apparence", "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_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", + "CHAT_CLOSE": "Fermer le chat", + "CHAT_DOCK_LEFT": "Ancrer le chat à gauche", + "CHAT_DOCK_RIGHT": "Ancrer le chat à droite", + "CHAT_DETACHED": "Détacher le chat", + "CHAT_PLACEHOLDER": "Écrire un message...", + "CHAT_SEND": "Envoyer", + "CHAT_MISSING_KEY": "Le chat est disponible uniquement via un lien d'invitation actuel.", + "CHAT_TOO_LONG": "500 caractères maximum.", + "CHAT_SEND_FAILED": "Le message n'a pas pu être envoyé.", + "CHAT_EMPTY": "Aucun message. Le chat est uniquement en direct." } diff --git a/extension/locales/it.json b/extension/locales/it.json index 3eda6a2..7fbe1f3 100644 --- a/extension/locales/it.json +++ b/extension/locales/it.json @@ -245,5 +245,20 @@ "LABEL_SETTINGS_GROUP_PROFILE": "Profilo & Aspetto", "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_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", + "CHAT_CLOSE": "Chiudi chat", + "CHAT_DOCK_LEFT": "Aggancia chat a sinistra", + "CHAT_DOCK_RIGHT": "Aggancia chat a destra", + "CHAT_DETACHED": "Sgancia chat", + "CHAT_PLACEHOLDER": "Scrivi un messaggio...", + "CHAT_SEND": "Invia", + "CHAT_MISSING_KEY": "La chat è disponibile solo tramite un link di invito attuale.", + "CHAT_TOO_LONG": "Massimo 500 caratteri.", + "CHAT_SEND_FAILED": "Impossibile inviare il messaggio.", + "CHAT_EMPTY": "Nessun messaggio. La chat è solo in diretta." } diff --git a/extension/locales/ja.json b/extension/locales/ja.json index 1047ab4..708f970 100644 --- a/extension/locales/ja.json +++ b/extension/locales/ja.json @@ -245,5 +245,20 @@ "LABEL_SETTINGS_GROUP_PROFILE": "プロフィールと外観", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "ユーザー名、テーマ、言語設定を変更します。", "LABEL_SETTINGS_GROUP_SYNC": "再生と同期", - "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "再生、通知、オーディオ設定をカスタマイズします。" + "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "再生、通知、オーディオ設定をカスタマイズします。", + "LABEL_CHAT_ENABLED": "ルームチャットを有効化", + "LABEL_CHAT_ENABLED_TOOLTIP": "選択した動画タブに暗号化されたルームチャットを表示します。", + "CHAT_TITLE": "ルームチャット", + "CHAT_LIVE_ONLY": "ライブのみ。履歴はありません。", + "CHAT_OPEN": "ルームチャットを開く", + "CHAT_CLOSE": "チャットを閉じる", + "CHAT_DOCK_LEFT": "チャットを左に固定", + "CHAT_DOCK_RIGHT": "チャットを右に固定", + "CHAT_DETACHED": "チャットを切り離す", + "CHAT_PLACEHOLDER": "メッセージを入力...", + "CHAT_SEND": "送信", + "CHAT_MISSING_KEY": "チャットは最新の招待リンクからのみ利用できます。", + "CHAT_TOO_LONG": "最大500文字です。", + "CHAT_SEND_FAILED": "メッセージを送信できませんでした。", + "CHAT_EMPTY": "まだメッセージはありません。チャットはライブのみです。" } diff --git a/extension/locales/ko.json b/extension/locales/ko.json index 5e8ad89..57b4a76 100644 --- a/extension/locales/ko.json +++ b/extension/locales/ko.json @@ -245,5 +245,20 @@ "LABEL_SETTINGS_GROUP_PROFILE": "프로필 및 테마", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "사용자 이름, 테마 및 언어 설정을 변경합니다.", "LABEL_SETTINGS_GROUP_SYNC": "재생 및 동기화", - "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "재생, 알림 및 오디오 설정을 맞춤설정합니다." + "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "재생, 알림 및 오디오 설정을 맞춤설정합니다.", + "LABEL_CHAT_ENABLED": "방 채팅 사용", + "LABEL_CHAT_ENABLED_TOOLTIP": "선택한 동영상 탭에 암호화된 방 채팅을 표시합니다.", + "CHAT_TITLE": "방 채팅", + "CHAT_LIVE_ONLY": "실시간 전용. 기록 없음.", + "CHAT_OPEN": "방 채팅 열기", + "CHAT_CLOSE": "채팅 닫기", + "CHAT_DOCK_LEFT": "채팅을 왼쪽에 고정", + "CHAT_DOCK_RIGHT": "채팅을 오른쪽에 고정", + "CHAT_DETACHED": "채팅 분리", + "CHAT_PLACEHOLDER": "메시지를 입력하세요...", + "CHAT_SEND": "보내기", + "CHAT_MISSING_KEY": "채팅은 최신 초대 링크를 통해서만 사용할 수 있습니다.", + "CHAT_TOO_LONG": "최대 500자입니다.", + "CHAT_SEND_FAILED": "메시지를 보낼 수 없습니다.", + "CHAT_EMPTY": "아직 메시지가 없습니다. 채팅은 실시간 전용입니다." } diff --git a/extension/locales/nl.json b/extension/locales/nl.json index c7ca7c6..c5f67b7 100644 --- a/extension/locales/nl.json +++ b/extension/locales/nl.json @@ -245,5 +245,20 @@ "LABEL_SETTINGS_GROUP_PROFILE": "Profiel & Weergave", "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_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", + "CHAT_CLOSE": "Chat sluiten", + "CHAT_DOCK_LEFT": "Chat links vastzetten", + "CHAT_DOCK_RIGHT": "Chat rechts vastzetten", + "CHAT_DETACHED": "Chat losmaken", + "CHAT_PLACEHOLDER": "Schrijf een bericht...", + "CHAT_SEND": "Verzenden", + "CHAT_MISSING_KEY": "Chat is alleen beschikbaar via een actuele uitnodigingslink.", + "CHAT_TOO_LONG": "Maximaal 500 tekens.", + "CHAT_SEND_FAILED": "Bericht kon niet worden verzonden.", + "CHAT_EMPTY": "Nog geen berichten. Chat is alleen live." } diff --git a/extension/locales/pl.json b/extension/locales/pl.json index d9cbc79..e8ab859 100644 --- a/extension/locales/pl.json +++ b/extension/locales/pl.json @@ -245,5 +245,20 @@ "LABEL_SETTINGS_GROUP_PROFILE": "Profil i Wygląd", "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_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", + "CHAT_CLOSE": "Zamknij czat", + "CHAT_DOCK_LEFT": "Przypnij czat po lewej", + "CHAT_DOCK_RIGHT": "Przypnij czat po prawej", + "CHAT_DETACHED": "Odłącz czat", + "CHAT_PLACEHOLDER": "Napisz wiadomość...", + "CHAT_SEND": "Wyślij", + "CHAT_MISSING_KEY": "Czat jest dostępny tylko przez aktualny link zaproszenia.", + "CHAT_TOO_LONG": "Maksymalnie 500 znaków.", + "CHAT_SEND_FAILED": "Nie udało się wysłać wiadomości.", + "CHAT_EMPTY": "Brak wiadomości. Czat działa tylko na żywo." } diff --git a/extension/locales/pt-BR.json b/extension/locales/pt-BR.json index ff34219..fe4d327 100644 --- a/extension/locales/pt-BR.json +++ b/extension/locales/pt-BR.json @@ -245,5 +245,20 @@ "LABEL_SETTINGS_GROUP_PROFILE": "Perfil e Aparência", "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_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", + "CHAT_CLOSE": "Fechar chat", + "CHAT_DOCK_LEFT": "Fixar chat à esquerda", + "CHAT_DOCK_RIGHT": "Fixar chat à direita", + "CHAT_DETACHED": "Desanexar chat", + "CHAT_PLACEHOLDER": "Escreva uma mensagem...", + "CHAT_SEND": "Enviar", + "CHAT_MISSING_KEY": "O chat está disponível apenas por um link de convite atual.", + "CHAT_TOO_LONG": "Máximo de 500 caracteres.", + "CHAT_SEND_FAILED": "Não foi possível enviar a mensagem.", + "CHAT_EMPTY": "Ainda não há mensagens. O chat é somente ao vivo." } diff --git a/extension/locales/pt.json b/extension/locales/pt.json index 97847fd..494f363 100644 --- a/extension/locales/pt.json +++ b/extension/locales/pt.json @@ -245,5 +245,20 @@ "LABEL_SETTINGS_GROUP_PROFILE": "Perfil e Aparência", "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_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", + "CHAT_CLOSE": "Fechar chat", + "CHAT_DOCK_LEFT": "Fixar chat à esquerda", + "CHAT_DOCK_RIGHT": "Fixar chat à direita", + "CHAT_DETACHED": "Desanexar chat", + "CHAT_PLACEHOLDER": "Escreva uma mensagem...", + "CHAT_SEND": "Enviar", + "CHAT_MISSING_KEY": "O chat está disponível apenas através de um link de convite atual.", + "CHAT_TOO_LONG": "Máximo de 500 caracteres.", + "CHAT_SEND_FAILED": "Não foi possível enviar a mensagem.", + "CHAT_EMPTY": "Ainda não há mensagens. O chat funciona apenas em direto." } diff --git a/extension/locales/ru.json b/extension/locales/ru.json index 7b679e7..373b433 100644 --- a/extension/locales/ru.json +++ b/extension/locales/ru.json @@ -245,5 +245,20 @@ "LABEL_SETTINGS_GROUP_PROFILE": "Профиль и Оформление", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Измените имя пользователя, тему и настройки языка.", "LABEL_SETTINGS_GROUP_SYNC": "Воспроизведение и Синхронизация", - "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Настройте воспроизведение, уведомления и параметры звука." + "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Настройте воспроизведение, уведомления и параметры звука.", + "LABEL_CHAT_ENABLED": "Включить чат комнаты", + "LABEL_CHAT_ENABLED_TOOLTIP": "Показывать зашифрованный чат комнаты на выбранной вкладке с видео.", + "CHAT_TITLE": "Чат комнаты", + "CHAT_LIVE_ONLY": "Только в реальном времени. Без истории.", + "CHAT_OPEN": "Открыть чат комнаты", + "CHAT_CLOSE": "Закрыть чат", + "CHAT_DOCK_LEFT": "Закрепить чат слева", + "CHAT_DOCK_RIGHT": "Закрепить чат справа", + "CHAT_DETACHED": "Открепить чат", + "CHAT_PLACEHOLDER": "Напишите сообщение...", + "CHAT_SEND": "Отправить", + "CHAT_MISSING_KEY": "Чат доступен только по актуальной ссылке-приглашению.", + "CHAT_TOO_LONG": "Не более 500 символов.", + "CHAT_SEND_FAILED": "Не удалось отправить сообщение.", + "CHAT_EMPTY": "Сообщений пока нет. Чат работает только в реальном времени." } diff --git a/extension/locales/tr.json b/extension/locales/tr.json index e69a99d..d0fc2ce 100644 --- a/extension/locales/tr.json +++ b/extension/locales/tr.json @@ -245,5 +245,20 @@ "LABEL_SETTINGS_GROUP_PROFILE": "Profil ve Görünüm", "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_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ç", + "CHAT_CLOSE": "Sohbeti kapat", + "CHAT_DOCK_LEFT": "Sohbeti sola sabitle", + "CHAT_DOCK_RIGHT": "Sohbeti sağa sabitle", + "CHAT_DETACHED": "Sohbeti ayır", + "CHAT_PLACEHOLDER": "Bir mesaj yazın...", + "CHAT_SEND": "Gönder", + "CHAT_MISSING_KEY": "Sohbet yalnızca güncel bir davet bağlantısıyla kullanılabilir.", + "CHAT_TOO_LONG": "En fazla 500 karakter.", + "CHAT_SEND_FAILED": "Mesaj gönderilemedi.", + "CHAT_EMPTY": "Henüz mesaj yok. Sohbet yalnızca canlıdır." } diff --git a/extension/locales/uk.json b/extension/locales/uk.json index a26676b..84850f5 100644 --- a/extension/locales/uk.json +++ b/extension/locales/uk.json @@ -245,5 +245,20 @@ "LABEL_SETTINGS_GROUP_PROFILE": "Профіль та Вигляд", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Змініть ім'я користувача, тему та мовні налаштування.", "LABEL_SETTINGS_GROUP_SYNC": "Відтворення та Синхронізація", - "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Налаштуйте відтворення, сповіщення та параметри звуку." + "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Налаштуйте відтворення, сповіщення та параметри звуку.", + "LABEL_CHAT_ENABLED": "Увімкнути чат кімнати", + "LABEL_CHAT_ENABLED_TOOLTIP": "Показувати зашифрований чат кімнати на вибраній вкладці з відео.", + "CHAT_TITLE": "Чат кімнати", + "CHAT_LIVE_ONLY": "Лише наживо. Без історії.", + "CHAT_OPEN": "Відкрити чат кімнати", + "CHAT_CLOSE": "Закрити чат", + "CHAT_DOCK_LEFT": "Закріпити чат ліворуч", + "CHAT_DOCK_RIGHT": "Закріпити чат праворуч", + "CHAT_DETACHED": "Від'єднати чат", + "CHAT_PLACEHOLDER": "Напишіть повідомлення...", + "CHAT_SEND": "Надіслати", + "CHAT_MISSING_KEY": "Чат доступний лише за актуальним посиланням-запрошенням.", + "CHAT_TOO_LONG": "Не більше 500 символів.", + "CHAT_SEND_FAILED": "Не вдалося надіслати повідомлення.", + "CHAT_EMPTY": "Повідомлень ще немає. Чат працює лише наживо." } diff --git a/extension/locales/zh.json b/extension/locales/zh.json index 786cf4b..9aa97ae 100644 --- a/extension/locales/zh.json +++ b/extension/locales/zh.json @@ -245,5 +245,20 @@ "LABEL_SETTINGS_GROUP_PROFILE": "个人资料与外观", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "更改您的用户名、主题和语言首选项。", "LABEL_SETTINGS_GROUP_SYNC": "播放与同步", - "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "自定义播放、通知和音频首选项。" + "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "自定义播放、通知和音频首选项。", + "LABEL_CHAT_ENABLED": "启用房间聊天", + "LABEL_CHAT_ENABLED_TOOLTIP": "在选定的视频标签页中显示加密的房间聊天。", + "CHAT_TITLE": "房间聊天", + "CHAT_LIVE_ONLY": "仅实时显示,无历史记录。", + "CHAT_OPEN": "打开房间聊天", + "CHAT_CLOSE": "关闭聊天", + "CHAT_DOCK_LEFT": "将聊天固定在左侧", + "CHAT_DOCK_RIGHT": "将聊天固定在右侧", + "CHAT_DETACHED": "浮动聊天", + "CHAT_PLACEHOLDER": "输入消息...", + "CHAT_SEND": "发送", + "CHAT_MISSING_KEY": "聊天仅可通过当前邀请链接使用。", + "CHAT_TOO_LONG": "最多500个字符。", + "CHAT_SEND_FAILED": "无法发送消息。", + "CHAT_EMPTY": "暂无消息。聊天仅实时显示。" } diff --git a/extension/popup.html b/extension/popup.html index 162711d..de0a429 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -1371,7 +1371,7 @@
- +
@@ -1625,6 +1625,13 @@
+
+ + +