mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 20:18:14 +00:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a0954079a1 | |||
| 9251ae6aff | |||
| 511e042b0b | |||
| 372c384a19 | |||
| 79715046a8 | |||
| 2ae79386a0 | |||
| 883e49433e | |||
| 59135b319e | |||
| ae2238e3bc | |||
| 77790a279c | |||
| 2df048621b | |||
| 17c3ea295a | |||
| 5d3cee4271 | |||
| 1149de7ab7 | |||
| 4882e058f1 | |||
| 8d7085c97e | |||
| 630869260b | |||
| e9d735cd39 | |||
| aee3588f43 | |||
| 7f898e6eb4 | |||
| d2d4f5f3ea | |||
| 6e625e3a38 | |||
| 6aa2f53e6b | |||
| 2b485fed53 | |||
| fe13275b5d | |||
| 61d0d64c7d | |||
| abcb10a1c3 | |||
| 03121477a8 | |||
| 285c04e00d | |||
| 1989eb639b | |||
| fd48ce8481 | |||
| 649c8796a9 | |||
| 142153a131 | |||
| a334605dd5 | |||
| 7e7e60f267 | |||
| 354500873c | |||
| 9e0d294758 | |||
| 36c4c492d3 | |||
| e9dc299fc9 | |||
| ee0d68d8d3 | |||
| f2eee6dc41 | |||
| 8fee98834c | |||
| 771da1cf82 | |||
| ab2d7747fd | |||
| 8f9676cc5a | |||
| 477a814a79 |
@@ -6,9 +6,9 @@ name: Beta Server Image
|
||||
#
|
||||
# Tags produced (on ghcr.io/<owner>/<repo>):
|
||||
# - beta moving channel pointer to the newest build
|
||||
# - <branch-slug> e.g. feature-host-control-mode
|
||||
# - <branch-slug> e.g. feature-textchat
|
||||
# - sha-<short-commit> immutable, pin to an exact build
|
||||
# - <custom> only on manual run, e.g. hostcontrol01
|
||||
# - <custom> only on manual run, e.g. chatbeta01
|
||||
# Never ':latest' (flavor: latest=false).
|
||||
#
|
||||
# Remove this workflow once the feature is merged & released the normal way.
|
||||
@@ -16,11 +16,11 @@ name: Beta Server Image
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- feature/host-control-mode
|
||||
- feature/textchat
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Extra immutable tag for this build (e.g. hostcontrol01). Optional.'
|
||||
description: 'Extra immutable tag for this build (e.g. chatbeta01). Optional.'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
@@ -37,6 +37,27 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
package-lock.json
|
||||
server/package-lock.json
|
||||
|
||||
- name: Install root dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install server dependencies
|
||||
run: npm ci
|
||||
working-directory: server
|
||||
|
||||
- name: Run verification suite
|
||||
run: npm run verify
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
@@ -23,6 +23,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -72,6 +72,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -109,7 +111,11 @@ jobs:
|
||||
sed -i "s/\"softwareVersion\": \".*\"/\"softwareVersion\": \"$VERSION\"/" website/template.html
|
||||
echo " ✓ website/template.html -> softwareVersion $VERSION"
|
||||
|
||||
# 6. README.md — version badge & banner
|
||||
# 6. website/llms.txt — machine-readable release metadata
|
||||
sed -i "s/Current website release: .*/Current website release: $VERSION/" website/llms.txt
|
||||
echo " ✓ website/llms.txt -> $VERSION"
|
||||
|
||||
# 7. README.md — version badge & banner
|
||||
sed -i "s|Release-v[0-9]\+\.[0-9]\+\.[0-9]\+-blue|Release-v$VERSION-blue|g" README.md
|
||||
sed -i "s/New v[0-9]\+\.[0-9]\+\.[0-9]\+ Release/New v$VERSION Release/g" README.md
|
||||
echo " ✓ README.md -> v$VERSION"
|
||||
@@ -120,7 +126,7 @@ jobs:
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add extension/manifest.base.json shared/constants.js package.json website/version.json website/template.html README.md
|
||||
git add extension/manifest.base.json shared/constants.js package.json website/version.json website/template.html website/llms.txt README.md
|
||||
git commit -m "chore(release): update versions to $GITHUB_REF_NAME [skip ci]" || echo "No changes to commit"
|
||||
git push origin HEAD:main
|
||||
env:
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ Please note that by participating in this project, you agree to abide by our [Co
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js** v18+
|
||||
- **Node.js** v20.9+
|
||||
- **Docker** (for local relay server testing)
|
||||
|
||||
### Quick Start
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml"><img src="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml/badge.svg" alt="Release Status"></a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v2.6.0-blue?logo=github" alt="GitHub release"></a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v2.6.4-blue?logo=github" alt="GitHub release"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue" alt="License"></a>
|
||||
<a href="https://addons.mozilla.org/de/firefox/addon/koalasync/"><img src="https://img.shields.io/badge/Firefox-Download-orange?logo=firefoxbrowser&logoColor=white" alt="Firefox Add-on"></a>
|
||||
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc"><img src="https://img.shields.io/badge/Chrome-Download-blue?logo=googlechrome&logoColor=white" alt="Chrome Extension"></a>
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<p align="center"><i>KoalaSync is a lightweight Browser Extension and Relay Server for synchronized video playback on almost any website with a video element—YouTube, Twitch, Netflix, Emby, Jellyfin, and beyond. Built with a focus on <b>Data Sovereignty</b> and <b>Performance</b>.</i></p>
|
||||
|
||||
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.6.0 Release!</b> — See what's changed</a></p>
|
||||
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.6.4 Release!</b> — See what's changed</a></p>
|
||||
|
||||
### 🌟 Why KoalaSync?
|
||||
|
||||
|
||||
@@ -4,6 +4,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
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# Ephemeral end-to-end encrypted chat
|
||||
|
||||
KoalaSync chat is an optional, live-only text channel rendered on the selected
|
||||
streaming tab. It is not a messenger and does not add chat UI to the extension
|
||||
popup.
|
||||
|
||||
## Security boundary
|
||||
|
||||
- The relay receives ciphertext only and never receives the chat secret.
|
||||
- The relay stores no messages in RAM or on disk and sends no backlog.
|
||||
- A late joiner sees only messages sent after joining.
|
||||
- Room authentication is unchanged. The room password remains separate from the
|
||||
chat secret and continues to be sent to the relay during `join_room`.
|
||||
- Message timing and ciphertext length remain visible to the relay. Same-room
|
||||
replay is accepted by the threat model.
|
||||
|
||||
## Invite format and key lifecycle
|
||||
|
||||
New invitations use a named fragment format:
|
||||
|
||||
```text
|
||||
#j2:r=<roomId>&p=<password>&k=<base64url-secret>[&u=<relayUrl>]
|
||||
```
|
||||
|
||||
The room creator generates 16 random bytes and encodes them as 22 unpadded
|
||||
base64url characters. URL fragments are not sent in HTTP requests. The website
|
||||
parses the fragment and passes structured fields through `bridge.js` to the
|
||||
extension. `chatKey` must never be included in a relay event.
|
||||
|
||||
Legacy `#join:` links remain supported and join normally without chat. Manual
|
||||
room/password entry also joins without chat. The extension clears any prior chat
|
||||
secret when joining without a key, switching rooms, or leaving.
|
||||
|
||||
Deployment order is website first, extension second. An old extension ignores the
|
||||
additional structured `chatKey` field and continues joining normally.
|
||||
|
||||
## Cryptography
|
||||
|
||||
- Secret: 16 cryptographically random bytes.
|
||||
- KDF: HKDF-SHA256 with `roomId` as salt and a fixed KoalaSync chat info label.
|
||||
- Encryption: AES-256-GCM using WebCrypto.
|
||||
- IV: fresh random 12-byte value for every message, prepended to the ciphertext.
|
||||
- AAD: `${roomId}|${senderId}`.
|
||||
- The derived `CryptoKey` is cached once per active room.
|
||||
|
||||
The relay stamps `id`, `senderId`, and `timestamp` on the ciphertext envelope.
|
||||
Changing `senderId` causes AES-GCM authentication to fail because the receiver uses
|
||||
the stamped value as AAD.
|
||||
|
||||
## Client policy
|
||||
|
||||
- Maximum plaintext length: 500 Unicode code points.
|
||||
- Decrypted text is untrusted. Escape HTML before applying the supported limited
|
||||
Markdown formatting.
|
||||
- No read receipts and no typing indicators.
|
||||
- The local message DOM is bounded; this is presentation state, not server history.
|
||||
|
||||
## Overlay behavior
|
||||
|
||||
- Default dock: right.
|
||||
- Live modes: right, left, and detached.
|
||||
- Detached mode is draggable and moderately resizable. Size and position are stored
|
||||
per origin and clamped to the current viewport.
|
||||
- The overlay uses a Shadow DOM and never changes host-page layout.
|
||||
- On `fullscreenchange`, its host moves into `document.fullscreenElement` and remains
|
||||
visible.
|
||||
- It follows all eucalyptus, cyber, and graphite light/dark theme combinations.
|
||||
- Without a key, the panel stays closed and a disabled chat control explains that a
|
||||
current invite link is required.
|
||||
- Chat display is a local option and defaults to off. Enabling or disabling it never
|
||||
deletes the room chat secret, so it can be enabled later without creating a room.
|
||||
- Without the relay `chat` capability, no chat control is shown.
|
||||
|
||||
## Mixed-version rollout
|
||||
|
||||
- New extensions announce `chat-v1` in `join_room.clientCapabilities`.
|
||||
- Old non-chat extensions omit the optional field and receive no `chat_message`
|
||||
events. Their playback and room protocol remains unchanged.
|
||||
- The first chat beta omitted the capability. When it sends one valid v1 chat
|
||||
frame, the relay treats that socket as chat-capable for the rest of the
|
||||
connection.
|
||||
- New extensions accept the first beta's unversioned server `chat` capability as
|
||||
well as `chat-v1`, so a server-first or extension-first rollout degrades safely.
|
||||
|
||||
Peer removal and role management are outside chat scope.
|
||||
+60
-1
@@ -11,6 +11,19 @@ the event names defined in `shared/constants.js`.
|
||||
object payload.
|
||||
- The relay caps incoming Socket.IO message size at 4 KB.
|
||||
|
||||
## Invite fragments
|
||||
|
||||
Current invitations use named URL-fragment fields:
|
||||
|
||||
```text
|
||||
#j2:r=<roomId>&p=<password>&k=<base64url-chat-secret>[&u=<relayUrl>]
|
||||
```
|
||||
|
||||
The fragment is parsed by the website and forwarded to the extension as structured
|
||||
fields. `k` is client-only and must never appear in a relay payload. Legacy
|
||||
`#join:<roomId>:<password>[:1:<relayUrl>]` fragments remain valid but provide no chat
|
||||
secret.
|
||||
|
||||
## Connection Handshake
|
||||
|
||||
The Socket.IO handshake must include:
|
||||
@@ -41,6 +54,7 @@ Payload:
|
||||
"password": "string, max 128, optional",
|
||||
"tabTitle": "string, max 100, optional",
|
||||
"mediaTitle": "string, max 100, optional",
|
||||
"clientCapabilities": ["chat-v1"],
|
||||
"protocolVersion": "string, max 16"
|
||||
}
|
||||
```
|
||||
@@ -69,13 +83,56 @@ Payload:
|
||||
"hostPeerId": "string or null",
|
||||
"controlMode": "everyone | host-only",
|
||||
"controllers": ["peerId"],
|
||||
"capabilities": ["host-control", "co-host"]
|
||||
"capabilities": ["host-control", "co-host", "chat", "chat-v1"]
|
||||
}
|
||||
```
|
||||
|
||||
`room_data` is sent to the joining socket. It is not the general broadcast used
|
||||
for every later room update.
|
||||
|
||||
## Ephemeral encrypted chat
|
||||
|
||||
Relays advertise chat support with `"chat-v1"` in `room_data.capabilities` and keep
|
||||
the initial beta's `"chat"` flag during the transition. New clients announce
|
||||
`"chat-v1"` in optional `join_room.clientCapabilities` (at most the first 16 entries
|
||||
are inspected; unknown or malformed values are ignored). Old clients omit the field
|
||||
and continue using the pre-chat protocol unchanged.
|
||||
|
||||
The relay sends `chat_message` only to sockets that announced `"chat-v1"`. As a
|
||||
transition for the first chat beta, a socket that sends a valid v1 ciphertext is
|
||||
marked capable for the rest of that connection. This prevents old non-chat
|
||||
extensions from receiving unknown events while preserving the first beta's send
|
||||
path.
|
||||
|
||||
### `chat_message`
|
||||
|
||||
Client to relay:
|
||||
|
||||
```json
|
||||
{ "ciphertext": "<unpadded-base64url>" }
|
||||
```
|
||||
|
||||
`ciphertext` contains a 12-byte AES-GCM IV followed by ciphertext and the 16-byte
|
||||
authentication tag. The relay validates only canonical base64url and byte bounds.
|
||||
It cannot inspect plaintext.
|
||||
|
||||
Relay to every chat-capable current room peer, including the sender:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "<server-generated UUID>",
|
||||
"senderId": "<server-stamped peer ID>",
|
||||
"timestamp": 1710000000000,
|
||||
"ciphertext": "<unpadded-base64url>"
|
||||
}
|
||||
```
|
||||
|
||||
Client-provided `id`, `senderId`, `timestamp`, or plaintext fields are discarded.
|
||||
The relay keeps no message collection and `room_data` contains no chat history.
|
||||
Messages are limited to 10 per socket per 10 seconds in addition to the global event
|
||||
budget. There are no typing, read-receipt, history, or chat-specific peer-management
|
||||
events.
|
||||
|
||||
## Room Leave
|
||||
|
||||
### `leave_room` (client -> server)
|
||||
@@ -365,5 +422,7 @@ If sender and target are still in the same room, the relay emits:
|
||||
|
||||
- `host-control`
|
||||
- `co-host`
|
||||
- `chat`
|
||||
- `chat-v1`
|
||||
|
||||
Clients should treat a missing or unknown capabilities list as unsupported.
|
||||
|
||||
+77
-11
@@ -30,6 +30,83 @@
|
||||
|
||||
*Prioritized for upcoming phases.*
|
||||
|
||||
### v3.0 — Modern Room Chat
|
||||
|
||||
- **Release date:** Not scheduled
|
||||
- **Priority:** P1
|
||||
- **Category:** Social / Communication / Privacy
|
||||
- **Status:** Planned. This is a roadmap target, not a release announcement.
|
||||
- **Existing foundation:** Opt-in, live-only end-to-end encrypted room chat; a floating
|
||||
chat bubble over the selected player opens a dockable, detachable, resizable overlay.
|
||||
The relay stores no chat history and mixed extension versions use capability-gated
|
||||
delivery.
|
||||
|
||||
#### Core experience
|
||||
|
||||
- Floating bubble with an accessible unread badge (`1`, `2`, `3`, …, `99+`), cleared
|
||||
only when the chat is actually viewed.
|
||||
- Subtle new-message pulse with reduced-motion support; no animation or sound while
|
||||
muted or during Do Not Disturb.
|
||||
- Drag-and-snap bubble positioning, remembered per site, with reset-to-default.
|
||||
- Dock left/right, detached overlay, compact mode, fullscreen-safe placement, responsive
|
||||
narrow-player layout, and keyboard-only operation.
|
||||
- Per-room mute, mention-only mode, notification sound controls, browser notifications,
|
||||
and a visible mute state.
|
||||
- Unread state survives overlay close/reopen and tab focus changes, but room messages
|
||||
remain memory-only unless a future storage option is explicitly enabled.
|
||||
|
||||
#### Messaging
|
||||
|
||||
- Replies with quoted context, emoji reactions, emoji picker, mentions, and typing
|
||||
indicators.
|
||||
- Edit and delete own messages with clear local tombstones; no silent mutation.
|
||||
- Delivery states (`sending`, `sent`, `failed`, `retry`) with idempotent retries and
|
||||
duplicate suppression after reconnects.
|
||||
- Message timestamps, date separators, jump-to-latest, unread divider, search within
|
||||
the current live session, copy, and selectable text.
|
||||
- Link detection with safe external opening, explicit scheme allowlist, and no automatic
|
||||
previews or remote-media loading.
|
||||
- Optional image/GIF/file sharing only after encrypted size limits, malware-risk UX,
|
||||
relay bandwidth limits, and privacy behavior are designed and tested.
|
||||
|
||||
#### Presence, safety, and control
|
||||
|
||||
- Online/reconnecting status, join/leave events, and room-member presence without
|
||||
exposing IP addresses or cross-room identity.
|
||||
- Local mute/block, host moderation controls, slow mode, spam limits, and configurable
|
||||
maximum message length.
|
||||
- Optional read receipts and activity indicators default off; never infer or expose
|
||||
viewing behavior without explicit consent.
|
||||
- Clear live-only/no-history explanation, encrypted-chat indicator, room-key fingerprint,
|
||||
and key-rotation/rejoin UX.
|
||||
|
||||
#### Reliability and compatibility
|
||||
|
||||
- Versioned chat capabilities for every new wire feature; unknown fields/events remain
|
||||
harmless to older extensions and old non-chat clients receive no chat traffic.
|
||||
- Server-first and extension-first rollout tests, including current, first-chat-beta,
|
||||
old non-chat, reconnect, duplicate-frame, and malformed-capability clients.
|
||||
- Bounded queues, payloads, unread counters, typing events, reactions, and attachment
|
||||
metadata; backpressure and rate limits on both client and relay.
|
||||
- Real Chrome and Firefox extension E2E coverage for bubble, unread count, overlay,
|
||||
fullscreen, reconnect, and mixed-version rooms before v3.0 can ship.
|
||||
|
||||
#### Accessibility and localization
|
||||
|
||||
- Screen-reader announcements that do not read every busy-room message by default.
|
||||
- Complete focus management, logical tab order, Escape behavior, high contrast,
|
||||
zoom/reflow, reduced motion, and touch target coverage.
|
||||
- All chat UI, notification text, moderation states, and errors localized across every
|
||||
supported locale before release.
|
||||
|
||||
#### Explicit non-goals for the first v3.0 release
|
||||
|
||||
- No server-readable plaintext.
|
||||
- No mandatory accounts, public room directory, advertising, tracking, or presence
|
||||
across rooms.
|
||||
- No unencrypted server-side history. Any future encrypted history requires a separate
|
||||
threat model, retention controls, export/delete behavior, and explicit opt-in.
|
||||
|
||||
### 2. Invite link with target URL for auto-redirect
|
||||
|
||||
- **Priority:** P2
|
||||
@@ -49,17 +126,6 @@
|
||||
|
||||
*Ideas and feature requests under evaluation.*
|
||||
|
||||
### In-room chat overlay (like TeleParty)
|
||||
|
||||
- **Priority:** P3
|
||||
- **Category:** Social / Communication
|
||||
- **Background:** A collapsible chat panel to the right of the video (or as an overlay) allowing text-based communication with everyone in the room.
|
||||
- **Why backlog (still uncertain):**
|
||||
- **Use case:** No strong personal need — with chat, latency matters less than with voice; async communication tolerates a few seconds of delay.
|
||||
- **Complexity:** Relatively large feature (UI + message persistence + possibly history).
|
||||
- **Legal/moderation:** Unclear what moderation requirements would apply if users can exchange chat messages. Could be relevant depending on jurisdiction.
|
||||
- **Status:** Under evaluation, may come later.
|
||||
|
||||
### Cross-frame video detection and control
|
||||
|
||||
- **Priority:** P3
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
# E2E-verschlüsselter In-Page-Chat für KoalaSync
|
||||
|
||||
## Auftrag
|
||||
|
||||
Baue einen privacy-first, Ende-zu-Ende-verschlüsselten Text-Chat, der direkt auf der
|
||||
Streaming-Seite neben dem Video sitzt. Neuer Branch von `main`, Vorschlag
|
||||
`feature/chat-e2e`.
|
||||
|
||||
### Harte Anforderungen
|
||||
|
||||
1. **Der Server darf Nachrichten nie lesen können.** Schlüssel nur clientseitig, der
|
||||
Relay sieht ausschließlich Ciphertext.
|
||||
2. **Der Server speichert keine Nachrichten.** Reines Live-Relay, kein RAM-Backlog,
|
||||
keine History. Wer zu spät kommt, verpasst den bisherigen Chat. Das ist gewollt.
|
||||
3. **Chat läuft auf der Streaming-Seite**, nicht im Extension-Popup.
|
||||
4. **Themes werden respektiert** (3 Paletten x light/dark).
|
||||
5. **Keine Read Receipts.** Bewusst gestrichen.
|
||||
6. **Volle Abwärtskompatibilität**: alte Extensions müssen weiter normal joinen können.
|
||||
|
||||
### Bedrohungsmodell (bestimmt alle Krypto-Entscheidungen)
|
||||
|
||||
Das hier ist eine Video-Sync-Extension mit optionalem, flüchtigem Chat, **keine
|
||||
sicherheitskritische Messenger-App**. Ziel ist: ein bösartiger Betreiber eines
|
||||
Custom-Relays soll nicht einfach mitlesen können. Ob etwas mit sehr viel Aufwand
|
||||
theoretisch knackbar wäre, ist egal.
|
||||
|
||||
Daraus folgt, und das ist bindend:
|
||||
- **Usability und Performance haben Vorrang** vor kryptografischer Maximalhärte.
|
||||
- Raum-Passwörter sind kurz, zufällig generiert (z.B. `0XUK3C`) und ständig neu. Wer
|
||||
eines knackt, kann trollen (`play`/`pause` senden). Nervig, nicht kritisch. Deshalb
|
||||
nutzt der Server bewusst simples SHA256/HMAC statt bcrypt. **Diese Linie beibehalten,
|
||||
nicht "verbessern".**
|
||||
- Der globale `SERVER_SALT` ist bekannt und kein Issue. Räume sind flüchtig, es gibt
|
||||
keine Datenbank.
|
||||
|
||||
### Explizite Nicht-Ziele
|
||||
|
||||
- Kein Chat-Tab im Popup. Nicht als Fallback, nicht übergangsweise.
|
||||
- Keine serverseitige `chatHistory` in irgendeiner Form.
|
||||
- **Die Auth nicht anfassen.** Das Klartext-Passwort an den Relay ist bewusst so und
|
||||
wird nicht umgebaut (Begründung unten).
|
||||
- `website/` Theme-Redesign auf `main` nicht anfassen.
|
||||
|
||||
---
|
||||
|
||||
## Krypto-Design (entschieden, nicht neu diskutieren)
|
||||
|
||||
### Woher der Schlüssel kommt
|
||||
|
||||
Das Raum-Passwort ist **unbrauchbar** als Schlüsselmaterial: der Client sendet es im
|
||||
Klartext an den Relay (`join_room` -> `payload.password`, `server/index.js:349`), der
|
||||
Server HMACt es erst selbst (`hashPassword`, Zeile 52-55). Der Server könnte jeden
|
||||
daraus abgeleiteten Schlüssel mitberechnen.
|
||||
|
||||
Es zu ändern würde auch nichts bringen: Raum-Passwörter haben ~31 Bit Entropie
|
||||
(6 Zeichen A-Z0-9). Ein bösartiger Server könnte sie offline durchprobieren und den
|
||||
Chat-Schlüssel ableiten.
|
||||
|
||||
**Lösung: ein eigenes, zufälliges Secret im URL-Fragment.** Fragmente werden nie an
|
||||
einen Server gesendet, und das Secret hat volle Entropie per Konstruktion. Der
|
||||
Einladungslink transportiert bereits heute das Passwort im Fragment, der Mechanismus
|
||||
existiert also schon.
|
||||
|
||||
### Primitive
|
||||
|
||||
- **16 zufällige Bytes** (128 Bit), base64url, 22 Zeichen. Beispiel:
|
||||
`R5Ti1nxp0crfAFHf3gVncw`
|
||||
- Ableitung: **HKDF-SHA256**(secret, salt=roomId) -> AES-256-GCM-Key.
|
||||
**Kein PBKDF2, kein Stretching.** Slow KDFs existieren nur, um schwache
|
||||
Menschen-Passwörter zu strecken. Das Secret ist zufällig mit voller Entropie, HKDF
|
||||
ist ein einziger HMAC (Mikrosekunden). Das ist dieselbe Logik wie sha256-statt-bcrypt
|
||||
beim Server.
|
||||
- **Key genau einmal pro Raum ableiten und den `CryptoKey` cachen.** Nicht pro
|
||||
Nachricht neu ableiten oder importieren.
|
||||
- Pro Nachricht: **zufälliger 12-Byte-IV**, dem Ciphertext vorangestellt.
|
||||
- Alles über **WebCrypto**, keine Fremdabhängigkeit.
|
||||
|
||||
### AAD: senderId gegen Umetikettierung binden (Pflicht)
|
||||
|
||||
`senderId` wird vom Server gestempelt und liegt damit **außerhalb** des Ciphertexts.
|
||||
Ohne Gegenmaßnahme kann ein bösartiger Relay Alices Ciphertext als Bob weiterreichen.
|
||||
Das ist exakt der Angreifer, gegen den dieses Feature gebaut wird.
|
||||
|
||||
Deshalb: **AES-GCM mit AAD** verschlüsseln.
|
||||
```
|
||||
AAD = `${roomId}|${senderId}` // senderId = eigene peerId beim Verschlüsseln,
|
||||
// envelope.senderId beim Entschlüsseln
|
||||
```
|
||||
Etikettiert der Server um, schlägt die Auth-Tag-Prüfung fehl und die Nachricht wird
|
||||
verworfen. Kostet null Performance und bindet die Nachricht zusätzlich an den Raum
|
||||
(kein Cross-Room-Replay).
|
||||
|
||||
Bewusst **nicht** abgedeckt und akzeptiert: Nachrichtenlängen und Timing bleiben für den
|
||||
Server sichtbar (kein Padding). Ein Relay kann eine Nachricht innerhalb desselben Raums
|
||||
erneut abspielen. Für das Bedrohungsmodell irrelevant.
|
||||
|
||||
### Sanitization wandert auf den Client (Pflicht)
|
||||
|
||||
Der Server sieht nur noch Ciphertext und **kann Text nicht mehr prüfen**. Die Bedrohung
|
||||
verschwindet dadurch nicht, sie verschiebt sich: entschlüsselter Text stammt von einem
|
||||
**Peer, der den Key besitzt**.
|
||||
|
||||
- Entschlüsselter Text ist **untrusted input**. Vor dem Rendern zwingend durch
|
||||
`escapeChatHtml`/`formatChatText` (escapen, dann Markdown). Niemals rohes `innerHTML`.
|
||||
- Die Längengrenze (500 Codepoints) muss der **Client** durchsetzen, vor dem
|
||||
Verschlüsseln. Der Server kann nur noch Bytes zählen.
|
||||
- **Bestehende Schranke beachten:** `maxHttpBufferSize: 4096` (`server/index.js:142`)
|
||||
gilt global pro Socket-Nachricht. Nachgerechnet: 500 Codepoints als 4-Byte-Emoji
|
||||
ergeben 2000 B Klartext, +16 B GCM-Tag +12 B IV = 2028 B, base64 = 2704 Zeichen,
|
||||
socket.io-Frame = **2731 B**. Passt, Headroom 1365 B. Bei 700 Codepoints wären es
|
||||
3799 B. Wer die Zeichengrenze anhebt, muss diese Rechnung neu machen, sonst reißt das
|
||||
Limit.
|
||||
|
||||
---
|
||||
|
||||
## Link-Format und Abwärtskompatibilität (entschieden)
|
||||
|
||||
### Warum das alte Format nicht erweiterbar ist
|
||||
|
||||
Aktuell (`popup.js:559-563`):
|
||||
```
|
||||
offiziell: #join:<roomId>:<password>
|
||||
custom: #join:<roomId>:<password>:1:<encodedUrl>
|
||||
```
|
||||
|
||||
Der Extension-Parser (`popup.js:1294-1313`) nimmt `roomId` von vorne, das Paar
|
||||
`(flag, url)` von hinten, und **alles dazwischen ist das Passwort** (`parts.join(':')`).
|
||||
Es gibt keine Position, die er verwirft. Verifiziert durch Ausführen der echten Parser
|
||||
gegen echte Links:
|
||||
|
||||
| Link | Alte Extension | Alte Website |
|
||||
|---|---|---|
|
||||
| `#join:SILENT-EAGLE-90:30PXPD:1:wss%3A%2F%2F…` (Kontrolle) | `password:"30PXPD"` OK | `serverFlag:"1"` OK |
|
||||
| Key inline: `…:30PXPD:<KEY>:1:wss%3A%2F%2F…` | `password:"30PXPD:R5Ti1nxp…"` **korrumpiert** | `serverFlag:"R5Ti…"`, `serverUrl:"1"` -> **falscher Server** |
|
||||
|
||||
Zwei verschiedene, irreführende Fehler aus derselben Zeile. Deshalb: neues Präfix.
|
||||
|
||||
### Neues Format
|
||||
|
||||
```
|
||||
#j2:r=<roomId>&p=<password>&k=<key>[&u=<encodedRelayUrl>]
|
||||
```
|
||||
|
||||
- `URLSearchParams`, kein positionales Parsen. Behebt nebenbei einen bestehenden Bug:
|
||||
heute zerschießt ein Doppelpunkt im Passwort den Website-Parser (`parts[2]`), während
|
||||
der Extension-Parser (`parts.join(':')`) damit klarkommt.
|
||||
- **`s=1` entfällt.** Es existierte nur wegen des positionalen Parsens. Mit benannten
|
||||
Parametern gilt: `u` vorhanden bedeutet Custom Relay.
|
||||
- Das Präfix darf `#join:` **nicht als Teilstring enthalten**, sonst greift
|
||||
`includes('#join:')` in alten Extensions doch.
|
||||
|
||||
Beispiele:
|
||||
```
|
||||
offiziell: …/join.html#j2:r=SAPPHIRE-DUCK-49&p=0XUK3C&k=R5Ti1nxp0crfAFHf3gVncw
|
||||
custom: …/join.html#j2:r=SILENT-EAGLE-90&p=30PXPD&k=R5Ti1nxp0crfAFHf3gVncw
|
||||
&u=wss%3A%2F%2Fsync.shik3i.net
|
||||
```
|
||||
|
||||
### Warum das alte Extensions nicht bricht
|
||||
|
||||
**Die Website ist der Hauptpfad, nicht die Extension.** `website/app.js:452-458` parst
|
||||
das Fragment selbst, prüft via `document.documentElement.dataset.koalasyncInstalled`
|
||||
(gesetzt von `bridge.js:9`), ob die Extension da ist, und dispatcht dann automatisch
|
||||
(`app.js:515-526`, "AUTO-TRIGGER JOIN") ein `KOALASYNC_JOIN_REQUEST` mit fertig
|
||||
geparsten, **strukturierten Feldern**. `bridge.js` reicht es als `WEB_JOIN_REQUEST` an
|
||||
den background weiter. In diesem Pfad liest die Extension die URL nie an.
|
||||
|
||||
Altes `bridge.js` destrukturiert nur, was es kennt:
|
||||
```js
|
||||
const { roomId, password, useCustomServer, serverUrl } = e.detail;
|
||||
```
|
||||
Ein zusätzliches `chatKey` fällt dort stillschweigend auf den Boden.
|
||||
|
||||
**Daraus folgt die Deploy-Reihenfolge: Website zuerst.** Sie ist die einzige Stelle, die
|
||||
das neue Format kennen muss, und sie ist sofort deploybar. Die Extension darf beliebig
|
||||
hinterherhinken (Store-Review, Update-Zyklen der Nutzer).
|
||||
|
||||
| Kombination | Ergebnis |
|
||||
|---|---|
|
||||
| Neue Website + neue Ext | Join + Chat |
|
||||
| Neue Website + alte Ext | Join normal, kein Chat, `chatKey` ignoriert |
|
||||
| Neue Ext + alter `#join:`-Link | Join, kein Key, Chat aus. Legacy-Parser bleibt erhalten |
|
||||
| Alte Website + neuer Link | Präfix unbekannt, Join-Seite tot. **Existiert nach dem Website-Deploy nicht mehr** |
|
||||
|
||||
`checkInviteLink()` (`popup.js:1289`) ist nur der Komfort-Pfad "Popup auf der Join-Seite
|
||||
öffnen und Felder vorausfüllen". Bei `#j2:` greift er in alten Extensions nicht mehr,
|
||||
das Feld bleibt leer statt falsch befüllt. Die neue Extension muss dort **beide**
|
||||
Formate parsen (`#j2:` und Legacy `#join:`).
|
||||
|
||||
**`history.replaceState` auf das Legacy-Format: nicht tun.** Naheliegende Idee, um das
|
||||
Autofill alter Extensions zu retten, aber ein Eigentor: es entfernt `k` aus der
|
||||
Adressleiste. Nutzer kopieren die URL aus der Adressleiste, um sie weiterzuteilen. Der
|
||||
weitergegebene Link joint dann zwar, hat aber stillschweigend keinen Chat mehr. Der
|
||||
Fragment-Inhalt muss unangetastet bleiben.
|
||||
|
||||
### Lebenszyklus des Keys
|
||||
|
||||
- **Erzeugt wird er vom Raum-Ersteller**, einmal, beim Anlegen des Raums. Er lebt im
|
||||
background neben `roomId`/`password` und geht in den Invite-Link.
|
||||
- **Er darf niemals in einem Relay-Payload landen.** Nicht in `join_room`, nirgends.
|
||||
Dafür einen Test schreiben, der alle ausgehenden Events gegen den Key prüft.
|
||||
- **Krypto gehört in den background**, nicht in den content script. Der Key wird damit
|
||||
gar nicht erst in den Kontext einer fremden Seite ausgeliefert. Das Overlay schickt
|
||||
Klartext an den background und bekommt Klartext zurück, verschlüsselt wird
|
||||
ausschließlich dort.
|
||||
- **Kanten, die korrekt fallen müssen:**
|
||||
- Raum-Ersteller hat eine **alte** Extension: es existiert kein Key, niemand chattet.
|
||||
Korrektes Verhalten, kein Fehlerfall.
|
||||
- Ein Peer mit **alter** Extension teilt den Invite aus seinem eigenen Popup: der Link
|
||||
trägt keinen Key. Wer darüber joint, chattet nicht, während die anderen chatten.
|
||||
- Jemand tippt Raum und Passwort **manuell**: kein Key, kein Chat.
|
||||
- Relay **ohne** Chat-Capability: Chat-UI gar nicht erst anzeigen.
|
||||
|
||||
---
|
||||
|
||||
## Vorgeschichte
|
||||
|
||||
Vorgängerbranch `feature/soonTMChat` (Stand `c706513`) ist **verworfen**, bleibt als
|
||||
Referenz liegen. Gründe:
|
||||
|
||||
- Chat lag nur im Popup. Ein Chrome-Popup schließt beim Fokusverlust, man kann nicht
|
||||
gleichzeitig Video schauen und chatten. Strukturell unbrauchbar.
|
||||
- Der Server speicherte bis zu 500 Klartext-Nachrichten pro Raum und schickte jedem
|
||||
Joiner das Backlog.
|
||||
- Verifizierter Folgebug: wer einen Raum mit >120 Nachrichten Historie betrat, wurde
|
||||
**rausgeworfen**. `renderChatHistory()` feuerte eine ungebündelte `chat_read`-Quittung
|
||||
pro Nachricht und riss `CHAT_READ_RATE_LIMIT` (120/10s). Reproduziert bei
|
||||
`CHAT_HISTORY_LIMIT=200`, dokumentiert erlaubt sind 500.
|
||||
|
||||
**Lehre: kein ungebündeltes Event-pro-Nachricht-Muster. Jedes Client-Verhalten gegen die
|
||||
Rate-Limits gegenrechnen, bevor es eingebaut wird.**
|
||||
|
||||
### Wiederverwendbar (per `git checkout feature/soonTMChat -- <pfad>`, kritisch prüfen)
|
||||
|
||||
| Datei | Was | Einschränkung |
|
||||
|---|---|---|
|
||||
| `extension/chat.js` | `escapeChatHtml`, `formatChatText`, `insertEmoji`, `createTypingTracker`, `createRemoteTypingTracker` | reine Funktionen, storage-frei, getestet. `createReceiptTracker` weglassen |
|
||||
| `extension/chat.test.mjs` | Unit-Tests | Receipt-Tests weg |
|
||||
| `extension/locales/*.json` | `CHAT_*`-Keys, 15 Sprachen | Receipt-Keys weg, neue Keys für Overlay nötig |
|
||||
| `server/chat.js` | `sanitizeChatUsername`, `canKickPeer` | `sanitizeChatText`/`createChatMessage` greifen auf Klartext zu, bei E2E unmöglich. `parseChatHistoryLimit`, `appendChatHistory`, `canRelayReadReceipt` entfallen |
|
||||
| `server/rate-limiter.js` | `checkChatMessageRate` (10/10s) | `checkChatReadRate` entfällt |
|
||||
| `shared/constants.js` | `CAPABILITIES.CHAT`, Event-Namen | **muss neu hinzu.** main kennt nur `HOST_CONTROL` und `CO_HOST` (`shared/constants.js:78`, `server/index.js:174`) |
|
||||
|
||||
**Server-Design, das überleben soll:** Der Server vergab Message-ID, `senderId` und
|
||||
`timestamp` selbst und ignorierte Client-Angaben (Spoofing-Schutz). Bleibt richtig, auch
|
||||
wenn der Text jetzt Ciphertext ist.
|
||||
|
||||
---
|
||||
|
||||
## Technische Randbedingungen
|
||||
|
||||
### Overlay-Kontext
|
||||
|
||||
- `content.js` wird **programmatisch** injiziert:
|
||||
`chrome.scripting.executeScript({ target: {tabId}, files: ['content.js'] })`
|
||||
(`background.js:1775`). Nicht über `manifest.content_scripts`, dort steht nur
|
||||
`bridge.js` für `https://sync.koalastuff.net/*`.
|
||||
- `host_permissions: ["<all_urls>"]`, MV3.
|
||||
- Das Overlay lebt im DOM fremder Seiten (Netflix, YouTube, Emby, Jellyfin).
|
||||
**Shadow DOM ist Pflicht**, sonst bluten fremde Styles rein und umgekehrt.
|
||||
- Die Theme-Variablen aus `popup.html` existieren im Page-Kontext **nicht** und müssen
|
||||
in den Shadow Root injiziert werden.
|
||||
- **Risiko, vorab prüfen:** Netflix-Vollbild ist ein eigener Fullscreen-Element-Kontext.
|
||||
Ein Overlay im normalen DOM verschwindet dort möglicherweise. Das kann die
|
||||
Positionierung grundlegend beeinflussen.
|
||||
|
||||
### Performance auf der Host-Seite
|
||||
|
||||
Das Video ist das Produkt, der Chat ist Beiwerk. Ein Overlay, das die Wiedergabe ruckeln
|
||||
lässt, ist schlechter als kein Overlay.
|
||||
|
||||
- Nur im **Ziel-Tab** injizieren (`currentTabId` im background), nicht auf jeder Seite.
|
||||
- `position: fixed`, eigener Compositing-Layer, **kein Eingriff in das Layout der
|
||||
Host-Seite**. Kein Layout-Thrashing, keine erzwungenen Reflows während der Wiedergabe.
|
||||
- Nachrichtenliste beim Rendern nicht unbegrenzt wachsen lassen (DOM-Knoten deckeln).
|
||||
|
||||
### Firefox
|
||||
|
||||
`scripts/build-extension.cjs:199` baut ein eigenes **Firefox-Target**, und `bridge.js`
|
||||
enthält bereits Firefox-spezifisches `cloneInto`-Handling für CustomEvent-Details
|
||||
(isolierte Welten in FF MV3). Overlay, Shadow DOM und WebCrypto müssen dort ebenfalls
|
||||
laufen. Neue Website-zu-Extension-Felder (`chatKey`) gehen durch dieselbe
|
||||
`cloneInto`-Stelle.
|
||||
|
||||
### Theme-System (aus `main`, verbindlich)
|
||||
|
||||
- `extension/theme-init.js` setzt auf `<html>`: `data-theme` (`light`/`dark`),
|
||||
`data-palette` (`eucalyptus`/`cyber`/`graphite`), Klasse `theme-light`.
|
||||
- Quelle: `chrome.storage.local`, Keys `themeMode` und `themePalette`, plus
|
||||
`chrome.storage.onChanged` für Live-Updates. Das Overlay nutzt denselben Mechanismus
|
||||
im Shadow Root.
|
||||
- **Kein `prefers-color-scheme`** für Theme-Farben. Das System ist explizit, nur der
|
||||
Modus `system` liest die OS-Präferenz und das erledigt `theme-init.js`.
|
||||
- Tokens: `--bg`, `--card`, `--surface-alt`, `--surface-deep`, `--accent`, `--text`,
|
||||
`--text-muted`, `--border-soft`, `--border-strong`, `--text-on-green`.
|
||||
Text auf `--accent` immer `--text-on-green`, nie `white`.
|
||||
- Getönte `oklch`-Schatten sind pro Palette hartcodiert und brauchen
|
||||
`html[data-palette=...]`-Overrides. Neutrale Schwarz-Alpha-Schatten sind
|
||||
palettenunabhängig und der einfachere Weg.
|
||||
- Bekannte Alt-Last, nicht Aufgabe dieses Branches: `--text-on-green` auf `--accent`
|
||||
erreicht in eucalyptus/light 3.76 und cyber/dark 4.24, unter WCAG AA.
|
||||
|
||||
### Qualitäts-Gates
|
||||
|
||||
- `npm run lint`, `npx vitest run`, `npm run verify` müssen grün sein.
|
||||
- i18n: alle 15 Sprachen in `extension/locales/`, `en` ist Baseline-Fallback. Keine
|
||||
hartcodierten UI-Strings, `getMessage(key, { placeholder })` aus `i18n.js`.
|
||||
- Bei einem Release-Tag: Eintrag in `docs/CHANGELOG.md`.
|
||||
- Keine Em-Dashes in nutzersichtbaren Texten.
|
||||
|
||||
---
|
||||
|
||||
## Offene Punkte (mit dem Nutzer klären, nicht selbst entscheiden)
|
||||
|
||||
1. **Overlay-Positionierung**: fest andockbar rechts/links, oder frei verschiebbar?
|
||||
Position pro Seite in `chrome.storage.local` merken? Verhalten im Vollbild?
|
||||
2. **Peers ohne Key**: Chat-UI ganz ausblenden mit Hinweis, oder anzeigen und
|
||||
Nachrichten als "nicht lesbar" markieren? (Die Fälle, in denen das eintritt, stehen
|
||||
oben unter Lebenszyklus.)
|
||||
3. **Metadaten**: Username bleibt heute für den Server sichtbar (er sanitized ihn).
|
||||
Mitverschlüsseln? Typing-Indikatoren verraten dem Server, wer wann aktiv ist.
|
||||
Behalten, verschlüsseln oder streichen?
|
||||
4. **Kick-Funktion**: `canKickPeer` existiert (Host/Controller-Rechte). Behalten?
|
||||
5. **Nachrichtenlängen-Limit**: 500 Codepoints beibehalten, oder angesichts des
|
||||
4096-B-Frames anders wählen?
|
||||
|
||||
---
|
||||
|
||||
## Vorgehen
|
||||
|
||||
1. Offene Punkte klären.
|
||||
2. Krypto- und Linkformat-Design in `docs/CHAT.md` festhalten. Das bestehende Dokument
|
||||
beschreibt das verworfene Konzept und wird neu geschrieben. Chat-Events in
|
||||
`docs/PROTOCOL.md` ergänzen.
|
||||
3. Website-Parser (`#j2:`, Legacy `#join:` weiter unterstützen) und Bridge-Feld
|
||||
`chatKey` zuerst, weil davon die Abwärtskompatibilität hängt.
|
||||
4. Dann Relay (reines Relay ohne Storage), dann Krypto im background, dann Overlay, dann
|
||||
i18n, dann Tests.
|
||||
5. **Verifizieren, nicht annehmen:**
|
||||
- Overlay auf mindestens zwei echten Seiten in allen 6 Theme-Kombinationen, nicht nur
|
||||
in einer isolierten HTML-Datei.
|
||||
- Chrome **und** Firefox-Build.
|
||||
- Ein Test, der beweist, dass der Key in keinem ausgehenden Relay-Payload vorkommt.
|
||||
- Ein Test, der eine umetikettierte Nachricht (fremde `senderId`) als ungültig
|
||||
zurückweist (AAD-Bindung).
|
||||
- Alte Extension gegen neue Website: Join funktioniert weiter, Chat ist stumm.
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Synchronisiere die Videowiedergabe auf YouTube, Netflix, Emby, Jellyfin und jeder HTML5-Seite in Echtzeit mit Freunden."
|
||||
"message": "Erstelle private Watch Partys und synchronisiere Videos mit Freunden auf YouTube, Netflix, Jellyfin, Emby und fast jeder Website."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends."
|
||||
"message": "Create private watch parties and sync videos with friends on YouTube, Netflix, Jellyfin, Emby, and almost any HTML5 website."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Mira videos junto a tus amigos en perfecta sincronización. Compatible con Netflix, YouTube, Twitch, Prime Video, Disney+ y cualquier reproductor HTML5."
|
||||
"message": "Crea fiestas privadas y sincroniza vídeos con amigos en YouTube, Netflix, Jellyfin, Emby y casi cualquier sitio HTML5."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Synchronisez la lecture vidéo sur YouTube, Netflix, Emby, Jellyfin et tout site HTML5 en temps réel avec vos amis."
|
||||
"message": "Créez des watch parties privées et regardez en synchro entre amis sur YouTube, Netflix, Jellyfin, Emby et presque tout site HTML5."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Sincronizza la riproduzione video su YouTube, Netflix, Emby, Jellyfin e qualsiasi sito HTML5 in tempo reale con gli amici."
|
||||
"message": "Crea watch party private e sincronizza i video con gli amici su YouTube, Netflix, Jellyfin, Emby e quasi ogni sito HTML5."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "YouTube、Netflix、Emby、Jellyfin、その他HTML5サイトでの動画再生を友達とリアルタイムで同期します。"
|
||||
"message": "プライベートなウォッチパーティーを作成し、YouTube、Netflix、Jellyfin、EmbyやほぼすべてのHTML5サイトで友達と動画を同期再生できます。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "YouTube, Netflix, Emby, Jellyfin 및 모든 HTML5 사이트에서 친구들과 실시간으로 비디오 재생을 동기화하세요."
|
||||
"message": "비공개 Watch Party를 만들고 YouTube, Netflix, Jellyfin, Emby 및 거의 모든 HTML5 사이트에서 친구들과 영상을 동기화하세요."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Synchroniseer video-afspelen op YouTube, Netflix, Emby, Jellyfin en elke HTML5-site in realtime met vrienden."
|
||||
"message": "Maak privéwatchparty's en kijk synchroon met vrienden op YouTube, Netflix, Jellyfin, Emby en vrijwel elke HTML5-website."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Synchronizuj odtwarzanie wideo na YouTube, Netflix, Emby, Jellyfin i dowolnej stronie HTML5 w czasie rzeczywistym ze znajomymi."
|
||||
"message": "Twórz prywatne wspólne seanse i synchronizuj filmy ze znajomymi na YouTube, Netflix, Jellyfin, Emby i niemal każdej stronie HTML5."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Assista a vídeos junto com seus amigos em sincronia perfeita. Compatível com Netflix, YouTube, Twitch, Prime Video, Disney+ e qualquer player HTML5."
|
||||
"message": "Crie watch parties privadas e sincronize vídeos com amigos no YouTube, Netflix, Jellyfin, Emby e em quase qualquer site HTML5."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Veja vídeos em conjunto com os seus amigos em sincronia perfeita. Compatível com Netflix, YouTube, Twitch, Prime Video, Disney+ e qualquer leitor HTML5."
|
||||
"message": "Crie sessões privadas e veja vídeos sincronizados com amigos no YouTube, Netflix, Jellyfin, Emby e em quase qualquer site HTML5."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Синхронизируйте воспроизведение видео на YouTube, Netflix, Emby, Jellyfin и любых HTML5-сайтах в реальном времени с друзьями."
|
||||
"message": "Создавайте закрытые Watch Party и смотрите синхронно с друзьями на YouTube, Netflix, Jellyfin, Emby и почти любом сайте HTML5."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "YouTube, Netflix, Emby, Jellyfin ve herhangi bir HTML5 sitesinde video oynatmayı arkadaşlarınızla gerçek zamanlı olarak senkronize edin."
|
||||
"message": "Özel izleme partileri kurun; YouTube, Netflix, Jellyfin, Emby ve çoğu HTML5 sitesinde arkadaşlarınızla senkron izleyin."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "Синхронізуйте відтворення відео на YouTube, Netflix, Emby, Jellyfin і будь-якому сайті HTML5 у режимі реального часу з друзями."
|
||||
"message": "Створюйте закриті Watch Party й дивіться синхронно з друзями на YouTube, Netflix, Jellyfin, Emby та майже будь-якому сайті HTML5."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"appName": {
|
||||
"message": "KoalaSync"
|
||||
"message": "Watch Party - KoalaSync"
|
||||
},
|
||||
"appDesc": {
|
||||
"message": "与朋友实时同步YouTube、Netflix、Emby、Jellyfin和任何HTML5网站上的视频播放。"
|
||||
"message": "创建私人观影派对,与好友在 YouTube、Netflix、Jellyfin、Emby 及几乎任何 HTML5 视频网站同步观看。"
|
||||
}
|
||||
}
|
||||
|
||||
+982
-142
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -11,11 +11,12 @@ document.documentElement.dataset.koalasyncInstalled = 'true';
|
||||
// 2. Listen for Join Requests from the Website
|
||||
window.addEventListener('KOALASYNC_JOIN_REQUEST', (e) => {
|
||||
if (!e || !e.detail) return;
|
||||
const { roomId, password, useCustomServer, serverUrl } = e.detail;
|
||||
const { roomId, password, chatKey, useCustomServer, serverUrl } = e.detail;
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'WEB_JOIN_REQUEST',
|
||||
roomId,
|
||||
password,
|
||||
chatKey,
|
||||
useCustomServer,
|
||||
serverUrl
|
||||
}).catch(() => {});
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
const CHAT_INFO = 'KoalaSync E2E Chat v1';
|
||||
const SECRET_BYTES = 16;
|
||||
const IV_BYTES = 12;
|
||||
const MIN_ENCRYPTED_BYTES = 29;
|
||||
const MAX_ENCRYPTED_BYTES = 2028;
|
||||
|
||||
let cachedKeyPromise = null;
|
||||
let cachedRoomId = '';
|
||||
let cachedSecret = '';
|
||||
let cacheGeneration = 0;
|
||||
|
||||
function bytesToBase64Url(bytes) {
|
||||
let binary = '';
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return globalThis.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function base64UrlToBytes(value) {
|
||||
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value) || value.length % 4 === 1) return null;
|
||||
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4);
|
||||
try {
|
||||
const binary = globalThis.atob(padded);
|
||||
const bytes = Uint8Array.from(binary, char => char.charCodeAt(0));
|
||||
return bytesToBase64Url(bytes) === value ? bytes : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function generateChatSecret(cryptoImpl = globalThis.crypto) {
|
||||
const bytes = new Uint8Array(SECRET_BYTES);
|
||||
cryptoImpl.getRandomValues(bytes);
|
||||
return bytesToBase64Url(bytes);
|
||||
}
|
||||
|
||||
export function validateChatSecret(value) {
|
||||
const bytes = base64UrlToBytes(value);
|
||||
return bytes?.length === SECRET_BYTES ? value : '';
|
||||
}
|
||||
|
||||
export function countCodePoints(value) {
|
||||
return [...String(value ?? '')].length;
|
||||
}
|
||||
|
||||
export function normalizeOutgoingChatText(value) {
|
||||
if (typeof value !== 'string') throw new TypeError('Chat message must be text');
|
||||
const text = value.trim();
|
||||
if (!text) throw new TypeError('Chat message must not be empty');
|
||||
if (countCodePoints(text) > 500) throw new RangeError('Chat message exceeds 500 Unicode code points');
|
||||
return text;
|
||||
}
|
||||
|
||||
export function clearChatKeyCache() {
|
||||
cacheGeneration++;
|
||||
cachedKeyPromise = null;
|
||||
cachedRoomId = '';
|
||||
cachedSecret = '';
|
||||
}
|
||||
|
||||
export async function deriveChatKey(roomId, secret, cryptoImpl = globalThis.crypto) {
|
||||
const validSecret = validateChatSecret(secret);
|
||||
if (!roomId || !validSecret) throw new TypeError('Valid roomId and chat secret are required');
|
||||
if (cachedKeyPromise && cachedRoomId === roomId && cachedSecret === validSecret) return cachedKeyPromise;
|
||||
|
||||
const encoder = new globalThis.TextEncoder();
|
||||
const generation = cacheGeneration;
|
||||
const keyPromise = (async () => {
|
||||
const material = await cryptoImpl.subtle.importKey(
|
||||
'raw', base64UrlToBytes(validSecret), { name: 'HKDF' }, false, ['deriveKey']
|
||||
);
|
||||
return cryptoImpl.subtle.deriveKey({
|
||||
name: 'HKDF',
|
||||
hash: 'SHA-256',
|
||||
salt: encoder.encode(roomId),
|
||||
info: encoder.encode(CHAT_INFO)
|
||||
}, material, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
||||
})();
|
||||
cachedKeyPromise = keyPromise;
|
||||
cachedRoomId = roomId;
|
||||
cachedSecret = validSecret;
|
||||
try {
|
||||
return await keyPromise;
|
||||
} catch (error) {
|
||||
if (generation === cacheGeneration && cachedKeyPromise === keyPromise) clearChatKeyCache();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function encryptChatMessage({ text, roomId, senderId, secret }, cryptoImpl = globalThis.crypto) {
|
||||
const plaintext = normalizeOutgoingChatText(text);
|
||||
if (!senderId) throw new TypeError('senderId is required');
|
||||
const key = await deriveChatKey(roomId, secret, cryptoImpl);
|
||||
const encoder = new globalThis.TextEncoder();
|
||||
const iv = new Uint8Array(IV_BYTES);
|
||||
cryptoImpl.getRandomValues(iv);
|
||||
const encrypted = new Uint8Array(await cryptoImpl.subtle.encrypt({
|
||||
name: 'AES-GCM',
|
||||
iv,
|
||||
additionalData: encoder.encode(`${roomId}|${senderId}`)
|
||||
}, key, encoder.encode(plaintext)));
|
||||
const envelope = new Uint8Array(iv.length + encrypted.length);
|
||||
envelope.set(iv);
|
||||
envelope.set(encrypted, iv.length);
|
||||
return bytesToBase64Url(envelope);
|
||||
}
|
||||
|
||||
export async function decryptChatMessage({ ciphertext, roomId, senderId, secret }, cryptoImpl = globalThis.crypto) {
|
||||
if (!senderId) throw new TypeError('senderId is required');
|
||||
const bytes = base64UrlToBytes(ciphertext);
|
||||
if (!bytes || bytes.length < MIN_ENCRYPTED_BYTES || bytes.length > MAX_ENCRYPTED_BYTES) {
|
||||
throw new TypeError('Invalid chat ciphertext');
|
||||
}
|
||||
const key = await deriveChatKey(roomId, secret, cryptoImpl);
|
||||
const encoder = new globalThis.TextEncoder();
|
||||
const plaintext = await cryptoImpl.subtle.decrypt({
|
||||
name: 'AES-GCM',
|
||||
iv: bytes.slice(0, IV_BYTES),
|
||||
additionalData: encoder.encode(`${roomId}|${senderId}`)
|
||||
}, key, bytes.slice(IV_BYTES));
|
||||
const text = new globalThis.TextDecoder('utf-8', { fatal: true }).decode(plaintext);
|
||||
return normalizeOutgoingChatText(text);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { webcrypto } from 'node:crypto';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
clearChatKeyCache,
|
||||
countCodePoints,
|
||||
decryptChatMessage,
|
||||
deriveChatKey,
|
||||
encryptChatMessage,
|
||||
generateChatSecret,
|
||||
normalizeOutgoingChatText,
|
||||
validateChatSecret
|
||||
} from './chat-crypto.js';
|
||||
|
||||
afterEach(clearChatKeyCache);
|
||||
|
||||
describe('chat crypto', () => {
|
||||
it('generates canonical 128-bit secrets', () => {
|
||||
const secret = generateChatSecret(webcrypto);
|
||||
expect(secret).toMatch(/^[A-Za-z0-9_-]{22}$/);
|
||||
expect(validateChatSecret(secret)).toBe(secret);
|
||||
expect(validateChatSecret(`${secret}=`)).toBe('');
|
||||
});
|
||||
|
||||
it('round-trips Unicode with a cached room key and fresh IVs', async () => {
|
||||
const secret = generateChatSecret(webcrypto);
|
||||
const firstKey = await deriveChatKey('ROOM-1', secret, webcrypto);
|
||||
const secondKey = await deriveChatKey('ROOM-1', secret, webcrypto);
|
||||
expect(secondKey).toBe(firstKey);
|
||||
|
||||
const input = { text: 'Hello 🐨 **world**', roomId: 'ROOM-1', senderId: 'alice', secret };
|
||||
const first = await encryptChatMessage(input, webcrypto);
|
||||
const second = await encryptChatMessage(input, webcrypto);
|
||||
expect(second).not.toBe(first);
|
||||
await expect(decryptChatMessage({ ciphertext: first, ...input }, webcrypto)).resolves.toBe(input.text);
|
||||
});
|
||||
|
||||
it('deduplicates in-flight key derivations without restoring a cleared cache', async () => {
|
||||
const secret = generateChatSecret(webcrypto);
|
||||
let deriveCalls = 0;
|
||||
let releaseDerive;
|
||||
const delayedCrypto = {
|
||||
...webcrypto,
|
||||
subtle: {
|
||||
importKey: (...args) => webcrypto.subtle.importKey(...args),
|
||||
deriveKey: async (...args) => {
|
||||
deriveCalls++;
|
||||
await new Promise(resolve => { releaseDerive = resolve; });
|
||||
return webcrypto.subtle.deriveKey(...args);
|
||||
}
|
||||
}
|
||||
};
|
||||
const first = deriveChatKey('ROOM-1', secret, delayedCrypto);
|
||||
const second = deriveChatKey('ROOM-1', secret, delayedCrypto);
|
||||
await Promise.resolve();
|
||||
expect(deriveCalls).toBe(1);
|
||||
clearChatKeyCache();
|
||||
releaseDerive();
|
||||
await Promise.all([first, second]);
|
||||
const fresh = deriveChatKey('ROOM-1', secret, webcrypto);
|
||||
expect(fresh).not.toBe(first);
|
||||
await fresh;
|
||||
});
|
||||
|
||||
it('rejects relabeling, cross-room replay, and the wrong secret', async () => {
|
||||
const secret = generateChatSecret(webcrypto);
|
||||
const ciphertext = await encryptChatMessage({ text: 'secret', roomId: 'ROOM-1', senderId: 'alice', secret }, webcrypto);
|
||||
clearChatKeyCache();
|
||||
await expect(decryptChatMessage({ ciphertext, roomId: 'ROOM-1', senderId: 'bob', secret }, webcrypto)).rejects.toThrow();
|
||||
clearChatKeyCache();
|
||||
await expect(decryptChatMessage({ ciphertext, roomId: 'ROOM-2', senderId: 'alice', secret }, webcrypto)).rejects.toThrow();
|
||||
clearChatKeyCache();
|
||||
await expect(decryptChatMessage({ ciphertext, roomId: 'ROOM-1', senderId: 'alice', secret: generateChatSecret(webcrypto) }, webcrypto)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('enforces 500 Unicode code points before encryption', () => {
|
||||
expect(countCodePoints('😀'.repeat(500))).toBe(500);
|
||||
expect(normalizeOutgoingChatText(` ${'😀'.repeat(500)} `)).toBe('😀'.repeat(500));
|
||||
expect(() => normalizeOutgoingChatText('😀'.repeat(501))).toThrow(RangeError);
|
||||
expect(() => normalizeOutgoingChatText(' ')).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it('keeps the worst-case 500-codepoint payload within the relay byte bound', async () => {
|
||||
const secret = generateChatSecret(webcrypto);
|
||||
const ciphertext = await encryptChatMessage({
|
||||
text: '😀'.repeat(500), roomId: 'ROOM-1', senderId: 'alice', secret
|
||||
}, webcrypto);
|
||||
const bytes = Buffer.from(ciphertext, 'base64url');
|
||||
expect(bytes).toHaveLength(2028);
|
||||
});
|
||||
|
||||
it('rejects non-canonical plaintext and malformed UTF-8 after authentication', async () => {
|
||||
const secret = generateChatSecret(webcrypto);
|
||||
const roomId = 'ROOM-1';
|
||||
const senderId = 'alice';
|
||||
const key = await deriveChatKey(roomId, secret, webcrypto);
|
||||
const encoder = new globalThis.TextEncoder();
|
||||
|
||||
async function encryptRaw(plaintext) {
|
||||
const iv = webcrypto.getRandomValues(new Uint8Array(12));
|
||||
const encrypted = new Uint8Array(await webcrypto.subtle.encrypt({
|
||||
name: 'AES-GCM',
|
||||
iv,
|
||||
additionalData: encoder.encode(`${roomId}|${senderId}`)
|
||||
}, key, plaintext));
|
||||
return Buffer.concat([Buffer.from(iv), Buffer.from(encrypted)]).toString('base64url');
|
||||
}
|
||||
|
||||
const whitespace = await encryptRaw(encoder.encode(' '));
|
||||
await expect(decryptChatMessage({ ciphertext: whitespace, roomId, senderId, secret }, webcrypto)).rejects.toThrow(TypeError);
|
||||
const malformed = await encryptRaw(Uint8Array.of(0xff));
|
||||
await expect(decryptChatMessage({ ciphertext: malformed, roomId, senderId, secret }, webcrypto)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
(function exposeChatFormat(root) {
|
||||
function escapeChatHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function formatChatText(value) {
|
||||
return escapeChatHtml(value)
|
||||
.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*([^*\n]+)\*/g, '<em>$1</em>');
|
||||
}
|
||||
|
||||
function tokenizeChatText(value) {
|
||||
const text = String(value ?? '');
|
||||
const tokens = [];
|
||||
const pattern = /\*\*([^*\n]+)\*\*|\*([^*\n]+)\*/g;
|
||||
let cursor = 0;
|
||||
let match;
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
if (match.index > cursor) tokens.push({ type: 'text', text: text.slice(cursor, match.index) });
|
||||
tokens.push({ type: match[1] !== undefined ? 'strong' : 'em', text: match[1] ?? match[2] });
|
||||
cursor = pattern.lastIndex;
|
||||
}
|
||||
if (cursor < text.length) tokens.push({ type: 'text', text: text.slice(cursor) });
|
||||
return tokens;
|
||||
}
|
||||
|
||||
root.KoalaSyncChatFormat = Object.freeze({ escapeChatHtml, formatChatText, tokenizeChatText });
|
||||
})(globalThis);
|
||||
@@ -0,0 +1,22 @@
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
beforeAll(async () => {
|
||||
await import('./chat-format.js');
|
||||
});
|
||||
|
||||
describe('chat formatting', () => {
|
||||
it('escapes executable HTML before applying limited Markdown', () => {
|
||||
const result = globalThis.KoalaSyncChatFormat.formatChatText('<img src=x onerror=alert(1)> **bold** *italic*');
|
||||
expect(result).toBe('<img src=x onerror=alert(1)> <strong>bold</strong> <em>italic</em>');
|
||||
expect(result).not.toContain('<img');
|
||||
});
|
||||
|
||||
it('tokenizes only text, strong, and emphasis nodes', () => {
|
||||
expect(globalThis.KoalaSyncChatFormat.tokenizeChatText('<script>x</script> **bold** *italic*')).toEqual([
|
||||
{ type: 'text', text: '<script>x</script> ' },
|
||||
{ type: 'strong', text: 'bold' },
|
||||
{ type: 'text', text: ' ' },
|
||||
{ type: 'em', text: 'italic' }
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const overlaySource = fs.readFileSync(path.join(extensionDir, 'chat-overlay.js'), 'utf8');
|
||||
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
|
||||
const popupSource = fs.readFileSync(path.join(extensionDir, 'popup.js'), 'utf8');
|
||||
const localeDir = path.join(extensionDir, 'locales');
|
||||
const chatKeys = [
|
||||
'LABEL_CHAT_ENABLED',
|
||||
'LABEL_CHAT_ENABLED_TOOLTIP',
|
||||
'CHAT_TITLE',
|
||||
'CHAT_LIVE_ONLY',
|
||||
'CHAT_OPEN',
|
||||
'CHAT_CLOSE',
|
||||
'CHAT_DOCK_LEFT',
|
||||
'CHAT_DOCK_RIGHT',
|
||||
'CHAT_DETACHED',
|
||||
'CHAT_PLACEHOLDER',
|
||||
'CHAT_SEND',
|
||||
'CHAT_MISSING_KEY',
|
||||
'CHAT_TOO_LONG',
|
||||
'CHAT_SEND_FAILED',
|
||||
'CHAT_EMPTY'
|
||||
];
|
||||
|
||||
describe('chat overlay contract', () => {
|
||||
it('isolates the overlay and never renders markup as HTML', () => {
|
||||
expect(overlaySource).toContain("attachShadow({ mode: 'open' })");
|
||||
expect(overlaySource).not.toMatch(/\.innerHTML\s*=|insertAdjacentHTML|\.outerHTML\s*=/);
|
||||
expect(overlaySource).toContain('document.createTextNode(token.text)');
|
||||
});
|
||||
|
||||
it('injects formatting and overlay code only with the selected-tab content script', () => {
|
||||
expect(backgroundSource).toContain("files: ['chat-format.js', 'chat-overlay.js', 'content.js']");
|
||||
expect(overlaySource).toContain("const storageKey = `chatOverlayLayout:${location.origin}`");
|
||||
expect(overlaySource).toContain('document.fullscreenElement || document.documentElement');
|
||||
});
|
||||
|
||||
it('supports all layout and theme combinations with bounded message DOM', () => {
|
||||
expect(overlaySource).toContain("['left', 'right', 'detached']");
|
||||
expect(overlaySource).toContain('!layout.detachedInitialized');
|
||||
expect(overlaySource).toContain('const rect = panel.getBoundingClientRect()');
|
||||
expect(overlaySource).not.toContain('contentRect');
|
||||
expect(overlaySource).toContain("#app[data-palette=\"cyber\"][data-theme=\"light\"]");
|
||||
expect(overlaySource).toContain("#app[data-palette=\"graphite\"][data-theme=\"light\"]");
|
||||
expect(overlaySource).toContain('const MAX_MESSAGES = 200');
|
||||
expect(overlaySource).toContain('while (messages.querySelectorAll(\'.message\').length > MAX_MESSAGES)');
|
||||
expect(overlaySource).toContain('Math.max(1, window.innerWidth)');
|
||||
expect(overlaySource).toContain('min(${MIN_WIDTH}px, calc(100vw - 16px))');
|
||||
});
|
||||
|
||||
it('guards async refresh/send work and clears all composer state on room reset', () => {
|
||||
expect(overlaySource).toContain('generation !== refreshGeneration');
|
||||
expect(overlaySource).toContain('if (sending || !context?.enabled) return');
|
||||
expect(overlaySource).toContain('textarea.value === submittedValue');
|
||||
expect(overlaySource).toMatch(/CHAT_RESET[\s\S]*resetComposer\(\)/);
|
||||
expect(overlaySource).toContain('setTimeout(() => finish(null), timeoutMs)');
|
||||
expect(backgroundSource).toContain('chatReceiveQueue = chatReceiveQueue.catch(() => {}).then');
|
||||
expect(backgroundSource).toContain("status: 'rate_limited'");
|
||||
});
|
||||
|
||||
it('keeps chat hidden by default without discarding the room chat key', () => {
|
||||
expect(popupSource).toContain('localData.chatEnabled === true');
|
||||
expect(backgroundSource).toContain('chatEnabled: data.chatEnabled === true');
|
||||
expect(backgroundSource).toContain('clientCapabilities: CLIENT_CAPABILITIES');
|
||||
expect(overlaySource).toContain('all:initial;display:none;position:fixed');
|
||||
expect(overlaySource).toContain("host.style.display = supported && optedIn ? '' : 'none'");
|
||||
expect(overlaySource).toContain('supported && optedIn && hasKey && connected');
|
||||
expect(popupSource).toContain("chrome.storage.local.set({ chatEnabled: elements.chatEnabled.checked })");
|
||||
expect(popupSource).toMatch(/if \(isCreating\) \{[\s\S]*?type: 'CREATE_CHAT_KEY'[\s\S]*?chatKey = normalizeChatKey/);
|
||||
expect(popupSource).not.toMatch(/chatEnabled[\s\S]{0,120}chatKey:\s*''/);
|
||||
});
|
||||
|
||||
it('keeps unavailable chat controls discoverable to assistive technology', () => {
|
||||
expect(overlaySource).toContain("launcher.setAttribute('aria-disabled'");
|
||||
expect(overlaySource).not.toContain('launcher.disabled =');
|
||||
expect(overlaySource).toContain("launcher.setAttribute('aria-describedby', launcherHint.id)");
|
||||
expect(overlaySource).toContain("textarea.setAttribute('aria-describedby', 'chat-composer-count chat-composer-status')");
|
||||
expect(overlaySource).toContain("status.setAttribute('role', 'status')");
|
||||
});
|
||||
|
||||
it('creates a chat key for both generated-room entry points', () => {
|
||||
expect(popupSource).toContain('let pendingRoomCreation = false');
|
||||
expect(popupSource).toContain('const isCreating = pendingRoomCreation || !roomIdInput');
|
||||
expect(popupSource).toMatch(/function handleCreateRoom\(\)[\s\S]*pendingRoomCreation = true[\s\S]*elements\.joinBtn\.click\(\)/);
|
||||
expect(popupSource).toContain("type: 'CREATE_CHAT_KEY'");
|
||||
});
|
||||
|
||||
it('contains every chat string in all 15 extension locales', () => {
|
||||
const localeFiles = fs.readdirSync(localeDir).filter(file => file.endsWith('.json')).sort();
|
||||
expect(localeFiles).toHaveLength(15);
|
||||
for (const file of localeFiles) {
|
||||
const messages = JSON.parse(fs.readFileSync(path.join(localeDir, file), 'utf8'));
|
||||
for (const key of chatKeys) {
|
||||
expect(messages[key], `${file}: ${key}`).toBeTypeOf('string');
|
||||
expect(messages[key], `${file}: ${key}`).not.toBe('');
|
||||
expect(messages[key], `${file}: ${key}`).not.toMatch(/[—–]/);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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 };
|
||||
})();
|
||||
@@ -0,0 +1,58 @@
|
||||
export const MAX_ROOM_ID_LENGTH = 64;
|
||||
export const CHAT_SEND_LIMIT = 9;
|
||||
export const CHAT_SEND_WINDOW_MS = 10000;
|
||||
|
||||
export function normalizeRoomId(value) {
|
||||
if (typeof value !== 'string') return '';
|
||||
return value.trim().replace(/[^a-zA-Z0-9\-]/g, '').slice(0, MAX_ROOM_ID_LENGTH);
|
||||
}
|
||||
|
||||
export function createChatSendLimiter({
|
||||
limit = CHAT_SEND_LIMIT,
|
||||
windowMs = CHAT_SEND_WINDOW_MS,
|
||||
now = () => Date.now()
|
||||
} = {}) {
|
||||
let timestamps = [];
|
||||
|
||||
return {
|
||||
take() {
|
||||
const current = now();
|
||||
timestamps = timestamps.filter(timestamp => current - timestamp < windowMs);
|
||||
if (timestamps.length >= limit) {
|
||||
return {
|
||||
allowed: false,
|
||||
retryAfterMs: Math.max(1, windowMs - (current - timestamps[0]))
|
||||
};
|
||||
}
|
||||
timestamps.push(current);
|
||||
return { allowed: true, retryAfterMs: 0 };
|
||||
},
|
||||
reset() {
|
||||
timestamps = [];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createLatestTaskQueue() {
|
||||
let generation = 0;
|
||||
let tail = Promise.resolve();
|
||||
|
||||
return {
|
||||
invalidate() {
|
||||
generation++;
|
||||
},
|
||||
async run(task) {
|
||||
const requestGeneration = ++generation;
|
||||
const previous = tail;
|
||||
let release;
|
||||
tail = new Promise(resolve => { release = resolve; });
|
||||
await previous.catch(() => {});
|
||||
const isCurrent = () => requestGeneration === generation;
|
||||
try {
|
||||
return await task(isCurrent);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CHAT_SEND_LIMIT,
|
||||
createChatSendLimiter,
|
||||
createLatestTaskQueue,
|
||||
MAX_ROOM_ID_LENGTH,
|
||||
normalizeRoomId
|
||||
} from './chat-session.js';
|
||||
|
||||
describe('chat session boundaries', () => {
|
||||
it('matches the relay room ID sanitizer and length limit', () => {
|
||||
expect(normalizeRoomId(' ROOM_!42 ')).toBe('ROOM42');
|
||||
expect(normalizeRoomId('A'.repeat(MAX_ROOM_ID_LENGTH + 10))).toBe('A'.repeat(MAX_ROOM_ID_LENGTH));
|
||||
expect(normalizeRoomId(null)).toBe('');
|
||||
});
|
||||
|
||||
it('keeps client chat bursts below the relay disconnect threshold', () => {
|
||||
let current = 1000;
|
||||
const limiter = createChatSendLimiter({ now: () => current });
|
||||
for (let index = 0; index < CHAT_SEND_LIMIT; index++) {
|
||||
expect(limiter.take()).toEqual({ allowed: true, retryAfterMs: 0 });
|
||||
}
|
||||
expect(limiter.take()).toEqual({ allowed: false, retryAfterMs: 10000 });
|
||||
current += 10000;
|
||||
expect(limiter.take()).toEqual({ allowed: true, retryAfterMs: 0 });
|
||||
limiter.reset();
|
||||
expect(limiter.take()).toEqual({ allowed: true, retryAfterMs: 0 });
|
||||
});
|
||||
|
||||
it('serializes join work and prevents an older request from winning storage races', async () => {
|
||||
const queue = createLatestTaskQueue();
|
||||
let releaseFirst;
|
||||
const writes = [];
|
||||
const first = queue.run(async isCurrent => {
|
||||
await new Promise(resolve => { releaseFirst = resolve; });
|
||||
if (isCurrent()) writes.push('first');
|
||||
return { status: isCurrent() ? 'ok' : 'superseded' };
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
const second = queue.run(async isCurrent => {
|
||||
if (isCurrent()) writes.push('second');
|
||||
return { status: 'ok' };
|
||||
});
|
||||
releaseFirst();
|
||||
await expect(first).resolves.toEqual({ status: 'superseded' });
|
||||
await expect(second).resolves.toEqual({ status: 'ok' });
|
||||
expect(writes).toEqual(['second']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
export function buildChatRelayPayload(ciphertext) {
|
||||
return { ciphertext };
|
||||
}
|
||||
|
||||
export function encodeSocketEvent(event, data, forbiddenSecret = '') {
|
||||
const payload = JSON.stringify([event, data]);
|
||||
if (forbiddenSecret && payload.includes(forbiddenSecret)) {
|
||||
throw new Error('Refusing to send chat secret to relay');
|
||||
}
|
||||
return `42${payload}`;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildChatRelayPayload, encodeSocketEvent } from './chat-wire.js';
|
||||
|
||||
describe('chat wire boundary', () => {
|
||||
const secret = 'R5Ti1nxp0crfAFHf3gVncw';
|
||||
|
||||
it('sends only ciphertext for chat messages', () => {
|
||||
expect(buildChatRelayPayload('ciphertext')).toEqual({ ciphertext: 'ciphertext' });
|
||||
});
|
||||
|
||||
it('rejects the secret anywhere in any outgoing relay event', () => {
|
||||
for (const [event, payload] of [
|
||||
['join_room', { roomId: 'ROOM', password: 'PASS', chatKey: secret }],
|
||||
['peer_status', { status: 'heartbeat', nested: { secret } }],
|
||||
['chat_message', { ciphertext: `prefix-${secret}-suffix` }]
|
||||
]) {
|
||||
expect(() => encodeSocketEvent(event, payload, secret)).toThrow('Refusing to send chat secret');
|
||||
}
|
||||
expect(encodeSocketEvent('join_room', { roomId: 'ROOM', password: 'PASS' }, secret)).not.toContain(secret);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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 l’accès ». Si aucune boîte de dialogue n’apparaît, autorisez KoalaSync sur ce site dans les permissions des extensions ou du site.",
|
||||
"BTN_RETRY_ACCESS": "Autoriser l’accè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."
|
||||
}
|
||||
|
||||
@@ -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 dell’estensione 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."
|
||||
}
|
||||
|
||||
@@ -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": "まだメッセージはありません。チャットはライブのみです。"
|
||||
}
|
||||
|
||||
@@ -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": "아직 메시지가 없습니다. 채팅은 실시간 전용입니다."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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": "Сообщений пока нет. Чат работает только в реальном времени."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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": "Повідомлень ще немає. Чат працює лише наживо."
|
||||
}
|
||||
|
||||
@@ -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": "暂无消息。聊天仅实时显示。"
|
||||
}
|
||||
|
||||
@@ -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": "2.6.4",
|
||||
"description": "__MSG_appDesc__",
|
||||
"permissions": [
|
||||
"storage",
|
||||
"tabs",
|
||||
|
||||
@@ -1,20 +1,7 @@
|
||||
export function initTabManager({
|
||||
getCurrentTabId,
|
||||
setCurrentTabId,
|
||||
setCurrentTabTitle,
|
||||
setLastContentHeartbeatAt,
|
||||
setRoomIdleSince,
|
||||
getCurrentRoom,
|
||||
getPeerId,
|
||||
getStorageInitialized,
|
||||
updateBadgeStatus,
|
||||
addLog,
|
||||
getSettings,
|
||||
emit,
|
||||
applyAudioSettingsToTab,
|
||||
injectContentScript,
|
||||
ensureState,
|
||||
EVENTS
|
||||
reactivateCurrentTarget,
|
||||
ensureState
|
||||
}) {
|
||||
chrome.storage.onChanged.addListener(async (changes, area) => {
|
||||
if (area !== 'local' || !changes.audioSettings) return;
|
||||
@@ -28,65 +15,14 @@ export function initTabManager({
|
||||
}).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(() => {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+75
-3
@@ -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;
|
||||
@@ -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>
|
||||
@@ -1432,6 +1456,10 @@
|
||||
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 & Sync</summary>
|
||||
<div class="details-content">
|
||||
|
||||
+332
-83
@@ -3,13 +3,26 @@ import { BLACKLIST_DOMAINS } from './shared/blacklist.js';
|
||||
import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from './shared/names.js';
|
||||
import { loadLocale, translateDOM, getMessage, getSystemLanguage } from './i18n.js';
|
||||
import { TITLE_PRIVACY_MODES, normalizeSendTabTitle, normalizeTabTitle } from './title-privacy.js';
|
||||
import { normalizeRoomId } from './chat-session.js';
|
||||
import './shared/invite-links.js';
|
||||
import { normalizeTabId, requestOriginPermission } from './host-access.js';
|
||||
|
||||
let pendingInviteRoomId = '';
|
||||
let pendingInviteChatKey = '';
|
||||
let pendingRoomCreation = false;
|
||||
|
||||
function normalizeChatKey(value) {
|
||||
return typeof value === 'string' && /^[A-Za-z0-9_-]{21}[AQgw]$/.test(value) ? value : '';
|
||||
}
|
||||
|
||||
const elements = {
|
||||
tabs: document.querySelectorAll('.tabs .tab-btn'),
|
||||
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 +68,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 +341,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 +366,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 +402,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 +432,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 +496,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 +561,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 +580,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 +674,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 +1125,7 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
|
||||
if (populateTabsToken !== token) return;
|
||||
currentTargetTabId = null;
|
||||
} else {
|
||||
currentTargetTabId = status?.targetTabId;
|
||||
currentTargetTabId = status?.targetTabId || status?.pendingTargetTabId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1118,7 +1245,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 +1416,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 +1465,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 +1610,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 +1755,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 +1771,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 +1832,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 +1867,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 +1893,7 @@ function handleCreateRoom() {
|
||||
const password = secureGenerateId();
|
||||
elements.roomId.value = roomId;
|
||||
elements.password.value = password;
|
||||
pendingRoomCreation = true;
|
||||
window.justCreatedRoom = true;
|
||||
|
||||
// Auto-connect
|
||||
@@ -1749,18 +1923,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 +1975,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 +2015,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,15 +2051,28 @@ 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 = () => {
|
||||
const retryQueryTime = async () => {
|
||||
if (!(await targetIsStillCurrent())) {
|
||||
failForceSyncTime();
|
||||
return;
|
||||
}
|
||||
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (retryResponse) => {
|
||||
if (chrome.runtime.lastError || !retryResponse || !Number.isFinite(retryResponse.currentTime)) {
|
||||
failForceSyncTime();
|
||||
@@ -1855,8 +2087,19 @@ elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
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 +2115,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 +2129,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 +2142,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 +2156,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 +2326,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 +2341,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 +2393,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 +2416,15 @@ chrome.runtime.onMessage.addListener((msg) => {
|
||||
|
||||
if (msg.success) {
|
||||
// Final confirmation of join from background
|
||||
chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl'], (data) => {
|
||||
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
|
||||
chrome.storage.local.get(['roomId', 'password', 'chatKey', 'useCustomServer', 'serverUrl'], (data) => {
|
||||
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl, data.chatKey);
|
||||
const syncTabBtn = document.querySelector('.tab-btn[data-tab="tab-sync"]');
|
||||
if (syncTabBtn) syncTabBtn.click();
|
||||
showSelectVideoHint();
|
||||
});
|
||||
} else {
|
||||
// Join failed: reset UI state
|
||||
chrome.storage.local.set({ roomId: '', password: '' });
|
||||
chrome.storage.local.set({ roomId: '', password: '', chatKey: '' });
|
||||
elements.roomId.value = '';
|
||||
elements.password.value = '';
|
||||
updateUI(null, null);
|
||||
|
||||
Generated
+783
-1254
File diff suppressed because it is too large
Load Diff
+17
-9
@@ -1,9 +1,12 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "2.6.0",
|
||||
"version": "2.6.4",
|
||||
"description": "KoalaSync Build Scripts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build:extension": "node scripts/build-extension.cjs",
|
||||
"indexnow": "node website/submit-indexnow.cjs",
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
@@ -0,0 +1,180 @@
|
||||
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,
|
||||
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);
|
||||
|
||||
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');
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import http from 'node:http';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -22,7 +23,10 @@ async function c() {
|
||||
function s(ws, evt, d={}) { ws.send(`42${JSON.stringify([evt,d])}`); }
|
||||
function a(ws) { if (ws._m.length) { const r=ws._m.shift(); return r.startsWith('42') ? JSON.parse(r.substring(2)) : r; } return new Promise((resolve, reject) => { const t=setTimeout(()=>reject(Error('timeout')),3e3); const h=(d)=>{clearTimeout(t);ws.removeListener('message',h);const r=d.toString();resolve(r.startsWith('42')?JSON.parse(r.substring(2)):r);};ws.on('message',h);}); }
|
||||
async function w(ws, evt, ms=3000) { const st=Date.now(); while(Date.now()-st<ms) { for(let i=0;i<ws._m.length;i++){const r=ws._m[i];ws._m.splice(i,1);if(r.startsWith('42')){try{const[e]=JSON.parse(r.substring(2));if(e===evt)return e}catch{/* skip */}}} await new Promise(r=>setTimeout(r,50));} throw Error(`wait:${evt}`); }
|
||||
async function j(ws, rid, pid, pw=null) { s(ws,'join_room',{roomId:rid,peerId:pid,password:pw,protocolVersion:'1.0.0'}); assert.equal((await a(ws))[0],'room_data'); }
|
||||
async function j(ws, rid, pid, pw=null, clientCapabilities=undefined) {
|
||||
s(ws,'join_room',{roomId:rid,peerId:pid,password:pw,protocolVersion:'1.0.0',clientCapabilities});
|
||||
assert.equal((await a(ws))[0],'room_data');
|
||||
}
|
||||
function close() { clients.forEach(w=>{try{w.close()}catch{/* ignore */}}); clients.length=0; }
|
||||
// Test suite opens >10 connections/min — clear the IP connection counter so the
|
||||
// connection rate limiter doesn't mask test failures (test-only, never at runtime).
|
||||
@@ -70,6 +74,70 @@ try {
|
||||
assert.equal(capEv, 'room_data');
|
||||
assert.ok(Array.isArray(capData.capabilities) && capData.capabilities.includes('host-control'),
|
||||
'ROOM_DATA advertises the host-control capability');
|
||||
assert.ok(capData.capabilities.includes('chat'), 'ROOM_DATA advertises the chat capability');
|
||||
assert.ok(capData.capabilities.includes('chat-v1'), 'ROOM_DATA advertises the versioned chat capability');
|
||||
assert.equal(capData.chatHistory, undefined, 'ROOM_DATA never contains chat history');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- Encrypted chat is a live-only canonical relay ---
|
||||
const chatRoom = 'chat-'+Date.now();
|
||||
const chat1 = await c(), chat2 = await c();
|
||||
await j(chat1, chatRoom, 'alice', null, ['chat-v1']);
|
||||
await j(chat2, chatRoom, 'bob', null, ['chat-v1']);
|
||||
chat1._m.length = chat2._m.length = 0;
|
||||
const ciphertext = Buffer.alloc(64, 7).toString('base64url');
|
||||
s(chat1, 'chat_message', {
|
||||
ciphertext,
|
||||
id: 'spoofed-id', senderId: 'mallory', timestamp: 1, text: 'plaintext'
|
||||
});
|
||||
const [chat1Event, chat1Data] = await a(chat1);
|
||||
const [chat2Event, chat2Data] = await a(chat2);
|
||||
assert.equal(chat1Event, 'chat_message', 'sender receives canonical chat envelope');
|
||||
assert.equal(chat2Event, 'chat_message', 'peer receives canonical chat envelope');
|
||||
assert.deepEqual(chat2Data, chat1Data, 'all peers receive the same canonical envelope');
|
||||
assert.equal(chat1Data.senderId, 'alice', 'relay stamps senderId');
|
||||
assert.equal(chat1Data.ciphertext, ciphertext, 'relay preserves ciphertext');
|
||||
assert.equal(chat1Data.text, undefined, 'relay drops plaintext fields');
|
||||
assert.notEqual(chat1Data.id, 'spoofed-id', 'relay replaces client IDs');
|
||||
assert.ok(Number.isFinite(chat1Data.timestamp) && chat1Data.timestamp > 1, 'relay stamps timestamp');
|
||||
|
||||
const late = await c();
|
||||
s(late, 'join_room', { roomId: chatRoom, peerId: 'late', protocolVersion: '1.0.0' });
|
||||
const [lateEvent, lateRoomData] = await a(late);
|
||||
assert.equal(lateEvent, 'room_data');
|
||||
assert.equal(lateRoomData.chatHistory, undefined, 'late joiner gets no chat backlog');
|
||||
assert.equal(late._m.some(raw => raw.includes('chat_message')), false, 'late joiner receives no old message');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
// --- Mixed rollout: old non-chat clients never receive unknown chat events ---
|
||||
const mixedChatRoom = 'chat-mixed-'+Date.now();
|
||||
const newChat = await c(), oldNoChat = await c();
|
||||
await j(newChat, mixedChatRoom, 'new-chat', null, ['chat-v1', 'chat-v1', 'future-chat', 42]);
|
||||
await j(oldNoChat, mixedChatRoom, 'old-no-chat', null, ['chat-v2']);
|
||||
newChat._m.length = oldNoChat._m.length = 0;
|
||||
s(newChat, 'chat_message', { ciphertext });
|
||||
await w(newChat, 'chat_message');
|
||||
let oldReceivedChat = false;
|
||||
try { await w(oldNoChat, 'chat_message', 500); oldReceivedChat = true; } catch { /* expected */ }
|
||||
assert.equal(oldReceivedChat, false, 'old client receives no unknown chat event');
|
||||
|
||||
// The same old client remains fully usable for the pre-chat protocol.
|
||||
newChat._m.length = oldNoChat._m.length = 0;
|
||||
s(oldNoChat, 'play', { currentTime: 12 });
|
||||
await w(newChat, 'play');
|
||||
s(newChat, 'pause', { currentTime: 13 });
|
||||
await w(oldNoChat, 'pause');
|
||||
|
||||
// First chat beta compatibility: sending a valid chat frame dynamically
|
||||
// proves receive support even when that beta omitted clientCapabilities.
|
||||
const firstBeta = await c();
|
||||
await j(firstBeta, mixedChatRoom, 'first-beta');
|
||||
firstBeta._m.length = newChat._m.length = 0;
|
||||
s(firstBeta, 'chat_message', { ciphertext });
|
||||
await w(firstBeta, 'chat_message');
|
||||
await w(newChat, 'chat_message');
|
||||
close();
|
||||
resetConnectionRate();
|
||||
|
||||
|
||||
@@ -12,11 +12,14 @@ const demoCss = fs.readFileSync(path.join(repoRoot, 'website', 'styles', 'demo.c
|
||||
const landingPrimaryCss = fs.readFileSync(path.join(repoRoot, 'website', 'styles', 'landing-primary.css'), 'utf8');
|
||||
const legalCss = fs.readFileSync(path.join(repoRoot, 'website', 'styles', 'legal.css'), 'utf8');
|
||||
const joinPage = fs.readFileSync(path.join(repoRoot, 'website', 'join.html'), 'utf8');
|
||||
const siteAccessHelpPage = fs.readFileSync(path.join(repoRoot, 'website', 'site-access-help.html'), 'utf8');
|
||||
const websiteBuild = fs.readFileSync(path.join(repoRoot, 'website', 'build.cjs'), 'utf8');
|
||||
const imprintPage = fs.readFileSync(path.join(repoRoot, 'website', 'imprint.html'), 'utf8');
|
||||
const germanImprintPage = fs.readFileSync(path.join(repoRoot, 'website', 'impressum-de.html'), 'utf8');
|
||||
const alternativesIndex = fs.readFileSync(path.join(repoRoot, 'website', 'alternatives', 'index.html'), 'utf8');
|
||||
const alternativesCss = fs.readFileSync(path.join(repoRoot, 'website', 'styles', 'alternatives.css'), 'utf8');
|
||||
const llmsText = fs.readFileSync(path.join(repoRoot, 'website', 'llms.txt'), 'utf8');
|
||||
const websiteVersion = JSON.parse(fs.readFileSync(path.join(repoRoot, 'website', 'version.json'), 'utf8')).version;
|
||||
const mockupStart = template.indexOf('<div class="extension-mockup">');
|
||||
const mockupEnd = template.indexOf('<div class="demo-invite-fly"', mockupStart);
|
||||
|
||||
@@ -63,6 +66,32 @@ for (const section of requiredLlmsSections) {
|
||||
if (/\bWebRTC\b|\bport 54000\b|peer-to-peer by design/i.test(llmsText)) {
|
||||
throw new Error('llms.txt must describe the current WebSocket relay architecture without obsolete WebRTC or port claims');
|
||||
}
|
||||
if (!llmsText.includes(`Current website release: ${websiteVersion}`)) {
|
||||
throw new Error(`llms.txt release must match website/version.json (${websiteVersion})`);
|
||||
}
|
||||
const requiredSupportSocialMetadata = [
|
||||
'name="twitter:card" content="summary_large_image"',
|
||||
'name="twitter:title"',
|
||||
'name="twitter:description"',
|
||||
'name="twitter:image"',
|
||||
'"datePublished"',
|
||||
'"dateModified"',
|
||||
'"mainEntityOfPage"'
|
||||
];
|
||||
for (const metadata of requiredSupportSocialMetadata) {
|
||||
if (!siteAccessHelpPage.includes(metadata)) {
|
||||
throw new Error(`site-access-help.html is missing SEO metadata: ${metadata}`);
|
||||
}
|
||||
}
|
||||
if (!websiteBuild.includes("['log', '-1', '--format=%cs', '--', ...sourceFiles]")
|
||||
|| !websiteBuild.includes("lastmod(['website/site-access-help.html'])")
|
||||
|| !websiteBuild.includes("['rev-parse', '--is-shallow-repository']")
|
||||
|| !websiteBuild.includes("['diff', '--name-only', '--']")
|
||||
|| !websiteBuild.includes("['diff', '--cached', '--name-only', '--']")
|
||||
|| !websiteBuild.includes("['ls-files', '--others', '--exclude-standard', '--']")
|
||||
|| !websiteBuild.includes('sourceFiles.some(file => dirtySourceFiles.has(file))')) {
|
||||
throw new Error('Sitemap lastmod values must come from the mapped source files in Git');
|
||||
}
|
||||
const documentedRelayUrls = [...llmsText.matchAll(/`(wss:\/\/[^`\s]+)`/g)].map(([, value]) => new URL(value));
|
||||
const hasCanonicalPublicRelay = documentedRelayUrls.some((url) => (
|
||||
url.protocol === 'wss:' &&
|
||||
@@ -151,11 +180,9 @@ if (!/\.legal-inline-link\s*\{[^}]*text-decoration:\s*underline/s.test(legalCss)
|
||||
throw new Error('Legal prose links must use a non-color underline cue');
|
||||
}
|
||||
for (const [name, page] of [['imprint.html', imprintPage], ['impressum-de.html', germanImprintPage]]) {
|
||||
if ((page.match(/class="legal-inline-link"/g) || []).length !== 3) {
|
||||
throw new Error(`${name} must mark all three prose links as legal-inline-link`);
|
||||
}
|
||||
if ((page.match(/class="legal-inline-label"/g) || []).length !== 2 || /opacity:\s*0\.6/.test(page)) {
|
||||
throw new Error(`${name} contact labels must use full-opacity theme text`);
|
||||
if (!/rel="canonical" href="https:\/\/koalastuff\.net\/legal"/.test(page)
|
||||
|| !/href="https:\/\/koalastuff\.net\/legal"/.test(page)) {
|
||||
throw new Error(`${name} must point to the central KoalaStuff legal notice`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,6 +203,7 @@ if (!/animation:\s*none\s*!important/.test(reducedMotionRule)) {
|
||||
console.log('Extension mockup theme-sensitive text uses theme-aware colors');
|
||||
console.log('Landing CSS is render-blocking, single-request, and cascade-stable');
|
||||
console.log('Landing head discovers a complete and architecture-accurate llms.txt');
|
||||
console.log('Support-page social metadata and Git-derived sitemap dates remain truthful');
|
||||
console.log('Website self-hosting examples include production secrets and consistent networking');
|
||||
console.log('Foreground film birds use complete, always-on wing and glide animations');
|
||||
console.log('All Getting Started mockups define explicit light-theme surfaces');
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export const CHAT_CIPHERTEXT_MAX_BYTES = 2028;
|
||||
export const CHAT_CIPHERTEXT_MIN_BYTES = 29;
|
||||
|
||||
export function normalizeChatCiphertext(value) {
|
||||
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value) || value.length % 4 === 1) return null;
|
||||
const bytes = Buffer.from(value, 'base64url');
|
||||
if (bytes.length < CHAT_CIPHERTEXT_MIN_BYTES || bytes.length > CHAT_CIPHERTEXT_MAX_BYTES) return null;
|
||||
if (bytes.toString('base64url') !== value) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function createChatEnvelope(data, senderId, now = Date.now, createId = randomUUID) {
|
||||
if (!data || typeof data !== 'object' || typeof senderId !== 'string' || !senderId) return null;
|
||||
const ciphertext = normalizeChatCiphertext(data.ciphertext);
|
||||
if (!ciphertext) return null;
|
||||
return {
|
||||
id: createId(),
|
||||
senderId,
|
||||
timestamp: now(),
|
||||
ciphertext
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Buffer } from 'node:buffer';
|
||||
import {
|
||||
CHAT_CIPHERTEXT_MAX_BYTES,
|
||||
createChatEnvelope,
|
||||
normalizeChatCiphertext
|
||||
} from './chat.js';
|
||||
|
||||
const encoded = (length) => Buffer.alloc(length, 7).toString('base64url');
|
||||
|
||||
describe('encrypted chat relay envelopes', () => {
|
||||
it('accepts canonical base64url within the encrypted message bounds', () => {
|
||||
expect(normalizeChatCiphertext(encoded(29))).toBe(encoded(29));
|
||||
expect(normalizeChatCiphertext(encoded(CHAT_CIPHERTEXT_MAX_BYTES))).toBe(encoded(CHAT_CIPHERTEXT_MAX_BYTES));
|
||||
});
|
||||
|
||||
it('rejects malformed, padded, too-small, and oversized payloads', () => {
|
||||
expect(normalizeChatCiphertext('<script>')).toBeNull();
|
||||
expect(normalizeChatCiphertext(`${encoded(29)}=`)).toBeNull();
|
||||
expect(normalizeChatCiphertext(encoded(28))).toBeNull();
|
||||
expect(normalizeChatCiphertext(encoded(CHAT_CIPHERTEXT_MAX_BYTES + 1))).toBeNull();
|
||||
expect(normalizeChatCiphertext('A')).toBeNull();
|
||||
});
|
||||
|
||||
it('stamps authoritative metadata and keeps only ciphertext from the sender', () => {
|
||||
const ciphertext = encoded(64);
|
||||
const envelope = createChatEnvelope({
|
||||
ciphertext,
|
||||
id: 'spoofed',
|
||||
senderId: 'mallory',
|
||||
timestamp: 1,
|
||||
text: 'plaintext must not survive'
|
||||
}, 'alice', () => 1234, () => 'server-id');
|
||||
|
||||
expect(envelope).toEqual({
|
||||
id: 'server-id',
|
||||
senderId: 'alice',
|
||||
timestamp: 1234,
|
||||
ciphertext
|
||||
});
|
||||
expect(JSON.stringify(envelope)).not.toContain('plaintext');
|
||||
});
|
||||
});
|
||||
+63
-1
@@ -5,6 +5,7 @@ import { Server } from 'socket.io';
|
||||
import crypto from 'crypto';
|
||||
import dotenv from 'dotenv';
|
||||
import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES } from '../shared/constants.js';
|
||||
import { createChatEnvelope } from './chat.js';
|
||||
import {
|
||||
buildHealthPayload,
|
||||
checkCooldown,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
connectionCounts,
|
||||
failedAuthAttempts,
|
||||
eventCounts,
|
||||
chatMessageCounts,
|
||||
healthCounts,
|
||||
adminMetricsAuthCounts,
|
||||
roomListCooldowns,
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
recordAuthFailure,
|
||||
checkConnectionRate,
|
||||
checkEventRate,
|
||||
checkChatMessageRate,
|
||||
checkHealthRate,
|
||||
checkAdminMetricsAuthRate,
|
||||
checkLeaveRoomRate,
|
||||
@@ -171,7 +174,26 @@ const HOST_ONLY_GATED_EVENTS = new Set([
|
||||
// Features this relay supports, advertised to clients in ROOM_DATA so they can
|
||||
// enable matching UI/behavior only when the server actually backs it. Append a
|
||||
// flag here when a new server-gated feature ships (e.g. co-host promotion).
|
||||
const SERVER_CAPABILITIES = [CAPABILITIES.HOST_CONTROL, CAPABILITIES.CO_HOST];
|
||||
const SERVER_CAPABILITIES = [
|
||||
CAPABILITIES.HOST_CONTROL,
|
||||
CAPABILITIES.CO_HOST,
|
||||
CAPABILITIES.CHAT,
|
||||
CAPABILITIES.CHAT_V1
|
||||
];
|
||||
|
||||
function normalizeClientCapabilities(value) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return [...new Set(value.slice(0, 16)
|
||||
.filter(capability => typeof capability === 'string')
|
||||
.map(capability => capability.substring(0, 32))
|
||||
.filter(capability => capability === CAPABILITIES.CHAT_V1)
|
||||
)];
|
||||
}
|
||||
|
||||
function clientSupportsChat(socket) {
|
||||
return Array.isArray(socket?.data?.clientCapabilities) &&
|
||||
socket.data.clientCapabilities.includes(CAPABILITIES.CHAT_V1);
|
||||
}
|
||||
|
||||
// M-4: minimum interval between CONTROL_MODE changes per room. Stops a rapidly
|
||||
// toggling host from thrashing every guest's UI (locked/unlocked/locked...) and
|
||||
@@ -353,6 +375,7 @@ io.on('connection', (socket) => {
|
||||
const username = typeof payload.username === 'string' ? payload.username.substring(0, 30) : null;
|
||||
const tabTitle = typeof payload.tabTitle === 'string' ? payload.tabTitle.substring(0, 100) : null;
|
||||
const mediaTitle = typeof payload.mediaTitle === 'string' ? payload.mediaTitle.substring(0, 100) : null;
|
||||
const clientCapabilities = normalizeClientCapabilities(payload.clientCapabilities);
|
||||
|
||||
if (!roomId || !peerId) return; // Guard: empty or invalid after sanitization
|
||||
|
||||
@@ -367,6 +390,7 @@ io.on('connection', (socket) => {
|
||||
// Cleanup old room if re-joining
|
||||
const oldMapping = socketToRoom.get(socket.id);
|
||||
if (oldMapping && oldMapping.roomId === roomId && oldMapping.peerId === peerId) {
|
||||
socket.data.clientCapabilities = clientCapabilities;
|
||||
return; // Already in this room with same peerId, ignore to prevent spam
|
||||
}
|
||||
if (oldMapping && oldMapping.roomId !== roomId) {
|
||||
@@ -490,6 +514,7 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
|
||||
socket.join(roomId);
|
||||
socket.data.clientCapabilities = clientCapabilities;
|
||||
room.peers.add(socket.id);
|
||||
room.peerIds.set(socket.id, peerId);
|
||||
room.peerData.set(socket.id, {
|
||||
@@ -844,8 +869,45 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on(EVENTS.CHAT_MESSAGE, (data) => {
|
||||
try {
|
||||
if (!checkEventRate(socket.id) || !checkChatMessageRate(socket.id)) {
|
||||
log('SECURITY', `Encrypted chat rate limit exceeded for socket: ${socket.id}`);
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const mapping = socketToRoom.get(socket.id);
|
||||
if (!mapping) return;
|
||||
const room = rooms.get(mapping.roomId);
|
||||
if (!room) return;
|
||||
|
||||
const envelope = createChatEnvelope(data, mapping.peerId);
|
||||
if (!envelope) return;
|
||||
// Transitional compatibility: the first chat beta did not announce
|
||||
// clientCapabilities. Successfully sending a canonical chat frame is
|
||||
// sufficient proof that this socket understands the v1 receive event.
|
||||
if (!clientSupportsChat(socket)) {
|
||||
socket.data.clientCapabilities = [
|
||||
...(socket.data.clientCapabilities || []),
|
||||
CAPABILITIES.CHAT_V1
|
||||
];
|
||||
}
|
||||
room.lastActivity = Date.now();
|
||||
for (const socketId of room.peers) {
|
||||
const recipient = io.sockets.sockets.get(socketId);
|
||||
if (clientSupportsChat(recipient)) {
|
||||
recipient.emit(EVENTS.CHAT_MESSAGE, envelope);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log('ERROR', `CHAT_MESSAGE handler error: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
eventCounts.delete(socket.id);
|
||||
chatMessageCounts.delete(socket.id);
|
||||
roomListCooldowns.delete(socket.id);
|
||||
leaveRoomCounts.delete(socket.id);
|
||||
const mapping = socketToRoom.get(socket.id);
|
||||
|
||||
Generated
+90
-51
@@ -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"
|
||||
|
||||
@@ -12,6 +12,8 @@ export const CONNECTION_RATE_LIMIT = 10; // max new connections per IP
|
||||
export const CONNECTION_RATE_WINDOW_MS = 60000; // 1 minute
|
||||
export const EVENT_RATE_LIMIT = 50; // max relayed events per socket per window
|
||||
export const EVENT_RATE_WINDOW_MS = 10000; // 10 seconds
|
||||
export const CHAT_MESSAGE_RATE_LIMIT = 10; // max encrypted chat messages per socket per window
|
||||
export const CHAT_MESSAGE_RATE_WINDOW_MS = 10000; // 10 seconds
|
||||
export const HEALTH_RATE_WINDOW_MS = 60000; // 1 minute
|
||||
export const ADMIN_METRICS_AUTH_WINDOW_MS = 60000; // 1 minute
|
||||
export const LEAVE_ROOM_RATE_LIMIT = 10; // max LEAVE_ROOM events per socket per window
|
||||
@@ -20,6 +22,7 @@ export const LEAVE_ROOM_RATE_WINDOW_MS = 60000; // 1 minute
|
||||
export const connectionCounts = new Map(); // ip -> { count, resetTime }
|
||||
export const failedAuthAttempts = new Map(); // Map<IP+RoomID, {count, lastAttempt}>
|
||||
export const eventCounts = new Map(); // socketId -> { count, resetTime }
|
||||
export const chatMessageCounts = new Map(); // socketId -> { count, resetTime }
|
||||
export const healthCounts = new Map(); // ip -> { count, resetTime }
|
||||
export const adminMetricsAuthCounts = new Map(); // ip -> { count, resetTime }
|
||||
export const roomListCooldowns = new Map(); // socketId -> last allowed timestamp
|
||||
@@ -28,6 +31,7 @@ export const leaveRoomCounts = new Map(); // socketId -> { count, resetTime }
|
||||
export const rateLimitDenied = {
|
||||
connections: 0,
|
||||
events: 0,
|
||||
chatMessages: 0,
|
||||
health: 0,
|
||||
adminMetricsAuth: 0,
|
||||
roomList: 0,
|
||||
@@ -104,6 +108,17 @@ export function checkEventRate(socketId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function checkChatMessageRate(socketId) {
|
||||
const now = Date.now();
|
||||
const entry = chatMessageCounts.get(socketId) || { count: 0, resetTime: now + CHAT_MESSAGE_RATE_WINDOW_MS };
|
||||
if (now > entry.resetTime) { entry.count = 0; entry.resetTime = now + CHAT_MESSAGE_RATE_WINDOW_MS; }
|
||||
entry.count++;
|
||||
chatMessageCounts.set(socketId, entry);
|
||||
if (entry.count <= CHAT_MESSAGE_RATE_LIMIT) return true;
|
||||
rateLimitDenied.chatMessages++;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function checkHealthRate(ip) {
|
||||
const now = Date.now();
|
||||
const entry = healthCounts.get(ip) || { count: 0, resetTime: now + HEALTH_RATE_WINDOW_MS };
|
||||
@@ -163,6 +178,11 @@ export function startRateLimitCleanup(io) {
|
||||
eventCounts.delete(socketId);
|
||||
}
|
||||
}
|
||||
for (const [socketId, entry] of chatMessageCounts.entries()) {
|
||||
if (now > entry.resetTime || !io.sockets.sockets.has(socketId)) {
|
||||
chatMessageCounts.delete(socketId);
|
||||
}
|
||||
}
|
||||
for (const [socketId, entry] of leaveRoomCounts.entries()) {
|
||||
if (now > entry.resetTime || !io.sockets.sockets.has(socketId)) {
|
||||
leaveRoomCounts.delete(socketId);
|
||||
@@ -189,6 +209,7 @@ export function clearRateLimitMaps() {
|
||||
connectionCounts.clear();
|
||||
failedAuthAttempts.clear();
|
||||
eventCounts.clear();
|
||||
chatMessageCounts.clear();
|
||||
|
||||
healthCounts.clear();
|
||||
adminMetricsAuthCounts.clear();
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
checkLeaveRoomRate,
|
||||
checkChatMessageRate,
|
||||
CHAT_MESSAGE_RATE_LIMIT,
|
||||
CHAT_MESSAGE_RATE_WINDOW_MS,
|
||||
chatMessageCounts,
|
||||
LEAVE_ROOM_RATE_LIMIT,
|
||||
LEAVE_ROOM_RATE_WINDOW_MS,
|
||||
rateLimitDenied,
|
||||
@@ -99,6 +103,32 @@ describe('LEAVE_ROOM Rate Limiter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('CHAT_MESSAGE Rate Limiter', () => {
|
||||
const socketId = 'chat-socket';
|
||||
|
||||
beforeEach(() => {
|
||||
clearRateLimitMaps();
|
||||
rateLimitDenied.chatMessages = 0;
|
||||
});
|
||||
|
||||
afterEach(() => clearRateLimitMaps());
|
||||
|
||||
it('allows ten messages per ten-second window and blocks the next', () => {
|
||||
for (let i = 0; i < CHAT_MESSAGE_RATE_LIMIT; i++) {
|
||||
expect(checkChatMessageRate(socketId)).toBe(true);
|
||||
}
|
||||
expect(checkChatMessageRate(socketId)).toBe(false);
|
||||
expect(rateLimitDenied.chatMessages).toBe(1);
|
||||
});
|
||||
|
||||
it('resets after the window expires', () => {
|
||||
for (let i = 0; i <= CHAT_MESSAGE_RATE_LIMIT; i++) checkChatMessageRate(socketId);
|
||||
const entry = chatMessageCounts.get(socketId);
|
||||
entry.resetTime = Date.now() - CHAT_MESSAGE_RATE_WINDOW_MS - 1;
|
||||
expect(checkChatMessageRate(socketId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rate Limit Constants', () => {
|
||||
it('should have correct rate limit values', () => {
|
||||
expect(LEAVE_ROOM_RATE_LIMIT).toBe(10);
|
||||
|
||||
+7
-2
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
export const PROTOCOL_VERSION = "1.0.0";
|
||||
export const APP_VERSION = "2.6.0";
|
||||
export const APP_VERSION = "2.6.4";
|
||||
|
||||
export const OFFICIAL_SERVER_URL = 'wss://syncserver.koalastuff.net';
|
||||
export const OFFICIAL_LANDING_PAGE_URL = 'https://sync.koalastuff.net';
|
||||
@@ -57,6 +57,9 @@ export const EVENTS = {
|
||||
EPISODE_READY: "episode_ready", // Response: loaded the episode and paused at 0:00
|
||||
EPISODE_LOBBY_CANCEL: "episode_lobby_cancel", // Broadcast: cancel active lobby and resume
|
||||
|
||||
// Ephemeral end-to-end encrypted chat
|
||||
CHAT_MESSAGE: "chat_message", // Ciphertext relay; no server history
|
||||
|
||||
// Ping / Latency
|
||||
PING: "ping", // { t: timestamp, target?: peerId } — empty target = server echo
|
||||
PONG: "pong" // server responds with same { t } for client RTT calculation
|
||||
@@ -77,7 +80,9 @@ export const CONTROL_MODES = {
|
||||
// simply ignore the field. Add a flag here as each server-gated feature lands.
|
||||
export const CAPABILITIES = {
|
||||
HOST_CONTROL: 'host-control',
|
||||
CO_HOST: 'co-host' // owner promotes guests to additional controllers
|
||||
CO_HOST: 'co-host', // owner promotes guests to additional controllers
|
||||
CHAT: 'chat', // legacy server capability used by the first chat beta
|
||||
CHAT_V1: 'chat-v1' // versioned client/server chat wire contract
|
||||
};
|
||||
|
||||
export const HEARTBEAT_INTERVAL = 15000; // 15s
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
(function exposeInviteLinks(root) {
|
||||
const J2_PREFIX = '#j2:';
|
||||
const LEGACY_PREFIX = '#join:';
|
||||
|
||||
function parseJ2Hash(hash) {
|
||||
if (typeof hash !== 'string' || !hash.startsWith(J2_PREFIX)) return null;
|
||||
const params = new URLSearchParams(hash.slice(J2_PREFIX.length));
|
||||
const roomId = params.get('r') || '';
|
||||
const password = params.get('p') || '';
|
||||
const chatKey = params.get('k') || '';
|
||||
const serverUrl = params.get('u') || '';
|
||||
if (!roomId || !password || !chatKey) return null;
|
||||
return {
|
||||
format: 'j2',
|
||||
roomId,
|
||||
password,
|
||||
chatKey,
|
||||
useCustomServer: serverUrl.length > 0,
|
||||
serverUrl
|
||||
};
|
||||
}
|
||||
|
||||
function parseLegacyHash(hash) {
|
||||
if (typeof hash !== 'string' || !hash.startsWith(LEGACY_PREFIX)) return null;
|
||||
const parts = hash.slice(LEGACY_PREFIX.length).split(':');
|
||||
const roomId = parts.shift() || '';
|
||||
let useCustomServer = false;
|
||||
let serverUrl = '';
|
||||
|
||||
if (parts.length >= 3) {
|
||||
const flag = parts.at(-2);
|
||||
const rawUrl = parts.at(-1) || '';
|
||||
let decodedUrl = '';
|
||||
try {
|
||||
decodedUrl = decodeURIComponent(rawUrl);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
const custom = flag === '1' && /^(?:ws|wss):\/\//.test(decodedUrl);
|
||||
const official = flag === '0' && rawUrl === '';
|
||||
if (custom || official) {
|
||||
parts.splice(-2, 2);
|
||||
useCustomServer = custom;
|
||||
serverUrl = custom ? decodedUrl : '';
|
||||
}
|
||||
}
|
||||
|
||||
const password = parts.join(':');
|
||||
if (!roomId || !password) return null;
|
||||
return {
|
||||
format: 'legacy',
|
||||
roomId,
|
||||
password,
|
||||
chatKey: '',
|
||||
useCustomServer,
|
||||
serverUrl
|
||||
};
|
||||
}
|
||||
|
||||
function parseInviteHash(hash) {
|
||||
return parseJ2Hash(hash) || parseLegacyHash(hash);
|
||||
}
|
||||
|
||||
function buildJ2Hash({ roomId, password, chatKey, serverUrl = '' }) {
|
||||
if (!roomId || !password || !chatKey) throw new TypeError('roomId, password, and chatKey are required');
|
||||
const params = new URLSearchParams({ r: roomId, p: password, k: chatKey });
|
||||
if (serverUrl) params.set('u', serverUrl);
|
||||
return `${J2_PREFIX}${params.toString()}`;
|
||||
}
|
||||
|
||||
root.KoalaSyncInviteLinks = Object.freeze({
|
||||
J2_PREFIX,
|
||||
LEGACY_PREFIX,
|
||||
parseInviteHash,
|
||||
buildJ2Hash
|
||||
});
|
||||
})(globalThis);
|
||||
@@ -0,0 +1,58 @@
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
beforeAll(async () => {
|
||||
await import('./invite-links.js');
|
||||
});
|
||||
|
||||
describe('invite links', () => {
|
||||
it('round-trips official and custom j2 links', () => {
|
||||
const api = globalThis.KoalaSyncInviteLinks;
|
||||
const official = api.buildJ2Hash({
|
||||
roomId: 'SAPPHIRE-DUCK-49',
|
||||
password: '0X:UK3C',
|
||||
chatKey: 'R5Ti1nxp0crfAFHf3gVncw'
|
||||
});
|
||||
expect(api.parseInviteHash(official)).toEqual({
|
||||
format: 'j2',
|
||||
roomId: 'SAPPHIRE-DUCK-49',
|
||||
password: '0X:UK3C',
|
||||
chatKey: 'R5Ti1nxp0crfAFHf3gVncw',
|
||||
useCustomServer: false,
|
||||
serverUrl: ''
|
||||
});
|
||||
|
||||
const custom = api.buildJ2Hash({
|
||||
roomId: 'SILENT-EAGLE-90',
|
||||
password: '30PXPD',
|
||||
chatKey: 'R5Ti1nxp0crfAFHf3gVncw',
|
||||
serverUrl: 'wss://sync.example.test/socket?region=eu'
|
||||
});
|
||||
expect(api.parseInviteHash(custom)).toMatchObject({
|
||||
useCustomServer: true,
|
||||
serverUrl: 'wss://sync.example.test/socket?region=eu'
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps legacy links compatible, including colons in passwords', () => {
|
||||
const parse = globalThis.KoalaSyncInviteLinks.parseInviteHash;
|
||||
expect(parse('#join:ROOM-1:pass:with:colons')).toMatchObject({
|
||||
format: 'legacy',
|
||||
password: 'pass:with:colons',
|
||||
chatKey: '',
|
||||
useCustomServer: false
|
||||
});
|
||||
expect(parse('#join:ROOM-1:pass:with:colons:1:wss%3A%2F%2Frelay.example')).toMatchObject({
|
||||
password: 'pass:with:colons',
|
||||
useCustomServer: true,
|
||||
serverUrl: 'wss://relay.example'
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects incomplete or malformed invite hashes', () => {
|
||||
const parse = globalThis.KoalaSyncInviteLinks.parseInviteHash;
|
||||
expect(parse('#j2:r=ROOM&p=PASS')).toBeNull();
|
||||
expect(parse('#join:ROOM')).toBeNull();
|
||||
expect(parse('#join:ROOM:PASS:1:%E0%A4%A')).toBeNull();
|
||||
expect(parse('#other:r=ROOM&p=PASS&k=KEY')).toBeNull();
|
||||
});
|
||||
});
|
||||
+3
-1
@@ -8,7 +8,9 @@ export default defineConfig({
|
||||
'server/**/*.test.js',
|
||||
'server/**/*.test.mjs',
|
||||
'shared/**/*.test.js',
|
||||
'shared/**/*.test.mjs'
|
||||
'shared/**/*.test.mjs',
|
||||
'extension/**/*.test.js',
|
||||
'extension/**/*.test.mjs'
|
||||
],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
|
||||
+1
-1
@@ -135,7 +135,7 @@
|
||||
<div class="container">
|
||||
<p>© 2026 KoalaSync. Open source under the MIT License.</p>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
|
||||
<a href="/imprint" style="color: var(--text-muted); text-decoration: none;">Legal Notice</a>
|
||||
<a href="https://koalastuff.net/legal" style="color: var(--text-muted); text-decoration: none;">Legal Notice</a>
|
||||
<a href="/privacy" style="color: var(--text-muted); text-decoration: none;">Privacy Policy</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+32
-27
@@ -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
@@ -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); });
|
||||
|
||||
@@ -297,7 +297,7 @@
|
||||
<p>© 2026 KoalaSync. Open Source unter der MIT-Lizenz.</p>
|
||||
<p style="font-size: 0.8rem; margin-top: 0.5rem;">Keine Relay-Logs. Kein Tracking. Website-Access-Logs werden nach 7 Tagen gelöscht.</p>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
|
||||
<a href="impressum" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
|
||||
<a href="https://koalastuff.net/legal" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
|
||||
<a href="datenschutz" style="color: var(--text-muted); text-decoration: none;">Datenschutz</a>
|
||||
<a href="alternatives/teleparty">Vergleiche & Anleitungen</a>
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" style="color: var(--text-muted); text-decoration: none; display: inline-flex; align-items: center; gap: 4px;">
|
||||
|
||||
+15
-244
@@ -1,250 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" class="lang-de">
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Impressum | KoalaSync</title>
|
||||
<link rel="preload" href="../style.min.css" as="style">
|
||||
<link rel="stylesheet" href="../style.min.css">
|
||||
<link rel="icon" type="image/webp" href="../assets/NewLogoIcon_64.webp">
|
||||
<link rel="canonical" href="https://sync.koalastuff.net/de/impressum">
|
||||
<link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/imprint">
|
||||
<link rel="alternate" hreflang="de" href="https://sync.koalastuff.net/de/impressum">
|
||||
<link rel="alternate" hreflang="x-default" href="https://sync.koalastuff.net/imprint">
|
||||
<meta name="description" content="Impressum (Legal Notice) von KoalaSync - Anbieterkennzeichnung und Kontaktinformationen für die Open-Source Video-Synchronisations-Erweiterung.">
|
||||
<meta name="robots" content="index, follow">
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{ "@type": "ListItem", "position": 1, "name": "Startseite", "item": "https://sync.koalastuff.net/de/" },
|
||||
{ "@type": "ListItem", "position": 2, "name": "Impressum", "item": "https://sync.koalastuff.net/de/impressum" }
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": "Impressum",
|
||||
"url": "https://sync.koalastuff.net/de/impressum",
|
||||
"description": "Impressum (Legal Notice) von KoalaSync - Anbieterkennzeichnung und Kontaktinformationen für die Open-Source Video-Synchronisations-Erweiterung.",
|
||||
"inLanguage": "de",
|
||||
"isPartOf": {
|
||||
"@type": "WebSite",
|
||||
"name": "KoalaSync",
|
||||
"url": "https://sync.koalastuff.net/de/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Mobile Browser Theme Styling -->
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
|
||||
<script src="../lang-init.min.js"></script>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Impressum | KoalaSync</title>
|
||||
<meta name="description" content="Das KoalaSync-Impressum ist zum zentralen KoalaStuff-Impressum umgezogen.">
|
||||
<meta name="robots" content="noindex, follow">
|
||||
<link rel="canonical" href="https://koalastuff.net/legal">
|
||||
<link rel="stylesheet" href="../style.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-nature" aria-hidden="true">
|
||||
<div class="bg-dusk-tint"></div>
|
||||
<div class="bg-depth-day">
|
||||
<div class="bg-godrays">
|
||||
<span class="godray godray-1"></span>
|
||||
<span class="godray godray-2"></span>
|
||||
<span class="godray godray-3"></span>
|
||||
</div>
|
||||
<div class="bg-canopy"></div>
|
||||
<span class="canopy-arc canopy-arc-1"></span>
|
||||
<span class="canopy-arc canopy-arc-2"></span>
|
||||
<span class="canopy-arc canopy-arc-3"></span>
|
||||
<span class="canopy-arc canopy-arc-4"></span>
|
||||
</div>
|
||||
<div id="bamboo-far">
|
||||
<span class="bamboo-stalk far-1"></span>
|
||||
<span class="bamboo-stalk far-2"></span>
|
||||
<span class="bamboo-stalk far-3"></span>
|
||||
<span class="bamboo-stalk far-4"></span>
|
||||
<span class="bamboo-stalk far-5"></span>
|
||||
</div>
|
||||
<div id="bamboo-near">
|
||||
<div class="bamboo-stalk near-1">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
<i class="bamboo-leaf leaf-2"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-2">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-3">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fall-leaf fall-leaf-1"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-2"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-3"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-4"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-5"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-6"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-7"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-8"><i></i></span>
|
||||
<span class="bg-grass bg-grass-1"></span>
|
||||
<span class="bg-grass bg-grass-2"></span>
|
||||
<span class="bg-grass bg-grass-3"></span>
|
||||
<span class="bg-grass bg-grass-4"></span>
|
||||
<div class="bg-depth-dusk">
|
||||
<div class="bg-mist bg-mist-1"></div>
|
||||
<div class="bg-mist bg-mist-2"></div>
|
||||
<div class="bg-horizon"></div>
|
||||
<span class="firefly-wrap fw-1"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-2"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-3"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-4"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-5"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-6"><span class="firefly"></span></span>
|
||||
</div>
|
||||
<div class="bg-light-sweep"></div>
|
||||
<div class="bg-grain"></div>
|
||||
<main class="legal-content">
|
||||
<div class="legal-card">
|
||||
<h1>Impressum umgezogen</h1>
|
||||
<p>Das KoalaSync-Impressum ist jetzt unter der zentralen KoalaStuff-Adresse verfügbar.</p>
|
||||
<p><a class="legal-inline-link" href="https://koalastuff.net/legal">Zentrales Impressum öffnen</a></p>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<div class="container nav-content">
|
||||
<a href="./" class="logo-area" style="text-decoration: none;">
|
||||
<img src="../assets/NewLogoIcon_128.webp" alt="KoalaSync Logo" width="40" height="40">
|
||||
<span>KoalaSync</span>
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
<a href="./" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="width: 16px; height: 16px; display: block;"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>
|
||||
<span>Startseite</span>
|
||||
</a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" rel="noopener" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" aria-hidden="true" style="display: block;"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="lang-select-container">
|
||||
<select class="lang-dropdown" aria-label="Select Language">
|
||||
<option value="en">🇬🇧 English</option>
|
||||
<option value="de">🇩🇪 Deutsch</option>
|
||||
<option value="fr">🇫🇷 Français</option>
|
||||
<option value="es">🇪🇸 Español</option>
|
||||
<option value="pt-BR">🇧🇷 Português (Brasil)</option>
|
||||
<option value="ru">🇷🇺 Русский</option>
|
||||
<option value="it">🇮🇹 Italiano</option>
|
||||
<option value="pl">🇵🇱 Polski</option>
|
||||
<option value="tr">🇹🇷 Türkçe</option>
|
||||
<option value="nl">🇳🇱 Nederlands</option>
|
||||
<option value="ja">🇯🇵 日本語</option>
|
||||
<option value="ko">🇰🇷 한국어</option>
|
||||
<option value="pt">🇵🇹 Português (Portugal)</option>
|
||||
</select>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="chevron-icon"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</div>
|
||||
<button class="hamburger" aria-label="Menu" aria-expanded="false">☰</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="legal-content">
|
||||
<div class="legal-card" data-reveal style="padding: 2rem;">
|
||||
<div style="display: flex; justify-content: center;">
|
||||
<img src="../assets/KoalaImprintl-180.webp" srcset="../assets/KoalaImprintl-180.webp 180w, ../assets/KoalaImprintl-360.webp 360w" sizes="180px" alt="Niedlicher Koala, der das Impressum repräsentiert" class="legal-mascot" width="180" height="180">
|
||||
</div>
|
||||
<h1>Impressum / Anbieterinformationen</h1>
|
||||
<p style="text-align: center; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.8rem; border-bottom: 1px solid var(--glass-border); padding-bottom: 1.5rem; margin-bottom: 2rem;">
|
||||
Kontakt & Projektinformationen
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2>Betreiber & Kontakt</h2>
|
||||
<p>Timo Schmidt</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
E-Mail: <span class="email-reveal" data-user="koalasync" data-domain="koalastuff.net" style="color: var(--accent); cursor: pointer; text-decoration: underline;">[E-Mail anzeigen]</span>
|
||||
</p>
|
||||
<p style="margin-top: 0.75rem;">
|
||||
<span class="legal-inline-label">🔒 Private Nachricht:</span>
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" class="legal-inline-link">@koalastuff auf Mastodon</a>
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
<span class="legal-inline-label">🐛 Fehlerberichte und Sicherheitsmeldungen:</span>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/issues" target="_blank" rel="noopener" class="legal-inline-link">GitHub Issues</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Projektinformationen</h2>
|
||||
<p>
|
||||
KoalaSync ist eine kostenlose und quelloffene Browser-Erweiterung. Der Quellcode ist auf GitHub unter der MIT-Lizenz verfügbar.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
Informationen zum Datenschutz, einschließlich Hosting, Access Logs, Relay-Verhalten und Betroffenenrechten, finden Sie in der <a href="datenschutz" class="legal-inline-link">Datenschutzerklärung</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Haftung für Inhalte</h2>
|
||||
<p>
|
||||
Die Inhalte dieser Website wurden mit Sorgfalt erstellt. Ich kann jedoch nicht gewährleisten, dass alle Informationen jederzeit vollständig, richtig oder aktuell sind.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
Als Diensteanbieter bin ich nach den allgemeinen Gesetzen für eigene Inhalte auf diesen Seiten verantwortlich.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Haftung für Links</h2>
|
||||
<p>
|
||||
Diese Website enthält Links zu externen Websites Dritter. Auf deren Inhalte habe ich keinen Einfluss. Für die Inhalte verlinkter Seiten ist der jeweilige Anbieter oder Betreiber verantwortlich.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
Wenn mir rechtswidrige Inhalte auf verlinkten Seiten bekannt werden, entferne ich die entsprechenden Links.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Lizenz & Urheberrecht</h2>
|
||||
<p>
|
||||
KoalaSync ist freie und quelloffene Software. Sofern nicht anders angegeben, steht der Quellcode von KoalaSync unter der MIT-Lizenz.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
Die MIT-Lizenz erlaubt Nutzung, Vervielfältigung, Bearbeitung, Verbreitung, Unterlizenzierung und Verkauf der Software, sofern der Copyright-Hinweis und der Lizenztext entsprechend den Lizenzbedingungen erhalten bleiben.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
Projektname, Logos, Grafiken, Website-Texte und andere Nicht-Code-Assets können separaten Rechten unterliegen, sofern sie nicht ausdrücklich von der Repository-Lizenz umfasst sind.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p>© 2026 KoalaSync. Open Source unter der MIT-Lizenz.</p>
|
||||
<p style="font-size: 0.8rem; margin-top: 0.5rem;">Keine Relay-Logs. Kein Tracking. Website-Access-Logs werden nach 7 Tagen gelöscht.</p>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
|
||||
<a href="impressum" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
|
||||
<a href="datenschutz" style="color: var(--text-muted); text-decoration: none;">Datenschutz</a>
|
||||
<a href="alternatives">Vergleiche & Anleitungen</a>
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" style="color: var(--text-muted); text-decoration: none; display: inline-flex; align-items: center; gap: 4px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="14" height="14" aria-hidden="true" style="display: block;"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></svg>
|
||||
Mastodon
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="../app.min.js"></script>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+8
-20
@@ -1,26 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, follow">
|
||||
<title>Redirecting...</title>
|
||||
<script>
|
||||
(function() {
|
||||
var savedLang = localStorage.getItem('koala_lang');
|
||||
var browserLang = (navigator.language || navigator.userLanguage || '').toLowerCase();
|
||||
var isDe = (savedLang === 'de') || (!savedLang && browserLang.indexOf('de') === 0);
|
||||
var path = window.location.pathname;
|
||||
var hasHtml = path.endsWith('.html');
|
||||
if (isDe) {
|
||||
window.location.replace(hasHtml ? 'de/impressum.html' : 'de/impressum');
|
||||
} else {
|
||||
window.location.replace(hasHtml ? 'imprint.html' : 'imprint');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="refresh" content="0; url=https://koalastuff.net/legal">
|
||||
<meta name="robots" content="noindex, follow">
|
||||
<link rel="canonical" href="https://koalastuff.net/legal">
|
||||
<title>Legal Notice | KoalaSync</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Redirecting / Weiterleitung...</p>
|
||||
<p><a href="https://koalastuff.net/legal">Open the central KoalaStuff legal notice</a></p>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+15
-244
@@ -1,250 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="lang-en">
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Legal Notice | KoalaSync</title>
|
||||
<link rel="preload" href="style.min.css" as="style">
|
||||
<link rel="stylesheet" href="style.min.css">
|
||||
<link rel="icon" type="image/webp" href="assets/NewLogoIcon_64.webp">
|
||||
<link rel="canonical" href="https://sync.koalastuff.net/imprint">
|
||||
<link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/imprint">
|
||||
<link rel="alternate" hreflang="de" href="https://sync.koalastuff.net/de/impressum">
|
||||
<link rel="alternate" hreflang="x-default" href="https://sync.koalastuff.net/imprint">
|
||||
<meta name="description" content="Legal Notice (Impressum) of KoalaSync - Contact information and operator details for the open-source video synchronization extension.">
|
||||
<meta name="robots" content="index, follow">
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://sync.koalastuff.net/" },
|
||||
{ "@type": "ListItem", "position": 2, "name": "Legal Notice", "item": "https://sync.koalastuff.net/imprint" }
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": "Legal Notice",
|
||||
"url": "https://sync.koalastuff.net/imprint",
|
||||
"description": "Legal Notice (Impressum) of KoalaSync - Contact information and operator details for the open-source video synchronization extension.",
|
||||
"inLanguage": "en",
|
||||
"isPartOf": {
|
||||
"@type": "WebSite",
|
||||
"name": "KoalaSync",
|
||||
"url": "https://sync.koalastuff.net/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Mobile Browser Theme Styling -->
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
|
||||
<script src="lang-init.min.js"></script>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Legal Notice | KoalaSync</title>
|
||||
<meta name="description" content="The KoalaSync legal notice has moved to the central KoalaStuff legal notice.">
|
||||
<meta name="robots" content="noindex, follow">
|
||||
<link rel="canonical" href="https://koalastuff.net/legal">
|
||||
<link rel="stylesheet" href="style.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-nature" aria-hidden="true">
|
||||
<div class="bg-dusk-tint"></div>
|
||||
<div class="bg-depth-day">
|
||||
<div class="bg-godrays">
|
||||
<span class="godray godray-1"></span>
|
||||
<span class="godray godray-2"></span>
|
||||
<span class="godray godray-3"></span>
|
||||
</div>
|
||||
<div class="bg-canopy"></div>
|
||||
<span class="canopy-arc canopy-arc-1"></span>
|
||||
<span class="canopy-arc canopy-arc-2"></span>
|
||||
<span class="canopy-arc canopy-arc-3"></span>
|
||||
<span class="canopy-arc canopy-arc-4"></span>
|
||||
</div>
|
||||
<div id="bamboo-far">
|
||||
<span class="bamboo-stalk far-1"></span>
|
||||
<span class="bamboo-stalk far-2"></span>
|
||||
<span class="bamboo-stalk far-3"></span>
|
||||
<span class="bamboo-stalk far-4"></span>
|
||||
<span class="bamboo-stalk far-5"></span>
|
||||
</div>
|
||||
<div id="bamboo-near">
|
||||
<div class="bamboo-stalk near-1">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
<i class="bamboo-leaf leaf-2"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-2">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-3">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fall-leaf fall-leaf-1"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-2"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-3"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-4"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-5"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-6"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-7"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-8"><i></i></span>
|
||||
<span class="bg-grass bg-grass-1"></span>
|
||||
<span class="bg-grass bg-grass-2"></span>
|
||||
<span class="bg-grass bg-grass-3"></span>
|
||||
<span class="bg-grass bg-grass-4"></span>
|
||||
<div class="bg-depth-dusk">
|
||||
<div class="bg-mist bg-mist-1"></div>
|
||||
<div class="bg-mist bg-mist-2"></div>
|
||||
<div class="bg-horizon"></div>
|
||||
<span class="firefly-wrap fw-1"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-2"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-3"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-4"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-5"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-6"><span class="firefly"></span></span>
|
||||
</div>
|
||||
<div class="bg-light-sweep"></div>
|
||||
<div class="bg-grain"></div>
|
||||
<main class="legal-content">
|
||||
<div class="legal-card">
|
||||
<h1>Legal notice moved</h1>
|
||||
<p>The KoalaSync legal notice is now available at the central KoalaStuff address.</p>
|
||||
<p><a class="legal-inline-link" href="https://koalastuff.net/legal">Open the central legal notice</a></p>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<div class="container nav-content">
|
||||
<a href="./" class="logo-area" style="text-decoration: none;">
|
||||
<img src="assets/NewLogoIcon_128.webp" alt="KoalaSync Logo" width="40" height="40">
|
||||
<span>KoalaSync</span>
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
<a href="./" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="width: 16px; height: 16px; display: block;"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>
|
||||
<span>Home</span>
|
||||
</a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" rel="noopener" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" aria-hidden="true" style="display: block;"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="lang-select-container">
|
||||
<select class="lang-dropdown" aria-label="Select Language">
|
||||
<option value="en">🇬🇧 English</option>
|
||||
<option value="de">🇩🇪 Deutsch</option>
|
||||
<option value="fr">🇫🇷 Français</option>
|
||||
<option value="es">🇪🇸 Español</option>
|
||||
<option value="pt-BR">🇧🇷 Português (Brasil)</option>
|
||||
<option value="ru">🇷🇺 Русский</option>
|
||||
<option value="it">🇮🇹 Italiano</option>
|
||||
<option value="pl">🇵🇱 Polski</option>
|
||||
<option value="tr">🇹🇷 Türkçe</option>
|
||||
<option value="nl">🇳🇱 Nederlands</option>
|
||||
<option value="ja">🇯🇵 日本語</option>
|
||||
<option value="ko">🇰🇷 한국어</option>
|
||||
<option value="pt">🇵🇹 Português (Portugal)</option>
|
||||
</select>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="chevron-icon"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</div>
|
||||
<button class="hamburger" aria-label="Menu" aria-expanded="false">☰</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="legal-content">
|
||||
<div class="legal-card" data-reveal style="padding: 2rem;">
|
||||
<div style="display: flex; justify-content: center;">
|
||||
<img src="assets/KoalaImprintl-180.webp" srcset="assets/KoalaImprintl-180.webp 180w, assets/KoalaImprintl-360.webp 360w" sizes="180px" alt="Cute Koala representing the legal notice page" class="legal-mascot" width="180" height="180">
|
||||
</div>
|
||||
<h1>Legal Notice</h1>
|
||||
<p style="text-align: center; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.8rem; border-bottom: 1px solid var(--glass-border); padding-bottom: 1.5rem; margin-bottom: 2rem;">
|
||||
Contact & Project Information
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2>Operator & Contact</h2>
|
||||
<p>Timo Schmidt</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
E-Mail: <span class="email-reveal" data-user="koalasync" data-domain="koalastuff.net" style="color: var(--accent); cursor: pointer; text-decoration: underline;">[Show Email]</span>
|
||||
</p>
|
||||
<p style="margin-top: 0.75rem;">
|
||||
<span class="legal-inline-label">🔒 Private message:</span>
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" class="legal-inline-link">@koalastuff on Mastodon</a>
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
<span class="legal-inline-label">🐛 Bug reports and security concerns:</span>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/issues" target="_blank" rel="noopener" class="legal-inline-link">GitHub Issues</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Project Information</h2>
|
||||
<p>
|
||||
KoalaSync is a free and open-source browser extension. The source code is available on GitHub under the MIT License.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
For privacy-related information, including hosting, access logs, relay behavior, and data subject rights, please see the <a href="privacy" class="legal-inline-link">Privacy Policy</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Liability for Content</h2>
|
||||
<p>
|
||||
The contents of this website were created with care. However, I cannot guarantee that all information is always complete, accurate, or up to date.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
As a service provider, I am responsible for my own content on these pages in accordance with the general laws.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Liability for Links</h2>
|
||||
<p>
|
||||
This website contains links to external third-party websites. I have no influence over the content of those websites. The respective provider or operator is responsible for the content of linked pages.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
If I become aware of unlawful content on linked pages, I will remove the corresponding links.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>License & Copyright</h2>
|
||||
<p>
|
||||
KoalaSync is free and open-source software. Unless otherwise stated, the KoalaSync source code is licensed under the MIT License.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
The MIT License allows use, copying, modification, distribution, sublicensing, and sale of the software, provided that the copyright notice and license text are included where required by the license.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
Project name, logos, graphics, website text, and other non-code assets may be subject to separate rights unless they are explicitly included under the repository license.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p>© 2026 KoalaSync. Open source under the MIT License.</p>
|
||||
<p style="font-size: 0.8rem; margin-top: 0.5rem;">No relay logs. No tracking. Website access logs are deleted after 7 days.</p>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
|
||||
<a href="imprint" style="color: var(--text-muted); text-decoration: none;">Legal Notice</a>
|
||||
<a href="privacy" style="color: var(--text-muted); text-decoration: none;">Privacy Policy</a>
|
||||
<a href="alternatives">Guides & comparisons</a>
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" style="color: var(--text-muted); text-decoration: none; display: inline-flex; align-items: center; gap: 4px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="14" height="14" aria-hidden="true" style="display: block;"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></svg>
|
||||
Mastodon
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="app.min.js"></script>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+3
-2
@@ -186,8 +186,8 @@
|
||||
<p style="font-size: 0.8rem; margin-top: 0.5rem;"><span lang="en">No relay logs. No tracking. Website access logs are deleted after 7 days.</span><span lang="de">Keine Relay-Logs. Kein Tracking. Website-Access-Logs werden nach 7 Tagen gelöscht.</span></p>
|
||||
<p class="footer-disclaimer" style="margin-top: 1.2rem;"><span lang="en">KoalaSync is not affiliated with, endorsed by, or associated with Netflix, Disney+, Amazon, YouTube, Twitch, or any other streaming platform. All trademarks are property of their respective owners.</span><span lang="de">KoalaSync ist nicht mit Netflix, Disney+, Amazon, YouTube, Twitch oder anderen Streaming-Plattformen verbunden, wird von diesen nicht unterstützt oder steht in keiner Beziehung zu diesen. Alle Markenrechte liegen bei ihren jeweiligen Inhabern.</span></p>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
|
||||
<a href="imprint" lang="en" style="color: var(--text-muted); text-decoration: none;">Legal Notice</a>
|
||||
<a href="de/impressum" lang="de" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
|
||||
<a href="https://koalastuff.net/legal" lang="en" style="color: var(--text-muted); text-decoration: none;">Legal Notice</a>
|
||||
<a href="https://koalastuff.net/legal" lang="de" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
|
||||
<a href="privacy" lang="en" style="color: var(--text-muted); text-decoration: none;">Privacy Policy</a>
|
||||
<a href="de/datenschutz" lang="de" style="color: var(--text-muted); text-decoration: none;">Datenschutz</a>
|
||||
<a href="alternatives"><span lang="en">Guides & comparisons</span><span lang="de">Vergleiche & Anleitungen</span></a>
|
||||
@@ -203,6 +203,7 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="invite-links.min.js"></script>
|
||||
<script src="app.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+2
-2
@@ -78,7 +78,7 @@ Compatibility depends on each website's player implementation and can change whe
|
||||
|
||||
## Technical information
|
||||
|
||||
- Current website release: 2.5.4
|
||||
- Current website release: 2.6.4
|
||||
- License: MIT
|
||||
- Extension runtime: dependency-free browser extension code
|
||||
- Relay: Node.js with Socket.IO-compatible WebSocket messaging
|
||||
@@ -112,6 +112,6 @@ Compatibility depends on each website's player implementation and can change whe
|
||||
- [Main website](https://sync.koalastuff.net/)
|
||||
- [GitHub issues](https://github.com/Shik3i/KoalaSync/issues): Bugs, compatibility reports and feature requests
|
||||
- [Mastodon](https://mastodon.social/@koalastuff): Private contact and project updates
|
||||
- [Legal notice](https://sync.koalastuff.net/imprint)
|
||||
- [Legal notice](https://koalastuff.net/legal)
|
||||
|
||||
KoalaSync is an independent project and is not affiliated with, endorsed by or associated with Netflix, Disney+, Amazon, YouTube, Twitch or any other streaming platform. All trademarks belong to their respective owners.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 n’accède pas à votre site vidéo ?",
|
||||
"SUPPORT_BANNER_CTA": "Réparer l’accès au site",
|
||||
"SUPPORT_BANNER_DISMISS": "Fermer l’avertissement",
|
||||
"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",
|
||||
|
||||
@@ -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 l’accesso 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",
|
||||
|
||||
@@ -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": "ダウンロードボタンを見下ろしているかわいいコアラ",
|
||||
|
||||
@@ -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": "다운로드 버튼을 내려다보고 서 있는 귀여운 코알라",
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"TWITTER_DESCRIPTION": "Start een watch party op Netflix, Emby, Jellyfin, YouTube, Twitch en bijna elke HTML5-video. Open source, privacyvriendelijk en zonder account.",
|
||||
"NAV_FEATURES": "Functies",
|
||||
"NAV_HOW_IT_WORKS": "Hoe het werkt",
|
||||
"SUPPORT_BANNER_TEXT": "Heeft KoalaSync geen toegang tot je videosite?",
|
||||
"SUPPORT_BANNER_CTA": "Websitetoegang herstellen",
|
||||
"SUPPORT_BANNER_DISMISS": "Melding sluiten",
|
||||
"HERO_TITLE": "Maak van elke video een <span class='hero-highlight'>watch party</span>.",
|
||||
"HERO_SUBTITLE": "Jouw filmavond op afstand, zonder vertraging. Geen account, geen tracking, geen advertenties. Deel een link en druk samen op play.",
|
||||
"HERO_MASCOT_ALT": "Een schattige koala die staat en naar beneden kijkt naar de downloadknoppen",
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"TWITTER_DESCRIPTION": "Zorganizuj watch party na Netflix, Emby, Jellyfin, YouTube, Twitch i prawie każdym wideo HTML5. Open source, prywatnie i bez konta.",
|
||||
"NAV_FEATURES": "Funkcje",
|
||||
"NAV_HOW_IT_WORKS": "Jak to działa",
|
||||
"SUPPORT_BANNER_TEXT": "KoalaSync nie ma dostępu do strony z filmem?",
|
||||
"SUPPORT_BANNER_CTA": "Napraw dostęp do strony",
|
||||
"SUPPORT_BANNER_DISMISS": "Zamknij powiadomienie",
|
||||
"HERO_TITLE": "Zamień dowolne wideo w <span class='hero-highlight'>watch party</span>.",
|
||||
"HERO_SUBTITLE": "Twój zdalny wieczór filmowy bez opóźnień. Bez konta, bez śledzenia, bez reklam. Udostępnij link i naciśnijcie play razem.",
|
||||
"HERO_MASCOT_ALT": "Uroczy koala stojący i patrzący w dół na przyciski pobierania",
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"TWITTER_DESCRIPTION": "Crie uma watch party na Netflix, Emby, Jellyfin, YouTube, Twitch e quase qualquer vídeo HTML5. Open source, privado e sem conta.",
|
||||
"NAV_FEATURES": "Recursos",
|
||||
"NAV_HOW_IT_WORKS": "Como Funciona",
|
||||
"SUPPORT_BANNER_TEXT": "O KoalaSync não consegue acessar o site do vídeo?",
|
||||
"SUPPORT_BANNER_CTA": "Corrigir acesso ao site",
|
||||
"SUPPORT_BANNER_DISMISS": "Fechar aviso",
|
||||
"HERO_TITLE": "Transforme qualquer vídeo em uma <span class='hero-highlight'>watch party</span>.",
|
||||
"HERO_SUBTITLE": "Sua sessão de cinema à distância, sem atrasos. Sem conta, sem rastreamento, sem anúncios. Compartilhe um link e deem play juntos.",
|
||||
"HERO_MASCOT_ALT": "Um coala fofo olhando para os botões de download",
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"TWITTER_DESCRIPTION": "Cria uma watch party na Netflix, Emby, Jellyfin, YouTube, Twitch e quase qualquer vídeo HTML5. Open source, privado e sem conta.",
|
||||
"NAV_FEATURES": "Funcionalidades",
|
||||
"NAV_HOW_IT_WORKS": "Como Funciona",
|
||||
"SUPPORT_BANNER_TEXT": "O KoalaSync não consegue aceder ao site do vídeo?",
|
||||
"SUPPORT_BANNER_CTA": "Corrigir acesso ao site",
|
||||
"SUPPORT_BANNER_DISMISS": "Fechar aviso",
|
||||
"HERO_TITLE": "Transforma qualquer vídeo numa <span class='hero-highlight'>watch party</span>.",
|
||||
"HERO_SUBTITLE": "A tua noite de cinema à distância, sem atrasos. Sem conta, sem rastreio, sem anúncios. Partilha um link e carreguem no play juntos.",
|
||||
"HERO_MASCOT_ALT": "Um coala fofo a olhar para os botões de download",
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"TWITTER_DESCRIPTION": "Запускайте watch party в Netflix, Emby, Jellyfin, YouTube, Twitch и почти любом HTML5-видео. Open source, приватно и без аккаунта.",
|
||||
"NAV_FEATURES": "Функции",
|
||||
"NAV_HOW_IT_WORKS": "Как это работает",
|
||||
"SUPPORT_BANNER_TEXT": "KoalaSync не может получить доступ к сайту с видео?",
|
||||
"SUPPORT_BANNER_CTA": "Исправить доступ к сайту",
|
||||
"SUPPORT_BANNER_DISMISS": "Закрыть уведомление",
|
||||
"HERO_TITLE": "Преврати любое видео в <span class='hero-highlight'>watch party</span>.",
|
||||
"HERO_SUBTITLE": "Ваш киновечер на расстоянии, без задержек. Без аккаунта, без слежки, без рекламы. Просто поделитесь ссылкой и нажмите play вместе.",
|
||||
"HERO_MASCOT_ALT": "Милый коала стоит и смотрит вниз на кнопки загрузки",
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"TWITTER_DESCRIPTION": "Netflix, Emby, Jellyfin, YouTube, Twitch ve neredeyse tüm HTML5 videolarında watch party başlatın. Açık kaynak, gizlilik odaklı ve hesapsız.",
|
||||
"NAV_FEATURES": "Özellikler",
|
||||
"NAV_HOW_IT_WORKS": "Nasıl Çalışır",
|
||||
"SUPPORT_BANNER_TEXT": "KoalaSync video sitesine erişemiyor mu?",
|
||||
"SUPPORT_BANNER_CTA": "Site erişimini düzelt",
|
||||
"SUPPORT_BANNER_DISMISS": "Bildirimi kapat",
|
||||
"HERO_TITLE": "Herhangi bir videoyu <span class='hero-highlight'>watch party</span>'ye dönüştür.",
|
||||
"HERO_SUBTITLE": "Gecikmesiz uzaktan film gecen. Hesap yok, takip yok, reklam yok. Sadece bir bağlantı paylaş ve birlikte oynatın.",
|
||||
"HERO_MASCOT_ALT": "İndirme düğmelerine bakan sevimli bir koala",
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"TWITTER_DESCRIPTION": "Запускайте watch party у Netflix, Emby, Jellyfin, YouTube, Twitch і майже будь-якому HTML5-відео. Open source, приватно й без акаунта.",
|
||||
"NAV_FEATURES": "особливості",
|
||||
"NAV_HOW_IT_WORKS": "Як це працює",
|
||||
"SUPPORT_BANNER_TEXT": "KoalaSync не має доступу до сайту з відео?",
|
||||
"SUPPORT_BANNER_CTA": "Виправити доступ до сайту",
|
||||
"SUPPORT_BANNER_DISMISS": "Закрити сповіщення",
|
||||
"HERO_TITLE": "Перетвори будь-яке відео на <span class='hero-highlight'>watch party</span>.",
|
||||
"HERO_SUBTITLE": "Ваш кіновечір на відстані, без затримок. Без акаунта, без стеження, без реклами. Просто поділіться посиланням і натисніть play разом.",
|
||||
"HERO_MASCOT_ALT": "Мила коала стоїть і дивиться вниз на кнопки завантаження",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user