Compare commits

...

49 Commits

Author SHA1 Message Date
Timo 213d2867fc fix(extension): support Google Drive video frames 2026-07-26 03:52:05 +02:00
GitHub Action 90a58eb507 chore(release): update versions to v3.0.0 [skip ci] 2026-07-26 00:25:06 +00:00
KoalaDev 72113b6504 Merge pull request #32 from Shik3i/agent/update-tooling-node24
Prepare v3.0.0: encrypted text chat and runtime updates
2026-07-26 02:24:12 +02:00
Timo a0954079a1 harden chat settings and server image 2026-07-26 02:22:07 +02:00
Timo 9251ae6aff add dedicated chat settings 2026-07-26 02:12:28 +02:00
Timo 511e042b0b update tooling and server runtime 2026-07-26 01:58:23 +02:00
KoalaDev 372c384a19 Merge pull request #31 from Shik3i/agent/dependabot-security-updates
Fix Dependabot build dependency alerts
2026-07-26 01:32:41 +02:00
Timo 79715046a8 fix npm 11 lockfile portability 2026-07-26 01:23:07 +02:00
Timo 2ae79386a0 fix vulnerable build dependencies 2026-07-26 01:18:24 +02:00
Timo 883e49433e Merge branch 'feature/textchat' 2026-07-26 01:04:38 +02:00
Timo 59135b319e Merge remote-tracking branch 'origin/main' into feature/textchat
# Conflicts:
#	website/build.cjs
2026-07-26 01:03:27 +02:00
Timo ae2238e3bc docs: plan modern chat for v3.0 2026-07-26 01:02:33 +02:00
Timo 77790a279c fix(chat): preserve mixed-version compatibility 2026-07-26 01:02:20 +02:00
KoalaDev 2df048621b Centralize KoalaStuff legal notice 2026-07-23 03:04:20 +02:00
KoalaDev 17c3ea295a Centralize KoalaStuff legal notice 2026-07-23 02:59:58 +02:00
Timo 5d3cee4271 fix: update sitemap dates for dirty sources 2026-07-19 21:41:45 +02:00
Timo 1149de7ab7 fix: derive sitemap dates from page history 2026-07-19 21:33:53 +02:00
Timo 4882e058f1 fix: keep support SEO and sitemap metadata accurate 2026-07-19 21:21:49 +02:00
Timo 8d7085c97e fix: hide access banner on smaller screens 2026-07-19 21:10:50 +02:00
Timo 630869260b ci: publish text chat server beta 2026-07-19 20:43:11 +02:00
Timo e9d735cd39 Merge remote-tracking branch 'origin/main' into feature/textchat
# Conflicts:
#	extension/background.js
#	extension/popup.js
2026-07-19 20:32:24 +02:00
Timo aee3588f43 Document v2.6.4 release 2026-07-16 12:01:21 +02:00
GitHub Action 7f898e6eb4 chore(release): update versions to v2.6.4 [skip ci] 2026-07-16 09:51:25 +00:00
Timo d2d4f5f3ea Localize extension store metadata 2026-07-16 11:50:21 +02:00
GitHub Action 6e625e3a38 chore(release): update versions to v2.6.3 [skip ci] 2026-07-15 11:37:32 +00:00
Timo 6aa2f53e6b Fix access guide spacing and user help link 2026-07-15 13:34:35 +02:00
Timo 2b485fed53 chore(release): update versions to v2.6.3 [skip ci] 2026-07-15 13:34:24 +02:00
Timo fe13275b5d Harden host access recovery races 2026-07-15 13:31:56 +02:00
Timo 61d0d64c7d Remove developer link from user access guide 2026-07-15 13:28:20 +02:00
Timo abcb10a1c3 Scope access help to Chromium rollout 2026-07-15 13:23:54 +02:00
Timo 03121477a8 Add dismissible localized access banner 2026-07-15 13:20:00 +02:00
Timo 285c04e00d Align browser help cards 2026-07-15 13:09:50 +02:00
Timo 1989eb639b Clarify sudden Chrome site access rollout 2026-07-15 13:07:48 +02:00
Timo fd48ce8481 Fix site access banner overlay and immediate guidance 2026-07-15 13:05:24 +02:00
GitHub Action 649c8796a9 chore(release): update versions to v2.6.2 [skip ci] 2026-07-15 10:47:24 +00:00
Timo 142153a131 Harden host access recovery for v2.6.2 2026-07-15 12:46:57 +02:00
Timo a334605dd5 Remove unrelated relay troubleshooting from help page 2026-07-15 12:27:59 +02:00
Timo 7e7e60f267 Fix blocked website access and add troubleshooting page 2026-07-15 12:24:59 +02:00
KoalaDev 354500873c fix: keep llms release version in sync 2026-07-15 10:05:26 +02:00
KoalaDev 9e0d294758 fix(chat): harden client state and messaging 2026-07-15 08:40:27 +02:00
KoalaDev 36c4c492d3 fix(chat): preserve detached overlay geometry 2026-07-15 07:31:27 +02:00
KoalaDev e9dc299fc9 feat(chat): add in-page overlay 2026-07-15 07:23:02 +02:00
KoalaDev ee0d68d8d3 feat(chat): add client encryption and key lifecycle 2026-07-15 07:10:32 +02:00
KoalaDev f2eee6dc41 feat(chat): add stateless ciphertext relay 2026-07-15 07:02:08 +02:00
KoalaDev 8fee98834c feat(chat): add encrypted invite contract 2026-07-15 06:58:22 +02:00
KoalaDev 771da1cf82 docs: add E2E chat feature brief
Design brief for the in-page E2E encrypted chat: threat model, key
derivation via URL fragment, #j2 link format, backward compatibility
with old extensions, and the constraints the discarded feature/soonTMChat
branch got wrong.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 06:25:52 +02:00
KoalaDev ab2d7747fd Merge branch 'main' of github.com:Shik3i/KoalaSync 2026-07-15 06:16:17 +02:00
KoalaDev 8f9676cc5a fix(popup): make Status tab match the other tabs
The tab is still internally named data-tab="tab-dev". It used to be the
dev/diagnostics tab and was deliberately de-emphasized with font-size:
11px and a dimmer color. It was later relabelled to the user-facing
"Status" tab, but the rule stayed, leaving it 3px smaller and paler than
Room/Sync/Settings.

