From 771da1cf82a894a34e75f241b7390b66d8bbf4d9 Mon Sep 17 00:00:00 2001 From: KoalaDev <6156589+Shik3i@users.noreply.github.com> Date: Wed, 15 Jul 2026 06:25:52 +0200 Subject: [PATCH 01/16] docs: add E2E chat feature brief Design brief for the in-page E2E encrypted chat: threat model, key derivation via URL fragment, #j2 link format, backward compatibility with old extensions, and the constraints the discarded feature/soonTMChat branch got wrong. Co-Authored-By: Claude Opus 4.8 --- docs/chat-e2e-prompt.md | 346 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 docs/chat-e2e-prompt.md 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. From 8fee98834c6dd1d04649b8b814fb1fe1d784a98d Mon Sep 17 00:00:00 2001 From: KoalaDev <6156589+Shik3i@users.noreply.github.com> Date: Wed, 15 Jul 2026 06:58:22 +0200 Subject: [PATCH 02/16] feat(chat): add encrypted invite contract --- docs/CHAT.md | 72 ++++++++++++++++++++++++++++++++++ docs/PROTOCOL.md | 13 +++++++ extension/bridge.js | 3 +- scripts/build-extension.cjs | 4 +- shared/invite-links.js | 77 +++++++++++++++++++++++++++++++++++++ shared/invite-links.test.js | 58 ++++++++++++++++++++++++++++ website/app.js | 51 ++++++++++++------------ website/build.cjs | 11 ++++++ website/join.html | 1 + website/template.html | 1 + 10 files changed, 261 insertions(+), 30 deletions(-) create mode 100644 docs/CHAT.md create mode 100644 shared/invite-links.js create mode 100644 shared/invite-links.test.js diff --git a/docs/CHAT.md b/docs/CHAT.md new file mode 100644 index 0000000..e6ad00b --- /dev/null +++ b/docs/CHAT.md @@ -0,0 +1,72 @@ +# 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. +- Without the relay `chat` capability, no chat control is shown. + +Peer removal and role management are outside chat scope. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index ae01122..b5d8f43 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: 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/scripts/build-extension.cjs b/scripts/build-extension.cjs index b5d9ac6..c80b799 100644 --- a/scripts/build-extension.cjs +++ b/scripts/build-extension.cjs @@ -22,7 +22,7 @@ if (!fs.existsSync(extSharedDir)) { fs.mkdirSync(extSharedDir, { recursive: true }); } -const sharedFiles = ['constants.js', 'blacklist.js', 'names.js', 'README.md']; +const sharedFiles = ['constants.js', 'blacklist.js', 'names.js', 'invite-links.js', 'README.md']; for (const file of sharedFiles) { const src = path.join(masterSharedDir, file); const dest = path.join(extSharedDir, file); @@ -31,7 +31,7 @@ for (const file of sharedFiles) { } fs.copyFileSync(src, dest); } -console.log('✓ constants.js, blacklist.js, names.js, and README.md synced to extension/shared/'); +console.log('✓ shared runtime files synced to extension/shared/'); // Read the base manifest const baseManifest = JSON.parse(fs.readFileSync(baseManifestPath, 'utf8')); diff --git a/shared/invite-links.js b/shared/invite-links.js new file mode 100644 index 0000000..d5c6955 --- /dev/null +++ b/shared/invite-links.js @@ -0,0 +1,77 @@ +(function exposeInviteLinks(root) { + const J2_PREFIX = '#j2:'; + const LEGACY_PREFIX = '#join:'; + + function parseJ2Hash(hash) { + if (typeof hash !== 'string' || !hash.startsWith(J2_PREFIX)) return null; + const params = new URLSearchParams(hash.slice(J2_PREFIX.length)); + const roomId = params.get('r') || ''; + const password = params.get('p') || ''; + const chatKey = params.get('k') || ''; + const serverUrl = params.get('u') || ''; + if (!roomId || !password || !chatKey) return null; + return { + format: 'j2', + roomId, + password, + chatKey, + useCustomServer: serverUrl.length > 0, + serverUrl + }; + } + + function parseLegacyHash(hash) { + if (typeof hash !== 'string' || !hash.startsWith(LEGACY_PREFIX)) return null; + const parts = hash.slice(LEGACY_PREFIX.length).split(':'); + const roomId = parts.shift() || ''; + let useCustomServer = false; + let serverUrl = ''; + + if (parts.length >= 3) { + const flag = parts.at(-2); + const rawUrl = parts.at(-1) || ''; + let decodedUrl = ''; + try { + decodedUrl = decodeURIComponent(rawUrl); + } catch (_) { + return null; + } + const custom = flag === '1' && /^(?:ws|wss):\/\//.test(decodedUrl); + const official = flag === '0' && rawUrl === ''; + if (custom || official) { + parts.splice(-2, 2); + useCustomServer = custom; + serverUrl = custom ? decodedUrl : ''; + } + } + + const password = parts.join(':'); + if (!roomId || !password) return null; + return { + format: 'legacy', + roomId, + password, + chatKey: '', + useCustomServer, + serverUrl + }; + } + + function parseInviteHash(hash) { + return parseJ2Hash(hash) || parseLegacyHash(hash); + } + + function buildJ2Hash({ roomId, password, chatKey, serverUrl = '' }) { + if (!roomId || !password || !chatKey) throw new TypeError('roomId, password, and chatKey are required'); + const params = new URLSearchParams({ r: roomId, p: password, k: chatKey }); + if (serverUrl) params.set('u', serverUrl); + return `${J2_PREFIX}${params.toString()}`; + } + + root.KoalaSyncInviteLinks = Object.freeze({ + J2_PREFIX, + LEGACY_PREFIX, + parseInviteHash, + buildJ2Hash + }); +})(globalThis); diff --git a/shared/invite-links.test.js b/shared/invite-links.test.js new file mode 100644 index 0000000..2e963aa --- /dev/null +++ b/shared/invite-links.test.js @@ -0,0 +1,58 @@ +import { beforeAll, describe, expect, it } from 'vitest'; + +beforeAll(async () => { + await import('./invite-links.js'); +}); + +describe('invite links', () => { + it('round-trips official and custom j2 links', () => { + const api = globalThis.KoalaSyncInviteLinks; + const official = api.buildJ2Hash({ + roomId: 'SAPPHIRE-DUCK-49', + password: '0X:UK3C', + chatKey: 'R5Ti1nxp0crfAFHf3gVncw' + }); + expect(api.parseInviteHash(official)).toEqual({ + format: 'j2', + roomId: 'SAPPHIRE-DUCK-49', + password: '0X:UK3C', + chatKey: 'R5Ti1nxp0crfAFHf3gVncw', + useCustomServer: false, + serverUrl: '' + }); + + const custom = api.buildJ2Hash({ + roomId: 'SILENT-EAGLE-90', + password: '30PXPD', + chatKey: 'R5Ti1nxp0crfAFHf3gVncw', + serverUrl: 'wss://sync.example.test/socket?region=eu' + }); + expect(api.parseInviteHash(custom)).toMatchObject({ + useCustomServer: true, + serverUrl: 'wss://sync.example.test/socket?region=eu' + }); + }); + + it('keeps legacy links compatible, including colons in passwords', () => { + const parse = globalThis.KoalaSyncInviteLinks.parseInviteHash; + expect(parse('#join:ROOM-1:pass:with:colons')).toMatchObject({ + format: 'legacy', + password: 'pass:with:colons', + chatKey: '', + useCustomServer: false + }); + expect(parse('#join:ROOM-1:pass:with:colons:1:wss%3A%2F%2Frelay.example')).toMatchObject({ + password: 'pass:with:colons', + useCustomServer: true, + serverUrl: 'wss://relay.example' + }); + }); + + it('rejects incomplete or malformed invite hashes', () => { + const parse = globalThis.KoalaSyncInviteLinks.parseInviteHash; + expect(parse('#j2:r=ROOM&p=PASS')).toBeNull(); + expect(parse('#join:ROOM')).toBeNull(); + expect(parse('#join:ROOM:PASS:1:%E0%A4%A')).toBeNull(); + expect(parse('#other:r=ROOM&p=PASS&k=KEY')).toBeNull(); + }); +}); diff --git a/website/app.js b/website/app.js index 3a5fa7e..faa2ac1 100644 --- a/website/app.js +++ b/website/app.js @@ -449,15 +449,11 @@ document.addEventListener('DOMContentLoaded', () => { setTimeout(() => { const isInstalled = document.documentElement.dataset.koalasyncInstalled === 'true'; - if (window.location.hash.startsWith('#join:')) { - const parts = window.location.hash.split(':'); - if (parts.length >= 3) { - const roomId = parts[1]; - const password = parts[2]; - const serverFlag = parts[3] || '0'; - const serverUrl = parts[4] ? decodeURIComponent(parts[4]) : ''; + const invite = globalThis.KoalaSyncInviteLinks?.parseInviteHash(window.location.hash); + if (invite) { + const { roomId, password, chatKey, useCustomServer, serverUrl } = invite; - if (isJoinPage) { + if (isJoinPage) { const displayRoom = document.getElementById('display-room-id'); const actions = document.getElementById('join-actions'); if (displayRoom) displayRoom.textContent = roomId; @@ -519,14 +515,15 @@ document.addEventListener('DOMContentLoaded', () => { detail: { roomId, password, - useCustomServer: serverFlag === '1', - serverUrl: serverUrl + chatKey, + useCustomServer, + serverUrl } })); }, 500); } } - } else { + } else { // Fallback banner for index.html if (!document.getElementById('koala-banner')) { const banner = document.createElement('div'); @@ -556,22 +553,22 @@ document.addEventListener('DOMContentLoaded', () => { } } - // Global listener for Join Button - document.addEventListener('click', (e) => { - if (e.target && e.target.id === 'webJoinBtn') { - e.target.textContent = 'JOINING...'; - e.target.disabled = true; - window.dispatchEvent(new CustomEvent('KOALASYNC_JOIN_REQUEST', { - detail: { - roomId, - password, - useCustomServer: serverFlag === '1', - serverUrl: serverUrl - } - })); - } - }); - } + // Global listener for Join Button + document.addEventListener('click', (e) => { + if (e.target && e.target.id === 'webJoinBtn') { + e.target.textContent = 'JOINING...'; + e.target.disabled = true; + window.dispatchEvent(new CustomEvent('KOALASYNC_JOIN_REQUEST', { + detail: { + roomId, + password, + chatKey, + useCustomServer, + serverUrl + } + })); + } + }); } }, 600); // 600ms delay to ensure bridge.js has set the dataset }; diff --git a/website/build.cjs b/website/build.cjs index 4171418..a56b5ec 100644 --- a/website/build.cjs +++ b/website/build.cjs @@ -284,6 +284,12 @@ async function compile() { const appName = `app.${appHash}.min.js`; const appSRI = sha384(appMin); + const inviteLinksRaw = fs.readFileSync(path.join(websiteDir, '..', 'shared', 'invite-links.js'), 'utf8'); + const inviteLinksMin = await minifyJS(inviteLinksRaw); + const inviteLinksHash = sha8(inviteLinksMin); + const inviteLinksName = `invite-links.${inviteLinksHash}.min.js`; + const inviteLinksSRI = sha384(inviteLinksMin); + const langRaw = fs.readFileSync(path.join(websiteDir, 'lang-init.js'), 'utf8'); const langMin = await minifyJS(langRaw); const langHash = sha8(langMin); @@ -298,6 +304,7 @@ async function compile() { console.log(` Landing ${bundle.key}: ${bundle.name} (${(bundle.min.length/1024).toFixed(1)} KB)`); } console.log(` App: ${appName} (${(appMin.length/1024).toFixed(1)} KB, -${appPct}%)`); + console.log(` Invite links: ${inviteLinksName} (${(inviteLinksMin.length/1024).toFixed(1)} KB)`); console.log(` Lang: ${langName} (${(langMin.length/1024).toFixed(1)} KB, -${langPct}%)`); // ── 2. Clean stale minified output ── @@ -314,6 +321,7 @@ async function compile() { fs.writeFileSync(path.join(wwwDir, bundle.name), bundle.min); } fs.writeFileSync(path.join(wwwDir, appName), appMin); + fs.writeFileSync(path.join(wwwDir, inviteLinksName), inviteLinksMin); fs.writeFileSync(path.join(wwwDir, langName), langMin); // ── 3. Compile HTML templates ── @@ -652,6 +660,9 @@ async function compile() { html = html.replace(/(]*?\bsrc=")((?:\.\.\/)*\/?)app\.min\.js"/g, (m, before, prefix) => { return `${before}${prefix}${appName}" integrity="${appSRI}" crossorigin="anonymous"`; }); + html = html.replace(/(]*?\bsrc=")((?:\.\.\/)*\/?)invite-links\.min\.js"/g, (m, before, prefix) => { + return `${before}${prefix}${inviteLinksName}" integrity="${inviteLinksSRI}" crossorigin="anonymous"`; + }); html = html.replace(/(]*?\bsrc=")((?:\.\.\/)*\/?)lang-init\.min\.js"/g, (m, before, prefix) => { return `${before}${prefix}${langName}" integrity="${langSRI}" crossorigin="anonymous"`; }); diff --git a/website/join.html b/website/join.html index 6e8d09c..f85aa6f 100644 --- a/website/join.html +++ b/website/join.html @@ -203,6 +203,7 @@ + diff --git a/website/template.html b/website/template.html index 70e391c..93ec16c 100644 --- a/website/template.html +++ b/website/template.html @@ -1573,6 +1573,7 @@ + From f2eee6dc4156d0af824ea79d5151487997f22ef8 Mon Sep 17 00:00:00 2001 From: KoalaDev <6156589+Shik3i@users.noreply.github.com> Date: Wed, 15 Jul 2026 07:02:08 +0200 Subject: [PATCH 03/16] feat(chat): add stateless ciphertext relay --- docs/PROTOCOL.md | 37 ++++++++++++++++++++++++++++++- scripts/test-server-ws.mjs | 33 +++++++++++++++++++++++++++ server/chat.js | 24 ++++++++++++++++++++ server/chat.test.mjs | 43 ++++++++++++++++++++++++++++++++++++ server/index.js | 28 ++++++++++++++++++++++- server/rate-limiter.js | 21 ++++++++++++++++++ server/rate-limiter.test.mjs | 30 +++++++++++++++++++++++++ shared/constants.js | 6 ++++- 8 files changed, 219 insertions(+), 3 deletions(-) create mode 100644 server/chat.js create mode 100644 server/chat.test.mjs diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index b5d8f43..4a18209 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -82,13 +82,47 @@ Payload: "hostPeerId": "string or null", "controlMode": "everyone | host-only", "controllers": ["peerId"], - "capabilities": ["host-control", "co-host"] + "capabilities": ["host-control", "co-host", "chat"] } ``` `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"` in `room_data.capabilities`. Clients +must not infer support from another field. + +### `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 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) @@ -378,5 +412,6 @@ If sender and target are still in the same room, the relay emits: - `host-control` - `co-host` +- `chat` Clients should treat a missing or unknown capabilities list as unsupported. diff --git a/scripts/test-server-ws.mjs b/scripts/test-server-ws.mjs index 5c1186c..32ae992 100644 --- a/scripts/test-server-ws.mjs +++ b/scripts/test-server-ws.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { Buffer } from 'node:buffer'; import http from 'node:http'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; @@ -70,6 +71,38 @@ try { assert.equal(capEv, 'room_data'); assert.ok(Array.isArray(capData.capabilities) && capData.capabilities.includes('host-control'), 'ROOM_DATA advertises the host-control capability'); + assert.ok(capData.capabilities.includes('chat'), 'ROOM_DATA advertises the chat capability'); + assert.equal(capData.chatHistory, undefined, 'ROOM_DATA never contains chat history'); + close(); + resetConnectionRate(); + + // --- Encrypted chat is a live-only canonical relay --- + const chatRoom = 'chat-'+Date.now(); + const chat1 = await c(), chat2 = await c(); + await j(chat1, chatRoom, 'alice'); await j(chat2, chatRoom, 'bob'); + chat1._m.length = chat2._m.length = 0; + const ciphertext = Buffer.alloc(64, 7).toString('base64url'); + s(chat1, 'chat_message', { + ciphertext, + id: 'spoofed-id', senderId: 'mallory', timestamp: 1, text: 'plaintext' + }); + const [chat1Event, chat1Data] = await a(chat1); + const [chat2Event, chat2Data] = await a(chat2); + assert.equal(chat1Event, 'chat_message', 'sender receives canonical chat envelope'); + assert.equal(chat2Event, 'chat_message', 'peer receives canonical chat envelope'); + assert.deepEqual(chat2Data, chat1Data, 'all peers receive the same canonical envelope'); + assert.equal(chat1Data.senderId, 'alice', 'relay stamps senderId'); + assert.equal(chat1Data.ciphertext, ciphertext, 'relay preserves ciphertext'); + assert.equal(chat1Data.text, undefined, 'relay drops plaintext fields'); + assert.notEqual(chat1Data.id, 'spoofed-id', 'relay replaces client IDs'); + assert.ok(Number.isFinite(chat1Data.timestamp) && chat1Data.timestamp > 1, 'relay stamps timestamp'); + + const late = await c(); + s(late, 'join_room', { roomId: chatRoom, peerId: 'late', protocolVersion: '1.0.0' }); + const [lateEvent, lateRoomData] = await a(late); + assert.equal(lateEvent, 'room_data'); + assert.equal(lateRoomData.chatHistory, undefined, 'late joiner gets no chat backlog'); + assert.equal(late._m.some(raw => raw.includes('chat_message')), false, 'late joiner receives no old message'); close(); resetConnectionRate(); diff --git a/server/chat.js b/server/chat.js new file mode 100644 index 0000000..6792d24 --- /dev/null +++ b/server/chat.js @@ -0,0 +1,24 @@ +import { randomUUID } from 'node:crypto'; + +export const CHAT_CIPHERTEXT_MAX_BYTES = 2028; +export const CHAT_CIPHERTEXT_MIN_BYTES = 29; + +export function normalizeChatCiphertext(value) { + if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value) || value.length % 4 === 1) return null; + const bytes = Buffer.from(value, 'base64url'); + if (bytes.length < CHAT_CIPHERTEXT_MIN_BYTES || bytes.length > CHAT_CIPHERTEXT_MAX_BYTES) return null; + if (bytes.toString('base64url') !== value) return null; + return value; +} + +export function createChatEnvelope(data, senderId, now = Date.now, createId = randomUUID) { + if (!data || typeof data !== 'object' || typeof senderId !== 'string' || !senderId) return null; + const ciphertext = normalizeChatCiphertext(data.ciphertext); + if (!ciphertext) return null; + return { + id: createId(), + senderId, + timestamp: now(), + ciphertext + }; +} diff --git a/server/chat.test.mjs b/server/chat.test.mjs new file mode 100644 index 0000000..51907fb --- /dev/null +++ b/server/chat.test.mjs @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { Buffer } from 'node:buffer'; +import { + CHAT_CIPHERTEXT_MAX_BYTES, + createChatEnvelope, + normalizeChatCiphertext +} from './chat.js'; + +const encoded = (length) => Buffer.alloc(length, 7).toString('base64url'); + +describe('encrypted chat relay envelopes', () => { + it('accepts canonical base64url within the encrypted message bounds', () => { + expect(normalizeChatCiphertext(encoded(29))).toBe(encoded(29)); + expect(normalizeChatCiphertext(encoded(CHAT_CIPHERTEXT_MAX_BYTES))).toBe(encoded(CHAT_CIPHERTEXT_MAX_BYTES)); + }); + + it('rejects malformed, padded, too-small, and oversized payloads', () => { + expect(normalizeChatCiphertext(' **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..fe488f5 --- /dev/null +++ b/extension/chat-overlay-contract.test.mjs @@ -0,0 +1,58 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const extensionDir = path.dirname(new URL(import.meta.url).pathname); +const overlaySource = fs.readFileSync(path.join(extensionDir, 'chat-overlay.js'), 'utf8'); +const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8'); +const localeDir = path.join(extensionDir, 'locales'); +const chatKeys = [ + '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("#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)'); + }); + + 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..0c98950 --- /dev/null +++ b/extension/chat-overlay.js @@ -0,0 +1,467 @@ +(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 layout = { mode: 'right', x: 24, y: 72, width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }; + let themeMode = 'system'; + let themePalette = 'eucalyptus'; + + const host = document.createElement('div'); + host.id = 'koalasync-chat-overlay-host'; + host.style.cssText = 'all:initial;position:fixed;inset:0;z-index:2147483647;pointer-events:none;contain:layout style paint;transform:translateZ(0);'; + 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(:disabled) { border-color: var(--accent); transform: translateY(-1px); } + .launcher:disabled { cursor: not-allowed; opacity: .58; } + .panel { + position: fixed; display: none; flex-direction: column; overflow: hidden; + min-width: ${MIN_WIDTH}px; min-height: ${MIN_HEIGHT}px; 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; } + @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 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 textarea = element('textarea'); + const composerRow = element('div', 'composer-row'); + const count = element('span', 'count', '0/500'); + const sendButton = element('button', 'send'); + sendButton.type = 'submit'; + composerRow.append(count, sendButton); + const status = element('div', 'status'); + composer.append(textarea, composerRow, status); + panel.append(header, messages, composer); + app.append(launcher, panel); + shadow.append(style, app); + document.documentElement.append(host); + + function messageRuntime(payload) { + return new Promise(resolve => { + try { + chrome.runtime.sendMessage(payload, response => { + if (chrome.runtime.lastError) resolve(null); + else resolve(response || null); + }); + } catch (_) { + resolve(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 || ''); + launcher.title = context?.supported && !context?.hasKey ? (text.missingKey || '') : (text.open || ''); + panel.setAttribute('aria-label', text.title || ''); + textarea.placeholder = text.placeholder || ''; + 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(320, window.innerWidth), height: Math.max(360, window.innerHeight) }; + } + + function clampDetached() { + const viewport = viewportBounds(); + layout.width = Math.min(Math.max(Number(layout.width) || DEFAULT_WIDTH, MIN_WIDTH), Math.min(600, viewport.width - 16)); + layout.height = Math.min(Math.max(Number(layout.height) || DEFAULT_HEIGHT, MIN_HEIGHT), viewport.height - 16); + layout.x = Math.min(Math.max(Number(layout.x) || 8, 8), Math.max(8, viewport.width - layout.width - 8)); + layout.y = Math.min(Math.max(Number(layout.y) || 8, 8), Math.max(8, viewport.height - layout.height - 8)); + } + + 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 { + panel.style.top = '64px'; + panel.style.width = `${DEFAULT_WIDTH}px`; + panel.style.height = 'min(560px, calc(100vh - 80px))'; + panel.style[layout.mode] = '16px'; + launcher.style.top = 'calc(50vh - 24px)'; + launcher.style.left = layout.mode === 'left' ? '16px' : 'auto'; + launcher.style.right = layout.mode === 'right' ? '16px' : 'auto'; + } + globalThis.queueMicrotask(() => { applyingLayout = false; }); + } + + function setMode(mode) { + if (!['left', 'right', 'detached'].includes(mode)) return; + if (mode === 'detached' && layout.mode !== 'detached') { + 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.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 hasKey = !!context?.hasKey; + context = context ? { ...context, enabled: supported && hasKey } : null; + if (previousRoomId && previousRoomId !== context?.roomId) clearMessages(); + host.style.display = supported ? '' : 'none'; + launcher.disabled = !hasKey; + if (!supported || !hasKey) setOpened(false); + applyStrings(); + applyLayout(); + } + + async function refresh() { + if (destroyed) return; + applyContext(await messageRuntime({ type: 'GET_CHAT_CONTEXT' })); + } + + 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); + } + + 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(); + composer.requestSubmit(); + } + }); + composer.addEventListener('submit', async event => { + event.preventDefault(); + const text = textarea.value.trim(); + if (!text) return; + if ([...text].length > 500) { + status.textContent = strings().tooLong || ''; + return; + } + sendButton.disabled = true; + status.textContent = ''; + const response = await messageRuntime({ type: 'CHAT_SEND', text }); + sendButton.disabled = false; + if (response?.status === 'ok') { + 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(entries => { + if (applyingLayout || layout.mode !== 'detached') return; + const rect = entries[0]?.contentRect; + 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') refresh(); + if (message?.type === 'CHAT_RESET') { + clearMessages(); + refresh(); + } + if (message?.type === 'CHAT_DESTROY') destroy(); + } + + function destroy() { + if (destroyed) return; + destroyed = true; + 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] }; + themeMode = data.themeMode || 'system'; + themePalette = data.themePalette || 'eucalyptus'; + applyTheme(); + applyLayout(); + }); + + window.koalaSyncChatOverlay = { refresh, destroy }; + refresh(); +})(); diff --git a/extension/locales/de.json b/extension/locales/de.json index 6507be7..8f258da 100644 --- a/extension/locales/de.json +++ b/extension/locales/de.json @@ -243,5 +243,18 @@ "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.", + "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 25f5c04..0fb0ab3 100644 --- a/extension/locales/en.json +++ b/extension/locales/en.json @@ -243,5 +243,18 @@ "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.", + "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 624e419..c9c9da9 100644 --- a/extension/locales/es.json +++ b/extension/locales/es.json @@ -243,5 +243,18 @@ "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.", + "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 67e0408..ea6ee69 100644 --- a/extension/locales/fr.json +++ b/extension/locales/fr.json @@ -243,5 +243,18 @@ "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.", + "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 bb72c95..fec5034 100644 --- a/extension/locales/it.json +++ b/extension/locales/it.json @@ -243,5 +243,18 @@ "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.", + "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 70162a8..5c04171 100644 --- a/extension/locales/ja.json +++ b/extension/locales/ja.json @@ -243,5 +243,18 @@ "LABEL_SETTINGS_GROUP_PROFILE": "プロフィールと外観", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "ユーザー名、テーマ、言語設定を変更します。", "LABEL_SETTINGS_GROUP_SYNC": "再生と同期", - "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "再生、通知、オーディオ設定をカスタマイズします。" + "LABEL_SETTINGS_GROUP_SYNC_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 d40a320..25721a2 100644 --- a/extension/locales/ko.json +++ b/extension/locales/ko.json @@ -243,5 +243,18 @@ "LABEL_SETTINGS_GROUP_PROFILE": "프로필 및 테마", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "사용자 이름, 테마 및 언어 설정을 변경합니다.", "LABEL_SETTINGS_GROUP_SYNC": "재생 및 동기화", - "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "재생, 알림 및 오디오 설정을 맞춤설정합니다." + "LABEL_SETTINGS_GROUP_SYNC_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 dd2a201..ef78f36 100644 --- a/extension/locales/nl.json +++ b/extension/locales/nl.json @@ -243,5 +243,18 @@ "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.", + "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 dde3e3d..2d6964d 100644 --- a/extension/locales/pl.json +++ b/extension/locales/pl.json @@ -243,5 +243,18 @@ "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.", + "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 1d07782..9d75de4 100644 --- a/extension/locales/pt-BR.json +++ b/extension/locales/pt-BR.json @@ -243,5 +243,18 @@ "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.", + "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 b360401..3bc3b91 100644 --- a/extension/locales/pt.json +++ b/extension/locales/pt.json @@ -243,5 +243,18 @@ "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.", + "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 7aa0e35..b43dca0 100644 --- a/extension/locales/ru.json +++ b/extension/locales/ru.json @@ -243,5 +243,18 @@ "LABEL_SETTINGS_GROUP_PROFILE": "Профиль и Оформление", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Измените имя пользователя, тему и настройки языка.", "LABEL_SETTINGS_GROUP_SYNC": "Воспроизведение и Синхронизация", - "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Настройте воспроизведение, уведомления и параметры звука." + "LABEL_SETTINGS_GROUP_SYNC_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 b5b29da..f00a1b6 100644 --- a/extension/locales/tr.json +++ b/extension/locales/tr.json @@ -243,5 +243,18 @@ "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.", + "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 22aa7d5..13dbbdb 100644 --- a/extension/locales/uk.json +++ b/extension/locales/uk.json @@ -243,5 +243,18 @@ "LABEL_SETTINGS_GROUP_PROFILE": "Профіль та Вигляд", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Змініть ім'я користувача, тему та мовні налаштування.", "LABEL_SETTINGS_GROUP_SYNC": "Відтворення та Синхронізація", - "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Налаштуйте відтворення, сповіщення та параметри звуку." + "LABEL_SETTINGS_GROUP_SYNC_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 dda1b53..311bb0d 100644 --- a/extension/locales/zh.json +++ b/extension/locales/zh.json @@ -243,5 +243,18 @@ "LABEL_SETTINGS_GROUP_PROFILE": "个人资料与外观", "LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "更改您的用户名、主题和语言首选项。", "LABEL_SETTINGS_GROUP_SYNC": "播放与同步", - "LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "自定义播放、通知和音频首选项。" + "LABEL_SETTINGS_GROUP_SYNC_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": "暂无消息。聊天仅实时显示。" } From 36c4c492d3eed5bed74eaeaf4568e7191a1a56b0 Mon Sep 17 00:00:00 2001 From: KoalaDev <6156589+Shik3i@users.noreply.github.com> Date: Wed, 15 Jul 2026 07:31:27 +0200 Subject: [PATCH 06/16] fix(chat): preserve detached overlay geometry --- extension/chat-overlay-contract.test.mjs | 3 +++ extension/chat-overlay.js | 11 +++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/extension/chat-overlay-contract.test.mjs b/extension/chat-overlay-contract.test.mjs index fe488f5..8604c1c 100644 --- a/extension/chat-overlay-contract.test.mjs +++ b/extension/chat-overlay-contract.test.mjs @@ -37,6 +37,9 @@ describe('chat overlay contract', () => { 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'); diff --git a/extension/chat-overlay.js b/extension/chat-overlay.js index 0c98950..c6be649 100644 --- a/extension/chat-overlay.js +++ b/extension/chat-overlay.js @@ -16,7 +16,7 @@ let destroyed = false; let saveTimer = null; let applyingLayout = false; - let layout = { mode: 'right', x: 24, y: 72, width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }; + let layout = { mode: 'right', x: 24, y: 72, width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, detachedInitialized: false }; let themeMode = 'system'; let themePalette = 'eucalyptus'; @@ -265,9 +265,10 @@ function setMode(mode) { if (!['left', 'right', 'detached'].includes(mode)) return; - if (mode === 'detached' && layout.mode !== 'detached') { + 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(); @@ -388,9 +389,9 @@ saveLayout(); }); - const resizeObserver = new globalThis.ResizeObserver(entries => { + const resizeObserver = new globalThis.ResizeObserver(() => { if (applyingLayout || layout.mode !== 'detached') return; - const rect = entries[0]?.contentRect; + const rect = panel.getBoundingClientRect(); if (!opened || !rect?.width || !rect.height) return; layout.width = rect.width; layout.height = rect.height; @@ -456,6 +457,8 @@ 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(); From 9e0d2947581259b998e340505549746c214e8248 Mon Sep 17 00:00:00 2001 From: KoalaDev <6156589+Shik3i@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:40:27 +0200 Subject: [PATCH 07/16] fix(chat): harden client state and messaging --- extension/background.js | 267 +++++++++++++++-------- extension/chat-crypto.js | 42 ++-- extension/chat-crypto.test.mjs | 50 +++++ extension/chat-overlay-contract.test.mjs | 28 +++ extension/chat-overlay.js | 120 +++++++--- extension/chat-session.js | 58 +++++ extension/chat-session.test.mjs | 49 +++++ extension/popup.html | 2 +- extension/popup.js | 26 ++- 9 files changed, 504 insertions(+), 138 deletions(-) create mode 100644 extension/chat-session.js create mode 100644 extension/chat-session.test.mjs diff --git a/extension/background.js b/extension/background.js index 3cc6dbb..f2bf39c 100644 --- a/extension/background.js +++ b/extension/background.js @@ -6,6 +6,7 @@ import { applyTitlePrivacyToPayload, sanitizeSharedTitle, sanitizeTabTitle, norm 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 './page-api-seek-overrides.js'; // --- Uninstall URL Initialization --- @@ -82,7 +83,26 @@ let hostPeerId = null; // peerId of the room host (creator / // 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 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. @@ -377,10 +397,12 @@ async function getSettings() { 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, username, @@ -474,6 +496,7 @@ function forceDisconnect() { currentServerUrl = null; isConnecting = false; isNamespaceJoined = false; + invalidateChatSession(); isForceSyncInitiator = false; expectedAcksCount = 0; roomIdleSince = null; @@ -567,7 +590,7 @@ async function leaveRoomAfterIdleGrace(reason) { hcmDesynced: false }).catch(() => {}); chatSecretGuard = ''; - clearChatKeyCache(); + invalidateChatSession(); await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {}); addLog(reason, 'info'); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {}); @@ -709,6 +732,7 @@ async function connect() { socket.onclose = () => { isConnecting = false; isNamespaceJoined = false; + invalidateChatSession(); stopPing(); if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } @@ -790,6 +814,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(); } @@ -1045,6 +1070,7 @@ async 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; @@ -1131,42 +1157,54 @@ async function handleServerEvent(event, data) { break; case EVENTS.CHAT_MESSAGE: { if (!currentRoom || !serverSupports(CAPABILITIES.CHAT) || !currentTabId) break; - const settings = await getSettings(); - if (!settings.chatKey) break; - try { - const text = await decryptChatMessage({ - ciphertext: data.ciphertext, - roomId: currentRoom.roomId, - senderId: data.senderId, - secret: settings.chatKey - }); - const senderPeer = currentRoom.peers?.find(candidate => - (typeof candidate === 'object' ? candidate.peerId : candidate) === data.senderId - ); - const tabId = Number(currentTabId); - if (Number.isInteger(tabId)) { - chrome.tabs.sendMessage(tabId, { - type: 'CHAT_MESSAGE', - message: { - id: data.id, - senderId: data.senderId, - username: typeof senderPeer === 'object' ? senderPeer.username : null, - timestamp: data.timestamp, - text - } - }).catch(() => {}); + 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.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'); } - } catch (_) { - 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; @@ -1910,7 +1948,7 @@ function leaveOldRoomIfSwitching(newRoomId) { hostPeerId = null; controllers = []; serverCapabilities = []; - clearChatKeyCache(); + 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. @@ -1952,21 +1990,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)) 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(() => {}); @@ -2053,6 +2103,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { sendResponse({ supported: serverSupports(CAPABILITIES.CHAT), hasKey: !!settings.chatKey, + connected: !!(socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined), peerId, roomId: currentRoom.roomId, strings: { @@ -2082,18 +2133,39 @@ async function handleAsyncMessage(message, sender, sendResponse) { 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.chatKey) { - sendResponse({ status: 'missing_key' }); + 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: currentRoom.roomId, + roomId, senderId: peerId, - secret: settings.chatKey + 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) { @@ -2102,7 +2174,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { } else if (message.type === 'CREATE_CHAT_KEY') { const chatKey = generateChatSecret(); chatSecretGuard = chatKey; - clearChatKeyCache(); + invalidateChatSession(); await chrome.storage.local.set({ chatKey }); sendResponse({ status: 'ok', chatKey }); } else if (message.type === 'SET_CONTROL_MODE') { @@ -2174,6 +2246,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(); connectIntent = false; reconnectFailed = false; reconnectAttempts = 0; @@ -2219,7 +2292,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { hcmDesynced: false }); chatSecretGuard = ''; - clearChatKeyCache(); + invalidateChatSession(); chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {}); addLog('Left Room', 'info'); chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {}); @@ -2237,7 +2310,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { sendResponse({ status: 'ok' }); } else if (message.type === 'WEB_JOIN_REQUEST') { const { roomId: rawRoomId, password, chatKey: rawChatKey, useCustomServer, serverUrl } = message; - const roomId = typeof rawRoomId === 'string' ? rawRoomId.replace(/[^a-zA-Z0-9\-]/g, '') : ''; + const roomId = normalizeRoomId(rawRoomId); const chatKey = validateChatSecret(rawChatKey); if (!roomId) { const errMsg = { type: 'JOIN_STATUS', success: false, message: 'Invalid room ID' }; @@ -2248,53 +2321,77 @@ async function handleAsyncMessage(message, sender, sendResponse) { sendResponse({ status: 'invalid_room_id' }); return; } - connectIntent = true; - chrome.storage.local.set({ - roomId, - password, - chatKey, - useCustomServer: !!useCustomServer, - serverUrl: serverUrl || '' - }, async () => { - chatSecretGuard = chatKey; - clearChatKeyCache(); - const settings = await getSettings(); - 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)); - 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, + 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. diff --git a/extension/chat-crypto.js b/extension/chat-crypto.js index c06443d..9129884 100644 --- a/extension/chat-crypto.js +++ b/extension/chat-crypto.js @@ -4,9 +4,10 @@ const IV_BYTES = 12; const MIN_ENCRYPTED_BYTES = 29; const MAX_ENCRYPTED_BYTES = 2028; -let cachedKey = null; +let cachedKeyPromise = null; let cachedRoomId = ''; let cachedSecret = ''; +let cacheGeneration = 0; function bytesToBase64Url(bytes) { let binary = ''; @@ -50,7 +51,8 @@ export function normalizeOutgoingChatText(value) { } export function clearChatKeyCache() { - cachedKey = null; + cacheGeneration++; + cachedKeyPromise = null; cachedRoomId = ''; cachedSecret = ''; } @@ -58,21 +60,30 @@ export function clearChatKeyCache() { 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 (cachedKey && cachedRoomId === roomId && cachedSecret === validSecret) return cachedKey; + if (cachedKeyPromise && cachedRoomId === roomId && cachedSecret === validSecret) return cachedKeyPromise; const encoder = new globalThis.TextEncoder(); - const material = await cryptoImpl.subtle.importKey( - 'raw', base64UrlToBytes(validSecret), { name: 'HKDF' }, false, ['deriveKey'] - ); - cachedKey = await 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']); + 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; - return cachedKey; + 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) { @@ -106,7 +117,6 @@ export async function decryptChatMessage({ ciphertext, roomId, senderId, secret iv: bytes.slice(0, IV_BYTES), additionalData: encoder.encode(`${roomId}|${senderId}`) }, key, bytes.slice(IV_BYTES)); - const text = new globalThis.TextDecoder().decode(plaintext); - if (!text || countCodePoints(text) > 500) throw new RangeError('Decrypted chat message exceeds client limits'); - return text; + 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 index e5c6caa..a2e205d 100644 --- a/extension/chat-crypto.test.mjs +++ b/extension/chat-crypto.test.mjs @@ -35,6 +35,33 @@ describe('chat crypto', () => { 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); @@ -61,4 +88,27 @@ describe('chat crypto', () => { 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-overlay-contract.test.mjs b/extension/chat-overlay-contract.test.mjs index 8604c1c..9f093f4 100644 --- a/extension/chat-overlay-contract.test.mjs +++ b/extension/chat-overlay-contract.test.mjs @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'; const extensionDir = path.dirname(new URL(import.meta.url).pathname); 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 = [ 'CHAT_TITLE', @@ -44,6 +45,33 @@ describe('chat overlay contract', () => { 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 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', () => { diff --git a/extension/chat-overlay.js b/extension/chat-overlay.js index c6be649..d2e7ed2 100644 --- a/extension/chat-overlay.js +++ b/extension/chat-overlay.js @@ -16,6 +16,9 @@ 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'; @@ -85,11 +88,11 @@ 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(:disabled) { border-color: var(--accent); transform: translateY(-1px); } - .launcher:disabled { cursor: not-allowed; opacity: .58; } + .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_WIDTH}px; min-height: ${MIN_HEIGHT}px; max-width: calc(100vw - 16px); + 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); @@ -120,6 +123,7 @@ .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; } } `; @@ -135,6 +139,8 @@ 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'); @@ -155,28 +161,43 @@ 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'); - composer.append(textarea, composerRow, status); + status.id = 'chat-composer-status'; + status.setAttribute('role', 'status'); + composer.append(textareaLabel, textarea, composerRow, status); panel.append(header, messages, composer); - app.append(launcher, panel); + app.append(launcherHint, launcher, panel); shadow.append(style, app); document.documentElement.append(host); - function messageRuntime(payload) { + 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) resolve(null); - else resolve(response || null); + if (chrome.runtime.lastError) finish(null); + else finish(response); }); } catch (_) { - resolve(null); + finish(null); } }); } @@ -190,9 +211,16 @@ title.textContent = text.title || ''; subtitle.textContent = text.liveOnly || ''; launcher.setAttribute('aria-label', text.open || ''); - launcher.title = context?.supported && !context?.hasKey ? (text.missingKey || '') : (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 || ''; @@ -212,15 +240,19 @@ } function viewportBounds() { - return { width: Math.max(320, window.innerWidth), height: Math.max(360, window.innerHeight) }; + return { width: Math.max(1, window.innerWidth), height: Math.max(1, window.innerHeight) }; } function clampDetached() { const viewport = viewportBounds(); - layout.width = Math.min(Math.max(Number(layout.width) || DEFAULT_WIDTH, MIN_WIDTH), Math.min(600, viewport.width - 16)); - layout.height = Math.min(Math.max(Number(layout.height) || DEFAULT_HEIGHT, MIN_HEIGHT), viewport.height - 16); - layout.x = Math.min(Math.max(Number(layout.x) || 8, 8), Math.max(8, viewport.width - layout.width - 8)); - layout.y = Math.min(Math.max(Number(layout.y) || 8, 8), Math.max(8, viewport.height - layout.height - 8)); + 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() { @@ -252,13 +284,16 @@ launcher.style.right = 'auto'; launcher.style.top = `${layout.y}px`; } else { - panel.style.top = '64px'; - panel.style.width = `${DEFAULT_WIDTH}px`; - panel.style.height = 'min(560px, calc(100vh - 80px))'; - panel.style[layout.mode] = '16px'; - launcher.style.top = 'calc(50vh - 24px)'; - launcher.style.left = layout.mode === 'left' ? '16px' : 'auto'; - launcher.style.right = layout.mode === 'right' ? '16px' : 'auto'; + 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; }); } @@ -290,18 +325,22 @@ context = next || null; const supported = !!context?.supported; const hasKey = !!context?.hasKey; - context = context ? { ...context, enabled: supported && hasKey } : null; + const connected = !!context?.connected; + context = context ? { ...context, enabled: supported && hasKey && connected } : null; if (previousRoomId && previousRoomId !== context?.roomId) clearMessages(); host.style.display = supported ? '' : 'none'; - launcher.disabled = !hasKey; - if (!supported || !hasKey) setOpened(false); + launcher.setAttribute('aria-disabled', String(!context?.enabled)); + if (!context?.enabled) setOpened(false); applyStrings(); applyLayout(); } async function refresh() { if (destroyed) return; - applyContext(await messageRuntime({ type: 'GET_CHAT_CONTEXT' })); + const generation = ++refreshGeneration; + const next = await messageRuntime({ type: 'GET_CHAT_CONTEXT' }); + if (destroyed || generation !== refreshGeneration) return; + applyContext(next); } function appendMessage(message) { @@ -332,6 +371,16 @@ 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')); @@ -347,24 +396,32 @@ textarea.addEventListener('keydown', event => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); - composer.requestSubmit(); + 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') { - textarea.value = ''; - count.textContent = '0/500'; + if (textarea.value === submittedValue) { + textarea.value = ''; + count.textContent = '0/500'; + } } else { status.textContent = response?.status === 'too_long' ? (strings().tooLong || '') : (strings().sendFailed || ''); } @@ -428,9 +485,10 @@ function handleRuntime(message) { if (message?.type === 'CHAT_MESSAGE') appendMessage(message.message); - if (message?.type === 'CHAT_CONTEXT_UPDATE') refresh(); + 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(); @@ -439,6 +497,8 @@ function destroy() { if (destroyed) return; destroyed = true; + refreshGeneration++; + sendGeneration++; if (saveTimer) clearTimeout(saveTimer); resizeObserver.disconnect(); document.removeEventListener('fullscreenchange', moveIntoFullscreen); 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/popup.html b/extension/popup.html index 33db0ff..7077b24 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -1353,7 +1353,7 @@
- +
diff --git a/extension/popup.js b/extension/popup.js index 006add2..2633ced 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -3,10 +3,12 @@ import { BLACKLIST_DOMAINS } from './shared/blacklist.js'; import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from './shared/names.js'; import { loadLocale, translateDOM, getMessage, getSystemLanguage } from './i18n.js'; import { TITLE_PRIVACY_MODES, normalizeSendTabTitle, normalizeTabTitle } from './title-privacy.js'; +import { normalizeRoomId } from './chat-session.js'; import './shared/invite-links.js'; let pendingInviteRoomId = ''; let pendingInviteChatKey = ''; +let pendingRoomCreation = false; function normalizeChatKey(value) { return typeof value === 'string' && /^[A-Za-z0-9_-]{21}[AQgw]$/.test(value) ? value : ''; @@ -356,7 +358,7 @@ async function init() { } elements.serverUrl.value = localData.serverUrl || ''; - elements.roomId.value = localData.roomId || ''; + elements.roomId.value = normalizeRoomId(localData.roomId); elements.password.value = localData.password || ''; elements.username.value = username; syncDevToolsVisibility(); @@ -1305,9 +1307,9 @@ function checkInviteLink() { try { const invite = globalThis.KoalaSyncInviteLinks.parseInviteHash(new URL(tab.url).hash); if (!invite) return; - elements.roomId.value = invite.roomId; + elements.roomId.value = normalizeRoomId(invite.roomId); elements.password.value = invite.password; - pendingInviteRoomId = invite.roomId; + pendingInviteRoomId = elements.roomId.value; pendingInviteChatKey = normalizeChatKey(invite.chatKey); elements.serverUrl.value = invite.serverUrl; @@ -1596,7 +1598,8 @@ function showError(msg) { // --- Action Handlers --- elements.roomId.addEventListener('input', () => { - elements.roomId.value = elements.roomId.value.replace(/[^a-zA-Z0-9\-]/g, ''); + elements.roomId.value = normalizeRoomId(elements.roomId.value); + pendingRoomCreation = false; if (pendingInviteRoomId && elements.roomId.value !== pendingInviteRoomId) { pendingInviteRoomId = ''; pendingInviteChatKey = ''; @@ -1611,8 +1614,8 @@ elements.joinBtn.addEventListener('click', async () => { isProcessingConnection = false; return; } - const roomIdInput = elements.roomId.value.trim(); - const isCreating = !roomIdInput; + const roomIdInput = normalizeRoomId(elements.roomId.value); + const isCreating = pendingRoomCreation || !roomIdInput; elements.joinBtn.disabled = true; elements.joinBtn.textContent = isCreating ? getMessage('BTN_STATE_CREATING') : getMessage('BTN_STATE_JOINING'); @@ -1677,10 +1680,20 @@ elements.joinBtn.addEventListener('click', async () => { if (isCreating) { const created = await new Promise(resolve => chrome.runtime.sendMessage({ type: 'CREATE_CHAT_KEY' }, resolve)); chatKey = normalizeChatKey(created?.chatKey); + if (!chatKey) { + pendingRoomCreation = false; + elements.joinBtn.disabled = false; + elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM'); + if (joinBtnTimeout) { clearTimeout(joinBtnTimeout); joinBtnTimeout = null; } + isProcessingConnection = false; + showError(getMessage('CHAT_SEND_FAILED')); + return; + } } if (isCreating) window.justCreatedRoom = true; pendingInviteRoomId = ''; pendingInviteChatKey = ''; + pendingRoomCreation = false; await chrome.storage.local.set({ serverUrl, roomId, password, chatKey, useCustomServer: useCustom }); elements.roomId.value = roomId; @@ -1723,6 +1736,7 @@ function handleCreateRoom() { const password = secureGenerateId(); elements.roomId.value = roomId; elements.password.value = password; + pendingRoomCreation = true; window.justCreatedRoom = true; // Auto-connect From 354500873c9daae434fa0ce660e1c02e3d01aaec Mon Sep 17 00:00:00 2001 From: KoalaDev <6156589+Shik3i@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:05:26 +0200 Subject: [PATCH 08/16] fix: keep llms release version in sync --- .github/workflows/release.yml | 8 ++++++-- scripts/test-website-theme.mjs | 4 ++++ website/assets/NewLogoIcon_64.webp | Bin 1282 -> 1282 bytes website/llms.txt | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a64c5af..afce636 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -109,7 +109,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 +124,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/scripts/test-website-theme.mjs b/scripts/test-website-theme.mjs index 8c02b38..8d20604 100644 --- a/scripts/test-website-theme.mjs +++ b/scripts/test-website-theme.mjs @@ -17,6 +17,7 @@ const germanImprintPage = fs.readFileSync(path.join(repoRoot, 'website', 'impres const alternativesIndex = fs.readFileSync(path.join(repoRoot, 'website', 'alternatives', 'index.html'), 'utf8'); const alternativesCss = fs.readFileSync(path.join(repoRoot, 'website', 'styles', 'alternatives.css'), 'utf8'); const llmsText = fs.readFileSync(path.join(repoRoot, 'website', 'llms.txt'), 'utf8'); +const websiteVersion = JSON.parse(fs.readFileSync(path.join(repoRoot, 'website', 'version.json'), 'utf8')).version; const mockupStart = template.indexOf('
'); const mockupEnd = template.indexOf('
new URL(value)); const hasCanonicalPublicRelay = documentedRelayUrls.some((url) => ( url.protocol === 'wss:' && diff --git a/website/assets/NewLogoIcon_64.webp b/website/assets/NewLogoIcon_64.webp index b1f4e43ede2c8f7304360593b419303eb296c0d2..660ffe28987821948744dafb8543121d3c9924ee 100644 GIT binary patch delta 175 zcmV;g08sye3W5r-j{*uRbh!h)p{C}lShJY|(*b{`0tbHB-=W1lvs8JmTg?5P$6NU| zO#PNrF}c9Ck3x?#EFIFIEEWqqr&MVE&)R*f)BkMkZ`0M?cCguvrw7XYD@K>HoHNx9RHcyI5?-@+?sg|AU>&-G4md zD$=Kt1fjo2eO@GCUGUCe6T6+N5TM#$rGCu%%0C_&{K4-ZrketIuEE5-AGFN%Jt!V_ dA-)%z8o>$A#3xK?o>SG_`v#;TD2(v9006+KRrvq_ diff --git a/website/llms.txt b/website/llms.txt index 001daf9..e22d065 100644 --- a/website/llms.txt +++ b/website/llms.txt @@ -78,7 +78,7 @@ Compatibility depends on each website's player implementation and can change whe ## Technical information -- Current website release: 2.5.4 +- Current website release: 2.6.1 - License: MIT - Extension runtime: dependency-free browser extension code - Relay: Node.js with Socket.IO-compatible WebSocket messaging From 630869260b135c325915feb82c86aea45fa91c42 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Sun, 19 Jul 2026 20:43:11 +0200 Subject: [PATCH 09/16] ci: publish text chat server beta --- .github/workflows/beta-server-image.yml | 27 +++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/.github/workflows/beta-server-image.yml b/.github/workflows/beta-server-image.yml index 25ae743..2ad6f9d 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: '' @@ -38,6 +38,25 @@ jobs: - name: Checkout code uses: actions/checkout@v7 + - 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 From 8d7085c97e8dea60b9540c63b5dd43ec70623d1a Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:10:50 +0200 Subject: [PATCH 10/16] fix: hide access banner on smaller screens --- website/styles/foundation.css | 26 ++------------------------ 1 file changed, 2 insertions(+), 24 deletions(-) diff --git a/website/styles/foundation.css b/website/styles/foundation.css index 78638d4..292d3b7 100644 --- a/website/styles/foundation.css +++ b/website/styles/foundation.css @@ -174,31 +174,9 @@ nav.nav-scrolled + .site-access-banner { color: var(--text); } -@media (max-width: 768px) { +@media (max-width: 1024px) { .site-access-banner { - top: 73px; - } - - nav.nav-scrolled + .site-access-banner { - top: 59px; - } - - .site-access-banner-inner { - min-height: 64px; - padding: 0.65rem 3rem 0.65rem 1rem; - gap: 0.5rem; - flex-wrap: wrap; - font-size: 0.8rem; - } - - .site-access-banner-icon { - width: 1.3rem; - height: 1.3rem; - font-size: 0.75rem; - } - - .site-access-banner-dismiss { - right: 0.75rem; + display: none; } } From 4882e058f10b28df16407bf454e531abc80be13b Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Sun, 19 Jul 2026 21:21:49 +0200 Subject: [PATCH 11/16] fix: keep support SEO and sitemap metadata accurate --- scripts/test-website-theme.mjs | 20 ++++++++++++++++++++ website/build.cjs | 10 +--------- website/site-access-help.html | 24 ++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/scripts/test-website-theme.mjs b/scripts/test-website-theme.mjs index 8d20604..c7dd168 100644 --- a/scripts/test-website-theme.mjs +++ b/scripts/test-website-theme.mjs @@ -12,6 +12,8 @@ const demoCss = fs.readFileSync(path.join(repoRoot, 'website', 'styles', 'demo.c const landingPrimaryCss = fs.readFileSync(path.join(repoRoot, 'website', 'styles', 'landing-primary.css'), 'utf8'); const legalCss = fs.readFileSync(path.join(repoRoot, 'website', 'styles', 'legal.css'), 'utf8'); const joinPage = fs.readFileSync(path.join(repoRoot, 'website', 'join.html'), 'utf8'); +const siteAccessHelpPage = fs.readFileSync(path.join(repoRoot, 'website', 'site-access-help.html'), 'utf8'); +const websiteBuild = fs.readFileSync(path.join(repoRoot, 'website', 'build.cjs'), 'utf8'); const imprintPage = fs.readFileSync(path.join(repoRoot, 'website', 'imprint.html'), 'utf8'); const germanImprintPage = fs.readFileSync(path.join(repoRoot, 'website', 'impressum-de.html'), 'utf8'); const alternativesIndex = fs.readFileSync(path.join(repoRoot, 'website', 'alternatives', 'index.html'), 'utf8'); @@ -67,6 +69,23 @@ if (/\bWebRTC\b|\bport 54000\b|peer-to-peer by design/i.test(llmsText)) { if (!llmsText.includes(`Current website release: ${websiteVersion}`)) { throw new Error(`llms.txt release must match website/version.json (${websiteVersion})`); } +const requiredSupportSocialMetadata = [ + 'name="twitter:card" content="summary_large_image"', + 'name="twitter:title"', + 'name="twitter:description"', + 'name="twitter:image"', + '"datePublished"', + '"dateModified"', + '"mainEntityOfPage"' +]; +for (const metadata of requiredSupportSocialMetadata) { + if (!siteAccessHelpPage.includes(metadata)) { + throw new Error(`site-access-help.html is missing SEO metadata: ${metadata}`); + } +} +if (//.test(websiteBuild) || /const today = new Date\(\)/.test(websiteBuild)) { + throw new Error('Sitemap must not claim synthetic modification dates for every URL'); +} const documentedRelayUrls = [...llmsText.matchAll(/`(wss:\/\/[^`\s]+)`/g)].map(([, value]) => new URL(value)); const hasCanonicalPublicRelay = documentedRelayUrls.some((url) => ( url.protocol === 'wss:' && @@ -180,6 +199,7 @@ if (!/animation:\s*none\s*!important/.test(reducedMotionRule)) { console.log('Extension mockup theme-sensitive text uses theme-aware colors'); console.log('Landing CSS is render-blocking, single-request, and cascade-stable'); console.log('Landing head discovers a complete and architecture-accurate llms.txt'); +console.log('Support-page social metadata and sitemap dates remain truthful'); console.log('Website self-hosting examples include production secrets and consistent networking'); console.log('Foreground film birds use complete, always-on wing and glide animations'); console.log('All Getting Started mockups define explicit light-theme surfaces'); diff --git a/website/build.cjs b/website/build.cjs index 2e7c2af..05155e9 100644 --- a/website/build.cjs +++ b/website/build.cjs @@ -716,8 +716,6 @@ function generateSitemap(wwwDir) { { code: 'pt', prefix: 'pt/', hreflang: 'pt' } ]; - const today = new Date().toISOString().split('T')[0]; - let xml = ` `; @@ -726,31 +724,26 @@ function generateSitemap(wwwDir) { xml += ` https://sync.koalastuff.net/imprint - ${today} monthly 0.3 https://sync.koalastuff.net/privacy - ${today} monthly 0.3 https://sync.koalastuff.net/site-access-help.html - ${today} weekly 0.8 https://sync.koalastuff.net/de/impressum - ${today} monthly 0.3 https://sync.koalastuff.net/de/datenschutz - ${today} monthly 0.3 `; @@ -761,7 +754,6 @@ function generateSitemap(wwwDir) { xml += ` ${loc} - ${today} ${changefreq} ${priority}`; for (const alt of languages) { @@ -794,6 +786,6 @@ function generateSitemap(wwwDir) { xml += `\n\n`; fs.writeFileSync(path.join(wwwDir, 'sitemap.xml'), xml.trim() + '\n', 'utf8'); - console.log(' ✓ Dynamically generated sitemap.xml with current date'); + console.log(' ✓ Dynamically generated sitemap.xml without synthetic modification dates'); } compile().catch(err => { console.error('Build failed:', err); process.exit(1); }); diff --git a/website/site-access-help.html b/website/site-access-help.html index 40570e3..86a738c 100644 --- a/website/site-access-help.html +++ b/website/site-access-help.html @@ -17,6 +17,14 @@ + + + + + + + + - - - - - - - - - + + + Impressum | KoalaSync + + + + -