mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-26 12:08:15 +00:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 213d2867fc | |||
| 90a58eb507 | |||
| 72113b6504 | |||
| a0954079a1 | |||
| 9251ae6aff | |||
| 511e042b0b | |||
| 372c384a19 | |||
| 79715046a8 | |||
| 2ae79386a0 | |||
| 883e49433e | |||
| 59135b319e | |||
| ae2238e3bc | |||
| 77790a279c | |||
| 2df048621b | |||
| 17c3ea295a | |||
| 5d3cee4271 | |||
| 1149de7ab7 | |||
| 4882e058f1 | |||
| 8d7085c97e | |||
| 630869260b | |||
| e9d735cd39 | |||
| aee3588f43 | |||
| 7f898e6eb4 | |||
| d2d4f5f3ea | |||
| 6e625e3a38 | |||
| 354500873c | |||
| 9e0d294758 | |||
| 36c4c492d3 | |||
| e9dc299fc9 | |||
| ee0d68d8d3 | |||
| f2eee6dc41 | |||
| 8fee98834c | |||
| 771da1cf82 |
@@ -6,9 +6,9 @@ name: Beta Server Image
|
||||
#
|
||||
# Tags produced (on ghcr.io/<owner>/<repo>):
|
||||
# - beta moving channel pointer to the newest build
|
||||
# - <branch-slug> e.g. feature-host-control-mode
|
||||
# - <branch-slug> e.g. feature-textchat
|
||||
# - sha-<short-commit> immutable, pin to an exact build
|
||||
# - <custom> only on manual run, e.g. hostcontrol01
|
||||
# - <custom> only on manual run, e.g. chatbeta01
|
||||
# Never ':latest' (flavor: latest=false).
|
||||
#
|
||||
# Remove this workflow once the feature is merged & released the normal way.
|
||||
@@ -16,11 +16,11 @@ name: Beta Server Image
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- feature/host-control-mode
|
||||
- feature/textchat
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Extra immutable tag for this build (e.g. hostcontrol01). Optional.'
|
||||
description: 'Extra immutable tag for this build (e.g. chatbeta01). Optional.'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
@@ -37,6 +37,27 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
package-lock.json
|
||||
server/package-lock.json
|
||||
|
||||
- name: Install root dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install server dependencies
|
||||
run: npm ci
|
||||
working-directory: server
|
||||
|
||||
- name: Run verification suite
|
||||
run: npm run verify
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
@@ -23,6 +23,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
@@ -72,6 +72,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -109,7 +111,11 @@ jobs:
|
||||
sed -i "s/\"softwareVersion\": \".*\"/\"softwareVersion\": \"$VERSION\"/" website/template.html
|
||||
echo " ✓ website/template.html -> softwareVersion $VERSION"
|
||||
|
||||
# 6. README.md — version badge & banner
|
||||
# 6. website/llms.txt — machine-readable release metadata
|
||||
sed -i "s/Current website release: .*/Current website release: $VERSION/" website/llms.txt
|
||||
echo " ✓ website/llms.txt -> $VERSION"
|
||||
|
||||
# 7. README.md — version badge & banner
|
||||
sed -i "s|Release-v[0-9]\+\.[0-9]\+\.[0-9]\+-blue|Release-v$VERSION-blue|g" README.md
|
||||
sed -i "s/New v[0-9]\+\.[0-9]\+\.[0-9]\+ Release/New v$VERSION Release/g" README.md
|
||||
echo " ✓ README.md -> v$VERSION"
|
||||
@@ -120,7 +126,7 @@ jobs:
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add extension/manifest.base.json shared/constants.js package.json website/version.json website/template.html README.md
|
||||
git add extension/manifest.base.json shared/constants.js package.json website/version.json website/template.html website/llms.txt README.md
|
||||
git commit -m "chore(release): update versions to $GITHUB_REF_NAME [skip ci]" || echo "No changes to commit"
|
||||
git push origin HEAD:main
|
||||
env:
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ Please note that by participating in this project, you agree to abide by our [Co
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js** v18+
|
||||
- **Node.js** v20.9+
|
||||
- **Docker** (for local relay server testing)
|
||||
|
||||
### Quick Start
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml"><img src="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml/badge.svg" alt="Release Status"></a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v2.6.3-blue?logo=github" alt="GitHub release"></a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v3.0.0-blue?logo=github" alt="GitHub release"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue" alt="License"></a>
|
||||
<a href="https://addons.mozilla.org/de/firefox/addon/koalasync/"><img src="https://img.shields.io/badge/Firefox-Download-orange?logo=firefoxbrowser&logoColor=white" alt="Firefox Add-on"></a>
|
||||
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc"><img src="https://img.shields.io/badge/Chrome-Download-blue?logo=googlechrome&logoColor=white" alt="Chrome Extension"></a>
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<p align="center"><i>KoalaSync is a lightweight Browser Extension and Relay Server for synchronized video playback on almost any website with a video element—YouTube, Twitch, Netflix, Emby, Jellyfin, and beyond. Built with a focus on <b>Data Sovereignty</b> and <b>Performance</b>.</i></p>
|
||||
|
||||
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.6.3 Release!</b> — See what's changed</a></p>
|
||||
<p align="center"><a href="docs/CHANGELOG.md"><b>New v3.0.0 Release!</b> — See what's changed</a></p>
|
||||
|
||||
### 🌟 Why KoalaSync?
|
||||
|
||||
|
||||
@@ -4,6 +4,31 @@ 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
|
||||
|
||||
@@ -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 视频网站同步观看。"
|
||||
}
|
||||
}
|
||||
|
||||
+561
-108
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);
|
||||
});
|
||||
});
|
||||
+70
-25
@@ -6,13 +6,15 @@
|
||||
|
||||
*/
|
||||
|
||||
|
||||
|
||||
(function() {
|
||||
|
||||
|
||||
(function() {
|
||||
|
||||
// Injection Guard: Check if already injected AND context is valid
|
||||
|
||||
try {
|
||||
try {
|
||||
|
||||
if (window.koalaSyncInjected && chrome.runtime.id) {
|
||||
window.koalaSyncTargetActive = true;
|
||||
return;
|
||||
}
|
||||
@@ -86,9 +88,34 @@
|
||||
if (_suppressTimers[state]) {
|
||||
|
||||
clearTimeout(_suppressTimers[state]);
|
||||
|
||||
if (_suppressTimers[state]) {
|
||||
|
||||
|
||||
delete _suppressTimers[state];
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// --- Seek Relay Filtering ---
|
||||
|
||||
// Minimum seek delta (seconds) to report. Prevents HLS/DASH buffering micro-seeks
|
||||
|
||||
// from being relayed to peers as user-initiated seeks.
|
||||
|
||||
const MIN_SEEK_DELTA = 2.0;
|
||||
|
||||
let lastReportedSeekTime = null; // last currentTime we relayed as a SEEK
|
||||
|
||||
let seekDebounceTimer = null; // debounce timer for rapid seek events
|
||||
|
||||
let expectedSeekTime = null; // strictly track programmatic seeks
|
||||
|
||||
|
||||
|
||||
const PAGE_API_SEEK_BRIDGE = 1;
|
||||
// Accurate Disney+ playhead pushed by the MAIN-world page-API bridge
|
||||
// (background.js installPageApiSeekBridge). The isolated content world
|
||||
// can't read the page's media player directly.
|
||||
let disneyPageApiTime = null;
|
||||
if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
|
||||
@@ -851,11 +878,17 @@
|
||||
document.addEventListener('DOMContentLoaded', retry, { once: true });
|
||||
|
||||
} else {
|
||||
|
||||
document.addEventListener('DOMContentLoaded', retry, { once: true });
|
||||
|
||||
} else {
|
||||
|
||||
|
||||
setTimeout(retry, 50);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
const host = hcmEl('div', 'all:initial');
|
||||
|
||||
const root = host.attachShadow({ mode: 'open' });
|
||||
@@ -864,9 +897,11 @@
|
||||
|
||||
b.append(hcmEl('span', null, '● ' + hcmStrings.badge), hcmEl('span', 'text-decoration:underline', hcmStrings.resync));
|
||||
|
||||
}
|
||||
|
||||
const host = hcmEl('div', 'all:initial');
|
||||
b.addEventListener('click', hcmExitDesync);
|
||||
|
||||
root.appendChild(b);
|
||||
|
||||
document.body.appendChild(host);
|
||||
|
||||
hcmBadgeHost = host;
|
||||
|
||||
@@ -951,8 +986,14 @@
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
|
||||
|
||||
// Multiple videos found → pick the best one
|
||||
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
|
||||
|
||||
|
||||
@@ -1136,9 +1177,10 @@
|
||||
|
||||
|
||||
|
||||
const ctx = initAudioContext();
|
||||
|
||||
if (!ctx) return null;
|
||||
src.connect(dryGain);
|
||||
|
||||
dryGain.connect(ctx.destination);
|
||||
|
||||
src.connect(compressor);
|
||||
|
||||
compressor.connect(compGain);
|
||||
@@ -1271,8 +1313,9 @@
|
||||
|
||||
}
|
||||
|
||||
|
||||
chain.compressor.release.value = params.release ?? 0.300;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// --- Episode Auto-Sync: Detection ---
|
||||
|
||||
@@ -1308,8 +1351,9 @@
|
||||
const ep = title.match(/(?:Episode|Folge|Ep\.?|#)\s*(\d+)/i);
|
||||
|
||||
if (ep) return `EP${String(ep[1]).padStart(3, '0')}`;
|
||||
// Extract a canonical episode identifier from a title string.
|
||||
|
||||
|
||||
return null;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1552,8 +1596,9 @@
|
||||
matches() { return isGoogleDrivePlayerFrame(); },
|
||||
playPauseButtonSelector: '.ytp-play-button'
|
||||
},
|
||||
lobbyPollTimer = null;
|
||||
|
||||
{
|
||||
name: 'twitch-player-buttons',
|
||||
urls: ['twitch.tv'],
|
||||
playPauseButtonSelector: '[data-a-target="player-play-pause-button"]'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -150,6 +150,18 @@ export function requestOriginPermission(chromeApi, originPattern) {
|
||||
).catch(() => false);
|
||||
}
|
||||
|
||||
export function containsOriginPermission(chromeApi, originPattern) {
|
||||
if (typeof originPattern !== 'string' || !originPattern) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return callBooleanPermissionMethod(
|
||||
chromeApi,
|
||||
'contains',
|
||||
{ origins: [originPattern] },
|
||||
{ timeoutMs: 1000 }
|
||||
).catch(() => null);
|
||||
}
|
||||
|
||||
export function isHostAccessError(error) {
|
||||
const message = typeof error?.message === 'string' ? error.message : String(error || '');
|
||||
return /host permission|permission to access (?:this|the|respective) host|cannot access contents of/i.test(message);
|
||||
|
||||
@@ -245,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."
|
||||
}
|
||||
|
||||
@@ -245,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."
|
||||
}
|
||||
|
||||
@@ -245,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."
|
||||
}
|
||||
|
||||
@@ -245,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."
|
||||
}
|
||||
|
||||
@@ -245,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."
|
||||
}
|
||||
|
||||
@@ -245,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": "まだメッセージはありません。チャットはライブのみです。"
|
||||
}
|
||||
|
||||
@@ -245,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": "아직 메시지가 없습니다. 채팅은 실시간 전용입니다."
|
||||
}
|
||||
|
||||
@@ -245,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."
|
||||
}
|
||||
|
||||
@@ -245,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."
|
||||
}
|
||||
|
||||
@@ -245,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."
|
||||
}
|
||||
|
||||
@@ -245,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."
|
||||
}
|
||||
|
||||
@@ -245,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": "Сообщений пока нет. Чат работает только в реальном времени."
|
||||
}
|
||||
|
||||
@@ -245,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."
|
||||
}
|
||||
|
||||
@@ -245,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": "Повідомлень ще немає. Чат працює лише наживо."
|
||||
}
|
||||
|
||||
@@ -245,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.3",
|
||||
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
|
||||
"name": "__MSG_appName__",
|
||||
"short_name": "KoalaSync",
|
||||
"version": "3.0.0",
|
||||
"description": "__MSG_appDesc__",
|
||||
"permissions": [
|
||||
"storage",
|
||||
"tabs",
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
const GOOGLE_DRIVE_HOST = 'drive.google.com';
|
||||
const GOOGLE_DRIVE_PLAYER_HOST = 'youtube.googleapis.com';
|
||||
export const GOOGLE_DRIVE_PLAYER_ORIGIN_PATTERN = 'https://youtube.googleapis.com/*';
|
||||
export const GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED = 'google_drive_player_access_required';
|
||||
export const GOOGLE_DRIVE_PLAYER_AMBIGUOUS = 'google_drive_player_ambiguous';
|
||||
|
||||
export function isGoogleDriveUrl(value) {
|
||||
try {
|
||||
return new URL(value).hostname.toLowerCase() === GOOGLE_DRIVE_HOST;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function inspectMediaFrame() {
|
||||
const videos = Array.from(document.querySelectorAll('video'));
|
||||
const drivePlayerIframes = Array.from(document.querySelectorAll('iframe')).filter((frame) => {
|
||||
try {
|
||||
const url = new URL(frame.src);
|
||||
if (url.hostname.toLowerCase() !== 'youtube.googleapis.com') return false;
|
||||
const origin = url.searchParams.get('origin') || url.searchParams.get('post_message_origin');
|
||||
return origin === 'https://drive.google.com';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
let bestVideoScore = 0;
|
||||
|
||||
for (const video of videos) {
|
||||
const width = video.videoWidth || video.offsetWidth || 0;
|
||||
const height = video.videoHeight || video.offsetHeight || 0;
|
||||
const duration = Number.isFinite(video.duration) ? video.duration : 0;
|
||||
bestVideoScore = Math.max(bestVideoScore, (width * height) + (duration * 100));
|
||||
}
|
||||
|
||||
return {
|
||||
href: window.location.href,
|
||||
videoCount: videos.length,
|
||||
videoScore: bestVideoScore,
|
||||
frameArea: Math.max(0, window.innerWidth) * Math.max(0, window.innerHeight),
|
||||
drivePlayerIframeCount: drivePlayerIframes.length,
|
||||
parentFrameVisible: window.__koalaParentFrameVisibility?.visible ?? null,
|
||||
parentFrameArea: window.__koalaParentFrameVisibility?.area ?? null
|
||||
};
|
||||
}
|
||||
|
||||
export function installParentFrameVisibilityProbe(token) {
|
||||
const handler = (event) => {
|
||||
if (event.source !== window.parent
|
||||
|| event.data?.type !== 'KOALASYNC_FRAME_VISIBILITY'
|
||||
|| event.data?.token !== token) {
|
||||
return;
|
||||
}
|
||||
window.__koalaParentFrameVisibility = {
|
||||
visible: event.data.visible === true,
|
||||
area: Number.isFinite(event.data.area) ? event.data.area : 0
|
||||
};
|
||||
window.removeEventListener('message', handler);
|
||||
};
|
||||
window.addEventListener('message', handler);
|
||||
}
|
||||
|
||||
export async function dispatchParentFrameVisibilityProbe(token) {
|
||||
let count = 0;
|
||||
for (const frame of document.querySelectorAll('iframe')) {
|
||||
try {
|
||||
const url = new URL(frame.src);
|
||||
const origin = url.searchParams.get('origin') || url.searchParams.get('post_message_origin');
|
||||
if (url.hostname.toLowerCase() !== 'youtube.googleapis.com'
|
||||
|| origin !== 'https://drive.google.com') {
|
||||
continue;
|
||||
}
|
||||
const rect = frame.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(frame);
|
||||
const area = Math.max(0, rect.width) * Math.max(0, rect.height);
|
||||
const intersectsViewport = rect.bottom > 0
|
||||
&& rect.right > 0
|
||||
&& rect.top < window.innerHeight
|
||||
&& rect.left < window.innerWidth;
|
||||
const browserReportsVisible = typeof frame.checkVisibility === 'function'
|
||||
? frame.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
|
||||
: true;
|
||||
const visible = area > 0
|
||||
&& intersectsViewport
|
||||
&& browserReportsVisible
|
||||
&& style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& Number(style.opacity) !== 0;
|
||||
frame.contentWindow?.postMessage({
|
||||
type: 'KOALASYNC_FRAME_VISIBILITY',
|
||||
token,
|
||||
visible,
|
||||
area
|
||||
}, 'https://youtube.googleapis.com');
|
||||
count++;
|
||||
} catch {
|
||||
// Ignore unrelated or not-yet-initialized frames.
|
||||
}
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
return count;
|
||||
}
|
||||
|
||||
function isGoogleDrivePlayerFrameUrl(value) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.hostname.toLowerCase() !== GOOGLE_DRIVE_PLAYER_HOST || url.pathname !== '/embed/') {
|
||||
return false;
|
||||
}
|
||||
const origin = url.searchParams.get('origin') || url.searchParams.get('post_message_origin');
|
||||
return origin === 'https://drive.google.com';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createGoogleDrivePlayerAccessError() {
|
||||
const error = new Error('Google Drive player frame access is required');
|
||||
error.code = GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED;
|
||||
error.originPattern = GOOGLE_DRIVE_PLAYER_ORIGIN_PATTERN;
|
||||
return error;
|
||||
}
|
||||
|
||||
function createGoogleDrivePlayerAmbiguousError() {
|
||||
const error = new Error('Google Drive player frame could not be identified safely');
|
||||
error.code = GOOGLE_DRIVE_PLAYER_AMBIGUOUS;
|
||||
return error;
|
||||
}
|
||||
|
||||
export function selectGoogleDrivePlayerFrame(injectionResults) {
|
||||
let candidates = (Array.isArray(injectionResults) ? injectionResults : [])
|
||||
.filter(entry => Number.isInteger(entry?.frameId) && entry?.result)
|
||||
.filter(entry => isGoogleDrivePlayerFrameUrl(entry.result.href));
|
||||
|
||||
if (candidates.length > 1) {
|
||||
const confirmedVisible = candidates.filter(entry => entry.result.parentFrameVisible === true);
|
||||
if (confirmedVisible.length > 0) {
|
||||
candidates = confirmedVisible;
|
||||
} else {
|
||||
const notConfirmedHidden = candidates.filter(entry =>
|
||||
entry.result.parentFrameVisible !== false
|
||||
);
|
||||
if (notConfirmedHidden.length !== 1) return null;
|
||||
candidates = notConfirmedHidden;
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort((left, right) => {
|
||||
const visibilityRank = result => result.parentFrameVisible === true
|
||||
? 2
|
||||
: result.parentFrameVisible === false
|
||||
? 0
|
||||
: 1;
|
||||
const leftVisible = visibilityRank(left.result);
|
||||
const rightVisible = visibilityRank(right.result);
|
||||
if (leftVisible !== rightVisible) return rightVisible - leftVisible;
|
||||
const leftParentArea = Number.isFinite(left.result.parentFrameArea)
|
||||
? left.result.parentFrameArea
|
||||
: left.result.frameArea;
|
||||
const rightParentArea = Number.isFinite(right.result.parentFrameArea)
|
||||
? right.result.parentFrameArea
|
||||
: right.result.frameArea;
|
||||
if (leftParentArea !== rightParentArea) return rightParentArea - leftParentArea;
|
||||
const leftHasVideo = left.result.videoCount > 0 ? 1 : 0;
|
||||
const rightHasVideo = right.result.videoCount > 0 ? 1 : 0;
|
||||
if (leftHasVideo !== rightHasVideo) return rightHasVideo - leftHasVideo;
|
||||
if (left.result.videoScore !== right.result.videoScore) {
|
||||
return right.result.videoScore - left.result.videoScore;
|
||||
}
|
||||
return right.result.frameArea - left.result.frameArea;
|
||||
});
|
||||
|
||||
return candidates[0] || null;
|
||||
}
|
||||
|
||||
export async function resolveMediaScriptTarget(chromeApi, tabId, tabUrl, {
|
||||
attempts = 8,
|
||||
retryDelayMs = 200
|
||||
} = {}) {
|
||||
const topFrameTarget = { tabId };
|
||||
if (!isGoogleDriveUrl(tabUrl)) return topFrameTarget;
|
||||
|
||||
let fallbackFrame = null;
|
||||
let drivePlayerIframeDetected = false;
|
||||
let ambiguousPlayerFramesDetected = false;
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
let results;
|
||||
try {
|
||||
results = await chromeApi.scripting.executeScript({
|
||||
target: { tabId, allFrames: true },
|
||||
func: inspectMediaFrame
|
||||
});
|
||||
} catch {
|
||||
let topFrameResult = null;
|
||||
try {
|
||||
const topResults = await chromeApi.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: inspectMediaFrame
|
||||
});
|
||||
topFrameResult = Array.isArray(topResults) ? topResults[0]?.result : null;
|
||||
} catch {
|
||||
// The generic injection path below remains the final source of truth.
|
||||
}
|
||||
if (topFrameResult?.drivePlayerIframeCount > 0) {
|
||||
throw createGoogleDrivePlayerAccessError();
|
||||
}
|
||||
return topFrameTarget;
|
||||
}
|
||||
|
||||
const topFrame = results.find(entry => entry?.frameId === 0);
|
||||
if (topFrame?.result?.drivePlayerIframeCount > 0) {
|
||||
drivePlayerIframeDetected = true;
|
||||
}
|
||||
const playerFrameCount = results.filter(entry =>
|
||||
isGoogleDrivePlayerFrameUrl(entry?.result?.href)
|
||||
).length;
|
||||
if (playerFrameCount > 1) {
|
||||
const token = `${tabId}:${attempt}:${Date.now()}:${Math.random()}`;
|
||||
try {
|
||||
await chromeApi.scripting.executeScript({
|
||||
target: { tabId, allFrames: true },
|
||||
func: installParentFrameVisibilityProbe,
|
||||
args: [token]
|
||||
});
|
||||
await chromeApi.scripting.executeScript({
|
||||
target: { tabId },
|
||||
func: dispatchParentFrameVisibilityProbe,
|
||||
args: [token]
|
||||
});
|
||||
results = await chromeApi.scripting.executeScript({
|
||||
target: { tabId, allFrames: true },
|
||||
func: inspectMediaFrame
|
||||
});
|
||||
} catch {
|
||||
// Ambiguous frames are retried below; never guess a stale player.
|
||||
}
|
||||
}
|
||||
const selectedFrame = selectGoogleDrivePlayerFrame(results);
|
||||
if (!selectedFrame && playerFrameCount > 1) {
|
||||
ambiguousPlayerFramesDetected = true;
|
||||
}
|
||||
if (selectedFrame?.result?.videoCount > 0) {
|
||||
return { tabId, frameIds: [selectedFrame.frameId] };
|
||||
}
|
||||
if (selectedFrame) fallbackFrame = selectedFrame;
|
||||
|
||||
if (attempt < attempts - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelayMs));
|
||||
}
|
||||
}
|
||||
|
||||
if (!fallbackFrame && ambiguousPlayerFramesDetected) {
|
||||
throw createGoogleDrivePlayerAmbiguousError();
|
||||
}
|
||||
if (!fallbackFrame && drivePlayerIframeDetected) {
|
||||
throw createGoogleDrivePlayerAccessError();
|
||||
}
|
||||
|
||||
return fallbackFrame
|
||||
? { tabId, frameIds: [fallbackFrame.frameId] }
|
||||
: topFrameTarget;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED,
|
||||
GOOGLE_DRIVE_PLAYER_AMBIGUOUS,
|
||||
isGoogleDriveUrl,
|
||||
resolveMediaScriptTarget,
|
||||
selectGoogleDrivePlayerFrame
|
||||
} from './media-frame-target.js';
|
||||
|
||||
const drivePlayerUrl = 'https://youtube.googleapis.com/embed/?enablejsapi=1&origin=https%3A%2F%2Fdrive.google.com';
|
||||
|
||||
describe('Google Drive media-frame targeting', () => {
|
||||
it('recognizes Drive tabs without treating other Google pages as Drive', () => {
|
||||
expect(isGoogleDriveUrl('https://drive.google.com/drive/u/1/search?q=mp4')).toBe(true);
|
||||
expect(isGoogleDriveUrl('https://docs.google.com/document/d/1/edit')).toBe(false);
|
||||
});
|
||||
|
||||
it('selects the visible frame containing the real video', () => {
|
||||
const selected = selectGoogleDrivePlayerFrame([
|
||||
{ frameId: 0, result: { href: 'https://drive.google.com/drive/u/1/search?q=mp4', videoCount: 0, videoScore: 0, frameArea: 900000 } },
|
||||
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 1900000, frameArea: 900000, parentFrameVisible: true } },
|
||||
{ frameId: 5, result: { href: drivePlayerUrl, videoCount: 0, videoScore: 0, frameArea: 0, parentFrameVisible: false } }
|
||||
]);
|
||||
|
||||
expect(selected.frameId).toBe(4);
|
||||
});
|
||||
|
||||
it('never lets a hidden loaded player outrank the visible Drive player', () => {
|
||||
const selected = selectGoogleDrivePlayerFrame([
|
||||
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 927600, frameArea: 900000, parentFrameVisible: true, parentFrameArea: 900000 } },
|
||||
{ frameId: 5, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 1281600, frameArea: 900000, parentFrameVisible: false, parentFrameArea: 900000 } }
|
||||
]);
|
||||
|
||||
expect(selected.frameId).toBe(4);
|
||||
});
|
||||
|
||||
it('refuses to guess when parent visibility is unavailable', () => {
|
||||
const selected = selectGoogleDrivePlayerFrame([
|
||||
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 927600, frameArea: 900000 } },
|
||||
{ frameId: 5, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 1281600, frameArea: 900000 } }
|
||||
]);
|
||||
|
||||
expect(selected).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores unrelated video frames inside a Drive tab', () => {
|
||||
const selected = selectGoogleDrivePlayerFrame([
|
||||
{ frameId: 3, result: { href: 'https://example.com/embed', videoCount: 1, videoScore: 3000000, frameArea: 1200000 } },
|
||||
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 927600, frameArea: 900000 } }
|
||||
]);
|
||||
|
||||
expect(selected.frameId).toBe(4);
|
||||
});
|
||||
|
||||
it('falls back to the visible Drive player frame while its video is loading', () => {
|
||||
const selected = selectGoogleDrivePlayerFrame([
|
||||
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 0, videoScore: 0, frameArea: 900000, parentFrameVisible: true } },
|
||||
{ frameId: 5, result: { href: drivePlayerUrl, videoCount: 0, videoScore: 0, frameArea: 0, parentFrameVisible: false } }
|
||||
]);
|
||||
|
||||
expect(selected.frameId).toBe(4);
|
||||
});
|
||||
|
||||
it('reports ambiguity instead of selecting a stale frame after retries', async () => {
|
||||
const ambiguousResults = [
|
||||
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 927600, frameArea: 900000 } },
|
||||
{ frameId: 5, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 1281600, frameArea: 900000 } }
|
||||
];
|
||||
const executeScript = vi.fn().mockResolvedValue(ambiguousResults);
|
||||
|
||||
await expect(resolveMediaScriptTarget(
|
||||
{ scripting: { executeScript } },
|
||||
42,
|
||||
'https://drive.google.com/drive/u/1/search?q=mp4',
|
||||
{ attempts: 1, retryDelayMs: 0 }
|
||||
)).rejects.toMatchObject({ code: GOOGLE_DRIVE_PLAYER_AMBIGUOUS });
|
||||
});
|
||||
|
||||
it('probes all frames and returns only the frame with the Drive video', async () => {
|
||||
const executeScript = vi.fn()
|
||||
.mockResolvedValueOnce([
|
||||
{ frameId: 0, result: { href: 'https://drive.google.com/drive/u/1/search?q=mp4', videoCount: 0, videoScore: 0, frameArea: 900000 } },
|
||||
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 0, videoScore: 0, frameArea: 900000 } }
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ frameId: 0, result: { href: 'https://drive.google.com/drive/u/1/search?q=mp4', videoCount: 0, videoScore: 0, frameArea: 900000 } },
|
||||
{ frameId: 4, result: { href: drivePlayerUrl, videoCount: 1, videoScore: 1900000, frameArea: 900000 } }
|
||||
]);
|
||||
|
||||
await expect(resolveMediaScriptTarget(
|
||||
{ scripting: { executeScript } },
|
||||
42,
|
||||
'https://drive.google.com/drive/u/1/search?q=mp4',
|
||||
{ retryDelayMs: 0 }
|
||||
)).resolves.toEqual({ tabId: 42, frameIds: [4] });
|
||||
|
||||
expect(executeScript).toHaveBeenCalledWith({
|
||||
target: { tabId: 42, allFrames: true },
|
||||
func: expect.any(Function)
|
||||
});
|
||||
});
|
||||
|
||||
it('reports missing access when Drive exposes a player iframe but the frame cannot be inspected', async () => {
|
||||
const executeScript = vi.fn().mockResolvedValue([
|
||||
{
|
||||
frameId: 0,
|
||||
result: {
|
||||
href: 'https://drive.google.com/drive/u/1/search?q=mp4',
|
||||
videoCount: 0,
|
||||
videoScore: 0,
|
||||
frameArea: 900000,
|
||||
drivePlayerIframeCount: 1
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
await expect(resolveMediaScriptTarget(
|
||||
{ scripting: { executeScript } },
|
||||
42,
|
||||
'https://drive.google.com/drive/u/1/search?q=mp4',
|
||||
{ attempts: 1, retryDelayMs: 0 }
|
||||
)).rejects.toMatchObject({ code: GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED });
|
||||
});
|
||||
|
||||
it('reports missing access when the all-frame probe itself is rejected', async () => {
|
||||
const executeScript = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('Cannot access contents of the frame'))
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
frameId: 0,
|
||||
result: {
|
||||
href: 'https://drive.google.com/drive/u/1/search?q=mp4',
|
||||
videoCount: 0,
|
||||
videoScore: 0,
|
||||
frameArea: 900000,
|
||||
drivePlayerIframeCount: 1
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
await expect(resolveMediaScriptTarget(
|
||||
{ scripting: { executeScript } },
|
||||
42,
|
||||
'https://drive.google.com/drive/u/1/search?q=mp4',
|
||||
{ attempts: 1, retryDelayMs: 0 }
|
||||
)).rejects.toMatchObject({ code: GOOGLE_DRIVE_PLAYER_ACCESS_REQUIRED });
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
export function initTabManager({
|
||||
getCurrentTabId,
|
||||
reactivateCurrentTarget,
|
||||
ensureState
|
||||
ensureState,
|
||||
sendToCurrentContent
|
||||
}) {
|
||||
chrome.storage.onChanged.addListener(async (changes, area) => {
|
||||
if (area !== 'local' || !changes.audioSettings) return;
|
||||
@@ -9,7 +10,7 @@ export function initTabManager({
|
||||
const tabId = getCurrentTabId();
|
||||
if (!tabId) return;
|
||||
|
||||
chrome.tabs.sendMessage(tabId, {
|
||||
sendToCurrentContent({
|
||||
action: 'APPLY_AUDIO_SETTINGS',
|
||||
settings: changes.audioSettings.newValue
|
||||
}).catch(() => {});
|
||||
|
||||
+53
-3
@@ -1215,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;
|
||||
@@ -1323,7 +1329,7 @@
|
||||
}
|
||||
@keyframes floatHint {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-4px); }
|
||||
50% { transform: translateY(-2px); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -1371,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>
|
||||
@@ -1446,7 +1452,7 @@
|
||||
<option value="" data-i18n="OPTION_SELECT_TAB">-- Select a Tab --</option>
|
||||
</select>
|
||||
<!-- Hint Tooltip -->
|
||||
<div id="targetTabHint" style="display: none; position: absolute; top: -25px; right: 0; background: var(--accent); color: var(--text-on-green); padding: 4px 8px; border-radius: 6px; font-size: 11px; font-weight: bold; pointer-events: none; animation: floatHint 2s ease-in-out infinite; box-shadow: 0 4px 12px color-mix(in oklch, var(--accent), transparent 60%); z-index: 10;" data-i18n="HINT_SELECT_VIDEO">
|
||||
<div id="targetTabHint" style="display: none; position: absolute; top: -8px; right: 0; background: var(--accent); color: var(--text-on-green); padding: 4px 8px; border-radius: 6px; font-size: 11px; font-weight: bold; pointer-events: none; animation: floatHint 2s ease-in-out infinite; box-shadow: 0 2px 6px color-mix(in oklch, var(--accent), transparent 82%); z-index: 10;" data-i18n="HINT_SELECT_VIDEO">
|
||||
Select your video here!
|
||||
<div style="position: absolute; bottom: -4px; right: 20px; width: 8px; height: 8px; background: var(--accent); transform: rotate(45deg);"></div>
|
||||
</div>
|
||||
@@ -1615,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">
|
||||
|
||||
+123
-70
@@ -1,10 +1,20 @@
|
||||
import { EVENTS, OFFICIAL_LANDING_PAGE_URL, SUPPORT_URL, getReviewUrl } from './shared/constants.js';
|
||||
import { BLACKLIST_DOMAINS } from './shared/blacklist.js';
|
||||
import { BLACKLIST_ALLOWLIST_DOMAINS, BLACKLIST_DOMAINS } from './shared/blacklist.js';
|
||||
import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from './shared/names.js';
|
||||
import { loadLocale, translateDOM, getMessage, getSystemLanguage } from './i18n.js';
|
||||
import { TITLE_PRIVACY_MODES, normalizeSendTabTitle, normalizeTabTitle } from './title-privacy.js';
|
||||
import { normalizeRoomId } from './chat-session.js';
|
||||
import './shared/invite-links.js';
|
||||
import { normalizeTabId, requestOriginPermission } from './host-access.js';
|
||||
import { isTabUrlFiltered } from './tab-filter.js';
|
||||
|
||||
let pendingInviteRoomId = '';
|
||||
let pendingInviteChatKey = '';
|
||||
let pendingRoomCreation = false;
|
||||
|
||||
function normalizeChatKey(value) {
|
||||
return typeof value === 'string' && /^[A-Za-z0-9_-]{21}[AQgw]$/.test(value) ? value : '';
|
||||
}
|
||||
|
||||
const elements = {
|
||||
tabs: document.querySelectorAll('.tabs .tab-btn'),
|
||||
@@ -59,6 +69,10 @@ const elements = {
|
||||
playBtn: document.getElementById('playBtn'),
|
||||
pauseBtn: document.getElementById('pauseBtn'),
|
||||
autoSyncNextEpisode: document.getElementById('autoSyncNextEpisode'),
|
||||
chatEnabled: document.getElementById('chatEnabled'),
|
||||
chatPosition: document.getElementById('chatPosition'),
|
||||
chatSize: document.getElementById('chatSize'),
|
||||
chatStartMode: document.getElementById('chatStartMode'),
|
||||
sendTabTitle: document.getElementById('sendTabTitle'),
|
||||
mediaTitlePrivacyMode: document.getElementById('mediaTitlePrivacyMode'),
|
||||
episodeLobbyCard: document.getElementById('episodeLobbyCard'),
|
||||
@@ -328,7 +342,7 @@ function setRoomRefreshCooldown() {
|
||||
async function init() {
|
||||
// Local-only by design — settings and room credentials never come from
|
||||
// storage.sync (only onboardingComplete + dismissedHints live there).
|
||||
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab', 'themeMode', 'themePalette']);
|
||||
const localData = await chrome.storage.local.get(['serverUrl', 'useCustomServer', 'roomId', 'password', 'chatKey', 'chatEnabled', 'chatPosition', 'chatSize', 'chatStartMode', 'username', 'filterNoise', 'autoSyncNextEpisode', 'sendTabTitle', 'mediaTitlePrivacyMode', 'titlePrivacyMode', 'forceSyncMode', 'browserNotifications', 'autoCopyInvite', 'locale', 'audioSettings', 'activeTab', 'themeMode', 'themePalette']);
|
||||
|
||||
let activeLang = localData.locale;
|
||||
if (!activeLang) {
|
||||
@@ -353,12 +367,17 @@ async function init() {
|
||||
}
|
||||
|
||||
elements.serverUrl.value = localData.serverUrl || '';
|
||||
elements.roomId.value = localData.roomId || '';
|
||||
elements.roomId.value = normalizeRoomId(localData.roomId);
|
||||
elements.password.value = localData.password || '';
|
||||
elements.username.value = username;
|
||||
syncDevToolsVisibility();
|
||||
if (elements.filterNoise) elements.filterNoise.checked = localData.filterNoise !== false;
|
||||
if (elements.autoSyncNextEpisode) elements.autoSyncNextEpisode.checked = localData.autoSyncNextEpisode !== false;
|
||||
if (elements.chatEnabled) elements.chatEnabled.checked = localData.chatEnabled === true;
|
||||
if (elements.chatPosition) elements.chatPosition.value = ['left', 'detached'].includes(localData.chatPosition) ? localData.chatPosition : 'right';
|
||||
if (elements.chatSize) elements.chatSize.value = ['compact', 'large', 'custom'].includes(localData.chatSize) ? localData.chatSize : 'standard';
|
||||
if (elements.chatStartMode) elements.chatStartMode.value = localData.chatStartMode === 'open' ? 'open' : 'bubble';
|
||||
syncChatSettingsState();
|
||||
const legacyTitlePrivacyMode = Object.values(TITLE_PRIVACY_MODES).includes(localData.titlePrivacyMode) ? localData.titlePrivacyMode : TITLE_PRIVACY_MODES.FULL;
|
||||
const mediaTitlePrivacyMode = Object.values(TITLE_PRIVACY_MODES).includes(localData.mediaTitlePrivacyMode) ? localData.mediaTitlePrivacyMode : legacyTitlePrivacyMode;
|
||||
if (elements.sendTabTitle) elements.sendTabTitle.checked = normalizeSendTabTitle(localData.sendTabTitle, legacyTitlePrivacyMode);
|
||||
@@ -384,7 +403,7 @@ async function init() {
|
||||
}
|
||||
|
||||
toggleUIState(!!localData.roomId);
|
||||
updateUI(localData.roomId, localData.password, localData.useCustomServer, localData.serverUrl);
|
||||
updateUI(localData.roomId, localData.password, localData.useCustomServer, localData.serverUrl, localData.chatKey);
|
||||
refreshLogs();
|
||||
refreshHistory();
|
||||
|
||||
@@ -656,15 +675,20 @@ if (elements.hostControlToggle) {
|
||||
});
|
||||
}
|
||||
|
||||
function updateUI(roomId, password, useCustomServer = false, serverUrl = '') {
|
||||
function updateUI(roomId, password, useCustomServer = false, serverUrl = '', chatKey = '') {
|
||||
const inRoom = !!roomId;
|
||||
toggleUIState(inRoom);
|
||||
if (inRoom) {
|
||||
let invite = `${OFFICIAL_LANDING_PAGE_URL}/join.html#join:${roomId}:${password}`;
|
||||
if (useCustomServer) {
|
||||
const encodedUrl = encodeURIComponent(serverUrl || '');
|
||||
invite += `:1:${encodedUrl}`;
|
||||
}
|
||||
const validChatKey = normalizeChatKey(chatKey);
|
||||
let invite = validChatKey
|
||||
? `${OFFICIAL_LANDING_PAGE_URL}/join.html${globalThis.KoalaSyncInviteLinks.buildJ2Hash({
|
||||
roomId,
|
||||
password,
|
||||
chatKey: validChatKey,
|
||||
serverUrl: useCustomServer ? serverUrl : ''
|
||||
})}`
|
||||
: `${OFFICIAL_LANDING_PAGE_URL}/join.html#join:${roomId}:${password}`;
|
||||
if (!validChatKey && useCustomServer) invite += `:1:${encodeURIComponent(serverUrl || '')}`;
|
||||
elements.inviteLink.value = invite;
|
||||
|
||||
if (window.justCreatedRoom) {
|
||||
@@ -1134,18 +1158,9 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
|
||||
const filteredTabs = tabs.filter(tab => {
|
||||
if (!tab.url || tab.url.startsWith('chrome://')) return false;
|
||||
if (isFilterActive && tab.id !== parseInt(currentTargetTabId)) {
|
||||
const urlStr = tab.url.toLowerCase();
|
||||
if (BLACKLIST_DOMAINS.some(d => {
|
||||
const domain = d.toLowerCase();
|
||||
try {
|
||||
const hostname = new URL(tab.url).hostname.toLowerCase();
|
||||
if (domain.endsWith('.')) return hostname.startsWith(domain) || hostname.includes('.' + domain);
|
||||
if (domain.includes('.')) return hostname === domain || hostname.endsWith('.' + domain);
|
||||
} catch {
|
||||
/* ignore invalid URLs */
|
||||
}
|
||||
return urlStr.includes(domain);
|
||||
})) return false;
|
||||
if (isTabUrlFiltered(tab.url, BLACKLIST_DOMAINS, BLACKLIST_ALLOWLIST_DOMAINS)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
@@ -1393,41 +1408,21 @@ function updateRoomList(rooms) {
|
||||
function checkInviteLink() {
|
||||
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
||||
const tab = tabs[0];
|
||||
if (tab && tab.url && tab.url.startsWith(OFFICIAL_LANDING_PAGE_URL + '/') && tab.url.includes('#join:')) {
|
||||
if (tab && tab.url && tab.url.startsWith(OFFICIAL_LANDING_PAGE_URL + '/')) {
|
||||
try {
|
||||
const rawHash = tab.url.split('#join:')[1];
|
||||
if (!rawHash) return;
|
||||
const parts = rawHash.split(':');
|
||||
if (parts.length >= 2) {
|
||||
const roomId = parts.shift();
|
||||
let useCustomServer = false;
|
||||
let serverUrl = '';
|
||||
const invite = globalThis.KoalaSyncInviteLinks.parseInviteHash(new URL(tab.url).hash);
|
||||
if (!invite) return;
|
||||
elements.roomId.value = normalizeRoomId(invite.roomId);
|
||||
elements.password.value = invite.password;
|
||||
pendingInviteRoomId = elements.roomId.value;
|
||||
pendingInviteChatKey = normalizeChatKey(invite.chatKey);
|
||||
|
||||
const last = parts[parts.length - 1];
|
||||
const secondToLast = parts[parts.length - 2];
|
||||
const decodedLast = decodeURIComponent(last || '');
|
||||
const isCustom = secondToLast === '1' && (decodedLast.startsWith('ws://') || decodedLast.startsWith('wss://'));
|
||||
const isOfficial = secondToLast === '0' && last === '';
|
||||
elements.serverUrl.value = invite.serverUrl;
|
||||
setServerMode(invite.useCustomServer);
|
||||
chrome.storage.local.set({ serverUrl: invite.serverUrl, useCustomServer: invite.useCustomServer });
|
||||
|
||||
if (parts.length >= 3 && (isCustom || isOfficial)) {
|
||||
serverUrl = decodeURIComponent(parts.pop());
|
||||
useCustomServer = parts.pop() === '1';
|
||||
}
|
||||
|
||||
const password = parts.join(':');
|
||||
|
||||
elements.roomId.value = roomId;
|
||||
elements.password.value = password;
|
||||
|
||||
if (serverUrl || useCustomServer) {
|
||||
elements.serverUrl.value = serverUrl;
|
||||
setServerMode(useCustomServer);
|
||||
chrome.storage.local.set({ serverUrl, useCustomServer });
|
||||
}
|
||||
|
||||
elements.joinBtn.style.boxShadow = '0 0 15px var(--accent)';
|
||||
setTimeout(() => elements.joinBtn.style.boxShadow = '', 2000);
|
||||
}
|
||||
elements.joinBtn.style.boxShadow = '0 0 15px var(--accent)';
|
||||
setTimeout(() => elements.joinBtn.style.boxShadow = '', 2000);
|
||||
} catch (_e) {
|
||||
// Malformed invite link, ignore
|
||||
}
|
||||
@@ -1462,6 +1457,41 @@ elements.autoSyncNextEpisode.addEventListener('change', () => {
|
||||
chrome.storage.local.set({ autoSyncNextEpisode: elements.autoSyncNextEpisode.checked });
|
||||
});
|
||||
|
||||
function syncChatSettingsState() {
|
||||
const enabled = elements.chatEnabled?.checked === true;
|
||||
for (const control of [elements.chatPosition, elements.chatSize, elements.chatStartMode]) {
|
||||
if (control) control.disabled = !enabled;
|
||||
}
|
||||
document.querySelectorAll('[data-chat-setting]').forEach(row => {
|
||||
row.classList.toggle('is-disabled', !enabled);
|
||||
row.setAttribute('aria-disabled', String(!enabled));
|
||||
});
|
||||
}
|
||||
|
||||
if (elements.chatEnabled) {
|
||||
elements.chatEnabled.addEventListener('change', () => {
|
||||
syncChatSettingsState();
|
||||
chrome.storage.local.set({ chatEnabled: elements.chatEnabled.checked });
|
||||
});
|
||||
}
|
||||
if (elements.chatPosition) {
|
||||
elements.chatPosition.addEventListener('change', () => {
|
||||
const chatPosition = ['left', 'detached'].includes(elements.chatPosition.value) ? elements.chatPosition.value : 'right';
|
||||
chrome.storage.local.set({ chatPosition });
|
||||
});
|
||||
}
|
||||
if (elements.chatSize) {
|
||||
elements.chatSize.addEventListener('change', () => {
|
||||
const chatSize = ['compact', 'large', 'custom'].includes(elements.chatSize.value) ? elements.chatSize.value : 'standard';
|
||||
chrome.storage.local.set({ chatSize });
|
||||
});
|
||||
}
|
||||
if (elements.chatStartMode) {
|
||||
elements.chatStartMode.addEventListener('change', () => {
|
||||
chrome.storage.local.set({ chatStartMode: elements.chatStartMode.value === 'open' ? 'open' : 'bubble' });
|
||||
});
|
||||
}
|
||||
|
||||
if (elements.sendTabTitle) {
|
||||
elements.sendTabTitle.addEventListener('change', () => {
|
||||
chrome.storage.local.set({ sendTabTitle: elements.sendTabTitle.checked }, () => {
|
||||
@@ -1572,8 +1602,8 @@ if (elements.langSelector) {
|
||||
lastKnownPeers = res.peers || [];
|
||||
if (res.lastActionState) updateLastActionUI(res.lastActionState, res.peers);
|
||||
|
||||
const data = await chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl']);
|
||||
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
|
||||
const data = await chrome.storage.local.get(['roomId', 'password', 'chatKey', 'useCustomServer', 'serverUrl']);
|
||||
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl, data.chatKey);
|
||||
|
||||
if (res.pendingTargetTabId) {
|
||||
showSiteAccessNotice(
|
||||
@@ -1588,8 +1618,8 @@ if (elements.langSelector) {
|
||||
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
|
||||
} else {
|
||||
applyConnectionStatus('disconnected');
|
||||
const data = await chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl']);
|
||||
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
|
||||
const data = await chrome.storage.local.get(['roomId', 'password', 'chatKey', 'useCustomServer', 'serverUrl']);
|
||||
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl, data.chatKey);
|
||||
await populateTabs();
|
||||
}
|
||||
});
|
||||
@@ -1717,7 +1747,12 @@ function showError(msg) {
|
||||
|
||||
// --- Action Handlers ---
|
||||
elements.roomId.addEventListener('input', () => {
|
||||
elements.roomId.value = elements.roomId.value.replace(/[^a-zA-Z0-9\-]/g, '');
|
||||
elements.roomId.value = normalizeRoomId(elements.roomId.value);
|
||||
pendingRoomCreation = false;
|
||||
if (pendingInviteRoomId && elements.roomId.value !== pendingInviteRoomId) {
|
||||
pendingInviteRoomId = '';
|
||||
pendingInviteChatKey = '';
|
||||
}
|
||||
});
|
||||
|
||||
elements.joinBtn.addEventListener('click', async () => {
|
||||
@@ -1728,8 +1763,8 @@ elements.joinBtn.addEventListener('click', async () => {
|
||||
isProcessingConnection = false;
|
||||
return;
|
||||
}
|
||||
const roomIdInput = elements.roomId.value.trim();
|
||||
const isCreating = !roomIdInput;
|
||||
const roomIdInput = normalizeRoomId(elements.roomId.value);
|
||||
const isCreating = pendingRoomCreation || !roomIdInput;
|
||||
|
||||
elements.joinBtn.disabled = true;
|
||||
elements.joinBtn.textContent = isCreating ? getMessage('BTN_STATE_CREATING') : getMessage('BTN_STATE_JOINING');
|
||||
@@ -1789,17 +1824,34 @@ elements.joinBtn.addEventListener('click', async () => {
|
||||
self.crypto.getRandomValues(array);
|
||||
password = Array.from(array, byte => chars[byte % chars.length]).join('');
|
||||
elements.password.value = password;
|
||||
window.justCreatedRoom = true;
|
||||
}
|
||||
let chatKey = roomId === pendingInviteRoomId ? pendingInviteChatKey : '';
|
||||
if (isCreating) {
|
||||
const created = await new Promise(resolve => chrome.runtime.sendMessage({ type: 'CREATE_CHAT_KEY' }, resolve));
|
||||
chatKey = normalizeChatKey(created?.chatKey);
|
||||
if (!chatKey) {
|
||||
pendingRoomCreation = false;
|
||||
elements.joinBtn.disabled = false;
|
||||
elements.joinBtn.textContent = getMessage('BTN_JOIN_ROOM');
|
||||
if (joinBtnTimeout) { clearTimeout(joinBtnTimeout); joinBtnTimeout = null; }
|
||||
isProcessingConnection = false;
|
||||
showError(getMessage('CHAT_SEND_FAILED'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (isCreating) window.justCreatedRoom = true;
|
||||
pendingInviteRoomId = '';
|
||||
pendingInviteChatKey = '';
|
||||
pendingRoomCreation = false;
|
||||
|
||||
await chrome.storage.local.set({ serverUrl, roomId, password, useCustomServer: useCustom });
|
||||
await chrome.storage.local.set({ serverUrl, roomId, password, chatKey, useCustomServer: useCustom });
|
||||
elements.roomId.value = roomId;
|
||||
|
||||
// Tell background to connect
|
||||
chrome.runtime.sendMessage({ type: 'CONNECT' });
|
||||
|
||||
// UI Feedback: Immediately switch state for better responsiveness
|
||||
updateUI(roomId, password, useCustom, serverUrl);
|
||||
updateUI(roomId, password, useCustom, serverUrl, chatKey);
|
||||
});
|
||||
|
||||
elements.leaveBtn.addEventListener('click', async () => {
|
||||
@@ -1807,7 +1859,7 @@ elements.leaveBtn.addEventListener('click', async () => {
|
||||
isProcessingConnection = true;
|
||||
clearConnectionErrorTimer();
|
||||
chrome.runtime.sendMessage({ type: 'LEAVE_ROOM' });
|
||||
await chrome.storage.local.set({ roomId: '', password: '' });
|
||||
await chrome.storage.local.set({ roomId: '', password: '', chatKey: '' });
|
||||
elements.roomId.value = '';
|
||||
elements.password.value = '';
|
||||
lastKnownPeers = [];
|
||||
@@ -1833,6 +1885,7 @@ function handleCreateRoom() {
|
||||
const password = secureGenerateId();
|
||||
elements.roomId.value = roomId;
|
||||
elements.password.value = password;
|
||||
pendingRoomCreation = true;
|
||||
window.justCreatedRoom = true;
|
||||
|
||||
// Auto-connect
|
||||
@@ -2012,7 +2065,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
failForceSyncTime();
|
||||
return;
|
||||
}
|
||||
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (retryResponse) => {
|
||||
chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId }, (retryResponse) => {
|
||||
if (chrome.runtime.lastError || !retryResponse || !Number.isFinite(retryResponse.currentTime)) {
|
||||
failForceSyncTime();
|
||||
return;
|
||||
@@ -2020,7 +2073,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
|
||||
sendForceSync(retryResponse.currentTime);
|
||||
});
|
||||
};
|
||||
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (response) => {
|
||||
chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId }, (response) => {
|
||||
if (Number.isFinite(response?.currentTime)) {
|
||||
sendForceSync(response.currentTime);
|
||||
return;
|
||||
@@ -2355,15 +2408,15 @@ chrome.runtime.onMessage.addListener((msg) => {
|
||||
|
||||
if (msg.success) {
|
||||
// Final confirmation of join from background
|
||||
chrome.storage.local.get(['roomId', 'password', 'useCustomServer', 'serverUrl'], (data) => {
|
||||
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl);
|
||||
chrome.storage.local.get(['roomId', 'password', 'chatKey', 'useCustomServer', 'serverUrl'], (data) => {
|
||||
updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl, data.chatKey);
|
||||
const syncTabBtn = document.querySelector('.tab-btn[data-tab="tab-sync"]');
|
||||
if (syncTabBtn) syncTabBtn.click();
|
||||
showSelectVideoHint();
|
||||
});
|
||||
} else {
|
||||
// Join failed: reset UI state
|
||||
chrome.storage.local.set({ roomId: '', password: '' });
|
||||
chrome.storage.local.set({ roomId: '', password: '', chatKey: '' });
|
||||
elements.roomId.value = '';
|
||||
elements.password.value = '';
|
||||
updateUI(null, null);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
function hostnameMatchesDomain(hostname, domain) {
|
||||
const normalizedHostname = String(hostname || '').toLowerCase();
|
||||
const normalizedDomain = String(domain || '').toLowerCase();
|
||||
return normalizedDomain
|
||||
&& (normalizedHostname === normalizedDomain
|
||||
|| normalizedHostname.endsWith(`.${normalizedDomain}`));
|
||||
}
|
||||
|
||||
export function isTabUrlFiltered(url, blacklistDomains, allowlistDomains = []) {
|
||||
const urlString = String(url || '');
|
||||
|
||||
try {
|
||||
const hostname = new URL(urlString).hostname.toLowerCase();
|
||||
if (allowlistDomains.some(domain => hostnameMatchesDomain(hostname, domain))) {
|
||||
return false;
|
||||
}
|
||||
return blacklistDomains.some(domain => hostnameMatchesDomain(hostname, domain));
|
||||
} catch {
|
||||
const normalizedUrl = urlString.toLowerCase();
|
||||
return blacklistDomains.some(domain => normalizedUrl.includes(String(domain).toLowerCase()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isTabUrlFiltered } from './tab-filter.js';
|
||||
|
||||
const blacklist = ['google.com', 'mail.google.com'];
|
||||
const allowlist = ['drive.google.com'];
|
||||
|
||||
describe('tab URL filtering', () => {
|
||||
it('keeps Google Drive selectable despite the google.com parent-domain rule', () => {
|
||||
expect(isTabUrlFiltered(
|
||||
'https://drive.google.com/drive/u/1/search?q=mp4',
|
||||
blacklist,
|
||||
allowlist
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it('continues to filter other Google subdomains', () => {
|
||||
expect(isTabUrlFiltered('https://mail.google.com/mail/u/0/', blacklist, allowlist)).toBe(true);
|
||||
expect(isTabUrlFiltered('https://www.google.com/search?q=video', blacklist, allowlist)).toBe(true);
|
||||
});
|
||||
});
|
||||
Generated
+783
-1254
File diff suppressed because it is too large
Load Diff
+17
-9
@@ -1,9 +1,12 @@
|
||||
{
|
||||
"name": "koalasync",
|
||||
"version": "2.6.3",
|
||||
"version": "3.0.0",
|
||||
"description": "KoalaSync Build Scripts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build:extension": "node scripts/build-extension.cjs",
|
||||
"indexnow": "node website/submit-indexnow.cjs",
|
||||
@@ -15,17 +18,22 @@
|
||||
"verify": "node scripts/verify-release.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"@playwright/test": "^1.62.0",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"addons-linter": "^10.9.0",
|
||||
"archiver": "^7.0.1",
|
||||
"archiver": "^8.0.0",
|
||||
"esbuild": "^0.28.1",
|
||||
"eslint": "^10.4.0",
|
||||
"eslint": "^10.8.0",
|
||||
"eslint-plugin-no-unsanitized": "^4.1.5",
|
||||
"fs-extra": "^11.2.0",
|
||||
"fs-extra": "^11.4.0",
|
||||
"htmlnano": "^3.4.0",
|
||||
"sharp": "^0.34.5",
|
||||
"svgo": "^4.0.1",
|
||||
"vitest": "^4.1.9"
|
||||
"sharp": "^0.35.3",
|
||||
"svgo": "^4.0.2",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"overrides": {
|
||||
"brace-expansion": "5.0.8",
|
||||
"fast-uri": "3.1.4",
|
||||
"postcss": "8.5.18"
|
||||
}
|
||||
}
|
||||
|
||||
+63
-11
@@ -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');
|
||||
@@ -98,14 +98,15 @@ function loadTimelineFns(hostname, document = makeDocument(), pageApiTime = null
|
||||
].join('\n'))({ location: { hostname } }, document);
|
||||
}
|
||||
|
||||
function loadPlayerFixFns(hostname) {
|
||||
function loadPlayerFixFns(hostname, href = `https://${hostname}/`) {
|
||||
return Function('window', [
|
||||
extractFunction('hostMatchesUrl', source),
|
||||
extractFunction('matchesPlayerUrls', source),
|
||||
extractFunction('isGoogleDrivePlayerFrame', source),
|
||||
extractFunction('getPlayerActionFixes', source),
|
||||
extractFunction('getActivePlayerActionFix', source),
|
||||
'return { getPlayerActionFixes, getActivePlayerActionFix };'
|
||||
].join('\n'))({ location: { hostname } });
|
||||
'return { isGoogleDrivePlayerFrame, getPlayerActionFixes, getActivePlayerActionFix };'
|
||||
].join('\n'))({ location: { hostname, href } });
|
||||
}
|
||||
|
||||
const disneyFns = loadTimelineFns('www.disneyplus.com', makeDocument(), {
|
||||
@@ -147,6 +148,28 @@ const twitchFixFns = loadPlayerFixFns('player.twitch.tv');
|
||||
assert.equal(twitchFixFns.getActivePlayerActionFix().name, 'twitch-player-buttons');
|
||||
assert.deepEqual(twitchFixFns.getActivePlayerActionFix().urls, ['twitch.tv']);
|
||||
|
||||
const googleDriveFixFns = loadPlayerFixFns(
|
||||
'youtube.googleapis.com',
|
||||
'https://youtube.googleapis.com/embed/?origin=https%3A%2F%2Fdrive.google.com'
|
||||
);
|
||||
assert.equal(googleDriveFixFns.getActivePlayerActionFix().name, 'google-drive-player-buttons');
|
||||
assert.deepEqual(googleDriveFixFns.getActivePlayerActionFix().urls, ['youtube.googleapis.com']);
|
||||
const unrelatedGoogleEmbedFixFns = loadPlayerFixFns(
|
||||
'youtube.googleapis.com',
|
||||
'https://youtube.googleapis.com/embed/?origin=https%3A%2F%2Fexample.com'
|
||||
);
|
||||
assert.equal(unrelatedGoogleEmbedFixFns.getActivePlayerActionFix(), null);
|
||||
const googleDrivePlayerFns = Function('window', [
|
||||
extractFunction('isGoogleDrivePlayerFrame', source),
|
||||
'return { isGoogleDrivePlayerFrame };'
|
||||
].join('\n'))({
|
||||
location: {
|
||||
hostname: 'youtube.googleapis.com',
|
||||
href: 'https://youtube.googleapis.com/embed/?origin=https%3A%2F%2Fdrive.google.com'
|
||||
}
|
||||
});
|
||||
assert.equal(googleDrivePlayerFns.isGoogleDrivePlayerFrame(), true);
|
||||
|
||||
const genericFixFns = loadPlayerFixFns('example.com');
|
||||
assert.equal(genericFixFns.getActivePlayerActionFix(), null);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { cwd } from 'node:process';
|
||||
import {
|
||||
HOST_ACCESS_REQUIRED_STATUS,
|
||||
addTabHostAccessRequest,
|
||||
containsOriginPermission,
|
||||
describeTabUrl,
|
||||
inspectTabHostAccess,
|
||||
isHostAccessError,
|
||||
@@ -122,6 +123,17 @@ const callbackPermissionChrome = {
|
||||
};
|
||||
assert.equal(await requestOriginPermission(callbackPermissionChrome, 'https://video.example/*'), true);
|
||||
assert.equal(await requestOriginPermission({ permissions: {} }, 'https://video.example/*'), null);
|
||||
let containedOriginRequest = null;
|
||||
assert.equal(await containsOriginPermission({
|
||||
permissions: {
|
||||
contains: async request => {
|
||||
containedOriginRequest = request;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}, 'https://youtube.googleapis.com/*'), true);
|
||||
assert.deepEqual(containedOriginRequest, { origins: ['https://youtube.googleapis.com/*'] });
|
||||
assert.equal(await containsOriginPermission({ permissions: {} }, 'https://youtube.googleapis.com/*'), null);
|
||||
|
||||
const background = fs.readFileSync(path.join(cwd(), 'extension', 'background.js'), 'utf8');
|
||||
const popup = fs.readFileSync(path.join(cwd(), 'extension', 'popup.js'), 'utf8');
|
||||
|
||||
@@ -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,7 @@ 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']],
|
||||
|
||||
+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);
|
||||
|
||||
@@ -178,3 +178,9 @@ export const BLACKLIST_DOMAINS = [
|
||||
'lichess.org',
|
||||
'skribbl.io'
|
||||
];
|
||||
|
||||
// Explicit exceptions for useful media pages whose parent domain is filtered.
|
||||
// Google Drive is otherwise matched indirectly by the broad `google.com` entry.
|
||||
export const BLACKLIST_ALLOWLIST_DOMAINS = [
|
||||
'drive.google.com'
|
||||
];
|
||||
|
||||
+7
-2
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
export const PROTOCOL_VERSION = "1.0.0";
|
||||
export const APP_VERSION = "2.6.3";
|
||||
export const APP_VERSION = "3.0.0";
|
||||
|
||||
export const OFFICIAL_SERVER_URL = 'wss://syncserver.koalastuff.net';
|
||||
export const OFFICIAL_LANDING_PAGE_URL = 'https://sync.koalastuff.net';
|
||||
@@ -57,6 +57,9 @@ export const EVENTS = {
|
||||
EPISODE_READY: "episode_ready", // Response: loaded the episode and paused at 0:00
|
||||
EPISODE_LOBBY_CANCEL: "episode_lobby_cancel", // Broadcast: cancel active lobby and resume
|
||||
|
||||
// Ephemeral end-to-end encrypted chat
|
||||
CHAT_MESSAGE: "chat_message", // Ciphertext relay; no server history
|
||||
|
||||
// Ping / Latency
|
||||
PING: "ping", // { t: timestamp, target?: peerId } — empty target = server echo
|
||||
PONG: "pong" // server responds with same { t } for client RTT calculation
|
||||
@@ -77,7 +80,9 @@ export const CONTROL_MODES = {
|
||||
// simply ignore the field. Add a flag here as each server-gated feature lands.
|
||||
export const CAPABILITIES = {
|
||||
HOST_CONTROL: 'host-control',
|
||||
CO_HOST: 'co-host' // owner promotes guests to additional controllers
|
||||
CO_HOST: 'co-host', // owner promotes guests to additional controllers
|
||||
CHAT: 'chat', // legacy server capability used by the first chat beta
|
||||
CHAT_V1: 'chat-v1' // versioned client/server chat wire contract
|
||||
};
|
||||
|
||||
export const HEARTBEAT_INTERVAL = 15000; // 15s
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
(function exposeInviteLinks(root) {
|
||||
const J2_PREFIX = '#j2:';
|
||||
const LEGACY_PREFIX = '#join:';
|
||||
|
||||
function parseJ2Hash(hash) {
|
||||
if (typeof hash !== 'string' || !hash.startsWith(J2_PREFIX)) return null;
|
||||
const params = new URLSearchParams(hash.slice(J2_PREFIX.length));
|
||||
const roomId = params.get('r') || '';
|
||||
const password = params.get('p') || '';
|
||||
const chatKey = params.get('k') || '';
|
||||
const serverUrl = params.get('u') || '';
|
||||
if (!roomId || !password || !chatKey) return null;
|
||||
return {
|
||||
format: 'j2',
|
||||
roomId,
|
||||
password,
|
||||
chatKey,
|
||||
useCustomServer: serverUrl.length > 0,
|
||||
serverUrl
|
||||
};
|
||||
}
|
||||
|
||||
function parseLegacyHash(hash) {
|
||||
if (typeof hash !== 'string' || !hash.startsWith(LEGACY_PREFIX)) return null;
|
||||
const parts = hash.slice(LEGACY_PREFIX.length).split(':');
|
||||
const roomId = parts.shift() || '';
|
||||
let useCustomServer = false;
|
||||
let serverUrl = '';
|
||||
|
||||
if (parts.length >= 3) {
|
||||
const flag = parts.at(-2);
|
||||
const rawUrl = parts.at(-1) || '';
|
||||
let decodedUrl = '';
|
||||
try {
|
||||
decodedUrl = decodeURIComponent(rawUrl);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
const custom = flag === '1' && /^(?:ws|wss):\/\//.test(decodedUrl);
|
||||
const official = flag === '0' && rawUrl === '';
|
||||
if (custom || official) {
|
||||
parts.splice(-2, 2);
|
||||
useCustomServer = custom;
|
||||
serverUrl = custom ? decodedUrl : '';
|
||||
}
|
||||
}
|
||||
|
||||
const password = parts.join(':');
|
||||
if (!roomId || !password) return null;
|
||||
return {
|
||||
format: 'legacy',
|
||||
roomId,
|
||||
password,
|
||||
chatKey: '',
|
||||
useCustomServer,
|
||||
serverUrl
|
||||
};
|
||||
}
|
||||
|
||||
function parseInviteHash(hash) {
|
||||
return parseJ2Hash(hash) || parseLegacyHash(hash);
|
||||
}
|
||||
|
||||
function buildJ2Hash({ roomId, password, chatKey, serverUrl = '' }) {
|
||||
if (!roomId || !password || !chatKey) throw new TypeError('roomId, password, and chatKey are required');
|
||||
const params = new URLSearchParams({ r: roomId, p: password, k: chatKey });
|
||||
if (serverUrl) params.set('u', serverUrl);
|
||||
return `${J2_PREFIX}${params.toString()}`;
|
||||
}
|
||||
|
||||
root.KoalaSyncInviteLinks = Object.freeze({
|
||||
J2_PREFIX,
|
||||
LEGACY_PREFIX,
|
||||
parseInviteHash,
|
||||
buildJ2Hash
|
||||
});
|
||||
})(globalThis);
|
||||
@@ -0,0 +1,58 @@
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
beforeAll(async () => {
|
||||
await import('./invite-links.js');
|
||||
});
|
||||
|
||||
describe('invite links', () => {
|
||||
it('round-trips official and custom j2 links', () => {
|
||||
const api = globalThis.KoalaSyncInviteLinks;
|
||||
const official = api.buildJ2Hash({
|
||||
roomId: 'SAPPHIRE-DUCK-49',
|
||||
password: '0X:UK3C',
|
||||
chatKey: 'R5Ti1nxp0crfAFHf3gVncw'
|
||||
});
|
||||
expect(api.parseInviteHash(official)).toEqual({
|
||||
format: 'j2',
|
||||
roomId: 'SAPPHIRE-DUCK-49',
|
||||
password: '0X:UK3C',
|
||||
chatKey: 'R5Ti1nxp0crfAFHf3gVncw',
|
||||
useCustomServer: false,
|
||||
serverUrl: ''
|
||||
});
|
||||
|
||||
const custom = api.buildJ2Hash({
|
||||
roomId: 'SILENT-EAGLE-90',
|
||||
password: '30PXPD',
|
||||
chatKey: 'R5Ti1nxp0crfAFHf3gVncw',
|
||||
serverUrl: 'wss://sync.example.test/socket?region=eu'
|
||||
});
|
||||
expect(api.parseInviteHash(custom)).toMatchObject({
|
||||
useCustomServer: true,
|
||||
serverUrl: 'wss://sync.example.test/socket?region=eu'
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps legacy links compatible, including colons in passwords', () => {
|
||||
const parse = globalThis.KoalaSyncInviteLinks.parseInviteHash;
|
||||
expect(parse('#join:ROOM-1:pass:with:colons')).toMatchObject({
|
||||
format: 'legacy',
|
||||
password: 'pass:with:colons',
|
||||
chatKey: '',
|
||||
useCustomServer: false
|
||||
});
|
||||
expect(parse('#join:ROOM-1:pass:with:colons:1:wss%3A%2F%2Frelay.example')).toMatchObject({
|
||||
password: 'pass:with:colons',
|
||||
useCustomServer: true,
|
||||
serverUrl: 'wss://relay.example'
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects incomplete or malformed invite hashes', () => {
|
||||
const parse = globalThis.KoalaSyncInviteLinks.parseInviteHash;
|
||||
expect(parse('#j2:r=ROOM&p=PASS')).toBeNull();
|
||||
expect(parse('#join:ROOM')).toBeNull();
|
||||
expect(parse('#join:ROOM:PASS:1:%E0%A4%A')).toBeNull();
|
||||
expect(parse('#other:r=ROOM&p=PASS&k=KEY')).toBeNull();
|
||||
});
|
||||
});
|
||||
+3
-1
@@ -8,7 +8,9 @@ export default defineConfig({
|
||||
'server/**/*.test.js',
|
||||
'server/**/*.test.mjs',
|
||||
'shared/**/*.test.js',
|
||||
'shared/**/*.test.mjs'
|
||||
'shared/**/*.test.mjs',
|
||||
'extension/**/*.test.js',
|
||||
'extension/**/*.test.mjs'
|
||||
],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
|
||||
+1
-1
@@ -135,7 +135,7 @@
|
||||
<div class="container">
|
||||
<p>© 2026 KoalaSync. Open source under the MIT License.</p>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
|
||||
<a href="/imprint" style="color: var(--text-muted); text-decoration: none;">Legal Notice</a>
|
||||
<a href="https://koalastuff.net/legal" style="color: var(--text-muted); text-decoration: none;">Legal Notice</a>
|
||||
<a href="/privacy" style="color: var(--text-muted); text-decoration: none;">Privacy Policy</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+24
-27
@@ -457,15 +457,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
setTimeout(() => {
|
||||
const isInstalled = document.documentElement.dataset.koalasyncInstalled === 'true';
|
||||
|
||||
if (window.location.hash.startsWith('#join:')) {
|
||||
const parts = window.location.hash.split(':');
|
||||
if (parts.length >= 3) {
|
||||
const roomId = parts[1];
|
||||
const password = parts[2];
|
||||
const serverFlag = parts[3] || '0';
|
||||
const serverUrl = parts[4] ? decodeURIComponent(parts[4]) : '';
|
||||
const invite = globalThis.KoalaSyncInviteLinks?.parseInviteHash(window.location.hash);
|
||||
if (invite) {
|
||||
const { roomId, password, chatKey, useCustomServer, serverUrl } = invite;
|
||||
|
||||
if (isJoinPage) {
|
||||
if (isJoinPage) {
|
||||
const displayRoom = document.getElementById('display-room-id');
|
||||
const actions = document.getElementById('join-actions');
|
||||
if (displayRoom) displayRoom.textContent = roomId;
|
||||
@@ -527,14 +523,15 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
detail: {
|
||||
roomId,
|
||||
password,
|
||||
useCustomServer: serverFlag === '1',
|
||||
serverUrl: serverUrl
|
||||
chatKey,
|
||||
useCustomServer,
|
||||
serverUrl
|
||||
}
|
||||
}));
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else {
|
||||
// Fallback banner for index.html
|
||||
if (!document.getElementById('koala-banner')) {
|
||||
const banner = document.createElement('div');
|
||||
@@ -564,22 +561,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Global listener for Join Button
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target && e.target.id === 'webJoinBtn') {
|
||||
e.target.textContent = 'JOINING...';
|
||||
e.target.disabled = true;
|
||||
window.dispatchEvent(new CustomEvent('KOALASYNC_JOIN_REQUEST', {
|
||||
detail: {
|
||||
roomId,
|
||||
password,
|
||||
useCustomServer: serverFlag === '1',
|
||||
serverUrl: serverUrl
|
||||
}
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
// Global listener for Join Button
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target && e.target.id === 'webJoinBtn') {
|
||||
e.target.textContent = 'JOINING...';
|
||||
e.target.disabled = true;
|
||||
window.dispatchEvent(new CustomEvent('KOALASYNC_JOIN_REQUEST', {
|
||||
detail: {
|
||||
roomId,
|
||||
password,
|
||||
chatKey,
|
||||
useCustomServer,
|
||||
serverUrl
|
||||
}
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
}, 600); // 600ms delay to ensure bridge.js has set the dataset
|
||||
};
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
+103
-36
@@ -5,6 +5,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const { execFileSync } = require('child_process');
|
||||
const sharp = require('sharp');
|
||||
const esbuild = require('esbuild');
|
||||
const htmlnano = require('htmlnano');
|
||||
@@ -285,6 +286,12 @@ async function compile() {
|
||||
const appName = `app.${appHash}.min.js`;
|
||||
const appSRI = sha384(appMin);
|
||||
|
||||
const inviteLinksRaw = fs.readFileSync(path.join(websiteDir, '..', 'shared', 'invite-links.js'), 'utf8');
|
||||
const inviteLinksMin = await minifyJS(inviteLinksRaw);
|
||||
const inviteLinksHash = sha8(inviteLinksMin);
|
||||
const inviteLinksName = `invite-links.${inviteLinksHash}.min.js`;
|
||||
const inviteLinksSRI = sha384(inviteLinksMin);
|
||||
|
||||
const langRaw = fs.readFileSync(path.join(websiteDir, 'lang-init.js'), 'utf8');
|
||||
const langMin = await minifyJS(langRaw);
|
||||
const langHash = sha8(langMin);
|
||||
@@ -299,6 +306,7 @@ async function compile() {
|
||||
console.log(` Landing ${bundle.key}: ${bundle.name} (${(bundle.min.length/1024).toFixed(1)} KB)`);
|
||||
}
|
||||
console.log(` App: ${appName} (${(appMin.length/1024).toFixed(1)} KB, -${appPct}%)`);
|
||||
console.log(` Invite links: ${inviteLinksName} (${(inviteLinksMin.length/1024).toFixed(1)} KB)`);
|
||||
console.log(` Lang: ${langName} (${(langMin.length/1024).toFixed(1)} KB, -${langPct}%)`);
|
||||
|
||||
// ── 2. Clean stale minified output ──
|
||||
@@ -315,6 +323,7 @@ async function compile() {
|
||||
fs.writeFileSync(path.join(wwwDir, bundle.name), bundle.min);
|
||||
}
|
||||
fs.writeFileSync(path.join(wwwDir, appName), appMin);
|
||||
fs.writeFileSync(path.join(wwwDir, inviteLinksName), inviteLinksMin);
|
||||
fs.writeFileSync(path.join(wwwDir, langName), langMin);
|
||||
|
||||
// ── 3. Compile HTML templates ──
|
||||
@@ -541,7 +550,7 @@ async function compile() {
|
||||
}
|
||||
|
||||
// ── 5.5 Generate dynamic sitemap ──
|
||||
generateSitemap(wwwDir);
|
||||
generateSitemap(websiteDir, wwwDir);
|
||||
|
||||
// Auto-copy Google verification files and IndexNow/txt key files from website source to www root
|
||||
const websiteFiles = fs.readdirSync(websiteDir);
|
||||
@@ -654,6 +663,9 @@ async function compile() {
|
||||
html = html.replace(/(<script\b[^>]*?\bsrc=")((?:\.\.\/)*\/?)app\.min\.js"/g, (m, before, prefix) => {
|
||||
return `${before}${prefix}${appName}" integrity="${appSRI}" crossorigin="anonymous"`;
|
||||
});
|
||||
html = html.replace(/(<script\b[^>]*?\bsrc=")((?:\.\.\/)*\/?)invite-links\.min\.js"/g, (m, before, prefix) => {
|
||||
return `${before}${prefix}${inviteLinksName}" integrity="${inviteLinksSRI}" crossorigin="anonymous"`;
|
||||
});
|
||||
html = html.replace(/(<script\b[^>]*?\bsrc=")((?:\.\.\/)*\/?)lang-init\.min\.js"/g, (m, before, prefix) => {
|
||||
return `${before}${prefix}${langName}" integrity="${langSRI}" crossorigin="anonymous"`;
|
||||
});
|
||||
@@ -686,7 +698,7 @@ async function compile() {
|
||||
console.log('KoalaSync build finished successfully! Output: website/www/');
|
||||
}
|
||||
|
||||
function generateSitemap(wwwDir) {
|
||||
function generateSitemap(websiteDir, wwwDir) {
|
||||
const languages = [
|
||||
{ code: 'en', prefix: '', hreflang: 'en' },
|
||||
{ code: 'de', prefix: 'de/', hreflang: 'de' },
|
||||
@@ -705,52 +717,105 @@ function generateSitemap(wwwDir) {
|
||||
{ code: 'pt', prefix: 'pt/', hreflang: 'pt' }
|
||||
];
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const repoRoot = path.resolve(websiteDir, '..');
|
||||
const lastModifiedCache = new Map();
|
||||
const dirtySourceFiles = new Set();
|
||||
let gitHistoryAvailable = false;
|
||||
let usedDirtySourceDate = false;
|
||||
|
||||
try {
|
||||
const isInsideWorkTree = execFileSync(
|
||||
'git', ['rev-parse', '--is-inside-work-tree'],
|
||||
{ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
||||
).trim() === 'true';
|
||||
const isShallow = execFileSync(
|
||||
'git', ['rev-parse', '--is-shallow-repository'],
|
||||
{ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
||||
).trim() === 'true';
|
||||
gitHistoryAvailable = isInsideWorkTree && !isShallow;
|
||||
|
||||
if (gitHistoryAvailable) {
|
||||
const dirtyCommands = [
|
||||
['diff', '--name-only', '--'],
|
||||
['diff', '--cached', '--name-only', '--'],
|
||||
['ls-files', '--others', '--exclude-standard', '--']
|
||||
];
|
||||
for (const args of dirtyCommands) {
|
||||
const output = execFileSync(
|
||||
'git', args,
|
||||
{ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
||||
);
|
||||
for (const file of output.split(/\r?\n/).filter(Boolean)) {
|
||||
dirtySourceFiles.add(file.replaceAll('\\', '/'));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
gitHistoryAvailable = false;
|
||||
}
|
||||
|
||||
function getLastModified(sourceFiles) {
|
||||
const cacheKey = sourceFiles.slice().sort().join('\0');
|
||||
if (lastModifiedCache.has(cacheKey)) return lastModifiedCache.get(cacheKey);
|
||||
if (!gitHistoryAvailable) {
|
||||
lastModifiedCache.set(cacheKey, null);
|
||||
return null;
|
||||
}
|
||||
let date = null;
|
||||
try {
|
||||
if (sourceFiles.some(file => dirtySourceFiles.has(file))) {
|
||||
usedDirtySourceDate = true;
|
||||
date = new Date().toISOString().slice(0, 10);
|
||||
} else {
|
||||
const output = execFileSync(
|
||||
'git',
|
||||
['log', '-1', '--format=%cs', '--', ...sourceFiles],
|
||||
{ cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
||||
).trim();
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(output)) date = output;
|
||||
}
|
||||
} catch (_) {
|
||||
// Source archives and minimal deployment environments may not contain
|
||||
// Git metadata. Omitting lastmod is more accurate than inventing a date.
|
||||
}
|
||||
lastModifiedCache.set(cacheKey, date);
|
||||
return date;
|
||||
}
|
||||
|
||||
function lastmod(sourceFiles, indent = ' ') {
|
||||
const date = getLastModified(sourceFiles);
|
||||
return date ? `\n${indent}<lastmod>${date}</lastmod>` : '';
|
||||
}
|
||||
|
||||
let xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
xmlns:xhtml="http://www.w3.org/1999/xhtml">`;
|
||||
|
||||
// Static legal pages
|
||||
// Static project-specific policy and help pages
|
||||
xml += `
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/imprint</loc>
|
||||
<lastmod>${today}</lastmod>
|
||||
<loc>https://sync.koalastuff.net/privacy</loc>${lastmod(['website/privacy.html'])}
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/privacy</loc>
|
||||
<lastmod>${today}</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/site-access-help.html</loc>
|
||||
<lastmod>${today}</lastmod>
|
||||
<loc>https://sync.koalastuff.net/site-access-help.html</loc>${lastmod(['website/site-access-help.html'])}
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/de/impressum</loc>
|
||||
<lastmod>${today}</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://sync.koalastuff.net/de/datenschutz</loc>
|
||||
<lastmod>${today}</lastmod>
|
||||
<loc>https://sync.koalastuff.net/de/datenschutz</loc>${lastmod(['website/datenschutz-de.html'])}
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>`;
|
||||
|
||||
function addPage(relativePath, changefreq, priority) {
|
||||
function addPage(relativePath, changefreq, priority, templateSource) {
|
||||
for (const lang of languages) {
|
||||
const loc = `https://sync.koalastuff.net/${lang.prefix}${relativePath}`;
|
||||
const sourceFiles = [templateSource, `website/locales/${lang.code}.json`];
|
||||
xml += `
|
||||
<url>
|
||||
<loc>${loc}</loc>
|
||||
<lastmod>${today}</lastmod>
|
||||
<loc>${loc}</loc>${lastmod(sourceFiles)}
|
||||
<changefreq>${changefreq}</changefreq>
|
||||
<priority>${priority}</priority>`;
|
||||
for (const alt of languages) {
|
||||
@@ -765,24 +830,26 @@ function generateSitemap(wwwDir) {
|
||||
}
|
||||
}
|
||||
|
||||
addPage('', 'weekly', '1.0');
|
||||
addPage('alternatives', 'weekly', '0.7');
|
||||
addPage('', 'weekly', '1.0', 'website/template.html');
|
||||
addPage('alternatives', 'weekly', '0.7', 'website/alternatives/index.html');
|
||||
|
||||
const subpages = [
|
||||
'alternatives/teleparty',
|
||||
'alternatives/screen-sharing',
|
||||
'alternatives/watch2gether',
|
||||
'alternatives/scener',
|
||||
'alternatives/kosmi',
|
||||
'alternatives/twoseven'
|
||||
['alternatives/teleparty', 'website/alternatives/teleparty.html'],
|
||||
['alternatives/screen-sharing', 'website/alternatives/screen-sharing.html'],
|
||||
['alternatives/watch2gether', 'website/alternatives/watch2gether.html'],
|
||||
['alternatives/scener', 'website/alternatives/scener.html'],
|
||||
['alternatives/kosmi', 'website/alternatives/kosmi.html'],
|
||||
['alternatives/twoseven', 'website/alternatives/twoseven.html']
|
||||
];
|
||||
for (const sub of subpages) {
|
||||
addPage(sub, 'weekly', '0.7');
|
||||
for (const [sub, templateSource] of subpages) {
|
||||
addPage(sub, 'weekly', '0.7', templateSource);
|
||||
}
|
||||
|
||||
xml += `\n</urlset>\n`;
|
||||
|
||||
fs.writeFileSync(path.join(wwwDir, 'sitemap.xml'), xml.trim() + '\n', 'utf8');
|
||||
console.log(' ✓ Dynamically generated sitemap.xml with current date');
|
||||
const datedEntries = (xml.match(/<lastmod>/g) || []).length;
|
||||
const dateSource = usedDirtySourceDate ? 'Git/working-tree-derived' : 'Git-derived';
|
||||
console.log(` ✓ Dynamically generated sitemap.xml with ${datedEntries} ${dateSource} modification dates`);
|
||||
}
|
||||
compile().catch(err => { console.error('Build failed:', err); process.exit(1); });
|
||||
|
||||
@@ -297,7 +297,7 @@
|
||||
<p>© 2026 KoalaSync. Open Source unter der MIT-Lizenz.</p>
|
||||
<p style="font-size: 0.8rem; margin-top: 0.5rem;">Keine Relay-Logs. Kein Tracking. Website-Access-Logs werden nach 7 Tagen gelöscht.</p>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
|
||||
<a href="impressum" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
|
||||
<a href="https://koalastuff.net/legal" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
|
||||
<a href="datenschutz" style="color: var(--text-muted); text-decoration: none;">Datenschutz</a>
|
||||
<a href="alternatives/teleparty">Vergleiche & Anleitungen</a>
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" style="color: var(--text-muted); text-decoration: none; display: inline-flex; align-items: center; gap: 4px;">
|
||||
|
||||
+15
-244
@@ -1,250 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" class="lang-de">
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Impressum | KoalaSync</title>
|
||||
<link rel="preload" href="../style.min.css" as="style">
|
||||
<link rel="stylesheet" href="../style.min.css">
|
||||
<link rel="icon" type="image/webp" href="../assets/NewLogoIcon_64.webp">
|
||||
<link rel="canonical" href="https://sync.koalastuff.net/de/impressum">
|
||||
<link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/imprint">
|
||||
<link rel="alternate" hreflang="de" href="https://sync.koalastuff.net/de/impressum">
|
||||
<link rel="alternate" hreflang="x-default" href="https://sync.koalastuff.net/imprint">
|
||||
<meta name="description" content="Impressum (Legal Notice) von KoalaSync - Anbieterkennzeichnung und Kontaktinformationen für die Open-Source Video-Synchronisations-Erweiterung.">
|
||||
<meta name="robots" content="index, follow">
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{ "@type": "ListItem", "position": 1, "name": "Startseite", "item": "https://sync.koalastuff.net/de/" },
|
||||
{ "@type": "ListItem", "position": 2, "name": "Impressum", "item": "https://sync.koalastuff.net/de/impressum" }
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": "Impressum",
|
||||
"url": "https://sync.koalastuff.net/de/impressum",
|
||||
"description": "Impressum (Legal Notice) von KoalaSync - Anbieterkennzeichnung und Kontaktinformationen für die Open-Source Video-Synchronisations-Erweiterung.",
|
||||
"inLanguage": "de",
|
||||
"isPartOf": {
|
||||
"@type": "WebSite",
|
||||
"name": "KoalaSync",
|
||||
"url": "https://sync.koalastuff.net/de/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Mobile Browser Theme Styling -->
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
|
||||
<script src="../lang-init.min.js"></script>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Impressum | KoalaSync</title>
|
||||
<meta name="description" content="Das KoalaSync-Impressum ist zum zentralen KoalaStuff-Impressum umgezogen.">
|
||||
<meta name="robots" content="noindex, follow">
|
||||
<link rel="canonical" href="https://koalastuff.net/legal">
|
||||
<link rel="stylesheet" href="../style.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-nature" aria-hidden="true">
|
||||
<div class="bg-dusk-tint"></div>
|
||||
<div class="bg-depth-day">
|
||||
<div class="bg-godrays">
|
||||
<span class="godray godray-1"></span>
|
||||
<span class="godray godray-2"></span>
|
||||
<span class="godray godray-3"></span>
|
||||
</div>
|
||||
<div class="bg-canopy"></div>
|
||||
<span class="canopy-arc canopy-arc-1"></span>
|
||||
<span class="canopy-arc canopy-arc-2"></span>
|
||||
<span class="canopy-arc canopy-arc-3"></span>
|
||||
<span class="canopy-arc canopy-arc-4"></span>
|
||||
</div>
|
||||
<div id="bamboo-far">
|
||||
<span class="bamboo-stalk far-1"></span>
|
||||
<span class="bamboo-stalk far-2"></span>
|
||||
<span class="bamboo-stalk far-3"></span>
|
||||
<span class="bamboo-stalk far-4"></span>
|
||||
<span class="bamboo-stalk far-5"></span>
|
||||
</div>
|
||||
<div id="bamboo-near">
|
||||
<div class="bamboo-stalk near-1">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
<i class="bamboo-leaf leaf-2"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-2">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-3">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fall-leaf fall-leaf-1"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-2"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-3"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-4"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-5"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-6"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-7"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-8"><i></i></span>
|
||||
<span class="bg-grass bg-grass-1"></span>
|
||||
<span class="bg-grass bg-grass-2"></span>
|
||||
<span class="bg-grass bg-grass-3"></span>
|
||||
<span class="bg-grass bg-grass-4"></span>
|
||||
<div class="bg-depth-dusk">
|
||||
<div class="bg-mist bg-mist-1"></div>
|
||||
<div class="bg-mist bg-mist-2"></div>
|
||||
<div class="bg-horizon"></div>
|
||||
<span class="firefly-wrap fw-1"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-2"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-3"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-4"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-5"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-6"><span class="firefly"></span></span>
|
||||
</div>
|
||||
<div class="bg-light-sweep"></div>
|
||||
<div class="bg-grain"></div>
|
||||
<main class="legal-content">
|
||||
<div class="legal-card">
|
||||
<h1>Impressum umgezogen</h1>
|
||||
<p>Das KoalaSync-Impressum ist jetzt unter der zentralen KoalaStuff-Adresse verfügbar.</p>
|
||||
<p><a class="legal-inline-link" href="https://koalastuff.net/legal">Zentrales Impressum öffnen</a></p>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<div class="container nav-content">
|
||||
<a href="./" class="logo-area" style="text-decoration: none;">
|
||||
<img src="../assets/NewLogoIcon_128.webp" alt="KoalaSync Logo" width="40" height="40">
|
||||
<span>KoalaSync</span>
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
<a href="./" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="width: 16px; height: 16px; display: block;"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>
|
||||
<span>Startseite</span>
|
||||
</a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" rel="noopener" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" aria-hidden="true" style="display: block;"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="lang-select-container">
|
||||
<select class="lang-dropdown" aria-label="Select Language">
|
||||
<option value="en">🇬🇧 English</option>
|
||||
<option value="de">🇩🇪 Deutsch</option>
|
||||
<option value="fr">🇫🇷 Français</option>
|
||||
<option value="es">🇪🇸 Español</option>
|
||||
<option value="pt-BR">🇧🇷 Português (Brasil)</option>
|
||||
<option value="ru">🇷🇺 Русский</option>
|
||||
<option value="it">🇮🇹 Italiano</option>
|
||||
<option value="pl">🇵🇱 Polski</option>
|
||||
<option value="tr">🇹🇷 Türkçe</option>
|
||||
<option value="nl">🇳🇱 Nederlands</option>
|
||||
<option value="ja">🇯🇵 日本語</option>
|
||||
<option value="ko">🇰🇷 한국어</option>
|
||||
<option value="pt">🇵🇹 Português (Portugal)</option>
|
||||
</select>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="chevron-icon"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</div>
|
||||
<button class="hamburger" aria-label="Menu" aria-expanded="false">☰</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="legal-content">
|
||||
<div class="legal-card" data-reveal style="padding: 2rem;">
|
||||
<div style="display: flex; justify-content: center;">
|
||||
<img src="../assets/KoalaImprintl-180.webp" srcset="../assets/KoalaImprintl-180.webp 180w, ../assets/KoalaImprintl-360.webp 360w" sizes="180px" alt="Niedlicher Koala, der das Impressum repräsentiert" class="legal-mascot" width="180" height="180">
|
||||
</div>
|
||||
<h1>Impressum / Anbieterinformationen</h1>
|
||||
<p style="text-align: center; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.8rem; border-bottom: 1px solid var(--glass-border); padding-bottom: 1.5rem; margin-bottom: 2rem;">
|
||||
Kontakt & Projektinformationen
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2>Betreiber & Kontakt</h2>
|
||||
<p>Timo Schmidt</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
E-Mail: <span class="email-reveal" data-user="koalasync" data-domain="koalastuff.net" style="color: var(--accent); cursor: pointer; text-decoration: underline;">[E-Mail anzeigen]</span>
|
||||
</p>
|
||||
<p style="margin-top: 0.75rem;">
|
||||
<span class="legal-inline-label">🔒 Private Nachricht:</span>
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" class="legal-inline-link">@koalastuff auf Mastodon</a>
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
<span class="legal-inline-label">🐛 Fehlerberichte und Sicherheitsmeldungen:</span>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/issues" target="_blank" rel="noopener" class="legal-inline-link">GitHub Issues</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Projektinformationen</h2>
|
||||
<p>
|
||||
KoalaSync ist eine kostenlose und quelloffene Browser-Erweiterung. Der Quellcode ist auf GitHub unter der MIT-Lizenz verfügbar.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
Informationen zum Datenschutz, einschließlich Hosting, Access Logs, Relay-Verhalten und Betroffenenrechten, finden Sie in der <a href="datenschutz" class="legal-inline-link">Datenschutzerklärung</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Haftung für Inhalte</h2>
|
||||
<p>
|
||||
Die Inhalte dieser Website wurden mit Sorgfalt erstellt. Ich kann jedoch nicht gewährleisten, dass alle Informationen jederzeit vollständig, richtig oder aktuell sind.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
Als Diensteanbieter bin ich nach den allgemeinen Gesetzen für eigene Inhalte auf diesen Seiten verantwortlich.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Haftung für Links</h2>
|
||||
<p>
|
||||
Diese Website enthält Links zu externen Websites Dritter. Auf deren Inhalte habe ich keinen Einfluss. Für die Inhalte verlinkter Seiten ist der jeweilige Anbieter oder Betreiber verantwortlich.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
Wenn mir rechtswidrige Inhalte auf verlinkten Seiten bekannt werden, entferne ich die entsprechenden Links.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Lizenz & Urheberrecht</h2>
|
||||
<p>
|
||||
KoalaSync ist freie und quelloffene Software. Sofern nicht anders angegeben, steht der Quellcode von KoalaSync unter der MIT-Lizenz.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
Die MIT-Lizenz erlaubt Nutzung, Vervielfältigung, Bearbeitung, Verbreitung, Unterlizenzierung und Verkauf der Software, sofern der Copyright-Hinweis und der Lizenztext entsprechend den Lizenzbedingungen erhalten bleiben.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
Projektname, Logos, Grafiken, Website-Texte und andere Nicht-Code-Assets können separaten Rechten unterliegen, sofern sie nicht ausdrücklich von der Repository-Lizenz umfasst sind.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p>© 2026 KoalaSync. Open Source unter der MIT-Lizenz.</p>
|
||||
<p style="font-size: 0.8rem; margin-top: 0.5rem;">Keine Relay-Logs. Kein Tracking. Website-Access-Logs werden nach 7 Tagen gelöscht.</p>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
|
||||
<a href="impressum" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
|
||||
<a href="datenschutz" style="color: var(--text-muted); text-decoration: none;">Datenschutz</a>
|
||||
<a href="alternatives">Vergleiche & Anleitungen</a>
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" style="color: var(--text-muted); text-decoration: none; display: inline-flex; align-items: center; gap: 4px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="14" height="14" aria-hidden="true" style="display: block;"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></svg>
|
||||
Mastodon
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="../app.min.js"></script>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+8
-20
@@ -1,26 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, follow">
|
||||
<title>Redirecting...</title>
|
||||
<script>
|
||||
(function() {
|
||||
var savedLang = localStorage.getItem('koala_lang');
|
||||
var browserLang = (navigator.language || navigator.userLanguage || '').toLowerCase();
|
||||
var isDe = (savedLang === 'de') || (!savedLang && browserLang.indexOf('de') === 0);
|
||||
var path = window.location.pathname;
|
||||
var hasHtml = path.endsWith('.html');
|
||||
if (isDe) {
|
||||
window.location.replace(hasHtml ? 'de/impressum.html' : 'de/impressum');
|
||||
} else {
|
||||
window.location.replace(hasHtml ? 'imprint.html' : 'imprint');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="refresh" content="0; url=https://koalastuff.net/legal">
|
||||
<meta name="robots" content="noindex, follow">
|
||||
<link rel="canonical" href="https://koalastuff.net/legal">
|
||||
<title>Legal Notice | KoalaSync</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Redirecting / Weiterleitung...</p>
|
||||
<p><a href="https://koalastuff.net/legal">Open the central KoalaStuff legal notice</a></p>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+15
-244
@@ -1,250 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="lang-en">
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Legal Notice | KoalaSync</title>
|
||||
<link rel="preload" href="style.min.css" as="style">
|
||||
<link rel="stylesheet" href="style.min.css">
|
||||
<link rel="icon" type="image/webp" href="assets/NewLogoIcon_64.webp">
|
||||
<link rel="canonical" href="https://sync.koalastuff.net/imprint">
|
||||
<link rel="alternate" hreflang="en" href="https://sync.koalastuff.net/imprint">
|
||||
<link rel="alternate" hreflang="de" href="https://sync.koalastuff.net/de/impressum">
|
||||
<link rel="alternate" hreflang="x-default" href="https://sync.koalastuff.net/imprint">
|
||||
<meta name="description" content="Legal Notice (Impressum) of KoalaSync - Contact information and operator details for the open-source video synchronization extension.">
|
||||
<meta name="robots" content="index, follow">
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://sync.koalastuff.net/" },
|
||||
{ "@type": "ListItem", "position": 2, "name": "Legal Notice", "item": "https://sync.koalastuff.net/imprint" }
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": "Legal Notice",
|
||||
"url": "https://sync.koalastuff.net/imprint",
|
||||
"description": "Legal Notice (Impressum) of KoalaSync - Contact information and operator details for the open-source video synchronization extension.",
|
||||
"inLanguage": "en",
|
||||
"isPartOf": {
|
||||
"@type": "WebSite",
|
||||
"name": "KoalaSync",
|
||||
"url": "https://sync.koalastuff.net/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Mobile Browser Theme Styling -->
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
|
||||
<script src="lang-init.min.js"></script>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Legal Notice | KoalaSync</title>
|
||||
<meta name="description" content="The KoalaSync legal notice has moved to the central KoalaStuff legal notice.">
|
||||
<meta name="robots" content="noindex, follow">
|
||||
<link rel="canonical" href="https://koalastuff.net/legal">
|
||||
<link rel="stylesheet" href="style.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="bg-nature" aria-hidden="true">
|
||||
<div class="bg-dusk-tint"></div>
|
||||
<div class="bg-depth-day">
|
||||
<div class="bg-godrays">
|
||||
<span class="godray godray-1"></span>
|
||||
<span class="godray godray-2"></span>
|
||||
<span class="godray godray-3"></span>
|
||||
</div>
|
||||
<div class="bg-canopy"></div>
|
||||
<span class="canopy-arc canopy-arc-1"></span>
|
||||
<span class="canopy-arc canopy-arc-2"></span>
|
||||
<span class="canopy-arc canopy-arc-3"></span>
|
||||
<span class="canopy-arc canopy-arc-4"></span>
|
||||
</div>
|
||||
<div id="bamboo-far">
|
||||
<span class="bamboo-stalk far-1"></span>
|
||||
<span class="bamboo-stalk far-2"></span>
|
||||
<span class="bamboo-stalk far-3"></span>
|
||||
<span class="bamboo-stalk far-4"></span>
|
||||
<span class="bamboo-stalk far-5"></span>
|
||||
</div>
|
||||
<div id="bamboo-near">
|
||||
<div class="bamboo-stalk near-1">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
<i class="bamboo-leaf leaf-2"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-2">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
<div class="bamboo-stalk near-3">
|
||||
<i class="bamboo-node node-1"></i>
|
||||
<i class="bamboo-node node-2"></i>
|
||||
<i class="bamboo-node node-3"></i>
|
||||
<i class="bamboo-leaf leaf-1"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fall-leaf fall-leaf-1"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-2"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-3"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-4"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-5"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-6"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-7"><i></i></span>
|
||||
<span class="fall-leaf fall-leaf-8"><i></i></span>
|
||||
<span class="bg-grass bg-grass-1"></span>
|
||||
<span class="bg-grass bg-grass-2"></span>
|
||||
<span class="bg-grass bg-grass-3"></span>
|
||||
<span class="bg-grass bg-grass-4"></span>
|
||||
<div class="bg-depth-dusk">
|
||||
<div class="bg-mist bg-mist-1"></div>
|
||||
<div class="bg-mist bg-mist-2"></div>
|
||||
<div class="bg-horizon"></div>
|
||||
<span class="firefly-wrap fw-1"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-2"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-3"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-4"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-5"><span class="firefly"></span></span>
|
||||
<span class="firefly-wrap fw-6"><span class="firefly"></span></span>
|
||||
</div>
|
||||
<div class="bg-light-sweep"></div>
|
||||
<div class="bg-grain"></div>
|
||||
<main class="legal-content">
|
||||
<div class="legal-card">
|
||||
<h1>Legal notice moved</h1>
|
||||
<p>The KoalaSync legal notice is now available at the central KoalaStuff address.</p>
|
||||
<p><a class="legal-inline-link" href="https://koalastuff.net/legal">Open the central legal notice</a></p>
|
||||
</div>
|
||||
|
||||
<nav>
|
||||
<div class="container nav-content">
|
||||
<a href="./" class="logo-area" style="text-decoration: none;">
|
||||
<img src="assets/NewLogoIcon_128.webp" alt="KoalaSync Logo" width="40" height="40">
|
||||
<span>KoalaSync</span>
|
||||
</a>
|
||||
<div class="nav-links">
|
||||
<a href="./" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="width: 16px; height: 16px; display: block;"><path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path><polyline points="9 22 9 12 15 12 15 22"></polyline></svg>
|
||||
<span>Home</span>
|
||||
</a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" rel="noopener" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" aria-hidden="true" style="display: block;"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<div class="lang-select-container">
|
||||
<select class="lang-dropdown" aria-label="Select Language">
|
||||
<option value="en">🇬🇧 English</option>
|
||||
<option value="de">🇩🇪 Deutsch</option>
|
||||
<option value="fr">🇫🇷 Français</option>
|
||||
<option value="es">🇪🇸 Español</option>
|
||||
<option value="pt-BR">🇧🇷 Português (Brasil)</option>
|
||||
<option value="ru">🇷🇺 Русский</option>
|
||||
<option value="it">🇮🇹 Italiano</option>
|
||||
<option value="pl">🇵🇱 Polski</option>
|
||||
<option value="tr">🇹🇷 Türkçe</option>
|
||||
<option value="nl">🇳🇱 Nederlands</option>
|
||||
<option value="ja">🇯🇵 日本語</option>
|
||||
<option value="ko">🇰🇷 한국어</option>
|
||||
<option value="pt">🇵🇹 Português (Portugal)</option>
|
||||
</select>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="chevron-icon"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</div>
|
||||
<button class="hamburger" aria-label="Menu" aria-expanded="false">☰</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="legal-content">
|
||||
<div class="legal-card" data-reveal style="padding: 2rem;">
|
||||
<div style="display: flex; justify-content: center;">
|
||||
<img src="assets/KoalaImprintl-180.webp" srcset="assets/KoalaImprintl-180.webp 180w, assets/KoalaImprintl-360.webp 360w" sizes="180px" alt="Cute Koala representing the legal notice page" class="legal-mascot" width="180" height="180">
|
||||
</div>
|
||||
<h1>Legal Notice</h1>
|
||||
<p style="text-align: center; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.8rem; border-bottom: 1px solid var(--glass-border); padding-bottom: 1.5rem; margin-bottom: 2rem;">
|
||||
Contact & Project Information
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2>Operator & Contact</h2>
|
||||
<p>Timo Schmidt</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
E-Mail: <span class="email-reveal" data-user="koalasync" data-domain="koalastuff.net" style="color: var(--accent); cursor: pointer; text-decoration: underline;">[Show Email]</span>
|
||||
</p>
|
||||
<p style="margin-top: 0.75rem;">
|
||||
<span class="legal-inline-label">🔒 Private message:</span>
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" class="legal-inline-link">@koalastuff on Mastodon</a>
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
<span class="legal-inline-label">🐛 Bug reports and security concerns:</span>
|
||||
<a href="https://github.com/Shik3i/KoalaSync/issues" target="_blank" rel="noopener" class="legal-inline-link">GitHub Issues</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Project Information</h2>
|
||||
<p>
|
||||
KoalaSync is a free and open-source browser extension. The source code is available on GitHub under the MIT License.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
For privacy-related information, including hosting, access logs, relay behavior, and data subject rights, please see the <a href="privacy" class="legal-inline-link">Privacy Policy</a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Liability for Content</h2>
|
||||
<p>
|
||||
The contents of this website were created with care. However, I cannot guarantee that all information is always complete, accurate, or up to date.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
As a service provider, I am responsible for my own content on these pages in accordance with the general laws.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Liability for Links</h2>
|
||||
<p>
|
||||
This website contains links to external third-party websites. I have no influence over the content of those websites. The respective provider or operator is responsible for the content of linked pages.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
If I become aware of unlawful content on linked pages, I will remove the corresponding links.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>License & Copyright</h2>
|
||||
<p>
|
||||
KoalaSync is free and open-source software. Unless otherwise stated, the KoalaSync source code is licensed under the MIT License.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
The MIT License allows use, copying, modification, distribution, sublicensing, and sale of the software, provided that the copyright notice and license text are included where required by the license.
|
||||
</p>
|
||||
<p style="margin-top: 0.5rem;">
|
||||
Project name, logos, graphics, website text, and other non-code assets may be subject to separate rights unless they are explicitly included under the repository license.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<p>© 2026 KoalaSync. Open source under the MIT License.</p>
|
||||
<p style="font-size: 0.8rem; margin-top: 0.5rem;">No relay logs. No tracking. Website access logs are deleted after 7 days.</p>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
|
||||
<a href="imprint" style="color: var(--text-muted); text-decoration: none;">Legal Notice</a>
|
||||
<a href="privacy" style="color: var(--text-muted); text-decoration: none;">Privacy Policy</a>
|
||||
<a href="alternatives">Guides & comparisons</a>
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" style="color: var(--text-muted); text-decoration: none; display: inline-flex; align-items: center; gap: 4px;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="14" height="14" aria-hidden="true" style="display: block;"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></svg>
|
||||
Mastodon
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="app.min.js"></script>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+3
-2
@@ -186,8 +186,8 @@
|
||||
<p style="font-size: 0.8rem; margin-top: 0.5rem;"><span lang="en">No relay logs. No tracking. Website access logs are deleted after 7 days.</span><span lang="de">Keine Relay-Logs. Kein Tracking. Website-Access-Logs werden nach 7 Tagen gelöscht.</span></p>
|
||||
<p class="footer-disclaimer" style="margin-top: 1.2rem;"><span lang="en">KoalaSync is not affiliated with, endorsed by, or associated with Netflix, Disney+, Amazon, YouTube, Twitch, or any other streaming platform. All trademarks are property of their respective owners.</span><span lang="de">KoalaSync ist nicht mit Netflix, Disney+, Amazon, YouTube, Twitch oder anderen Streaming-Plattformen verbunden, wird von diesen nicht unterstützt oder steht in keiner Beziehung zu diesen. Alle Markenrechte liegen bei ihren jeweiligen Inhabern.</span></p>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
|
||||
<a href="imprint" lang="en" style="color: var(--text-muted); text-decoration: none;">Legal Notice</a>
|
||||
<a href="de/impressum" lang="de" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
|
||||
<a href="https://koalastuff.net/legal" lang="en" style="color: var(--text-muted); text-decoration: none;">Legal Notice</a>
|
||||
<a href="https://koalastuff.net/legal" lang="de" style="color: var(--text-muted); text-decoration: none;">Impressum</a>
|
||||
<a href="privacy" lang="en" style="color: var(--text-muted); text-decoration: none;">Privacy Policy</a>
|
||||
<a href="de/datenschutz" lang="de" style="color: var(--text-muted); text-decoration: none;">Datenschutz</a>
|
||||
<a href="alternatives"><span lang="en">Guides & comparisons</span><span lang="de">Vergleiche & Anleitungen</span></a>
|
||||
@@ -203,6 +203,7 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="invite-links.min.js"></script>
|
||||
<script src="app.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+2
-2
@@ -78,7 +78,7 @@ Compatibility depends on each website's player implementation and can change whe
|
||||
|
||||
## Technical information
|
||||
|
||||
- Current website release: 2.5.4
|
||||
- Current website release: 3.0.0
|
||||
- License: MIT
|
||||
- Extension runtime: dependency-free browser extension code
|
||||
- Relay: Node.js with Socket.IO-compatible WebSocket messaging
|
||||
@@ -112,6 +112,6 @@ Compatibility depends on each website's player implementation and can change whe
|
||||
- [Main website](https://sync.koalastuff.net/)
|
||||
- [GitHub issues](https://github.com/Shik3i/KoalaSync/issues): Bugs, compatibility reports and feature requests
|
||||
- [Mastodon](https://mastodon.social/@koalastuff): Private contact and project updates
|
||||
- [Legal notice](https://sync.koalastuff.net/imprint)
|
||||
- [Legal notice](https://koalastuff.net/legal)
|
||||
|
||||
KoalaSync is an independent project and is not affiliated with, endorsed by or associated with Netflix, Disney+, Amazon, YouTube, Twitch or any other streaming platform. All trademarks belong to their respective owners.
|
||||
|
||||
@@ -297,7 +297,7 @@
|
||||
<p>© 2026 KoalaSync. Open source under the MIT License.</p>
|
||||
<p style="font-size: 0.8rem; margin-top: 0.5rem;">No relay logs. No tracking. Website access logs are deleted after 7 days.</p>
|
||||
<div style="margin-top: 1.5rem; font-size: 0.8rem; display: flex; justify-content: center; align-items: center; gap: 1.5rem; flex-wrap: wrap;">
|
||||
<a href="imprint" style="color: var(--text-muted); text-decoration: none;">Legal Notice</a>
|
||||
<a href="https://koalastuff.net/legal" style="color: var(--text-muted); text-decoration: none;">Legal Notice</a>
|
||||
<a href="privacy" style="color: var(--text-muted); text-decoration: none;">Privacy Policy</a>
|
||||
<a href="alternatives">Guides & comparisons</a>
|
||||
<a href="https://mastodon.social/@koalastuff" rel="me noopener" target="_blank" style="color: var(--text-muted); text-decoration: none; display: inline-flex; align-items: center; gap: 4px;">
|
||||
|
||||
@@ -17,6 +17,14 @@
|
||||
<meta property="og:title" content="Fix KoalaSync website access">
|
||||
<meta property="og:description" content="Quick browser-specific steps for reconnecting KoalaSync to a video tab.">
|
||||
<meta property="og:image" content="https://sync.koalastuff.net/assets/og-image.png">
|
||||
<meta property="og:image:width" content="1280">
|
||||
<meta property="og:image:height" content="640">
|
||||
<meta property="og:image:type" content="image/png">
|
||||
<meta property="og:site_name" content="KoalaSync">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="Fix KoalaSync website access">
|
||||
<meta name="twitter:description" content="Quick browser-specific steps for reconnecting KoalaSync to a video tab.">
|
||||
<meta name="twitter:image" content="https://sync.koalastuff.net/assets/og-image.png">
|
||||
<meta name="theme-color" content="#10190e">
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
@@ -24,8 +32,24 @@
|
||||
"@type": "TechArticle",
|
||||
"headline": "Fix KoalaSync website access",
|
||||
"description": "Restore KoalaSync access when a browser blocks the extension on a video website.",
|
||||
"datePublished": "2026-07-15T10:24:59Z",
|
||||
"dateModified": "2026-07-19T19:20:05Z",
|
||||
"inLanguage": "en",
|
||||
"url": "https://sync.koalastuff.net/site-access-help.html",
|
||||
"mainEntityOfPage": {
|
||||
"@type": "WebPage",
|
||||
"@id": "https://sync.koalastuff.net/site-access-help.html"
|
||||
},
|
||||
"author": {
|
||||
"@type": "Organization",
|
||||
"name": "KoalaSync",
|
||||
"url": "https://sync.koalastuff.net/"
|
||||
},
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": "KoalaSync",
|
||||
"url": "https://sync.koalastuff.net/"
|
||||
},
|
||||
"isPartOf": {
|
||||
"@type": "WebSite",
|
||||
"name": "KoalaSync",
|
||||
@@ -134,7 +158,7 @@
|
||||
<p class="footer-ram">Rooms are ephemeral and stored in RAM.</p>
|
||||
<p class="footer-copyright">© 2026 KoalaSync. Open source under the MIT License.</p>
|
||||
<div class="footer-links">
|
||||
<a href="imprint.html">Legal notice</a>
|
||||
<a href="https://koalastuff.net/legal">Legal notice</a>
|
||||
<a href="privacy.html">Privacy</a>
|
||||
<a href="https://github.com/Shik3i/KoalaSync" target="_blank" rel="noopener">GitHub</a>
|
||||
</div>
|
||||
|
||||
Vendored
+2
-24
@@ -174,31 +174,9 @@ nav.nav-scrolled + .site-access-banner {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (max-width: 1024px) {
|
||||
.site-access-banner {
|
||||
top: 73px;
|
||||
}
|
||||
|
||||
nav.nav-scrolled + .site-access-banner {
|
||||
top: 59px;
|
||||
}
|
||||
|
||||
.site-access-banner-inner {
|
||||
min-height: 64px;
|
||||
padding: 0.65rem 3rem 0.65rem 1rem;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.site-access-banner-icon {
|
||||
width: 1.3rem;
|
||||
height: 1.3rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.site-access-banner-dismiss {
|
||||
right: 0.75rem;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
"priceCurrency": "EUR"
|
||||
},
|
||||
"description": "{{SCHEMA_APP_DESC}}",
|
||||
"softwareVersion": "2.6.3",
|
||||
"softwareVersion": "3.0.0",
|
||||
"license": "https://opensource.org/licenses/MIT",
|
||||
"sameAs": "https://github.com/Shik3i/KoalaSync",
|
||||
"image": "https://sync.koalastuff.net/assets/NewLogoIcon.webp",
|
||||
@@ -1574,7 +1574,7 @@
|
||||
<p class="footer-disclaimer">{{FOOTER_DISCLAIMER}}</p>
|
||||
<p class="footer-copyright">© 2026 KoalaSync. {{FOOTER_MIT}}</p>
|
||||
<div class="footer-links">
|
||||
<a href="{{ASSET_PATH}}{{FOOTER_LEGAL_LINK}}"><span>{{FOOTER_LEGAL}}</span></a>
|
||||
<a href="https://koalastuff.net/legal"><span>{{FOOTER_LEGAL}}</span></a>
|
||||
<a href="{{ASSET_PATH}}{{FOOTER_PRIVACY_LINK}}"><span>{{FOOTER_PRIVACY}}</span></a>
|
||||
<a href="{{ASSET_PATH}}{{LANG_PREFIX}}alternatives"><span><span lang="en">Guides & comparisons</span><span lang="de">Vergleiche & Anleitungen</span></span></a>
|
||||
<a href="https://support.koalastuff.net" target="_blank" rel="noopener" title="{{FOOTER_SUPPORT}}" class="footer-support-btn">
|
||||
@@ -1589,6 +1589,7 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="{{ASSET_PATH}}invite-links.min.js"></script>
|
||||
<script src="{{ASSET_PATH}}app.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "2.6.3",
|
||||
"date": "2026-07-15T11:31:00Z"
|
||||
"version": "3.0.0",
|
||||
"date": "2026-07-26T00:25:06Z"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user