Drop tab-dev from the selector. #devToolsTabBtn, the opt-in Dev tab that
shares the rule, stays de-emphasized on purpose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 06:15:40 +02:00
GitHub Action 477a814a79 chore(release): update versions to v2.6.1 [skip ci] 2026-07-15 03:57:56 +00:00
114 changed files with 7116 additions and 2367 deletions
+25 -4
View File
@@ -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
+2
View File
@@ -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
+8 -2
View File
@@ -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
View File
@@ -22,7 +22,7 @@ Please note that by participating in this project, you agree to abide by our [Co
### Prerequisites
- **Node.js** v18+
- **Node.js** v20.9+
- **Docker** (for local relay server testing)
### Quick Start
+2 -2
View File
@@ -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.0-blue?logo=github" alt="GitHub release"></a>
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v3.0.0-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.0 Release!</b> — See what's changed</a></p>
<p align="center"><a href="docs/CHANGELOG.md"><b>New v3.0.0 Release!</b> — See what's changed</a></p>
### 🌟 Why KoalaSync?
+44
View File
@@ -4,6 +4,50 @@ All notable changes to the KoalaSync browser extension and relay server.
---
## [v3.0.0] — 2026-07-26
### Added
- **Extension: Optional encrypted room chat** — Rooms now support live-only, end-to-end encrypted text messages. Chat is disabled and hidden by default and can be enabled explicitly in the extension options.
- **Extension: Persistent room chat key** — Every room generates and retains a chat key even while chat is disabled, so chat can be enabled later without creating a new room.
- **Extension: Floating player chat** — When enabled, chat is available from a floating control over the selected player and opens in an overlay without replacing the synchronized video.
- **Extension: Dedicated chat settings** — Chat now has its own settings section for enablement, left/right/free-floating placement, compact/standard/large/custom sizing, and bubble-versus-open startup behavior.
### Security
- **Ciphertext-only relay** — The relay receives and forwards encrypted message payloads without storing chat history or accepting client-supplied plaintext, sender identities, timestamps, or message IDs as authoritative.
- **Backward-compatible rollout** — Versioned `chat-v1` capabilities ensure old non-chat extensions never receive unknown chat events, while current extensions continue to work with older chatless relay versions.
### Changed
- **Build dependencies** — Updated the supported build and validation toolchain and refreshed compatible transitive dependencies.
- **Relay container runtime** — Moved the production container from end-of-life Node.js 20 to Node.js 24 LTS and made production dependency installation deterministic with `npm ci --omit=dev`.
## [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
- **Extension: Host-access recovery races** — Prevents stale permission grants, reinjection retries, closed tabs, and rapid target changes from reactivating or clearing the wrong video tab.
- **Extension: Cross-browser host patterns** — Uses Firefox-compatible match patterns for local servers while preserving exact Chromium port scopes.
- **Extension: Force-sync target integrity** — Keeps sampled playback time and acknowledgements bound to the selected tab during recovery and target changes.
## [v2.6.2] — 2026-07-15
### Added
- **Website: Site-access recovery guide** — Added an English help page and a localized banner across every landing-page language.
### Fixed
- **Extension: Withheld website access recovery** — Detects browser-withheld host access, shows a localized allow-access action, requests only the selected website origin, and resumes the selected tab after access is granted.
- **Extension: Target-tab reliability** — Hardened permission recovery against rapid tab changes, navigation, closed tabs, service-worker restoration, and stale pending state.
- **Browser compatibility** — Uses Chrome's toolbar access request only when available, with a direct user-gesture permission fallback for Firefox and older Chromium browsers.
---
## [v2.6.1] — 2026-07-15
### Fixed
+85
View File
@@ -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
View File
@@ -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
View File
@@ -30,6 +30,83 @@
*Prioritized for upcoming phases.*
### v3.0 — Modern Room Chat
- **Release date:** Not scheduled
- **Priority:** P1
- **Category:** Social / Communication / Privacy
- **Status:** Planned. This is a roadmap target, not a release announcement.
- **Existing foundation:** Opt-in, live-only end-to-end encrypted room chat; a floating
chat bubble over the selected player opens a dockable, detachable, resizable overlay.
The relay stores no chat history and mixed extension versions use capability-gated
delivery.
#### Core experience
- Floating bubble with an accessible unread badge (`1`, `2`, `3`, …, `99+`), cleared
only when the chat is actually viewed.
- Subtle new-message pulse with reduced-motion support; no animation or sound while
muted or during Do Not Disturb.
- Drag-and-snap bubble positioning, remembered per site, with reset-to-default.
- Dock left/right, detached overlay, compact mode, fullscreen-safe placement, responsive
narrow-player layout, and keyboard-only operation.
- Per-room mute, mention-only mode, notification sound controls, browser notifications,
and a visible mute state.
- Unread state survives overlay close/reopen and tab focus changes, but room messages
remain memory-only unless a future storage option is explicitly enabled.
#### Messaging
- Replies with quoted context, emoji reactions, emoji picker, mentions, and typing
indicators.
- Edit and delete own messages with clear local tombstones; no silent mutation.
- Delivery states (`sending`, `sent`, `failed`, `retry`) with idempotent retries and
duplicate suppression after reconnects.
- Message timestamps, date separators, jump-to-latest, unread divider, search within
the current live session, copy, and selectable text.
- Link detection with safe external opening, explicit scheme allowlist, and no automatic
previews or remote-media loading.
- Optional image/GIF/file sharing only after encrypted size limits, malware-risk UX,
relay bandwidth limits, and privacy behavior are designed and tested.
#### Presence, safety, and control
- Online/reconnecting status, join/leave events, and room-member presence without
exposing IP addresses or cross-room identity.
- Local mute/block, host moderation controls, slow mode, spam limits, and configurable
maximum message length.
- Optional read receipts and activity indicators default off; never infer or expose
viewing behavior without explicit consent.
- Clear live-only/no-history explanation, encrypted-chat indicator, room-key fingerprint,
and key-rotation/rejoin UX.
#### Reliability and compatibility
- Versioned chat capabilities for every new wire feature; unknown fields/events remain
harmless to older extensions and old non-chat clients receive no chat traffic.
- Server-first and extension-first rollout tests, including current, first-chat-beta,
old non-chat, reconnect, duplicate-frame, and malformed-capability clients.
- Bounded queues, payloads, unread counters, typing events, reactions, and attachment
metadata; backpressure and rate limits on both client and relay.
- Real Chrome and Firefox extension E2E coverage for bubble, unread count, overlay,
fullscreen, reconnect, and mixed-version rooms before v3.0 can ship.
#### Accessibility and localization
- Screen-reader announcements that do not read every busy-room message by default.
- Complete focus management, logical tab order, Escape behavior, high contrast,
zoom/reflow, reduced motion, and touch target coverage.
- All chat UI, notification text, moderation states, and errors localized across every
supported locale before release.
#### Explicit non-goals for the first v3.0 release
- No server-readable plaintext.
- No mandatory accounts, public room directory, advertising, tracking, or presence
across rooms.
- No unencrypted server-side history. Any future encrypted history requires a separate
threat model, retention controls, export/delete behavior, and explicit opt-in.
### 2. Invite link with target URL for auto-redirect
- **Priority:** P2
@@ -49,17 +126,6 @@
*Ideas and feature requests under evaluation.*
### In-room chat overlay (like TeleParty)
- **Priority:** P3
- **Category:** Social / Communication
- **Background:** A collapsible chat panel to the right of the video (or as an overlay) allowing text-based communication with everyone in the room.
- **Why backlog (still uncertain):**
- **Use case:** No strong personal need — with chat, latency matters less than with voice; async communication tolerates a few seconds of delay.
- **Complexity:** Relatively large feature (UI + message persistence + possibly history).
- **Legal/moderation:** Unclear what moderation requirements would apply if users can exchange chat messages. Could be relevant depending on jurisdiction.
- **Status:** Under evaluation, may come later.
### Cross-frame video detection and control
- **Priority:** P3
+346
View File
@@ -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.
+2 -2
View File
@@ -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."
}
}
+2 -2
View File
@@ -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."
}
}
+2 -2
View File
@@ -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."
}
}
+2 -2
View File
@@ -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."
}
}
+2 -2
View File
@@ -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."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "YouTube、Netflix、Emby、Jellyfin、その他HTML5サイトでの動画再生を友達とリアルタイムで同期します。"
"message": "プライベートなウォッチパーティーを作成し、YouTube、Netflix、Jellyfin、EmbyやほぼすべてのHTML5サイトで友達と動画を同期再生できます。"
}
}
+2 -2
View File
@@ -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 사이트에서 친구들과 영상을 동기화하세요."
}
}
+2 -2
View File
@@ -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."
}
}
+2 -2
View File
@@ -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."
}
}
+2 -2
View File
@@ -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."
}
}
+2 -2
View File
@@ -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."
}
}
+2 -2
View File
@@ -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."
}
}
+2 -2
View File
@@ -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."
}
}
+2 -2
View File
@@ -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."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "与朋友实时同步YouTube、Netflix、Emby、Jellyfin任何HTML5网站上的视频播放。"
"message": "创建私人观影派对,与好友在 YouTube、Netflix、Jellyfin、Emby 及几乎任何 HTML5 视频网站同步观看。"
}
}
+1213 -176
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -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(() => {});
+122
View File
@@ -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);
}
+114
View File
@@ -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();
});
});
+33
View File
@@ -0,0 +1,33 @@
(function exposeChatFormat(root) {
function escapeChatHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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);
+22
View File
@@ -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('&lt;img src=x onerror=alert(1)&gt; <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' }
]);
});
});
+104
View File
@@ -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(/[—–]/);
}
}
});
});
+624
View File
@@ -0,0 +1,624 @@
(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 SIZE_PRESETS = Object.freeze({
compact: Object.freeze({ width: 320, height: 400 }),
standard: Object.freeze({ width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }),
large: Object.freeze({ width: 440, height: 640 })
});
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 preferencesLoaded = false;
let startStateApplied = false;
let refreshGeneration = 0;
let sendGeneration = 0;
let sending = false;
let layout = {
mode: 'right',
x: 24,
y: 72,
width: DEFAULT_WIDTH,
height: DEFAULT_HEIGHT,
customWidth: DEFAULT_WIDTH,
customHeight: DEFAULT_HEIGHT,
detachedInitialized: false
};
let chatPosition = 'right';
let chatSize = 'standard';
let chatStartMode = 'bubble';
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 normalizePosition(value) {
return ['left', 'right', 'detached'].includes(value) ? value : 'right';
}
function normalizeSize(value) {
return ['compact', 'standard', 'large', 'custom'].includes(value) ? value : 'standard';
}
function preferredSize() {
return SIZE_PRESETS[chatSize] || {
width: Number(layout.customWidth) || DEFAULT_WIDTH,
height: Number(layout.customHeight) || DEFAULT_HEIGHT
};
}
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;
const size = preferredSize();
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(size.width, viewport.width - gutter * 2))}px`;
panel.style.height = `${Math.max(1, Math.min(size.height, 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, persistPreference = true) {
mode = normalizePosition(mode);
if (mode === 'detached' && !layout.detachedInitialized) {
const size = preferredSize();
layout.x = Math.max(8, Math.round((window.innerWidth - size.width) / 2));
layout.y = Math.max(8, Math.round((window.innerHeight - size.height) / 2));
layout.detachedInitialized = true;
}
chatPosition = mode;
layout.mode = mode;
applyLayout();
saveLayout();
if (persistPreference) chrome.storage.local.set({ chatPosition: mode }).catch(() => {});
}
function setSize(size, persistPreference = true) {
chatSize = normalizeSize(size);
const preset = SIZE_PRESETS[chatSize];
if (preset) {
layout.width = preset.width;
layout.height = preset.height;
} else {
layout.width = Number(layout.customWidth) || DEFAULT_WIDTH;
layout.height = Number(layout.customHeight) || DEFAULT_HEIGHT;
}
if (layout.mode === 'detached') clampDetached();
applyLayout();
saveLayout();
if (persistPreference) chrome.storage.local.set({ chatSize }).catch(() => {});
}
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();
startStateApplied = false;
}
host.style.display = supported && optedIn ? '' : 'none';
launcher.setAttribute('aria-disabled', String(!context?.enabled));
if (!optedIn) startStateApplied = false;
if (!context?.enabled) {
setOpened(false);
} else if (preferencesLoaded && !startStateApplied) {
startStateApplied = true;
setOpened(chatStartMode === 'open');
}
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;
if (Math.abs(rect.width - layout.width) < 1 && Math.abs(rect.height - layout.height) < 1) return;
layout.width = rect.width;
layout.height = rect.height;
layout.customWidth = rect.width;
layout.customHeight = rect.height;
if (chatSize !== 'custom') {
chatSize = 'custom';
chrome.storage.local.set({ chatSize }).catch(() => {});
}
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.chatPosition) setMode(changes.chatPosition.newValue, false);
if (changes.chatSize) setSize(changes.chatSize.newValue, false);
if (changes.chatStartMode) {
chatStartMode = changes.chatStartMode.newValue === 'open' ? 'open' : 'bubble';
if (context?.enabled) {
startStateApplied = true;
setOpened(chatStartMode === 'open');
}
}
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', 'chatPosition', 'chatSize', 'chatStartMode'], data => {
const storedLayout = data[storageKey];
if (storedLayout && typeof storedLayout === 'object') {
layout = { ...layout, ...storedLayout };
layout.customWidth = Number(storedLayout.customWidth) || Number(storedLayout.width) || DEFAULT_WIDTH;
layout.customHeight = Number(storedLayout.customHeight) || Number(storedLayout.height) || DEFAULT_HEIGHT;
}
chatPosition = normalizePosition(data.chatPosition);
chatSize = normalizeSize(data.chatSize);
chatStartMode = data.chatStartMode === 'open' ? 'open' : 'bubble';
layout.mode = chatPosition;
if (layout.mode === 'detached') layout.detachedInitialized = true;
const preset = SIZE_PRESETS[chatSize];
if (preset) {
layout.width = preset.width;
layout.height = preset.height;
}
themeMode = data.themeMode || 'system';
themePalette = data.themePalette || 'eucalyptus';
preferencesLoaded = true;
applyTheme();
applyLayout();
refresh();
});
window.koalaSyncChatOverlay = { refresh, destroy };
})();
+58
View File
@@ -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();
}
}
};
}
+49
View File
@@ -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']);
});
});
+11
View File
@@ -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}`;
}
+21
View File
@@ -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);
});
});
+70 -25
View File
@@ -6,13 +6,15 @@
(function() {
// Injection Guard: Check if already injected AND context is valid
try {
if (window.koalaSyncInjected && chrome.runtime.id) {
return;
}
if (window.koalaSyncInjected && chrome.runtime.id) {
window.koalaSyncTargetActive = true;
return;
}
} catch (_e) {
// Context invalidated, proceed with re-injection
}
window.koalaSyncInjected = true;
window.koalaSyncInjected = true;
window.koalaSyncTargetActive = true;
// --- SHARED_EVENTS_INJECT_START ---
// This block is automatically updated by /scripts/build-extension.cjs
@@ -86,9 +88,34 @@
return Array.isArray(urls) && urls.some(url => hostMatchesUrl(host, url));
}
function isDisneyPlusHost() {
return matchesPlayerUrls(['disneyplus.com']);
}
function isDisneyPlusHost() {
return matchesPlayerUrls(['disneyplus.com']);
}
function isGoogleDrivePlayerFrame() {
if (window.location.hostname.toLowerCase() !== 'youtube.googleapis.com') return false;
try {
const url = new URL(window.location.href);
const origin = url.searchParams.get('origin') || url.searchParams.get('post_message_origin');
return origin === 'https://drive.google.com';
} catch {
return false;
}
}
function isContentTargetActive() {
return window.koalaSyncTargetActive === true;
}
if (isGoogleDrivePlayerFrame()) {
let lastDriveFrameVisible = window.innerWidth > 0 && window.innerHeight > 0;
window.addEventListener('resize', () => {
const visible = window.innerWidth > 0 && window.innerHeight > 0;
if (visible === lastDriveFrameVisible || !isContentTargetActive()) return;
lastDriveFrameVisible = visible;
chrome.runtime.sendMessage({ type: 'DRIVE_FRAME_VISIBILITY', visible }).catch(() => {});
}, { passive: true });
}
function getDisneyPlusTimeline() {
if (!isDisneyPlusHost()) return null;
@@ -851,11 +878,17 @@
function getPlayerActionFixes() {
return [
{
name: 'youtube-player-buttons',
urls: ['youtube.com'],
playPauseButtonSelector: '.ytp-play-button'
},
{
name: 'youtube-player-buttons',
urls: ['youtube.com'],
playPauseButtonSelector: '.ytp-play-button'
},
{
name: 'google-drive-player-buttons',
urls: ['youtube.googleapis.com'],
matches() { return isGoogleDrivePlayerFrame(); },
playPauseButtonSelector: '.ytp-play-button'
},
{
name: 'twitch-player-buttons',
urls: ['twitch.tv'],
@@ -864,9 +897,11 @@
];
}
function getActivePlayerActionFix() {
return getPlayerActionFixes().find(fix => matchesPlayerUrls(fix.urls)) || null;
}
function getActivePlayerActionFix() {
return getPlayerActionFixes().find(fix =>
typeof fix.matches === 'function' ? fix.matches() : matchesPlayerUrls(fix.urls)
) || null;
}
function tryPlayerActionFix(fix, action, video, data) {
if (!fix) return false;
@@ -951,8 +986,14 @@
}
// Listen for commands from background.js
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message) return;
if (message.type === 'KOALASYNC_DEACTIVATE') {
window.koalaSyncTargetActive = false;
sendResponse({ status: 'ok' });
return true;
}
if (!isContentTargetActive()) return;
if (message.action === 'get_current_time') {
const video = findVideo();
sendResponse({ currentTime: video ? getSyncCurrentTime(video) : null });
@@ -1136,9 +1177,10 @@
if (message.type === 'GET_VIDEO_STATE') {
const video = findVideo();
const platform = (() => {
const h = window.location.hostname.toLowerCase();
if (h === 'youtube.com' || h.endsWith('.youtube.com')) return 'YouTube';
const platform = (() => {
const h = window.location.hostname.toLowerCase();
if (isGoogleDrivePlayerFrame()) return 'Google Drive';
if (h === 'youtube.com' || h.endsWith('.youtube.com')) return 'YouTube';
if (h === 'twitch.tv' || h.endsWith('.twitch.tv')) return 'Twitch';
if (h === 'netflix.com' || h.endsWith('.netflix.com')) return 'Netflix';
if (h === 'primevideo.com' || h.endsWith('.primevideo.com') || /(^|\.)amazon\.(com\.[a-z]{2}|co\.[a-z]{2}|[a-z]{2,})$/.test(h)) return 'Prime Video';
@@ -1271,8 +1313,9 @@
// Build the relay payload from the *current* media state and send it to
// background.js. Re-reads the video each call so deferred (coalesced) emits
// carry an up-to-date position. Safe with no/invalid video — it no-ops.
function sendContentEvent(action) {
const video = findVideo();
function sendContentEvent(action) {
if (!isContentTargetActive()) return;
const video = findVideo();
if (!video) return;
const current = getSyncCurrentTime(video);
@@ -1308,8 +1351,9 @@
sendContentEvent(finalAction);
}
function reportEvent(action) {
if (seekDebounceTimer && (action === EVENTS.PLAY || action === EVENTS.PAUSE)) {
function reportEvent(action) {
if (!isContentTargetActive()) return;
if (seekDebounceTimer && (action === EVENTS.PLAY || action === EVENTS.PAUSE)) {
clearTimeout(seekDebounceTimer);
seekDebounceTimer = null;
const v = findVideo();
@@ -1552,8 +1596,9 @@
let proactiveHeartbeatTimeout = null;
let heartbeatErrorCount = 0;
function sendHeartbeat() {
const video = findVideo();
function sendHeartbeat() {
if (!isContentTargetActive()) return;
const video = findVideo();
if (!video) return;
const mediaTitle = (navigator.mediaSession && navigator.mediaSession.metadata) ? navigator.mediaSession.metadata.title : null;
+206
View File
@@ -0,0 +1,206 @@
export const HOST_ACCESS_REQUIRED_STATUS = 'host_permission_required';
export function normalizeTabId(value) {
if (typeof value === 'number') {
return Number.isSafeInteger(value) && value > 0 ? value : null;
}
if (typeof value !== 'string') return null;
const normalized = value.trim();
if (!/^[1-9]\d*$/.test(normalized)) return null;
const tabId = Number(normalized);
return Number.isSafeInteger(tabId) ? tabId : null;
}
function browserSupportsPortMatchPatterns(chromeApi) {
// runtime.getBrowserInfo is a Firefox-only WebExtension API. Firefox does
// not accept ports in match patterns, while Chromium does.
return typeof chromeApi?.runtime?.getBrowserInfo !== 'function';
}
export function describeTabUrl(rawUrl, { includePort = true } = {}) {
if (typeof rawUrl !== 'string' || !rawUrl) return null;
try {
const url = new URL(rawUrl);
if (url.protocol === 'http:' || url.protocol === 'https:') {
const permissionHost = includePort ? url.host : url.hostname;
return {
url: rawUrl,
host: url.host,
originPattern: `${url.protocol}//${permissionHost}/*`
};
}
if (url.protocol === 'file:') {
return {
url: rawUrl,
host: 'local file',
originPattern: 'file:///*'
};
}
} catch (_e) {
// Invalid and browser-internal URLs cannot receive host access.
}
return null;
}
export async function inspectTabHostAccess(chromeApi, tabId) {
const tab = await chromeApi.tabs.get(tabId);
// executeScript targets the committed document. pendingUrl may already point
// at another origin while tab.url is still the document being injected into.
const descriptor = describeTabUrl(tab?.url || tab?.pendingUrl || '', {
includePort: browserSupportsPortMatchPatterns(chromeApi)
});
if (!descriptor) {
return {
tab,
url: tab?.url || '',
host: null,
originPattern: null,
granted: null
};
}
if (typeof chromeApi.permissions?.contains !== 'function') {
return { tab, ...descriptor, granted: null };
}
try {
const granted = await callBooleanPermissionMethod(
chromeApi,
'contains',
{ origins: [descriptor.originPattern] },
{ timeoutMs: 1000 }
);
return {
tab,
...descriptor,
granted: granted === null ? null : granted === true
};
} catch (_e) {
// Permission inspection is advisory. The actual script injection below
// remains the source of truth, including temporary activeTab access.
return { tab, ...descriptor, granted: null };
}
}
function callBooleanPermissionMethod(chromeApi, methodName, request, { timeoutMs = null } = {}) {
const method = chromeApi.permissions?.[methodName];
if (typeof method !== 'function') return Promise.resolve(null);
return new Promise((resolve, reject) => {
let settled = false;
let timeout = null;
const clearSettlementTimeout = () => {
if (timeout !== null) {
clearTimeout(timeout);
timeout = null;
}
};
const finish = (value) => {
if (settled) return;
settled = true;
clearSettlementTimeout();
resolve(value === true ? true : value === false ? false : null);
};
const fail = (error) => {
if (settled) return;
settled = true;
clearSettlementTimeout();
reject(error instanceof Error ? error : new Error(String(error || 'Permission request failed')));
};
const callback = (value) => {
const lastError = chromeApi.runtime?.lastError;
if (lastError) {
fail(new Error(lastError.message || String(lastError)));
return;
}
finish(value);
};
if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
timeout = setTimeout(() => finish(null), timeoutMs);
}
try {
const result = method.call(chromeApi.permissions, request, callback);
if (result && typeof result.then === 'function') {
result.then(finish, fail);
} else if (typeof result === 'boolean') {
finish(result);
}
// Callback-based Chromium/Firefox APIs return undefined and settle
// through callback. Promise-based implementations settle above.
} catch (error) {
fail(error);
}
});
}
export function requestOriginPermission(chromeApi, originPattern) {
if (typeof originPattern !== 'string' || !originPattern) {
return Promise.resolve(null);
}
return callBooleanPermissionMethod(
chromeApi,
'request',
{ origins: [originPattern] },
{ timeoutMs: 60000 }
).catch(() => false);
}
export function containsOriginPermission(chromeApi, originPattern) {
if (typeof originPattern !== 'string' || !originPattern) {
return Promise.resolve(null);
}
return callBooleanPermissionMethod(
chromeApi,
'contains',
{ origins: [originPattern] },
{ timeoutMs: 1000 }
).catch(() => null);
}
export function isHostAccessError(error) {
const message = typeof error?.message === 'string' ? error.message : String(error || '');
return /host permission|permission to access (?:this|the|respective) host|cannot access contents of/i.test(message);
}
function hostAccessRequestDetails(tabId, originPattern) {
const request = { tabId };
if (typeof originPattern === 'string' && originPattern) {
request.pattern = originPattern;
}
return request;
}
export async function addTabHostAccessRequest(chromeApi, tabId, originPattern = null) {
if (typeof chromeApi.permissions?.addHostAccessRequest !== 'function') {
return false;
}
try {
await chromeApi.permissions.addHostAccessRequest(
hostAccessRequestDetails(tabId, originPattern)
);
return true;
} catch (_e) {
return false;
}
}
export async function removeTabHostAccessRequest(chromeApi, tabId, originPattern = null) {
if (typeof chromeApi.permissions?.removeHostAccessRequest !== 'function') {
return false;
}
try {
await chromeApi.permissions.removeHostAccessRequest(
hostAccessRequestDetails(tabId, originPattern)
);
return true;
} catch (_e) {
return false;
}
}
+32 -1
View File
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Identität noch nicht geladen. Warte kurz und versuche es erneut.",
"ERR_NO_PEERS_TIME": "Keine anderen Teilnehmer mit bekannter Zeit. Wechsle zu 'Zu mir springen'.",
"ERR_NO_VIDEO_TAB": "Verbindung zum Video-Tab fehlgeschlagen.",
"SITE_ACCESS_REQUIRED": "Dein Browser blockiert KoalaSync auf {host}. Klicke unten auf „Zugriff erlauben“. Falls kein Dialog erscheint, erlaube KoalaSync in den Erweiterungs- oder Websiteberechtigungen für diese Seite.",
"BTN_RETRY_ACCESS": "Zugriff erlauben",
"ERR_SELECT_VIDEO": "Bitte wähle zuerst ein Video aus!",
"TOAST_INVITE_COPIED": "Einladungslink kopiert!",
"TOAST_COPY_FAILED": "Kopieren in Zwischenablage fehlgeschlagen",
@@ -243,5 +245,34 @@
"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.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Darstellung und Position des verschlüsselten Raum-Chats festlegen.",
"CHAT_SETTINGS_NOTE": "Optional und Ende-zu-Ende verschlüsselt. Nachrichten sind nur live sichtbar und werden vom Relay nicht gespeichert.",
"LABEL_CHAT_POSITION": "Position",
"LABEL_CHAT_POSITION_TOOLTIP": "Lege fest, wo der Chat am ausgewählten Player angezeigt wird.",
"LABEL_CHAT_SIZE": "Overlay-Größe",
"LABEL_CHAT_SIZE_TOOLTIP": "Wähle die Standardgröße des Chat-Overlays.",
"OPTION_CHAT_SIZE_COMPACT": "Kompakt",
"OPTION_CHAT_SIZE_STANDARD": "Standard",
"OPTION_CHAT_SIZE_LARGE": "Groß",
"OPTION_CHAT_SIZE_CUSTOM": "Letzte eigene Größe",
"LABEL_CHAT_START_MODE": "Startansicht",
"LABEL_CHAT_START_MODE_TOOLTIP": "Lege fest, ob der Chat als schwebende Blase oder geöffnetes Overlay startet.",
"OPTION_CHAT_START_BUBBLE": "Schwebende Chatblase",
"OPTION_CHAT_START_OPEN": "Geöffnetes Overlay",
"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."
}
+32 -1
View File
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Identity not yet loaded. Wait a moment and try again.",
"ERR_NO_PEERS_TIME": "No other peers with a known time. Switch to 'Jump to Me'.",
"ERR_NO_VIDEO_TAB": "Could not connect to video tab.",
"SITE_ACCESS_REQUIRED": "Your browser is blocking KoalaSync on {host}. Click \"Allow access\" below. If no dialog appears, allow KoalaSync on this site in your extension or site permissions.",
"BTN_RETRY_ACCESS": "Allow access",
"ERR_SELECT_VIDEO": "Please select a video first!",
"TOAST_INVITE_COPIED": "Invite link copied!",
"TOAST_COPY_FAILED": "Failed to copy to clipboard",
@@ -243,5 +245,34 @@
"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.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Configure encrypted room chat appearance and placement.",
"CHAT_SETTINGS_NOTE": "Optional and end-to-end encrypted. Messages are live only and are not stored by the relay.",
"LABEL_CHAT_POSITION": "Position",
"LABEL_CHAT_POSITION_TOOLTIP": "Choose where chat is attached to the selected player.",
"LABEL_CHAT_SIZE": "Overlay size",
"LABEL_CHAT_SIZE_TOOLTIP": "Choose the default size of the chat overlay.",
"OPTION_CHAT_SIZE_COMPACT": "Compact",
"OPTION_CHAT_SIZE_STANDARD": "Standard",
"OPTION_CHAT_SIZE_LARGE": "Large",
"OPTION_CHAT_SIZE_CUSTOM": "Last custom size",
"LABEL_CHAT_START_MODE": "Start as",
"LABEL_CHAT_START_MODE_TOOLTIP": "Choose whether chat starts as a floating bubble or an open overlay.",
"OPTION_CHAT_START_BUBBLE": "Floating bubble",
"OPTION_CHAT_START_OPEN": "Open overlay",
"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."
}
+32 -1
View File
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Identidad no cargada aún. Espera un momento e inténtalo de nuevo.",
"ERR_NO_PEERS_TIME": "No hay otros participantes con posición conocida. Cambia a 'Traerlos a mi posición'.",
"ERR_NO_VIDEO_TAB": "No se pudo conectar a la pestaña de video.",
"SITE_ACCESS_REQUIRED": "Tu navegador está bloqueando KoalaSync en {host}. Haz clic en «Permitir acceso». Si no aparece ningún diálogo, permite KoalaSync en este sitio desde los permisos de extensiones o del sitio.",
"BTN_RETRY_ACCESS": "Permitir acceso",
"ERR_SELECT_VIDEO": "¡Selecciona un video primero!",
"TOAST_INVITE_COPIED": "¡Enlace de invitación copiado!",
"TOAST_COPY_FAILED": "Error al copiar al portapapeles",
@@ -243,5 +245,34 @@
"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.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Configura la apariencia y la posición del chat cifrado de la sala.",
"CHAT_SETTINGS_NOTE": "Opcional y cifrado de extremo a extremo. Los mensajes solo se muestran en directo y el relé no los almacena.",
"LABEL_CHAT_POSITION": "Posición",
"LABEL_CHAT_POSITION_TOOLTIP": "Elige dónde se acopla el chat al reproductor seleccionado.",
"LABEL_CHAT_SIZE": "Tamaño de la superposición",
"LABEL_CHAT_SIZE_TOOLTIP": "Elige el tamaño predeterminado de la superposición del chat.",
"OPTION_CHAT_SIZE_COMPACT": "Compacto",
"OPTION_CHAT_SIZE_STANDARD": "Estándar",
"OPTION_CHAT_SIZE_LARGE": "Grande",
"OPTION_CHAT_SIZE_CUSTOM": "Último tamaño personalizado",
"LABEL_CHAT_START_MODE": "Iniciar como",
"LABEL_CHAT_START_MODE_TOOLTIP": "Elige si el chat comienza como una burbuja flotante o una superposición abierta.",
"OPTION_CHAT_START_BUBBLE": "Burbuja flotante",
"OPTION_CHAT_START_OPEN": "Superposición abierta",
"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."
}
+32 -1
View File
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Identifiant non encore chargé. Veuillez patienter et réessayer.",
"ERR_NO_PEERS_TIME": "Aucun autre membre avec une position connue. Basculez sur 'Les amener à moi'.",
"ERR_NO_VIDEO_TAB": "Impossible de se connecter à l'onglet vidéo.",
"SITE_ACCESS_REQUIRED": "Votre navigateur bloque KoalaSync sur {host}. Cliquez sur « Autoriser laccès ». Si aucune boîte de dialogue napparaît, autorisez KoalaSync sur ce site dans les permissions des extensions ou du site.",
"BTN_RETRY_ACCESS": "Autoriser laccès",
"ERR_SELECT_VIDEO": "Veuillez d'abord sélectionner une vidéo !",
"TOAST_INVITE_COPIED": "Lien d'invitation copié !",
"TOAST_COPY_FAILED": "Échec de la copie dans le presse-papiers",
@@ -243,5 +245,34 @@
"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é.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Configurez l'apparence et l'emplacement du chat chiffré du salon.",
"CHAT_SETTINGS_NOTE": "Facultatif et chiffré de bout en bout. Les messages sont uniquement en direct et ne sont pas stockés par le relais.",
"LABEL_CHAT_POSITION": "Position",
"LABEL_CHAT_POSITION_TOOLTIP": "Choisissez où le chat est ancré au lecteur sélectionné.",
"LABEL_CHAT_SIZE": "Taille de la fenêtre",
"LABEL_CHAT_SIZE_TOOLTIP": "Choisissez la taille par défaut de la fenêtre de chat.",
"OPTION_CHAT_SIZE_COMPACT": "Compacte",
"OPTION_CHAT_SIZE_STANDARD": "Standard",
"OPTION_CHAT_SIZE_LARGE": "Grande",
"OPTION_CHAT_SIZE_CUSTOM": "Dernière taille personnalisée",
"LABEL_CHAT_START_MODE": "Affichage initial",
"LABEL_CHAT_START_MODE_TOOLTIP": "Choisissez si le chat démarre sous forme de bulle flottante ou de fenêtre ouverte.",
"OPTION_CHAT_START_BUBBLE": "Bulle flottante",
"OPTION_CHAT_START_OPEN": "Fenêtre ouverte",
"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."
}
+32 -1
View File
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Identità non ancora caricata. Attendi un momento.",
"ERR_NO_PEERS_TIME": "Nessun altro partecipante con posizione nota. Passa a 'Portali alla mia posizione'.",
"ERR_NO_VIDEO_TAB": "Impossibile connettersi alla scheda del video.",
"SITE_ACCESS_REQUIRED": "Il browser sta bloccando KoalaSync su {host}. Fai clic su «Consenti accesso». Se non appare alcuna finestra, consenti KoalaSync su questo sito nelle autorizzazioni dellestensione o del sito.",
"BTN_RETRY_ACCESS": "Consenti accesso",
"ERR_SELECT_VIDEO": "Seleziona prima un video!",
"TOAST_INVITE_COPIED": "Link di invito copiato!",
"TOAST_COPY_FAILED": "Impossibile copiare negli appunti",
@@ -243,5 +245,34 @@
"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.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Configura l'aspetto e la posizione della chat crittografata della stanza.",
"CHAT_SETTINGS_NOTE": "Opzionale e crittografata end-to-end. I messaggi sono solo in diretta e non vengono memorizzati dal relay.",
"LABEL_CHAT_POSITION": "Posizione",
"LABEL_CHAT_POSITION_TOOLTIP": "Scegli dove agganciare la chat al lettore selezionato.",
"LABEL_CHAT_SIZE": "Dimensione overlay",
"LABEL_CHAT_SIZE_TOOLTIP": "Scegli la dimensione predefinita dell'overlay della chat.",
"OPTION_CHAT_SIZE_COMPACT": "Compatto",
"OPTION_CHAT_SIZE_STANDARD": "Standard",
"OPTION_CHAT_SIZE_LARGE": "Grande",
"OPTION_CHAT_SIZE_CUSTOM": "Ultima dimensione personalizzata",
"LABEL_CHAT_START_MODE": "Avvio come",
"LABEL_CHAT_START_MODE_TOOLTIP": "Scegli se la chat si avvia come bolla mobile o overlay aperto.",
"OPTION_CHAT_START_BUBBLE": "Bolla mobile",
"OPTION_CHAT_START_OPEN": "Overlay aperto",
"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."
}
+32 -1
View File
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "IDがまだロードされていません。しばらく待ってから再試行してください。",
"ERR_NO_PEERS_TIME": "既知の時間を持つ他のメンバーがいません。「自分に合わせる」に切り替えてください。",
"ERR_NO_VIDEO_TAB": "ビデオタブに接続できませんでした。",
"SITE_ACCESS_REQUIRED": "ブラウザーが {host} で KoalaSync をブロックしています。下の「アクセスを許可」をクリックしてください。ダイアログが表示されない場合は、拡張機能またはサイトの権限でこのサイトの KoalaSync を許可してください。",
"BTN_RETRY_ACCESS": "アクセスを許可",
"ERR_SELECT_VIDEO": "最初にビデオを選択してください!",
"TOAST_INVITE_COPIED": "招待リンクをコピーしました!",
"TOAST_COPY_FAILED": "クリップボードへのコピーに失敗しました",
@@ -243,5 +245,34 @@
"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": "選択した動画タブに暗号化されたルームチャットを表示します。",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "暗号化されたルームチャットの外観と位置を設定します。",
"CHAT_SETTINGS_NOTE": "任意で利用でき、エンドツーエンドで暗号化されます。メッセージはリアルタイムのみで、リレーには保存されません。",
"LABEL_CHAT_POSITION": "位置",
"LABEL_CHAT_POSITION_TOOLTIP": "選択したプレーヤーのどこにチャットを配置するか選択します。",
"LABEL_CHAT_SIZE": "オーバーレイサイズ",
"LABEL_CHAT_SIZE_TOOLTIP": "チャットオーバーレイの標準サイズを選択します。",
"OPTION_CHAT_SIZE_COMPACT": "コンパクト",
"OPTION_CHAT_SIZE_STANDARD": "標準",
"OPTION_CHAT_SIZE_LARGE": "大",
"OPTION_CHAT_SIZE_CUSTOM": "前回のカスタムサイズ",
"LABEL_CHAT_START_MODE": "開始表示",
"LABEL_CHAT_START_MODE_TOOLTIP": "フローティングバブルまたは開いたオーバーレイで開始するか選択します。",
"OPTION_CHAT_START_BUBBLE": "フローティングバブル",
"OPTION_CHAT_START_OPEN": "開いたオーバーレイ",
"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": "まだメッセージはありません。チャットはライブのみです。"
}
+32 -1
View File
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "ID가 아직 로드되지 않았습니다. 잠시 후 다시 시도해 주세요.",
"ERR_NO_PEERS_TIME": "시간을 알고 있는 다른 피어가 없습니다. '나에게 이동'으로 전환하세요.",
"ERR_NO_VIDEO_TAB": "비디오 탭에 연결할 수 없습니다.",
"SITE_ACCESS_REQUIRED": "브라우저가 {host}에서 KoalaSync를 차단하고 있습니다. 아래의 ‘액세스 허용’을 클릭하세요. 대화상자가 표시되지 않으면 확장 프로그램 또는 사이트 권한에서 이 사이트의 KoalaSync를 허용하세요.",
"BTN_RETRY_ACCESS": "액세스 허용",
"ERR_SELECT_VIDEO": "먼저 비디오를 선택하세요!",
"TOAST_INVITE_COPIED": "초대 링크가 복사되었습니다!",
"TOAST_COPY_FAILED": "클립보드 복사에 실패했습니다",
@@ -243,5 +245,34 @@
"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": "선택한 동영상 탭에 암호화된 방 채팅을 표시합니다.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "암호화된 방 채팅의 모양과 위치를 설정합니다.",
"CHAT_SETTINGS_NOTE": "선택 기능이며 종단 간 암호화됩니다. 메시지는 실시간으로만 표시되고 릴레이에 저장되지 않습니다.",
"LABEL_CHAT_POSITION": "위치",
"LABEL_CHAT_POSITION_TOOLTIP": "선택한 플레이어에서 채팅을 고정할 위치를 선택합니다.",
"LABEL_CHAT_SIZE": "오버레이 크기",
"LABEL_CHAT_SIZE_TOOLTIP": "채팅 오버레이의 기본 크기를 선택합니다.",
"OPTION_CHAT_SIZE_COMPACT": "작게",
"OPTION_CHAT_SIZE_STANDARD": "표준",
"OPTION_CHAT_SIZE_LARGE": "크게",
"OPTION_CHAT_SIZE_CUSTOM": "마지막 사용자 지정 크기",
"LABEL_CHAT_START_MODE": "시작 화면",
"LABEL_CHAT_START_MODE_TOOLTIP": "플로팅 버블 또는 열린 오버레이로 시작할지 선택합니다.",
"OPTION_CHAT_START_BUBBLE": "플로팅 채팅 버블",
"OPTION_CHAT_START_OPEN": "열린 오버레이",
"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": "아직 메시지가 없습니다. 채팅은 실시간 전용입니다."
}
+32 -1
View File
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Identiteit nog niet geladen. Wacht een moment en probeer het opnieuw.",
"ERR_NO_PEERS_TIME": "Geen andere deelnemers met een bekende tijd. Schakel over naar 'Spring naar mij'.",
"ERR_NO_VIDEO_TAB": "Kon geen verbinding maken met videotabblad.",
"SITE_ACCESS_REQUIRED": "Je browser blokkeert KoalaSync op {host}. Klik hieronder op Toegang toestaan. Als er geen dialoog verschijnt, sta KoalaSync voor deze site toe via de extensie- of siterechten.",
"BTN_RETRY_ACCESS": "Toegang toestaan",
"ERR_SELECT_VIDEO": "Selecteer eerst een video!",
"TOAST_INVITE_COPIED": "Uitnodigingslink gekopieerd!",
"TOAST_COPY_FAILED": "Kopiëren naar klembord mislukt",
@@ -243,5 +245,34 @@
"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.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Pas het uiterlijk en de positie van de versleutelde kamerchat aan.",
"CHAT_SETTINGS_NOTE": "Optioneel en end-to-end versleuteld. Berichten zijn alleen live en worden niet door de relay opgeslagen.",
"LABEL_CHAT_POSITION": "Positie",
"LABEL_CHAT_POSITION_TOOLTIP": "Kies waar de chat aan de geselecteerde speler wordt gekoppeld.",
"LABEL_CHAT_SIZE": "Overlaygrootte",
"LABEL_CHAT_SIZE_TOOLTIP": "Kies de standaardgrootte van de chatoverlay.",
"OPTION_CHAT_SIZE_COMPACT": "Compact",
"OPTION_CHAT_SIZE_STANDARD": "Standaard",
"OPTION_CHAT_SIZE_LARGE": "Groot",
"OPTION_CHAT_SIZE_CUSTOM": "Laatste aangepaste grootte",
"LABEL_CHAT_START_MODE": "Start als",
"LABEL_CHAT_START_MODE_TOOLTIP": "Kies of de chat start als zwevende knop of als geopende overlay.",
"OPTION_CHAT_START_BUBBLE": "Zwevende chatknop",
"OPTION_CHAT_START_OPEN": "Geopende overlay",
"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."
}
+32 -1
View File
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Tożsamość nie została jeszcze załadowana. Odczekaj chwilę i spróbuj ponownie.",
"ERR_NO_PEERS_TIME": "Brak innych uczestników ze znanym czasem. Przełącz na 'Skocz do mnie'.",
"ERR_NO_VIDEO_TAB": "Nie można połączyć się z kartą wideo.",
"SITE_ACCESS_REQUIRED": "Przeglądarka blokuje KoalaSync na {host}. Kliknij poniżej „Zezwól na dostęp”. Jeśli nie pojawi się okno, zezwól KoalaSync na tej stronie w uprawnieniach rozszerzenia lub witryny.",
"BTN_RETRY_ACCESS": "Zezwól na dostęp",
"ERR_SELECT_VIDEO": "Najpierw wybierz wideo!",
"TOAST_INVITE_COPIED": "Link zaproszenia skopiowany!",
"TOAST_COPY_FAILED": "Nie udało się skopiować do schowka",
@@ -243,5 +245,34 @@
"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.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Skonfiguruj wygląd i położenie zaszyfrowanego czatu pokoju.",
"CHAT_SETTINGS_NOTE": "Opcjonalny i szyfrowany od końca do końca. Wiadomości są tylko na żywo i nie są przechowywane przez serwer pośredniczący.",
"LABEL_CHAT_POSITION": "Położenie",
"LABEL_CHAT_POSITION_TOOLTIP": "Wybierz, gdzie czat ma być przypięty do wybranego odtwarzacza.",
"LABEL_CHAT_SIZE": "Rozmiar nakładki",
"LABEL_CHAT_SIZE_TOOLTIP": "Wybierz domyślny rozmiar nakładki czatu.",
"OPTION_CHAT_SIZE_COMPACT": "Kompaktowy",
"OPTION_CHAT_SIZE_STANDARD": "Standardowy",
"OPTION_CHAT_SIZE_LARGE": "Duży",
"OPTION_CHAT_SIZE_CUSTOM": "Ostatni własny rozmiar",
"LABEL_CHAT_START_MODE": "Uruchom jako",
"LABEL_CHAT_START_MODE_TOOLTIP": "Wybierz, czy czat ma startować jako pływający przycisk, czy otwarta nakładka.",
"OPTION_CHAT_START_BUBBLE": "Pływający przycisk",
"OPTION_CHAT_START_OPEN": "Otwarta nakładka",
"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."
}
+32 -1
View File
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Identidade não carregada ainda. Aguarde um momento.",
"ERR_NO_PEERS_TIME": "Nenhum participante com posição conhecida. Mude para 'Trazer para a minha posição'.",
"ERR_NO_VIDEO_TAB": "Não foi possível conectar à aba do vídeo.",
"SITE_ACCESS_REQUIRED": "Seu navegador está bloqueando o KoalaSync em {host}. Clique em «Permitir acesso» abaixo. Se nenhuma caixa de diálogo aparecer, permita o KoalaSync neste site nas permissões da extensão ou do site.",
"BTN_RETRY_ACCESS": "Permitir acesso",
"ERR_SELECT_VIDEO": "Selecione um vídeo primeiro!",
"TOAST_INVITE_COPIED": "Link de convite copiado!",
"TOAST_COPY_FAILED": "Falha ao copiar para a área de transferência",
@@ -243,5 +245,34 @@
"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.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Configure a aparência e a posição do chat criptografado da sala.",
"CHAT_SETTINGS_NOTE": "Opcional e criptografado de ponta a ponta. As mensagens são apenas ao vivo e não são armazenadas pelo relay.",
"LABEL_CHAT_POSITION": "Posição",
"LABEL_CHAT_POSITION_TOOLTIP": "Escolha onde o chat fica fixado no player selecionado.",
"LABEL_CHAT_SIZE": "Tamanho da sobreposição",
"LABEL_CHAT_SIZE_TOOLTIP": "Escolha o tamanho padrão da sobreposição do chat.",
"OPTION_CHAT_SIZE_COMPACT": "Compacto",
"OPTION_CHAT_SIZE_STANDARD": "Padrão",
"OPTION_CHAT_SIZE_LARGE": "Grande",
"OPTION_CHAT_SIZE_CUSTOM": "Último tamanho personalizado",
"LABEL_CHAT_START_MODE": "Iniciar como",
"LABEL_CHAT_START_MODE_TOOLTIP": "Escolha se o chat começa como uma bolha flutuante ou uma sobreposição aberta.",
"OPTION_CHAT_START_BUBBLE": "Bolha flutuante",
"OPTION_CHAT_START_OPEN": "Sobreposição aberta",
"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."
}
+32 -1
View File
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Identidade ainda não carregada. Aguarde um momento.",
"ERR_NO_PEERS_TIME": "Nenhum participante com hora conhecida. Mude para 'Trazer para a minha posição'.",
"ERR_NO_VIDEO_TAB": "Não foi possível aceder ao separador do vídeo.",
"SITE_ACCESS_REQUIRED": "O seu navegador está a bloquear o KoalaSync em {host}. Clique em «Permitir acesso» abaixo. Se não aparecer uma caixa de diálogo, permita o KoalaSync neste site nas permissões da extensão ou do site.",
"BTN_RETRY_ACCESS": "Permitir acesso",
"ERR_SELECT_VIDEO": "Selecione primeiro um vídeo!",
"TOAST_INVITE_COPIED": "Link de convite copiado!",
"TOAST_COPY_FAILED": "Falha ao copiar para a área de transferência",
@@ -243,5 +245,34 @@
"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.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Configure o aspeto e a posição do chat encriptado da sala.",
"CHAT_SETTINGS_NOTE": "Opcional e encriptado ponto a ponto. As mensagens são apenas em direto e não são guardadas pelo relay.",
"LABEL_CHAT_POSITION": "Posição",
"LABEL_CHAT_POSITION_TOOLTIP": "Escolha onde o chat fica fixado no leitor selecionado.",
"LABEL_CHAT_SIZE": "Tamanho da sobreposição",
"LABEL_CHAT_SIZE_TOOLTIP": "Escolha o tamanho predefinido da sobreposição do chat.",
"OPTION_CHAT_SIZE_COMPACT": "Compacto",
"OPTION_CHAT_SIZE_STANDARD": "Padrão",
"OPTION_CHAT_SIZE_LARGE": "Grande",
"OPTION_CHAT_SIZE_CUSTOM": "Último tamanho personalizado",
"LABEL_CHAT_START_MODE": "Iniciar como",
"LABEL_CHAT_START_MODE_TOOLTIP": "Escolha se o chat começa como uma bolha flutuante ou uma sobreposição aberta.",
"OPTION_CHAT_START_BUBBLE": "Bolha flutuante",
"OPTION_CHAT_START_OPEN": "Sobreposição aberta",
"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."
}
+32 -1
View File
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Идентификатор еще не загружен. Подождите немного и повторите попытку.",
"ERR_NO_PEERS_TIME": "Нет других участников с известной позицией плеера. Выберите 'Синхронизировать по мне'.",
"ERR_NO_VIDEO_TAB": "Не удалось подключиться к вкладке с видео.",
"SITE_ACCESS_REQUIRED": "Браузер блокирует KoalaSync на {host}. Нажмите «Разрешить доступ» ниже. Если диалог не появится, разрешите KoalaSync для этого сайта в настройках расширения или разрешениях сайта.",
"BTN_RETRY_ACCESS": "Разрешить доступ",
"ERR_SELECT_VIDEO": "Пожалуйста, сначала выберите вкладку с видео!",
"TOAST_INVITE_COPIED": "Ссылка скопирована в буфер обмена!",
"TOAST_COPY_FAILED": "Не удалось скопировать ссылку в буфер обмена",
@@ -243,5 +245,34 @@
"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": "Показывать зашифрованный чат комнаты на выбранной вкладке с видео.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Настройте внешний вид и расположение зашифрованного чата комнаты.",
"CHAT_SETTINGS_NOTE": "Необязательный чат со сквозным шифрованием. Сообщения доступны только в реальном времени и не сохраняются ретранслятором.",
"LABEL_CHAT_POSITION": "Положение",
"LABEL_CHAT_POSITION_TOOLTIP": "Выберите, где чат будет закреплён на выбранном проигрывателе.",
"LABEL_CHAT_SIZE": "Размер окна",
"LABEL_CHAT_SIZE_TOOLTIP": "Выберите стандартный размер окна чата.",
"OPTION_CHAT_SIZE_COMPACT": "Компактный",
"OPTION_CHAT_SIZE_STANDARD": "Стандартный",
"OPTION_CHAT_SIZE_LARGE": "Большой",
"OPTION_CHAT_SIZE_CUSTOM": "Последний пользовательский размер",
"LABEL_CHAT_START_MODE": "Начальный вид",
"LABEL_CHAT_START_MODE_TOOLTIP": "Выберите, запускать чат как плавающую кнопку или открытое окно.",
"OPTION_CHAT_START_BUBBLE": "Плавающая кнопка",
"OPTION_CHAT_START_OPEN": "Открытое окно",
"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": "Сообщений пока нет. Чат работает только в реальном времени."
}
+32 -1
View File
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Kimlik henüz yüklenmedi. Biraz bekleyin ve tekrar deneyin.",
"ERR_NO_PEERS_TIME": "Bilinen bir zamanı olan başka bağlantı yok. 'Bana Atla' seçeneğine geçin.",
"ERR_NO_VIDEO_TAB": "Video sekmesine bağlanılamadı.",
"SITE_ACCESS_REQUIRED": "Tarayıcınız {host} üzerinde KoalaSync'i engelliyor. Aşağıdaki “Erişime izin ver” düğmesine tıklayın. İletişim kutusu görünmezse uzantı veya site izinlerinden bu site için KoalaSync'e izin verin.",
"BTN_RETRY_ACCESS": "Erişime izin ver",
"ERR_SELECT_VIDEO": "Lütfen önce bir video seçin!",
"TOAST_INVITE_COPIED": "Davet bağlantısı kopyalandı!",
"TOAST_COPY_FAILED": "Panoya kopyalanamadı",
@@ -243,5 +245,34 @@
"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.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Şifreli oda sohbetinin görünümünü ve konumunu yapılandırın.",
"CHAT_SETTINGS_NOTE": "İsteğe bağlı ve uçtan uca şifrelidir. Mesajlar yalnızca canlıdır ve aktarıcı tarafından saklanmaz.",
"LABEL_CHAT_POSITION": "Konum",
"LABEL_CHAT_POSITION_TOOLTIP": "Sohbetin seçili oynatıcıda nereye sabitleneceğini seçin.",
"LABEL_CHAT_SIZE": "Kaplama boyutu",
"LABEL_CHAT_SIZE_TOOLTIP": "Sohbet kaplamasının varsayılan boyutunu seçin.",
"OPTION_CHAT_SIZE_COMPACT": "Kompakt",
"OPTION_CHAT_SIZE_STANDARD": "Standart",
"OPTION_CHAT_SIZE_LARGE": "Büyük",
"OPTION_CHAT_SIZE_CUSTOM": "Son özel boyut",
"LABEL_CHAT_START_MODE": "Başlangıç görünümü",
"LABEL_CHAT_START_MODE_TOOLTIP": "Sohbetin kayan balon veya açık kaplama olarak başlamasını seçin.",
"OPTION_CHAT_START_BUBBLE": "Kayan sohbet balonu",
"OPTION_CHAT_START_OPEN": "Açık kaplama",
"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."
}
+32 -1
View File
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Посвідчення особи ще не завантажено. Зачекайте хвилинку та повторіть спробу.",
"ERR_NO_PEERS_TIME": "Немає інших аналогів із відомим часом. Переключіться на «Перейти до мене».",
"ERR_NO_VIDEO_TAB": "Не вдалося підключитися до вкладки відео.",
"SITE_ACCESS_REQUIRED": "Браузер блокує KoalaSync на {host}. Натисніть «Дозволити доступ» нижче. Якщо діалог не з’явиться, дозвольте KoalaSync для цього сайту в налаштуваннях розширення або дозволах сайту.",
"BTN_RETRY_ACCESS": "Дозволити доступ",
"ERR_SELECT_VIDEO": "Спочатку виберіть відео!",
"TOAST_INVITE_COPIED": "Посилання на запрошення скопійовано!",
"TOAST_COPY_FAILED": "Не вдалося скопіювати в буфер обміну",
@@ -243,5 +245,34 @@
"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": "Показувати зашифрований чат кімнати на вибраній вкладці з відео.",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "Налаштуйте вигляд і розташування зашифрованого чату кімнати.",
"CHAT_SETTINGS_NOTE": "Необов'язковий чат із наскрізним шифруванням. Повідомлення доступні лише наживо й не зберігаються ретранслятором.",
"LABEL_CHAT_POSITION": "Розташування",
"LABEL_CHAT_POSITION_TOOLTIP": "Виберіть, де чат буде закріплено на вибраному програвачі.",
"LABEL_CHAT_SIZE": "Розмір вікна",
"LABEL_CHAT_SIZE_TOOLTIP": "Виберіть стандартний розмір вікна чату.",
"OPTION_CHAT_SIZE_COMPACT": "Компактний",
"OPTION_CHAT_SIZE_STANDARD": "Стандартний",
"OPTION_CHAT_SIZE_LARGE": "Великий",
"OPTION_CHAT_SIZE_CUSTOM": "Останній власний розмір",
"LABEL_CHAT_START_MODE": "Початковий вигляд",
"LABEL_CHAT_START_MODE_TOOLTIP": "Виберіть, запускати чат як плаваючу кнопку чи відкрите вікно.",
"OPTION_CHAT_START_BUBBLE": "Плаваюча кнопка",
"OPTION_CHAT_START_OPEN": "Відкрите вікно",
"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": "Повідомлень ще немає. Чат працює лише наживо."
}
+32 -1
View File
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "身份尚未加载。稍等片刻,然后重试。",
"ERR_NO_PEERS_TIME": "没有其他已知时间的同行。切换到“跳到我这里”。",
"ERR_NO_VIDEO_TAB": "无法连接到视频选项卡。",
"SITE_ACCESS_REQUIRED": "浏览器正在阻止 KoalaSync 访问 {host}。请点击下方的“允许访问”。如果未显示对话框,请在扩展程序或网站权限中允许 KoalaSync 访问此网站。",
"BTN_RETRY_ACCESS": "允许访问",
"ERR_SELECT_VIDEO": "请先选择视频!",
"TOAST_INVITE_COPIED": "邀请链接已复制!",
"TOAST_COPY_FAILED": "无法复制到剪贴板",
@@ -243,5 +245,34 @@
"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": "在选定的视频标签页中显示加密的房间聊天。",
"LABEL_SETTINGS_GROUP_CHAT_TOOLTIP": "配置加密房间聊天的外观和位置。",
"CHAT_SETTINGS_NOTE": "可选并采用端到端加密。消息仅实时显示,且不会由中继服务器存储。",
"LABEL_CHAT_POSITION": "位置",
"LABEL_CHAT_POSITION_TOOLTIP": "选择聊天在当前播放器上的固定位置。",
"LABEL_CHAT_SIZE": "浮层大小",
"LABEL_CHAT_SIZE_TOOLTIP": "选择聊天浮层的默认大小。",
"OPTION_CHAT_SIZE_COMPACT": "紧凑",
"OPTION_CHAT_SIZE_STANDARD": "标准",
"OPTION_CHAT_SIZE_LARGE": "大",
"OPTION_CHAT_SIZE_CUSTOM": "上次自定义大小",
"LABEL_CHAT_START_MODE": "启动方式",
"LABEL_CHAT_START_MODE_TOOLTIP": "选择以悬浮气泡或打开的浮层启动聊天。",
"OPTION_CHAT_START_BUBBLE": "悬浮聊天气泡",
"OPTION_CHAT_START_OPEN": "打开浮层",
"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": "暂无消息。聊天仅实时显示。"
}
+4 -3
View File
@@ -1,9 +1,10 @@
{
"manifest_version": 3,
"default_locale": "en",
"name": "KoalaSync",
"version": "2.6.0",
"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": "3.0.0",
"description": "__MSG_appDesc__",
"permissions": [
"storage",
"tabs",
+262
View File
@@ -0,0 +1,262 @@
const GOOGLE_DRIVE_HOST = 'drive.google.com';
const GOOGLE_DRIVE_PLAYER_HOST = 'youtube.googleapis.com';
export const GOOGLE_DRIVE_PLAYER_ORIGIN_PATTERN = 'https://youtube.googleapis.com/*';
export const GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED = 'google_drive_player_access_required';
export const GOOGLE_DRIVE_PLAYER_AMBIGUOUS = 'google_drive_player_ambiguous';
export function isGoogleDriveUrl(value) {
try {
return new URL(value).hostname.toLowerCase() === GOOGLE_DRIVE_HOST;
} catch {
return false;
}
}
export function inspectMediaFrame() {
const videos = Array.from(document.querySelectorAll('video'));
const drivePlayerIframes = Array.from(document.querySelectorAll('iframe')).filter((frame) => {
try {
const url = new URL(frame.src);
if (url.hostname.toLowerCase() !== 'youtube.googleapis.com') return false;
const origin = url.searchParams.get('origin') || url.searchParams.get('post_message_origin');
return origin === 'https://drive.google.com';
} catch {
return false;
}
});
let bestVideoScore = 0;
for (const video of videos) {
const width = video.videoWidth || video.offsetWidth || 0;
const height = video.videoHeight || video.offsetHeight || 0;
const duration = Number.isFinite(video.duration) ? video.duration : 0;
bestVideoScore = Math.max(bestVideoScore, (width * height) + (duration * 100));
}
return {
href: window.location.href,
videoCount: videos.length,
videoScore: bestVideoScore,
frameArea: Math.max(0, window.innerWidth) * Math.max(0, window.innerHeight),
drivePlayerIframeCount: drivePlayerIframes.length,
parentFrameVisible: window.__koalaParentFrameVisibility?.visible ?? null,
parentFrameArea: window.__koalaParentFrameVisibility?.area ?? null
};
}
export function installParentFrameVisibilityProbe(token) {
const handler = (event) => {
if (event.source !== window.parent
|| event.data?.type !== 'KOALASYNC_FRAME_VISIBILITY'
|| event.data?.token !== token) {
return;
}
window.__koalaParentFrameVisibility = {
visible: event.data.visible === true,
area: Number.isFinite(event.data.area) ? event.data.area : 0
};
window.removeEventListener('message', handler);
};
window.addEventListener('message', handler);
}
export async function dispatchParentFrameVisibilityProbe(token) {
let count = 0;
for (const frame of document.querySelectorAll('iframe')) {
try {
const url = new URL(frame.src);
const origin = url.searchParams.get('origin') || url.searchParams.get('post_message_origin');
if (url.hostname.toLowerCase() !== 'youtube.googleapis.com'
|| origin !== 'https://drive.google.com') {
continue;
}
const rect = frame.getBoundingClientRect();
const style = window.getComputedStyle(frame);
const area = Math.max(0, rect.width) * Math.max(0, rect.height);
const intersectsViewport = rect.bottom > 0
&& rect.right > 0
&& rect.top < window.innerHeight
&& rect.left < window.innerWidth;
const browserReportsVisible = typeof frame.checkVisibility === 'function'
? frame.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
: true;
const visible = area > 0
&& intersectsViewport
&& browserReportsVisible
&& style.display !== 'none'
&& style.visibility !== 'hidden'
&& Number(style.opacity) !== 0;
frame.contentWindow?.postMessage({
type: 'KOALASYNC_FRAME_VISIBILITY',
token,
visible,
area
}, 'https://youtube.googleapis.com');
count++;
} catch {
// Ignore unrelated or not-yet-initialized frames.
}
}
await new Promise(resolve => setTimeout(resolve, 100));
return count;
}
function isGoogleDrivePlayerFrameUrl(value) {
try {
const url = new URL(value);
if (url.hostname.toLowerCase() !== GOOGLE_DRIVE_PLAYER_HOST || url.pathname !== '/embed/') {
return false;
}
const origin = url.searchParams.get('origin') || url.searchParams.get('post_message_origin');
return origin === 'https://drive.google.com';
} catch {
return false;
}
}
function createGoogleDrivePlayerAccessError() {
const error = new Error('Google Drive player frame access is required');
error.code = GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED;
error.originPattern = GOOGLE_DRIVE_PLAYER_ORIGIN_PATTERN;
return error;
}
function createGoogleDrivePlayerAmbiguousError() {
const error = new Error('Google Drive player frame could not be identified safely');
error.code = GOOGLE_DRIVE_PLAYER_AMBIGUOUS;
return error;
}
export function selectGoogleDrivePlayerFrame(injectionResults) {
let candidates = (Array.isArray(injectionResults) ? injectionResults : [])
.filter(entry => Number.isInteger(entry?.frameId) && entry?.result)
.filter(entry => isGoogleDrivePlayerFrameUrl(entry.result.href));
if (candidates.length > 1) {
const confirmedVisible = candidates.filter(entry => entry.result.parentFrameVisible === true);
if (confirmedVisible.length > 0) {
candidates = confirmedVisible;
} else {
const notConfirmedHidden = candidates.filter(entry =>
entry.result.parentFrameVisible !== false
);
if (notConfirmedHidden.length !== 1) return null;
candidates = notConfirmedHidden;
}
}
candidates.sort((left, right) => {
const visibilityRank = result => result.parentFrameVisible === true
? 2
: result.parentFrameVisible === false
? 0
: 1;
const leftVisible = visibilityRank(left.result);
const rightVisible = visibilityRank(right.result);
if (leftVisible !== rightVisible) return rightVisible - leftVisible;
const leftParentArea = Number.isFinite(left.result.parentFrameArea)
? left.result.parentFrameArea
: left.result.frameArea;
const rightParentArea = Number.isFinite(right.result.parentFrameArea)
? right.result.parentFrameArea
: right.result.frameArea;
if (leftParentArea !== rightParentArea) return rightParentArea - leftParentArea;
const leftHasVideo = left.result.videoCount > 0 ? 1 : 0;
const rightHasVideo = right.result.videoCount > 0 ? 1 : 0;
if (leftHasVideo !== rightHasVideo) return rightHasVideo - leftHasVideo;
if (left.result.videoScore !== right.result.videoScore) {
return right.result.videoScore - left.result.videoScore;
}
return right.result.frameArea - left.result.frameArea;
});
return candidates[0] || null;
}
export async function resolveMediaScriptTarget(chromeApi, tabId, tabUrl, {
attempts = 8,
retryDelayMs = 200
} = {}) {
const topFrameTarget = { tabId };
if (!isGoogleDriveUrl(tabUrl)) return topFrameTarget;
let fallbackFrame = null;
let drivePlayerIframeDetected = false;
let ambiguousPlayerFramesDetected = false;
for (let attempt = 0; attempt < attempts; attempt++) {
let results;
try {
results = await chromeApi.scripting.executeScript({
target: { tabId, allFrames: true },
func: inspectMediaFrame
});
} catch {
let topFrameResult = null;
try {
const topResults = await chromeApi.scripting.executeScript({
target: { tabId },
func: inspectMediaFrame
});
topFrameResult = Array.isArray(topResults) ? topResults[0]?.result : null;
} catch {
// The generic injection path below remains the final source of truth.
}
if (topFrameResult?.drivePlayerIframeCount > 0) {
throw createGoogleDrivePlayerAccessError();
}
return topFrameTarget;
}
const topFrame = results.find(entry => entry?.frameId === 0);
if (topFrame?.result?.drivePlayerIframeCount > 0) {
drivePlayerIframeDetected = true;
}
const playerFrameCount = results.filter(entry =>
isGoogleDrivePlayerFrameUrl(entry?.result?.href)
).length;
if (playerFrameCount > 1) {
const token = `${tabId}:${attempt}:${Date.now()}:${Math.random()}`;
try {
await chromeApi.scripting.executeScript({
target: { tabId, allFrames: true },
func: installParentFrameVisibilityProbe,
args: [token]
});
await chromeApi.scripting.executeScript({
target: { tabId },
func: dispatchParentFrameVisibilityProbe,
args: [token]
});
results = await chromeApi.scripting.executeScript({
target: { tabId, allFrames: true },
func: inspectMediaFrame
});
} catch {
// Ambiguous frames are retried below; never guess a stale player.
}
}
const selectedFrame = selectGoogleDrivePlayerFrame(results);
if (!selectedFrame && playerFrameCount > 1) {
ambiguousPlayerFramesDetected = true;
}
if (selectedFrame?.result?.videoCount > 0) {
return { tabId, frameIds: [selectedFrame.frameId] };
}
if (selectedFrame) fallbackFrame = selectedFrame;
if (attempt < attempts - 1) {
await new Promise(resolve => setTimeout(resolve, retryDelayMs));
}
}
if (!fallbackFrame && ambiguousPlayerFramesDetected) {
throw createGoogleDrivePlayerAmbiguousError();
}
if (!fallbackFrame && drivePlayerIframeDetected) {
throw createGoogleDrivePlayerAccessError();
}
return fallbackFrame
? { tabId, frameIds: [fallbackFrame.frameId] }
: topFrameTarget;
}
+148
View File
@@ -0,0 +1,148 @@
import { describe, expect, it, vi } from 'vitest';
import {
GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED,
GOOGLE_DRIVE_PLAYER_AMBIGUOUS,
isGoogleDriveUrl,
resolveMediaScriptTarget,
selectGoogleDrivePlayerFrame
} from './media-frame-target.js';
const drivePlayerUrl = 'https://youtube.googleapis.com/embed/?enablejsapi=1&origin=https%3A%2F%2Fdrive.google.com';
describe('Google Drive media-frame targeting', () => {
it('recognizes Drive tabs without treating other Google pages as Drive', () => {
expect(isGoogleDriveUrl('https://drive.google.com/drive/u/1/search?q=mp4')).toBe(true);
expect(isGoogleDriveUrl('https://docs.google.com/document/d/1/edit')).toBe(false);
});
it('selects the visible frame containing the real video', () => {
const selected = selectGoogleDrivePlayerFrame([
{ frameId: 0, result: { href: 'https://drive.google.com/drive/u/1/search?q=mp4', videoCount: 0, videoScore: 0, frameArea: 900000 } },
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 1900000, frameArea: 900000, parentFrameVisible: true } },
{ frameId: 5, result: { href: drivePlayerUrl, videoCount: 0, videoScore: 0, frameArea: 0, parentFrameVisible: false } }
]);
expect(selected.frameId).toBe(4);
});
it('never lets a hidden loaded player outrank the visible Drive player', () => {
const selected = selectGoogleDrivePlayerFrame([
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 927600, frameArea: 900000, parentFrameVisible: true, parentFrameArea: 900000 } },
{ frameId: 5, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 1281600, frameArea: 900000, parentFrameVisible: false, parentFrameArea: 900000 } }
]);
expect(selected.frameId).toBe(4);
});
it('refuses to guess when parent visibility is unavailable', () => {
const selected = selectGoogleDrivePlayerFrame([
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 927600, frameArea: 900000 } },
{ frameId: 5, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 1281600, frameArea: 900000 } }
]);
expect(selected).toBeNull();
});
it('ignores unrelated video frames inside a Drive tab', () => {
const selected = selectGoogleDrivePlayerFrame([
{ frameId: 3, result: { href: 'https://example.com/embed', videoCount: 1, videoScore: 3000000, frameArea: 1200000 } },
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 927600, frameArea: 900000 } }
]);
expect(selected.frameId).toBe(4);
});
it('falls back to the visible Drive player frame while its video is loading', () => {
const selected = selectGoogleDrivePlayerFrame([
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 0, videoScore: 0, frameArea: 900000, parentFrameVisible: true } },
{ frameId: 5, result: { href: drivePlayerUrl, videoCount: 0, videoScore: 0, frameArea: 0, parentFrameVisible: false } }
]);
expect(selected.frameId).toBe(4);
});
it('reports ambiguity instead of selecting a stale frame after retries', async () => {
const ambiguousResults = [
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 927600, frameArea: 900000 } },
{ frameId: 5, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 1281600, frameArea: 900000 } }
];
const executeScript = vi.fn().mockResolvedValue(ambiguousResults);
await expect(resolveMediaScriptTarget(
{ scripting: { executeScript } },
42,
'https://drive.google.com/drive/u/1/search?q=mp4',
{ attempts: 1, retryDelayMs: 0 }
)).rejects.toMatchObject({ code: GOOGLE_DRIVE_PLAYER_AMBIGUOUS });
});
it('probes all frames and returns only the frame with the Drive video', async () => {
const executeScript = vi.fn()
.mockResolvedValueOnce([
{ frameId: 0, result: { href: 'https://drive.google.com/drive/u/1/search?q=mp4', videoCount: 0, videoScore: 0, frameArea: 900000 } },
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 0, videoScore: 0, frameArea: 900000 } }
])
.mockResolvedValueOnce([
{ frameId: 0, result: { href: 'https://drive.google.com/drive/u/1/search?q=mp4', videoCount: 0, videoScore: 0, frameArea: 900000 } },
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 1900000, frameArea: 900000 } }
]);
await expect(resolveMediaScriptTarget(
{ scripting: { executeScript } },
42,
'https://drive.google.com/drive/u/1/search?q=mp4',
{ retryDelayMs: 0 }
)).resolves.toEqual({ tabId: 42, frameIds: [4] });
expect(executeScript).toHaveBeenCalledWith({
target: { tabId: 42, allFrames: true },
func: expect.any(Function)
});
});
it('reports missing access when Drive exposes a player iframe but the frame cannot be inspected', async () => {
const executeScript = vi.fn().mockResolvedValue([
{
frameId: 0,
result: {
href: 'https://drive.google.com/drive/u/1/search?q=mp4',
videoCount: 0,
videoScore: 0,
frameArea: 900000,
drivePlayerIframeCount: 1
}
}
]);
await expect(resolveMediaScriptTarget(
{ scripting: { executeScript } },
42,
'https://drive.google.com/drive/u/1/search?q=mp4',
{ attempts: 1, retryDelayMs: 0 }
)).rejects.toMatchObject({ code: GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED });
});
it('reports missing access when the all-frame probe itself is rejected', async () => {
const executeScript = vi.fn()
.mockRejectedValueOnce(new Error('Cannot access contents of the frame'))
.mockResolvedValueOnce([
{
frameId: 0,
result: {
href: 'https://drive.google.com/drive/u/1/search?q=mp4',
videoCount: 0,
videoScore: 0,
frameArea: 900000,
drivePlayerIframeCount: 1
}
}
]);
await expect(resolveMediaScriptTarget(
{ scripting: { executeScript } },
42,
'https://drive.google.com/drive/u/1/search?q=mp4',
{ attempts: 1, retryDelayMs: 0 }
)).rejects.toMatchObject({ code: GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED });
});
});
+9 -72
View File
@@ -1,20 +1,8 @@
export function initTabManager({
getCurrentTabId,
setCurrentTabId,
setCurrentTabTitle,
setLastContentHeartbeatAt,
setRoomIdleSince,
getCurrentRoom,
getPeerId,
getStorageInitialized,
updateBadgeStatus,
addLog,
getSettings,
emit,
applyAudioSettingsToTab,
injectContentScript,
reactivateCurrentTarget,
ensureState,
EVENTS
sendToCurrentContent
}) {
chrome.storage.onChanged.addListener(async (changes, area) => {
if (area !== 'local' || !changes.audioSettings) return;
@@ -22,71 +10,20 @@ export function initTabManager({
const tabId = getCurrentTabId();
if (!tabId) return;
chrome.tabs.sendMessage(tabId, {
sendToCurrentContent({
action: 'APPLY_AUDIO_SETTINGS',
settings: changes.audioSettings.newValue
}).catch(() => {});
});
chrome.tabs.onRemoved.addListener(async (tabId) => {
await ensureState();
if (tabId === getCurrentTabId()) {
const wasInRoom = !!getCurrentRoom();
setCurrentTabId(null);
setCurrentTabTitle(null);
setLastContentHeartbeatAt(null);
const now = Date.now();
setRoomIdleSince(now);
chrome.storage.session.set({
currentTabId: null,
currentTabTitle: null,
roomIdleSince: now,
lastContentHeartbeatAt: null
});
updateBadgeStatus();
addLog('Target tab closed.', 'warn');
if (wasInRoom) {
const roomAtClose = getCurrentRoom();
getSettings().then(settings => {
if (getCurrentRoom() !== roomAtClose) return;
emit(EVENTS.PEER_STATUS, {
peerId: getPeerId(),
playbackState: 'paused',
currentTime: null,
mediaTitle: null,
username: settings.username,
tabTitle: null
});
const room = getCurrentRoom();
if (room && Array.isArray(room.peers)) {
const me = room.peers.find(p => (p.peerId || p) === getPeerId());
if (me && typeof me === 'object') {
me.playbackState = 'paused';
me.currentTime = null;
me.mediaTitle = null;
me.tabTitle = null;
me.lastHeartbeat = Date.now();
if (getStorageInitialized()) {
chrome.storage.session.set({ currentRoom: room });
}
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: room.peers }).catch(() => {});
}
}
}).catch(() => {});
}
}
});
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, _tab) => {
await ensureState();
const curTabId = getCurrentTabId();
if (curTabId && tabId === parseInt(curTabId) && changeInfo.status === 'complete') {
injectContentScript(tabId)
.then(() => applyAudioSettingsToTab(tabId))
.catch(() => {});
const currentTabId = Number(getCurrentTabId());
if (Number.isInteger(currentTabId)
&& currentTabId > 0
&& tabId === currentTabId
&& changeInfo.status === 'complete') {
reactivateCurrentTarget(tabId).catch(() => {});
}
});
}
+77 -5
View File
@@ -453,6 +453,24 @@
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.08);
}
.site-access-notice {
display: none;
margin-top: 8px;
margin-bottom: 0;
border-left: 4px solid var(--warning);
background: color-mix(in oklch, var(--warning), var(--card) 90%);
color: var(--text);
font-size: 11px;
line-height: 1.45;
}
.site-access-notice button {
width: auto;
margin-top: 8px;
padding: 6px 10px;
font-size: 11px;
}
.peer-item {
padding: 10px 0;
border-bottom: 1px solid var(--border-strong);
@@ -1146,12 +1164,12 @@
color: var(--accent);
opacity: 1;
}
.tab-btn[data-tab="tab-dev"],
/* Only the opt-in Dev tab is de-emphasized. tab-dev is the user-facing
"Status" tab and must match the other tabs. */
#devToolsTabBtn {
color: color-mix(in oklch, var(--text-muted), transparent 18%);
font-size: 11px;
}
.tab-btn[data-tab="tab-dev"].active,
#devToolsTabBtn.active {
color: var(--text-on-green);
}
@@ -1197,6 +1215,12 @@
line-height: 1.45;
text-align: left;
}
[data-chat-setting] {
transition: opacity 0.2s;
}
[data-chat-setting].is-disabled {
opacity: 0.5;
}
.settings-issue-link {
margin: 8px 0 2px;
padding-top: 9px;
@@ -1305,7 +1329,7 @@
}
@keyframes floatHint {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
50% { transform: translateY(-2px); }
}
</style>
</head>
@@ -1353,7 +1377,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>
@@ -1428,10 +1452,14 @@
<option value="" data-i18n="OPTION_SELECT_TAB">-- Select a Tab --</option>
</select>
<!-- Hint Tooltip -->
<div id="targetTabHint" style="display: none; position: absolute; top: -25px; right: 0; background: var(--accent); color: var(--text-on-green); padding: 4px 8px; border-radius: 6px; font-size: 11px; font-weight: bold; pointer-events: none; animation: floatHint 2s ease-in-out infinite; box-shadow: 0 4px 12px color-mix(in oklch, var(--accent), transparent 60%); z-index: 10;" data-i18n="HINT_SELECT_VIDEO">
<div id="targetTabHint" style="display: none; position: absolute; top: -8px; right: 0; background: var(--accent); color: var(--text-on-green); padding: 4px 8px; border-radius: 6px; font-size: 11px; font-weight: bold; pointer-events: none; animation: floatHint 2s ease-in-out infinite; box-shadow: 0 2px 6px color-mix(in oklch, var(--accent), transparent 82%); z-index: 10;" data-i18n="HINT_SELECT_VIDEO">
Select your video here!
<div style="position: absolute; bottom: -4px; right: 20px; width: 8px; height: 8px; background: var(--accent); transform: rotate(45deg);"></div>
</div>
<div id="siteAccessNotice" class="info-card site-access-notice" role="alert" aria-live="polite">
<div id="siteAccessMessage"></div>
<button id="siteAccessRetry" class="secondary" data-i18n="BTN_RETRY_ACCESS">Try again</button>
</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
@@ -1593,6 +1621,50 @@
</div>
</details>
<details class="form-group" name="settings-accordion">
<summary title="Configure encrypted room chat appearance and placement." data-i18n="CHAT_TITLE" data-i18n-title="LABEL_SETTINGS_GROUP_CHAT_TOOLTIP">Room Chat</summary>
<div class="details-content">
<p class="settings-note" data-i18n="CHAT_SETTINGS_NOTE">Optional and end-to-end encrypted. Messages are live only and are not stored by the relay.</p>
<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" data-chat-setting>
<label for="chatPosition" title="Choose where chat is attached to the selected player." data-i18n="LABEL_CHAT_POSITION" data-i18n-title="LABEL_CHAT_POSITION_TOOLTIP">Position</label>
<span class="settings-select">
<select id="chatPosition" class="settings-control">
<option value="right" data-i18n="CHAT_DOCK_RIGHT">Dock chat right</option>
<option value="left" data-i18n="CHAT_DOCK_LEFT">Dock chat left</option>
<option value="detached" data-i18n="CHAT_DETACHED">Detach chat</option>
</select>
</span>
</div>
<div class="settings-row" data-chat-setting>
<label for="chatSize" title="Choose the default size of the chat overlay." data-i18n="LABEL_CHAT_SIZE" data-i18n-title="LABEL_CHAT_SIZE_TOOLTIP">Overlay size</label>
<span class="settings-select">
<select id="chatSize" class="settings-control">
<option value="compact" data-i18n="OPTION_CHAT_SIZE_COMPACT">Compact</option>
<option value="standard" data-i18n="OPTION_CHAT_SIZE_STANDARD">Standard</option>
<option value="large" data-i18n="OPTION_CHAT_SIZE_LARGE">Large</option>
<option value="custom" data-i18n="OPTION_CHAT_SIZE_CUSTOM">Last custom size</option>
</select>
</span>
</div>
<div class="settings-row" data-chat-setting>
<label for="chatStartMode" title="Choose whether chat starts as a floating bubble or an open overlay." data-i18n="LABEL_CHAT_START_MODE" data-i18n-title="LABEL_CHAT_START_MODE_TOOLTIP">Start as</label>
<span class="settings-select">
<select id="chatStartMode" class="settings-control">
<option value="bubble" data-i18n="OPTION_CHAT_START_BUBBLE">Floating bubble</option>
<option value="open" data-i18n="OPTION_CHAT_START_OPEN">Open overlay</option>
</select>
</span>
</div>
</div>
</details>
<details class="form-group" name="settings-accordion">
<summary title="Customize playback, notifications, and audio preferences." data-i18n="LABEL_SETTINGS_GROUP_SYNC" data-i18n-title="LABEL_SETTINGS_GROUP_SYNC_TOOLTIP">Playback &amp; Sync</summary>
<div class="details-content">
+339 -98
View File
@@ -1,15 +1,29 @@
import { EVENTS, OFFICIAL_LANDING_PAGE_URL, SUPPORT_URL, getReviewUrl } from './shared/constants.js';
import { BLACKLIST_DOMAINS } from './shared/blacklist.js';
import { BLACKLIST_ALLOWLIST_DOMAINS, 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';
import { isTabUrlFiltered } from './tab-filter.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'),
contents: document.querySelectorAll('.tab-content'),
copyInvite: document.getElementById('copyInvite'),
targetTab: document.getElementById('targetTab'),
siteAccessNotice: document.getElementById('siteAccessNotice'),
siteAccessMessage: document.getElementById('siteAccessMessage'),
siteAccessRetry: document.getElementById('siteAccessRetry'),
forceSyncBtn: document.getElementById('forceSyncBtn'),
forceSyncMode: document.getElementById('forceSyncMode'),
peerList: document.getElementById('peerList'),
@@ -55,6 +69,10 @@ const elements = {
playBtn: document.getElementById('playBtn'),
pauseBtn: document.getElementById('pauseBtn'),
autoSyncNextEpisode: document.getElementById('autoSyncNextEpisode'),
chatEnabled: document.getElementById('chatEnabled'),
chatPosition: document.getElementById('chatPosition'),
chatSize: document.getElementById('chatSize'),
chatStartMode: document.getElementById('chatStartMode'),
sendTabTitle: document.getElementById('sendTabTitle'),
mediaTitlePrivacyMode: document.getElementById('mediaTitlePrivacyMode'),
episodeLobbyCard: document.getElementById('episodeLobbyCard'),
@@ -324,7 +342,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', 'chatPosition', 'chatSize', 'chatStartMode', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab', 'themeMode', 'themePalette']);
let activeLang = localData.locale;
if (!activeLang) {
@@ -349,12 +367,17 @@ 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;
if (elements.chatPosition) elements.chatPosition.value = ['left', 'detached'].includes(localData.chatPosition) ? localData.chatPosition : 'right';
if (elements.chatSize) elements.chatSize.value = ['compact', 'large', 'custom'].includes(localData.chatSize) ? localData.chatSize : 'standard';
if (elements.chatStartMode) elements.chatStartMode.value = localData.chatStartMode === 'open' ? 'open' : 'bubble';
syncChatSettingsState();
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);
@@ -380,14 +403,14 @@ 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();
// Initial Status Check (status shows via GET_STATUS below)
// Initial Status Check
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, async (res) => {
chrome.runtime.sendMessage({ type: 'GET_STATUS', retryPendingTarget: true }, async (res) => {
if (chrome.runtime.lastError) {
console.warn('[Popup] Background not responding:', chrome.runtime.lastError.message);
await populateTabs();
@@ -410,13 +433,24 @@ async function init() {
applyConnectionStatus('connecting');
}
// Populate Tabs using the background's targetTabId
await populateTabs(res.peers, res.targetTabId);
if (res.pendingTargetTabId) {
showSiteAccessNotice(
res.pendingTargetHost,
res.pendingTargetOriginPattern,
res.pendingTargetTabId
);
} else {
hideSiteAccessNotice();
}
// Keep a denied selection visible while Chrome waits for the user
// to grant access; it becomes active automatically after approval.
await populateTabs(res.peers, res.targetTabId || res.pendingTargetTabId);
// Render lobby status if active
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
if (res.status === 'connected' && !res.targetTabId && localData.roomId) {
if (res.status === 'connected' && !res.targetTabId && !res.pendingTargetTabId && localData.roomId) {
const syncTabBtn = document.querySelector('.tab-btn[data-tab="tab-sync"]');
if (syncTabBtn) syncTabBtn.click();
showSelectVideoHint();
@@ -463,6 +497,10 @@ function toggleUIState(inRoom) {
// --- Host Control Mode UI ---
// True when we're a guest in a host-only room → remote-control buttons are locked.
let hcmGuestLocked = false;
let siteAccessBlocked = false;
let siteAccessHost = null;
let siteAccessOriginPattern = null;
let siteAccessTabId = null;
// Co-Host state mirrored for the peer-list renderer (promote/demote + role badges).
let hcmAmOwner = false;
@@ -524,12 +562,15 @@ function updateHostControlUI(state) {
}
function setRemoteControlsLocked(locked) {
const effectiveLocked = locked || siteAccessBlocked;
[elements.playBtn, elements.pauseBtn, elements.forceSyncBtn].forEach(btn => {
if (!btn) return;
btn.disabled = locked;
btn.style.opacity = locked ? '0.5' : '';
btn.style.cursor = locked ? 'not-allowed' : '';
btn.title = locked ? (getMessage('NOTICE_HOST_CONTROLS') || 'The host controls playback for everyone.') : '';
btn.disabled = effectiveLocked;
btn.style.opacity = effectiveLocked ? '0.5' : '';
btn.style.cursor = effectiveLocked ? 'not-allowed' : '';
btn.title = siteAccessBlocked
? (elements.siteAccessMessage?.textContent || getMessage('ERR_NO_VIDEO_TAB'))
: (locked ? (getMessage('NOTICE_HOST_CONTROLS') || 'The host controls playback for everyone.') : '');
});
// Always restore the default labels. The action handlers leave the text in a
// transitional state ("Playing..." / "Pausing...") and the 2.5s safety reset
@@ -540,6 +581,88 @@ function setRemoteControlsLocked(locked) {
if (elements.pauseBtn) elements.pauseBtn.textContent = getMessage('BTN_PAUSE') || 'Pause';
}
function showSiteAccessNotice(host, originPattern = null, tabId = null) {
siteAccessBlocked = true;
siteAccessHost = typeof host === 'string' && host.length > 0 ? host : 'website';
siteAccessOriginPattern = typeof originPattern === 'string' && originPattern.length > 0
? originPattern
: null;
siteAccessTabId = normalizeTabId(tabId);
if (elements.siteAccessMessage) {
elements.siteAccessMessage.textContent = getMessage('SITE_ACCESS_REQUIRED', {
host: siteAccessHost
});
}
if (elements.siteAccessRetry) {
elements.siteAccessRetry.disabled = !siteAccessOriginPattern || siteAccessTabId === null;
}
if (elements.siteAccessNotice) elements.siteAccessNotice.style.display = 'block';
setRemoteControlsLocked(hcmGuestLocked);
}
function hideSiteAccessNotice() {
siteAccessBlocked = false;
siteAccessHost = null;
siteAccessOriginPattern = null;
siteAccessTabId = null;
if (elements.siteAccessRetry) elements.siteAccessRetry.disabled = false;
if (elements.siteAccessNotice) elements.siteAccessNotice.style.display = 'none';
setRemoteControlsLocked(hcmGuestLocked);
}
function handleTargetTabResponse(response) {
if (response?.status === 'host_permission_required') {
showSiteAccessNotice(response.host, response.originPattern, response.tabId);
return false;
}
if (response?.status === 'ok') {
hideSiteAccessNotice();
return true;
}
if (response?.status === 'superseded') return false;
showToast(response?.message || getMessage('ERR_NO_VIDEO_TAB'), 'error', 5000);
return false;
}
function selectTargetTab(tabId, tabTitle) {
return new Promise(resolve => {
chrome.runtime.sendMessage({ type: 'SET_TARGET_TAB', tabId, tabTitle }, response => {
if (chrome.runtime.lastError) {
showToast(chrome.runtime.lastError.message || getMessage('ERR_NO_VIDEO_TAB'), 'error', 5000);
resolve(false);
return;
}
resolve(handleTargetTabResponse(response));
});
});
}
async function refreshTargetAccessState() {
const status = await new Promise(resolve => {
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, response => {
if (chrome.runtime.lastError) {
resolve(null);
return;
}
resolve(response || null);
});
});
if (!status) return;
if (status.pendingTargetTabId) {
showSiteAccessNotice(
status.pendingTargetHost,
status.pendingTargetOriginPattern,
status.pendingTargetTabId
);
} else {
hideSiteAccessNotice();
}
await populateTabs(
status.peers,
status.targetTabId ?? status.pendingTargetTabId ?? null
);
}
if (elements.hostControlToggle) {
elements.hostControlToggle.addEventListener('change', () => {
const mode = elements.hostControlToggle.checked ? 'host-only' : 'everyone';
@@ -552,15 +675,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) {
@@ -998,7 +1126,7 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
if (populateTabsToken !== token) return;
currentTargetTabId = null;
} else {
currentTargetTabId = status?.targetTabId;
currentTargetTabId = status?.targetTabId || status?.pendingTargetTabId;
}
}
@@ -1030,18 +1158,9 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
const filteredTabs = tabs.filter(tab => {
if (!tab.url || tab.url.startsWith('chrome://')) return false;
if (isFilterActive && tab.id !== parseInt(currentTargetTabId)) {
const urlStr = tab.url.toLowerCase();
if (BLACKLIST_DOMAINS.some(d => {
const domain = d.toLowerCase();
try {
const hostname = new URL(tab.url).hostname.toLowerCase();
if (domain.endsWith('.')) return hostname.startsWith(domain) || hostname.includes('.' + domain);
if (domain.includes('.')) return hostname === domain || hostname.endsWith('.' + domain);
} catch {
/* ignore invalid URLs */
}
return urlStr.includes(domain);
})) return false;
if (isTabUrlFiltered(tab.url, BLACKLIST_DOMAINS, BLACKLIST_ALLOWLIST_DOMAINS)) {
return false;
}
}
return true;
});
@@ -1118,7 +1237,7 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
if (matchOpt && elements.targetTab.options.length > 1) {
elements.targetTab.value = matchOpt.value;
const tabTitle = matchOpt.dataset.originalTitle || null;
chrome.runtime.sendMessage({ type: 'SET_TARGET_TAB', tabId: parseInt(matchOpt.value), tabTitle });
selectTargetTab(parseInt(matchOpt.value), tabTitle);
}
}
}
@@ -1289,41 +1408,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
}
@@ -1358,6 +1457,41 @@ elements.autoSyncNextEpisode.addEventListener('change', () => {
chrome.storage.local.set({ autoSyncNextEpisode: elements.autoSyncNextEpisode.checked });
});
function syncChatSettingsState() {
const enabled = elements.chatEnabled?.checked === true;
for (const control of [elements.chatPosition, elements.chatSize, elements.chatStartMode]) {
if (control) control.disabled = !enabled;
}
document.querySelectorAll('[data-chat-setting]').forEach(row => {
row.classList.toggle('is-disabled', !enabled);
row.setAttribute('aria-disabled', String(!enabled));
});
}
if (elements.chatEnabled) {
elements.chatEnabled.addEventListener('change', () => {
syncChatSettingsState();
chrome.storage.local.set({ chatEnabled: elements.chatEnabled.checked });
});
}
if (elements.chatPosition) {
elements.chatPosition.addEventListener('change', () => {
const chatPosition = ['left', 'detached'].includes(elements.chatPosition.value) ? elements.chatPosition.value : 'right';
chrome.storage.local.set({ chatPosition });
});
}
if (elements.chatSize) {
elements.chatSize.addEventListener('change', () => {
const chatSize = ['compact', 'large', 'custom'].includes(elements.chatSize.value) ? elements.chatSize.value : 'standard';
chrome.storage.local.set({ chatSize });
});
}
if (elements.chatStartMode) {
elements.chatStartMode.addEventListener('change', () => {
chrome.storage.local.set({ chatStartMode: elements.chatStartMode.value === 'open' ? 'open' : 'bubble' });
});
}
if (elements.sendTabTitle) {
elements.sendTabTitle.addEventListener('change', () => {
chrome.storage.local.set({ sendTabTitle: elements.sendTabTitle.checked }, () => {
@@ -1468,15 +1602,24 @@ 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);
await populateTabs(res.peers, res.targetTabId);
if (res.pendingTargetTabId) {
showSiteAccessNotice(
res.pendingTargetHost,
res.pendingTargetOriginPattern,
res.pendingTargetTabId
);
} else {
hideSiteAccessNotice();
}
await populateTabs(res.peers, res.targetTabId || res.pendingTargetTabId);
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();
}
});
@@ -1604,7 +1747,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 () => {
@@ -1615,8 +1763,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');
@@ -1676,17 +1824,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 () => {
@@ -1694,7 +1859,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 = [];
@@ -1720,6 +1885,7 @@ function handleCreateRoom() {
const password = secureGenerateId();
elements.roomId.value = roomId;
elements.password.value = password;
pendingRoomCreation = true;
window.justCreatedRoom = true;
// Auto-connect
@@ -1749,18 +1915,48 @@ elements.retryBtn.addEventListener('click', () => {
chrome.runtime.sendMessage({ type: 'RETRY_CONNECT' });
});
elements.targetTab.addEventListener('change', () => {
elements.targetTab.addEventListener('change', async () => {
const hint = document.getElementById('targetTabHint');
if (hint) hint.style.display = 'none';
const val = elements.targetTab.value;
const tabId = val ? parseInt(val) : null;
const tabTitle = elements.targetTab.options[elements.targetTab.selectedIndex]?.dataset.originalTitle || null;
chrome.runtime.sendMessage({ type: 'SET_TARGET_TAB', tabId, tabTitle });
await selectTargetTab(tabId, tabTitle);
});
if (elements.siteAccessRetry) {
elements.siteAccessRetry.addEventListener('click', async () => {
const tabId = normalizeTabId(elements.targetTab.value);
const tabTitle = elements.targetTab.options[elements.targetTab.selectedIndex]?.dataset.originalTitle || null;
if (tabId === null || siteAccessTabId !== tabId || !siteAccessOriginPattern) {
showToast(getMessage('ERR_SELECT_VIDEO'), 'warning');
return;
}
const requestedOriginPattern = siteAccessOriginPattern;
const requestedTabId = siteAccessTabId;
elements.siteAccessRetry.disabled = true;
elements.targetTab.disabled = true;
try {
const granted = await requestOriginPermission(chrome, requestedOriginPattern);
if (granted !== true) return;
const selectedTabId = normalizeTabId(elements.targetTab.value);
// permissions.onAdded may already have completed activation, or a
// newer selection may have replaced this request while it was open.
if (!siteAccessBlocked || selectedTabId !== tabId
|| siteAccessTabId !== requestedTabId
|| siteAccessOriginPattern !== requestedOriginPattern) return;
await selectTargetTab(tabId, tabTitle);
} finally {
elements.siteAccessRetry.disabled = siteAccessBlocked
&& (!siteAccessOriginPattern || siteAccessTabId === null);
elements.targetTab.disabled = false;
}
});
}
elements.forceSyncBtn.addEventListener('click', async () => {
if (hcmGuestLocked) return; // guest in host-only room — backstop (M-2/L-3)
if (hcmGuestLocked || siteAccessBlocked) return; // host/site-access backstop
if (elements.forceSyncBtn.disabled) return;
const originalText = elements.forceSyncBtn.textContent;
@@ -1771,6 +1967,11 @@ elements.forceSyncBtn.addEventListener('click', async () => {
elements.forceSyncBtn.disabled = false;
return;
}
const tabId = normalizeTabId(status.targetTabId);
if (tabId === null) {
elements.forceSyncBtn.disabled = false;
return;
}
const mode = elements.forceSyncMode.value;
let targetTime = null;
@@ -1806,23 +2007,33 @@ elements.forceSyncBtn.addEventListener('click', async () => {
// Don't unlock a button that's locked because we became a guest mid-flight —
// hcmGuestLocked is the source of truth for the lock state, and the next
// CONTROL_MODE update restores the correct label.
if (!forceSyncDone && !hcmGuestLocked) {
if (!forceSyncDone && !hcmGuestLocked && !siteAccessBlocked) {
elements.forceSyncBtn.disabled = false;
elements.forceSyncBtn.textContent = originalText;
}
};
forceSyncResetTimer = setTimeout(forceSyncReset, syncTimeoutMs);
const tabId = parseInt(status.targetTabId);
const failForceSyncTime = () => {
if (forceSyncDone) return;
if (forceSyncResetTimer) { clearTimeout(forceSyncResetTimer); forceSyncResetTimer = null; }
showError(getMessage('ERR_NO_VIDEO_TAB'));
forceSyncDone = true;
elements.forceSyncBtn.disabled = false;
if (!hcmGuestLocked && !siteAccessBlocked) elements.forceSyncBtn.disabled = false;
elements.forceSyncBtn.textContent = originalText;
};
const sendForceSync = (time) => {
const targetIsStillCurrent = () => new Promise(resolve => {
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, currentStatus => {
if (chrome.runtime.lastError) {
resolve(false);
return;
}
resolve(normalizeTabId(currentStatus?.targetTabId) === tabId);
});
});
const sendForceSync = async (time) => {
if (time === null || time === undefined) {
failForceSyncTime();
return;
@@ -1832,16 +2043,29 @@ elements.forceSyncBtn.addEventListener('click', async () => {
failForceSyncTime();
return;
}
if (!(await targetIsStillCurrent())) {
failForceSyncTime();
return;
}
chrome.runtime.sendMessage({
type: 'CONTENT_EVENT',
action: EVENTS.FORCE_SYNC_PREPARE,
expectedTabId: tabId,
payload: { targetTime: target }
}, response => {
if (chrome.runtime.lastError || response?.status === 'stale_target') {
failForceSyncTime();
}
});
};
if (mode === 'jump-to-me') {
const retryQueryTime = () => {
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (retryResponse) => {
const retryQueryTime = async () => {
if (!(await targetIsStillCurrent())) {
failForceSyncTime();
return;
}
chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId }, (retryResponse) => {
if (chrome.runtime.lastError || !retryResponse || !Number.isFinite(retryResponse.currentTime)) {
failForceSyncTime();
return;
@@ -1849,14 +2073,25 @@ elements.forceSyncBtn.addEventListener('click', async () => {
sendForceSync(retryResponse.currentTime);
});
};
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (response) => {
chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId }, (response) => {
if (Number.isFinite(response?.currentTime)) {
sendForceSync(response.currentTime);
return;
}
if (chrome.runtime.lastError || !response) {
chrome.runtime.sendMessage({ type: 'INJECT_CONTENT_SCRIPT', tabId }, (injectResponse) => {
chrome.runtime.sendMessage({
type: 'INJECT_CONTENT_SCRIPT',
tabId,
expectedCurrentTabId: tabId
}, (injectResponse) => {
if (chrome.runtime.lastError || !injectResponse || injectResponse.status !== 'ok') {
if (injectResponse?.status === 'host_permission_required') {
showSiteAccessNotice(
injectResponse.host,
injectResponse.originPattern,
injectResponse.tabId
);
}
failForceSyncTime();
return;
}
@@ -1872,7 +2107,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
});
elements.playBtn.addEventListener('click', () => {
if (hcmGuestLocked) return; // guest in host-only room — backstop
if (hcmGuestLocked || siteAccessBlocked) return; // host/site-access backstop
if (!elements.targetTab.value) {
showToast(getMessage('ERR_SELECT_VIDEO'), 'warning');
return;
@@ -1886,12 +2121,12 @@ elements.playBtn.addEventListener('click', () => {
}, (response) => {
if (response && response.status === 'ok_solo') {
elements.playBtn.textContent = getMessage('BTN_PLAY');
elements.playBtn.disabled = false;
if (!hcmGuestLocked && !siteAccessBlocked) elements.playBtn.disabled = false;
}
});
// Safety reset: restore button after 2.5s in case no peers respond
setTimeout(() => {
if (elements.playBtn.disabled && !hcmGuestLocked) {
if (elements.playBtn.disabled && !hcmGuestLocked && !siteAccessBlocked) {
elements.playBtn.textContent = getMessage('BTN_PLAY');
elements.playBtn.disabled = false;
}
@@ -1899,7 +2134,7 @@ elements.playBtn.addEventListener('click', () => {
});
elements.pauseBtn.addEventListener('click', () => {
if (hcmGuestLocked) return; // guest in host-only room — backstop
if (hcmGuestLocked || siteAccessBlocked) return; // host/site-access backstop
if (!elements.targetTab.value) {
showToast(getMessage('ERR_SELECT_VIDEO'), 'warning');
return;
@@ -1913,12 +2148,12 @@ elements.pauseBtn.addEventListener('click', () => {
}, (response) => {
if (response && response.status === 'ok_solo') {
elements.pauseBtn.textContent = getMessage('BTN_PAUSE');
elements.pauseBtn.disabled = false;
if (!hcmGuestLocked && !siteAccessBlocked) elements.pauseBtn.disabled = false;
}
});
// Safety reset: restore button after 2.5s in case no peers respond
setTimeout(() => {
if (elements.pauseBtn.disabled && !hcmGuestLocked) {
if (elements.pauseBtn.disabled && !hcmGuestLocked && !siteAccessBlocked) {
elements.pauseBtn.textContent = getMessage('BTN_PAUSE');
elements.pauseBtn.disabled = false;
}
@@ -2083,7 +2318,7 @@ chrome.runtime.onMessage.addListener((msg) => {
if (state.acks && state.acks.length >= peerCount) {
btn.textContent = getMessage('BTN_STATE_SYNCED');
setTimeout(() => {
btn.disabled = false;
if (!hcmGuestLocked && !siteAccessBlocked) btn.disabled = false;
btn.textContent = state.action === 'play' ? getMessage('BTN_PLAY') : getMessage('BTN_PAUSE');
}, 2000);
}
@@ -2098,7 +2333,7 @@ chrome.runtime.onMessage.addListener((msg) => {
forceSyncResetTimer = null;
}
if (elements.forceSyncBtn) {
elements.forceSyncBtn.disabled = false;
if (!hcmGuestLocked && !siteAccessBlocked) elements.forceSyncBtn.disabled = false;
elements.forceSyncBtn.textContent = getMessage('BTN_STATE_SYNCED');
setTimeout(() => {
elements.forceSyncBtn.textContent = getMessage('BTN_SYNC');
@@ -2150,6 +2385,12 @@ chrome.runtime.onMessage.addListener((msg) => {
}
});
}
} else if (msg.type === 'TARGET_TAB_READY') {
refreshTargetAccessState().catch(() => {});
} else if (msg.type === 'TARGET_TAB_ACCESS_REQUIRED') {
refreshTargetAccessState().catch(() => {});
} else if (msg.type === 'TARGET_TAB_CLEARED') {
refreshTargetAccessState().catch(() => {});
} else if (msg.type === 'PING_UPDATE') {
updatePingDisplay(msg.ping);
} else if (msg.type === 'HISTORY_UPDATE') {
@@ -2167,15 +2408,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);
+22
View File
@@ -0,0 +1,22 @@
function hostnameMatchesDomain(hostname, domain) {
const normalizedHostname = String(hostname || '').toLowerCase();
const normalizedDomain = String(domain || '').toLowerCase();
return normalizedDomain
&& (normalizedHostname === normalizedDomain
|| normalizedHostname.endsWith(`.${normalizedDomain}`));
}
export function isTabUrlFiltered(url, blacklistDomains, allowlistDomains = []) {
const urlString = String(url || '');
try {
const hostname = new URL(urlString).hostname.toLowerCase();
if (allowlistDomains.some(domain => hostnameMatchesDomain(hostname, domain))) {
return false;
}
return blacklistDomains.some(domain => hostnameMatchesDomain(hostname, domain));
} catch {
const normalizedUrl = urlString.toLowerCase();
return blacklistDomains.some(domain => normalizedUrl.includes(String(domain).toLowerCase()));
}
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { isTabUrlFiltered } from './tab-filter.js';
const blacklist = ['google.com', 'mail.google.com'];
const allowlist = ['drive.google.com'];
describe('tab URL filtering', () => {
it('keeps Google Drive selectable despite the google.com parent-domain rule', () => {
expect(isTabUrlFiltered(
'https://drive.google.com/drive/u/1/search?q=mp4',
blacklist,
allowlist
)).toBe(false);
});
it('continues to filter other Google subdomains', () => {
expect(isTabUrlFiltered('https://mail.google.com/mail/u/0/', blacklist, allowlist)).toBe(true);
expect(isTabUrlFiltered('https://www.google.com/search?q=video', blacklist, allowlist)).toBe(true);
});
});
+783 -1254
View File
File diff suppressed because it is too large Load Diff
+17 -9
View File
@@ -1,9 +1,12 @@
{
"name": "koalasync",
"version": "2.6.0",
"version": "3.0.0",
"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",
@@ -15,17 +18,22 @@
"verify": "node scripts/verify-release.mjs"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@vitest/coverage-v8": "^4.1.9",
"@playwright/test": "^1.62.0",
"@vitest/coverage-v8": "^4.1.10",
"addons-linter": "^10.9.0",
"archiver": "^7.0.1",
"archiver": "^8.0.0",
"esbuild": "^0.28.1",
"eslint": "^10.4.0",
"eslint": "^10.8.0",
"eslint-plugin-no-unsanitized": "^4.1.5",
"fs-extra": "^11.2.0",
"fs-extra": "^11.4.0",
"htmlnano": "^3.4.0",
"sharp": "^0.34.5",
"svgo": "^4.0.1",
"vitest": "^4.1.9"
"sharp": "^0.35.3",
"svgo": "^4.0.2",
"vitest": "^4.1.10"
},
"overrides": {
"brace-expansion": "5.0.8",
"fast-uri": "3.1.4",
"postcss": "8.5.18"
}
}
+63 -11
View File
@@ -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);
});
}
+62
View File
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const [popupHtml, popupJs, overlayJs, backgroundJs] = await Promise.all([
readFile(path.join(repoRoot, 'extension', 'popup.html'), 'utf8'),
readFile(path.join(repoRoot, 'extension', 'popup.js'), 'utf8'),
readFile(path.join(repoRoot, 'extension', 'chat-overlay.js'), 'utf8'),
readFile(path.join(repoRoot, 'extension', 'background.js'), 'utf8')
]);
function detailsSection(marker) {
const markerIndex = popupHtml.indexOf(marker);
assert.notEqual(markerIndex, -1, `missing settings marker: ${marker}`);
const start = popupHtml.lastIndexOf('<details', markerIndex);
const end = popupHtml.indexOf('</details>', markerIndex);
assert.ok(start >= 0 && end > markerIndex, `invalid details section: ${marker}`);
return popupHtml.slice(start, end + '</details>'.length);
}
const chatSection = detailsSection('data-i18n="CHAT_TITLE"');
const syncSection = detailsSection('data-i18n="LABEL_SETTINGS_GROUP_SYNC"');
for (const id of ['chatEnabled', 'chatPosition', 'chatSize', 'chatStartMode']) {
assert.match(chatSection, new RegExp(`id="${id}"`), `${id} belongs in the dedicated chat settings section`);
}
assert.doesNotMatch(syncSection, /id="chatEnabled"/, 'chat enablement must not remain under Playback & Sync');
for (const value of ['right', 'left', 'detached']) {
assert.match(chatSection, new RegExp(`<option value="${value}"`), `chat position supports ${value}`);
}
for (const value of ['compact', 'standard', 'large', 'custom']) {
assert.match(chatSection, new RegExp(`<option value="${value}"`), `chat size supports ${value}`);
}
for (const value of ['bubble', 'open']) {
assert.match(chatSection, new RegExp(`<option value="${value}"`), `chat start mode supports ${value}`);
}
for (const key of ['chatEnabled', 'chatPosition', 'chatSize', 'chatStartMode']) {
assert.match(popupJs, new RegExp(`chrome\\.storage\\.local\\.set\\(\\{ ${key}`), `popup persists ${key}`);
assert.ok(backgroundJs.includes(`'${key}'`), `legacy sync purge includes ${key}`);
}
for (const key of ['chatPosition', 'chatSize', 'chatStartMode']) {
assert.ok(overlayJs.includes(`'${key}'`), `overlay reads ${key}`);
}
assert.match(backgroundJs, /chatEnabled:\s*data\.chatEnabled === true/, 'background reads chatEnabled with an off-by-default contract');
assert.match(overlayJs, /context\?\.enabled/, 'overlay consumes chat enablement from the validated background context');
assert.match(overlayJs, /SIZE_PRESETS[\s\S]*compact[\s\S]*standard[\s\S]*large/, 'overlay defines all size presets');
assert.match(
overlayJs,
/} else {\s*layout\.width = Number\(layout\.customWidth\) \|\| DEFAULT_WIDTH;\s*layout\.height = Number\(layout\.customHeight\) \|\| DEFAULT_HEIGHT;/,
'custom size restores its dedicated dimensions'
);
assert.match(overlayJs, /layout\.customWidth = rect\.width[\s\S]*layout\.customHeight = rect\.height/, 'detached resize updates the custom-size snapshot');
assert.match(overlayJs, /changes\.chatPosition[\s\S]*setMode/, 'position changes apply live');
assert.match(overlayJs, /changes\.chatSize[\s\S]*setSize/, 'size changes apply live');
assert.match(overlayJs, /changes\.chatStartMode[\s\S]*setOpened/, 'startup-mode changes apply live');
console.log('chat settings tests passed');
+26 -3
View File
@@ -98,14 +98,15 @@ function loadTimelineFns(hostname, document = makeDocument(), pageApiTime = null
].join('\n'))({ location: { hostname } }, document);
}
function loadPlayerFixFns(hostname) {
function loadPlayerFixFns(hostname, href = `https://${hostname}/`) {
return Function('window', [
extractFunction('hostMatchesUrl', source),
extractFunction('matchesPlayerUrls', source),
extractFunction('isGoogleDrivePlayerFrame', source),
extractFunction('getPlayerActionFixes', source),
extractFunction('getActivePlayerActionFix', source),
'return { getPlayerActionFixes, getActivePlayerActionFix };'
].join('\n'))({ location: { hostname } });
'return { isGoogleDrivePlayerFrame, getPlayerActionFixes, getActivePlayerActionFix };'
].join('\n'))({ location: { hostname, href } });
}
const disneyFns = loadTimelineFns('www.disneyplus.com', makeDocument(), {
@@ -147,6 +148,28 @@ const twitchFixFns = loadPlayerFixFns('player.twitch.tv');
assert.equal(twitchFixFns.getActivePlayerActionFix().name, 'twitch-player-buttons');
assert.deepEqual(twitchFixFns.getActivePlayerActionFix().urls, ['twitch.tv']);
const googleDriveFixFns = loadPlayerFixFns(
'youtube.googleapis.com',
'https://youtube.googleapis.com/embed/?origin=https%3A%2F%2Fdrive.google.com'
);
assert.equal(googleDriveFixFns.getActivePlayerActionFix().name, 'google-drive-player-buttons');
assert.deepEqual(googleDriveFixFns.getActivePlayerActionFix().urls, ['youtube.googleapis.com']);
const unrelatedGoogleEmbedFixFns = loadPlayerFixFns(
'youtube.googleapis.com',
'https://youtube.googleapis.com/embed/?origin=https%3A%2F%2Fexample.com'
);
assert.equal(unrelatedGoogleEmbedFixFns.getActivePlayerActionFix(), null);
const googleDrivePlayerFns = Function('window', [
extractFunction('isGoogleDrivePlayerFrame', source),
'return { isGoogleDrivePlayerFrame };'
].join('\n'))({
location: {
hostname: 'youtube.googleapis.com',
href: 'https://youtube.googleapis.com/embed/?origin=https%3A%2F%2Fdrive.google.com'
}
});
assert.equal(googleDrivePlayerFns.isGoogleDrivePlayerFrame(), true);
const genericFixFns = loadPlayerFixFns('example.com');
assert.equal(genericFixFns.getActivePlayerActionFix(), null);
+192
View File
@@ -0,0 +1,192 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { cwd } from 'node:process';
import {
HOST_ACCESS_REQUIRED_STATUS,
addTabHostAccessRequest,
containsOriginPermission,
describeTabUrl,
inspectTabHostAccess,
isHostAccessError,
normalizeTabId,
removeTabHostAccessRequest,
requestOriginPermission
} from '../extension/host-access.js';
assert.equal(HOST_ACCESS_REQUIRED_STATUS, 'host_permission_required');
assert.equal(normalizeTabId(null), null);
assert.equal(normalizeTabId(undefined), null);
assert.equal(normalizeTabId(''), null);
assert.equal(normalizeTabId(0), null);
assert.equal(normalizeTabId('42'), 42);
assert.equal(normalizeTabId(true), null);
assert.equal(normalizeTabId([42]), null);
assert.equal(normalizeTabId('42.5'), null);
assert.equal(normalizeTabId(' 42 '), 42);
assert.equal(normalizeTabId(Number.MAX_SAFE_INTEGER + 1), null);
assert.deepEqual(describeTabUrl('https://emby.example:8443/web/index.html'), {
url: 'https://emby.example:8443/web/index.html',
host: 'emby.example:8443',
originPattern: 'https://emby.example:8443/*'
});
assert.deepEqual(describeTabUrl('http://localhost:8096/web/'), {
url: 'http://localhost:8096/web/',
host: 'localhost:8096',
originPattern: 'http://localhost:8096/*'
});
assert.deepEqual(describeTabUrl('http://localhost:8096/web/', { includePort: false }), {
url: 'http://localhost:8096/web/',
host: 'localhost:8096',
originPattern: 'http://localhost/*'
});
assert.equal(describeTabUrl('chrome://extensions/'), null);
assert.equal(describeTabUrl('not a url'), null);
let containsRequest = null;
const deniedChrome = {
tabs: {
get: async tabId => ({ id: tabId, url: 'https://video.example/watch' })
},
permissions: {
contains: async request => {
containsRequest = request;
return false;
}
}
};
const access = await inspectTabHostAccess(deniedChrome, 42);
assert.equal(access.granted, false);
assert.equal(access.host, 'video.example');
assert.deepEqual(containsRequest, { origins: ['https://video.example/*'] });
let firefoxContainsRequest = null;
const firefoxChrome = {
runtime: { getBrowserInfo: async () => ({ name: 'Firefox' }) },
tabs: {
get: async tabId => ({
id: tabId,
url: 'http://localhost:8096/web/',
pendingUrl: 'https://different.example/loading'
})
},
permissions: {
contains: async request => {
firefoxContainsRequest = request;
return false;
}
}
};
const firefoxAccess = await inspectTabHostAccess(firefoxChrome, 42);
assert.equal(firefoxAccess.host, 'localhost:8096');
assert.equal(firefoxAccess.originPattern, 'http://localhost/*');
assert.deepEqual(firefoxContainsRequest, { origins: ['http://localhost/*'] });
const unknownPermissionChrome = {
runtime: {},
tabs: {
get: async tabId => ({ id: tabId, url: 'https://video.example/watch' })
},
permissions: {
contains: (_request, callback) => { callback(undefined); }
}
};
assert.equal((await inspectTabHostAccess(unknownPermissionChrome, 42)).granted, null);
let requestedTabId = null;
const requestChrome = {
permissions: {
addHostAccessRequest: async request => { requestedTabId = request; }
}
};
assert.equal(await addTabHostAccessRequest(requestChrome, 42, 'https://video.example/*'), true);
assert.deepEqual(requestedTabId, { tabId: 42, pattern: 'https://video.example/*' });
assert.equal(await addTabHostAccessRequest({ permissions: {} }, 42), false);
let removedTabId = null;
const removeRequestChrome = {
permissions: {
removeHostAccessRequest: async request => { removedTabId = request; }
}
};
assert.equal(await removeTabHostAccessRequest(removeRequestChrome, 42, 'https://video.example/*'), true);
assert.deepEqual(removedTabId, { tabId: 42, pattern: 'https://video.example/*' });
assert.equal(await removeTabHostAccessRequest({ permissions: {} }, 42), false);
assert.equal(isHostAccessError(new Error('Missing host permission for the tab')), true);
assert.equal(isHostAccessError(new Error('No tab with id: 42')), false);
const callbackPermissionChrome = {
runtime: {},
permissions: {
request: (_request, callback) => { callback(true); }
}
};
assert.equal(await requestOriginPermission(callbackPermissionChrome, 'https://video.example/*'), true);
assert.equal(await requestOriginPermission({ permissions: {} }, 'https://video.example/*'), null);
let containedOriginRequest = null;
assert.equal(await containsOriginPermission({
permissions: {
contains: async request => {
containedOriginRequest = request;
return true;
}
}
}, 'https://youtube.googleapis.com/*'), true);
assert.deepEqual(containedOriginRequest, { origins: ['https://youtube.googleapis.com/*'] });
assert.equal(await containsOriginPermission({ permissions: {} }, 'https://youtube.googleapis.com/*'), null);
const background = fs.readFileSync(path.join(cwd(), 'extension', 'background.js'), 'utf8');
const popup = fs.readFileSync(path.join(cwd(), 'extension', 'popup.js'), 'utf8');
const popupHtml = fs.readFileSync(path.join(cwd(), 'extension', 'popup.html'), 'utf8');
const tabManager = fs.readFileSync(path.join(cwd(), 'extension', 'modules', 'tab-manager.js'), 'utf8');
assert.match(background, /await activateTargetTab\(message\.tabId, message\.tabTitle\)/,
'SET_TARGET_TAB must await successful activation before acknowledging it');
assert.match(background, /addTabHostAccessRequest\(chrome, tabId, access\.originPattern\)/,
'failed injection must register Chrome host-access request');
assert.match(background, /retryPendingTarget\(\)/,
'pending target must resume after the user grants access');
assert.match(background, /activationGeneration !== targetActivationGeneration/,
'stale concurrent tab activations must not overwrite the newest selection');
assert.match(background, /pendingTargetRequestId/,
'pending access recovery must use an identity token');
assert.match(background, /addedOrigins\.includes\(pending\.originPattern\)/,
'unrelated permission grants must not activate a pending target');
assert.match(background, /isCurrentTargetIdentity\(tabId, targetGeneration\)/,
'stale content-routing retries must not reactivate an old target');
assert.match(background, /message\.expectedTabId/,
'popup playback events must be rejected after their target changes');
assert.match(background, /completeForceSyncBeforeTargetChange\(selectedTabId\)/,
'a target switch must finish an in-flight force sync on the old target');
assert.match(background, /FORCE_SYNC_ACK'[\s\S]*ignored_unselected_tab/,
'stale content scripts must not acknowledge force sync for a new target');
const activateTargetBody = background.slice(
background.indexOf('async function activateTargetTab'),
background.indexOf('async function retryPendingTarget')
);
assert.ok(
activateTargetBody.indexOf('await injectContentScript') < activateTargetBody.indexOf('currentTabId = selectedTabId'),
'a tab must not become current until its content script injection succeeds'
);
assert.match(background, /removeTabHostAccessRequest\([\s\S]*pendingTabId/,
'clearing a pending target must also clear Chrome toolbar access requests');
assert.match(popup, /response\?\.status === 'host_permission_required'/,
'popup must render the structured host-access failure');
assert.match(popup, /requestOriginPermission\(chrome, requestedOriginPattern\)/,
'retry button must request withheld host access directly');
assert.match(popup, /expectedCurrentTabId: tabId/,
'manual reinjection must be tied to the selected target identity');
assert.match(popup, /expectedTabId: tabId/,
'force sync must be tied to the tab whose time was sampled');
assert.doesNotMatch(tabManager, /injectContentScript/,
'tab reload recovery must use the guarded background activation path');
assert.equal(
(background.match(/tabs\.onRemoved\.addListener/g) || []).length
+ (tabManager.match(/tabs\.onRemoved\.addListener/g) || []).length,
1,
'target-tab closure must have exactly one state owner'
);
assert.match(popupHtml, /id="siteAccessNotice"/,
'popup must contain a persistent site-access notice');
console.log('host access recovery tests passed');
+69 -1
View File
@@ -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();
+33 -5
View File
@@ -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');
+2
View File
@@ -20,6 +20,8 @@ const checks = [
['content video finder', 'node', ['scripts/test-content-video-finder.cjs']],
['audio settings', 'node', ['scripts/test-audio-settings.mjs']],
['popup refresh cooldown', 'node', ['scripts/test-popup-refresh-cooldown.mjs']],
['chat settings', 'node', ['scripts/test-chat-settings.mjs']],
['host access recovery', 'node', ['scripts/test-host-access.mjs']],
['server syntax index', 'node', ['-c', 'server/index.js']],
['server syntax ops', 'node', ['-c', 'server/ops.js']],
['server syntax rate-limiter', 'node', ['-c', 'server/rate-limiter.js']],
+6 -5
View File
@@ -1,15 +1,16 @@
FROM node:20-alpine
FROM node:24-alpine
WORKDIR /app
# Copy shared protocol first
COPY shared/ ./shared/
COPY --chown=node:node shared/ ./shared/
# Copy server
COPY server/package*.json ./server/
RUN cd server && npm install --production
COPY server/ ./server/
COPY --chown=node:node server/package*.json ./server/
RUN cd server && npm ci --omit=dev
COPY --chown=node:node server/ ./server/
WORKDIR /app/server
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
+24
View File
@@ -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
};
}
+43
View File
@@ -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
View File
@@ -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);
+90 -51
View File
@@ -30,12 +30,12 @@
}
},
"node_modules/@types/node": {
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"version": "26.1.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.19.0"
"undici-types": "~8.3.0"
}
},
"node_modules/@types/ws": {
@@ -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",
@@ -347,9 +360,9 @@
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -526,9 +539,9 @@
}
},
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -558,9 +571,9 @@
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
@@ -604,12 +617,16 @@
}
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
"integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/merge-descriptors": {
@@ -739,12 +756,13 @@
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
@@ -754,12 +772,16 @@
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
"integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/raw-body": {
@@ -851,14 +873,14 @@
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
@@ -951,9 +973,9 @@
}
},
"node_modules/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"version": "4.2.7",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz",
"integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
@@ -1025,23 +1047,40 @@
}
},
"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": {
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"license": "MIT"
},
"node_modules/unpipe": {
@@ -1069,9 +1108,9 @@
"license": "ISC"
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
+21
View File
@@ -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();
+30
View File
@@ -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);
+6
View File
@@ -178,3 +178,9 @@ export const BLACKLIST_DOMAINS = [
'lichess.org',
'skribbl.io'
];
// Explicit exceptions for useful media pages whose parent domain is filtered.
// Google Drive is otherwise matched indirectly by the broad `google.com` entry.
export const BLACKLIST_ALLOWLIST_DOMAINS = [
'drive.google.com'
];
+7 -2
View File
@@ -7,7 +7,7 @@
*/
export const PROTOCOL_VERSION = "1.0.0";
export const APP_VERSION = "2.6.0";
export const APP_VERSION = "3.0.0";
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
+77
View File
@@ -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);
+58
View File
@@ -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
View File
@@ -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
View File
@@ -135,7 +135,7 @@
<div class="container">
<p>&copy; 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>
+32 -27
View File
@@ -1,6 +1,14 @@
// KoalaSync Landing Page Logic
document.addEventListener('DOMContentLoaded', () => {
const siteAccessBanner = document.querySelector('.site-access-banner');
const siteAccessBannerDismiss = document.querySelector('.site-access-banner-dismiss');
if (siteAccessBanner && siteAccessBannerDismiss) {
siteAccessBannerDismiss.addEventListener('click', () => {
siteAccessBanner.hidden = true;
});
}
// Mockup Video Title Randomization on Load
const SERIES_NAMES = [
'Stranger Things',
@@ -449,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;
@@ -519,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');
@@ -556,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

+107 -32
View File
@@ -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');
@@ -235,6 +236,7 @@ async function compile() {
'styles/hero.css',
'styles/landing-primary.css',
'styles/legal.css',
'styles/support.css',
'styles/landing-controls.css',
'styles/join-spinner.css',
'styles/landing-sections.css',
@@ -284,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);
@@ -298,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 ──
@@ -314,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 ──
@@ -514,6 +524,7 @@ async function compile() {
{ src: 'join.html', dest: 'join.html' },
{ src: 'imprint.html', dest: 'imprint.html' },
{ src: 'privacy.html', dest: 'privacy.html' },
{ src: 'site-access-help.html', dest: 'site-access-help.html' },
{ src: 'impressum-de.html', dest: 'de/impressum.html' },
{ src: 'datenschutz-de.html', dest: 'de/datenschutz.html' },
{ src: 'impressum.html', dest: 'impressum.html' },
@@ -539,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);
@@ -652,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"`;
});
@@ -684,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' },
@@ -703,46 +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>
<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) {
@@ -757,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); });
+1 -1
View File
@@ -297,7 +297,7 @@
<p>&copy; 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
View File
@@ -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>&copy; 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
View File
@@ -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
View File
@@ -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>&copy; 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
View File
@@ -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
View File
@@ -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: 3.0.0
- 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.
+3
View File
@@ -14,6 +14,9 @@
"TWITTER_DESCRIPTION": "Starte eine Watch Party auf Netflix, Emby, Jellyfin, YouTube, Twitch und fast jedem HTML5-Video. Open Source, datenschutzfreundlich und ohne Konto.",
"NAV_FEATURES": "Funktionen",
"NAV_HOW_IT_WORKS": "So funktioniert's",
"SUPPORT_BANNER_TEXT": "KoalaSync kann nicht auf deine Videoseite zugreifen?",
"SUPPORT_BANNER_CTA": "Websitezugriff reparieren",
"SUPPORT_BANNER_DISMISS": "Hinweis schließen",
"HERO_TITLE": "Mach jedes Video zur <span class='hero-highlight'>Watch Party</span>.",
"HERO_SUBTITLE": "Dein Kino-Abend auf Distanz, ohne Lags. Kein Account, kein Tracking, keine Werbung. Einfach Link teilen und gemeinsam Play drücken.",
"HERO_MASCOT_ALT": "Ein niedlicher Koala steht da und schaut nach unten auf die Download-Buttons",
+3
View File
@@ -14,6 +14,9 @@
"TWITTER_DESCRIPTION": "Host a watch party on Netflix, Emby, Jellyfin, YouTube, Twitch and almost any HTML5 video. Open source, privacy-first, and account-free.",
"NAV_FEATURES": "Features",
"NAV_HOW_IT_WORKS": "How it works",
"SUPPORT_BANNER_TEXT": "KoalaSync cannot access your video site?",
"SUPPORT_BANNER_CTA": "Fix website access",
"SUPPORT_BANNER_DISMISS": "Dismiss notification",
"HERO_TITLE": "Turn any video into a <span class='hero-highlight'>watch party</span>.",
"HERO_SUBTITLE": "Your remote movie night without lags. No account, no tracking, no ads. Just share a link and press play together.",
"HERO_MASCOT_ALT": "A cute koala standing and looking down at the download buttons",
+3
View File
@@ -14,6 +14,9 @@
"TWITTER_DESCRIPTION": "Organiza una watch party en Netflix, Emby, Jellyfin, YouTube, Twitch y casi cualquier vídeo HTML5. Open source, privada y sin cuenta.",
"NAV_FEATURES": "Características",
"NAV_HOW_IT_WORKS": "Cómo funciona",
"SUPPORT_BANNER_TEXT": "¿KoalaSync no puede acceder al sitio del vídeo?",
"SUPPORT_BANNER_CTA": "Reparar acceso al sitio",
"SUPPORT_BANNER_DISMISS": "Cerrar aviso",
"HERO_TITLE": "Convierte cualquier vídeo en una <span class='hero-highlight'>watch party</span>.",
"HERO_SUBTITLE": "Tu noche de cine a distancia, sin retrasos. Sin cuenta, sin rastreo, sin anuncios. Comparte un enlace y dale al play juntos.",
"HERO_MASCOT_ALT": "Un lindo koala de pie mirando hacia abajo a los botones de descarga",
+3
View File
@@ -14,6 +14,9 @@
"TWITTER_DESCRIPTION": "Organisez une watch party sur Netflix, Emby, Jellyfin, YouTube, Twitch et presque toute vidéo HTML5. Open source, privé et sans compte.",
"NAV_FEATURES": "Fonctionnalités",
"NAV_HOW_IT_WORKS": "Comment ça marche",
"SUPPORT_BANNER_TEXT": "KoalaSync naccède pas à votre site vidéo ?",
"SUPPORT_BANNER_CTA": "Réparer laccès au site",
"SUPPORT_BANNER_DISMISS": "Fermer lavertissement",
"HERO_TITLE": "Transformez n'importe quelle vidéo en <span class='hero-highlight'>watch party</span>.",
"HERO_SUBTITLE": "Votre soirée ciné à distance, sans décalage. Pas de compte, pas de tracking, pas de pub. Partagez un lien et lancez la lecture ensemble.",
"HERO_MASCOT_ALT": "Un koala mignon debout et regardant vers le bas les boutons de téléchargement",
+3
View File
@@ -14,6 +14,9 @@
"TWITTER_DESCRIPTION": "Avvia una watch party su Netflix, Emby, Jellyfin, YouTube, Twitch e quasi ogni video HTML5. Open source, privata e senza account.",
"NAV_FEATURES": "Funzionalità",
"NAV_HOW_IT_WORKS": "Come Funziona",
"SUPPORT_BANNER_TEXT": "KoalaSync non riesce ad accedere al sito video?",
"SUPPORT_BANNER_CTA": "Ripristina laccesso al sito",
"SUPPORT_BANNER_DISMISS": "Chiudi avviso",
"HERO_TITLE": "Trasforma qualsiasi video in una <span class='hero-highlight'>watch party</span>.",
"HERO_SUBTITLE": "La tua serata film a distanza, senza lag. Niente account, niente tracciamento, niente pubblicità. Condividi un link e premete play insieme.",
"HERO_MASCOT_ALT": "Un simpatico koala che guarda i pulsanti di download",
+3
View File
@@ -14,6 +14,9 @@
"TWITTER_DESCRIPTION": "Netflix、Emby、Jellyfin、YouTube、Twitch、ほぼすべてのHTML5動画でウォッチパーティーを開催。オープンソース、プライバシー重視、アカウント不要。",
"NAV_FEATURES": "機能",
"NAV_HOW_IT_WORKS": "仕組み",
"SUPPORT_BANNER_TEXT": "KoalaSync が動画サイトにアクセスできませんか?",
"SUPPORT_BANNER_CTA": "サイトアクセスを修復",
"SUPPORT_BANNER_DISMISS": "通知を閉じる",
"HERO_TITLE": "どんな動画も<span class='hero-highlight'>ウォッチパーティー</span>に。",
"HERO_SUBTITLE": "離れていても一緒に映画ナイト。アカウント不要、トラッキングなし、広告なし。リンクを共有して、一緒に再生するだけ。",
"HERO_MASCOT_ALT": "ダウンロードボタンを見下ろしているかわいいコアラ",
+3
View File
@@ -14,6 +14,9 @@
"TWITTER_DESCRIPTION": "Netflix, Emby, Jellyfin, YouTube, Twitch 및 거의 모든 HTML5 영상에서 워치 파티를 열어보세요. 오픈소스, 개인정보 보호 중심, 계정 불필요.",
"NAV_FEATURES": "특징",
"NAV_HOW_IT_WORKS": "작동 방식",
"SUPPORT_BANNER_TEXT": "KoalaSync가 동영상 사이트에 액세스할 수 없나요?",
"SUPPORT_BANNER_CTA": "사이트 액세스 복구",
"SUPPORT_BANNER_DISMISS": "알림 닫기",
"HERO_TITLE": "어떤 영상이든 <span class='hero-highlight'>워치 파티</span>로.",
"HERO_SUBTITLE": "지연 없는 원격 영화의 밤. 계정 없음, 추적 없음, 광고 없음. 링크만 공유하고 함께 재생하세요.",
"HERO_MASCOT_ALT": "다운로드 버튼을 내려다보고 서 있는 귀여운 코알라",

Some files were not shown because too many files have changed in this diff Show More