mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 20:18:14 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 79715046a8 | |||
| 2ae79386a0 | |||
| 883e49433e | |||
| 59135b319e | |||
| ae2238e3bc | |||
| 77790a279c | |||
| 2df048621b | |||
| 17c3ea295a | |||
| 5d3cee4271 | |||
| 1149de7ab7 | |||
| 4882e058f1 | |||
| 8d7085c97e | |||
| 630869260b | |||
| e9d735cd39 | |||
| aee3588f43 | |||
| 7f898e6eb4 | |||
| d2d4f5f3ea | |||
| 6e625e3a38 | |||
| 354500873c | |||
| 9e0d294758 | |||
| 36c4c492d3 | |||
| e9dc299fc9 | |||
| ee0d68d8d3 | |||
| f2eee6dc41 | |||
| 8fee98834c | |||
| 771da1cf82 |
@@ -6,9 +6,9 @@ name: Beta Server Image
|
||||
#
|
||||
# Tags produced (on ghcr.io/<owner>/<repo>):
|
||||
# - beta moving channel pointer to the newest build
|
||||
# - <branch-slug> e.g. feature-host-control-mode
|
||||
# - <branch-slug> e.g. feature-textchat
|
||||
# - sha-<short-commit> immutable, pin to an exact build
|
||||
# - <custom> only on manual run, e.g. hostcontrol01
|
||||
# - <custom> only on manual run, e.g. chatbeta01
|
||||
# Never ':latest' (flavor: latest=false).
|
||||
#
|
||||
# Remove this workflow once the feature is merged & released the normal way.
|
||||
@@ -16,11 +16,11 @@ name: Beta Server Image
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- feature/host-control-mode
|
||||
- feature/textchat
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Extra immutable tag for this build (e.g. hostcontrol01). Optional.'
|
||||
description: 'Extra immutable tag for this build (e.g. chatbeta01). Optional.'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
@@ -37,6 +37,27 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
package-lock.json
|
||||
server/package-lock.json
|
||||
|
||||
- name: Install root dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install server dependencies
|
||||
run: npm ci
|
||||
working-directory: server
|
||||
|
||||
- name: Run verification suite
|
||||
run: npm run verify
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
@@ -23,6 +23,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -72,6 +72,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -109,7 +111,11 @@ jobs:
|
||||
sed -i "s/\"softwareVersion\": \".*\"/\"softwareVersion\": \"$VERSION\"/" website/template.html
|
||||
echo " ✓ website/template.html -> softwareVersion $VERSION"
|
||||
|
||||
# 6. README.md — version badge & banner
|
||||
# 6. website/llms.txt — machine-readable release metadata
|
||||
sed -i "s/Current website release: .*/Current website release: $VERSION/" website/llms.txt
|
||||
echo " ✓ website/llms.txt -> $VERSION"
|
||||
|
||||
# 7. README.md — version badge & banner
|
||||
sed -i "s|Release-v[0-9]\+\.[0-9]\+\.[0-9]\+-blue|Release-v$VERSION-blue|g" README.md
|
||||
sed -i "s/New v[0-9]\+\.[0-9]\+\.[0-9]\+ Release/New v$VERSION Release/g" README.md
|
||||
echo " ✓ README.md -> v$VERSION"
|
||||
@@ -120,7 +126,7 @@ jobs:
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add extension/manifest.base.json shared/constants.js package.json website/version.json website/template.html README.md
|
||||
git add extension/manifest.base.json shared/constants.js package.json website/version.json website/template.html website/llms.txt README.md
|
||||
git commit -m "chore(release): update versions to $GITHUB_REF_NAME [skip ci]" || echo "No changes to commit"
|
||||
git push origin HEAD:main
|
||||
env:
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ Please note that by participating in this project, you agree to abide by our [Co
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js** v18+
|
||||
- **Node.js** v20.9+
|
||||
- **Docker** (for local relay server testing)
|
||||
|
||||
### Quick Start
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml"><img src="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml/badge.svg" alt="Release Status"></a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v2.6.3-blue?logo=github" alt="GitHub release"></a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v2.6.4-blue?logo=github" alt="GitHub release"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue" alt="License"></a>
|
||||
<a href="https://addons.mozilla.org/de/firefox/addon/koalasync/"><img src="https://img.shields.io/badge/Firefox-Download-orange?logo=firefoxbrowser&logoColor=white" alt="Firefox Add-on"></a>
|
||||
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc"><img src="https://img.shields.io/badge/Chrome-Download-blue?logo=googlechrome&logoColor=white" alt="Chrome Extension"></a>
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<p align="center"><i>KoalaSync is a lightweight Browser Extension and Relay Server for synchronized video playback on almost any website with a video element—YouTube, Twitch, Netflix, Emby, Jellyfin, and beyond. Built with a focus on <b>Data Sovereignty</b> and <b>Performance</b>.</i></p>
|
||||
|
||||
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.6.3 Release!</b> — See what's changed</a></p>
|
||||
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.6.4 Release!</b> — See what's changed</a></p>
|
||||
|
||||
### 🌟 Why KoalaSync?
|
||||
|
||||
|
||||
@@ -4,6 +4,15 @@ All notable changes to the KoalaSync browser extension and relay server.
|
||||
|
||||
---
|
||||
|
||||
## [v2.6.4] — 2026-07-16
|
||||
|
||||
### Changed
|
||||
- **Extension: Localized store metadata** — Renamed the public extension title to `Watch Party - KoalaSync` and added natural, store-ready descriptions for all 15 supported locales while preserving regional language variants.
|
||||
- **Extension: Locale-based manifest metadata** — The extension name and description now resolve through Chrome/Firefox `_locales` messages instead of being hardcoded in the manifest.
|
||||
|
||||
### Fixed
|
||||
- **Build: Extension metadata validation** — Extension builds now fail clearly for invalid locale JSON, missing metadata, an incorrect extension title, or store titles and descriptions that exceed their character limits.
|
||||
|
||||
## [v2.6.3] — 2026-07-15
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# Ephemeral end-to-end encrypted chat
|
||||
|
||||
KoalaSync chat is an optional, live-only text channel rendered on the selected
|
||||
streaming tab. It is not a messenger and does not add chat UI to the extension
|
||||
popup.
|
||||
|
||||
## Security boundary
|
||||
|
||||
- The relay receives ciphertext only and never receives the chat secret.
|
||||
- The relay stores no messages in RAM or on disk and sends no backlog.
|
||||
- A late joiner sees only messages sent after joining.
|
||||
- Room authentication is unchanged. The room password remains separate from the
|
||||
chat secret and continues to be sent to the relay during `join_room`.
|
||||
- Message timing and ciphertext length remain visible to the relay. Same-room
|
||||
replay is accepted by the threat model.
|
||||
|
||||
## Invite format and key lifecycle
|
||||
|
||||
New invitations use a named fragment format:
|
||||
|
||||
```text
|
||||
#j2:r=<roomId>&p=<password>&k=<base64url-secret>[&u=<relayUrl>]
|
||||
```
|
||||
|
||||
The room creator generates 16 random bytes and encodes them as 22 unpadded
|
||||
base64url characters. URL fragments are not sent in HTTP requests. The website
|
||||
parses the fragment and passes structured fields through `bridge.js` to the
|
||||
extension. `chatKey` must never be included in a relay event.
|
||||
|
||||
Legacy `#join:` links remain supported and join normally without chat. Manual
|
||||
room/password entry also joins without chat. The extension clears any prior chat
|
||||
secret when joining without a key, switching rooms, or leaving.
|
||||
|
||||
Deployment order is website first, extension second. An old extension ignores the
|
||||
additional structured `chatKey` field and continues joining normally.
|
||||
|
||||
## Cryptography
|
||||
|
||||
- Secret: 16 cryptographically random bytes.
|
||||
- KDF: HKDF-SHA256 with `roomId` as salt and a fixed KoalaSync chat info label.
|
||||
- Encryption: AES-256-GCM using WebCrypto.
|
||||
- IV: fresh random 12-byte value for every message, prepended to the ciphertext.
|
||||
- AAD: `${roomId}|${senderId}`.
|
||||
- The derived `CryptoKey` is cached once per active room.
|
||||
|
||||
The relay stamps `id`, `senderId`, and `timestamp` on the ciphertext envelope.
|
||||
Changing `senderId` causes AES-GCM authentication to fail because the receiver uses
|
||||
the stamped value as AAD.
|
||||
|
||||
## Client policy
|
||||
|
||||
- Maximum plaintext length: 500 Unicode code points.
|
||||
- Decrypted text is untrusted. Escape HTML before applying the supported limited
|
||||
Markdown formatting.
|
||||
- No read receipts and no typing indicators.
|
||||
- The local message DOM is bounded; this is presentation state, not server history.
|
||||
|
||||
## Overlay behavior
|
||||
|
||||
- Default dock: right.
|
||||
- Live modes: right, left, and detached.
|
||||
- Detached mode is draggable and moderately resizable. Size and position are stored
|
||||
per origin and clamped to the current viewport.
|
||||
- The overlay uses a Shadow DOM and never changes host-page layout.
|
||||
- On `fullscreenchange`, its host moves into `document.fullscreenElement` and remains
|
||||
visible.
|
||||
- It follows all eucalyptus, cyber, and graphite light/dark theme combinations.
|
||||
- Without a key, the panel stays closed and a disabled chat control explains that a
|
||||
current invite link is required.
|
||||
- Chat display is a local option and defaults to off. Enabling or disabling it never
|
||||
deletes the room chat secret, so it can be enabled later without creating a room.
|
||||
- Without the relay `chat` capability, no chat control is shown.
|
||||
|
||||
## Mixed-version rollout
|
||||
|
||||
- New extensions announce `chat-v1` in `join_room.clientCapabilities`.
|
||||
- Old non-chat extensions omit the optional field and receive no `chat_message`
|
||||
events. Their playback and room protocol remains unchanged.
|
||||
- The first chat beta omitted the capability. When it sends one valid v1 chat
|
||||
frame, the relay treats that socket as chat-capable for the rest of the
|
||||
connection.
|
||||
- New extensions accept the first beta's unversioned server `chat` capability as
|
||||
well as `chat-v1`, so a server-first or extension-first rollout degrades safely.
|
||||
|
||||
Peer removal and role management are outside chat scope.
|
||||
+60
-1
@@ -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=<roomId>&p=<password>&k=<base64url-chat-secret>[&u=<relayUrl>]
|
||||
```
|
||||
|
||||
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:<roomId>:<password>[:1:<relayUrl>]` fragments remain valid but provide no chat
|
||||
secret.
|
||||
|
||||
## Connection Handshake
|
||||
|
||||
The Socket.IO handshake must include:
|
||||
@@ -41,6 +54,7 @@ Payload:
|
||||
"password": "string, max 128, optional",
|
||||
"tabTitle": "string, max 100, optional",
|
||||
"mediaTitle": "string, max 100, optional",
|
||||
"clientCapabilities": ["chat-v1"],
|
||||
"protocolVersion": "string, max 16"
|
||||
}
|
||||
```
|
||||
@@ -69,13 +83,56 @@ Payload:
|
||||
"hostPeerId": "string or null",
|
||||
"controlMode": "everyone | host-only",
|
||||
"controllers": ["peerId"],
|
||||
"capabilities": ["host-control", "co-host"]
|
||||
"capabilities": ["host-control", "co-host", "chat", "chat-v1"]
|
||||
}
|
||||
```
|
||||
|
||||
`room_data` is sent to the joining socket. It is not the general broadcast used
|
||||
for every later room update.
|
||||
|
||||
## Ephemeral encrypted chat
|
||||
|
||||
Relays advertise chat support with `"chat-v1"` in `room_data.capabilities` and keep
|
||||
the initial beta's `"chat"` flag during the transition. New clients announce
|
||||
`"chat-v1"` in optional `join_room.clientCapabilities` (at most the first 16 entries
|
||||
are inspected; unknown or malformed values are ignored). Old clients omit the field
|
||||
and continue using the pre-chat protocol unchanged.
|
||||
|
||||
The relay sends `chat_message` only to sockets that announced `"chat-v1"`. As a
|
||||
transition for the first chat beta, a socket that sends a valid v1 ciphertext is
|
||||
marked capable for the rest of that connection. This prevents old non-chat
|
||||
extensions from receiving unknown events while preserving the first beta's send
|
||||
path.
|
||||
|
||||
### `chat_message`
|
||||
|
||||
Client to relay:
|
||||
|
||||
```json
|
||||
{ "ciphertext": "<unpadded-base64url>" }
|
||||
```
|
||||
|
||||
`ciphertext` contains a 12-byte AES-GCM IV followed by ciphertext and the 16-byte
|
||||
authentication tag. The relay validates only canonical base64url and byte bounds.
|
||||
It cannot inspect plaintext.
|
||||
|
||||
Relay to every chat-capable current room peer, including the sender:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "<server-generated UUID>",
|
||||
"senderId": "<server-stamped peer ID>",
|
||||
"timestamp": 1710000000000,
|
||||
"ciphertext": "<unpadded-base64url>"
|
||||
}
|
||||
```
|
||||
|
||||
Client-provided `id`, `senderId`, `timestamp`, or plaintext fields are discarded.
|
||||
The relay keeps no message collection and `room_data` contains no chat history.
|
||||
Messages are limited to 10 per socket per 10 seconds in addition to the global event
|
||||
budget. There are no typing, read-receipt, history, or chat-specific peer-management
|
||||
events.
|
||||
|
||||
## Room Leave
|
||||
|
||||
### `leave_room` (client -> server)
|
||||
@@ -365,5 +422,7 @@ If sender and target are still in the same room, the relay emits:
|
||||
|
||||
- `host-control`
|
||||
- `co-host`
|
||||
- `chat`
|
||||
- `chat-v1`
|
||||
|
||||
Clients should treat a missing or unknown capabilities list as unsupported.
|
||||
|
||||
+77
-11
@@ -30,6 +30,83 @@
|
||||
|
||||
*Prioritized for upcoming phases.*
|
||||
|
||||
### v3.0 — Modern Room Chat
|
||||
|
||||
- **Release date:** Not scheduled
|
||||
- **Priority:** P1
|
||||
- **Category:** Social / Communication / Privacy
|
||||
- **Status:** Planned. This is a roadmap target, not a release announcement.
|
||||
- **Existing foundation:** Opt-in, live-only end-to-end encrypted room chat; a floating
|
||||
chat bubble over the selected player opens a dockable, detachable, resizable overlay.
|
||||
The relay stores no chat history and mixed extension versions use capability-gated
|
||||
delivery.
|
||||
|
||||
#### Core experience
|
||||
|
||||
- Floating bubble with an accessible unread badge (`1`, `2`, `3`, …, `99+`), cleared
|
||||
only when the chat is actually viewed.
|
||||
- Subtle new-message pulse with reduced-motion support; no animation or sound while
|
||||
muted or during Do Not Disturb.
|
||||
- Drag-and-snap bubble positioning, remembered per site, with reset-to-default.
|
||||
- Dock left/right, detached overlay, compact mode, fullscreen-safe placement, responsive
|
||||
narrow-player layout, and keyboard-only operation.
|
||||
- Per-room mute, mention-only mode, notification sound controls, browser notifications,
|
||||
and a visible mute state.
|
||||
- Unread state survives overlay close/reopen and tab focus changes, but room messages
|
||||
remain memory-only unless a future storage option is explicitly enabled.
|
||||
|
||||
#### Messaging
|
||||
|
||||
- Replies with quoted context, emoji reactions, emoji picker, mentions, and typing
|
||||
indicators.
|
||||
- Edit and delete own messages with clear local tombstones; no silent mutation.
|
||||
- Delivery states (`sending`, `sent`, `failed`, `retry`) with idempotent retries and
|
||||
duplicate suppression after reconnects.
|
||||
- Message timestamps, date separators, jump-to-latest, unread divider, search within
|
||||
the current live session, copy, and selectable text.
|
||||
- Link detection with safe external opening, explicit scheme allowlist, and no automatic
|
||||
previews or remote-media loading.
|
||||
- Optional image/GIF/file sharing only after encrypted size limits, malware-risk UX,
|
||||
relay bandwidth limits, and privacy behavior are designed and tested.
|
||||
|
||||
#### Presence, safety, and control
|
||||
|
||||
- Online/reconnecting status, join/leave events, and room-member presence without
|
||||
exposing IP addresses or cross-room identity.
|
||||
- Local mute/block, host moderation controls, slow mode, spam limits, and configurable
|
||||
maximum message length.
|
||||
- Optional read receipts and activity indicators default off; never infer or expose
|
||||
viewing behavior without explicit consent.
|
||||
- Clear live-only/no-history explanation, encrypted-chat indicator, room-key fingerprint,
|
||||
and key-rotation/rejoin UX.
|
||||
|
||||
#### Reliability and compatibility
|
||||
|
||||
- Versioned chat capabilities for every new wire feature; unknown fields/events remain
|
||||
harmless to older extensions and old non-chat clients receive no chat traffic.
|
||||
- Server-first and extension-first rollout tests, including current, first-chat-beta,
|
||||
old non-chat, reconnect, duplicate-frame, and malformed-capability clients.
|
||||
- Bounded queues, payloads, unread counters, typing events, reactions, and attachment
|
||||
metadata; backpressure and rate limits on both client and relay.
|
||||
- Real Chrome and Firefox extension E2E coverage for bubble, unread count, overlay,
|
||||
fullscreen, reconnect, and mixed-version rooms before v3.0 can ship.
|
||||
|
||||
#### Accessibility and localization
|
||||
|
||||
- Screen-reader announcements that do not read every busy-room message by default.
|
||||
- Complete focus management, logical tab order, Escape behavior, high contrast,
|
||||
zoom/reflow, reduced motion, and touch target coverage.
|
||||
- All chat UI, notification text, moderation states, and errors localized across every
|
||||
supported locale before release.
|
||||
|
||||
#### Explicit non-goals for the first v3.0 release
|
||||
|
||||
- No server-readable plaintext.
|
||||
- No mandatory accounts, public room directory, advertising, tracking, or presence
|
||||
across rooms.
|
||||
- No unencrypted server-side history. Any future encrypted history requires a separate
|
||||
threat model, retention controls, export/delete behavior, and explicit opt-in.
|
||||
|
||||
### 2. Invite link with target URL for auto-redirect
|
||||
|
||||
- **Priority:** P2
|
||||
@@ -49,17 +126,6 @@
|
||||
|
||||
*Ideas and feature requests under evaluation.*
|
||||
|
||||
### In-room chat overlay (like TeleParty)
|
||||
|
||||
- **Priority:** P3
|
||||
- **Category:** Social / Communication
|
||||
- **Background:** A collapsible chat panel to the right of the video (or as an overlay) allowing text-based communication with everyone in the room.
|
||||
- **Why backlog (still uncertain):**
|
||||
- **Use case:** No strong personal need — with chat, latency matters less than with voice; async communication tolerates a few seconds of delay.
|
||||
- **Complexity:** Relatively large feature (UI + message persistence + possibly history).
|
||||
- **Legal/moderation:** Unclear what moderation requirements would apply if users can exchange chat messages. Could be relevant depending on jurisdiction.
|
||||
- **Status:** Under evaluation, may come later.
|
||||
|
||||
### Cross-frame video detection and control
|
||||
|
||||
- **Priority:** P3
|
||||
|
||||
@@ -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:<roomId>:<password>
|
||||
custom: #join:<roomId>:<password>:1:<encodedUrl>
|
||||
```
|
||||
|
||||
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:<KEY>: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=<roomId>&p=<password>&k=<key>[&u=<encodedRelayUrl>]
|
||||
```
|
||||
|
||||
- `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 -- <pfad>`, 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: ["<all_urls>"]`, 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 `<html>`: `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.
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Synchronisiere die Videowiedergabe auf YouTube, Netflix, Emby, Jellyfin und jeder HTML5-Seite in Echtzeit mit Freunden."
|
||||
"message": "Erstelle private Watch Partys und synchronisiere Videos mit Freunden auf YouTube, Netflix, Jellyfin, Emby und fast jeder Website."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends."
|
||||
"message": "Create private watch parties and sync videos with friends on YouTube, Netflix, Jellyfin, Emby, and almost any HTML5 website."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Mira videos junto a tus amigos en perfecta sincronización. Compatible con Netflix, YouTube, Twitch, Prime Video, Disney+ y cualquier reproductor HTML5."
|
||||
"message": "Crea fiestas privadas y sincroniza vídeos con amigos en YouTube, Netflix, Jellyfin, Emby y casi cualquier sitio HTML5."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Synchronisez la lecture vidéo sur YouTube, Netflix, Emby, Jellyfin et tout site HTML5 en temps réel avec vos amis."
|
||||
"message": "Créez des watch parties privées et regardez en synchro entre amis sur YouTube, Netflix, Jellyfin, Emby et presque tout site HTML5."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Sincronizza la riproduzione video su YouTube, Netflix, Emby, Jellyfin e qualsiasi sito HTML5 in tempo reale con gli amici."
|
||||
"message": "Crea watch party private e sincronizza i video con gli amici su YouTube, Netflix, Jellyfin, Emby e quasi ogni sito HTML5."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "YouTube、Netflix、Emby、Jellyfin、その他HTML5サイトでの動画再生を友達とリアルタイムで同期します。"
|
||||
"message": "プライベートなウォッチパーティーを作成し、YouTube、Netflix、Jellyfin、EmbyやほぼすべてのHTML5サイトで友達と動画を同期再生できます。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "YouTube, Netflix, Emby, Jellyfin 및 모든 HTML5 사이트에서 친구들과 실시간으로 비디오 재생을 동기화하세요."
|
||||
"message": "비공개 Watch Party를 만들고 YouTube, Netflix, Jellyfin, Emby 및 거의 모든 HTML5 사이트에서 친구들과 영상을 동기화하세요."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Synchroniseer video-afspelen op YouTube, Netflix, Emby, Jellyfin en elke HTML5-site in realtime met vrienden."
|
||||
"message": "Maak privéwatchparty's en kijk synchroon met vrienden op YouTube, Netflix, Jellyfin, Emby en vrijwel elke HTML5-website."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Synchronizuj odtwarzanie wideo na YouTube, Netflix, Emby, Jellyfin i dowolnej stronie HTML5 w czasie rzeczywistym ze znajomymi."
|
||||
"message": "Twórz prywatne wspólne seanse i synchronizuj filmy ze znajomymi na YouTube, Netflix, Jellyfin, Emby i niemal każdej stronie HTML5."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Assista a vídeos junto com seus amigos em sincronia perfeita. Compatível com Netflix, YouTube, Twitch, Prime Video, Disney+ e qualquer player HTML5."
|
||||
"message": "Crie watch parties privadas e sincronize vídeos com amigos no YouTube, Netflix, Jellyfin, Emby e em quase qualquer site HTML5."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Veja vídeos em conjunto com os seus amigos em sincronia perfeita. Compatível com Netflix, YouTube, Twitch, Prime Video, Disney+ e qualquer leitor HTML5."
|
||||
"message": "Crie sessões privadas e veja vídeos sincronizados com amigos no YouTube, Netflix, Jellyfin, Emby e em quase qualquer site HTML5."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Синхронизируйте воспроизведение видео на YouTube, Netflix, Emby, Jellyfin и любых HTML5-сайтах в реальном времени с друзьями."
|
||||
"message": "Создавайте закрытые Watch Party и смотрите синхронно с друзьями на YouTube, Netflix, Jellyfin, Emby и почти любом сайте HTML5."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "YouTube, Netflix, Emby, Jellyfin ve herhangi bir HTML5 sitesinde video oynatmayı arkadaşlarınızla gerçek zamanlı olarak senkronize edin."
|
||||
"message": "Özel izleme partileri kurun; YouTube, Netflix, Jellyfin, Emby ve çoğu HTML5 sitesinde arkadaşlarınızla senkron izleyin."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Синхронізуйте відтворення відео на YouTube, Netflix, Emby, Jellyfin і будь-якому сайті HTML5 у режимі реального часу з друзями."
|
||||
"message": "Створюйте закриті Watch Party й дивіться синхронно з друзями на YouTube, Netflix, Jellyfin, Emby та майже будь-якому сайті HTML5."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "与朋友实时同步YouTube、Netflix、Emby、Jellyfin和任何HTML5网站上的视频播放。"
|
||||
"message": "创建私人观影派对,与好友在 YouTube、Netflix、Jellyfin、Emby 及几乎任何 HTML5 视频网站同步观看。"
|
||||
}
|
||||
}
|
||||
|
||||
+312
-57
@@ -4,6 +4,9 @@ import { loadLocale, getMessage, getSystemLanguage } from './i18n.js';
|
||||
import { sameEpisode, extractEpisodeId } from './episode-utils.js';
|
||||
import { applyTitlePrivacyToPayload, sanitizeSharedTitle, sanitizeTabTitle, normalizeSendTabTitle, normalizeTitlePrivacyMode } from './title-privacy.js';
|
||||
import { initTabManager } from './modules/tab-manager.js';
|
||||
import { clearChatKeyCache, decryptChatMessage, encryptChatMessage, generateChatSecret, validateChatSecret } from './chat-crypto.js';
|
||||
import { buildChatRelayPayload, encodeSocketEvent } from './chat-wire.js';
|
||||
import { createChatSendLimiter, createLatestTaskQueue, normalizeRoomId } from './chat-session.js';
|
||||
import { HOST_ACCESS_REQUIRED_STATUS, normalizeTabId, inspectTabHostAccess, isHostAccessError, addTabHostAccessRequest, removeTabHostAccessRequest } from './host-access.js';
|
||||
import './page-api-seek-overrides.js';
|
||||
|
||||
@@ -82,7 +85,31 @@ let hostPeerId = null; // peerId of the room host (creator /
|
||||
// Features the connected relay advertises in ROOM_DATA. Empty against an older
|
||||
// relay (no capabilities field) → host-control UI/behavior stays unavailable.
|
||||
let serverCapabilities = [];
|
||||
let chatSecretGuard = '';
|
||||
let chatSessionGeneration = 0;
|
||||
let chatReceiveQueue = Promise.resolve();
|
||||
const chatSendLimiter = createChatSendLimiter();
|
||||
const webJoinCoordinator = createLatestTaskQueue();
|
||||
function serverSupports(cap) { return Array.isArray(serverCapabilities) && serverCapabilities.includes(cap); }
|
||||
function serverSupportsChat() {
|
||||
return serverSupports(CAPABILITIES.CHAT_V1) || serverSupports(CAPABILITIES.CHAT);
|
||||
}
|
||||
const CLIENT_CAPABILITIES = Object.freeze([CAPABILITIES.CHAT_V1]);
|
||||
|
||||
function invalidateChatSession() {
|
||||
chatSessionGeneration++;
|
||||
chatReceiveQueue = Promise.resolve();
|
||||
chatSendLimiter.reset();
|
||||
clearChatKeyCache();
|
||||
}
|
||||
|
||||
async function clearFailedJoinCredentials() {
|
||||
webJoinCoordinator.invalidate();
|
||||
connectIntent = false;
|
||||
chatSecretGuard = '';
|
||||
invalidateChatSession();
|
||||
await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {});
|
||||
}
|
||||
// Local peer's desync state (content.js reports it via HCM_DESYNC_STATE). Relayed
|
||||
// in heartbeats so the host's popup UI can show "Solo" instead of silently
|
||||
// appearing un-ACK'd.
|
||||
@@ -371,7 +398,7 @@ async function getSettings() {
|
||||
// (username) must NEVER come from storage.sync — syncing them across devices
|
||||
// both leaks them and resurrects dead rooms on reinstall (a fresh install
|
||||
// has empty local storage but sync survives in the user's Google account).
|
||||
const data = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode']);
|
||||
const data = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'chatEnabled', 'username', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode']);
|
||||
let username = data.username;
|
||||
if (!username) {
|
||||
username = generateUsername();
|
||||
@@ -379,11 +406,17 @@ async function getSettings() {
|
||||
}
|
||||
const legacyTitlePrivacyMode = normalizeTitlePrivacyMode(data.titlePrivacyMode);
|
||||
const mediaTitlePrivacyMode = normalizeTitlePrivacyMode(data.mediaTitlePrivacyMode || legacyTitlePrivacyMode);
|
||||
const chatKey = validateChatSecret(data.chatKey);
|
||||
chatSecretGuard = chatKey;
|
||||
const roomId = normalizeRoomId(data.roomId);
|
||||
if (data.roomId && data.roomId !== roomId) chrome.storage.local.set({ roomId }).catch(() => {});
|
||||
return {
|
||||
serverUrl: data.serverUrl || '',
|
||||
useCustomServer: data.useCustomServer || false,
|
||||
roomId: data.roomId || '',
|
||||
roomId,
|
||||
password: data.password || '',
|
||||
chatKey,
|
||||
chatEnabled: data.chatEnabled === true,
|
||||
username,
|
||||
sendTabTitle: normalizeSendTabTitle(data.sendTabTitle, legacyTitlePrivacyMode),
|
||||
mediaTitlePrivacyMode
|
||||
@@ -419,7 +452,7 @@ function emitEpisodeLobbyForCurrentPrivacy() {
|
||||
// removes legacy keys that older versions wrote to sync (and that would
|
||||
// otherwise be redistributed across devices and resurrected on reinstall).
|
||||
const LEGACY_SYNC_KEYS = [
|
||||
'serverUrl', 'useCustomServer', 'roomId', 'password', 'username',
|
||||
'serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'username',
|
||||
'filterNoise', 'autoSyncNextEpisode', 'forceSyncMode',
|
||||
'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings',
|
||||
'titlePrivacyMode', 'sendTabTitle', 'mediaTitlePrivacyMode'
|
||||
@@ -475,6 +508,7 @@ function forceDisconnect() {
|
||||
currentServerUrl = null;
|
||||
isConnecting = false;
|
||||
isNamespaceJoined = false;
|
||||
invalidateChatSession();
|
||||
isForceSyncInitiator = false;
|
||||
expectedAcksCount = 0;
|
||||
roomIdleSince = null;
|
||||
@@ -545,6 +579,7 @@ function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null)
|
||||
completeForceSyncBeforeTargetChange(null);
|
||||
invalidateTargetActivations();
|
||||
clearPendingTarget().catch(() => {});
|
||||
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {});
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
@@ -579,6 +614,7 @@ async function leaveRoomAfterIdleGrace(reason) {
|
||||
// Notify content.js/popup BEFORE currentTabId is cleared so they can reset
|
||||
// any stale guest-side HCM state (dialog/badge/desync) — H-2.
|
||||
broadcastControlMode();
|
||||
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {});
|
||||
invalidateTargetActivations();
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
@@ -595,7 +631,9 @@ async function leaveRoomAfterIdleGrace(reason) {
|
||||
episodeLobby: null,
|
||||
hcmDesynced: false
|
||||
}).catch(() => {});
|
||||
await chrome.storage.local.set({ roomId: '', password: '' }).catch(() => {});
|
||||
chatSecretGuard = '';
|
||||
invalidateChatSession();
|
||||
await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {});
|
||||
addLog(reason, 'info');
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||
updateBadgeStatus();
|
||||
@@ -715,6 +753,7 @@ async function connect() {
|
||||
peerId,
|
||||
username: settings.username,
|
||||
tabTitle: sharedTitles.tabTitle,
|
||||
clientCapabilities: CLIENT_CAPABILITIES,
|
||||
protocolVersion: PROTOCOL_VERSION
|
||||
});
|
||||
}
|
||||
@@ -723,7 +762,7 @@ async function connect() {
|
||||
try {
|
||||
const payload = JSON.parse(msg.substring(2));
|
||||
try {
|
||||
handleServerEvent(payload[0], payload[1]);
|
||||
await handleServerEvent(payload[0], payload[1]);
|
||||
} catch (handlerErr) {
|
||||
addLog(`Handler error for ${payload[0]}: ${handlerErr.message}`, 'error');
|
||||
}
|
||||
@@ -736,6 +775,7 @@ async function connect() {
|
||||
socket.onclose = () => {
|
||||
isConnecting = false;
|
||||
isNamespaceJoined = false;
|
||||
invalidateChatSession();
|
||||
stopPing();
|
||||
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
||||
|
||||
@@ -817,6 +857,7 @@ function broadcastConnectionStatus(status) {
|
||||
status = 'idle';
|
||||
}
|
||||
chrome.runtime.sendMessage({ type: 'CONNECTION_STATUS', status }).catch(() => {});
|
||||
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CONNECTION_STATUS', status }).catch(() => {});
|
||||
updateBadgeStatus();
|
||||
}
|
||||
|
||||
@@ -917,10 +958,14 @@ function scheduleReconnect() {
|
||||
|
||||
function emit(event, data) {
|
||||
if (socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined) {
|
||||
const msg = `42${JSON.stringify([event, data])}`;
|
||||
try {
|
||||
const msg = encodeSocketEvent(event, data, chatSecretGuard);
|
||||
socket.send(msg);
|
||||
} catch (e) {
|
||||
if (e.message === 'Refusing to send chat secret to relay') {
|
||||
addLog(e.message, 'error');
|
||||
return;
|
||||
}
|
||||
// The socket can close between the readyState check and send()
|
||||
// (race with a server-side disconnect). Re-queue so the event is
|
||||
// retried on the next successful (re)connect instead of being lost.
|
||||
@@ -932,6 +977,17 @@ function emit(event, data) {
|
||||
}
|
||||
}
|
||||
|
||||
function emitLive(event, data) {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) return false;
|
||||
try {
|
||||
socket.send(encodeSocketEvent(event, data, chatSecretGuard));
|
||||
return true;
|
||||
} catch (e) {
|
||||
addLog(e.message === 'Refusing to send chat secret to relay' ? e.message : `Live send failed for ${event}: ${e.message}`, 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function queueEvent(event, data) {
|
||||
eventQueue.push({ event, data });
|
||||
if (eventQueue.length > 50) {
|
||||
@@ -1037,7 +1093,7 @@ function stopPing() {
|
||||
}
|
||||
|
||||
// --- Event Handlers ---
|
||||
function handleServerEvent(event, data) {
|
||||
async function handleServerEvent(event, data) {
|
||||
if (!data) {
|
||||
addLog(`Ignored server event ${event} due to empty payload`, 'warn');
|
||||
return;
|
||||
@@ -1057,12 +1113,14 @@ function handleServerEvent(event, data) {
|
||||
}
|
||||
switch (event) {
|
||||
case EVENTS.ROOM_DATA:
|
||||
if (currentRoom?.roomId !== data.roomId) invalidateChatSession();
|
||||
currentRoom = data;
|
||||
// Host Control Mode: adopt room role/mode on (re)join.
|
||||
controlMode = data.controlMode || CONTROL_MODES.EVERYONE;
|
||||
hostPeerId = data.hostPeerId || null;
|
||||
controllers = Array.isArray(data.controllers) ? data.controllers : [];
|
||||
serverCapabilities = Array.isArray(data.capabilities) ? data.capabilities : [];
|
||||
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
|
||||
hcmEnforceDesyncInvariant();
|
||||
broadcastControlMode();
|
||||
markRoomPotentiallyIdle();
|
||||
@@ -1140,12 +1198,56 @@ function handleServerEvent(event, data) {
|
||||
case EVENTS.ROOM_LIST:
|
||||
chrome.runtime.sendMessage({ type: 'ROOM_LIST', rooms: data.rooms }).catch(() => {});
|
||||
break;
|
||||
case EVENTS.CHAT_MESSAGE: {
|
||||
if (!currentRoom || !serverSupportsChat() || !currentTabId) break;
|
||||
const generation = chatSessionGeneration;
|
||||
const roomId = currentRoom.roomId;
|
||||
const tabId = Number(currentTabId);
|
||||
const received = { ...data };
|
||||
const isCurrentSession = () => generation === chatSessionGeneration &&
|
||||
currentRoom?.roomId === roomId && Number(currentTabId) === tabId;
|
||||
chatReceiveQueue = chatReceiveQueue.catch(() => {}).then(async () => {
|
||||
if (!isCurrentSession()) return;
|
||||
const settings = await getSettings();
|
||||
if (!settings.chatEnabled || !settings.chatKey || settings.roomId !== roomId || !isCurrentSession()) return;
|
||||
const chatKey = settings.chatKey;
|
||||
try {
|
||||
const text = await decryptChatMessage({
|
||||
ciphertext: received.ciphertext,
|
||||
roomId,
|
||||
senderId: received.senderId,
|
||||
secret: chatKey
|
||||
});
|
||||
if (!isCurrentSession() || chatSecretGuard !== chatKey) return;
|
||||
const senderPeer = currentRoom.peers?.find(candidate =>
|
||||
(typeof candidate === 'object' ? candidate.peerId : candidate) === received.senderId
|
||||
);
|
||||
if (Number.isInteger(tabId)) {
|
||||
chrome.tabs.sendMessage(tabId, {
|
||||
type: 'CHAT_MESSAGE',
|
||||
message: {
|
||||
id: received.id,
|
||||
senderId: received.senderId,
|
||||
username: typeof senderPeer === 'object' ? senderPeer.username : null,
|
||||
timestamp: received.timestamp,
|
||||
text
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch (_) {
|
||||
if (isCurrentSession()) addLog('Discarded chat message that failed authentication', 'warn');
|
||||
}
|
||||
});
|
||||
await chatReceiveQueue;
|
||||
break;
|
||||
}
|
||||
case EVENTS.ERROR:
|
||||
isConnecting = false;
|
||||
// If we get a server error before successfully joining a room,
|
||||
// clear connectIntent to prevent an infinite reconnect loop.
|
||||
if (!currentRoom) {
|
||||
connectIntent = false;
|
||||
// clear persisted credentials as well, otherwise service-worker
|
||||
// restart would immediately retry the rejected room.
|
||||
if (!currentRoom && connectIntent) {
|
||||
await clearFailedJoinCredentials();
|
||||
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||||
reconnectAttempts = 0;
|
||||
reconnectFailed = false;
|
||||
@@ -1860,7 +1962,7 @@ async function injectContentScript(tabId, { requestHostAccess = true } = {}) {
|
||||
});
|
||||
return await chrome.scripting.executeScript({
|
||||
target: { tabId },
|
||||
files: ['content.js']
|
||||
files: ['chat-format.js', 'chat-overlay.js', 'content.js']
|
||||
});
|
||||
} catch (error) {
|
||||
// A temporary activeTab grant is intentionally allowed to win: even if
|
||||
@@ -2071,6 +2173,7 @@ async function activateTargetTab(tabId, tabTitle, {
|
||||
if (currentRoom) roomIdleSince = Date.now();
|
||||
if (previousTabId) {
|
||||
resetAudioProcessingInTab(previousTabId);
|
||||
chrome.tabs.sendMessage(previousTabId, { type: 'CHAT_DESTROY' }).catch(() => {});
|
||||
}
|
||||
await chrome.storage.session.set({
|
||||
currentTabId: null,
|
||||
@@ -2133,6 +2236,7 @@ async function activateTargetTab(tabId, tabTitle, {
|
||||
if (currentRoom) roomIdleSince = Date.now();
|
||||
if (previousTabId && previousTabId !== selectedTabId) {
|
||||
resetAudioProcessingInTab(previousTabId);
|
||||
chrome.tabs.sendMessage(previousTabId, { type: 'CHAT_DESTROY' }).catch(() => {});
|
||||
}
|
||||
await chrome.storage.session.set({
|
||||
currentTabId,
|
||||
@@ -2391,6 +2495,7 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
|
||||
|
||||
function leaveOldRoomIfSwitching(newRoomId) {
|
||||
if (currentRoom && currentRoom.roomId !== newRoomId) {
|
||||
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_RESET' }).catch(() => {});
|
||||
addLog(`Switching rooms: leaving ${currentRoom.roomId} to join ${newRoomId}`, 'info');
|
||||
forceDisconnect();
|
||||
currentRoom = null;
|
||||
@@ -2398,6 +2503,7 @@ function leaveOldRoomIfSwitching(newRoomId) {
|
||||
hostPeerId = null;
|
||||
controllers = [];
|
||||
serverCapabilities = [];
|
||||
invalidateChatSession();
|
||||
hcmDesynced = false;
|
||||
// Notify content.js/popup so they drop any guest-side HCM state from the
|
||||
// previous room (badge/dialog/desync) — H-2/H-3.
|
||||
@@ -2439,21 +2545,33 @@ async function applyAudioSettingsToTab(tabId) {
|
||||
|
||||
// --- Extension Message Listeners ---
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
handleAsyncMessage(message, sender, sendResponse);
|
||||
handleAsyncMessage(message, sender, sendResponse).catch(error => {
|
||||
addLog(`Message handler failed for ${message?.type || 'unknown'}: ${error.message}`, 'error');
|
||||
try { sendResponse({ status: 'error' }); } catch (_) { /* channel already closed */ }
|
||||
});
|
||||
return true; // Keep channel open for async responses
|
||||
});
|
||||
|
||||
chrome.storage.onChanged.addListener((changes, area) => {
|
||||
if (area !== 'local' || (!changes.roomId && !changes.chatKey && !changes.chatEnabled)) return;
|
||||
if (changes.chatKey) chatSecretGuard = validateChatSecret(changes.chatKey.newValue);
|
||||
invalidateChatSession();
|
||||
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
|
||||
});
|
||||
|
||||
async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
if (!message) return;
|
||||
await ensureState();
|
||||
|
||||
if (message.type === 'CONNECT') {
|
||||
webJoinCoordinator.invalidate();
|
||||
const settings = await getSettings();
|
||||
connectIntent = !!settings.roomId;
|
||||
const desiredUrl = resolveServerUrl(settings);
|
||||
|
||||
if (settings.roomId && currentRoom && currentRoom.roomId === settings.roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
|
||||
broadcastConnectionStatus('connected');
|
||||
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
|
||||
const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve));
|
||||
for (const tab of tabs) {
|
||||
chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {});
|
||||
@@ -2481,6 +2599,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
peerId,
|
||||
username: settings.username,
|
||||
tabTitle: sharedTitles.tabTitle,
|
||||
clientCapabilities: CLIENT_CAPABILITIES,
|
||||
protocolVersion: PROTOCOL_VERSION
|
||||
});
|
||||
}
|
||||
@@ -2499,6 +2618,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
await retryPendingTarget();
|
||||
}
|
||||
const pendingTarget = await readPendingTarget();
|
||||
const settings = await getSettings();
|
||||
const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined;
|
||||
const isReconnecting = !isConnected && reconnectAttempts > 0;
|
||||
let status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : (isReconnecting ? 'reconnecting' : 'disconnected'));
|
||||
@@ -2528,8 +2648,106 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
amHost: amHost(),
|
||||
amController: amController(),
|
||||
hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL),
|
||||
coHostSupported: serverSupports(CAPABILITIES.CO_HOST)
|
||||
coHostSupported: serverSupports(CAPABILITIES.CO_HOST),
|
||||
chatSupported: serverSupportsChat(),
|
||||
hasChatKey: !!settings.chatKey,
|
||||
chatEnabled: settings.chatEnabled
|
||||
});
|
||||
} else if (message.type === 'GET_CHAT_CONTEXT') {
|
||||
const senderTabId = sender.tab?.id;
|
||||
if (!currentRoom || !currentTabId || senderTabId !== Number(currentTabId)) {
|
||||
sendResponse({ supported: false, hasKey: false });
|
||||
return;
|
||||
}
|
||||
const settings = await getSettings();
|
||||
const localeData = await chrome.storage.local.get(['locale']);
|
||||
await loadLocale(localeData.locale || getSystemLanguage());
|
||||
const translated = key => {
|
||||
const value = getMessage(key);
|
||||
return value === key ? '' : value;
|
||||
};
|
||||
sendResponse({
|
||||
supported: serverSupportsChat(),
|
||||
enabled: settings.chatEnabled,
|
||||
hasKey: !!settings.chatKey,
|
||||
connected: !!(socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined),
|
||||
peerId,
|
||||
roomId: currentRoom.roomId,
|
||||
strings: {
|
||||
title: translated('CHAT_TITLE'),
|
||||
liveOnly: translated('CHAT_LIVE_ONLY'),
|
||||
open: translated('CHAT_OPEN'),
|
||||
close: translated('CHAT_CLOSE'),
|
||||
dockLeft: translated('CHAT_DOCK_LEFT'),
|
||||
dockRight: translated('CHAT_DOCK_RIGHT'),
|
||||
detached: translated('CHAT_DETACHED'),
|
||||
placeholder: translated('CHAT_PLACEHOLDER'),
|
||||
send: translated('CHAT_SEND'),
|
||||
missingKey: translated('CHAT_MISSING_KEY'),
|
||||
tooLong: translated('CHAT_TOO_LONG'),
|
||||
sendFailed: translated('CHAT_SEND_FAILED'),
|
||||
empty: translated('CHAT_EMPTY'),
|
||||
you: translated('LABEL_YOU')
|
||||
}
|
||||
});
|
||||
} else if (message.type === 'CHAT_SEND') {
|
||||
const senderTabId = sender.tab?.id;
|
||||
if (!currentRoom || !currentTabId || senderTabId !== Number(currentTabId)) {
|
||||
sendResponse({ status: 'invalid_tab' });
|
||||
return;
|
||||
}
|
||||
if (!serverSupportsChat()) {
|
||||
sendResponse({ status: 'unsupported' });
|
||||
return;
|
||||
}
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) {
|
||||
sendResponse({ status: 'disconnected' });
|
||||
return;
|
||||
}
|
||||
const generation = chatSessionGeneration;
|
||||
const roomId = currentRoom.roomId;
|
||||
const tabId = Number(currentTabId);
|
||||
const socketSnapshot = socket;
|
||||
const isCurrentSession = () => generation === chatSessionGeneration &&
|
||||
currentRoom?.roomId === roomId && Number(currentTabId) === tabId &&
|
||||
socket === socketSnapshot && socketSnapshot.readyState === WebSocket.OPEN && isNamespaceJoined;
|
||||
const settings = await getSettings();
|
||||
if (!settings.chatEnabled) {
|
||||
sendResponse({ status: 'disabled' });
|
||||
return;
|
||||
}
|
||||
if (!settings.chatKey || settings.roomId !== roomId || !isCurrentSession()) {
|
||||
sendResponse({ status: settings.chatKey ? 'session_changed' : 'missing_key' });
|
||||
return;
|
||||
}
|
||||
const chatKey = settings.chatKey;
|
||||
const rateLimit = chatSendLimiter.take();
|
||||
if (!rateLimit.allowed) {
|
||||
sendResponse({ status: 'rate_limited', retryAfterMs: rateLimit.retryAfterMs });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const ciphertext = await encryptChatMessage({
|
||||
text: message.text,
|
||||
roomId,
|
||||
senderId: peerId,
|
||||
secret: chatKey
|
||||
});
|
||||
if (!isCurrentSession() || chatSecretGuard !== chatKey) {
|
||||
sendResponse({ status: 'session_changed' });
|
||||
return;
|
||||
}
|
||||
const sent = emitLive(EVENTS.CHAT_MESSAGE, buildChatRelayPayload(ciphertext));
|
||||
sendResponse({ status: sent ? 'ok' : 'disconnected' });
|
||||
} catch (err) {
|
||||
sendResponse({ status: err instanceof RangeError ? 'too_long' : 'invalid_message' });
|
||||
}
|
||||
} else if (message.type === 'CREATE_CHAT_KEY') {
|
||||
const chatKey = generateChatSecret();
|
||||
chatSecretGuard = chatKey;
|
||||
invalidateChatSession();
|
||||
await chrome.storage.local.set({ chatKey });
|
||||
sendResponse({ status: 'ok', chatKey });
|
||||
} else if (message.type === 'SET_CONTROL_MODE') {
|
||||
// Popup (host) toggles the room control mode. Server validates host authority
|
||||
// and broadcasts CONTROL_MODE back, which updates our local state + UI.
|
||||
@@ -2603,6 +2821,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
if (storageInitialized) chrome.storage.session.set({ hcmDesynced });
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'LEAVE_ROOM') {
|
||||
webJoinCoordinator.invalidate();
|
||||
completeForceSyncBeforeTargetChange(null);
|
||||
connectIntent = false;
|
||||
reconnectFailed = false;
|
||||
@@ -2619,6 +2838,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
// Notify content.js/popup BEFORE currentTabId is cleared so they drop any
|
||||
// stale guest-side HCM state (dialog/badge/desync) — H-2/H-3.
|
||||
broadcastControlMode();
|
||||
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {});
|
||||
invalidateTargetActivations();
|
||||
currentTabId = null;
|
||||
currentTabTitle = null;
|
||||
@@ -2649,7 +2869,9 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
expectedAcksCount: 0,
|
||||
hcmDesynced: false
|
||||
});
|
||||
chrome.storage.local.set({ roomId: '', password: '' }).catch(() => {});
|
||||
chatSecretGuard = '';
|
||||
invalidateChatSession();
|
||||
chrome.storage.local.set({ roomId: '', password: '', chatKey: '' }).catch(() => {});
|
||||
addLog('Left Room', 'info');
|
||||
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: [] }).catch(() => {});
|
||||
forceDisconnect();
|
||||
@@ -2665,8 +2887,9 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
emit(EVENTS.GET_ROOMS, {});
|
||||
sendResponse({ status: 'ok' });
|
||||
} else if (message.type === 'WEB_JOIN_REQUEST') {
|
||||
const { roomId: rawRoomId, password, useCustomServer, serverUrl } = message;
|
||||
const roomId = typeof rawRoomId === 'string' ? rawRoomId.replace(/[^a-zA-Z0-9\-]/g, '') : '';
|
||||
const { roomId: rawRoomId, password, chatKey: rawChatKey, useCustomServer, serverUrl } = message;
|
||||
const roomId = normalizeRoomId(rawRoomId);
|
||||
const chatKey = validateChatSecret(rawChatKey);
|
||||
if (!roomId) {
|
||||
const errMsg = { type: 'JOIN_STATUS', success: false, message: 'Invalid room ID' };
|
||||
chrome.runtime.sendMessage(errMsg).catch(() => {});
|
||||
@@ -2676,49 +2899,78 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
sendResponse({ status: 'invalid_room_id' });
|
||||
return;
|
||||
}
|
||||
connectIntent = true;
|
||||
chrome.storage.local.set({
|
||||
roomId,
|
||||
password,
|
||||
useCustomServer: !!useCustomServer,
|
||||
serverUrl: serverUrl || ''
|
||||
}, async () => {
|
||||
const settings = await getSettings();
|
||||
const desiredUrl = resolveServerUrl(settings);
|
||||
|
||||
if (roomId && currentRoom && currentRoom.roomId === roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
|
||||
broadcastConnectionStatus('connected');
|
||||
const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve));
|
||||
for (const tab of tabs) {
|
||||
chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {});
|
||||
}
|
||||
sendResponse({ status: 'already_joined' });
|
||||
return;
|
||||
await webJoinCoordinator.run(async isCurrentJoin => {
|
||||
if (!isCurrentJoin()) {
|
||||
sendResponse({ status: 'superseded' });
|
||||
return { status: 'superseded' };
|
||||
}
|
||||
|
||||
reconnectFailed = false;
|
||||
reconnectStartTime = null;
|
||||
reconnectAttempts = 0;
|
||||
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
|
||||
broadcastConnectionStatus('connecting');
|
||||
leaveOldRoomIfSwitching(roomId);
|
||||
|
||||
if (desiredUrl !== currentServerUrl || !socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) {
|
||||
if (desiredUrl !== currentServerUrl) forceDisconnect();
|
||||
connect();
|
||||
} else if (roomId) {
|
||||
const sharedTitles = getSharedTitleFields(settings);
|
||||
emit(EVENTS.JOIN_ROOM, {
|
||||
roomId,
|
||||
password,
|
||||
peerId,
|
||||
username: settings.username,
|
||||
tabTitle: sharedTitles.tabTitle,
|
||||
protocolVersion: PROTOCOL_VERSION
|
||||
try {
|
||||
connectIntent = true;
|
||||
await chrome.storage.local.set({
|
||||
roomId,
|
||||
password: typeof password === 'string' ? password : '',
|
||||
chatKey,
|
||||
useCustomServer: !!useCustomServer,
|
||||
serverUrl: typeof serverUrl === 'string' ? serverUrl : ''
|
||||
});
|
||||
if (!isCurrentJoin()) {
|
||||
sendResponse({ status: 'superseded' });
|
||||
return { status: 'superseded' };
|
||||
}
|
||||
chatSecretGuard = chatKey;
|
||||
invalidateChatSession();
|
||||
const settings = await getSettings();
|
||||
if (!isCurrentJoin()) {
|
||||
sendResponse({ status: 'superseded' });
|
||||
return { status: 'superseded' };
|
||||
}
|
||||
const desiredUrl = resolveServerUrl(settings);
|
||||
|
||||
if (roomId && currentRoom && currentRoom.roomId === roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
|
||||
broadcastConnectionStatus('connected');
|
||||
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
|
||||
const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve));
|
||||
if (!isCurrentJoin()) {
|
||||
sendResponse({ status: 'superseded' });
|
||||
return { status: 'superseded' };
|
||||
}
|
||||
for (const tab of tabs) {
|
||||
chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {});
|
||||
}
|
||||
sendResponse({ status: 'already_joined' });
|
||||
return { status: 'already_joined' };
|
||||
}
|
||||
|
||||
reconnectFailed = false;
|
||||
reconnectStartTime = null;
|
||||
reconnectAttempts = 0;
|
||||
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
|
||||
broadcastConnectionStatus('connecting');
|
||||
leaveOldRoomIfSwitching(roomId);
|
||||
|
||||
if (desiredUrl !== currentServerUrl || !socket || socket.readyState !== WebSocket.OPEN || !isNamespaceJoined) {
|
||||
if (desiredUrl !== currentServerUrl) forceDisconnect();
|
||||
connect();
|
||||
} else if (roomId) {
|
||||
const sharedTitles = getSharedTitleFields(settings);
|
||||
emit(EVENTS.JOIN_ROOM, {
|
||||
roomId,
|
||||
password,
|
||||
peerId,
|
||||
username: settings.username,
|
||||
tabTitle: sharedTitles.tabTitle,
|
||||
clientCapabilities: CLIENT_CAPABILITIES,
|
||||
protocolVersion: PROTOCOL_VERSION
|
||||
});
|
||||
}
|
||||
addLog(`Joining room via link: ${roomId}`, 'info');
|
||||
sendResponse({ status: 'ok' });
|
||||
return { status: 'ok' };
|
||||
} catch (_) {
|
||||
if (isCurrentJoin()) await clearFailedJoinCredentials();
|
||||
sendResponse({ status: 'storage_error' });
|
||||
return { status: 'storage_error' };
|
||||
}
|
||||
addLog(`Joining room via link: ${roomId}`, 'info');
|
||||
sendResponse({ status: 'ok' });
|
||||
});
|
||||
} else if (message.type === 'REGENERATE_ID') {
|
||||
// Match getPeerId()'s 16-hex-char generation — see comment there.
|
||||
@@ -3036,7 +3288,10 @@ async function handleAsyncMessage(message, sender, sendResponse) {
|
||||
currentTabTitle = null;
|
||||
lastContentHeartbeatAt = null;
|
||||
if (currentRoom) roomIdleSince = Date.now();
|
||||
if (previousTabId) resetAudioProcessingInTab(previousTabId);
|
||||
if (previousTabId) {
|
||||
resetAudioProcessingInTab(previousTabId);
|
||||
chrome.tabs.sendMessage(Number(previousTabId), { type: 'CHAT_DESTROY' }).catch(() => {});
|
||||
}
|
||||
await clearPendingTarget();
|
||||
await chrome.storage.session.set({
|
||||
currentTabId: null,
|
||||
|
||||
+2
-1
@@ -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(() => {});
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
const CHAT_INFO = 'KoalaSync E2E Chat v1';
|
||||
const SECRET_BYTES = 16;
|
||||
const IV_BYTES = 12;
|
||||
const MIN_ENCRYPTED_BYTES = 29;
|
||||
const MAX_ENCRYPTED_BYTES = 2028;
|
||||
|
||||
let cachedKeyPromise = null;
|
||||
let cachedRoomId = '';
|
||||
let cachedSecret = '';
|
||||
let cacheGeneration = 0;
|
||||
|
||||
function bytesToBase64Url(bytes) {
|
||||
let binary = '';
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return globalThis.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function base64UrlToBytes(value) {
|
||||
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value) || value.length % 4 === 1) return null;
|
||||
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4);
|
||||
try {
|
||||
const binary = globalThis.atob(padded);
|
||||
const bytes = Uint8Array.from(binary, char => char.charCodeAt(0));
|
||||
return bytesToBase64Url(bytes) === value ? bytes : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function generateChatSecret(cryptoImpl = globalThis.crypto) {
|
||||
const bytes = new Uint8Array(SECRET_BYTES);
|
||||
cryptoImpl.getRandomValues(bytes);
|
||||
return bytesToBase64Url(bytes);
|
||||
}
|
||||
|
||||
export function validateChatSecret(value) {
|
||||
const bytes = base64UrlToBytes(value);
|
||||
return bytes?.length === SECRET_BYTES ? value : '';
|
||||
}
|
||||
|
||||
export function countCodePoints(value) {
|
||||
return [...String(value ?? '')].length;
|
||||
}
|
||||
|
||||
export function normalizeOutgoingChatText(value) {
|
||||
if (typeof value !== 'string') throw new TypeError('Chat message must be text');
|
||||
const text = value.trim();
|
||||
if (!text) throw new TypeError('Chat message must not be empty');
|
||||
if (countCodePoints(text) > 500) throw new RangeError('Chat message exceeds 500 Unicode code points');
|
||||
return text;
|
||||
}
|
||||
|
||||
export function clearChatKeyCache() {
|
||||
cacheGeneration++;
|
||||
cachedKeyPromise = null;
|
||||
cachedRoomId = '';
|
||||
cachedSecret = '';
|
||||
}
|
||||
|
||||
export async function deriveChatKey(roomId, secret, cryptoImpl = globalThis.crypto) {
|
||||
const validSecret = validateChatSecret(secret);
|
||||
if (!roomId || !validSecret) throw new TypeError('Valid roomId and chat secret are required');
|
||||
if (cachedKeyPromise && cachedRoomId === roomId && cachedSecret === validSecret) return cachedKeyPromise;
|
||||
|
||||
const encoder = new globalThis.TextEncoder();
|
||||
const generation = cacheGeneration;
|
||||
const keyPromise = (async () => {
|
||||
const material = await cryptoImpl.subtle.importKey(
|
||||
'raw', base64UrlToBytes(validSecret), { name: 'HKDF' }, false, ['deriveKey']
|
||||
);
|
||||
return cryptoImpl.subtle.deriveKey({
|
||||
name: 'HKDF',
|
||||
hash: 'SHA-256',
|
||||
salt: encoder.encode(roomId),
|
||||
info: encoder.encode(CHAT_INFO)
|
||||
}, material, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
||||
})();
|
||||
cachedKeyPromise = keyPromise;
|
||||
cachedRoomId = roomId;
|
||||
cachedSecret = validSecret;
|
||||
try {
|
||||
return await keyPromise;
|
||||
} catch (error) {
|
||||
if (generation === cacheGeneration && cachedKeyPromise === keyPromise) clearChatKeyCache();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function encryptChatMessage({ text, roomId, senderId, secret }, cryptoImpl = globalThis.crypto) {
|
||||
const plaintext = normalizeOutgoingChatText(text);
|
||||
if (!senderId) throw new TypeError('senderId is required');
|
||||
const key = await deriveChatKey(roomId, secret, cryptoImpl);
|
||||
const encoder = new globalThis.TextEncoder();
|
||||
const iv = new Uint8Array(IV_BYTES);
|
||||
cryptoImpl.getRandomValues(iv);
|
||||
const encrypted = new Uint8Array(await cryptoImpl.subtle.encrypt({
|
||||
name: 'AES-GCM',
|
||||
iv,
|
||||
additionalData: encoder.encode(`${roomId}|${senderId}`)
|
||||
}, key, encoder.encode(plaintext)));
|
||||
const envelope = new Uint8Array(iv.length + encrypted.length);
|
||||
envelope.set(iv);
|
||||
envelope.set(encrypted, iv.length);
|
||||
return bytesToBase64Url(envelope);
|
||||
}
|
||||
|
||||
export async function decryptChatMessage({ ciphertext, roomId, senderId, secret }, cryptoImpl = globalThis.crypto) {
|
||||
if (!senderId) throw new TypeError('senderId is required');
|
||||
const bytes = base64UrlToBytes(ciphertext);
|
||||
if (!bytes || bytes.length < MIN_ENCRYPTED_BYTES || bytes.length > MAX_ENCRYPTED_BYTES) {
|
||||
throw new TypeError('Invalid chat ciphertext');
|
||||
}
|
||||
const key = await deriveChatKey(roomId, secret, cryptoImpl);
|
||||
const encoder = new globalThis.TextEncoder();
|
||||
const plaintext = await cryptoImpl.subtle.decrypt({
|
||||
name: 'AES-GCM',
|
||||
iv: bytes.slice(0, IV_BYTES),
|
||||
additionalData: encoder.encode(`${roomId}|${senderId}`)
|
||||
}, key, bytes.slice(IV_BYTES));
|
||||
const text = new globalThis.TextDecoder('utf-8', { fatal: true }).decode(plaintext);
|
||||
return normalizeOutgoingChatText(text);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { webcrypto } from 'node:crypto';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
clearChatKeyCache,
|
||||
countCodePoints,
|
||||
decryptChatMessage,
|
||||
deriveChatKey,
|
||||
encryptChatMessage,
|
||||
generateChatSecret,
|
||||
normalizeOutgoingChatText,
|
||||
validateChatSecret
|
||||
} from './chat-crypto.js';
|
||||
|
||||
afterEach(clearChatKeyCache);
|
||||
|
||||
describe('chat crypto', () => {
|
||||
it('generates canonical 128-bit secrets', () => {
|
||||
const secret = generateChatSecret(webcrypto);
|
||||
expect(secret).toMatch(/^[A-Za-z0-9_-]{22}$/);
|
||||
expect(validateChatSecret(secret)).toBe(secret);
|
||||
expect(validateChatSecret(`${secret}=`)).toBe('');
|
||||
});
|
||||
|
||||
it('round-trips Unicode with a cached room key and fresh IVs', async () => {
|
||||
const secret = generateChatSecret(webcrypto);
|
||||
const firstKey = await deriveChatKey('ROOM-1', secret, webcrypto);
|
||||
const secondKey = await deriveChatKey('ROOM-1', secret, webcrypto);
|
||||
expect(secondKey).toBe(firstKey);
|
||||
|
||||
const input = { text: 'Hello 🐨 **world**', roomId: 'ROOM-1', senderId: 'alice', secret };
|
||||
const first = await encryptChatMessage(input, webcrypto);
|
||||
const second = await encryptChatMessage(input, webcrypto);
|
||||
expect(second).not.toBe(first);
|
||||
await expect(decryptChatMessage({ ciphertext: first, ...input }, webcrypto)).resolves.toBe(input.text);
|
||||
});
|
||||
|
||||
it('deduplicates in-flight key derivations without restoring a cleared cache', async () => {
|
||||
const secret = generateChatSecret(webcrypto);
|
||||
let deriveCalls = 0;
|
||||
let releaseDerive;
|
||||
const delayedCrypto = {
|
||||
...webcrypto,
|
||||
subtle: {
|
||||
importKey: (...args) => webcrypto.subtle.importKey(...args),
|
||||
deriveKey: async (...args) => {
|
||||
deriveCalls++;
|
||||
await new Promise(resolve => { releaseDerive = resolve; });
|
||||
return webcrypto.subtle.deriveKey(...args);
|
||||
}
|
||||
}
|
||||
};
|
||||
const first = deriveChatKey('ROOM-1', secret, delayedCrypto);
|
||||
const second = deriveChatKey('ROOM-1', secret, delayedCrypto);
|
||||
await Promise.resolve();
|
||||
expect(deriveCalls).toBe(1);
|
||||
clearChatKeyCache();
|
||||
releaseDerive();
|
||||
await Promise.all([first, second]);
|
||||
const fresh = deriveChatKey('ROOM-1', secret, webcrypto);
|
||||
expect(fresh).not.toBe(first);
|
||||
await fresh;
|
||||
});
|
||||
|
||||
it('rejects relabeling, cross-room replay, and the wrong secret', async () => {
|
||||
const secret = generateChatSecret(webcrypto);
|
||||
const ciphertext = await encryptChatMessage({ text: 'secret', roomId: 'ROOM-1', senderId: 'alice', secret }, webcrypto);
|
||||
clearChatKeyCache();
|
||||
await expect(decryptChatMessage({ ciphertext, roomId: 'ROOM-1', senderId: 'bob', secret }, webcrypto)).rejects.toThrow();
|
||||
clearChatKeyCache();
|
||||
await expect(decryptChatMessage({ ciphertext, roomId: 'ROOM-2', senderId: 'alice', secret }, webcrypto)).rejects.toThrow();
|
||||
clearChatKeyCache();
|
||||
await expect(decryptChatMessage({ ciphertext, roomId: 'ROOM-1', senderId: 'alice', secret: generateChatSecret(webcrypto) }, webcrypto)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('enforces 500 Unicode code points before encryption', () => {
|
||||
expect(countCodePoints('😀'.repeat(500))).toBe(500);
|
||||
expect(normalizeOutgoingChatText(` ${'😀'.repeat(500)} `)).toBe('😀'.repeat(500));
|
||||
expect(() => normalizeOutgoingChatText('😀'.repeat(501))).toThrow(RangeError);
|
||||
expect(() => normalizeOutgoingChatText(' ')).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('keeps the worst-case 500-codepoint payload within the relay byte bound', async () => {
|
||||
const secret = generateChatSecret(webcrypto);
|
||||
const ciphertext = await encryptChatMessage({
|
||||
text: '😀'.repeat(500), roomId: 'ROOM-1', senderId: 'alice', secret
|
||||
}, webcrypto);
|
||||
const bytes = Buffer.from(ciphertext, 'base64url');
|
||||
expect(bytes).toHaveLength(2028);
|
||||
});
|
||||
|
||||
it('rejects non-canonical plaintext and malformed UTF-8 after authentication', async () => {
|
||||
const secret = generateChatSecret(webcrypto);
|
||||
const roomId = 'ROOM-1';
|
||||
const senderId = 'alice';
|
||||
const key = await deriveChatKey(roomId, secret, webcrypto);
|
||||
const encoder = new globalThis.TextEncoder();
|
||||
|
||||
async function encryptRaw(plaintext) {
|
||||
const iv = webcrypto.getRandomValues(new Uint8Array(12));
|
||||
const encrypted = new Uint8Array(await webcrypto.subtle.encrypt({
|
||||
name: 'AES-GCM',
|
||||
iv,
|
||||
additionalData: encoder.encode(`${roomId}|${senderId}`)
|
||||
}, key, plaintext));
|
||||
return Buffer.concat([Buffer.from(iv), Buffer.from(encrypted)]).toString('base64url');
|
||||
}
|
||||
|
||||
const whitespace = await encryptRaw(encoder.encode(' '));
|
||||
await expect(decryptChatMessage({ ciphertext: whitespace, roomId, senderId, secret }, webcrypto)).rejects.toThrow(TypeError);
|
||||
const malformed = await encryptRaw(Uint8Array.of(0xff));
|
||||
await expect(decryptChatMessage({ ciphertext: malformed, roomId, senderId, secret }, webcrypto)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
(function exposeChatFormat(root) {
|
||||
function escapeChatHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function formatChatText(value) {
|
||||
return escapeChatHtml(value)
|
||||
.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*([^*\n]+)\*/g, '<em>$1</em>');
|
||||
}
|
||||
|
||||
function tokenizeChatText(value) {
|
||||
const text = String(value ?? '');
|
||||
const tokens = [];
|
||||
const pattern = /\*\*([^*\n]+)\*\*|\*([^*\n]+)\*/g;
|
||||
let cursor = 0;
|
||||
let match;
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
if (match.index > cursor) tokens.push({ type: 'text', text: text.slice(cursor, match.index) });
|
||||
tokens.push({ type: match[1] !== undefined ? 'strong' : 'em', text: match[1] ?? match[2] });
|
||||
cursor = pattern.lastIndex;
|
||||
}
|
||||
if (cursor < text.length) tokens.push({ type: 'text', text: text.slice(cursor) });
|
||||
return tokens;
|
||||
}
|
||||
|
||||
root.KoalaSyncChatFormat = Object.freeze({ escapeChatHtml, formatChatText, tokenizeChatText });
|
||||
})(globalThis);
|
||||
@@ -0,0 +1,22 @@
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
beforeAll(async () => {
|
||||
await import('./chat-format.js');
|
||||
});
|
||||
|
||||
describe('chat formatting', () => {
|
||||
it('escapes executable HTML before applying limited Markdown', () => {
|
||||
const result = globalThis.KoalaSyncChatFormat.formatChatText('<img src=x onerror=alert(1)> **bold** *italic*');
|
||||
expect(result).toBe('<img src=x onerror=alert(1)> <strong>bold</strong> <em>italic</em>');
|
||||
expect(result).not.toContain('<img');
|
||||
});
|
||||
|
||||
it('tokenizes only text, strong, and emphasis nodes', () => {
|
||||
expect(globalThis.KoalaSyncChatFormat.tokenizeChatText('<script>x</script> **bold** *italic*')).toEqual([
|
||||
{ type: 'text', text: '<script>x</script> ' },
|
||||
{ type: 'strong', text: 'bold' },
|
||||
{ type: 'text', text: ' ' },
|
||||
{ type: 'em', text: 'italic' }
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const overlaySource = fs.readFileSync(path.join(extensionDir, 'chat-overlay.js'), 'utf8');
|
||||
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
|
||||
const popupSource = fs.readFileSync(path.join(extensionDir, 'popup.js'), 'utf8');
|
||||
const localeDir = path.join(extensionDir, 'locales');
|
||||
const chatKeys = [
|
||||
'LABEL_CHAT_ENABLED',
|
||||
'LABEL_CHAT_ENABLED_TOOLTIP',
|
||||
'CHAT_TITLE',
|
||||
'CHAT_LIVE_ONLY',
|
||||
'CHAT_OPEN',
|
||||
'CHAT_CLOSE',
|
||||
'CHAT_DOCK_LEFT',
|
||||
'CHAT_DOCK_RIGHT',
|
||||
'CHAT_DETACHED',
|
||||
'CHAT_PLACEHOLDER',
|
||||
'CHAT_SEND',
|
||||
'CHAT_MISSING_KEY',
|
||||
'CHAT_TOO_LONG',
|
||||
'CHAT_SEND_FAILED',
|
||||
'CHAT_EMPTY'
|
||||
];
|
||||
|
||||
describe('chat overlay contract', () => {
|
||||
it('isolates the overlay and never renders markup as HTML', () => {
|
||||
expect(overlaySource).toContain("attachShadow({ mode: 'open' })");
|
||||
expect(overlaySource).not.toMatch(/\.innerHTML\s*=|insertAdjacentHTML|\.outerHTML\s*=/);
|
||||
expect(overlaySource).toContain('document.createTextNode(token.text)');
|
||||
});
|
||||
|
||||
it('injects formatting and overlay code only with the selected-tab content script', () => {
|
||||
expect(backgroundSource).toContain("files: ['chat-format.js', 'chat-overlay.js', 'content.js']");
|
||||
expect(overlaySource).toContain("const storageKey = `chatOverlayLayout:${location.origin}`");
|
||||
expect(overlaySource).toContain('document.fullscreenElement || document.documentElement');
|
||||
});
|
||||
|
||||
it('supports all layout and theme combinations with bounded message DOM', () => {
|
||||
expect(overlaySource).toContain("['left', 'right', 'detached']");
|
||||
expect(overlaySource).toContain('!layout.detachedInitialized');
|
||||
expect(overlaySource).toContain('const rect = panel.getBoundingClientRect()');
|
||||
expect(overlaySource).not.toContain('contentRect');
|
||||
expect(overlaySource).toContain("#app[data-palette=\"cyber\"][data-theme=\"light\"]");
|
||||
expect(overlaySource).toContain("#app[data-palette=\"graphite\"][data-theme=\"light\"]");
|
||||
expect(overlaySource).toContain('const MAX_MESSAGES = 200');
|
||||
expect(overlaySource).toContain('while (messages.querySelectorAll(\'.message\').length > MAX_MESSAGES)');
|
||||
expect(overlaySource).toContain('Math.max(1, window.innerWidth)');
|
||||
expect(overlaySource).toContain('min(${MIN_WIDTH}px, calc(100vw - 16px))');
|
||||
});
|
||||
|
||||
it('guards async refresh/send work and clears all composer state on room reset', () => {
|
||||
expect(overlaySource).toContain('generation !== refreshGeneration');
|
||||
expect(overlaySource).toContain('if (sending || !context?.enabled) return');
|
||||
expect(overlaySource).toContain('textarea.value === submittedValue');
|
||||
expect(overlaySource).toMatch(/CHAT_RESET[\s\S]*resetComposer\(\)/);
|
||||
expect(overlaySource).toContain('setTimeout(() => finish(null), timeoutMs)');
|
||||
expect(backgroundSource).toContain('chatReceiveQueue = chatReceiveQueue.catch(() => {}).then');
|
||||
expect(backgroundSource).toContain("status: 'rate_limited'");
|
||||
});
|
||||
|
||||
it('keeps chat hidden by default without discarding the room chat key', () => {
|
||||
expect(popupSource).toContain('localData.chatEnabled === true');
|
||||
expect(backgroundSource).toContain('chatEnabled: data.chatEnabled === true');
|
||||
expect(backgroundSource).toContain('clientCapabilities: CLIENT_CAPABILITIES');
|
||||
expect(overlaySource).toContain('all:initial;display:none;position:fixed');
|
||||
expect(overlaySource).toContain("host.style.display = supported && optedIn ? '' : 'none'");
|
||||
expect(overlaySource).toContain('supported && optedIn && hasKey && connected');
|
||||
expect(popupSource).toContain("chrome.storage.local.set({ chatEnabled: elements.chatEnabled.checked })");
|
||||
expect(popupSource).toMatch(/if \(isCreating\) \{[\s\S]*?type: 'CREATE_CHAT_KEY'[\s\S]*?chatKey = normalizeChatKey/);
|
||||
expect(popupSource).not.toMatch(/chatEnabled[\s\S]{0,120}chatKey:\s*''/);
|
||||
});
|
||||
|
||||
it('keeps unavailable chat controls discoverable to assistive technology', () => {
|
||||
expect(overlaySource).toContain("launcher.setAttribute('aria-disabled'");
|
||||
expect(overlaySource).not.toContain('launcher.disabled =');
|
||||
expect(overlaySource).toContain("launcher.setAttribute('aria-describedby', launcherHint.id)");
|
||||
expect(overlaySource).toContain("textarea.setAttribute('aria-describedby', 'chat-composer-count chat-composer-status')");
|
||||
expect(overlaySource).toContain("status.setAttribute('role', 'status')");
|
||||
});
|
||||
|
||||
it('creates a chat key for both generated-room entry points', () => {
|
||||
expect(popupSource).toContain('let pendingRoomCreation = false');
|
||||
expect(popupSource).toContain('const isCreating = pendingRoomCreation || !roomIdInput');
|
||||
expect(popupSource).toMatch(/function handleCreateRoom\(\)[\s\S]*pendingRoomCreation = true[\s\S]*elements\.joinBtn\.click\(\)/);
|
||||
expect(popupSource).toContain("type: 'CREATE_CHAT_KEY'");
|
||||
});
|
||||
|
||||
it('contains every chat string in all 15 extension locales', () => {
|
||||
const localeFiles = fs.readdirSync(localeDir).filter(file => file.endsWith('.json')).sort();
|
||||
expect(localeFiles).toHaveLength(15);
|
||||
for (const file of localeFiles) {
|
||||
const messages = JSON.parse(fs.readFileSync(path.join(localeDir, file), 'utf8'));
|
||||
for (const key of chatKeys) {
|
||||
expect(messages[key], `${file}: ${key}`).toBeTypeOf('string');
|
||||
expect(messages[key], `${file}: ${key}`).not.toBe('');
|
||||
expect(messages[key], `${file}: ${key}`).not.toMatch(/[—–]/);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,531 @@
|
||||
(function initKoalaSyncChatOverlay() {
|
||||
if (window.koalaSyncChatOverlay?.refresh) {
|
||||
window.koalaSyncChatOverlay.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
const MAX_MESSAGES = 200;
|
||||
const MIN_WIDTH = 300;
|
||||
const MIN_HEIGHT = 320;
|
||||
const DEFAULT_WIDTH = 360;
|
||||
const DEFAULT_HEIGHT = 520;
|
||||
const storageKey = `chatOverlayLayout:${location.origin}`;
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: light)');
|
||||
let context = null;
|
||||
let opened = false;
|
||||
let destroyed = false;
|
||||
let saveTimer = null;
|
||||
let applyingLayout = false;
|
||||
let refreshGeneration = 0;
|
||||
let sendGeneration = 0;
|
||||
let sending = false;
|
||||
let layout = { mode: 'right', x: 24, y: 72, width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, detachedInitialized: false };
|
||||
let themeMode = 'system';
|
||||
let themePalette = 'eucalyptus';
|
||||
|
||||
const host = document.createElement('div');
|
||||
host.id = 'koalasync-chat-overlay-host';
|
||||
host.style.cssText = 'all:initial;display:none;position:fixed;inset:0;z-index:2147483647;pointer-events:none;contain:layout style paint;transform:translateZ(0);';
|
||||
const shadow = host.attachShadow({ mode: 'open' });
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
:host { all: initial; }
|
||||
* { box-sizing: border-box; }
|
||||
#app {
|
||||
--bg: oklch(0.205 0.025 155); --card: oklch(0.30 0.035 150);
|
||||
--surface-alt: oklch(0.265 0.032 150); --surface-deep: oklch(0.205 0.025 155);
|
||||
--accent: oklch(0.73 0.13 155); --accent-hover: oklch(0.79 0.12 155);
|
||||
--text: oklch(0.96 0.01 90); --text-muted: oklch(0.78 0.02 90);
|
||||
--border-soft: oklch(0.88 0.025 145 / 0.09); --border-strong: oklch(0.39 0.038 145);
|
||||
--text-on-green: oklch(0.14 0.02 90); --danger: oklch(0.63 0.18 25);
|
||||
position: fixed; inset: 0; pointer-events: none; color: var(--text);
|
||||
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
#app[data-theme="light"] {
|
||||
--bg: oklch(0.935 0.018 110); --card: oklch(0.995 0.006 100);
|
||||
--surface-alt: oklch(0.91 0.022 115); --surface-deep: oklch(0.935 0.018 110);
|
||||
--accent: oklch(0.52 0.13 155); --accent-hover: oklch(0.45 0.12 155);
|
||||
--text: oklch(0.21 0.03 140); --text-muted: oklch(0.43 0.03 135);
|
||||
--border-soft: oklch(0.28 0.035 140 / 0.10); --border-strong: oklch(0.73 0.035 125);
|
||||
--text-on-green: oklch(0.16 0.03 140);
|
||||
}
|
||||
#app[data-palette="cyber"] {
|
||||
--bg: oklch(0.185 0.04 265); --card: oklch(0.285 0.05 265);
|
||||
--surface-alt: oklch(0.25 0.048 265); --surface-deep: oklch(0.185 0.04 265);
|
||||
--accent: oklch(0.59 0.20 275); --accent-hover: oklch(0.69 0.17 275);
|
||||
--text: oklch(0.97 0.012 265); --text-muted: oklch(0.74 0.03 265);
|
||||
--border-soft: oklch(0.9 0.03 265 / 0.10); --border-strong: oklch(0.42 0.05 265);
|
||||
--text-on-green: oklch(0.99 0.006 275);
|
||||
}
|
||||
#app[data-palette="cyber"][data-theme="light"] {
|
||||
--bg: oklch(0.945 0.014 265); --card: oklch(0.995 0.005 265);
|
||||
--surface-alt: oklch(0.915 0.02 265); --surface-deep: oklch(0.945 0.014 265);
|
||||
--accent: oklch(0.53 0.21 275); --accent-hover: oklch(0.46 0.20 275);
|
||||
--text: oklch(0.26 0.05 265); --text-muted: oklch(0.48 0.045 265);
|
||||
--border-soft: oklch(0.30 0.05 265 / 0.12); --border-strong: oklch(0.79 0.03 265);
|
||||
--text-on-green: oklch(0.99 0.005 275);
|
||||
}
|
||||
#app[data-palette="graphite"] {
|
||||
--bg: oklch(0.165 0.004 260); --card: oklch(0.255 0.005 260);
|
||||
--surface-alt: oklch(0.225 0.005 260); --surface-deep: oklch(0.165 0.004 260);
|
||||
--accent: oklch(0.93 0.006 260); --accent-hover: oklch(0.99 0.003 260);
|
||||
--text: oklch(0.97 0.003 260); --text-muted: oklch(0.72 0.005 260);
|
||||
--border-soft: oklch(0.92 0.008 260 / 0.11); --border-strong: oklch(0.42 0.006 260);
|
||||
--text-on-green: oklch(0.20 0.004 260);
|
||||
}
|
||||
#app[data-palette="graphite"][data-theme="light"] {
|
||||
--bg: oklch(0.928 0.003 260); --card: oklch(1 0 0);
|
||||
--surface-alt: oklch(0.90 0.004 260); --surface-deep: oklch(0.928 0.003 260);
|
||||
--accent: oklch(0.28 0.006 260); --accent-hover: oklch(0.18 0.005 260);
|
||||
--text: oklch(0.22 0.005 260); --text-muted: oklch(0.46 0.006 260);
|
||||
--border-soft: oklch(0.20 0.006 260 / 0.13); --border-strong: oklch(0.77 0.004 260);
|
||||
--text-on-green: oklch(0.98 0.002 260);
|
||||
}
|
||||
button, textarea { font: inherit; }
|
||||
button { color: inherit; }
|
||||
.launcher {
|
||||
position: fixed; width: 48px; height: 48px; border: 1px solid var(--border-strong);
|
||||
border-radius: 16px; background: var(--card); color: var(--text); cursor: pointer;
|
||||
pointer-events: auto; box-shadow: 0 10px 28px rgb(0 0 0 / .32); font-size: 22px;
|
||||
}
|
||||
.launcher:hover:not([aria-disabled="true"]) { border-color: var(--accent); transform: translateY(-1px); }
|
||||
.launcher[aria-disabled="true"] { cursor: not-allowed; opacity: .58; }
|
||||
.panel {
|
||||
position: fixed; display: none; flex-direction: column; overflow: hidden;
|
||||
min-width: min(${MIN_WIDTH}px, calc(100vw - 16px)); min-height: min(${MIN_HEIGHT}px, calc(100vh - 16px)); max-width: calc(100vw - 16px);
|
||||
max-height: calc(100vh - 16px); pointer-events: auto; color: var(--text);
|
||||
background: var(--card); border: 1px solid var(--border-strong); border-radius: 18px;
|
||||
box-shadow: 0 18px 55px rgb(0 0 0 / .38); transform: translateZ(0);
|
||||
}
|
||||
.panel.open { display: flex; }
|
||||
.header { display: flex; align-items: center; gap: 10px; padding: 12px; border-bottom: 1px solid var(--border-soft); user-select: none; }
|
||||
.header.detached { cursor: move; }
|
||||
.heading { min-width: 0; flex: 1; }
|
||||
.title { font-size: 14px; font-weight: 760; }
|
||||
.subtitle { color: var(--text-muted); font-size: 11px; }
|
||||
.modes { display: flex; gap: 4px; }
|
||||
.icon-button { width: 30px; height: 30px; border: 1px solid var(--border-soft); border-radius: 9px; background: var(--surface-alt); cursor: pointer; }
|
||||
.icon-button:hover, .icon-button.active { border-color: var(--accent); color: var(--accent); }
|
||||
.messages { flex: 1; min-height: 0; overflow-y: auto; padding: 12px; background: var(--bg); overscroll-behavior: contain; }
|
||||
.empty { color: var(--text-muted); text-align: center; padding: 32px 12px; font-size: 12px; }
|
||||
.message { margin-bottom: 11px; overflow-wrap: anywhere; }
|
||||
.meta { display: flex; align-items: baseline; gap: 7px; margin-bottom: 2px; }
|
||||
.sender { color: var(--accent); font-weight: 700; font-size: 12px; }
|
||||
.time { color: var(--text-muted); font-size: 10px; }
|
||||
.body { white-space: pre-wrap; color: var(--text); }
|
||||
.composer { padding: 10px; border-top: 1px solid var(--border-soft); background: var(--card); }
|
||||
textarea { width: 100%; min-height: 62px; max-height: 132px; resize: vertical; border: 1px solid var(--border-strong); border-radius: 11px; padding: 9px; background: var(--surface-deep); color: var(--text); outline: none; }
|
||||
textarea:focus { border-color: var(--accent); }
|
||||
textarea::placeholder { color: var(--text-muted); }
|
||||
.composer-row { display: flex; align-items: center; gap: 8px; margin-top: 7px; }
|
||||
.count { flex: 1; color: var(--text-muted); font-size: 10px; }
|
||||
.status { color: var(--danger); font-size: 10px; min-height: 14px; }
|
||||
.send { border: 0; border-radius: 10px; padding: 8px 13px; background: var(--accent); color: var(--text-on-green); font-weight: 750; cursor: pointer; }
|
||||
.send:hover:not(:disabled) { background: var(--accent-hover); }
|
||||
.send:disabled { opacity: .55; cursor: wait; }
|
||||
.visually-hidden { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
@media (max-width: 520px) { .panel { max-width: calc(100vw - 12px); } }
|
||||
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
|
||||
`;
|
||||
|
||||
function element(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
const app = element('div');
|
||||
app.id = 'app';
|
||||
const launcher = element('button', 'launcher', '💬');
|
||||
launcher.type = 'button';
|
||||
const launcherHint = element('span', 'visually-hidden');
|
||||
launcherHint.id = 'chat-launcher-hint';
|
||||
const panel = element('section', 'panel');
|
||||
panel.setAttribute('role', 'dialog');
|
||||
const header = element('header', 'header');
|
||||
const heading = element('div', 'heading');
|
||||
const title = element('div', 'title');
|
||||
const subtitle = element('div', 'subtitle');
|
||||
heading.append(title, subtitle);
|
||||
const modes = element('div', 'modes');
|
||||
const leftButton = element('button', 'icon-button', '←');
|
||||
const detachedButton = element('button', 'icon-button', '↗');
|
||||
const rightButton = element('button', 'icon-button', '→');
|
||||
const closeButton = element('button', 'icon-button', '×');
|
||||
for (const button of [leftButton, detachedButton, rightButton, closeButton]) button.type = 'button';
|
||||
modes.append(leftButton, detachedButton, rightButton, closeButton);
|
||||
header.append(heading, modes);
|
||||
const messages = element('div', 'messages');
|
||||
messages.setAttribute('aria-live', 'polite');
|
||||
const empty = element('div', 'empty');
|
||||
messages.append(empty);
|
||||
const composer = element('form', 'composer');
|
||||
const textareaLabel = element('label', 'visually-hidden');
|
||||
textareaLabel.htmlFor = 'chat-composer-input';
|
||||
const textarea = element('textarea');
|
||||
textarea.id = 'chat-composer-input';
|
||||
textarea.setAttribute('aria-describedby', 'chat-composer-count chat-composer-status');
|
||||
const composerRow = element('div', 'composer-row');
|
||||
const count = element('span', 'count', '0/500');
|
||||
count.id = 'chat-composer-count';
|
||||
const sendButton = element('button', 'send');
|
||||
sendButton.type = 'submit';
|
||||
composerRow.append(count, sendButton);
|
||||
const status = element('div', 'status');
|
||||
status.id = 'chat-composer-status';
|
||||
status.setAttribute('role', 'status');
|
||||
composer.append(textareaLabel, textarea, composerRow, status);
|
||||
panel.append(header, messages, composer);
|
||||
app.append(launcherHint, launcher, panel);
|
||||
shadow.append(style, app);
|
||||
document.documentElement.append(host);
|
||||
|
||||
function messageRuntime(payload, timeoutMs = 5000) {
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
const finish = response => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
resolve(response || null);
|
||||
};
|
||||
const timeout = setTimeout(() => finish(null), timeoutMs);
|
||||
try {
|
||||
chrome.runtime.sendMessage(payload, response => {
|
||||
if (chrome.runtime.lastError) finish(null);
|
||||
else finish(response);
|
||||
});
|
||||
} catch (_) {
|
||||
finish(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function strings() {
|
||||
return context?.strings || {};
|
||||
}
|
||||
|
||||
function applyStrings() {
|
||||
const text = strings();
|
||||
title.textContent = text.title || '';
|
||||
subtitle.textContent = text.liveOnly || '';
|
||||
launcher.setAttribute('aria-label', text.open || '');
|
||||
const unavailableHint = context?.supported && !context?.hasKey
|
||||
? (text.missingKey || '')
|
||||
: context?.supported && !context?.connected ? (text.sendFailed || '') : '';
|
||||
launcher.title = unavailableHint || text.open || '';
|
||||
launcherHint.textContent = unavailableHint;
|
||||
if (unavailableHint) launcher.setAttribute('aria-describedby', launcherHint.id);
|
||||
else launcher.removeAttribute('aria-describedby');
|
||||
panel.setAttribute('aria-label', text.title || '');
|
||||
textarea.placeholder = text.placeholder || '';
|
||||
textareaLabel.textContent = text.placeholder || text.title || '';
|
||||
sendButton.textContent = text.send || '';
|
||||
empty.textContent = text.empty || '';
|
||||
leftButton.title = text.dockLeft || '';
|
||||
detachedButton.title = text.detached || '';
|
||||
rightButton.title = text.dockRight || '';
|
||||
closeButton.title = text.close || '';
|
||||
leftButton.setAttribute('aria-label', text.dockLeft || '');
|
||||
detachedButton.setAttribute('aria-label', text.detached || '');
|
||||
rightButton.setAttribute('aria-label', text.dockRight || '');
|
||||
closeButton.setAttribute('aria-label', text.close || '');
|
||||
}
|
||||
|
||||
function applyTheme() {
|
||||
const light = themeMode === 'light' || (themeMode === 'system' && systemTheme.matches);
|
||||
app.dataset.theme = light ? 'light' : 'dark';
|
||||
app.dataset.palette = ['eucalyptus', 'cyber', 'graphite'].includes(themePalette) ? themePalette : 'eucalyptus';
|
||||
}
|
||||
|
||||
function viewportBounds() {
|
||||
return { width: Math.max(1, window.innerWidth), height: Math.max(1, window.innerHeight) };
|
||||
}
|
||||
|
||||
function clampDetached() {
|
||||
const viewport = viewportBounds();
|
||||
const maxWidth = Math.max(1, Math.min(600, viewport.width - 16));
|
||||
const maxHeight = Math.max(1, viewport.height - 16);
|
||||
const horizontalInset = Math.min(8, Math.max(0, Math.floor((viewport.width - 1) / 2)));
|
||||
const verticalInset = Math.min(8, Math.max(0, Math.floor((viewport.height - 1) / 2)));
|
||||
layout.width = Math.min(Math.max(Number(layout.width) || DEFAULT_WIDTH, Math.min(MIN_WIDTH, maxWidth)), maxWidth);
|
||||
layout.height = Math.min(Math.max(Number(layout.height) || DEFAULT_HEIGHT, Math.min(MIN_HEIGHT, maxHeight)), maxHeight);
|
||||
layout.x = Math.min(Math.max(Number(layout.x) || horizontalInset, horizontalInset), Math.max(horizontalInset, viewport.width - layout.width - horizontalInset));
|
||||
layout.y = Math.min(Math.max(Number(layout.y) || verticalInset, verticalInset), Math.max(verticalInset, viewport.height - layout.height - verticalInset));
|
||||
}
|
||||
|
||||
function saveLayout() {
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => {
|
||||
saveTimer = null;
|
||||
chrome.storage.local.set({ [storageKey]: layout }).catch(() => {});
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function applyLayout() {
|
||||
applyingLayout = true;
|
||||
panel.style.right = 'auto';
|
||||
panel.style.left = 'auto';
|
||||
panel.style.bottom = 'auto';
|
||||
panel.style.resize = 'none';
|
||||
header.classList.toggle('detached', layout.mode === 'detached');
|
||||
leftButton.classList.toggle('active', layout.mode === 'left');
|
||||
detachedButton.classList.toggle('active', layout.mode === 'detached');
|
||||
rightButton.classList.toggle('active', layout.mode === 'right');
|
||||
if (layout.mode === 'detached') {
|
||||
clampDetached();
|
||||
panel.style.left = `${layout.x}px`;
|
||||
panel.style.top = `${layout.y}px`;
|
||||
panel.style.width = `${layout.width}px`;
|
||||
panel.style.height = `${layout.height}px`;
|
||||
panel.style.resize = 'both';
|
||||
launcher.style.left = `${layout.x}px`;
|
||||
launcher.style.right = 'auto';
|
||||
launcher.style.top = `${layout.y}px`;
|
||||
} else {
|
||||
const viewport = viewportBounds();
|
||||
const gutter = Math.min(16, Math.max(0, Math.floor((viewport.width - 1) / 2)));
|
||||
const panelTop = viewport.height < MIN_HEIGHT + 80 ? Math.min(8, Math.max(0, viewport.height - 1)) : 64;
|
||||
panel.style.top = `${panelTop}px`;
|
||||
panel.style.width = `${Math.max(1, Math.min(DEFAULT_WIDTH, viewport.width - gutter * 2))}px`;
|
||||
panel.style.height = `${Math.max(1, Math.min(560, viewport.height - panelTop - Math.min(16, Math.max(0, viewport.height - panelTop - 1))))}px`;
|
||||
panel.style[layout.mode] = `${gutter}px`;
|
||||
launcher.style.top = `${Math.max(0, Math.min(viewport.height - 48, viewport.height / 2 - 24))}px`;
|
||||
launcher.style.left = layout.mode === 'left' ? `${gutter}px` : 'auto';
|
||||
launcher.style.right = layout.mode === 'right' ? `${gutter}px` : 'auto';
|
||||
}
|
||||
globalThis.queueMicrotask(() => { applyingLayout = false; });
|
||||
}
|
||||
|
||||
function setMode(mode) {
|
||||
if (!['left', 'right', 'detached'].includes(mode)) return;
|
||||
if (mode === 'detached' && !layout.detachedInitialized) {
|
||||
layout.x = Math.max(8, Math.round((window.innerWidth - DEFAULT_WIDTH) / 2));
|
||||
layout.y = Math.max(8, Math.round((window.innerHeight - DEFAULT_HEIGHT) / 2));
|
||||
layout.detachedInitialized = true;
|
||||
}
|
||||
layout.mode = mode;
|
||||
applyLayout();
|
||||
saveLayout();
|
||||
}
|
||||
|
||||
function setOpened(next) {
|
||||
opened = !!next && !!context?.enabled;
|
||||
panel.classList.toggle('open', opened);
|
||||
launcher.style.display = opened ? 'none' : '';
|
||||
if (opened) {
|
||||
textarea.focus();
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function applyContext(next) {
|
||||
const previousRoomId = context?.roomId;
|
||||
context = next || null;
|
||||
const supported = !!context?.supported;
|
||||
const optedIn = !!context?.enabled;
|
||||
const hasKey = !!context?.hasKey;
|
||||
const connected = !!context?.connected;
|
||||
context = context ? { ...context, enabled: supported && optedIn && hasKey && connected } : null;
|
||||
if (previousRoomId && previousRoomId !== context?.roomId) clearMessages();
|
||||
host.style.display = supported && optedIn ? '' : 'none';
|
||||
launcher.setAttribute('aria-disabled', String(!context?.enabled));
|
||||
if (!context?.enabled) setOpened(false);
|
||||
applyStrings();
|
||||
applyLayout();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (destroyed) return;
|
||||
const generation = ++refreshGeneration;
|
||||
const next = await messageRuntime({ type: 'GET_CHAT_CONTEXT' });
|
||||
if (destroyed || generation !== refreshGeneration) return;
|
||||
applyContext(next);
|
||||
}
|
||||
|
||||
function appendMessage(message) {
|
||||
if (!context?.enabled || !message || typeof message.text !== 'string') return;
|
||||
if (empty.isConnected) empty.remove();
|
||||
const wrapper = element('article', 'message');
|
||||
const meta = element('div', 'meta');
|
||||
const own = message.senderId === context.peerId;
|
||||
const sender = element('span', 'sender', own ? (strings().you || '') : (message.username || message.senderId || ''));
|
||||
const time = element('time', 'time');
|
||||
const date = new Date(message.timestamp);
|
||||
time.textContent = Number.isFinite(date.getTime()) ? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
|
||||
meta.append(sender, time);
|
||||
const body = element('div', 'body');
|
||||
const tokens = globalThis.KoalaSyncChatFormat?.tokenizeChatText(message.text) || [{ type: 'text', text: message.text }];
|
||||
for (const token of tokens) {
|
||||
const node = token.type === 'strong' ? document.createElement('strong') : token.type === 'em' ? document.createElement('em') : document.createTextNode(token.text);
|
||||
if (node.nodeType === 1) node.textContent = token.text;
|
||||
body.append(node);
|
||||
}
|
||||
wrapper.append(meta, body);
|
||||
messages.append(wrapper);
|
||||
while (messages.querySelectorAll('.message').length > MAX_MESSAGES) messages.querySelector('.message')?.remove();
|
||||
messages.scrollTop = messages.scrollHeight;
|
||||
}
|
||||
|
||||
function clearMessages() {
|
||||
messages.replaceChildren(empty);
|
||||
}
|
||||
|
||||
function resetComposer() {
|
||||
sendGeneration++;
|
||||
sending = false;
|
||||
textarea.value = '';
|
||||
count.textContent = '0/500';
|
||||
count.style.color = '';
|
||||
status.textContent = '';
|
||||
sendButton.disabled = false;
|
||||
}
|
||||
|
||||
launcher.addEventListener('click', () => setOpened(true));
|
||||
closeButton.addEventListener('click', () => setOpened(false));
|
||||
leftButton.addEventListener('click', () => setMode('left'));
|
||||
detachedButton.addEventListener('click', () => setMode('detached'));
|
||||
rightButton.addEventListener('click', () => setMode('right'));
|
||||
|
||||
textarea.addEventListener('input', () => {
|
||||
const length = [...textarea.value].length;
|
||||
count.textContent = `${length}/500`;
|
||||
count.style.color = length > 500 ? 'var(--danger)' : '';
|
||||
status.textContent = length > 500 ? (strings().tooLong || '') : '';
|
||||
});
|
||||
textarea.addEventListener('keydown', event => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
if (!sending) composer.requestSubmit();
|
||||
}
|
||||
});
|
||||
composer.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
if (sending || !context?.enabled) return;
|
||||
const submittedValue = textarea.value;
|
||||
const text = textarea.value.trim();
|
||||
if (!text) return;
|
||||
if ([...text].length > 500) {
|
||||
status.textContent = strings().tooLong || '';
|
||||
return;
|
||||
}
|
||||
const generation = ++sendGeneration;
|
||||
sending = true;
|
||||
sendButton.disabled = true;
|
||||
status.textContent = '';
|
||||
const response = await messageRuntime({ type: 'CHAT_SEND', text });
|
||||
if (destroyed || generation !== sendGeneration) return;
|
||||
sending = false;
|
||||
sendButton.disabled = false;
|
||||
if (response?.status === 'ok') {
|
||||
if (textarea.value === submittedValue) {
|
||||
textarea.value = '';
|
||||
count.textContent = '0/500';
|
||||
}
|
||||
} else {
|
||||
status.textContent = response?.status === 'too_long' ? (strings().tooLong || '') : (strings().sendFailed || '');
|
||||
}
|
||||
});
|
||||
|
||||
let drag = null;
|
||||
header.addEventListener('pointerdown', event => {
|
||||
if (layout.mode !== 'detached' || event.target.closest('button')) return;
|
||||
drag = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, x: layout.x, y: layout.y };
|
||||
header.setPointerCapture(event.pointerId);
|
||||
});
|
||||
header.addEventListener('pointermove', event => {
|
||||
if (!drag || event.pointerId !== drag.pointerId) return;
|
||||
layout.x = drag.x + event.clientX - drag.startX;
|
||||
layout.y = drag.y + event.clientY - drag.startY;
|
||||
clampDetached();
|
||||
applyLayout();
|
||||
});
|
||||
header.addEventListener('pointerup', event => {
|
||||
if (!drag || event.pointerId !== drag.pointerId) return;
|
||||
drag = null;
|
||||
saveLayout();
|
||||
});
|
||||
|
||||
const resizeObserver = new globalThis.ResizeObserver(() => {
|
||||
if (applyingLayout || layout.mode !== 'detached') return;
|
||||
const rect = panel.getBoundingClientRect();
|
||||
if (!opened || !rect?.width || !rect.height) return;
|
||||
layout.width = rect.width;
|
||||
layout.height = rect.height;
|
||||
clampDetached();
|
||||
saveLayout();
|
||||
});
|
||||
resizeObserver.observe(panel);
|
||||
|
||||
function moveIntoFullscreen() {
|
||||
const parent = document.fullscreenElement || document.documentElement;
|
||||
if (parent && host.parentNode !== parent) parent.append(host);
|
||||
globalThis.requestAnimationFrame(applyLayout);
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
if (layout.mode === 'detached') {
|
||||
clampDetached();
|
||||
applyLayout();
|
||||
saveLayout();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSystemTheme() {
|
||||
if (themeMode === 'system') applyTheme();
|
||||
}
|
||||
|
||||
function handleStorage(changes, area) {
|
||||
if (area !== 'local') return;
|
||||
if (changes.themeMode) themeMode = changes.themeMode.newValue || 'system';
|
||||
if (changes.themePalette) themePalette = changes.themePalette.newValue || 'eucalyptus';
|
||||
if (changes.themeMode || changes.themePalette) applyTheme();
|
||||
if (changes.locale) refresh();
|
||||
}
|
||||
|
||||
function handleRuntime(message) {
|
||||
if (message?.type === 'CHAT_MESSAGE') appendMessage(message.message);
|
||||
if (message?.type === 'CHAT_CONTEXT_UPDATE' || message?.type === 'CONNECTION_STATUS') refresh();
|
||||
if (message?.type === 'CHAT_RESET') {
|
||||
clearMessages();
|
||||
resetComposer();
|
||||
refresh();
|
||||
}
|
||||
if (message?.type === 'CHAT_DESTROY') destroy();
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
refreshGeneration++;
|
||||
sendGeneration++;
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
resizeObserver.disconnect();
|
||||
document.removeEventListener('fullscreenchange', moveIntoFullscreen);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
systemTheme.removeEventListener('change', handleSystemTheme);
|
||||
chrome.storage.onChanged.removeListener(handleStorage);
|
||||
chrome.runtime.onMessage.removeListener(handleRuntime);
|
||||
host.remove();
|
||||
window.koalaSyncChatOverlay = null;
|
||||
}
|
||||
|
||||
document.addEventListener('fullscreenchange', moveIntoFullscreen);
|
||||
window.addEventListener('resize', handleResize, { passive: true });
|
||||
systemTheme.addEventListener('change', handleSystemTheme);
|
||||
chrome.storage.onChanged.addListener(handleStorage);
|
||||
chrome.runtime.onMessage.addListener(handleRuntime);
|
||||
chrome.storage.local.get([storageKey, 'themeMode', 'themePalette'], data => {
|
||||
if (data[storageKey] && typeof data[storageKey] === 'object') layout = { ...layout, ...data[storageKey] };
|
||||
if (!['left', 'right', 'detached'].includes(layout.mode)) layout.mode = 'right';
|
||||
if (layout.mode === 'detached') layout.detachedInitialized = true;
|
||||
themeMode = data.themeMode || 'system';
|
||||
themePalette = data.themePalette || 'eucalyptus';
|
||||
applyTheme();
|
||||
applyLayout();
|
||||
});
|
||||
|
||||
window.koalaSyncChatOverlay = { refresh, destroy };
|
||||
refresh();
|
||||
})();
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
export function buildChatRelayPayload(ciphertext) {
|
||||
return { ciphertext };
|
||||
}
|
||||
|
||||
export function encodeSocketEvent(event, data, forbiddenSecret = '') {
|
||||
const payload = JSON.stringify([event, data]);
|
||||
if (forbiddenSecret && payload.includes(forbiddenSecret)) {
|
||||
throw new Error('Refusing to send chat secret to relay');
|
||||
}
|
||||
return `42${payload}`;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildChatRelayPayload, encodeSocketEvent } from './chat-wire.js';
|
||||
|
||||
describe('chat wire boundary', () => {
|
||||
const secret = 'R5Ti1nxp0crfAFHf3gVncw';
|
||||
|
||||
it('sends only ciphertext for chat messages', () => {
|
||||
expect(buildChatRelayPayload('ciphertext')).toEqual({ ciphertext: 'ciphertext' });
|
||||
});
|
||||
|
||||
it('rejects the secret anywhere in any outgoing relay event', () => {
|
||||
for (const [event, payload] of [
|
||||
['join_room', { roomId: 'ROOM', password: 'PASS', chatKey: secret }],
|
||||
['peer_status', { status: 'heartbeat', nested: { secret } }],
|
||||
['chat_message', { ciphertext: `prefix-${secret}-suffix` }]
|
||||
]) {
|
||||
expect(() => encodeSocketEvent(event, payload, secret)).toThrow('Refusing to send chat secret');
|
||||
}
|
||||
expect(encodeSocketEvent('join_room', { roomId: 'ROOM', password: 'PASS' }, secret)).not.toContain(secret);
|
||||
});
|
||||
});
|
||||
@@ -245,5 +245,20 @@
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "Profil & Aussehen",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Ändere deinen Benutzernamen, dein Design und deine Spracheinstellungen.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Wiedergabe & Sync",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Passe Wiedergabe-, Benachrichtigungs- und Audioeinstellungen an."
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Passe Wiedergabe-, Benachrichtigungs- und Audioeinstellungen an.",
|
||||
"LABEL_CHAT_ENABLED": "Raum-Chat aktivieren",
|
||||
"LABEL_CHAT_ENABLED_TOOLTIP": "Verschlüsselten Raum-Chat im ausgewählten Video-Tab anzeigen.",
|
||||
"CHAT_TITLE": "Raum-Chat",
|
||||
"CHAT_LIVE_ONLY": "Nur live. Kein Verlauf.",
|
||||
"CHAT_OPEN": "Raum-Chat öffnen",
|
||||
"CHAT_CLOSE": "Chat schließen",
|
||||
"CHAT_DOCK_LEFT": "Chat links andocken",
|
||||
"CHAT_DOCK_RIGHT": "Chat rechts andocken",
|
||||
"CHAT_DETACHED": "Chat frei positionieren",
|
||||
"CHAT_PLACEHOLDER": "Nachricht schreiben...",
|
||||
"CHAT_SEND": "Senden",
|
||||
"CHAT_MISSING_KEY": "Chat ist nur über einen aktuellen Einladungslink verfügbar.",
|
||||
"CHAT_TOO_LONG": "Maximal 500 Zeichen.",
|
||||
"CHAT_SEND_FAILED": "Nachricht konnte nicht gesendet werden.",
|
||||
"CHAT_EMPTY": "Noch keine Nachrichten. Der Chat ist nur live."
|
||||
}
|
||||
|
||||
@@ -245,5 +245,20 @@
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "Profile & Appearance",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Change your username, theme, and language preferences.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Playback & Sync",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Customize playback, notifications, and audio preferences."
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Customize playback, notifications, and audio preferences.",
|
||||
"LABEL_CHAT_ENABLED": "Enable Room Chat",
|
||||
"LABEL_CHAT_ENABLED_TOOLTIP": "Show encrypted room chat on the selected video tab.",
|
||||
"CHAT_TITLE": "Room Chat",
|
||||
"CHAT_LIVE_ONLY": "Live only. No history.",
|
||||
"CHAT_OPEN": "Open room chat",
|
||||
"CHAT_CLOSE": "Close chat",
|
||||
"CHAT_DOCK_LEFT": "Dock chat left",
|
||||
"CHAT_DOCK_RIGHT": "Dock chat right",
|
||||
"CHAT_DETACHED": "Detach chat",
|
||||
"CHAT_PLACEHOLDER": "Write a message...",
|
||||
"CHAT_SEND": "Send",
|
||||
"CHAT_MISSING_KEY": "Chat is available only through a current invite link.",
|
||||
"CHAT_TOO_LONG": "Maximum 500 characters.",
|
||||
"CHAT_SEND_FAILED": "Message could not be sent.",
|
||||
"CHAT_EMPTY": "No messages yet. Chat is live only."
|
||||
}
|
||||
|
||||
@@ -245,5 +245,20 @@
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "Perfil y Apariencia",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Cambia tu nombre de usuario, tema y preferencias de idioma.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Reproducción y Sincronización",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personaliza la reproducción, las notificaciones y las preferencias de audio."
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personaliza la reproducción, las notificaciones y las preferencias de audio.",
|
||||
"LABEL_CHAT_ENABLED": "Activar chat de la sala",
|
||||
"LABEL_CHAT_ENABLED_TOOLTIP": "Mostrar el chat cifrado de la sala en la pestaña de vídeo seleccionada.",
|
||||
"CHAT_TITLE": "Chat de la sala",
|
||||
"CHAT_LIVE_ONLY": "Solo en directo. Sin historial.",
|
||||
"CHAT_OPEN": "Abrir chat de la sala",
|
||||
"CHAT_CLOSE": "Cerrar chat",
|
||||
"CHAT_DOCK_LEFT": "Acoplar chat a la izquierda",
|
||||
"CHAT_DOCK_RIGHT": "Acoplar chat a la derecha",
|
||||
"CHAT_DETACHED": "Separar chat",
|
||||
"CHAT_PLACEHOLDER": "Escribe un mensaje...",
|
||||
"CHAT_SEND": "Enviar",
|
||||
"CHAT_MISSING_KEY": "El chat solo está disponible mediante un enlace de invitación actual.",
|
||||
"CHAT_TOO_LONG": "Máximo 500 caracteres.",
|
||||
"CHAT_SEND_FAILED": "No se pudo enviar el mensaje.",
|
||||
"CHAT_EMPTY": "Aún no hay mensajes. El chat solo es en directo."
|
||||
}
|
||||
|
||||
@@ -245,5 +245,20 @@
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "Profil & Apparence",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Modifiez votre nom d'utilisateur, votre thème et vos préférences linguistiques.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Lecture & Sync",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personnalisez la lecture, les notifications et les préférences audio."
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personnalisez la lecture, les notifications et les préférences audio.",
|
||||
"LABEL_CHAT_ENABLED": "Activer le chat du salon",
|
||||
"LABEL_CHAT_ENABLED_TOOLTIP": "Afficher le chat chiffré du salon dans l'onglet vidéo sélectionné.",
|
||||
"CHAT_TITLE": "Chat du salon",
|
||||
"CHAT_LIVE_ONLY": "En direct uniquement. Aucun historique.",
|
||||
"CHAT_OPEN": "Ouvrir le chat du salon",
|
||||
"CHAT_CLOSE": "Fermer le chat",
|
||||
"CHAT_DOCK_LEFT": "Ancrer le chat à gauche",
|
||||
"CHAT_DOCK_RIGHT": "Ancrer le chat à droite",
|
||||
"CHAT_DETACHED": "Détacher le chat",
|
||||
"CHAT_PLACEHOLDER": "Écrire un message...",
|
||||
"CHAT_SEND": "Envoyer",
|
||||
"CHAT_MISSING_KEY": "Le chat est disponible uniquement via un lien d'invitation actuel.",
|
||||
"CHAT_TOO_LONG": "500 caractères maximum.",
|
||||
"CHAT_SEND_FAILED": "Le message n'a pas pu être envoyé.",
|
||||
"CHAT_EMPTY": "Aucun message. Le chat est uniquement en direct."
|
||||
}
|
||||
|
||||
@@ -245,5 +245,20 @@
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "Profilo & Aspetto",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Modifica il tuo nome utente, tema e preferenze di lingua.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Riproduzione & Sync",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalizza la riproduzione, le notifiche e le preferenze audio."
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalizza la riproduzione, le notifiche e le preferenze audio.",
|
||||
"LABEL_CHAT_ENABLED": "Attiva chat della stanza",
|
||||
"LABEL_CHAT_ENABLED_TOOLTIP": "Mostra la chat crittografata della stanza nella scheda video selezionata.",
|
||||
"CHAT_TITLE": "Chat della stanza",
|
||||
"CHAT_LIVE_ONLY": "Solo in diretta. Nessuna cronologia.",
|
||||
"CHAT_OPEN": "Apri la chat della stanza",
|
||||
"CHAT_CLOSE": "Chiudi chat",
|
||||
"CHAT_DOCK_LEFT": "Aggancia chat a sinistra",
|
||||
"CHAT_DOCK_RIGHT": "Aggancia chat a destra",
|
||||
"CHAT_DETACHED": "Sgancia chat",
|
||||
"CHAT_PLACEHOLDER": "Scrivi un messaggio...",
|
||||
"CHAT_SEND": "Invia",
|
||||
"CHAT_MISSING_KEY": "La chat è disponibile solo tramite un link di invito attuale.",
|
||||
"CHAT_TOO_LONG": "Massimo 500 caratteri.",
|
||||
"CHAT_SEND_FAILED": "Impossibile inviare il messaggio.",
|
||||
"CHAT_EMPTY": "Nessun messaggio. La chat è solo in diretta."
|
||||
}
|
||||
|
||||
@@ -245,5 +245,20 @@
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "プロフィールと外観",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "ユーザー名、テーマ、言語設定を変更します。",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "再生と同期",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "再生、通知、オーディオ設定をカスタマイズします。"
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "再生、通知、オーディオ設定をカスタマイズします。",
|
||||
"LABEL_CHAT_ENABLED": "ルームチャットを有効化",
|
||||
"LABEL_CHAT_ENABLED_TOOLTIP": "選択した動画タブに暗号化されたルームチャットを表示します。",
|
||||
"CHAT_TITLE": "ルームチャット",
|
||||
"CHAT_LIVE_ONLY": "ライブのみ。履歴はありません。",
|
||||
"CHAT_OPEN": "ルームチャットを開く",
|
||||
"CHAT_CLOSE": "チャットを閉じる",
|
||||
"CHAT_DOCK_LEFT": "チャットを左に固定",
|
||||
"CHAT_DOCK_RIGHT": "チャットを右に固定",
|
||||
"CHAT_DETACHED": "チャットを切り離す",
|
||||
"CHAT_PLACEHOLDER": "メッセージを入力...",
|
||||
"CHAT_SEND": "送信",
|
||||
"CHAT_MISSING_KEY": "チャットは最新の招待リンクからのみ利用できます。",
|
||||
"CHAT_TOO_LONG": "最大500文字です。",
|
||||
"CHAT_SEND_FAILED": "メッセージを送信できませんでした。",
|
||||
"CHAT_EMPTY": "まだメッセージはありません。チャットはライブのみです。"
|
||||
}
|
||||
|
||||
@@ -245,5 +245,20 @@
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "프로필 및 테마",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "사용자 이름, 테마 및 언어 설정을 변경합니다.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "재생 및 동기화",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "재생, 알림 및 오디오 설정을 맞춤설정합니다."
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "재생, 알림 및 오디오 설정을 맞춤설정합니다.",
|
||||
"LABEL_CHAT_ENABLED": "방 채팅 사용",
|
||||
"LABEL_CHAT_ENABLED_TOOLTIP": "선택한 동영상 탭에 암호화된 방 채팅을 표시합니다.",
|
||||
"CHAT_TITLE": "방 채팅",
|
||||
"CHAT_LIVE_ONLY": "실시간 전용. 기록 없음.",
|
||||
"CHAT_OPEN": "방 채팅 열기",
|
||||
"CHAT_CLOSE": "채팅 닫기",
|
||||
"CHAT_DOCK_LEFT": "채팅을 왼쪽에 고정",
|
||||
"CHAT_DOCK_RIGHT": "채팅을 오른쪽에 고정",
|
||||
"CHAT_DETACHED": "채팅 분리",
|
||||
"CHAT_PLACEHOLDER": "메시지를 입력하세요...",
|
||||
"CHAT_SEND": "보내기",
|
||||
"CHAT_MISSING_KEY": "채팅은 최신 초대 링크를 통해서만 사용할 수 있습니다.",
|
||||
"CHAT_TOO_LONG": "최대 500자입니다.",
|
||||
"CHAT_SEND_FAILED": "메시지를 보낼 수 없습니다.",
|
||||
"CHAT_EMPTY": "아직 메시지가 없습니다. 채팅은 실시간 전용입니다."
|
||||
}
|
||||
|
||||
@@ -245,5 +245,20 @@
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "Profiel & Weergave",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Wijzig uw gebruikersnaam, thema en taalvoorkeuren.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Afspelen & Sync",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Pas afspelen, meldingen en audiovoorkeuren aan."
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Pas afspelen, meldingen en audiovoorkeuren aan.",
|
||||
"LABEL_CHAT_ENABLED": "Kamerchat inschakelen",
|
||||
"LABEL_CHAT_ENABLED_TOOLTIP": "Versleutelde kamerchat tonen in het geselecteerde videotabblad.",
|
||||
"CHAT_TITLE": "Kamerchat",
|
||||
"CHAT_LIVE_ONLY": "Alleen live. Geen geschiedenis.",
|
||||
"CHAT_OPEN": "Kamerchat openen",
|
||||
"CHAT_CLOSE": "Chat sluiten",
|
||||
"CHAT_DOCK_LEFT": "Chat links vastzetten",
|
||||
"CHAT_DOCK_RIGHT": "Chat rechts vastzetten",
|
||||
"CHAT_DETACHED": "Chat losmaken",
|
||||
"CHAT_PLACEHOLDER": "Schrijf een bericht...",
|
||||
"CHAT_SEND": "Verzenden",
|
||||
"CHAT_MISSING_KEY": "Chat is alleen beschikbaar via een actuele uitnodigingslink.",
|
||||
"CHAT_TOO_LONG": "Maximaal 500 tekens.",
|
||||
"CHAT_SEND_FAILED": "Bericht kon niet worden verzonden.",
|
||||
"CHAT_EMPTY": "Nog geen berichten. Chat is alleen live."
|
||||
}
|
||||
|
||||
@@ -245,5 +245,20 @@
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "Profil i Wygląd",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Zmień swoją nazwę użytkownika, motyw i preferencje językowe.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Odtwarzanie i Sync",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Dostosuj odtwarzanie, powiadomienia i preferencje audio."
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Dostosuj odtwarzanie, powiadomienia i preferencje audio.",
|
||||
"LABEL_CHAT_ENABLED": "Włącz czat pokoju",
|
||||
"LABEL_CHAT_ENABLED_TOOLTIP": "Pokaż zaszyfrowany czat pokoju na wybranej karcie wideo.",
|
||||
"CHAT_TITLE": "Czat pokoju",
|
||||
"CHAT_LIVE_ONLY": "Tylko na żywo. Bez historii.",
|
||||
"CHAT_OPEN": "Otwórz czat pokoju",
|
||||
"CHAT_CLOSE": "Zamknij czat",
|
||||
"CHAT_DOCK_LEFT": "Przypnij czat po lewej",
|
||||
"CHAT_DOCK_RIGHT": "Przypnij czat po prawej",
|
||||
"CHAT_DETACHED": "Odłącz czat",
|
||||
"CHAT_PLACEHOLDER": "Napisz wiadomość...",
|
||||
"CHAT_SEND": "Wyślij",
|
||||
"CHAT_MISSING_KEY": "Czat jest dostępny tylko przez aktualny link zaproszenia.",
|
||||
"CHAT_TOO_LONG": "Maksymalnie 500 znaków.",
|
||||
"CHAT_SEND_FAILED": "Nie udało się wysłać wiadomości.",
|
||||
"CHAT_EMPTY": "Brak wiadomości. Czat działa tylko na żywo."
|
||||
}
|
||||
|
||||
@@ -245,5 +245,20 @@
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "Perfil e Aparência",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Altere o seu nome de usuário, tema e preferências de idioma.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Reprodução e Sincronização",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalize a reprodução, as notificações e as preferências de áudio."
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalize a reprodução, as notificações e as preferências de áudio.",
|
||||
"LABEL_CHAT_ENABLED": "Ativar chat da sala",
|
||||
"LABEL_CHAT_ENABLED_TOOLTIP": "Mostrar o chat criptografado da sala na guia de vídeo selecionada.",
|
||||
"CHAT_TITLE": "Chat da sala",
|
||||
"CHAT_LIVE_ONLY": "Somente ao vivo. Sem histórico.",
|
||||
"CHAT_OPEN": "Abrir chat da sala",
|
||||
"CHAT_CLOSE": "Fechar chat",
|
||||
"CHAT_DOCK_LEFT": "Fixar chat à esquerda",
|
||||
"CHAT_DOCK_RIGHT": "Fixar chat à direita",
|
||||
"CHAT_DETACHED": "Desanexar chat",
|
||||
"CHAT_PLACEHOLDER": "Escreva uma mensagem...",
|
||||
"CHAT_SEND": "Enviar",
|
||||
"CHAT_MISSING_KEY": "O chat está disponível apenas por um link de convite atual.",
|
||||
"CHAT_TOO_LONG": "Máximo de 500 caracteres.",
|
||||
"CHAT_SEND_FAILED": "Não foi possível enviar a mensagem.",
|
||||
"CHAT_EMPTY": "Ainda não há mensagens. O chat é somente ao vivo."
|
||||
}
|
||||
|
||||
@@ -245,5 +245,20 @@
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "Perfil e Aparência",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Altere o seu nome de utilizador, tema e preferências de idioma.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Reprodução e Sincronização",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalize a reprodução, as notificações e as preferências de áudio."
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Personalize a reprodução, as notificações e as preferências de áudio.",
|
||||
"LABEL_CHAT_ENABLED": "Ativar chat da sala",
|
||||
"LABEL_CHAT_ENABLED_TOOLTIP": "Mostrar o chat encriptado da sala no separador de vídeo selecionado.",
|
||||
"CHAT_TITLE": "Chat da sala",
|
||||
"CHAT_LIVE_ONLY": "Apenas em direto. Sem histórico.",
|
||||
"CHAT_OPEN": "Abrir chat da sala",
|
||||
"CHAT_CLOSE": "Fechar chat",
|
||||
"CHAT_DOCK_LEFT": "Fixar chat à esquerda",
|
||||
"CHAT_DOCK_RIGHT": "Fixar chat à direita",
|
||||
"CHAT_DETACHED": "Desanexar chat",
|
||||
"CHAT_PLACEHOLDER": "Escreva uma mensagem...",
|
||||
"CHAT_SEND": "Enviar",
|
||||
"CHAT_MISSING_KEY": "O chat está disponível apenas através de um link de convite atual.",
|
||||
"CHAT_TOO_LONG": "Máximo de 500 caracteres.",
|
||||
"CHAT_SEND_FAILED": "Não foi possível enviar a mensagem.",
|
||||
"CHAT_EMPTY": "Ainda não há mensagens. O chat funciona apenas em direto."
|
||||
}
|
||||
|
||||
@@ -245,5 +245,20 @@
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "Профиль и Оформление",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Измените имя пользователя, тему и настройки языка.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Воспроизведение и Синхронизация",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Настройте воспроизведение, уведомления и параметры звука."
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Настройте воспроизведение, уведомления и параметры звука.",
|
||||
"LABEL_CHAT_ENABLED": "Включить чат комнаты",
|
||||
"LABEL_CHAT_ENABLED_TOOLTIP": "Показывать зашифрованный чат комнаты на выбранной вкладке с видео.",
|
||||
"CHAT_TITLE": "Чат комнаты",
|
||||
"CHAT_LIVE_ONLY": "Только в реальном времени. Без истории.",
|
||||
"CHAT_OPEN": "Открыть чат комнаты",
|
||||
"CHAT_CLOSE": "Закрыть чат",
|
||||
"CHAT_DOCK_LEFT": "Закрепить чат слева",
|
||||
"CHAT_DOCK_RIGHT": "Закрепить чат справа",
|
||||
"CHAT_DETACHED": "Открепить чат",
|
||||
"CHAT_PLACEHOLDER": "Напишите сообщение...",
|
||||
"CHAT_SEND": "Отправить",
|
||||
"CHAT_MISSING_KEY": "Чат доступен только по актуальной ссылке-приглашению.",
|
||||
"CHAT_TOO_LONG": "Не более 500 символов.",
|
||||
"CHAT_SEND_FAILED": "Не удалось отправить сообщение.",
|
||||
"CHAT_EMPTY": "Сообщений пока нет. Чат работает только в реальном времени."
|
||||
}
|
||||
|
||||
@@ -245,5 +245,20 @@
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "Profil ve Görünüm",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Kullanıcı adınızı, temanızı ve dil tercihlerinizi değiştirin.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Oynatma ve Eşitleme",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Oynatma, bildirim ve ses tercihlerini özelleştirin."
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Oynatma, bildirim ve ses tercihlerini özelleştirin.",
|
||||
"LABEL_CHAT_ENABLED": "Oda sohbetini etkinleştir",
|
||||
"LABEL_CHAT_ENABLED_TOOLTIP": "Şifreli oda sohbetini seçili video sekmesinde göster.",
|
||||
"CHAT_TITLE": "Oda Sohbeti",
|
||||
"CHAT_LIVE_ONLY": "Yalnızca canlı. Geçmiş yok.",
|
||||
"CHAT_OPEN": "Oda sohbetini aç",
|
||||
"CHAT_CLOSE": "Sohbeti kapat",
|
||||
"CHAT_DOCK_LEFT": "Sohbeti sola sabitle",
|
||||
"CHAT_DOCK_RIGHT": "Sohbeti sağa sabitle",
|
||||
"CHAT_DETACHED": "Sohbeti ayır",
|
||||
"CHAT_PLACEHOLDER": "Bir mesaj yazın...",
|
||||
"CHAT_SEND": "Gönder",
|
||||
"CHAT_MISSING_KEY": "Sohbet yalnızca güncel bir davet bağlantısıyla kullanılabilir.",
|
||||
"CHAT_TOO_LONG": "En fazla 500 karakter.",
|
||||
"CHAT_SEND_FAILED": "Mesaj gönderilemedi.",
|
||||
"CHAT_EMPTY": "Henüz mesaj yok. Sohbet yalnızca canlıdır."
|
||||
}
|
||||
|
||||
@@ -245,5 +245,20 @@
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "Профіль та Вигляд",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Змініть ім'я користувача, тему та мовні налаштування.",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "Відтворення та Синхронізація",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Налаштуйте відтворення, сповіщення та параметри звуку."
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Налаштуйте відтворення, сповіщення та параметри звуку.",
|
||||
"LABEL_CHAT_ENABLED": "Увімкнути чат кімнати",
|
||||
"LABEL_CHAT_ENABLED_TOOLTIP": "Показувати зашифрований чат кімнати на вибраній вкладці з відео.",
|
||||
"CHAT_TITLE": "Чат кімнати",
|
||||
"CHAT_LIVE_ONLY": "Лише наживо. Без історії.",
|
||||
"CHAT_OPEN": "Відкрити чат кімнати",
|
||||
"CHAT_CLOSE": "Закрити чат",
|
||||
"CHAT_DOCK_LEFT": "Закріпити чат ліворуч",
|
||||
"CHAT_DOCK_RIGHT": "Закріпити чат праворуч",
|
||||
"CHAT_DETACHED": "Від'єднати чат",
|
||||
"CHAT_PLACEHOLDER": "Напишіть повідомлення...",
|
||||
"CHAT_SEND": "Надіслати",
|
||||
"CHAT_MISSING_KEY": "Чат доступний лише за актуальним посиланням-запрошенням.",
|
||||
"CHAT_TOO_LONG": "Не більше 500 символів.",
|
||||
"CHAT_SEND_FAILED": "Не вдалося надіслати повідомлення.",
|
||||
"CHAT_EMPTY": "Повідомлень ще немає. Чат працює лише наживо."
|
||||
}
|
||||
|
||||
@@ -245,5 +245,20 @@
|
||||
"LABEL_SETTINGS_GROUP_PROFILE": "个人资料与外观",
|
||||
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "更改您的用户名、主题和语言首选项。",
|
||||
"LABEL_SETTINGS_GROUP_SYNC": "播放与同步",
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "自定义播放、通知和音频首选项。"
|
||||
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "自定义播放、通知和音频首选项。",
|
||||
"LABEL_CHAT_ENABLED": "启用房间聊天",
|
||||
"LABEL_CHAT_ENABLED_TOOLTIP": "在选定的视频标签页中显示加密的房间聊天。",
|
||||
"CHAT_TITLE": "房间聊天",
|
||||
"CHAT_LIVE_ONLY": "仅实时显示,无历史记录。",
|
||||
"CHAT_OPEN": "打开房间聊天",
|
||||
"CHAT_CLOSE": "关闭聊天",
|
||||
"CHAT_DOCK_LEFT": "将聊天固定在左侧",
|
||||
"CHAT_DOCK_RIGHT": "将聊天固定在右侧",
|
||||
"CHAT_DETACHED": "浮动聊天",
|
||||
"CHAT_PLACEHOLDER": "输入消息...",
|
||||
"CHAT_SEND": "发送",
|
||||
"CHAT_MISSING_KEY": "聊天仅可通过当前邀请链接使用。",
|
||||
"CHAT_TOO_LONG": "最多500个字符。",
|
||||
"CHAT_SEND_FAILED": "无法发送消息。",
|
||||
"CHAT_EMPTY": "暂无消息。聊天仅实时显示。"
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"default_locale": "en",
|
||||
"name": "KoalaSync",
|
||||
"version": "2.6.3",
|
||||
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
|
||||
"name": "__MSG_appName__",
|
||||
"short_name": "KoalaSync",
|
||||
"version": "2.6.4",
|
||||
"description": "__MSG_appDesc__",
|
||||
"permissions": [
|
||||
"storage",
|
||||
"tabs",
|
||||
|
||||
@@ -1371,7 +1371,7 @@
|
||||
<!-- Room credentials — distinct group below the server picker. -->
|
||||
<div class="form-group">
|
||||
<label for="roomId" title="The unique identifier for your sync room" data-i18n="LABEL_ROOM_ID" data-i18n-title="LABEL_ROOM_ID_TOOLTIP">Room ID</label>
|
||||
<input type="text" id="roomId" data-i18n-placeholder="PLACEHOLDER_ROOM_ID" data-i18n-title="PLACEHOLDER_ROOM_ID_TOOLTIP" placeholder="Enter Room ID" title="The unique ID of the room you want to join">
|
||||
<input type="text" id="roomId" maxlength="64" data-i18n-placeholder="PLACEHOLDER_ROOM_ID" data-i18n-title="PLACEHOLDER_ROOM_ID_TOOLTIP" placeholder="Enter Room ID" title="The unique ID of the room you want to join">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password" title="Optional password to restrict room access" data-i18n="LABEL_PASSWORD" data-i18n-title="LABEL_PASSWORD_TOOLTIP">Password (Optional)</label>
|
||||
@@ -1625,6 +1625,13 @@
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label for="chatEnabled" title="Show encrypted room chat on the selected video tab." data-i18n="LABEL_CHAT_ENABLED" data-i18n-title="LABEL_CHAT_ENABLED_TOOLTIP">Enable Room Chat</label>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="chatEnabled">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label for="filterNoise" title="Filters out non-video tabs and unrelated domains to keep the list clean" data-i18n="LABEL_HIDE_CLUTTER" data-i18n-title="LABEL_HIDE_CLUTTER_TOOLTIP">Hide Clutter Tabs</label>
|
||||
<label class="toggle-switch">
|
||||
|
||||
+80
-55
@@ -3,8 +3,17 @@ 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';
|
||||
import { normalizeTabId, requestOriginPermission } from './host-access.js';
|
||||
|
||||
let pendingInviteRoomId = '';
|
||||
let pendingInviteChatKey = '';
|
||||
let pendingRoomCreation = false;
|
||||
|
||||
function normalizeChatKey(value) {
|
||||
return typeof value === 'string' && /^[A-Za-z0-9_-]{21}[AQgw]$/.test(value) ? value : '';
|
||||
}
|
||||
|
||||
const elements = {
|
||||
tabs: document.querySelectorAll('.tabs .tab-btn'),
|
||||
@@ -59,6 +68,7 @@ const elements = {
|
||||
playBtn: document.getElementById('playBtn'),
|
||||
pauseBtn: document.getElementById('pauseBtn'),
|
||||
autoSyncNextEpisode: document.getElementById('autoSyncNextEpisode'),
|
||||
chatEnabled: document.getElementById('chatEnabled'),
|
||||
sendTabTitle: document.getElementById('sendTabTitle'),
|
||||
mediaTitlePrivacyMode: document.getElementById('mediaTitlePrivacyMode'),
|
||||
episodeLobbyCard: document.getElementById('episodeLobbyCard'),
|
||||
@@ -328,7 +338,7 @@ function setRoomRefreshCooldown() {
|
||||
async function init() {
|
||||
// Local-only by design — settings and room credentials never come from
|
||||
// storage.sync (only onboardingComplete + dismissedHints live there).
|
||||
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab', 'themeMode', 'themePalette']);
|
||||
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'chatEnabled', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab', 'themeMode', 'themePalette']);
|
||||
|
||||
let activeLang = localData.locale;
|
||||
if (!activeLang) {
|
||||
@@ -353,12 +363,13 @@ 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();
|
||||
if (elements.filterNoise) elements.filterNoise.checked = localData.filterNoise !== false;
|
||||
if (elements.autoSyncNextEpisode) elements.autoSyncNextEpisode.checked = localData.autoSyncNextEpisode !== false;
|
||||
if (elements.chatEnabled) elements.chatEnabled.checked = localData.chatEnabled === true;
|
||||
const legacyTitlePrivacyMode = Object.values(TITLE_PRIVACY_MODES).includes(localData.titlePrivacyMode) ? localData.titlePrivacyMode : TITLE_PRIVACY_MODES.FULL;
|
||||
const mediaTitlePrivacyMode = Object.values(TITLE_PRIVACY_MODES).includes(localData.mediaTitlePrivacyMode) ? localData.mediaTitlePrivacyMode : legacyTitlePrivacyMode;
|
||||
if (elements.sendTabTitle) elements.sendTabTitle.checked = normalizeSendTabTitle(localData.sendTabTitle, legacyTitlePrivacyMode);
|
||||
@@ -384,7 +395,7 @@ async function init() {
|
||||
}
|
||||
|
||||
toggleUIState(!!localData.roomId);
|
||||
updateUI(localData.roomId, localData.password, localData.useCustomServer, localData.serverUrl);
|
||||
updateUI(localData.roomId, localData.password, localData.useCustomServer, localData.serverUrl, localData.chatKey);
|
||||
refreshLogs();
|
||||
refreshHistory();
|
||||
|
||||
@@ -656,15 +667,20 @@ if (elements.hostControlToggle) {
|
||||
});
|
||||
}
|
||||
|
||||
function updateUI(roomId, password, useCustomServer = false, serverUrl = '') {
|
||||
function updateUI(roomId, password, useCustomServer = false, serverUrl = '', chatKey = '') {
|
||||
const inRoom = !!roomId;
|
||||
toggleUIState(inRoom);
|
||||
if (inRoom) {
|
||||
let invite = `${OFFICIAL_LANDING_PAGE_URL}/join.html#join:${roomId}:${password}`;
|
||||
if (useCustomServer) {
|
||||
const encodedUrl = encodeURIComponent(serverUrl || '');
|
||||
invite += `:1:${encodedUrl}`;
|
||||
}
|
||||
const validChatKey = normalizeChatKey(chatKey);
|
||||
let invite = validChatKey
|
||||
? `${OFFICIAL_LANDING_PAGE_URL}/join.html${globalThis.KoalaSyncInviteLinks.buildJ2Hash({
|
||||
roomId,
|
||||
password,
|
||||
chatKey: validChatKey,
|
||||
serverUrl: useCustomServer ? serverUrl : ''
|
||||
})}`
|
||||
: `${OFFICIAL_LANDING_PAGE_URL}/join.html#join:${roomId}:${password}`;
|
||||
if (!validChatKey && useCustomServer) invite += `:1:${encodeURIComponent(serverUrl || '')}`;
|
||||
elements.inviteLink.value = invite;
|
||||
|
||||
if (window.justCreatedRoom) {
|
||||
@@ -1393,41 +1409,21 @@ function updateRoomList(rooms) {
|
||||
function checkInviteLink() {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
const tab = tabs[0];
|
||||
if (tab && tab.url && tab.url.startsWith(OFFICIAL_LANDING_PAGE_URL + '/') && tab.url.includes('#join:')) {
|
||||
if (tab && tab.url && tab.url.startsWith(OFFICIAL_LANDING_PAGE_URL + '/')) {
|
||||
try {
|
||||
const rawHash = tab.url.split('#join:')[1];
|
||||
if (!rawHash) return;
|
||||
const parts = rawHash.split(':');
|
||||
if (parts.length >= 2) {
|
||||
const roomId = parts.shift();
|
||||
let useCustomServer = false;
|
||||
let serverUrl = '';
|
||||
const invite = globalThis.KoalaSyncInviteLinks.parseInviteHash(new URL(tab.url).hash);
|
||||
if (!invite) return;
|
||||
elements.roomId.value = normalizeRoomId(invite.roomId);
|
||||
elements.password.value = invite.password;
|
||||
pendingInviteRoomId = elements.roomId.value;
|
||||
pendingInviteChatKey = normalizeChatKey(invite.chatKey);
|
||||
|
||||
const last = parts[parts.length - 1];
|
||||
const secondToLast = parts[parts.length - 2];
|
||||
const decodedLast = decodeURIComponent(last || '');
|
||||
const isCustom = secondToLast === '1' && (decodedLast.startsWith('ws://') || decodedLast.startsWith('wss://'));
|
||||
const isOfficial = secondToLast === '0' && last === '';
|
||||
elements.serverUrl.value = invite.serverUrl;
|
||||
setServerMode(invite.useCustomServer);
|
||||
chrome.storage.local.set({ serverUrl: invite.serverUrl, useCustomServer: invite.useCustomServer });
|
||||
|
||||
if (parts.length >= 3 && (isCustom || isOfficial)) {
|
||||
serverUrl = decodeURIComponent(parts.pop());
|
||||
useCustomServer = parts.pop() === '1';
|
||||
}
|
||||
|
||||
const password = parts.join(':');
|
||||
|
||||
elements.roomId.value = roomId;
|
||||
elements.password.value = password;
|
||||
|
||||
if (serverUrl || useCustomServer) {
|
||||
elements.serverUrl.value = serverUrl;
|
||||
setServerMode(useCustomServer);
|
||||
chrome.storage.local.set({ serverUrl, useCustomServer });
|
||||
}
|
||||
|
||||
elements.joinBtn.style.boxShadow = '0 0 15px var(--accent)';
|
||||
setTimeout(() => elements.joinBtn.style.boxShadow = '', 2000);
|
||||
}
|
||||
elements.joinBtn.style.boxShadow = '0 0 15px var(--accent)';
|
||||
setTimeout(() => elements.joinBtn.style.boxShadow = '', 2000);
|
||||
} catch (_e) {
|
||||
// Malformed invite link, ignore
|
||||
}
|
||||
@@ -1462,6 +1458,12 @@ elements.autoSyncNextEpisode.addEventListener('change', () => {
|
||||
chrome.storage.local.set({ autoSyncNextEpisode: elements.autoSyncNextEpisode.checked });
|
||||
});
|
||||
|
||||
if (elements.chatEnabled) {
|
||||
elements.chatEnabled.addEventListener('change', () => {
|
||||
chrome.storage.local.set({ chatEnabled: elements.chatEnabled.checked });
|
||||
});
|
||||
}
|
||||
|
||||
if (elements.sendTabTitle) {
|
||||
elements.sendTabTitle.addEventListener('change', () => {
|
||||
chrome.storage.local.set({ sendTabTitle: elements.sendTabTitle.checked }, () => {
|
||||
@@ -1572,8 +1574,8 @@ if (elements.langSelector) {
|
||||
lastKnownPeers = res.peers || [];
|
||||
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
|
||||
|
||||
const data = await chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl']);
|
||||
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
|
||||
const data = await chrome.storage.local.get(['roomId', 'password', 'chatKey', 'useCustomServer', 'serverUrl']);
|
||||
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl, data.chatKey);
|
||||
|
||||
if (res.pendingTargetTabId) {
|
||||
showSiteAccessNotice(
|
||||
@@ -1588,8 +1590,8 @@ if (elements.langSelector) {
|
||||
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
|
||||
} else {
|
||||
applyConnectionStatus('disconnected');
|
||||
const data = await chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl']);
|
||||
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
|
||||
const data = await chrome.storage.local.get(['roomId', 'password', 'chatKey', 'useCustomServer', 'serverUrl']);
|
||||
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl, data.chatKey);
|
||||
await populateTabs();
|
||||
}
|
||||
});
|
||||
@@ -1717,7 +1719,12 @@ 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 = '';
|
||||
}
|
||||
});
|
||||
|
||||
elements.joinBtn.addEventListener('click', async () => {
|
||||
@@ -1728,8 +1735,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');
|
||||
@@ -1789,17 +1796,34 @@ elements.joinBtn.addEventListener('click', async () => {
|
||||
self.crypto.getRandomValues(array);
|
||||
password = Array.from(array, byte => chars[byte % chars.length]).join('');
|
||||
elements.password.value = password;
|
||||
window.justCreatedRoom = true;
|
||||
}
|
||||
let chatKey = roomId === pendingInviteRoomId ? pendingInviteChatKey : '';
|
||||
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, useCustomServer: useCustom });
|
||||
await chrome.storage.local.set({ serverUrl, roomId, password, chatKey, useCustomServer: useCustom });
|
||||
elements.roomId.value = roomId;
|
||||
|
||||
// Tell background to connect
|
||||
chrome.runtime.sendMessage({ type: 'CONNECT' });
|
||||
|
||||
// UI Feedback: Immediately switch state for better responsiveness
|
||||
updateUI(roomId, password, useCustom, serverUrl);
|
||||
updateUI(roomId, password, useCustom, serverUrl, chatKey);
|
||||
});
|
||||
|
||||
elements.leaveBtn.addEventListener('click', async () => {
|
||||
@@ -1807,7 +1831,7 @@ elements.leaveBtn.addEventListener('click', async () => {
|
||||
isProcessingConnection = true;
|
||||
clearConnectionErrorTimer();
|
||||
chrome.runtime.sendMessage({ type: 'LEAVE_ROOM' });
|
||||
await chrome.storage.local.set({ roomId: '', password: '' });
|
||||
await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' });
|
||||
elements.roomId.value = '';
|
||||
elements.password.value = '';
|
||||
lastKnownPeers = [];
|
||||
@@ -1833,6 +1857,7 @@ function handleCreateRoom() {
|
||||
const password = secureGenerateId();
|
||||
elements.roomId.value = roomId;
|
||||
elements.password.value = password;
|
||||
pendingRoomCreation = true;
|
||||
window.justCreatedRoom = true;
|
||||
|
||||
// Auto-connect
|
||||
@@ -2355,15 +2380,15 @@ chrome.runtime.onMessage.addListener((msg) => {
|
||||
|
||||
if (msg.success) {
|
||||
// Final confirmation of join from background
|
||||
chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl'], (data) => {
|
||||
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
|
||||
chrome.storage.local.get(['roomId', 'password', 'chatKey', 'useCustomServer', 'serverUrl'], (data) => {
|
||||
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl, data.chatKey);
|
||||
const syncTabBtn = document.querySelector('.tab-btn[data-tab="tab-sync"]');
|
||||
if (syncTabBtn) syncTabBtn.click();
|
||||
showSelectVideoHint();
|
||||
});
|
||||
} else {
|
||||
// Join failed: reset UI state
|
||||
chrome.storage.local.set({ roomId: '', password: '' });
|
||||
chrome.storage.local.set({ roomId: '', password: '', chatKey: '' });
|
||||
elements.roomId.value = '';
|
||||
elements.password.value = '';
|
||||
updateUI(null, null);
|
||||
|
||||
Generated
+266
-721
File diff suppressed because it is too large
Load Diff
+12
-4
@@ -1,9 +1,12 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "2.6.3",
|
||||
"version": "2.6.4",
|
||||
"description": "KoalaSync Build Scripts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build:extension": "node scripts/build-extension.cjs",
|
||||
"indexnow": "node website/submit-indexnow.cjs",
|
||||
@@ -18,14 +21,19 @@
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"addons-linter": "^10.9.0",
|
||||
"archiver": "^7.0.1",
|
||||
"archiver": "^8.0.0",
|
||||
"esbuild": "^0.28.1",
|
||||
"eslint": "^10.4.0",
|
||||
"eslint-plugin-no-unsanitized": "^4.1.5",
|
||||
"fs-extra": "^11.2.0",
|
||||
"htmlnano": "^3.4.0",
|
||||
"sharp": "^0.34.5",
|
||||
"svgo": "^4.0.1",
|
||||
"sharp": "^0.35.3",
|
||||
"svgo": "^4.0.2",
|
||||
"vitest": "^4.1.9"
|
||||
},
|
||||
"overrides": {
|
||||
"brace-expansion": "5.0.8",
|
||||
"fast-uri": "3.1.4",
|
||||
"postcss": "8.5.18"
|
||||
}
|
||||
}
|
||||
|
||||
+63
-11
@@ -1,11 +1,63 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const archiver = require('archiver');
|
||||
|
||||
const rootDir = path.join(__dirname, '..');
|
||||
const extDir = path.join(rootDir, 'extension');
|
||||
const distDir = path.join(rootDir, 'dist');
|
||||
const baseManifestPath = path.join(extDir, 'manifest.base.json');
|
||||
const localesDir = path.join(extDir, '_locales');
|
||||
const requiredAppName = 'Watch Party - KoalaSync';
|
||||
const maxAppNameLength = 75;
|
||||
const maxAppDescLength = 132;
|
||||
|
||||
function countUnicodeCharacters(value) {
|
||||
return Array.from(value).length;
|
||||
}
|
||||
|
||||
function validateExtensionMetadata() {
|
||||
const localeDirectories = fs.readdirSync(localesDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
|
||||
for (const locale of localeDirectories) {
|
||||
const messagesPath = path.join(localesDir, locale, 'messages.json');
|
||||
let messages;
|
||||
|
||||
try {
|
||||
messages = JSON.parse(fs.readFileSync(messagesPath, 'utf8'));
|
||||
} catch (error) {
|
||||
throw new Error(`CRITICAL: Invalid locale JSON at _locales/${locale}/messages.json: ${error.message}`);
|
||||
}
|
||||
|
||||
if (!messages || typeof messages !== 'object' || Array.isArray(messages)) {
|
||||
throw new Error(`CRITICAL: _locales/${locale}/messages.json must contain a JSON object`);
|
||||
}
|
||||
|
||||
for (const key of ['appName', 'appDesc']) {
|
||||
if (!messages[key] || typeof messages[key].message !== 'string' || messages[key].message.trim().length === 0) {
|
||||
throw new Error(`CRITICAL: _locales/${locale}/messages.json is missing a valid ${key}.message`);
|
||||
}
|
||||
}
|
||||
|
||||
const appNameLength = countUnicodeCharacters(messages.appName.message);
|
||||
const appDescLength = countUnicodeCharacters(messages.appDesc.message);
|
||||
|
||||
if (appNameLength > maxAppNameLength) {
|
||||
throw new Error(`CRITICAL: _locales/${locale}/messages.json appName.message is ${appNameLength} characters; maximum is ${maxAppNameLength}`);
|
||||
}
|
||||
if (messages.appName.message !== requiredAppName) {
|
||||
throw new Error(`CRITICAL: _locales/${locale}/messages.json appName.message must exactly equal "${requiredAppName}"`);
|
||||
}
|
||||
if (appDescLength > maxAppDescLength) {
|
||||
throw new Error(`CRITICAL: _locales/${locale}/messages.json appDesc.message is ${appDescLength} characters; maximum is ${maxAppDescLength}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✓ Validated localized extension metadata for ${localeDirectories.length} locales`);
|
||||
}
|
||||
|
||||
validateExtensionMetadata();
|
||||
|
||||
// Ensure dist directory exists
|
||||
if (fs.existsSync(distDir)) {
|
||||
@@ -22,7 +74,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 +83,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'));
|
||||
@@ -149,18 +201,18 @@ function copyExtensionFiles(targetDir, browserName) {
|
||||
}
|
||||
|
||||
// Helper to zip a directory
|
||||
function zipDirectory(sourceDir, outPath) {
|
||||
async function zipDirectory(sourceDir, outPath) {
|
||||
const { ZipArchive } = await import('archiver');
|
||||
return new Promise((resolve, reject) => {
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
const archive = new ZipArchive({ zlib: { level: 9 } });
|
||||
const stream = fs.createWriteStream(outPath);
|
||||
|
||||
archive
|
||||
.directory(sourceDir, false)
|
||||
.on('error', err => reject(err))
|
||||
.pipe(stream);
|
||||
|
||||
archive.on('error', reject);
|
||||
stream.on('close', () => resolve());
|
||||
archive.finalize();
|
||||
stream.on('error', reject);
|
||||
archive.pipe(stream);
|
||||
archive.directory(sourceDir, false);
|
||||
void archive.finalize().catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -22,7 +23,10 @@ async function c() {
|
||||
function s(ws, evt, d={}) { ws.send(`42${JSON.stringify([evt,d])}`); }
|
||||
function a(ws) { if (ws._m.length) { const r=ws._m.shift(); return r.startsWith('42') ? JSON.parse(r.substring(2)) : r; } return new Promise((resolve, reject) => { const t=setTimeout(()=>reject(Error('timeout')),3e3); const h=(d)=>{clearTimeout(t);ws.removeListener('message',h);const r=d.toString();resolve(r.startsWith('42')?JSON.parse(r.substring(2)):r);};ws.on('message',h);}); }
|
||||
async function w(ws, evt, ms=3000) { const st=Date.now(); while(Date.now()-st<ms) { for(let i=0;i<ws._m.length;i++){const r=ws._m[i];ws._m.splice(i,1);if(r.startsWith('42')){try{const[e]=JSON.parse(r.substring(2));if(e===evt)return e}catch{/* skip */}}} await new Promise(r=>setTimeout(r,50));} throw Error(`wait:${evt}`); }
|
||||
async function j(ws, rid, pid, pw=null) { s(ws,'join_room',{roomId:rid,peerId:pid,password:pw,protocolVersion:'1.0.0'}); assert.equal((await a(ws))[0],'room_data'); }
|
||||
async function j(ws, rid, pid, pw=null, clientCapabilities=undefined) {
|
||||
s(ws,'join_room',{roomId:rid,peerId:pid,password:pw,protocolVersion:'1.0.0',clientCapabilities});
|
||||
assert.equal((await a(ws))[0],'room_data');
|
||||
}
|
||||
function close() { clients.forEach(w=>{try{w.close()}catch{/* ignore */}}); clients.length=0; }
|
||||
// Test suite opens >10 connections/min — clear the IP connection counter so the
|
||||
// connection rate limiter doesn't mask test failures (test-only, never at runtime).
|
||||
@@ -70,6 +74,70 @@ 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.ok(capData.capabilities.includes('chat-v1'), 'ROOM_DATA advertises the versioned chat capability');
|
||||
assert.equal(capData.chatHistory, undefined, 'ROOM_DATA never contains chat history');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- 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', null, ['chat-v1']);
|
||||
await j(chat2, chatRoom, 'bob', null, ['chat-v1']);
|
||||
chat1._m.length = chat2._m.length = 0;
|
||||
const ciphertext = Buffer.alloc(64, 7).toString('base64url');
|
||||
s(chat1, 'chat_message', {
|
||||
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();
|
||||
|
||||
// --- Mixed rollout: old non-chat clients never receive unknown chat events ---
|
||||
const mixedChatRoom = 'chat-mixed-'+Date.now();
|
||||
const newChat = await c(), oldNoChat = await c();
|
||||
await j(newChat, mixedChatRoom, 'new-chat', null, ['chat-v1', 'chat-v1', 'future-chat', 42]);
|
||||
await j(oldNoChat, mixedChatRoom, 'old-no-chat', null, ['chat-v2']);
|
||||
newChat._m.length = oldNoChat._m.length = 0;
|
||||
s(newChat, 'chat_message', { ciphertext });
|
||||
await w(newChat, 'chat_message');
|
||||
let oldReceivedChat = false;
|
||||
try { await w(oldNoChat, 'chat_message', 500); oldReceivedChat = true; } catch { /* expected */ }
|
||||
assert.equal(oldReceivedChat, false, 'old client receives no unknown chat event');
|
||||
|
||||
// The same old client remains fully usable for the pre-chat protocol.
|
||||
newChat._m.length = oldNoChat._m.length = 0;
|
||||
s(oldNoChat, 'play', { currentTime: 12 });
|
||||
await w(newChat, 'play');
|
||||
s(newChat, 'pause', { currentTime: 13 });
|
||||
await w(oldNoChat, 'pause');
|
||||
|
||||
// First chat beta compatibility: sending a valid chat frame dynamically
|
||||
// proves receive support even when that beta omitted clientCapabilities.
|
||||
const firstBeta = await c();
|
||||
await j(firstBeta, mixedChatRoom, 'first-beta');
|
||||
firstBeta._m.length = newChat._m.length = 0;
|
||||
s(firstBeta, 'chat_message', { ciphertext });
|
||||
await w(firstBeta, 'chat_message');
|
||||
await w(newChat, 'chat_message');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
|
||||
@@ -12,11 +12,14 @@ 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');
|
||||
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('<div class="extension-mockup">');
|
||||
const mockupEnd = template.indexOf('<div class="demo-invite-fly"', mockupStart);
|
||||
|
||||
@@ -63,6 +66,32 @@ for (const section of requiredLlmsSections) {
|
||||
if (/\bWebRTC\b|\bport 54000\b|peer-to-peer by design/i.test(llmsText)) {
|
||||
throw new Error('llms.txt must describe the current WebSocket relay architecture without obsolete WebRTC or port claims');
|
||||
}
|
||||
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 (!websiteBuild.includes("['log', '-1', '--format=%cs', '--', ...sourceFiles]")
|
||||
|| !websiteBuild.includes("lastmod(['website/site-access-help.html'])")
|
||||
|| !websiteBuild.includes("['rev-parse', '--is-shallow-repository']")
|
||||
|| !websiteBuild.includes("['diff', '--name-only', '--']")
|
||||
|| !websiteBuild.includes("['diff', '--cached', '--name-only', '--']")
|
||||
|| !websiteBuild.includes("['ls-files', '--others', '--exclude-standard', '--']")
|
||||
|| !websiteBuild.includes('sourceFiles.some(file => dirtySourceFiles.has(file))')) {
|
||||
throw new Error('Sitemap lastmod values must come from the mapped source files in Git');
|
||||
}
|
||||
const documentedRelayUrls = [...llmsText.matchAll(/`(wss:\/\/[^`\s]+)`/g)].map(([, value]) => new URL(value));
|
||||
const hasCanonicalPublicRelay = documentedRelayUrls.some((url) => (
|
||||
url.protocol === 'wss:' &&
|
||||
@@ -151,11 +180,9 @@ if (!/\.legal-inline-link\s*\{[^}]*text-decoration:\s*underline/s.test(legalCss)
|
||||
throw new Error('Legal prose links must use a non-color underline cue');
|
||||
}
|
||||
for (const [name, page] of [['imprint.html', imprintPage], ['impressum-de.html', germanImprintPage]]) {
|
||||
if ((page.match(/class="legal-inline-link"/g) || []).length !== 3) {
|
||||
throw new Error(`${name} must mark all three prose links as legal-inline-link`);
|
||||
}
|
||||
if ((page.match(/class="legal-inline-label"/g) || []).length !== 2 || /opacity:\s*0\.6/.test(page)) {
|
||||
throw new Error(`${name} contact labels must use full-opacity theme text`);
|
||||
if (!/rel="canonical" href="https:\/\/koalastuff\.net\/legal"/.test(page)
|
||||
|| !/href="https:\/\/koalastuff\.net\/legal"/.test(page)) {
|
||||
throw new Error(`${name} must point to the central KoalaStuff legal notice`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +203,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 Git-derived 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');
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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('<script>')).toBeNull();
|
||||
expect(normalizeChatCiphertext(`${encoded(29)}=`)).toBeNull();
|
||||
expect(normalizeChatCiphertext(encoded(28))).toBeNull();
|
||||
expect(normalizeChatCiphertext(encoded(CHAT_CIPHERTEXT_MAX_BYTES + 1))).toBeNull();
|
||||
expect(normalizeChatCiphertext('A')).toBeNull();
|
||||
});
|
||||
|
||||
it('stamps authoritative metadata and keeps only ciphertext from the sender', () => {
|
||||
const ciphertext = encoded(64);
|
||||
const envelope = createChatEnvelope({
|
||||
ciphertext,
|
||||
id: 'spoofed',
|
||||
senderId: 'mallory',
|
||||
timestamp: 1,
|
||||
text: 'plaintext must not survive'
|
||||
}, 'alice', () => 1234, () => 'server-id');
|
||||
|
||||
expect(envelope).toEqual({
|
||||
id: 'server-id',
|
||||
senderId: 'alice',
|
||||
timestamp: 1234,
|
||||
ciphertext
|
||||
});
|
||||
expect(JSON.stringify(envelope)).not.toContain('plaintext');
|
||||
});
|
||||
});
|
||||
+63
-1
@@ -5,6 +5,7 @@ import { Server } from 'socket.io';
|
||||
import crypto from 'crypto';
|
||||
import dotenv from 'dotenv';
|
||||
import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES } from '../shared/constants.js';
|
||||
import { createChatEnvelope } from './chat.js';
|
||||
import {
|
||||
buildHealthPayload,
|
||||
checkCooldown,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
connectionCounts,
|
||||
failedAuthAttempts,
|
||||
eventCounts,
|
||||
chatMessageCounts,
|
||||
healthCounts,
|
||||
adminMetricsAuthCounts,
|
||||
roomListCooldowns,
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
recordAuthFailure,
|
||||
checkConnectionRate,
|
||||
checkEventRate,
|
||||
checkChatMessageRate,
|
||||
checkHealthRate,
|
||||
checkAdminMetricsAuthRate,
|
||||
checkLeaveRoomRate,
|
||||
@@ -171,7 +174,26 @@ const HOST_ONLY_GATED_EVENTS = new Set([
|
||||
// Features this relay supports, advertised to clients in ROOM_DATA so they can
|
||||
// enable matching UI/behavior only when the server actually backs it. Append a
|
||||
// flag here when a new server-gated feature ships (e.g. co-host promotion).
|
||||
const SERVER_CAPABILITIES = [CAPABILITIES.HOST_CONTROL, CAPABILITIES.CO_HOST];
|
||||
const SERVER_CAPABILITIES = [
|
||||
CAPABILITIES.HOST_CONTROL,
|
||||
CAPABILITIES.CO_HOST,
|
||||
CAPABILITIES.CHAT,
|
||||
CAPABILITIES.CHAT_V1
|
||||
];
|
||||
|
||||
function normalizeClientCapabilities(value) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return [...new Set(value.slice(0, 16)
|
||||
.filter(capability => typeof capability === 'string')
|
||||
.map(capability => capability.substring(0, 32))
|
||||
.filter(capability => capability === CAPABILITIES.CHAT_V1)
|
||||
)];
|
||||
}
|
||||
|
||||
function clientSupportsChat(socket) {
|
||||
return Array.isArray(socket?.data?.clientCapabilities) &&
|
||||
socket.data.clientCapabilities.includes(CAPABILITIES.CHAT_V1);
|
||||
}
|
||||
|
||||
// M-4: minimum interval between CONTROL_MODE changes per room. Stops a rapidly
|
||||
// toggling host from thrashing every guest's UI (locked/unlocked/locked...) and
|
||||
@@ -353,6 +375,7 @@ io.on('connection', (socket) => {
|
||||
const username = typeof payload.username === 'string' ? payload.username.substring(0, 30) : null;
|
||||
const tabTitle = typeof payload.tabTitle === 'string' ? payload.tabTitle.substring(0, 100) : null;
|
||||
const mediaTitle = typeof payload.mediaTitle === 'string' ? payload.mediaTitle.substring(0, 100) : null;
|
||||
const clientCapabilities = normalizeClientCapabilities(payload.clientCapabilities);
|
||||
|
||||
if (!roomId || !peerId) return; // Guard: empty or invalid after sanitization
|
||||
|
||||
@@ -367,6 +390,7 @@ io.on('connection', (socket) => {
|
||||
// Cleanup old room if re-joining
|
||||
const oldMapping = socketToRoom.get(socket.id);
|
||||
if (oldMapping && oldMapping.roomId === roomId && oldMapping.peerId === peerId) {
|
||||
socket.data.clientCapabilities = clientCapabilities;
|
||||
return; // Already in this room with same peerId, ignore to prevent spam
|
||||
}
|
||||
if (oldMapping && oldMapping.roomId !== roomId) {
|
||||
@@ -490,6 +514,7 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
|
||||
socket.join(roomId);
|
||||
socket.data.clientCapabilities = clientCapabilities;
|
||||
room.peers.add(socket.id);
|
||||
room.peerIds.set(socket.id, peerId);
|
||||
room.peerData.set(socket.id, {
|
||||
@@ -844,8 +869,45 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on(EVENTS.CHAT_MESSAGE, (data) => {
|
||||
try {
|
||||
if (!checkEventRate(socket.id) || !checkChatMessageRate(socket.id)) {
|
||||
log('SECURITY', `Encrypted chat rate limit exceeded for socket: ${socket.id}`);
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const mapping = socketToRoom.get(socket.id);
|
||||
if (!mapping) return;
|
||||
const room = rooms.get(mapping.roomId);
|
||||
if (!room) return;
|
||||
|
||||
const envelope = createChatEnvelope(data, mapping.peerId);
|
||||
if (!envelope) return;
|
||||
// Transitional compatibility: the first chat beta did not announce
|
||||
// clientCapabilities. Successfully sending a canonical chat frame is
|
||||
// sufficient proof that this socket understands the v1 receive event.
|
||||
if (!clientSupportsChat(socket)) {
|
||||
socket.data.clientCapabilities = [
|
||||
...(socket.data.clientCapabilities || []),
|
||||
CAPABILITIES.CHAT_V1
|
||||
];
|
||||
}
|
||||
room.lastActivity = Date.now();
|
||||
for (const socketId of room.peers) {
|
||||
const recipient = io.sockets.sockets.get(socketId);
|
||||
if (clientSupportsChat(recipient)) {
|
||||
recipient.emit(EVENTS.CHAT_MESSAGE, envelope);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log('ERROR', `CHAT_MESSAGE handler error: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
eventCounts.delete(socket.id);
|
||||
chatMessageCounts.delete(socket.id);
|
||||
roomListCooldowns.delete(socket.id);
|
||||
leaveRoomCounts.delete(socket.id);
|
||||
const mapping = socketToRoom.get(socket.id);
|
||||
|
||||
Generated
+44
-14
@@ -70,20 +70,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^1.0.5",
|
||||
"content-type": "^2.0.0",
|
||||
"debug": "^4.4.3",
|
||||
"http-errors": "^2.0.0",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"http-errors": "^2.0.1",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"on-finished": "^2.4.1",
|
||||
"qs": "^6.14.1",
|
||||
"raw-body": "^3.0.1",
|
||||
"type-is": "^2.0.1"
|
||||
"qs": "^6.15.2",
|
||||
"raw-body": "^3.0.2",
|
||||
"type-is": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -93,6 +93,19 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/content-type": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -1025,17 +1038,34 @@
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
||||
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"content-type": "^1.0.5",
|
||||
"content-type": "^2.0.0",
|
||||
"media-typer": "^1.1.0",
|
||||
"mime-types": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is/node_modules/content-type": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
|
||||
@@ -12,6 +12,8 @@ export const CONNECTION_RATE_LIMIT = 10; // max new connections per IP
|
||||
export const CONNECTION_RATE_WINDOW_MS = 60000; // 1 minute
|
||||
export const EVENT_RATE_LIMIT = 50; // max relayed events per socket per window
|
||||
export const EVENT_RATE_WINDOW_MS = 10000; // 10 seconds
|
||||
export const CHAT_MESSAGE_RATE_LIMIT = 10; // max encrypted chat messages per socket per window
|
||||
export const CHAT_MESSAGE_RATE_WINDOW_MS = 10000; // 10 seconds
|
||||
export const HEALTH_RATE_WINDOW_MS = 60000; // 1 minute
|
||||
export const ADMIN_METRICS_AUTH_WINDOW_MS = 60000; // 1 minute
|
||||
export const LEAVE_ROOM_RATE_LIMIT = 10; // max LEAVE_ROOM events per socket per window
|
||||
@@ -20,6 +22,7 @@ export const LEAVE_ROOM_RATE_WINDOW_MS = 60000; // 1 minute
|
||||
export const connectionCounts = new Map(); // ip -> { count, resetTime }
|
||||
export const failedAuthAttempts = new Map(); // Map<IP+RoomID, {count, lastAttempt}>
|
||||
export const eventCounts = new Map(); // socketId -> { count, resetTime }
|
||||
export const chatMessageCounts = new Map(); // socketId -> { count, resetTime }
|
||||
export const healthCounts = new Map(); // ip -> { count, resetTime }
|
||||
export const adminMetricsAuthCounts = new Map(); // ip -> { count, resetTime }
|
||||
export const roomListCooldowns = new Map(); // socketId -> last allowed timestamp
|
||||
@@ -28,6 +31,7 @@ export const leaveRoomCounts = new Map(); // socketId -> { count, resetTime }
|
||||
export const rateLimitDenied = {
|
||||
connections: 0,
|
||||
events: 0,
|
||||
chatMessages: 0,
|
||||
health: 0,
|
||||
adminMetricsAuth: 0,
|
||||
roomList: 0,
|
||||
@@ -104,6 +108,17 @@ export function checkEventRate(socketId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function checkChatMessageRate(socketId) {
|
||||
const now = Date.now();
|
||||
const entry = chatMessageCounts.get(socketId) || { count: 0, resetTime: now + CHAT_MESSAGE_RATE_WINDOW_MS };
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + CHAT_MESSAGE_RATE_WINDOW_MS; }
|
||||
entry.count++;
|
||||
chatMessageCounts.set(socketId, entry);
|
||||
if (entry.count <= CHAT_MESSAGE_RATE_LIMIT) return true;
|
||||
rateLimitDenied.chatMessages++;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function checkHealthRate(ip) {
|
||||
const now = Date.now();
|
||||
const entry = healthCounts.get(ip) || { count: 0, resetTime: now + HEALTH_RATE_WINDOW_MS };
|
||||
@@ -163,6 +178,11 @@ export function startRateLimitCleanup(io) {
|
||||
eventCounts.delete(socketId);
|
||||
}
|
||||
}
|
||||
for (const [socketId, entry] of chatMessageCounts.entries()) {
|
||||
if (now > entry.resetTime || !io.sockets.sockets.has(socketId)) {
|
||||
chatMessageCounts.delete(socketId);
|
||||
}
|
||||
}
|
||||
for (const [socketId, entry] of leaveRoomCounts.entries()) {
|
||||
if (now > entry.resetTime || !io.sockets.sockets.has(socketId)) {
|
||||
leaveRoomCounts.delete(socketId);
|
||||
@@ -189,6 +209,7 @@ export function clearRateLimitMaps() {
|
||||
connectionCounts.clear();
|
||||
failedAuthAttempts.clear();
|
||||
eventCounts.clear();
|
||||
chatMessageCounts.clear();
|
||||
|
||||
healthCounts.clear();
|
||||
adminMetricsAuthCounts.clear();
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
checkLeaveRoomRate,
|
||||
checkChatMessageRate,
|
||||
CHAT_MESSAGE_RATE_LIMIT,
|
||||
CHAT_MESSAGE_RATE_WINDOW_MS,
|
||||
chatMessageCounts,
|
||||
LEAVE_ROOM_RATE_LIMIT,
|
||||
LEAVE_ROOM_RATE_WINDOW_MS,
|
||||
rateLimitDenied,
|
||||
@@ -99,6 +103,32 @@ describe('LEAVE_ROOM Rate Limiter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('CHAT_MESSAGE Rate Limiter', () => {
|
||||
const socketId = 'chat-socket';
|
||||
|
||||
beforeEach(() => {
|
||||
clearRateLimitMaps();
|
||||
rateLimitDenied.chatMessages = 0;
|
||||
});
|
||||
|
||||
afterEach(() => clearRateLimitMaps());
|
||||
|
||||
it('allows ten messages per ten-second window and blocks the next', () => {
|
||||
for (let i = 0; i < CHAT_MESSAGE_RATE_LIMIT; i++) {
|
||||
expect(checkChatMessageRate(socketId)).toBe(true);
|
||||
}
|
||||
expect(checkChatMessageRate(socketId)).toBe(false);
|
||||
expect(rateLimitDenied.chatMessages).toBe(1);
|
||||
});
|
||||
|
||||
it('resets after the window expires', () => {
|
||||
for (let i = 0; i <= CHAT_MESSAGE_RATE_LIMIT; i++) checkChatMessageRate(socketId);
|
||||
const entry = chatMessageCounts.get(socketId);
|
||||
entry.resetTime = Date.now() - CHAT_MESSAGE_RATE_WINDOW_MS - 1;
|
||||
expect(checkChatMessageRate(socketId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rate Limit Constants', () => {
|
||||
it('should have correct rate limit values', () => {
|
||||
expect(LEAVE_ROOM_RATE_LIMIT).toBe(10);
|
||||
|
||||
+7
-2
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
export const PROTOCOL_VERSION = "1.0.0";
|
||||
export const APP_VERSION = "2.6.3";
|
||||
export const APP_VERSION = "2.6.4";
|
||||
|
||||
export const OFFICIAL_SERVER_URL = 'wss://syncserver.koalastuff.net';
|
||||
export const OFFICIAL_LANDING_PAGE_URL = 'https://sync.koalastuff.net';
|
||||
@@ -57,6 +57,9 @@ export const EVENTS = {
|
||||
EPISODE_READY: "episode_ready", // Response: loaded the episode and paused at 0:00
|
||||
EPISODE_LOBBY_CANCEL: "episode_lobby_cancel", // Broadcast: cancel active lobby and resume
|
||||
|
||||
// Ephemeral end-to-end encrypted chat
|
||||
CHAT_MESSAGE: "chat_message", // Ciphertext relay; no server history
|
||||
|
||||
// Ping / Latency
|
||||
PING: "ping", // { t: timestamp, target?: peerId } — empty target = server echo
|
||||
PONG: "pong" // server responds with same { t } for client RTT calculation
|
||||
@@ -77,7 +80,9 @@ export const CONTROL_MODES = {
|
||||
// simply ignore the field. Add a flag here as each server-gated feature lands.
|
||||
export const CAPABILITIES = {
|
||||
HOST_CONTROL: 'host-control',
|
||||
CO_HOST: 'co-host' // owner promotes guests to additional controllers
|
||||
CO_HOST: 'co-host', // owner promotes guests to additional controllers
|
||||
CHAT: 'chat', // legacy server capability used by the first chat beta
|
||||
CHAT_V1: 'chat-v1' // versioned client/server chat wire contract
|
||||
};
|
||||
|
||||
export const HEARTBEAT_INTERVAL = 15000; // 15s
|
||||
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+3
-1
@@ -8,7 +8,9 @@ export default defineConfig({
|
||||
'server/**/*.test.js',
|
||||
'server/**/*.test.mjs',
|
||||
'shared/**/*.test.js',
|
||||
'shared/**/*.test.mjs'
|
||||
'shared/**/*.test.mjs',
|
||||
'extension/**/*.test.js',
|
||||
'extension/**/*.test.mjs'
|
||||
],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
|
||||
+1
-1
@@ -135,7 +135,7 @@
|
||||
<div class="container">
|
||||
<p>© 2026 KoalaSync. Open source under the MIT License.</p>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
|
||||
<a href="/imprint" style="color: var(--text-muted); text-decoration: none;">Legal Notice</a>
|
||||
<a href="https://koalastuff.net/legal" style="color: var(--text-muted); text-decoration: none;">Legal Notice</a>
|
||||
<a href="/privacy" style="color: var(--text-muted); text-decoration: none;">Privacy Policy</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+24
-27
@@ -457,15 +457,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;
|
||||
@@ -527,14 +523,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');
|
||||
@@ -564,22 +561,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
|
||||
};
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
+103
-36
@@ -5,6 +5,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { execFileSync } = require('child_process');
|
||||
const sharp = require('sharp');
|
||||
const esbuild = require('esbuild');
|
||||
const htmlnano = require('htmlnano');
|
||||
@@ -285,6 +286,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);
|
||||
@@ -299,6 +306,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 ──
|
||||
@@ -315,6 +323,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 ──
|
||||
@@ -541,7 +550,7 @@ async function compile() {
|
||||
}
|
||||
|
||||
// ── 5.5 Generate dynamic sitemap ──
|
||||
generateSitemap(wwwDir);
|
||||
generateSitemap(websiteDir, wwwDir);
|
||||
|
||||
// Auto-copy Google verification files and IndexNow/txt key files from website source to www root
|
||||
const websiteFiles = fs.readdirSync(websiteDir);
|
||||
@@ -654,6 +663,9 @@ async function compile() {
|
||||
html = html.replace(/(<script\b[^>]*?\bsrc=")((?:\.\.\/)*\/?)app\.min\.js"/g, (m, before, prefix) => {
|
||||
return `${before}${prefix}${appName}" integrity="${appSRI}" crossorigin="anonymous"`;
|
||||
});
|
||||
html = html.replace(/(<script\b[^>]*?\bsrc=")((?:\.\.\/)*\/?)invite-links\.min\.js"/g, (m, before, prefix) => {
|
||||
return `${before}${prefix}${inviteLinksName}" integrity="${inviteLinksSRI}" crossorigin="anonymous"`;
|
||||
});
|
||||
html = html.replace(/(<script\b[^>]*?\bsrc=")((?:\.\.\/)*\/?)lang-init\.min\.js"/g, (m, before, prefix) => {
|
||||
return `${before}${prefix}${langName}" integrity="${langSRI}" crossorigin="anonymous"`;
|
||||
});
|
||||
@@ -686,7 +698,7 @@ async function compile() {
|
||||
console.log('KoalaSync build finished successfully! Output: website/www/');
|
||||
}
|
||||
|
||||
function generateSitemap(wwwDir) {
|
||||
function generateSitemap(websiteDir, wwwDir) {
|
||||
const languages = [
|
||||
{ code: 'en', prefix: '', hreflang: 'en' },
|
||||
{ code: 'de', prefix: 'de/', hreflang: 'de' },
|
||||
@@ -705,52 +717,105 @@ function generateSitemap(wwwDir) {
|
||||
{ code: 'pt', prefix: 'pt/', hreflang: 'pt' }
|
||||
];
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const repoRoot = path.resolve(websiteDir, '..');
|
||||
const lastModifiedCache = new Map();
|
||||
const dirtySourceFiles = new Set();
|
||||
let gitHistoryAvailable = false;
|
||||
let usedDirtySourceDate = false;
|
||||
|
||||
try {
|
||||
const isInsideWorkTree = execFileSync(
|
||||
'git', ['rev-parse', '--is-inside-work-tree'],
|
||||
{ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
||||
).trim() === 'true';
|
||||
const isShallow = execFileSync(
|
||||
'git', ['rev-parse', '--is-shallow-repository'],
|
||||
{ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
||||
).trim() === 'true';
|
||||
gitHistoryAvailable = isInsideWorkTree && !isShallow;
|
||||
|
||||
if (gitHistoryAvailable) {
|
||||
const dirtyCommands = [
|
||||
['diff', '--name-only', '--'],
|
||||
['diff', '--cached', '--name-only', '--'],
|
||||
['ls-files', '--others', '--exclude-standard', '--']
|
||||
];
|
||||
for (const args of dirtyCommands) {
|
||||
const output = execFileSync(
|
||||
'git', args,
|
||||
{ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
||||
);
|
||||
for (const file of output.split(/\r?\n/).filter(Boolean)) {
|
||||
dirtySourceFiles.add(file.replaceAll('\\', '/'));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
gitHistoryAvailable = false;
|
||||
}
|
||||
|
||||
function getLastModified(sourceFiles) {
|
||||
const cacheKey = sourceFiles.slice().sort().join('\0');
|
||||
if (lastModifiedCache.has(cacheKey)) return lastModifiedCache.get(cacheKey);
|
||||
if (!gitHistoryAvailable) {
|
||||
lastModifiedCache.set(cacheKey, null);
|
||||
return null;
|
||||
}
|
||||
let date = null;
|
||||
try {
|
||||
if (sourceFiles.some(file => dirtySourceFiles.has(file))) {
|
||||
usedDirtySourceDate = true;
|
||||
date = new Date().toISOString().slice(0, 10);
|
||||
} else {
|
||||
const output = execFileSync(
|
||||
'git',
|
||||
['log', '-1', '--format=%cs', '--', ...sourceFiles],
|
||||
{ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
||||
).trim();
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(output)) date = output;
|
||||
}
|
||||
} catch (_) {
|
||||
// Source archives and minimal deployment environments may not contain
|
||||
// Git metadata. Omitting lastmod is more accurate than inventing a date.
|
||||
}
|
||||
lastModifiedCache.set(cacheKey, date);
|
||||
return date;
|
||||
}
|
||||
|
||||
function lastmod(sourceFiles, indent = ' ') {
|
||||
const date = getLastModified(sourceFiles);
|
||||
return date ? `\n${indent}<lastmod>${date}</lastmod>` : '';
|
||||
}
|
||||
|
||||
let xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
xmlns:xhtml="http://www.w3.org/1999/xhtml">`;
|
||||
|
||||
// Static legal pages
|
||||
// Static project-specific policy and help pages
|
||||
xml += `
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/imprint</loc>
|
||||
<lastmod>${today}</lastmod>
|
||||
<loc>https://sync.koalastuff.net/privacy</loc>${lastmod(['website/privacy.html'])}
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/privacy</loc>
|
||||
<lastmod>${today}</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/site-access-help.html</loc>
|
||||
<lastmod>${today}</lastmod>
|
||||
<loc>https://sync.koalastuff.net/site-access-help.html</loc>${lastmod(['website/site-access-help.html'])}
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/de/impressum</loc>
|
||||
<lastmod>${today}</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/de/datenschutz</loc>
|
||||
<lastmod>${today}</lastmod>
|
||||
<loc>https://sync.koalastuff.net/de/datenschutz</loc>${lastmod(['website/datenschutz-de.html'])}
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>`;
|
||||
|
||||
function addPage(relativePath, changefreq, priority) {
|
||||
function addPage(relativePath, changefreq, priority, templateSource) {
|
||||
for (const lang of languages) {
|
||||
const loc = `https://sync.koalastuff.net/${lang.prefix}${relativePath}`;
|
||||
const sourceFiles = [templateSource, `website/locales/${lang.code}.json`];
|
||||
xml += `
|
||||
<url>
|
||||
<loc>${loc}</loc>
|
||||
<lastmod>${today}</lastmod>
|
||||
<loc>${loc}</loc>${lastmod(sourceFiles)}
|
||||
<changefreq>${changefreq}</changefreq>
|
||||
<priority>${priority}</priority>`;
|
||||
for (const alt of languages) {
|
||||
@@ -765,24 +830,26 @@ function generateSitemap(wwwDir) {
|
||||
}
|
||||
}
|
||||
|
||||
addPage('', 'weekly', '1.0');
|
||||
addPage('alternatives', 'weekly', '0.7');
|
||||
addPage('', 'weekly', '1.0', 'website/template.html');
|
||||
addPage('alternatives', 'weekly', '0.7', 'website/alternatives/index.html');
|
||||
|
||||
const subpages = [
|
||||
'alternatives/teleparty',
|
||||
'alternatives/screen-sharing',
|
||||
'alternatives/watch2gether',
|
||||
'alternatives/scener',
|
||||
'alternatives/kosmi',
|
||||
'alternatives/twoseven'
|
||||
['alternatives/teleparty', 'website/alternatives/teleparty.html'],
|
||||
['alternatives/screen-sharing', 'website/alternatives/screen-sharing.html'],
|
||||
['alternatives/watch2gether', 'website/alternatives/watch2gether.html'],
|
||||
['alternatives/scener', 'website/alternatives/scener.html'],
|
||||
['alternatives/kosmi', 'website/alternatives/kosmi.html'],
|
||||
['alternatives/twoseven', 'website/alternatives/twoseven.html']
|
||||
];
|
||||
for (const sub of subpages) {
|
||||
addPage(sub, 'weekly', '0.7');
|
||||
for (const [sub, templateSource] of subpages) {
|
||||
addPage(sub, 'weekly', '0.7', templateSource);
|
||||
}
|
||||
|
||||
xml += `\n</urlset>\n`;
|
||||
|
||||
fs.writeFileSync(path.join(wwwDir, 'sitemap.xml'), xml.trim() + '\n', 'utf8');
|
||||
console.log(' ✓ Dynamically generated sitemap.xml with current date');
|
||||
const datedEntries = (xml.match(/<lastmod>/g) || []).length;
|
||||
const dateSource = usedDirtySourceDate ? 'Git/working-tree-derived' : 'Git-derived';
|
||||
console.log(` ✓ Dynamically generated sitemap.xml with ${datedEntries} ${dateSource} modification dates`);
|
||||
}
|
||||
compile().catch(err => { console.error('Build failed:', err); process.exit(1); });
|
||||
|
||||
@@ -297,7 +297,7 @@
|
||||
<p>© 2026 KoalaSync. Open Source unter der MIT-Lizenz.</p>
|
||||
<p style="font-size: 0.8rem; margin-top: 0.5rem;">Keine Relay-Logs. Kein Tracking. Website-Access-Logs werden nach 7 Tagen gelöscht.</p>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
|
||||
<a href="impressum" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
|
||||
<a href="https://koalastuff.net/legal" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
|
||||
<a href="datenschutz" style="color: var(--text-muted); text-decoration: none;">Datenschutz</a>
|
||||
<a href="alternatives/teleparty">Vergleiche & Anleitungen</a>
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" style="color: var(--text-muted); text-decoration: none; display: inline-flex; align-items: center; gap: 4px;">
|
||||
|
||||
+15
-244
@@ -1,250 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" class="lang-de">
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Impressum | KoalaSync</title>
|
||||
<link rel="preload" href="../style.min.css" as="style">
|
||||
<link rel="stylesheet" href="../style.min.css">
|
||||
<link rel="icon" type="image/webp" href="../assets/NewLogoIcon_64.webp">
|
||||
<link rel="canonical" href="https://sync.koalastuff.net/de/impressum">
|
||||
<link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/imprint">
|
||||
<link rel="alternate" hreflang="de" href="https://sync.koalastuff.net/de/impressum">
|
||||
<link rel="alternate" hreflang="x-default" href="https://sync.koalastuff.net/imprint">
|
||||
<meta name="description" content="Impressum (Legal Notice) von KoalaSync - Anbieterkennzeichnung und Kontaktinformationen für die Open-Source Video-Synchronisations-Erweiterung.">
|
||||
<meta name="robots" content="index, follow">
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{ "@type": "ListItem", "position": 1, "name": "Startseite", "item": "https://sync.koalastuff.net/de/" },
|
||||
{ "@type": "ListItem", "position": 2, "name": "Impressum", "item": "https://sync.koalastuff.net/de/impressum" }
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": "Impressum",
|
||||
"url": "https://sync.koalastuff.net/de/impressum",
|
||||
"description": "Impressum (Legal Notice) von KoalaSync - Anbieterkennzeichnung und Kontaktinformationen für die Open-Source Video-Synchronisations-Erweiterung.",
|
||||
"inLanguage": "de",
|
||||
"isPartOf": {
|
||||
"@type": "WebSite",
|
||||
"name": "KoalaSync",
|
||||
"url": "https://sync.koalastuff.net/de/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Mobile Browser Theme Styling -->
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
|
||||
<script src="../lang-init.min.js"></script>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Impressum | KoalaSync</title>
|
||||
<meta name="description" content="Das KoalaSync-Impressum ist zum zentralen KoalaStuff-Impressum umgezogen.">
|
||||
<meta name="robots" content="noindex, follow">
|
||||
<link rel="canonical" href="https://koalastuff.net/legal">
|
||||
<link rel="stylesheet" href="../style.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-nature" aria-hidden="true">
|
||||
<div class="bg-dusk-tint"></div>
|
||||
<div class="bg-depth-day">
|
||||
<div class="bg-godrays">
|
||||
<span class="godray godray-1"></span>
|
||||
<span class="godray godray-2"></span>
|
||||
<span class="godray godray-3"></span>
|
||||
</div>
|
||||
<div class="bg-canopy"></div>
|
||||
<span class="canopy-arc canopy-arc-1"></span>
|
||||
<span class="canopy-arc canopy-arc-2"></span>
|
||||
<span class="canopy-arc canopy-arc-3"></span>
|
||||
<span class="canopy-arc canopy-arc-4"></span>
|
||||
</div>
|
||||
<div id="bamboo-far">
|
||||
<span class="bamboo-stalk far-1"></span>
|
||||
<span class="bamboo-stalk far-2"></span>
|
||||
<span class="bamboo-stalk far-3"></span>
|
||||
<span class="bamboo-stalk far-4"></span>
|
||||
<span class="bamboo-stalk far-5"></span>
|
||||
</div>
|
||||
<div id="bamboo-near">
|
||||
<div class="bamboo-stalk near-1">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
<i class="bamboo-leaf leaf-2"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-2">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-3">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fall-leaf fall-leaf-1"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-2"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-3"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-4"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-5"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-6"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-7"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-8"><i></i></span>
|
||||
<span class="bg-grass bg-grass-1"></span>
|
||||
<span class="bg-grass bg-grass-2"></span>
|
||||
<span class="bg-grass bg-grass-3"></span>
|
||||
<span class="bg-grass bg-grass-4"></span>
|
||||
<div class="bg-depth-dusk">
|
||||
<div class="bg-mist bg-mist-1"></div>
|
||||
<div class="bg-mist bg-mist-2"></div>
|
||||
<div class="bg-horizon"></div>
|
||||
<span class="firefly-wrap fw-1"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-2"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-3"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-4"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-5"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-6"><span class="firefly"></span></span>
|
||||
</div>
|
||||
<div class="bg-light-sweep"></div>
|
||||
<div class="bg-grain"></div>
|
||||
<main class="legal-content">
|
||||
<div class="legal-card">
|
||||
<h1>Impressum umgezogen</h1>
|
||||
<p>Das KoalaSync-Impressum ist jetzt unter der zentralen KoalaStuff-Adresse verfügbar.</p>
|
||||
<p><a class="legal-inline-link" href="https://koalastuff.net/legal">Zentrales Impressum öffnen</a></p>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<div class="container nav-content">
|
||||
<a href="./" class="logo-area" style="text-decoration: none;">
|
||||
<img src="../assets/NewLogoIcon_128.webp" alt="KoalaSync Logo" width="40" height="40">
|
||||
<span>KoalaSync</span>
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
<a href="./" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="width: 16px; height: 16px; display: block;"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>
|
||||
<span>Startseite</span>
|
||||
</a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" rel="noopener" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" aria-hidden="true" style="display: block;"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="lang-select-container">
|
||||
<select class="lang-dropdown" aria-label="Select Language">
|
||||
<option value="en">🇬🇧 English</option>
|
||||
<option value="de">🇩🇪 Deutsch</option>
|
||||
<option value="fr">🇫🇷 Français</option>
|
||||
<option value="es">🇪🇸 Español</option>
|
||||
<option value="pt-BR">🇧🇷 Português (Brasil)</option>
|
||||
<option value="ru">🇷🇺 Русский</option>
|
||||
<option value="it">🇮🇹 Italiano</option>
|
||||
<option value="pl">🇵🇱 Polski</option>
|
||||
<option value="tr">🇹🇷 Türkçe</option>
|
||||
<option value="nl">🇳🇱 Nederlands</option>
|
||||
<option value="ja">🇯🇵 日本語</option>
|
||||
<option value="ko">🇰🇷 한국어</option>
|
||||
<option value="pt">🇵🇹 Português (Portugal)</option>
|
||||
</select>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="chevron-icon"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</div>
|
||||
<button class="hamburger" aria-label="Menu" aria-expanded="false">☰</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="legal-content">
|
||||
<div class="legal-card" data-reveal style="padding: 2rem;">
|
||||
<div style="display: flex; justify-content: center;">
|
||||
<img src="../assets/KoalaImprintl-180.webp" srcset="../assets/KoalaImprintl-180.webp 180w, ../assets/KoalaImprintl-360.webp 360w" sizes="180px" alt="Niedlicher Koala, der das Impressum repräsentiert" class="legal-mascot" width="180" height="180">
|
||||
</div>
|
||||
<h1>Impressum / Anbieterinformationen</h1>
|
||||
<p style="text-align: center; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.8rem; border-bottom: 1px solid var(--glass-border); padding-bottom: 1.5rem; margin-bottom: 2rem;">
|
||||
Kontakt & Projektinformationen
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2>Betreiber & Kontakt</h2>
|
||||
<p>Timo Schmidt</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
E-Mail: <span class="email-reveal" data-user="koalasync" data-domain="koalastuff.net" style="color: var(--accent); cursor: pointer; text-decoration: underline;">[E-Mail anzeigen]</span>
|
||||
</p>
|
||||
<p style="margin-top: 0.75rem;">
|
||||
<span class="legal-inline-label">🔒 Private Nachricht:</span>
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" class="legal-inline-link">@koalastuff auf Mastodon</a>
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
<span class="legal-inline-label">🐛 Fehlerberichte und Sicherheitsmeldungen:</span>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/issues" target="_blank" rel="noopener" class="legal-inline-link">GitHub Issues</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Projektinformationen</h2>
|
||||
<p>
|
||||
KoalaSync ist eine kostenlose und quelloffene Browser-Erweiterung. Der Quellcode ist auf GitHub unter der MIT-Lizenz verfügbar.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
Informationen zum Datenschutz, einschließlich Hosting, Access Logs, Relay-Verhalten und Betroffenenrechten, finden Sie in der <a href="datenschutz" class="legal-inline-link">Datenschutzerklärung</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Haftung für Inhalte</h2>
|
||||
<p>
|
||||
Die Inhalte dieser Website wurden mit Sorgfalt erstellt. Ich kann jedoch nicht gewährleisten, dass alle Informationen jederzeit vollständig, richtig oder aktuell sind.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
Als Diensteanbieter bin ich nach den allgemeinen Gesetzen für eigene Inhalte auf diesen Seiten verantwortlich.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Haftung für Links</h2>
|
||||
<p>
|
||||
Diese Website enthält Links zu externen Websites Dritter. Auf deren Inhalte habe ich keinen Einfluss. Für die Inhalte verlinkter Seiten ist der jeweilige Anbieter oder Betreiber verantwortlich.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
Wenn mir rechtswidrige Inhalte auf verlinkten Seiten bekannt werden, entferne ich die entsprechenden Links.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Lizenz & Urheberrecht</h2>
|
||||
<p>
|
||||
KoalaSync ist freie und quelloffene Software. Sofern nicht anders angegeben, steht der Quellcode von KoalaSync unter der MIT-Lizenz.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
Die MIT-Lizenz erlaubt Nutzung, Vervielfältigung, Bearbeitung, Verbreitung, Unterlizenzierung und Verkauf der Software, sofern der Copyright-Hinweis und der Lizenztext entsprechend den Lizenzbedingungen erhalten bleiben.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
Projektname, Logos, Grafiken, Website-Texte und andere Nicht-Code-Assets können separaten Rechten unterliegen, sofern sie nicht ausdrücklich von der Repository-Lizenz umfasst sind.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p>© 2026 KoalaSync. Open Source unter der MIT-Lizenz.</p>
|
||||
<p style="font-size: 0.8rem; margin-top: 0.5rem;">Keine Relay-Logs. Kein Tracking. Website-Access-Logs werden nach 7 Tagen gelöscht.</p>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
|
||||
<a href="impressum" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
|
||||
<a href="datenschutz" style="color: var(--text-muted); text-decoration: none;">Datenschutz</a>
|
||||
<a href="alternatives">Vergleiche & Anleitungen</a>
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" style="color: var(--text-muted); text-decoration: none; display: inline-flex; align-items: center; gap: 4px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="14" height="14" aria-hidden="true" style="display: block;"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></svg>
|
||||
Mastodon
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="../app.min.js"></script>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+8
-20
@@ -1,26 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, follow">
|
||||
<title>Redirecting...</title>
|
||||
<script>
|
||||
(function() {
|
||||
var savedLang = localStorage.getItem('koala_lang');
|
||||
var browserLang = (navigator.language || navigator.userLanguage || '').toLowerCase();
|
||||
var isDe = (savedLang === 'de') || (!savedLang && browserLang.indexOf('de') === 0);
|
||||
var path = window.location.pathname;
|
||||
var hasHtml = path.endsWith('.html');
|
||||
if (isDe) {
|
||||
window.location.replace(hasHtml ? 'de/impressum.html' : 'de/impressum');
|
||||
} else {
|
||||
window.location.replace(hasHtml ? 'imprint.html' : 'imprint');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="refresh" content="0; url=https://koalastuff.net/legal">
|
||||
<meta name="robots" content="noindex, follow">
|
||||
<link rel="canonical" href="https://koalastuff.net/legal">
|
||||
<title>Legal Notice | KoalaSync</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Redirecting / Weiterleitung...</p>
|
||||
<p><a href="https://koalastuff.net/legal">Open the central KoalaStuff legal notice</a></p>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+15
-244
@@ -1,250 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="lang-en">
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Legal Notice | KoalaSync</title>
|
||||
<link rel="preload" href="style.min.css" as="style">
|
||||
<link rel="stylesheet" href="style.min.css">
|
||||
<link rel="icon" type="image/webp" href="assets/NewLogoIcon_64.webp">
|
||||
<link rel="canonical" href="https://sync.koalastuff.net/imprint">
|
||||
<link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/imprint">
|
||||
<link rel="alternate" hreflang="de" href="https://sync.koalastuff.net/de/impressum">
|
||||
<link rel="alternate" hreflang="x-default" href="https://sync.koalastuff.net/imprint">
|
||||
<meta name="description" content="Legal Notice (Impressum) of KoalaSync - Contact information and operator details for the open-source video synchronization extension.">
|
||||
<meta name="robots" content="index, follow">
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://sync.koalastuff.net/" },
|
||||
{ "@type": "ListItem", "position": 2, "name": "Legal Notice", "item": "https://sync.koalastuff.net/imprint" }
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": "Legal Notice",
|
||||
"url": "https://sync.koalastuff.net/imprint",
|
||||
"description": "Legal Notice (Impressum) of KoalaSync - Contact information and operator details for the open-source video synchronization extension.",
|
||||
"inLanguage": "en",
|
||||
"isPartOf": {
|
||||
"@type": "WebSite",
|
||||
"name": "KoalaSync",
|
||||
"url": "https://sync.koalastuff.net/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Mobile Browser Theme Styling -->
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
|
||||
<script src="lang-init.min.js"></script>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Legal Notice | KoalaSync</title>
|
||||
<meta name="description" content="The KoalaSync legal notice has moved to the central KoalaStuff legal notice.">
|
||||
<meta name="robots" content="noindex, follow">
|
||||
<link rel="canonical" href="https://koalastuff.net/legal">
|
||||
<link rel="stylesheet" href="style.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-nature" aria-hidden="true">
|
||||
<div class="bg-dusk-tint"></div>
|
||||
<div class="bg-depth-day">
|
||||
<div class="bg-godrays">
|
||||
<span class="godray godray-1"></span>
|
||||
<span class="godray godray-2"></span>
|
||||
<span class="godray godray-3"></span>
|
||||
</div>
|
||||
<div class="bg-canopy"></div>
|
||||
<span class="canopy-arc canopy-arc-1"></span>
|
||||
<span class="canopy-arc canopy-arc-2"></span>
|
||||
<span class="canopy-arc canopy-arc-3"></span>
|
||||
<span class="canopy-arc canopy-arc-4"></span>
|
||||
</div>
|
||||
<div id="bamboo-far">
|
||||
<span class="bamboo-stalk far-1"></span>
|
||||
<span class="bamboo-stalk far-2"></span>
|
||||
<span class="bamboo-stalk far-3"></span>
|
||||
<span class="bamboo-stalk far-4"></span>
|
||||
<span class="bamboo-stalk far-5"></span>
|
||||
</div>
|
||||
<div id="bamboo-near">
|
||||
<div class="bamboo-stalk near-1">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
<i class="bamboo-leaf leaf-2"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-2">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-3">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fall-leaf fall-leaf-1"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-2"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-3"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-4"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-5"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-6"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-7"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-8"><i></i></span>
|
||||
<span class="bg-grass bg-grass-1"></span>
|
||||
<span class="bg-grass bg-grass-2"></span>
|
||||
<span class="bg-grass bg-grass-3"></span>
|
||||
<span class="bg-grass bg-grass-4"></span>
|
||||
<div class="bg-depth-dusk">
|
||||
<div class="bg-mist bg-mist-1"></div>
|
||||
<div class="bg-mist bg-mist-2"></div>
|
||||
<div class="bg-horizon"></div>
|
||||
<span class="firefly-wrap fw-1"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-2"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-3"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-4"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-5"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-6"><span class="firefly"></span></span>
|
||||
</div>
|
||||
<div class="bg-light-sweep"></div>
|
||||
<div class="bg-grain"></div>
|
||||
<main class="legal-content">
|
||||
<div class="legal-card">
|
||||
<h1>Legal notice moved</h1>
|
||||
<p>The KoalaSync legal notice is now available at the central KoalaStuff address.</p>
|
||||
<p><a class="legal-inline-link" href="https://koalastuff.net/legal">Open the central legal notice</a></p>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<div class="container nav-content">
|
||||
<a href="./" class="logo-area" style="text-decoration: none;">
|
||||
<img src="assets/NewLogoIcon_128.webp" alt="KoalaSync Logo" width="40" height="40">
|
||||
<span>KoalaSync</span>
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
<a href="./" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="width: 16px; height: 16px; display: block;"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>
|
||||
<span>Home</span>
|
||||
</a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" rel="noopener" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" aria-hidden="true" style="display: block;"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="lang-select-container">
|
||||
<select class="lang-dropdown" aria-label="Select Language">
|
||||
<option value="en">🇬🇧 English</option>
|
||||
<option value="de">🇩🇪 Deutsch</option>
|
||||
<option value="fr">🇫🇷 Français</option>
|
||||
<option value="es">🇪🇸 Español</option>
|
||||
<option value="pt-BR">🇧🇷 Português (Brasil)</option>
|
||||
<option value="ru">🇷🇺 Русский</option>
|
||||
<option value="it">🇮🇹 Italiano</option>
|
||||
<option value="pl">🇵🇱 Polski</option>
|
||||
<option value="tr">🇹🇷 Türkçe</option>
|
||||
<option value="nl">🇳🇱 Nederlands</option>
|
||||
<option value="ja">🇯🇵 日本語</option>
|
||||
<option value="ko">🇰🇷 한국어</option>
|
||||
<option value="pt">🇵🇹 Português (Portugal)</option>
|
||||
</select>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="chevron-icon"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</div>
|
||||
<button class="hamburger" aria-label="Menu" aria-expanded="false">☰</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="legal-content">
|
||||
<div class="legal-card" data-reveal style="padding: 2rem;">
|
||||
<div style="display: flex; justify-content: center;">
|
||||
<img src="assets/KoalaImprintl-180.webp" srcset="assets/KoalaImprintl-180.webp 180w, assets/KoalaImprintl-360.webp 360w" sizes="180px" alt="Cute Koala representing the legal notice page" class="legal-mascot" width="180" height="180">
|
||||
</div>
|
||||
<h1>Legal Notice</h1>
|
||||
<p style="text-align: center; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.8rem; border-bottom: 1px solid var(--glass-border); padding-bottom: 1.5rem; margin-bottom: 2rem;">
|
||||
Contact & Project Information
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2>Operator & Contact</h2>
|
||||
<p>Timo Schmidt</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
E-Mail: <span class="email-reveal" data-user="koalasync" data-domain="koalastuff.net" style="color: var(--accent); cursor: pointer; text-decoration: underline;">[Show Email]</span>
|
||||
</p>
|
||||
<p style="margin-top: 0.75rem;">
|
||||
<span class="legal-inline-label">🔒 Private message:</span>
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" class="legal-inline-link">@koalastuff on Mastodon</a>
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
<span class="legal-inline-label">🐛 Bug reports and security concerns:</span>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/issues" target="_blank" rel="noopener" class="legal-inline-link">GitHub Issues</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Project Information</h2>
|
||||
<p>
|
||||
KoalaSync is a free and open-source browser extension. The source code is available on GitHub under the MIT License.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
For privacy-related information, including hosting, access logs, relay behavior, and data subject rights, please see the <a href="privacy" class="legal-inline-link">Privacy Policy</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Liability for Content</h2>
|
||||
<p>
|
||||
The contents of this website were created with care. However, I cannot guarantee that all information is always complete, accurate, or up to date.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
As a service provider, I am responsible for my own content on these pages in accordance with the general laws.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Liability for Links</h2>
|
||||
<p>
|
||||
This website contains links to external third-party websites. I have no influence over the content of those websites. The respective provider or operator is responsible for the content of linked pages.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
If I become aware of unlawful content on linked pages, I will remove the corresponding links.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>License & Copyright</h2>
|
||||
<p>
|
||||
KoalaSync is free and open-source software. Unless otherwise stated, the KoalaSync source code is licensed under the MIT License.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
The MIT License allows use, copying, modification, distribution, sublicensing, and sale of the software, provided that the copyright notice and license text are included where required by the license.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
Project name, logos, graphics, website text, and other non-code assets may be subject to separate rights unless they are explicitly included under the repository license.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p>© 2026 KoalaSync. Open source under the MIT License.</p>
|
||||
<p style="font-size: 0.8rem; margin-top: 0.5rem;">No relay logs. No tracking. Website access logs are deleted after 7 days.</p>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
|
||||
<a href="imprint" style="color: var(--text-muted); text-decoration: none;">Legal Notice</a>
|
||||
<a href="privacy" style="color: var(--text-muted); text-decoration: none;">Privacy Policy</a>
|
||||
<a href="alternatives">Guides & comparisons</a>
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" style="color: var(--text-muted); text-decoration: none; display: inline-flex; align-items: center; gap: 4px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="14" height="14" aria-hidden="true" style="display: block;"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></svg>
|
||||
Mastodon
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="app.min.js"></script>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+3
-2
@@ -186,8 +186,8 @@
|
||||
<p style="font-size: 0.8rem; margin-top: 0.5rem;"><span lang="en">No relay logs. No tracking. Website access logs are deleted after 7 days.</span><span lang="de">Keine Relay-Logs. Kein Tracking. Website-Access-Logs werden nach 7 Tagen gelöscht.</span></p>
|
||||
<p class="footer-disclaimer" style="margin-top: 1.2rem;"><span lang="en">KoalaSync is not affiliated with, endorsed by, or associated with Netflix, Disney+, Amazon, YouTube, Twitch, or any other streaming platform. All trademarks are property of their respective owners.</span><span lang="de">KoalaSync ist nicht mit Netflix, Disney+, Amazon, YouTube, Twitch oder anderen Streaming-Plattformen verbunden, wird von diesen nicht unterstützt oder steht in keiner Beziehung zu diesen. Alle Markenrechte liegen bei ihren jeweiligen Inhabern.</span></p>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
|
||||
<a href="imprint" lang="en" style="color: var(--text-muted); text-decoration: none;">Legal Notice</a>
|
||||
<a href="de/impressum" lang="de" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
|
||||
<a href="https://koalastuff.net/legal" lang="en" style="color: var(--text-muted); text-decoration: none;">Legal Notice</a>
|
||||
<a href="https://koalastuff.net/legal" lang="de" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
|
||||
<a href="privacy" lang="en" style="color: var(--text-muted); text-decoration: none;">Privacy Policy</a>
|
||||
<a href="de/datenschutz" lang="de" style="color: var(--text-muted); text-decoration: none;">Datenschutz</a>
|
||||
<a href="alternatives"><span lang="en">Guides & comparisons</span><span lang="de">Vergleiche & Anleitungen</span></a>
|
||||
@@ -203,6 +203,7 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="invite-links.min.js"></script>
|
||||
<script src="app.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+2
-2
@@ -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.4
|
||||
- License: MIT
|
||||
- Extension runtime: dependency-free browser extension code
|
||||
- Relay: Node.js with Socket.IO-compatible WebSocket messaging
|
||||
@@ -112,6 +112,6 @@ Compatibility depends on each website's player implementation and can change whe
|
||||
- [Main website](https://sync.koalastuff.net/)
|
||||
- [GitHub issues](https://github.com/Shik3i/KoalaSync/issues): Bugs, compatibility reports and feature requests
|
||||
- [Mastodon](https://mastodon.social/@koalastuff): Private contact and project updates
|
||||
- [Legal notice](https://sync.koalastuff.net/imprint)
|
||||
- [Legal notice](https://koalastuff.net/legal)
|
||||
|
||||
KoalaSync is an independent project and is not affiliated with, endorsed by or associated with Netflix, Disney+, Amazon, YouTube, Twitch or any other streaming platform. All trademarks belong to their respective owners.
|
||||
|
||||
@@ -297,7 +297,7 @@
|
||||
<p>© 2026 KoalaSync. Open source under the MIT License.</p>
|
||||
<p style="font-size: 0.8rem; margin-top: 0.5rem;">No relay logs. No tracking. Website access logs are deleted after 7 days.</p>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
|
||||
<a href="imprint" style="color: var(--text-muted); text-decoration: none;">Legal Notice</a>
|
||||
<a href="https://koalastuff.net/legal" style="color: var(--text-muted); text-decoration: none;">Legal Notice</a>
|
||||
<a href="privacy" style="color: var(--text-muted); text-decoration: none;">Privacy Policy</a>
|
||||
<a href="alternatives">Guides & comparisons</a>
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" style="color: var(--text-muted); text-decoration: none; display: inline-flex; align-items: center; gap: 4px;">
|
||||
|
||||
@@ -17,6 +17,14 @@
|
||||
<meta property="og:title" content="Fix KoalaSync website access">
|
||||
<meta property="og:description" content="Quick browser-specific steps for reconnecting KoalaSync to a video tab.">
|
||||
<meta property="og:image" content="https://sync.koalastuff.net/assets/og-image.png">
|
||||
<meta property="og:image:width" content="1280">
|
||||
<meta property="og:image:height" content="640">
|
||||
<meta property="og:image:type" content="image/png">
|
||||
<meta property="og:site_name" content="KoalaSync">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="Fix KoalaSync website access">
|
||||
<meta name="twitter:description" content="Quick browser-specific steps for reconnecting KoalaSync to a video tab.">
|
||||
<meta name="twitter:image" content="https://sync.koalastuff.net/assets/og-image.png">
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
@@ -24,8 +32,24 @@
|
||||
"@type": "TechArticle",
|
||||
"headline": "Fix KoalaSync website access",
|
||||
"description": "Restore KoalaSync access when a browser blocks the extension on a video website.",
|
||||
"datePublished": "2026-07-15T10:24:59Z",
|
||||
"dateModified": "2026-07-19T19:20:05Z",
|
||||
"inLanguage": "en",
|
||||
"url": "https://sync.koalastuff.net/site-access-help.html",
|
||||
"mainEntityOfPage": {
|
||||
"@type": "WebPage",
|
||||
"@id": "https://sync.koalastuff.net/site-access-help.html"
|
||||
},
|
||||
"author": {
|
||||
"@type": "Organization",
|
||||
"name": "KoalaSync",
|
||||
"url": "https://sync.koalastuff.net/"
|
||||
},
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": "KoalaSync",
|
||||
"url": "https://sync.koalastuff.net/"
|
||||
},
|
||||
"isPartOf": {
|
||||
"@type": "WebSite",
|
||||
"name": "KoalaSync",
|
||||
@@ -134,7 +158,7 @@
|
||||
<p class="footer-ram">Rooms are ephemeral and stored in RAM.</p>
|
||||
<p class="footer-copyright">© 2026 KoalaSync. Open source under the MIT License.</p>
|
||||
<div class="footer-links">
|
||||
<a href="imprint.html">Legal notice</a>
|
||||
<a href="https://koalastuff.net/legal">Legal notice</a>
|
||||
<a href="privacy.html">Privacy</a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" rel="noopener">GitHub</a>
|
||||
</div>
|
||||
|
||||
Vendored
+2
-24
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
"priceCurrency": "EUR"
|
||||
},
|
||||
"description": "{{SCHEMA_APP_DESC}}",
|
||||
"softwareVersion": "2.6.3",
|
||||
"softwareVersion": "2.6.4",
|
||||
"license": "https://opensource.org/licenses/MIT",
|
||||
"sameAs": "https://github.com/Shik3i/KoalaSync",
|
||||
"image": "https://sync.koalastuff.net/assets/NewLogoIcon.webp",
|
||||
@@ -1574,7 +1574,7 @@
|
||||
<p class="footer-disclaimer">{{FOOTER_DISCLAIMER}}</p>
|
||||
<p class="footer-copyright">© 2026 KoalaSync. {{FOOTER_MIT}}</p>
|
||||
<div class="footer-links">
|
||||
<a href="{{ASSET_PATH}}{{FOOTER_LEGAL_LINK}}"><span>{{FOOTER_LEGAL}}</span></a>
|
||||
<a href="https://koalastuff.net/legal"><span>{{FOOTER_LEGAL}}</span></a>
|
||||
<a href="{{ASSET_PATH}}{{FOOTER_PRIVACY_LINK}}"><span>{{FOOTER_PRIVACY}}</span></a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives"><span><span lang="en">Guides & comparisons</span><span lang="de">Vergleiche & Anleitungen</span></span></a>
|
||||
<a href="https://support.koalastuff.net" target="_blank" rel="noopener" title="{{FOOTER_SUPPORT}}" class="footer-support-btn">
|
||||
@@ -1589,6 +1589,7 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="{{ASSET_PATH}}invite-links.min.js"></script>
|
||||
<script src="{{ASSET_PATH}}app.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "2.6.3",
|
||||
"date": "2026-07-15T11:31:00Z"
|
||||
"version": "2.6.4",
|
||||
"date": "2026-07-16T09:51:25Z"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user