Compare commits

..

190 Commits

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 06:15:40 +02:00
GitHub Action 477a814a79 chore(release): update versions to v2.6.1 [skip ci] 2026-07-15 03:57:56 +00:00
KoalaDev cb84709358 fix(popup): render lobby peer names as text, not markup
Peer usernames are remote-controlled and were interpolated into an
innerHTML string in updateLobbyUI. The server only truncates them to 30
chars, so a peer could inject markup into everyone else's popup: enough
to load a remote image (leaking viewer IPs) or spoof readiness badges.
Inline handlers were already blocked by the MV3 default CSP.

Build the peer items with the DOM API, matching the pattern the sibling
peer list already uses.

Add the two checks that would have caught this before upload:
- eslint no-unsanitized, which reproduces the AMO warning at lint time
- addons-linter on the built XPI in verify-release, with
  --warnings-as-errors since it exits 0 on warnings and AMO rejects them

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 05:57:38 +02:00
KoalaDev 43a7bb0d57 docs: scope changelog header to extension and relay server
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 05:16:58 +02:00
KoalaDev e1e86307b1 docs: drop redesign-era items from v2.6.0 Fixed
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 05:16:45 +02:00
KoalaDev e934fcf484 docs: limit v2.6.0 changelog to extension and server
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 05:16:16 +02:00
GitHub Action d4181b2eaf chore(release): update versions to v2.6.0 [skip ci] 2026-07-15 03:14:35 +00:00
KoalaDev 66d650f49c docs: date v2.6.0 changelog release
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 05:13:43 +02:00
KoalaDev 2cde14b260 Merge pull request #28 from Shik3i/redesign/koala-nature-theme
Redesign the website and extension with the Koala nature theme
2026-07-15 05:03:48 +02:00
KoalaDev db8bf8f647 docs: remove em dashes from store copy 2026-07-15 04:58:51 +02:00
KoalaDev 81001f2890 docs: correct relay and self-hosting copy 2026-07-15 04:55:53 +02:00
KoalaDev 861ffc306a test(website): validate canonical relay URL 2026-07-15 04:53:29 +02:00
KoalaDev 6f6aac9c18 docs: refresh project documentation 2026-07-15 04:45:45 +02:00
KoalaDev e4984b2d03 fix(website): complete self-hosting example 2026-07-15 04:37:51 +02:00
KoalaDev 18205f6573 docs(website): expand AI project profile 2026-07-15 04:29:20 +02:00
KoalaDev a4bbddf3a4 fix(website): address accessibility audit findings 2026-07-15 04:23:14 +02:00
KoalaDev b577f06988 fix(website): restore light mode step numbers 2026-07-15 03:48:22 +02:00
KoalaDev 61514b9b6f fix(website): generate sitemap only in build output 2026-07-15 03:30:22 +02:00
Timo 62046acb81 fix(ci): preserve optional lockfile entries 2026-07-14 16:38:47 +02:00
Timo 8b6a1412cc fix(build): replace regex HTML minifier 2026-07-14 16:30:13 +02:00
Timo 6e5a790707 fix(website): restore GitHub hero button outline 2026-07-14 16:11:50 +02:00
Timo 0f4b1d7217 docs(release): prepare v2.6.0 changelog and sitemap 2026-07-14 13:58:24 +02:00
Timo af0da75d38 style(website): landing motion, interaction & layout polish
Feedback round (calmer, less "template/AI"):
- Dial back animated background motion globally (sway/leaf-drift halved,
  fewer falling leaves, gentler godrays/fireflies/light-sweep).
- Fire leaf confetti on click of real controls only (was pointerdown
  everywhere, which showered mobile users on every scroll).
- Remove hover lift/scale from non-interactive cards, screenshots and
  mascots so they no longer read as clickable.
- Fix mobile nav menu colours in the light theme (was a hard-coded dark
  panel with low-contrast links).
- Calm how-it-works steps 1-2 (no button pulse/shine/cursor bob); step 3
  keeps its lively sync animation. Remove the now-static "copied" toast
  from step 1 since it only made sense mid-animation.

UX audit follow-ups:
- Unify vertical rhythm with one section-padding scale; vertically centre
  the hero so space is balanced instead of pooling under the CTAs.
- Split the mixed "Why KoalaSync?" grid into use-case scenarios and a
  labelled "Features" subsection, and move the #features nav anchor there.
- Demote the hero GitHub CTA to a quiet text link (Chrome > Firefox >
  GitHub hierarchy); scale down oversized section mascots and give them a
  shared soft shadow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 02:43:05 +02:00
Timo 63414bfcdd fix: strip all parenthesized/bracketed leading numbers in tab title normalization 2026-07-12 17:54:54 +02:00
Timo af1b66c6b7 fix: support multi-digit plus notification badges while preserving large numeric titles 2026-07-12 17:43:20 +02:00
Timo cc96fc1771 fix: support (99+) style notification badges in tab title normalization 2026-07-12 17:29:33 +02:00
Timo 9e8a446d97 fix: resolve Episode Lobby stuck loading state and prevent triggers on non-episodic media 2026-07-12 16:57:06 +02:00
Timo b987d13612 fix(extension): localize theme and appearance tooltips and selector options 2026-07-12 14:54:00 +02:00
Timo c175364f9a chore(website): refresh generated assets 2026-07-12 13:13:47 +02:00
Timo 0168484382 style(extension): unify control system and accessibility 2026-07-12 13:04:23 +02:00
Timo 99ecf9c9a8 fix(extension): normalize primary action labels 2026-07-12 12:17:32 +02:00
Timo f58ab54f45 fix(extension): remove redundant status accent bar 2026-07-12 12:15:21 +02:00
Timo 104602edec fix(extension): unify status UX and theme design 2026-07-12 11:10:15 +02:00
Timo 9d6a3650c4 fix(extension): polish settings selects and dark palette 2026-07-12 11:02:14 +02:00
Timo 986b2a6e56 style(extension): redesign settings and color system 2026-07-12 10:59:19 +02:00
Timo fdf20b6bf6 style(website): declutter background and polish stacked hero on small screens
- thin out bamboo/leaf/firefly decor in tiers (<=1440 / <=1024 / <=768px)
  so nothing moves behind the text column and copy stays readable
- center the single-column hero below 1024px (text, koala, CTAs, trust
  line) and let the odd CTA button span the full grid row
- remove the periodic wind gusts; keep the constant gentle bamboo sway
- reword the German demo CTA to "Live-Demo ansehen"

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 10:59:00 +02:00
KoalaDev 3ce64b8c41 style(website): place footer-ram at top of footer and adjust vertical margins 2026-07-12 06:55:32 +02:00
KoalaDev 6f8bc40023 style(website): swap footer-ram above copyright and apply unified small disclaimer style 2026-07-12 06:53:21 +02:00
KoalaDev 6f203405f9 fix(website): restore missing footer-disclaimer class styling to landing-primary.css 2026-07-12 06:52:34 +02:00
KoalaDev 949075b267 style(website): optimize header scroll-shrink and clean up footer styles/hover effects 2026-07-12 06:50:37 +02:00
KoalaDev 4fb8e0f53a style(extension): apply UI/UX audit enhancements, consolidate footers, and style capsule badges 2026-07-12 06:38:26 +02:00
KoalaDev bd33ea4804 feat(extension): implement accordion mutual exclusion behavior for settings details categories 2026-07-12 06:31:24 +02:00
KoalaDev 9cd3fc1ee2 feat(extension): redesign settings tab into 4 collapsible accordion groups with chevron indicator and hover effects 2026-07-12 06:28:34 +02:00
KoalaDev 44f9a78bb0 perf(website): migrate forest parallax to native CSS scroll-driven animations, fix step contrast, and resolve forced reflows 2026-07-12 06:18:58 +02:00
KoalaDev e27319908d refactor(website): apply day/night sky palette variables to sync theater illustration and video canvas 2026-07-12 05:48:27 +02:00
KoalaDev af4ac943a9 Landing performance: flag font subset, responsive mascots, AVIF cache, HTML minify
- Flag font: ship a committed fontTools subset (43 KB, -45%) of
  TwemojiCountryFlags.woff2 with only the 15 flags the site uses, under a
  content-hashed name with a preload on every flag-bearing page. The build
  validates the subset against a manifest and fails when a new flag
  appears without regenerating (npm run subset-flags). JS subsetters
  (subset-font/harfbuzzjs) were rejected: their wasm builds silently drop
  the COLR/CPAL color tables, rendering all flags invisible.
- Responsive mascots: legal/join/404 pages served 434-500px sources for
  175-180px slots; the build now emits 180w/360w variants (AVIF 1x: ~7 KB
  vs ~22 KB) and the pages use srcset. Small logo spots (14-42px) now use
  the existing 64/128px variants instead of the 256px file.
- injectAvifPictures copies the img sizes attribute onto the AVIF
  <source>; without it Chrome mis-selects w-descriptor candidates and can
  drop the image entirely.
- AVIF conversion is cached by content hash (.avif-cache.json); the old
  mtime check never hit because copyDirSync refreshes mtimes, so every
  verify re-encoded all 26 files.
- HTML output is minified (comments + indentation, <pre> preserved,
  newlines kept for inline-script safety): index.html 121 KB -> 93 KB.
- unicode-range trimmed to U+1F1E6-1F1FF; tag-sequence flags fall through
  to the system emoji font.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 04:34:53 +02:00
KoalaDev 9fd4e870f3 Update theme test after film bird removal
The test still required 4 foreground film birds, but commit 16cb138
deliberately removed them all; now it guards against remnants instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 04:34:36 +02:00
KoalaDev 16cb138eeb Remove film birds; fix language pill highlight
Strip every bird from the demo mockups: both hero video tabs and both
step-3 player scenes lose the background flock (.film-birds) and the
foreground flapping bird (.film-hero-bird), plus all related CSS in
styles/demo.css and the dead style.legacy.css.

Language selector: the <select> was 30px narrower than its pill, so in
appearance:base-select the click/focus highlight covered only the inner
~110px ("half the box"). Move the padding from the container onto the
control so it fills the pill edge-to-edge; mirror the fix in the mobile
legal.css override.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 03:57:18 +02:00
KoalaDev 17c9da292c Fix Getting Started light theme 2026-07-12 03:37:07 +02:00
KoalaDev c7c124dac7 Improve animated film bird 2026-07-12 03:28:05 +02:00
KoalaDev ea0970df72 Guard landing CSS cascade stability 2026-07-11 18:10:59 +02:00
KoalaDev 7614534818 Restore landing CSS cascade stability 2026-07-11 16:11:31 +02:00
KoalaDev 72d4f04526 Split landing CSS by render priority 2026-07-11 15:48:09 +02:00
KoalaDev 7fea11de0a Polish extension and landing page UI 2026-07-11 11:21:47 +02:00
KoalaDev fbef3d180c Give light theme proper green bamboo and a frosted language pill
- Bamboo layers get their own light-mode recipes (fresh greens, visible
  segment rings, sheen) instead of dark-theme gradients faded to 45%
- Language selector pill: frosted white glass, light dropdown/picker
  (was hard-coded dark glass in both themes)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 10:24:18 +02:00
KoalaDev fecaa8b54d Fix bamboo parallax direction: layers follow the scroll, not oppose it
Scrolling down now moves all bamboo layers up-screen with the content
(near fastest, far slowest) instead of pushing them down against it.
Vertical overscan flipped from top (-70%) to bottom (-40%) so the
upward drift never exposes a gap at the viewport bottom.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 10:20:38 +02:00
KoalaDev 8175f82f0b Polish demo film, bamboo forest, popup a11y, and step-1 toast
- Demo mockup video: drifting clouds, shooting star, moon craters, bird
  flock, valley mist band, floating lantern instead of bouncing ball,
  stronger cinematic vignette
- Player controls: play/pause glyph, duration, buffered range, volume,
  HD badge, fullscreen icon
- Bamboo background: two faint centre trunks, segment rings on far
  stalks, wider near stalks with extra leaf, smaller blurred layers
  (240% -> 200%) and reduced blur radii for cheaper compositing
- Step-1 illustration: invite toast floats fully below the popup card
- Extension popup: label/for associations for all settings controls
- Demo video select: drop invalid inline SVG inside <option>

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 10:10:37 +02:00
Timo 04df35bbe6 Enhance forest scene with mid bamboo layer, butterflies, click confetti, and demo scene polish 2026-07-11 06:08:12 +02:00
KoalaDev 21383b8b9c Polish animated forest background 2026-07-10 20:42:54 +02:00
KoalaDev 9cddac47ef Make forest parallax continuous and add scroll-depth journey
The drift clamp saturated after ~2600px, so nothing moved on long
pages. The vertical drift now maps scroll progress (0..1 over the full
document) onto the stalks' overscan, so the parallax runs end to end.

Turn the background into a descent through the forest: god-ray light
shafts and the canopy fade out while a dusk tint, thicker mist, the
terracotta horizon glow and the fireflies fade in as you scroll down.
Fireflies additionally wander organically (independent translate
property composing with their scale pulse), and on hover-capable
devices the bamboo layers tilt subtly toward the cursor. All layers
animate transform/opacity only, rAF-throttled, reduced-motion safe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 19:42:23 +02:00
KoalaDev 1f1844e99b Fix bamboo stalk taper and align stray colors to palette
The stalks used border-radius:50%, which renders a tall ellipse whose
ends taper to points, and unbounded parallax drift pushed them off the
bottom of the viewport on long pages. Rounded caps + 20% overscan +
a viewport-relative drift clamp keep them full-height while scrolling.

Sweep the remaining off-palette colors into the nature palette:
content-script overlays (slate grays), amber warnings/badges/star
accents (now terracotta) in popup, background badge and website
illustrations, and the support-heart red (now --danger).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 19:28:50 +02:00
KoalaDev aa52d319e1 Redesign website and extension with koala nature theme
v3.0.0 "The Greens Update" palette: forest-green oklch color system
(accent green + terracotta secondary) replaces the indigo/slate scheme
across the landing page and the extension popup/options. The blob
background becomes an animated bamboo forest overlay (parallax stalks,
falling leaves, canopy, ground mist, fireflies, light sweep, grain)
with prefers-reduced-motion support. Layout, structure and copy are
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 19:18:40 +02:00
KoalaDev 05246ac485 Fix light mode step text contrast 2026-07-09 17:25:20 +02:00
KoalaDev 6412a9e6ff Unify website section canvas 2026-07-09 17:07:23 +02:00
KoalaDev ee1c2947ce Fix join mobile navigation toggle 2026-07-09 14:47:29 +02:00
KoalaDev df8133fb5b Avoid persisting passive language visits 2026-07-09 14:40:44 +02:00
KoalaDev e25ae25bbb Respect system theme on website 2026-07-09 14:38:09 +02:00
KoalaDev 1d049e1745 perf(website): fix startup forced reflow and adjust AVIF compression quality to 65 2026-07-09 05:12:46 +02:00
KoalaDev 6aa42668e9 feat(website): add SRI to scripts & styles and optimize hero image preload 2026-07-09 05:06:35 +02:00
KoalaDev 71fdea78f3 feat(website): optimize rendering performance and improve structured metadata 2026-07-09 04:53:07 +02:00
Timo 5b7ee654e4 fix(website): keep hero demo hint readable 2026-07-08 20:22:20 +02:00
KoalaDev 7718885511 seo(website): add comprehensive AI agent rules to .gitignore 2026-07-08 17:31:02 +02:00
KoalaDev 12bca85356 build(website): move locales translation utility to scripts/ and add validate-brand-names script 2026-07-08 17:28:11 +02:00
KoalaDev b4bf297a5b i18n(website): translate new alternatives pages in all languages and save translation script as workspace example 2026-07-08 17:26:41 +02:00
KoalaDev 1e417e3379 fix(website): wrap locale replacement values in function to prevent RegExp dollar replacement bugs 2026-07-08 16:59:04 +02:00
KoalaDev d7d795f076 build(website): integrate dynamic sitemap generation into main build script 2026-07-08 16:53:57 +02:00
KoalaDev a45cfb063e seo(website): regenerate sitemap.xml to index all alternatives and update llms.txt 2026-07-08 16:52:46 +02:00
KoalaDev ee28f9d275 fix(website): grammar in TwoSeven German locale 2026-07-08 16:51:08 +02:00
KoalaDev 21936e7f71 feat(website): add KoalaSync vs TwoSeven comparison page 2026-07-08 16:50:05 +02:00
KoalaDev a45e52c6ee feat(website): add KoalaSync vs Kosmi comparison page 2026-07-08 16:47:29 +02:00
KoalaDev a0b4f1a2bb feat(website): update Scener comparison with pricing and technical differences 2026-07-08 16:38:40 +02:00
KoalaDev 4a8fdfbe70 chore: remove obsolete LANDINGPAGE_UI_OVERHAUL_PLAN.md 2026-07-08 16:32:31 +02:00
KoalaDev 9127b1f8dd feat(website): add KoalaSync vs Scener comparison page 2026-07-08 16:27:04 +02:00
KoalaDev e3a27db611 docs: add v2.5.4 changelog entry 2026-07-08 16:26:17 +02:00
GitHub Action 861bb05ec2 chore(release): update versions to v2.5.4 [skip ci] 2026-07-08 14:25:32 +00:00
KoalaDev 9f02a47d98 feat(website): add KoalaSync vs Watch2Gether comparison page 2026-07-08 16:20:44 +02:00
KoalaDev c3827fa4b7 refactor: remove cloud storage providers from domains blacklist 2026-07-08 16:04:00 +02:00
KoalaDev 61ba32addf refactor: remove localhost from domains blacklist 2026-07-08 16:03:53 +02:00
KoalaDev 140e2c0d00 chore: clarify privacy and legal notice wording 2026-07-07 12:57:58 +02:00
KoalaDev cc53494cc0 Merge pull request #25 from Shik3i/landingpage-redesign
Landing page redesign and polish
2026-07-07 07:10:24 +02:00
KoalaDev fd3c3e6cdb fix: satisfy landing page resize observer lint 2026-07-07 06:28:34 +02:00
KoalaDev 7d6ca9fd68 fix: finish responsive landing page polish 2026-07-07 06:21:36 +02:00
KoalaDev 84526cf8e9 fix: complete website seo localization 2026-07-07 02:38:28 +02:00
KoalaDev c16bf30ad5 fix: persist theme and lighten demo mockup 2026-07-07 02:28:45 +02:00
KoalaDev e559b41c93 fix: polish light mode landing page 2026-07-07 02:12:40 +02:00
KoalaDev a9687219e0 fix: normalize mobile platform logos 2026-07-07 02:07:15 +02:00
KoalaDev dfe0b42252 fix: hide hero github cta on mobile 2026-07-07 02:05:31 +02:00
KoalaDev d29c842e96 fix: compact getting started section 2026-07-07 02:02:44 +02:00
KoalaDev 07839a907b fix: refine landing page responsive layout 2026-07-07 02:00:37 +02:00
KoalaDev 78efb04b6a feat: refine landing page demo experience 2026-07-07 01:49:25 +02:00
KoalaDev e093a8afae docs: refresh onboarding and examples 2026-07-06 17:31:24 +02:00
KoalaDev b8484f77d6 content: swap privacy tile for the unique Volume Leveling feature
The combined section's third feature tile was Privacy, which is already
covered by the hero trust line and the comparison table. Replace it with
Volume Leveling / Audio Compressor, a genuinely differentiating feature not
shown anywhere else on the page. Shorten its description to match the other
tiles (all 15 locales). The three tiles are now: 1-click invites, volume
leveling, universal HTML5 support.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:59:09 +02:00
KoalaDev 8d7b7f68f3 copy: shorten the Universal HTML5 Support feature description
Trim the long three-sentence blurb to a tight two-liner (platform list plus
the "only sync signals, never the video" reassurance) so it matches the other
tiles in the combined section. Applied to all 15 locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:51:14 +02:00
KoalaDev 2848a4450c fix: guarantee the three hero CTAs stay on one line
The headline column in the old narrow two-column range (769-1023px) was too
tight to fit the full "Add to X" label plus two more buttons, so GitHub wrapped
to a second row. The hero is now single-column below 1024px (headline gets the
full width, demo stacks under it, matching the demo's own breakpoint) and
two-column at >=1024px where the column is wide enough. GitHub button label
shortened to "GitHub" for extra margin. Verified one row at 769/900/1024/1280
and the mobile stack at 375px.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:47:37 +02:00
KoalaDev 4c519a81e0 a11y: hero demo passes WCAG contrast (Lighthouse 4x100 desktop)
- Remove the confusing, space-hungry "Episode Lobby" card from the mockup
- Give the decorative demo mockup a contrast-safe palette: accent shown as
  text uses a lighter indigo, and buttons with white text on a colour get
  darker shades, so every walkthrough state meets WCAG AA
- Drop the scroll-reveal fade from the above-the-fold hero demo; the fade
  briefly rendered its text at partial opacity, which axe caught as failing
  contrast on ~1 in 3 audits
- Mark the demo as decorative for assistive tech (aria-hidden + removed from
  tab order); mouse interaction is unchanged
- Darken the footer support pill so white text meets contrast

Desktop Lighthouse: 100/100/100/100 (stable across runs). Mobile: 100
a11y / best-practices / SEO; performance ~87 (throttled LCP, separate work).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 16:30:37 +02:00
KoalaDev db70828c82 feat: browser-aware CTAs, tidier hero demo, comparison guides link
- Hero CTAs: the install buttons are compact (just the browser name) so all
  three fit one line; JS detects the visitor's browser and expands that one
  to the full "Add to X" call to action. Unknown browsers stay compact.
- Hero demo: caption now sits directly under the two windows (absolutely
  positioned to the card cascade) instead of far below the tall scene. The
  scene keeps a fixed height that already fits the open popup, so the
  vertically centered hero never jumps when the popup toggles.
- The background window now also shows a (non-interactive) extension icon,
  making it clear both browsers have KoalaSync installed.
- Escape now also closes the demo popup.
- Added a link from the comparison table to the fuller alternatives/guides
  hub, localized in all 15 languages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 16:03:48 +02:00
KoalaDev fd0ad310ce fix: hero demo popup closes on any outside click, add close button
Outside-click handling was scoped to the demo scene, so clicks elsewhere
on the page left the popup open and it felt unclosable. A document-level
listener now collapses it from anywhere, like a real extension popup,
and the popup header gained an explicit close button next to the version
link (hidden in the static mobile mockup).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 03:23:15 +02:00
KoalaDev 917afd795e fix: bottom-anchor hero demo popup instead of oversizing the scene
Reserving the full 600px open-popup height pushed the H1 down on short
viewports. The scene now stays at 500px (below the left column height,
so it never drives the grid row) and the extension popup is anchored to
the scene bottom, growing upwards on open. Layout stays pixel-stable
when the walkthrough toggles the popup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 03:10:18 +02:00
KoalaDev da681e9e3f fix: reserve open-popup height for hero demo scene
The scene animated its own height when the walkthrough toggled the
extension popup; with the vertically centered hero grid this re-centered
the left column and shifted everything below on mid-size screens. The
scene now always reserves the 600px open state, so toggling the popup
never reflows the page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:56:37 +02:00
KoalaDev 37df4b267c feat: landing page redesign
- New hero headline "Turn any video into a watch party" with animated
  gradient highlight, sharper subtitle and quiet trust line under the CTAs
- Changelog badge redesigned as the koala's speech bubble (version and
  release date baked in at build time, no version.json fetch anymore)
- Light/dark theme toggle in the header (dark default, pre-paint via
  lang-init, full light theme across sections)
- Use cases and "Why KoalaSync" merged into one section with the three
  strongest feature tiles; question-mark koala moved to the FAQ
- FAQ and self-hosting setup collapsed by default via shared
  section-collapse component (hash deep links auto-open them)
- Compat/juggler section more compact
- og:image switched to the 1280x640 repository OpenGraph card (landing
  and join page), generated during build
- "watch party" keyword added to meta title/description in all 15 locales
- All em-dashes removed from user-facing copy

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 21:50:45 +02:00
KoalaDev 793248053c Merge pull request #24 from Shik3i/chore/email-koalastuff
chore: migrate contact emails to koalastuff.net
2026-07-03 23:50:05 +02:00
Timo (KoalaDev) 1aa95079cf chore: migrate all contact emails from koalamail.rocks to koalastuff.net 2026-07-03 23:44:04 +02:00
KoalaDev 0976e765c3 Merge pull request #23 from Kaia-Alenia/fix-cors-env
docs: Update Vix test status to full in TESTED_SERVICES.md
2026-07-03 18:51:25 +02:00
Kaia-Alenia ee2c284cb4 Merge remote-tracking branch 'origin/main' into fix-cors-env
# Conflicts:
#	docs/TESTED_SERVICES.md
2026-07-03 10:23:54 -06:00
Kaia-Alenia 883807afd7 docs: Update Vix test status to full in TESTED_SERVICES.md 2026-07-03 09:38:38 -06:00
KoalaDev 46b2004f78 Merge pull request #22 from Shik3i/landingpage-ui-overhaul
Landing page UI overhaul: new extension mockup, interactive demo, 3-step guide, hero fixes
2026-07-03 14:36:00 +02:00
Timo 3fd02246ab feat: Add llms.txt for AI crawler support
- Create website/llms.txt following llmstxt.org spec (H1, blockquote,
  H2 sections with link lists, Optional section)
- Include llms.txt in website build pipeline (generic files copy)
- Sections: Features, Download, Technical, Optional (alternatives,
  sitemap, privacy, imprint, join)
2026-07-03 14:26:15 +02:00
Timo 14c9ab1d5e fix: Ensure hero heading has minimum distance from fixed nav bar across all languages
- Change .hero from align-items:center to flex-start so content starts from top
- Increase padding-top in short-viewport media query (<=920px height) to
  clamp(7.5rem, 10vh, 9rem) from clamp(3.75rem, 6vh, 5.5rem) so it always
  clears the ~96px nav bar
- Fix pre-existing lint errors: remove unused variables demoFinishedByStory,
  firstTake; add website/www to eslint ignores
2026-07-03 14:02:47 +02:00
Timo 0127d62a49 Tighten hero demo layout 2026-07-03 13:09:11 +02:00
KoalaDev 2d94cc10e4 website: Fix popup not collapsing after demo walkthrough completes
Remove firstTake && !demoFinishedByStory guard from takeOver handler
so clicks outside the extension mockup always collapse the popup,
regardless of whether the automated walkthrough finished.
2026-07-03 12:43:59 +02:00
KoalaDev c5c140d31c website: Link label elements to their select elements via for/id attributes in mockups for better accessibility 2026-07-03 12:14:35 +02:00
KoalaDev 5a53abc636 website: Adjust extension-mockup wrapper height to 600px to display the leave room button without scrolling 2026-07-03 12:13:34 +02:00
KoalaDev f0328fb865 website: Fall back to static extension mockup on medium screens (768px to 1024px) to prevent overlapping in narrower columns 2026-07-03 12:11:30 +02:00
KoalaDev e1e18aa26c website: Completely hide hero extension mockup wrapper on all stacked viewport sizes (768px and below) 2026-07-03 12:09:16 +02:00
KoalaDev cece816e00 website: Completely hide hero extension demo mockup wrapper on mobile screens (480px and below) 2026-07-03 11:57:33 +02:00
KoalaDev 9690868e33 website: Add smooth FAQ accordion spring animation with rotation icon transition 2026-07-03 11:35:00 +02:00
KoalaDev cb6d7eb98c website: Reformat Step 2 illustration as a realistic vertical extension popup mockup in the Sync tab 2026-07-03 11:32:51 +02:00
KoalaDev ab6356c5ad website: Replace long series name with shorter alternatives and randomize season 1 episode numbers dynamically 2026-07-03 11:30:04 +02:00
KoalaDev 5460ac3a01 website: Add micro-animations including scroll progress bar, nav logo entrance spin, and button shine sweeps 2026-07-03 11:26:45 +02:00
KoalaDev 60c80ad7fb website: Synchronize and randomize mockup video titles, and fix Step 2 illustration to use realistic select video dropdown 2026-07-03 11:25:12 +02:00
KoalaDev c8df8b6d08 website: Redesign Step 2 browser mockup as video player + select dropdown, and redesign Step 3 mockups to display matching forest movie 2026-07-03 11:22:10 +02:00
KoalaDev 49712a7151 website: Bring lower browser mockup to foreground on hover 2026-07-03 11:20:28 +02:00
KoalaDev 23aa0c8a15 website: Fix Host Control mockup to use the actual 'Only I can control playback' toggle switch 2026-07-03 11:15:32 +02:00
KoalaDev ae3b837bb4 website: Add Host Control Mode interactive mockup to Bento Card 1 and update translations in all 15 locales 2026-07-03 11:12:23 +02:00
KoalaDev 14ab6b2d50 website: Restructure 3-step guide flowchart, swap illustrations, and rewrite translations in all 15 locales 2026-07-03 11:09:25 +02:00
KoalaDev 82b90d86a9 website: Rename mockup usernames to ChillCat and HappyDog across HTML, JS, and all 15 locales 2026-07-03 11:07:03 +02:00
KoalaDev 537c8f3c60 website: Visual polish of 3-step guide and description updates in 13 locales 2026-07-03 11:05:45 +02:00
KoalaDev 6b81668e16 website: Fix mockup animation sync/pausing and improve 3-step guide copy 2026-07-03 11:01:57 +02:00
KoalaDev d6a23f2eb5 website: Hero layout and mock walkthrough visual optimizations 2026-07-03 10:57:23 +02:00
KoalaDev 573847486b fix: hero CTAs invisible until scroll, seek animation skip, layout overhaul
- Remove data-reveal from hero h1/subtitle/mascot/cta-group/version-badge
  (critical above-fold content must never start hidden)
- Change IntersectionObserver rootMargin -150px to -30px, threshold 0.1 to 0.05
  (CTAs at ~530px were past the observer zone on laptops)
- Hero layout: align-items flex-start + padding-top 7.5rem for content at top
- Cards 62cqw to 70cqw, -3% offsets for dramatic overlap cascade
- Film animation: replace stick-figure walker with cinematic night scene
  (7 twinkling stars, glowing moon with drift, parallax mountains, firefly)
- Seek now restarts all film child animations via .demo-reset class toggle
  (was incorrectly resetting parent only — no visible jump)
- Add .demo-seek-sweep overlay + brightness flash during seek
- Video select: placeholder option ('Choose a video tab...') until story picks
- RATE 8 to 1 for realtime playback
- Version badge moved below download buttons
- All 15 locale files: add DEMO_SELECT_PLACEHOLDER key
2026-07-03 10:42:09 +02:00
KoalaDev e5a4032710 work in progress: UI overhaul updates, new logo, and localization translations 2026-07-03 10:01:15 +02:00
KoalaDev 44d452a82f docs: update and categorize all entries in docs/README.md 2026-07-02 22:58:19 +02:00
KoalaDev 82d0bd4eb8 docs: correct Netflix compatibility status from N/A to X 2026-07-02 22:54:32 +02:00
KoalaDev d3ca2a2378 docs: update tested services compatibility table and contribution guide 2026-07-02 22:51:08 +02:00
KoalaDev b7622a8347 Update feature_request.md 2026-07-02 18:30:37 +02:00
Timo 3a733934c5 fix(lint): add website/**/*.cjs files to Node globals configuration 2026-07-02 18:01:11 +02:00
Timo 25ad3e8d59 feat(website): add cross-platform indexnow submission script 2026-07-02 17:39:17 +02:00
Timo 223c3949d2 feat(website): auto-copy google and indexnow verification files during build 2026-07-02 17:38:15 +02:00
Timo 3392f0b3a7 docs: tighten v2.5.3 changelog wording 2026-07-02 17:16:15 +02:00
GitHub Action 72ea5d1ee9 chore(release): update versions to v2.5.3 [skip ci] 2026-07-02 14:59:12 +00:00
174 changed files with 29609 additions and 7199 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
name: 🚀 Feature Request
about: Suggest a new feature or enhancement for KoalaSync
title: ''
labels: enhancement
labels: feature
assignees: ''
---
+25 -4
View File
@@ -6,9 +6,9 @@ name: Beta Server Image
#
# Tags produced (on ghcr.io/<owner>/<repo>):
# - beta moving channel pointer to the newest build
# - <branch-slug> e.g. feature-host-control-mode
# - <branch-slug> e.g. feature-textchat
# - sha-<short-commit> immutable, pin to an exact build
# - <custom> only on manual run, e.g. hostcontrol01
# - <custom> only on manual run, e.g. chatbeta01
# Never ':latest' (flavor: latest=false).
#
# Remove this workflow once the feature is merged & released the normal way.
@@ -16,11 +16,11 @@ name: Beta Server Image
on:
push:
branches:
- feature/host-control-mode
- feature/textchat
workflow_dispatch:
inputs:
tag:
description: 'Extra immutable tag for this build (e.g. hostcontrol01). Optional.'
description: 'Extra immutable tag for this build (e.g. chatbeta01). Optional.'
required: false
default: ''
@@ -37,6 +37,27 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: |
package-lock.json
server/package-lock.json
- name: Install root dependencies
run: npm ci
- name: Install server dependencies
run: npm ci
working-directory: server
- name: Run verification suite
run: npm run verify
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
+2
View File
@@ -23,6 +23,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v6
+8 -6
View File
@@ -72,6 +72,8 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v6
@@ -109,22 +111,22 @@ 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"
# 7. website/sitemap.xml — lastmod dates
sed -i "s/<lastmod>[0-9-]*<\/lastmod>/<lastmod>$(date +%Y-%m-%d)<\/lastmod>/g" website/sitemap.xml
echo " ✓ website/sitemap.xml -> lastmod $(date +%Y-%m-%d)"
echo "Version injection complete."
- name: Commit and push version updates back to main
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 website/sitemap.xml
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:
+16
View File
@@ -43,6 +43,22 @@ extension/shared/
# Auto-generated website build output
website/www/
website/.avif-cache.json
# Temporary scratch files
scratch/
# AI Assistants and Agents
.agents/
.antigravity/
.gemini/
.claude/
.codex/
.opencode/
.cursor/
.cursorrules
.copilot/
.cline/
.clinerules
.windsurfrules
.codeium/
+1 -1
View File
@@ -59,7 +59,7 @@ representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the project maintainer at **koaladev@koalamail.rocks**.
reported to the project maintainer at **koalasync@koalastuff.net**.
All complaints will be reviewed and investigated promptly and fairly.
All project maintainers are obligated to respect the privacy and security of the
+1 -1
View File
@@ -22,7 +22,7 @@ Please note that by participating in this project, you agree to abide by our [Co
### Prerequisites
- **Node.js** v18+
- **Node.js** v20.9+
- **Docker** (for local relay server testing)
### Quick Start
+17 -11
View File
@@ -6,7 +6,7 @@
<p align="center">
<a href="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml"><img src="https://github.com/Shik3i/KoalaSync/actions/workflows/release.yml/badge.svg" alt="Release Status"></a>
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v2.5.2-blue?logo=github" alt="GitHub release"></a>
<a href="https://github.com/Shik3i/KoalaSync/releases"><img src="https://img.shields.io/badge/Release-v2.6.4-blue?logo=github" alt="GitHub release"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue" alt="License"></a>
<a href="https://addons.mozilla.org/de/firefox/addon/koalasync/"><img src="https://img.shields.io/badge/Firefox-Download-orange?logo=firefoxbrowser&logoColor=white" alt="Firefox Add-on"></a>
<a href="https://chromewebstore.google.com/detail/koalasync/obbnmkmlaaddodakcbdljknjpagklifc"><img src="https://img.shields.io/badge/Chrome-Download-blue?logo=googlechrome&logoColor=white" alt="Chrome Extension"></a>
@@ -14,7 +14,7 @@
<p align="center"><i>KoalaSync is a lightweight Browser Extension and Relay Server for synchronized video playback on almost any website with a video element—YouTube, Twitch, Netflix, Emby, Jellyfin, and beyond. Built with a focus on <b>Data Sovereignty</b> and <b>Performance</b>.</i></p>
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.5.2 Release!</b> — See what's changed</a></p>
<p align="center"><a href="docs/CHANGELOG.md"><b>New v2.6.4 Release!</b> — See what's changed</a></p>
### 🌟 Why KoalaSync?
@@ -29,6 +29,7 @@
- **Global Synchronization**: Synchronize Play, Pause, and Seeking on any website with a `<video>` tag.
- **Episode Auto-Sync**: Perfectly sync series binges. All peers wait until everyone has loaded the next episode before starting together.
- **Host Control & Co-Hosts**: Room hosts can lock playback control to trusted controllers while guests keep watching in sync.
- **Smart Matching**: Automatically highlights tabs containing matching video titles.
- **Dual Heartbeat Architecture**: Robust session tracking that prevents ghost rooms and stale connections.
- **Efficient Relay**: Minimal overhead WebSocket message forwarding.
@@ -58,7 +59,7 @@ The easiest and safest way to install KoalaSync is directly through the official
### 🌐 Localization & Translations
Both the official KoalaSync website and the **v2.0 Browser Extension** feature full dynamic localization:
Both the official KoalaSync website and the browser extension feature dynamic localization:
- **Available Languages**: Support is included for 15 languages: English (`en`), German (`de`), French (`fr`), Spanish (`es`), Portuguese (Brazil) (`pt-BR`), Russian (`ru`), Italian (`it`), Polish (`pl`), Turkish (`tr`), Dutch (`nl`), Japanese (`ja`), Korean (`ko`), Chinese (Simplified) (`zh`), Ukrainian (`uk`), and European Portuguese (`pt`).
- **Real-Time Extension Localization**: Inside the extension Settings panel, users can swap languages instantly. The entire interface, notifications, Empty States, and onboarding guides re-translate dynamically in real-time.
- **Contributing**: We welcome community translations for both the website and the extension! Please refer directly to the [TRANSLATION.md](docs/TRANSLATION.md) guide for step-by-step instructions on how to audit, refine, or add new languages.
@@ -86,16 +87,20 @@ npm run build:extension
The compiled artifacts will be available in the `dist/` directory.
#### For Self-Hosting (Docker)
Deploy your own private relay server using our official image:
For local development or a simple private relay, use the root Compose file:
```bash
# Pull the latest image
docker pull ghcr.io/shik3i/koalasync:latest
# Or use our example compose file
cp examples/docker-compose.caddy.example.yml docker-compose.yml
docker-compose up -d
cp server/.env.example server/.env
# Edit server/.env and set SERVER_SALT to a unique random value:
# openssl rand -base64 32
docker compose up -d --build
```
The server will be available at `ws://localhost:3000`. See [Docker network compose](examples/docker-compose.caddy.example.yml) or [Static IP compose](examples/docker-compose.ip.example.yml) for ready-to-use Docker Compose files.
The local relay will be available at `ws://localhost:3000`.
For production, use the official image and one of the ready-to-edit examples:
- [Docker network compose](examples/docker-compose.caddy.example.yml): for a Caddy or reverse-proxy network. This file does not publish `localhost:3000`; Caddy routes traffic to the container.
- [Static IP compose](examples/docker-compose.ip.example.yml): for a pre-existing Docker network with a fixed container IP.
In every real deployment, set a unique `SERVER_SALT`. Without it the relay still starts, but it warns that room-password hashes are using the public fallback salt from the repository.
To connect your extension to a self-hosted server, open the popup → **Room** tab → select **Custom Server** → enter your server's WebSocket URL (e.g., `ws://localhost:3000`).
@@ -134,6 +139,7 @@ gh attestation verify dist/koalasync-chrome.zip \
- **[PROTOCOL.md](docs/PROTOCOL.md)**: WebSocket protocol specification and event reference.
- **[ROADMAP.md](docs/ROADMAP.md)**: Planned features, backlog, and rejected ideas.
- **[SECURITY.md](SECURITY.md)**: Disclosure policy and security practices.
- **[AI_INIT.md](docs/AI_INIT.md)**: Maintainer and agent onboarding notes for safe code changes.
- **[Caddyfile.example](examples/Caddyfile.example)**: Production Caddy configuration for website and relay.
---
+1 -1
View File
@@ -44,7 +44,7 @@ The following components are within scope for security reports:
Instead, email the project maintainer privately:
**`koalasync_admin@koalamail.rocks`**
**`koalasync@koalastuff.net`**
Encrypt sensitive findings with our PGP key (available on request).
+2 -2
View File
@@ -16,7 +16,7 @@ Ready-to-paste copy for product listings, launch pages, directory submissions, a
## 2. Three-Sentence Overview
> KoalaSync is a lightweight browser extension that keeps you and your friends perfectly in sync on YouTube, Netflix, Twitch, Prime Video, Jellyfin, Emby, and almost any other site with an HTML5 video player — press play once and everyone stays together. It's built privacy-first: no accounts, no telemetry, and the official relay server runs entirely in volatile RAM with zero persistence, so nothing about your sessions is ever stored. Open source under the MIT license and fully self-hostable with a single Docker command, KoalaSync is a transparent watch-party tool that works everywhere and respects your data sovereignty.
> KoalaSync is a lightweight browser extension that keeps you and your friends perfectly in sync on YouTube, Netflix, Twitch, Prime Video, Jellyfin, Emby, and almost any other site with an HTML5 video player — press play once and everyone stays together. It's built privacy-first: no accounts, no telemetry, and the official relay server runs entirely in volatile RAM with zero persistence, so nothing about your sessions is ever stored. Open source under the MIT license and fully self-hostable with a single Docker Compose command, KoalaSync is a transparent watch-party tool that works everywhere and respects your data sovereignty.
---
@@ -38,7 +38,7 @@ Most watch-party tools fall into one of two traps: they only work on a short all
### Built for people who actually want to read the code
KoalaSync is MIT-licensed open source, built by a solo developer. Audit it, fork it, change it. The extension is dependency-free with a direct Socket.IO wire implementation — no opaque libraries, no framework bloat, no surprise third-party SDKs. Want full sovereignty? Self-host your own relay with a single Docker command and keep all watch-party coordination traffic inside your own infrastructure. The official public relay is there when you don't care, self-hosting is there when you do.
KoalaSync is MIT-licensed open source, built by a solo developer. Audit it, fork it, change it. The extension is dependency-free with a direct Socket.IO wire implementation — no opaque libraries, no framework bloat, no surprise third-party SDKs. Want full sovereignty? Set a unique `SERVER_SALT` in the supplied Compose file, then start your own relay with a single Docker Compose command and keep all watch-party coordination traffic inside your own infrastructure. The official public relay is there when you don't care, self-hosting is there when you do.
### Little touches you'll notice
+4 -4
View File
@@ -1,6 +1,6 @@
KoalaSync: Private Watch Parties for Emby, Jellyfin, Plex, Netflix & YouTube
Tired of counting down "3, 2, 1, Play" over voice chat? KoalaSync keeps you and your friends perfectly in sync. Whether you are streaming from your own self-hosted media server like Emby, Jellyfin or Plex, or watching on a major platform like Netflix, Prime Video or YouTube KoalaSync is designed for smooth, browser-based watch parties.
Tired of counting down "3, 2, 1, Play" over voice chat? KoalaSync keeps you and your friends perfectly in sync. Whether you are streaming from your own self-hosted media server like Emby, Jellyfin or Plex, or watching on a major platform like Netflix, Prime Video or YouTube, KoalaSync is designed for smooth, browser-based watch parties.
✨ CORE FEATURES
@@ -12,7 +12,7 @@ No account required. No tracking. Just create a room, invite your friends, and s
• Smart Binge-Watching: When a new episode loads, KoalaSync automatically pauses the lobby until everyone is ready. No spoilers, no one left behind.
• Smart Audio Compressor: Tired of quiet dialogue and suddenly loud action scenes? Balance whispering, explosions, and music with a single click while you watch.
• One-Click Invites: Send a smart invite link to your friends. When they open it, KoalaSync automatically configures the room so they can join instantly.
• 13 Languages: Enjoy a native experience with a fully translated user interface.
• 15 Languages: Enjoy a native experience in English, German, French, Spanish, Brazilian and European Portuguese, Russian, Italian, Polish, Turkish, Dutch, Japanese, Korean, Simplified Chinese, and Ukrainian.
@@ -33,14 +33,14 @@ KoalaSync is built for private watch parties without unnecessary data collection
3. Share the invite link with your friends.
4. Open your favorite streaming site or media server.
5. Select the active video tab.
6. Press play — everyone stays perfectly in sync.
6. Press play. Everyone stays perfectly in sync.
⚙️ UNDER THE HOOD
KoalaSync is lightweight, transparent, and built with privacy in mind.
• On-Demand Relay: Playback state is synchronized through a custom WebSocket-based relay server. No persistent connection — the relay is only active while you're in a room. No background traffic, no idle connections.
• On-Demand Relay: Playback state is synchronized through a custom WebSocket-based relay server. There is no permanent background connection. The relay connection is maintained only while you're in a room and closes when you leave or automatically after two hours without a selected-video heartbeat.
• No Media Streaming: KoalaSync does not stream, proxy, upload, download, or redistribute any video content. Everyone watches from their own browser on the original website.
• Temporary Room State Only: The relay server only coordinates room state such as play, pause, seek position, active target, nickname, and readiness status.
• Docker Self-Hosting: The relay server can be self-hosted with Docker if you prefer to run your own private instance.
+29 -27
View File
@@ -7,7 +7,7 @@ Welcome to the KoalaSync project. This file is the primary entry point for any d
> - **Font Stack**: Use a modern system font stack (e.g., -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif) to maintain a premium look without external dependencies. Prohibit the use of `@import` or `<link>` for external font services.
>
> [!IMPORTANT]
> **Caveman Communication Protocol**: To conserve context window and tokens, AI agents MUST communicate with the user in **caveman language** (broken, short German/English hybrid caveman style, e.g., "Ich Antigravity. Ich machen Git pull. Code fertig.") at all times. This rule is highly strict and must NOT be broken under any circumstances unless the user explicitly requests to drop it.
> **Communication Standard**: Be concise, concrete, and professional. Match the user's language when practical, explain risky changes before making them, and report exactly what was changed and verified. Do not use joke protocols or intentionally broken language in contributor-facing work.
---
@@ -21,28 +21,30 @@ KoalaSync is a specialized tool for **synchronized video playback** across multi
- `extension/`: Browser Extension (Chrome & Firefox, Manifest V3). Contains background service worker, content scripts, and popup UI.
- `server/`: Node.js Relay Server using Socket.IO (WebSocket-only).
- `website/`: **Landing Page** & Invitation Bridge (Marketing, Tutorials, and Downloads).
- **`build.cjs`**: Zero-dependency static site compiler. Translates `template.html` + `locales/*.json``www/`. Also minifies CSS/JS automatically.
- **`www/` is auto-generated**: Never edit files in `www/` directly. Always edit source files (`template.html`, `style.css`, `app.js`, `lang-init.js`, `locales/*.json`) and run `node website/build.cjs` to regenerate. CSS/JS are output as `.min.*` files — a built-in cleanup step removes stale artifacts on each build.
- `shared/`: **Single Source of Truth** for protocol constants and event names.
- **`build.cjs`**: Static site compiler using the repository's build dependencies. Translates `template.html` + `locales/*.json``www/`, generates the sitemap, and minifies HTML/CSS/JS.
- **`www/` is auto-generated**: Never edit files in `www/` directly. Always edit source files (`template.html`, `style.css`, `styles/*.css`, `app.js`, `lang-init.js`, `locales/*.json`) and run `node website/build.cjs` to regenerate. `style.css` is the development manifest; `style.legacy.css` is an unloaded, byte-identical migration reference. Production creates page-specific and render-priority CSS bundles from `styles/*.css`; CSS/JS are output as `.min.*` files and stale generated assets are removed on each build.
- `shared/`: **Single Source of Truth** for protocol constants, event names, blacklist data, and generated usernames.
- `scripts/`: Development utilities (e.g., `build-extension.cjs`).
- `docker-compose.yml`: Root-level orchestration for the relay server.
> [!IMPORTANT]
> **Single Source of Truth**: `shared/constants.js` and `shared/blacklist.js` are the master files. They must be synchronized to the `extension/shared/` directory using `node scripts/build-extension.cjs`.
> **Single Source of Truth**: `shared/constants.js`, `shared/blacklist.js`, and `shared/names.js` are the master shared files. They must be synchronized to the `extension/shared/` directory using `node scripts/build-extension.cjs`.
> - **Extension Modules** (`background.js`, `popup.js`) import directly from `./shared/constants.js`.
> - **Content Scripts** (`content.js`) use a **marker-injected synchronous copy** of the constants. The build script automatically replaces the marked blocks — no manual mirroring needed.
## 3. Mandatory Reading
Before touching any code, you MUST read the following documents in order:
1. [ARCHITECTURE.md](ARCHITECTURE.md) Detailed communication flows, Dual Heartbeat, and two-phase sync protocol.
2. [extension/README.md](../extension/README.md) Extension components, tab structure, and loading process.
3. [SYNC_GUIDE.md](SYNC_GUIDE.md) Protocol constants and synchronization requirements.
1. [../README.md](../README.md) User, developer, and self-hosting overview.
2. [README.md](README.md) Documentation map by role.
3. [ARCHITECTURE.md](ARCHITECTURE.md) Detailed communication flows, Dual Heartbeat, and two-phase sync protocol.
4. [../extension/README.md](../extension/README.md) Extension components, tab structure, and loading process.
5. [SYNC_GUIDE.md](SYNC_GUIDE.md) Protocol constants and synchronization requirements.
## 4. The "Vanilla JS Mirror" Pattern
To avoid boot-time race conditions in Manifest V3 without a bundler, the following architectural trade-off is enforced:
- **Synchronous Execution**: `content.js` MUST execute synchronously to catch early media events.
- **Automated Injection**: The build script (`node scripts/build-extension.cjs`) automatically injects `EVENTS` and `HEARTBEAT_INTERVAL` into `content.js` using marker-based replacement (see `../scripts/README.md` for marker details).
- **Maintenance**: After modifying `shared/constants.js`, simply run the build script. No manual mirroring is required.
- **Automated Injection**: The build script (`node scripts/build-extension.cjs`) automatically injects `EVENTS`, `HEARTBEAT_INTERVAL`, and episode utility functions into `content.js` using marker-based replacement (see `../scripts/README.md` for marker details).
- **Maintenance**: After modifying any root `shared/` file, run the build script. No manual mirroring is required.
## 5. File Responsibility Map
@@ -52,26 +54,24 @@ To avoid boot-time race conditions in Manifest V3 without a bundler, the followi
| `content.js` | Video element detection, media control, event origin detection (loop prevention) |
| `popup.js` | UI rendering, user input handling, peer display, invitation link generation |
| `bridge.js` | Landing page ↔ extension communication for invitation join flow |
| `episode-utils.js` | Shared episode parsing, imported by background and injected into content |
| `title-privacy.js` | Tab/media title privacy normalization and sanitization |
| `page-api-seek-overrides.js` | Page-level seek bridge for site-specific player APIs |
| `server/index.js` | Room management, message relay, rate limiting, authentication, peer lifecycle |
## 6. Design Guidelines
The popup UI follows a strict design system. Do not modify these variables or the layout structure without explicit approval.
- **Font**: System font stack. **MANDATORY**: No external CDNs or Google Fonts to ensure 100% privacy.
- **Popup Width**: Fixed at `320px`.
- **Tab Structure**: Must maintain the **Room**, **Sync**, **Settings**, and **Dev** tabs.
- **CSS Variables**:
| Variable | Value | Purpose |
| :--- | :--- | :--- |
| `--bg` | `#0f172a` | Main background |
| `--card` | `#1e293b` | Form and info cards |
| `--accent` | `#6366f1` | Primary actions and branding |
| `--success` | `#22c55e` | Success states / Online dot |
| `--error` | `#ef4444` | Errors / Offline dot |
- **Tab Structure**: Must maintain the visible **Room**, **Sync**, **Settings**, and **Status** tabs. The hidden **Dev** tab is available only for developer diagnostics.
- **Appearance modes**: Preserve `system`, `light`, and `dark` behavior and the Eucalyptus, Cyber, and Graphite palettes.
- **CSS variables**: Use the semantic tokens defined in `popup.html` (`--bg`, `--card`, `--accent`, `--text`, `--text-muted`, `--success`, `--warning`, and `--error`). Do not hard-code the retired indigo/slate palette or bypass palette-specific token overrides.
## 7. Non-Negotiables (Core Logic)
The following features are critical and must not be removed or fundamentally altered:
- **Two-Phase Force Sync**: The `Prepare``ACK``Execute` flow ensures all peers are buffered before playback resumes.
- **Episode Auto-Sync**: Ensures series binges stay perfectly synced. A lobby initiates during title transitions, freezing peers until everyone is ready.
- **Host Control Mode**: In `host-only` rooms, only the host and promoted controllers may initiate room-moving playback events. Both client-side UX and server-side gates must remain intact.
- **Dual Heartbeat**:
- **Background Heartbeat (1m)**: Ensures session persistence even without a video element.
- **Content Heartbeat (15s)**: Transmits current video metadata (time, title).
@@ -80,8 +80,9 @@ The following features are critical and must not be removed or fundamentally alt
- **Platform Specifics**: Specialized click-logic for YouTube (`.ytp-play-button`) and Twitch.
- **pollSeekReady()**: Polling mechanism that checks `video.readyState` before acknowledging sync.
- **SW Keep-alive**: Use of `chrome.alarms` to prevent the Manifest V3 Service Worker from suspending.
- **Diagnostics**: The "Dev" tab provides real-time access to the underlying `<video>` state for troubleshooting.
- **Diagnostics**: The visible **Status** tab provides real-time access to connection state, ping, logs, history, and underlying `<video>` state for troubleshooting.
- **Persistence**: `peerId` and `username` must be stored to remain stable across sessions.
- **Title Privacy**: Do not accidentally reintroduce tab/media title sharing when users disabled or reduced it.
- **Room ID Format**: Room IDs are restricted to `[a-zA-Z0-9-]` only (alphanumeric + hyphens). This is enforced server-side.
## 8. Technical Constraints
@@ -89,7 +90,8 @@ The following features are critical and must not be removed or fundamentally alt
- **Manual Protocol**: `background.js` implements a subset of the Socket.IO wire protocol natively.
- **Server Transport**: Restricted to `websocket` only. Polling is disabled.
- **Docker Context**: The Docker build must run from the **Repo Root**.
- **Manifest Settings**: `run_at` must remain `document_idle`, and `all_frames` must remain `false`.
- **Content Script Scope**: `bridge.js` is the only static content script in the manifest and runs on `https://sync.koalastuff.net/*` at `document_start`. Video control scripts are injected only into the selected tab via `chrome.scripting`; do not add broad persistent video content-script matches.
- **Self-Hosting Salt**: Production/self-hosted relay examples must include a unique `SERVER_SALT` so room-password hashes are not derived with the public fallback salt.
- **Strict Backward & Forward Compatibility (Store Delay Rule)**: Browser extensions are distributed through stores (e.g., Chrome Web Store, Firefox Add-ons) which can take up to 2 weeks to approve updates. Therefore, the server MUST NOT reject older extension clients unless a critical protocol version bump is explicitly authorized, and new extension versions MUST remain fully operational when connected to older servers (e.g., by silently falling back if a new feature is not supported). This is a core architectural requirement.
## 9. Security & Deployment
@@ -117,7 +119,7 @@ Before starting any task, committing, or pushing, you **MUST** run `git pull --r
> **AI AGENTS MUST FOLLOW THIS EXACT SEQUENCE WHEN RELEASING A NEW VERSION OR TAGGING.**
>
> **🚫 NO MANUAL VERSION BUMPING**: You MUST **NEVER** manually modify the version strings in `package.json`, `extension/manifest.base.json`, or `website/version.json`. The GitHub Actions CI pipeline automatically extracts the version from the git tag (e.g. `v2.0.5` -> `2.0.5`), injects it into all target files, and commits the updates back to `main` with `[skip ci]`. Manual bumps will cause merge conflicts and build failures.
> - **Website Versioning**: **NEVER** manually modify the version fallback strings in `website/index.html`. The website dynamically fetches the latest version and release date from `website/version.json` at runtime using `website/app.js`. Manual bumps in the HTML file are completely redundant and should be avoided.
> - **Website Versioning**: **NEVER** manually modify generated version strings in `website/www/`. The website build injects version data from `website/version.json` into generated output.
1. **MANDATORY SYNTAX & LINT CHECKS**: Before staging, committing, or pushing any changes, you **MUST** run both checks on every modified JavaScript file:
- **Syntax Validation**: Run `node -c` on every single modified JavaScript file (e.g., `node -c extension/background.js` and `node -c extension/content.js`). **NEVER** commit or push code that fails this check.
- **ESLint Validation**: Run `npm run lint` (or `npx eslint .`). The output must show **zero errors and zero warnings**. ESLint is configured to catch undefined variables, unused vars, unreachable code, and other semantic issues. **NEVER** commit or push code that fails this check.
@@ -141,18 +143,18 @@ Before starting any task, committing, or pushing, you **MUST** run `git pull --r
3. Implement the handler in `server/index.js` and `background.js`.
### Making Website Changes
1. Edit source files in `website/` (`template.html`, `style.css`, `app.js`, `lang-init.js`, or `locales/*.json`).
1. Edit source files in `website/` (`template.html`, `style.css`, `styles/*.css`, `app.js`, `lang-init.js`, or `locales/*.json`).
2. Run the compiler: `node website/build.cjs`. This generates the multilingual pages in `www/` and minifies CSS/JS.
3. Verify the output: `node --check website/www/app.js && node --check website/www/lang-init.js`.
3. Verify the sources and generated contract: `node --check website/app.js`, `node --check website/lang-init.js`, `node scripts/test-website-locales.mjs`, and `node scripts/test-website-theme.mjs`.
4. Test locally: `npx serve website/www` or `python3 -m http.server 8080 -d website/www`.
5. Commit both source changes and the updated `www/` output.
5. Commit the source changes only. `website/www/` is generated and gitignored.
### Testing Locally
1. Run the build script: `node scripts/build-extension.cjs`.
2. Load `dist/chrome/` as an "Unpacked Extension" in Chrome (or `dist/firefox/` in Firefox).
3. Start the server from the root: `docker-compose up --build`.
3. Start the server from the root: `docker compose up --build`.
4. Use **different browser profiles** or vendors to test multi-peer logic.
5. Use the **Dev tab** to verify real-time video element metadata.
5. Use the **Status tab** to verify real-time connection state, logs, and video element metadata.
### Locking Old Versions
1. Update `MIN_VERSION` in the server's `.env` file to the minimum acceptable version.
+60 -8
View File
@@ -4,20 +4,72 @@ All notable changes to the KoalaSync browser extension and relay server.
---
## Unreleased
## [v2.6.4] — 2026-07-16
### Changed
- **Extension: Localized store metadata** — Renamed the public extension title to `Watch Party - KoalaSync` and added natural, store-ready descriptions for all 15 supported locales while preserving regional language variants.
- **Extension: Locale-based manifest metadata** — The extension name and description now resolve through Chrome/Firefox `_locales` messages instead of being hardcoded in the manifest.
### Fixed
- **Build: Extension metadata validation** — Extension builds now fail clearly for invalid locale JSON, missing metadata, an incorrect extension title, or store titles and descriptions that exceed their character limits.
## [v2.6.3] — 2026-07-15
### Fixed
- **Extension: Host-access recovery races** — Prevents stale permission grants, reinjection retries, closed tabs, and rapid target changes from reactivating or clearing the wrong video tab.
- **Extension: Cross-browser host patterns** — Uses Firefox-compatible match patterns for local servers while preserving exact Chromium port scopes.
- **Extension: Force-sync target integrity** — Keeps sampled playback time and acknowledgements bound to the selected tab during recovery and target changes.
## [v2.6.2] — 2026-07-15
### Added
- **Website: Site-access recovery guide** — Added an English help page and a localized banner across every landing-page language.
### Fixed
- **Extension: Withheld website access recovery** — Detects browser-withheld host access, shows a localized allow-access action, requests only the selected website origin, and resumes the selected tab after access is granted.
- **Extension: Target-tab reliability** — Hardened permission recovery against rapid tab changes, navigation, closed tabs, service-worker restoration, and stale pending state.
- **Browser compatibility** — Uses Chrome's toolbar access request only when available, with a direct user-gesture permission fallback for Firefox and older Chromium browsers.
---
## [v2.6.1] — 2026-07-15
### Fixed
- **Extension: Episode Lobby peer list** — Peer names in the lobby are now rendered as text instead of markup. A peer could previously put HTML in their username and have it rendered in everyone else's popup, which allowed loading remote images (leaking viewer IP addresses) and spoofing the readiness badges. Scripts were already blocked by the extension's content security policy.
### Changed
- **Release checks: AMO validation** — `npm run verify` now runs Mozilla's `addons-linter` against the built Firefox artifact with `--warnings-as-errors`, and ESLint enforces `no-unsanitized`, so upload-blocking issues surface locally instead of at submission time.
---
## [v2.6.0] — 2026-07-15
### Added
- **Extension: Appearance controls** — Added localized system, light, and dark theme options with an early theme initializer to avoid flashes during popup startup.
### Changed
- **Extension: Popup and settings redesign** — Unified controls, status surfaces, colors, icons, badges, and accessibility behavior; settings are now organized into mutually exclusive accordion groups.
### Fixed
- **Extension: Episode Lobby reliability** — Prevented the lobby from remaining stuck in a loading state and stopped episode transitions from triggering on non-episodic media.
- **Extension: Tab-title normalization** — Notification counters such as `(14)`, `[7]`, and `(99+)` are removed reliably without stripping legitimate large numeric titles.
---
## [v2.5.4] — 2026-07-08
### Changed
- **Extension: Refined clutter blacklist** — Removed `localhost` and cloud storage providers (`drive.google.com`, `dropbox.com`, `onedrive.live.com`, `icloud.com`) from the domains blacklist so that local web development servers and tabs hosting video files in cloud storage are no longer hidden under "Hide Clutter Tabs".
---
## [v2.5.3] — 2026-07-02
### Fixed
- **Extension: Disney+ force sync and seek reliability** — The v2.5.2 Disney+ page-API integration leaked blob-relative `<video>` time into force sync, seeks, and heartbeats when the page-API bridge had no fresh data, which presented as "force sync does nothing on Disney+". Time/duration accessors now refuse to return native values on Disney+ (returning null/0) so stale bridge data degrades to a clean no-op instead of broadcasting garbage. `FORCE_SYNC_PREPARE` and `SEEK` payloads are now validated as finite before being relayed, and the popup's force-sync flow fails cleanly with a clear error rather than sending NaN through.
- **Extension: Force-sync no longer routed twice** — A popup-initiated `FORCE_SYNC_PREPARE` was being delivered to the content script twice (once by the generic popup route and once by the force-sync-specific route), causing a double seek. The generic route is now scoped to play/pause/seek only.
- **Extension: Disney+ Host Control Mode classification** — `hcmIsLive` previously read the native `<video>` duration to detect live streams, which on Disney+ is blob-relative garbage and falsely classified every stream as live (disabling snap-back and the desync dialog). The native-duration live signal is now skipped on Disney+ while YouTube/Twitch live detection via `Infinity` duration is preserved.
- **Extension: Disney+ episode auto-sync and lobby readiness** — Episode-transition detection and the episode-lobby "ready" poll now read the playhead through the gated time accessor, so they no longer rely on blob-relative `currentTime` on Disney+.
- **Extension: Force-sync median no longer skewed by peers without a known time** — A peer broadcasting `currentTime: null` (e.g. a Disney+ peer whose page-API bridge was not yet ready, or a freshly joined peer) was coerced to `0` by the jump-to-others median calculation, dragging the sync target toward the start of the video. Null and empty peer times are now excluded before the median.
- **Extension: Force-sync `jump-to-me` retry on Disney+** — When the content script responds but the page-API bridge has not yet delivered a finite time (typical during the first ~250 ms after a Disney+ player loads), the popup now retries once without redundantly re-injecting the content script.
- **Extension: Empty-string seek payload rejection** — The internal seek-time coercion no longer treats `''` as `0`; an empty-string `targetTime`/`currentTime` is rejected as invalid.
- **Extension: Disney+ force sync and seeking** — Fixed force sync on Disney+ failing or jumping to wrong positions. The extension now relies solely on accurate player-API time and fails cleanly when it isn't available yet, instead of falling back to unreliable raw video data.
- **Extension: Force-sync accuracy** — When syncing to the group, peers whose current position isn't known yet (e.g. a Disney+ peer that just loaded) no longer pull the sync target toward the start of the video.
- **Extension: Disney+ Host Control Mode** — Regular Disney+ content is no longer misclassified as a live stream, which had silently disabled the desync dialog and snap-back for guests in host-controlled rooms. YouTube and Twitch live detection is unchanged.
- **Extension: Disney+ episode auto-sync** — Episode transitions and the "waiting for peers" lobby flow now work reliably on Disney+ again.
---
+85
View File
@@ -0,0 +1,85 @@
# Ephemeral end-to-end encrypted chat
KoalaSync chat is an optional, live-only text channel rendered on the selected
streaming tab. It is not a messenger and does not add chat UI to the extension
popup.
## Security boundary
- The relay receives ciphertext only and never receives the chat secret.
- The relay stores no messages in RAM or on disk and sends no backlog.
- A late joiner sees only messages sent after joining.
- Room authentication is unchanged. The room password remains separate from the
chat secret and continues to be sent to the relay during `join_room`.
- Message timing and ciphertext length remain visible to the relay. Same-room
replay is accepted by the threat model.
## Invite format and key lifecycle
New invitations use a named fragment format:
```text
#j2:r=<roomId>&p=<password>&k=<base64url-secret>[&u=<relayUrl>]
```
The room creator generates 16 random bytes and encodes them as 22 unpadded
base64url characters. URL fragments are not sent in HTTP requests. The website
parses the fragment and passes structured fields through `bridge.js` to the
extension. `chatKey` must never be included in a relay event.
Legacy `#join:` links remain supported and join normally without chat. Manual
room/password entry also joins without chat. The extension clears any prior chat
secret when joining without a key, switching rooms, or leaving.
Deployment order is website first, extension second. An old extension ignores the
additional structured `chatKey` field and continues joining normally.
## Cryptography
- Secret: 16 cryptographically random bytes.
- KDF: HKDF-SHA256 with `roomId` as salt and a fixed KoalaSync chat info label.
- Encryption: AES-256-GCM using WebCrypto.
- IV: fresh random 12-byte value for every message, prepended to the ciphertext.
- AAD: `${roomId}|${senderId}`.
- The derived `CryptoKey` is cached once per active room.
The relay stamps `id`, `senderId`, and `timestamp` on the ciphertext envelope.
Changing `senderId` causes AES-GCM authentication to fail because the receiver uses
the stamped value as AAD.
## Client policy
- Maximum plaintext length: 500 Unicode code points.
- Decrypted text is untrusted. Escape HTML before applying the supported limited
Markdown formatting.
- No read receipts and no typing indicators.
- The local message DOM is bounded; this is presentation state, not server history.
## Overlay behavior
- Default dock: right.
- Live modes: right, left, and detached.
- Detached mode is draggable and moderately resizable. Size and position are stored
per origin and clamped to the current viewport.
- The overlay uses a Shadow DOM and never changes host-page layout.
- On `fullscreenchange`, its host moves into `document.fullscreenElement` and remains
visible.
- It follows all eucalyptus, cyber, and graphite light/dark theme combinations.
- Without a key, the panel stays closed and a disabled chat control explains that a
current invite link is required.
- Chat display is a local option and defaults to off. Enabling or disabling it never
deletes the room chat secret, so it can be enabled later without creating a room.
- Without the relay `chat` capability, no chat control is shown.
## Mixed-version rollout
- New extensions announce `chat-v1` in `join_room.clientCapabilities`.
- Old non-chat extensions omit the optional field and receive no `chat_message`
events. Their playback and room protocol remains unchanged.
- The first chat beta omitted the capability. When it sends one valid v1 chat
frame, the relay treats that socket as chat-capable for the rest of the
connection.
- New extensions accept the first beta's unversioned server `chat` capability as
well as `chat-v1`, so a server-first or extension-first rollout degrades safely.
Peer removal and role management are outside chat scope.
+3 -2
View File
@@ -18,8 +18,9 @@ KoalaSync is a **privacy-preserving watch-party sync tool**, not a security prod
- **Rooms are ephemeral.** They exist for a few hours and are auto-reaped. There are no
accounts, no persistent storage, no money, and no sensitive content on the relay.
- **The relay is a dumb, stateless message bus.** It forwards play/pause/seek between
peers who *chose* to watch together and joined via an invite link shared out-of-band.
- **The relay is a zero-persistence, in-memory message bus.** It temporarily holds room
and peer state while forwarding play/pause/seek between peers who *chose* to watch
together and joined via an invite link shared out-of-band.
- **Participants are invited.** Anyone in a room was let in. The social contract is
"we're watching a video together," not "mutually distrusting parties."
+60 -1
View File
@@ -11,6 +11,19 @@ the event names defined in `shared/constants.js`.
object payload.
- The relay caps incoming Socket.IO message size at 4 KB.
## Invite fragments
Current invitations use named URL-fragment fields:
```text
#j2:r=<roomId>&p=<password>&k=<base64url-chat-secret>[&u=<relayUrl>]
```
The fragment is parsed by the website and forwarded to the extension as structured
fields. `k` is client-only and must never appear in a relay payload. Legacy
`#join:<roomId>:<password>[:1:<relayUrl>]` fragments remain valid but provide no chat
secret.
## Connection Handshake
The Socket.IO handshake must include:
@@ -41,6 +54,7 @@ Payload:
"password": "string, max 128, optional",
"tabTitle": "string, max 100, optional",
"mediaTitle": "string, max 100, optional",
"clientCapabilities": ["chat-v1"],
"protocolVersion": "string, max 16"
}
```
@@ -69,13 +83,56 @@ Payload:
"hostPeerId": "string or null",
"controlMode": "everyone | host-only",
"controllers": ["peerId"],
"capabilities": ["host-control", "co-host"]
"capabilities": ["host-control", "co-host", "chat", "chat-v1"]
}
```
`room_data` is sent to the joining socket. It is not the general broadcast used
for every later room update.
## Ephemeral encrypted chat
Relays advertise chat support with `"chat-v1"` in `room_data.capabilities` and keep
the initial beta's `"chat"` flag during the transition. New clients announce
`"chat-v1"` in optional `join_room.clientCapabilities` (at most the first 16 entries
are inspected; unknown or malformed values are ignored). Old clients omit the field
and continue using the pre-chat protocol unchanged.
The relay sends `chat_message` only to sockets that announced `"chat-v1"`. As a
transition for the first chat beta, a socket that sends a valid v1 ciphertext is
marked capable for the rest of that connection. This prevents old non-chat
extensions from receiving unknown events while preserving the first beta's send
path.
### `chat_message`
Client to relay:
```json
{ "ciphertext": "<unpadded-base64url>" }
```
`ciphertext` contains a 12-byte AES-GCM IV followed by ciphertext and the 16-byte
authentication tag. The relay validates only canonical base64url and byte bounds.
It cannot inspect plaintext.
Relay to every chat-capable current room peer, including the sender:
```json
{
"id": "<server-generated UUID>",
"senderId": "<server-stamped peer ID>",
"timestamp": 1710000000000,
"ciphertext": "<unpadded-base64url>"
}
```
Client-provided `id`, `senderId`, `timestamp`, or plaintext fields are discarded.
The relay keeps no message collection and `room_data` contains no chat history.
Messages are limited to 10 per socket per 10 seconds in addition to the global event
budget. There are no typing, read-receipt, history, or chat-specific peer-management
events.
## Room Leave
### `leave_room` (client -> server)
@@ -365,5 +422,7 @@ If sender and target are still in the same room, the relay emits:
- `host-control`
- `co-host`
- `chat`
- `chat-v1`
Clients should treat a missing or unknown capabilities list as unsupported.
+37 -5
View File
@@ -1,8 +1,40 @@
# Technical Documentation
This directory contains deep-dives into the KoalaSync protocol and architecture.
This directory contains deep-dives into the KoalaSync protocol, architecture, roadmap, and operational guidelines.
- [HOW_IT_WORKS.md](HOW_IT_WORKS.md): Step-by-step walkthrough of every user flow, from room creation to synchronized playback. Ideal for store reviewers and manual testers.
- [ARCHITECTURE.md](ARCHITECTURE.md): Communication flows, Dual Heartbeat, and Sync logic.
- [SYNC_GUIDE.md](SYNC_GUIDE.md): Protocol constants and sync requirements.
- [TRANSLATION.md](TRANSLATION.md): Translation and localization guide for the extension and website.
## Start Here by Role
- **Users and reviewers**: Start with [HOW_IT_WORKS.md](HOW_IT_WORKS.md), then [PRIVACY.md](PRIVACY.md) and [TESTED_SERVICES.md](TESTED_SERVICES.md).
- **Self-hosters**: Start with the root [README.md](../README.md), then [devops.md](devops.md), [PROTOCOL.md](PROTOCOL.md), and the examples in `../examples/`.
- **Contributors**: Start with [../CONTRIBUTING.md](../CONTRIBUTING.md), then [ARCHITECTURE.md](ARCHITECTURE.md), [SYNC_GUIDE.md](SYNC_GUIDE.md), and the README for the subdirectory you are editing.
- **AI agents**: Start with [AI_INIT.md](AI_INIT.md), then read the relevant subdirectory README before changing files.
- **Security reviewers**: Start with [SECURITY.md](../SECURITY.md), [KNOWN_LIMITATIONS.md](KNOWN_LIMITATIONS.md), [PRIVACY.md](PRIVACY.md), and [PROTOCOL.md](PROTOCOL.md).
## 🏗️ Core Architecture & Design
- **[ARCHITECTURE.md](ARCHITECTURE.md)**: Overview of the communication flows, Dual Heartbeat architecture, and synchronization logic.
- **[HOW_IT_WORKS.md](HOW_IT_WORKS.md)**: Step-by-step walkthrough of every user flow, from room creation to synchronized playback. Ideal for store reviewers and manual testers.
- **[host-control-mode.md](host-control-mode.md)**: Design, requirements, and edge cases of the Host Control feature.
- **[AI_INIT.md](AI_INIT.md)**: Maintainer and AI-agent onboarding: non-negotiables, workflow order, and safety checks.
## 📡 Protocol & Synchronization
- **[PROTOCOL.md](PROTOCOL.md)**: Low-level message format and payload descriptions for the KoalaSync sync protocol.
- **[SYNC_GUIDE.md](SYNC_GUIDE.md)**: Guide on keeping protocol constants synchronized across the workspace.
## 📋 Compatibility, Roadmap & Contribution
- **[TESTED_SERVICES.md](TESTED_SERVICES.md)**: Status of compatibility with major streaming services and contribution guidelines for testing new platforms.
- **[KNOWN_LIMITATIONS.md](KNOWN_LIMITATIONS.md)**: Threat model and accepted design limitations (NOFIX entries) for security audits.
- **[PRIVACY.md](PRIVACY.md)**: Privacy model and data-handling policy for users, reviewers, and contributors.
- **[ROADMAP.md](ROADMAP.md)**: Planned features, backlog items, and rejected proposals.
- **[TRANSLATION.md](TRANSLATION.md)**: Guide for native speakers to contribute and audit dynamic extension/website translations.
## 🚀 DevOps & Releases
- **[devops.md](devops.md)**: Guide on the automated tag-based release pipeline.
- **[CHANGELOG.md](CHANGELOG.md)**: Detailed history of releases and changes.
---
*For high-level project information and developer setup instructions, refer to the root [README.md](../README.md).*
+77 -11
View File
@@ -30,6 +30,83 @@
*Prioritized for upcoming phases.*
### v3.0 — Modern Room Chat
- **Release date:** Not scheduled
- **Priority:** P1
- **Category:** Social / Communication / Privacy
- **Status:** Planned. This is a roadmap target, not a release announcement.
- **Existing foundation:** Opt-in, live-only end-to-end encrypted room chat; a floating
chat bubble over the selected player opens a dockable, detachable, resizable overlay.
The relay stores no chat history and mixed extension versions use capability-gated
delivery.
#### Core experience
- Floating bubble with an accessible unread badge (`1`, `2`, `3`, …, `99+`), cleared
only when the chat is actually viewed.
- Subtle new-message pulse with reduced-motion support; no animation or sound while
muted or during Do Not Disturb.
- Drag-and-snap bubble positioning, remembered per site, with reset-to-default.
- Dock left/right, detached overlay, compact mode, fullscreen-safe placement, responsive
narrow-player layout, and keyboard-only operation.
- Per-room mute, mention-only mode, notification sound controls, browser notifications,
and a visible mute state.
- Unread state survives overlay close/reopen and tab focus changes, but room messages
remain memory-only unless a future storage option is explicitly enabled.
#### Messaging
- Replies with quoted context, emoji reactions, emoji picker, mentions, and typing
indicators.
- Edit and delete own messages with clear local tombstones; no silent mutation.
- Delivery states (`sending`, `sent`, `failed`, `retry`) with idempotent retries and
duplicate suppression after reconnects.
- Message timestamps, date separators, jump-to-latest, unread divider, search within
the current live session, copy, and selectable text.
- Link detection with safe external opening, explicit scheme allowlist, and no automatic
previews or remote-media loading.
- Optional image/GIF/file sharing only after encrypted size limits, malware-risk UX,
relay bandwidth limits, and privacy behavior are designed and tested.
#### Presence, safety, and control
- Online/reconnecting status, join/leave events, and room-member presence without
exposing IP addresses or cross-room identity.
- Local mute/block, host moderation controls, slow mode, spam limits, and configurable
maximum message length.
- Optional read receipts and activity indicators default off; never infer or expose
viewing behavior without explicit consent.
- Clear live-only/no-history explanation, encrypted-chat indicator, room-key fingerprint,
and key-rotation/rejoin UX.
#### Reliability and compatibility
- Versioned chat capabilities for every new wire feature; unknown fields/events remain
harmless to older extensions and old non-chat clients receive no chat traffic.
- Server-first and extension-first rollout tests, including current, first-chat-beta,
old non-chat, reconnect, duplicate-frame, and malformed-capability clients.
- Bounded queues, payloads, unread counters, typing events, reactions, and attachment
metadata; backpressure and rate limits on both client and relay.
- Real Chrome and Firefox extension E2E coverage for bubble, unread count, overlay,
fullscreen, reconnect, and mixed-version rooms before v3.0 can ship.
#### Accessibility and localization
- Screen-reader announcements that do not read every busy-room message by default.
- Complete focus management, logical tab order, Escape behavior, high contrast,
zoom/reflow, reduced motion, and touch target coverage.
- All chat UI, notification text, moderation states, and errors localized across every
supported locale before release.
#### Explicit non-goals for the first v3.0 release
- No server-readable plaintext.
- No mandatory accounts, public room directory, advertising, tracking, or presence
across rooms.
- No unencrypted server-side history. Any future encrypted history requires a separate
threat model, retention controls, export/delete behavior, and explicit opt-in.
### 2. Invite link with target URL for auto-redirect
- **Priority:** P2
@@ -49,17 +126,6 @@
*Ideas and feature requests under evaluation.*
### In-room chat overlay (like TeleParty)
- **Priority:** P3
- **Category:** Social / Communication
- **Background:** A collapsible chat panel to the right of the video (or as an overlay) allowing text-based communication with everyone in the room.
- **Why backlog (still uncertain):**
- **Use case:** No strong personal need — with chat, latency matters less than with voice; async communication tolerates a few seconds of delay.
- **Complexity:** Relatively large feature (UI + message persistence + possibly history).
- **Legal/moderation:** Unclear what moderation requirements would apply if users can exchange chat messages. Could be relevant depending on jurisdiction.
- **Status:** Under evaluation, may come later.
### Cross-frame video detection and control
- **Priority:** P3
+7 -5
View File
@@ -10,8 +10,9 @@ You MUST run the build script in any of the following scenarios:
1. **After a fresh `git clone` or `git pull`** (as the synced files are ignored by git).
2. **After modifying** `shared/constants.js`.
3. **After modifying** `shared/blacklist.js`.
4. **Before committing** changes to the repository if any protocol-related files were touched.
5. **Before deploying** the server or releasing the extension.
4. **After modifying** `shared/names.js`.
5. **Before committing** changes to the repository if any shared protocol or extension-mirrored files were touched.
6. **Before deploying** the server or releasing the extension.
## How to sync
@@ -24,10 +25,11 @@ npm run build:extension
## What does it do?
The build script performs the following actions:
1. Synchronizes protocol constants by copying `shared/constants.js`, `shared/blacklist.js`, and `shared/README.md` into `extension/shared/`.
1. Synchronizes shared files by copying `shared/constants.js`, `shared/blacklist.js`, `shared/names.js`, and `shared/README.md` into `extension/shared/`.
2. Injects `EVENTS`, `HEARTBEAT_INTERVAL`, and `episode-utils.js` functions (`extractEpisodeId`, `sameEpisode`) into `content.js` via marker-based replacement.
3. Compiles browser-specific manifest files.
4. Packages the final ready-to-publish extension artifacts for Chrome and Firefox into the `dist/` directory.
3. Injects browser-specific uninstall URL constants into `background.js` and a build timestamp into `popup.html`.
4. Compiles browser-specific manifest files.
5. Packages the final ready-to-publish extension artifacts for Chrome and Firefox into the `dist/` directory.
## Protocol Versioning
The system enforces a strict `protocolVersion` check during the `JOIN_ROOM` handshake.
+44 -24
View File
@@ -1,38 +1,58 @@
# Tested Streaming Services
# 🎬 Tested Streaming Services & Compatibility
This document tracks which streaming platforms and media servers have been tested with KoalaSync.
This document tracks which streaming platforms and media servers are supported by the KoalaSync extension.
| Service | Sync Works | Media Title | Episode Auto-Sync | Notes |
|---------|:----------:|:-----------:|:-----------------:|-------|
| **YouTube** | ✅ Full | ✅ Full | ❌ | Individual videos, not episodes — no episode auto-sync. |
| **Twitch** | ✅ Full | ✅ Full | ❌ | Individual streams/VODs, not episodes — no episode auto-sync. |
| **Netflix** | ✅ Full | ❌ | ❌ | No media title exposed. |
| **Emby** | ✅ Full | ✅ Full | ✅ Full | Best-in-class support. |
| **Jellyfin** | ✅ Full | ✅ Full | ✅ Full | — |
| **Plex** | Not tested | Not tested | Not tested | — |
| **Disney+** | ✅ Full | ⚠️ Partial | ❌ | Series title only (e.g. "The Simpsons"), no episode info. |
| **Prime Video** | ✅ Full | ✅ Full | ❌ | — |
| **HBO Max / Max** | Not tested | Not tested | Not tested | — |
| **Crunchyroll** | Not tested | Not tested | Not tested | — |
| **Vimeo** | Not tested | Not tested | Not tested | — |
| **Dailymotion** | Not tested | Not tested | Not tested | — |
| **ARD / ZDF Mediathek** | Not tested | Not tested | Not tested | — |
> [!TIP]
> **Contributions are highly welcome!** 🤝 Anyone can easily update this list. If you have tested a streaming service (whether it works, has issues, or is not yet listed), please help the project by submitting a quick Pull Request. See the [How to Contribute](#how-to-contribute) guide below!
## Legend
---
## Compatibility Matrix
| Service | Sync Works | Media Title | Episode Auto-Sync | Last Tested | Tested By | Extension Version | Notes |
| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :--- |
| **YouTube** | ✅ Full | ✅ Full | ❌ N/A | — | — | — | Individual videos, not episodes. |
| **Twitch** | ✅ Full | ✅ Full | ❌ N/A | — | — | — | Individual streams/VODs. |
| **Netflix** | ✅ Full | ❌ | ❌ | — | — | — | No media title exposed. |
| **Emby** | ✅ Full | ✅ Full | ✅ Full | — | — | — | Best-in-class support. |
| **Jellyfin** | ✅ Full | ✅ Full | ✅ Full | — | — | — | — |
| **Plex** | Not tested | Not tested | Not tested | — | — | — | — |
| **Disney+** | ✅ Full | ⚠️ Partial | ❌ | — | — | — | Series title only (e.g. "The Simpsons"), no episode info. |
| **Prime Video** | ✅ Full | ✅ Full | ❌ | — | — | — | — |
| **HBO Max / Max** | Not tested | Not tested | Not tested | — | — | — | — |
| **Crunchyroll** | Not tested | Not tested | Not tested | — | — | — | — |
| **Vimeo** | Not tested | Not tested | Not tested | — | — | — | — |
| **Dailymotion** | Not tested | Not tested | Not tested | — | — | — | — |
| **ARD / ZDF Mediathek** | Not tested | Not tested | Not tested | — | — | — | — |
| **Vix** | ✅ Full | ✅ Full | ✅ Full | — | — | — | Everything works correctly. |
### Legend
| Symbol | Meaning |
|--------|---------|
| :---: | :--- |
| ✅ Full | Works without limitations. |
| ⚠️ Partial | Works with caveats (see Notes). |
| ❌ N/A | Not applicable or not supported. |
| ❌ | Not supported / does not work. |
| ❌ N/A | Not applicable (feature does not exist on the platform). |
| **Not tested** | Has not been tested yet. |
---
## How to Contribute
Tested a service that's not listed? Found different behavior than documented?
Updating this compatibility list is quick and easy! You don't need deep coding skills to contribute:
1. Test KoalaSync on the service with two browser profiles
2. Use the extension's **Dev tab** to check `readyState`, `currentTime`, and media title
3. Open a GitHub issue or PR updating this table
1. **Fork the Repository**: Click the **Fork** button at the top of the [KoalaSync GitHub Repository](https://github.com/Shik3i/KoalaSync).
2. **Edit this File**: Open [docs/TESTED_SERVICES.md](TESTED_SERVICES.md) in your fork's browser editor (or clone it locally) and update the table with your testing details.
3. **Commit & Push**: Commit your changes with a clear message (e.g., `docs: update Netflix compatibility status`).
4. **Create a Pull Request**: Submit the Pull Request (PR) from your fork to our `main` branch.
> [!NOTE]
> **Reporting Problems:** If you notice a bug or partial support on a service, please open a [GitHub Issue](https://github.com/Shik3i/KoalaSync/issues) describing the problem, and link it in the **Notes** column of the table.
>
> _If you are unsure how to create/link an issue, don't worry! Simply submit the PR anyway, and the maintainers will gladly create and link the issue for you._
---
## Technical Background
+346
View File
@@ -0,0 +1,346 @@
# E2E-verschlüsselter In-Page-Chat für KoalaSync
## Auftrag
Baue einen privacy-first, Ende-zu-Ende-verschlüsselten Text-Chat, der direkt auf der
Streaming-Seite neben dem Video sitzt. Neuer Branch von `main`, Vorschlag
`feature/chat-e2e`.
### Harte Anforderungen
1. **Der Server darf Nachrichten nie lesen können.** Schlüssel nur clientseitig, der
Relay sieht ausschließlich Ciphertext.
2. **Der Server speichert keine Nachrichten.** Reines Live-Relay, kein RAM-Backlog,
keine History. Wer zu spät kommt, verpasst den bisherigen Chat. Das ist gewollt.
3. **Chat läuft auf der Streaming-Seite**, nicht im Extension-Popup.
4. **Themes werden respektiert** (3 Paletten x light/dark).
5. **Keine Read Receipts.** Bewusst gestrichen.
6. **Volle Abwärtskompatibilität**: alte Extensions müssen weiter normal joinen können.
### Bedrohungsmodell (bestimmt alle Krypto-Entscheidungen)
Das hier ist eine Video-Sync-Extension mit optionalem, flüchtigem Chat, **keine
sicherheitskritische Messenger-App**. Ziel ist: ein bösartiger Betreiber eines
Custom-Relays soll nicht einfach mitlesen können. Ob etwas mit sehr viel Aufwand
theoretisch knackbar wäre, ist egal.
Daraus folgt, und das ist bindend:
- **Usability und Performance haben Vorrang** vor kryptografischer Maximalhärte.
- Raum-Passwörter sind kurz, zufällig generiert (z.B. `0XUK3C`) und ständig neu. Wer
eines knackt, kann trollen (`play`/`pause` senden). Nervig, nicht kritisch. Deshalb
nutzt der Server bewusst simples SHA256/HMAC statt bcrypt. **Diese Linie beibehalten,
nicht "verbessern".**
- Der globale `SERVER_SALT` ist bekannt und kein Issue. Räume sind flüchtig, es gibt
keine Datenbank.
### Explizite Nicht-Ziele
- Kein Chat-Tab im Popup. Nicht als Fallback, nicht übergangsweise.
- Keine serverseitige `chatHistory` in irgendeiner Form.
- **Die Auth nicht anfassen.** Das Klartext-Passwort an den Relay ist bewusst so und
wird nicht umgebaut (Begründung unten).
- `website/` Theme-Redesign auf `main` nicht anfassen.
---
## Krypto-Design (entschieden, nicht neu diskutieren)
### Woher der Schlüssel kommt
Das Raum-Passwort ist **unbrauchbar** als Schlüsselmaterial: der Client sendet es im
Klartext an den Relay (`join_room` -> `payload.password`, `server/index.js:349`), der
Server HMACt es erst selbst (`hashPassword`, Zeile 52-55). Der Server könnte jeden
daraus abgeleiteten Schlüssel mitberechnen.
Es zu ändern würde auch nichts bringen: Raum-Passwörter haben ~31 Bit Entropie
(6 Zeichen A-Z0-9). Ein bösartiger Server könnte sie offline durchprobieren und den
Chat-Schlüssel ableiten.
**Lösung: ein eigenes, zufälliges Secret im URL-Fragment.** Fragmente werden nie an
einen Server gesendet, und das Secret hat volle Entropie per Konstruktion. Der
Einladungslink transportiert bereits heute das Passwort im Fragment, der Mechanismus
existiert also schon.
### Primitive
- **16 zufällige Bytes** (128 Bit), base64url, 22 Zeichen. Beispiel:
`R5Ti1nxp0crfAFHf3gVncw`
- Ableitung: **HKDF-SHA256**(secret, salt=roomId) -> AES-256-GCM-Key.
**Kein PBKDF2, kein Stretching.** Slow KDFs existieren nur, um schwache
Menschen-Passwörter zu strecken. Das Secret ist zufällig mit voller Entropie, HKDF
ist ein einziger HMAC (Mikrosekunden). Das ist dieselbe Logik wie sha256-statt-bcrypt
beim Server.
- **Key genau einmal pro Raum ableiten und den `CryptoKey` cachen.** Nicht pro
Nachricht neu ableiten oder importieren.
- Pro Nachricht: **zufälliger 12-Byte-IV**, dem Ciphertext vorangestellt.
- Alles über **WebCrypto**, keine Fremdabhängigkeit.
### AAD: senderId gegen Umetikettierung binden (Pflicht)
`senderId` wird vom Server gestempelt und liegt damit **außerhalb** des Ciphertexts.
Ohne Gegenmaßnahme kann ein bösartiger Relay Alices Ciphertext als Bob weiterreichen.
Das ist exakt der Angreifer, gegen den dieses Feature gebaut wird.
Deshalb: **AES-GCM mit AAD** verschlüsseln.
```
AAD = `${roomId}|${senderId}` // senderId = eigene peerId beim Verschlüsseln,
// envelope.senderId beim Entschlüsseln
```
Etikettiert der Server um, schlägt die Auth-Tag-Prüfung fehl und die Nachricht wird
verworfen. Kostet null Performance und bindet die Nachricht zusätzlich an den Raum
(kein Cross-Room-Replay).
Bewusst **nicht** abgedeckt und akzeptiert: Nachrichtenlängen und Timing bleiben für den
Server sichtbar (kein Padding). Ein Relay kann eine Nachricht innerhalb desselben Raums
erneut abspielen. Für das Bedrohungsmodell irrelevant.
### Sanitization wandert auf den Client (Pflicht)
Der Server sieht nur noch Ciphertext und **kann Text nicht mehr prüfen**. Die Bedrohung
verschwindet dadurch nicht, sie verschiebt sich: entschlüsselter Text stammt von einem
**Peer, der den Key besitzt**.
- Entschlüsselter Text ist **untrusted input**. Vor dem Rendern zwingend durch
`escapeChatHtml`/`formatChatText` (escapen, dann Markdown). Niemals rohes `innerHTML`.
- Die Längengrenze (500 Codepoints) muss der **Client** durchsetzen, vor dem
Verschlüsseln. Der Server kann nur noch Bytes zählen.
- **Bestehende Schranke beachten:** `maxHttpBufferSize: 4096` (`server/index.js:142`)
gilt global pro Socket-Nachricht. Nachgerechnet: 500 Codepoints als 4-Byte-Emoji
ergeben 2000 B Klartext, +16 B GCM-Tag +12 B IV = 2028 B, base64 = 2704 Zeichen,
socket.io-Frame = **2731 B**. Passt, Headroom 1365 B. Bei 700 Codepoints wären es
3799 B. Wer die Zeichengrenze anhebt, muss diese Rechnung neu machen, sonst reißt das
Limit.
---
## Link-Format und Abwärtskompatibilität (entschieden)
### Warum das alte Format nicht erweiterbar ist
Aktuell (`popup.js:559-563`):
```
offiziell: #join:<roomId>:<password>
custom: #join:<roomId>:<password>:1:<encodedUrl>
```
Der Extension-Parser (`popup.js:1294-1313`) nimmt `roomId` von vorne, das Paar
`(flag, url)` von hinten, und **alles dazwischen ist das Passwort** (`parts.join(':')`).
Es gibt keine Position, die er verwirft. Verifiziert durch Ausführen der echten Parser
gegen echte Links:
| Link | Alte Extension | Alte Website |
|---|---|---|
| `#join:SILENT-EAGLE-90:30PXPD:1:wss%3A%2F%2F…` (Kontrolle) | `password:"30PXPD"` OK | `serverFlag:"1"` OK |
| Key inline: `…:30PXPD:<KEY>:1:wss%3A%2F%2F…` | `password:"30PXPD:R5Ti1nxp…"` **korrumpiert** | `serverFlag:"R5Ti…"`, `serverUrl:"1"` -> **falscher Server** |
Zwei verschiedene, irreführende Fehler aus derselben Zeile. Deshalb: neues Präfix.
### Neues Format
```
#j2:r=<roomId>&p=<password>&k=<key>[&u=<encodedRelayUrl>]
```
- `URLSearchParams`, kein positionales Parsen. Behebt nebenbei einen bestehenden Bug:
heute zerschießt ein Doppelpunkt im Passwort den Website-Parser (`parts[2]`), während
der Extension-Parser (`parts.join(':')`) damit klarkommt.
- **`s=1` entfällt.** Es existierte nur wegen des positionalen Parsens. Mit benannten
Parametern gilt: `u` vorhanden bedeutet Custom Relay.
- Das Präfix darf `#join:` **nicht als Teilstring enthalten**, sonst greift
`includes('#join:')` in alten Extensions doch.
Beispiele:
```
offiziell: …/join.html#j2:r=SAPPHIRE-DUCK-49&p=0XUK3C&k=R5Ti1nxp0crfAFHf3gVncw
custom: …/join.html#j2:r=SILENT-EAGLE-90&p=30PXPD&k=R5Ti1nxp0crfAFHf3gVncw
&u=wss%3A%2F%2Fsync.shik3i.net
```
### Warum das alte Extensions nicht bricht
**Die Website ist der Hauptpfad, nicht die Extension.** `website/app.js:452-458` parst
das Fragment selbst, prüft via `document.documentElement.dataset.koalasyncInstalled`
(gesetzt von `bridge.js:9`), ob die Extension da ist, und dispatcht dann automatisch
(`app.js:515-526`, "AUTO-TRIGGER JOIN") ein `KOALASYNC_JOIN_REQUEST` mit fertig
geparsten, **strukturierten Feldern**. `bridge.js` reicht es als `WEB_JOIN_REQUEST` an
den background weiter. In diesem Pfad liest die Extension die URL nie an.
Altes `bridge.js` destrukturiert nur, was es kennt:
```js
const { roomId, password, useCustomServer, serverUrl } = e.detail;
```
Ein zusätzliches `chatKey` fällt dort stillschweigend auf den Boden.
**Daraus folgt die Deploy-Reihenfolge: Website zuerst.** Sie ist die einzige Stelle, die
das neue Format kennen muss, und sie ist sofort deploybar. Die Extension darf beliebig
hinterherhinken (Store-Review, Update-Zyklen der Nutzer).
| Kombination | Ergebnis |
|---|---|
| Neue Website + neue Ext | Join + Chat |
| Neue Website + alte Ext | Join normal, kein Chat, `chatKey` ignoriert |
| Neue Ext + alter `#join:`-Link | Join, kein Key, Chat aus. Legacy-Parser bleibt erhalten |
| Alte Website + neuer Link | Präfix unbekannt, Join-Seite tot. **Existiert nach dem Website-Deploy nicht mehr** |
`checkInviteLink()` (`popup.js:1289`) ist nur der Komfort-Pfad "Popup auf der Join-Seite
öffnen und Felder vorausfüllen". Bei `#j2:` greift er in alten Extensions nicht mehr,
das Feld bleibt leer statt falsch befüllt. Die neue Extension muss dort **beide**
Formate parsen (`#j2:` und Legacy `#join:`).
**`history.replaceState` auf das Legacy-Format: nicht tun.** Naheliegende Idee, um das
Autofill alter Extensions zu retten, aber ein Eigentor: es entfernt `k` aus der
Adressleiste. Nutzer kopieren die URL aus der Adressleiste, um sie weiterzuteilen. Der
weitergegebene Link joint dann zwar, hat aber stillschweigend keinen Chat mehr. Der
Fragment-Inhalt muss unangetastet bleiben.
### Lebenszyklus des Keys
- **Erzeugt wird er vom Raum-Ersteller**, einmal, beim Anlegen des Raums. Er lebt im
background neben `roomId`/`password` und geht in den Invite-Link.
- **Er darf niemals in einem Relay-Payload landen.** Nicht in `join_room`, nirgends.
Dafür einen Test schreiben, der alle ausgehenden Events gegen den Key prüft.
- **Krypto gehört in den background**, nicht in den content script. Der Key wird damit
gar nicht erst in den Kontext einer fremden Seite ausgeliefert. Das Overlay schickt
Klartext an den background und bekommt Klartext zurück, verschlüsselt wird
ausschließlich dort.
- **Kanten, die korrekt fallen müssen:**
- Raum-Ersteller hat eine **alte** Extension: es existiert kein Key, niemand chattet.
Korrektes Verhalten, kein Fehlerfall.
- Ein Peer mit **alter** Extension teilt den Invite aus seinem eigenen Popup: der Link
trägt keinen Key. Wer darüber joint, chattet nicht, während die anderen chatten.
- Jemand tippt Raum und Passwort **manuell**: kein Key, kein Chat.
- Relay **ohne** Chat-Capability: Chat-UI gar nicht erst anzeigen.
---
## Vorgeschichte
Vorgängerbranch `feature/soonTMChat` (Stand `c706513`) ist **verworfen**, bleibt als
Referenz liegen. Gründe:
- Chat lag nur im Popup. Ein Chrome-Popup schließt beim Fokusverlust, man kann nicht
gleichzeitig Video schauen und chatten. Strukturell unbrauchbar.
- Der Server speicherte bis zu 500 Klartext-Nachrichten pro Raum und schickte jedem
Joiner das Backlog.
- Verifizierter Folgebug: wer einen Raum mit >120 Nachrichten Historie betrat, wurde
**rausgeworfen**. `renderChatHistory()` feuerte eine ungebündelte `chat_read`-Quittung
pro Nachricht und riss `CHAT_READ_RATE_LIMIT` (120/10s). Reproduziert bei
`CHAT_HISTORY_LIMIT=200`, dokumentiert erlaubt sind 500.
**Lehre: kein ungebündeltes Event-pro-Nachricht-Muster. Jedes Client-Verhalten gegen die
Rate-Limits gegenrechnen, bevor es eingebaut wird.**
### Wiederverwendbar (per `git checkout feature/soonTMChat -- <pfad>`, kritisch prüfen)
| Datei | Was | Einschränkung |
|---|---|---|
| `extension/chat.js` | `escapeChatHtml`, `formatChatText`, `insertEmoji`, `createTypingTracker`, `createRemoteTypingTracker` | reine Funktionen, storage-frei, getestet. `createReceiptTracker` weglassen |
| `extension/chat.test.mjs` | Unit-Tests | Receipt-Tests weg |
| `extension/locales/*.json` | `CHAT_*`-Keys, 15 Sprachen | Receipt-Keys weg, neue Keys für Overlay nötig |
| `server/chat.js` | `sanitizeChatUsername`, `canKickPeer` | `sanitizeChatText`/`createChatMessage` greifen auf Klartext zu, bei E2E unmöglich. `parseChatHistoryLimit`, `appendChatHistory`, `canRelayReadReceipt` entfallen |
| `server/rate-limiter.js` | `checkChatMessageRate` (10/10s) | `checkChatReadRate` entfällt |
| `shared/constants.js` | `CAPABILITIES.CHAT`, Event-Namen | **muss neu hinzu.** main kennt nur `HOST_CONTROL` und `CO_HOST` (`shared/constants.js:78`, `server/index.js:174`) |
**Server-Design, das überleben soll:** Der Server vergab Message-ID, `senderId` und
`timestamp` selbst und ignorierte Client-Angaben (Spoofing-Schutz). Bleibt richtig, auch
wenn der Text jetzt Ciphertext ist.
---
## Technische Randbedingungen
### Overlay-Kontext
- `content.js` wird **programmatisch** injiziert:
`chrome.scripting.executeScript({ target: {tabId}, files: ['content.js'] })`
(`background.js:1775`). Nicht über `manifest.content_scripts`, dort steht nur
`bridge.js` für `https://sync.koalastuff.net/*`.
- `host_permissions: ["<all_urls>"]`, MV3.
- Das Overlay lebt im DOM fremder Seiten (Netflix, YouTube, Emby, Jellyfin).
**Shadow DOM ist Pflicht**, sonst bluten fremde Styles rein und umgekehrt.
- Die Theme-Variablen aus `popup.html` existieren im Page-Kontext **nicht** und müssen
in den Shadow Root injiziert werden.
- **Risiko, vorab prüfen:** Netflix-Vollbild ist ein eigener Fullscreen-Element-Kontext.
Ein Overlay im normalen DOM verschwindet dort möglicherweise. Das kann die
Positionierung grundlegend beeinflussen.
### Performance auf der Host-Seite
Das Video ist das Produkt, der Chat ist Beiwerk. Ein Overlay, das die Wiedergabe ruckeln
lässt, ist schlechter als kein Overlay.
- Nur im **Ziel-Tab** injizieren (`currentTabId` im background), nicht auf jeder Seite.
- `position: fixed`, eigener Compositing-Layer, **kein Eingriff in das Layout der
Host-Seite**. Kein Layout-Thrashing, keine erzwungenen Reflows während der Wiedergabe.
- Nachrichtenliste beim Rendern nicht unbegrenzt wachsen lassen (DOM-Knoten deckeln).
### Firefox
`scripts/build-extension.cjs:199` baut ein eigenes **Firefox-Target**, und `bridge.js`
enthält bereits Firefox-spezifisches `cloneInto`-Handling für CustomEvent-Details
(isolierte Welten in FF MV3). Overlay, Shadow DOM und WebCrypto müssen dort ebenfalls
laufen. Neue Website-zu-Extension-Felder (`chatKey`) gehen durch dieselbe
`cloneInto`-Stelle.
### Theme-System (aus `main`, verbindlich)
- `extension/theme-init.js` setzt auf `<html>`: `data-theme` (`light`/`dark`),
`data-palette` (`eucalyptus`/`cyber`/`graphite`), Klasse `theme-light`.
- Quelle: `chrome.storage.local`, Keys `themeMode` und `themePalette`, plus
`chrome.storage.onChanged` für Live-Updates. Das Overlay nutzt denselben Mechanismus
im Shadow Root.
- **Kein `prefers-color-scheme`** für Theme-Farben. Das System ist explizit, nur der
Modus `system` liest die OS-Präferenz und das erledigt `theme-init.js`.
- Tokens: `--bg`, `--card`, `--surface-alt`, `--surface-deep`, `--accent`, `--text`,
`--text-muted`, `--border-soft`, `--border-strong`, `--text-on-green`.
Text auf `--accent` immer `--text-on-green`, nie `white`.
- Getönte `oklch`-Schatten sind pro Palette hartcodiert und brauchen
`html[data-palette=...]`-Overrides. Neutrale Schwarz-Alpha-Schatten sind
palettenunabhängig und der einfachere Weg.
- Bekannte Alt-Last, nicht Aufgabe dieses Branches: `--text-on-green` auf `--accent`
erreicht in eucalyptus/light 3.76 und cyber/dark 4.24, unter WCAG AA.
### Qualitäts-Gates
- `npm run lint`, `npx vitest run`, `npm run verify` müssen grün sein.
- i18n: alle 15 Sprachen in `extension/locales/`, `en` ist Baseline-Fallback. Keine
hartcodierten UI-Strings, `getMessage(key, { placeholder })` aus `i18n.js`.
- Bei einem Release-Tag: Eintrag in `docs/CHANGELOG.md`.
- Keine Em-Dashes in nutzersichtbaren Texten.
---
## Offene Punkte (mit dem Nutzer klären, nicht selbst entscheiden)
1. **Overlay-Positionierung**: fest andockbar rechts/links, oder frei verschiebbar?
Position pro Seite in `chrome.storage.local` merken? Verhalten im Vollbild?
2. **Peers ohne Key**: Chat-UI ganz ausblenden mit Hinweis, oder anzeigen und
Nachrichten als "nicht lesbar" markieren? (Die Fälle, in denen das eintritt, stehen
oben unter Lebenszyklus.)
3. **Metadaten**: Username bleibt heute für den Server sichtbar (er sanitized ihn).
Mitverschlüsseln? Typing-Indikatoren verraten dem Server, wer wann aktiv ist.
Behalten, verschlüsseln oder streichen?
4. **Kick-Funktion**: `canKickPeer` existiert (Host/Controller-Rechte). Behalten?
5. **Nachrichtenlängen-Limit**: 500 Codepoints beibehalten, oder angesichts des
4096-B-Frames anders wählen?
---
## Vorgehen
1. Offene Punkte klären.
2. Krypto- und Linkformat-Design in `docs/CHAT.md` festhalten. Das bestehende Dokument
beschreibt das verworfene Konzept und wird neu geschrieben. Chat-Events in
`docs/PROTOCOL.md` ergänzen.
3. Website-Parser (`#j2:`, Legacy `#join:` weiter unterstützen) und Bridge-Feld
`chatKey` zuerst, weil davon die Abwärtskompatibilität hängt.
4. Dann Relay (reines Relay ohne Storage), dann Krypto im background, dann Overlay, dann
i18n, dann Tests.
5. **Verifizieren, nicht annehmen:**
- Overlay auf mindestens zwei echten Seiten in allen 6 Theme-Kombinationen, nicht nur
in einer isolierten HTML-Datei.
- Chrome **und** Firefox-Build.
- Ein Test, der beweist, dass der Key in keinem ausgehenden Relay-Payload vorkommt.
- Ein Test, der eine umetikettierte Nachricht (fremde `senderId`) als ungültig
zurückweist (AAD-Bindung).
- Alte Extension gegen neue Website: Join funktioniert weiter, Chat ist stumm.
+13 -2
View File
@@ -1,6 +1,8 @@
import noUnsanitized from "eslint-plugin-no-unsanitized";
export default [
{
ignores: ["coverage/**", "dist/**", "node_modules/**", "scratch/**"]
ignores: ["coverage/**", "dist/**", "node_modules/**", "scratch/**", "website/www/**"]
},
{
languageOptions: {
@@ -18,6 +20,8 @@ export default [
setInterval: "readonly",
clearTimeout: "readonly",
clearInterval: "readonly",
requestAnimationFrame: "readonly",
cancelAnimationFrame: "readonly",
fetch: "readonly",
CustomEvent: "readonly",
MutationObserver: "readonly",
@@ -43,7 +47,14 @@ export default [
process: "readonly",
}
},
plugins: {
"no-unsanitized": noUnsanitized
},
rules: {
// Mirrors the AMO validator's "Unsafe assignment to innerHTML" check, so a
// rejection at upload time surfaces here instead.
"no-unsanitized/property": "error",
"no-unsanitized/method": "error",
"no-undef": "error",
"no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_", "caughtErrorsIgnorePattern": "^_" }],
"no-unreachable": "error",
@@ -58,7 +69,7 @@ export default [
}
},
{
files: ["server/**/*.js", "scripts/**/*.js", "scripts/**/*.cjs", "website/build.cjs"],
files: ["server/**/*.js", "scripts/**/*.js", "scripts/**/*.cjs", "website/build.cjs", "website/**/*.cjs"],
languageOptions: {
globals: {
require: "readonly",
+16
View File
@@ -18,6 +18,14 @@
# # Clean URLs support (resolves /join to join.html, etc.)
# try_files {path} {path}.html {path}/
# file_server
#
# # Serve the themed 404 page for unknown URLs (without this block,
# # Caddy returns an empty 404 response)
# handle_errors {
# @notfound expression {err.status_code} == 404
# rewrite @notfound /404.html
# file_server
# }
# }
#
# syncserver.koalastuff.net {
@@ -57,6 +65,14 @@ sync.koalastuff.net {
try_files {path} {path}.html {path}/
file_server
# Serve the themed 404 page for unknown URLs (without this block,
# Caddy returns an empty 404 response)
handle_errors {
@notfound expression {err.status_code} == 404
rewrite @notfound /404.html
file_server
}
# Static Caching for high-performance PageSpeed (1 year with validation)
@static {
file
@@ -9,6 +9,7 @@ services:
- MIN_VERSION=1.0.0 # Minimum client version allowed to connect
- MAX_ROOMS=100 # Maximum number of rooms that can exist
- MAX_PEERS_PER_ROOM=25 # Maximum number of peers allowed per room
- SERVER_SALT=CHANGE_ME_GENERATE_WITH_OPENSSL_RAND_BASE64_32 # Required: unique random salt for room-password hashes
- ADMIN_METRICS_TOKEN= # Optional: 32+ char random token for aggregate-only /health metrics
pids_limit: 2048 # Limits the container to 2048 process IDs for safety
networks: # Attaches the service to the networks listed below
+1
View File
@@ -11,6 +11,7 @@ services:
- MIN_VERSION=1.0.0 # Minimum client version allowed to connect
- MAX_ROOMS=100 # Maximum number of rooms that can exist
- MAX_PEERS_PER_ROOM=25 # Maximum number of peers allowed per room
- SERVER_SALT=CHANGE_ME_GENERATE_WITH_OPENSSL_RAND_BASE64_32 # Required: unique random salt for room-password hashes
- ADMIN_METRICS_TOKEN= # Optional: 32+ char random token for aggregate-only /health metrics
pids_limit: 2048 # Limits the container to 2048 process IDs for safety
networks: # Attaches the service to the networks listed below
+86 -32
View File
@@ -1,52 +1,106 @@
# KoalaSync Browser Extension
A Manifest V3 Browser Extension (Chrome & Firefox) for synchronized video playback across any website.
This directory contains the Manifest V3 browser extension for Chrome and Firefox. It owns the popup UI, background service worker, content-script video control, invitation bridge, audio processing, and all browser-local settings.
## Where You Are
- `manifest.base.json` is the source manifest. The build script creates browser-specific `manifest.json` files in `dist/chrome/` and `dist/firefox/`.
- `background.js` is the long-lived coordinator: WebSocket client, room state, host-control authority, heartbeat, reconnects, tab selection, and content-script injection.
- `content.js` runs in the selected video tab. It detects video state, applies remote play/pause/seek, handles episode transitions, and applies local audio processing.
- `popup.html` and `popup.js` implement the visible extension UI.
- `extension/shared/` is generated by `npm run build:extension` from the root `shared/` directory. Do not edit it directly.
## Key Features
- **Manifest V3**: Optimized Service Worker architecture with session persistence.
- **Pure Vanilla JS**: No external dependencies or heavy libraries.
- **Smart Peer IDs**: Hexadecimal IDs combined with customizable Usernames for easy identification.
- **On-Demand Connection**: The service worker only maintains a WebSocket connection while you're in a room. No persistent background connections — privacy-first architecture. Based on `connectIntent` flag that gates all reconnect attempts.
- **Live Diagnostics**: Built-in "Dev" tab for real-time video state debugging (ReadyState, CurrentTime, etc.).
- **Dynamic i18n (Multi-Language)**: Fully localized in 13 languages (`en`, `de`, `fr`, `es`, `it`, `pl`, `tr`, `nl`, `ja`, `ko`, `pt-BR`, `pt`, `ru`) with auto-detected fallback and dynamic on-the-fly language selectors.
- **Manifest V3**: Service-worker architecture with session persistence and explicit keep-alive handling.
- **Pure Vanilla JS**: No extension runtime dependencies and no bundler inside `extension/`.
- **On-Demand Connection**: The service worker connects only while the user intends to be in a room.
- **Host Control & Co-Hosts**: Hosts can switch a room into `host-only` mode and grant controller rights to trusted peers.
- **Episode Auto-Sync**: Title/episode changes can open a lobby so peers resume together once everyone is ready.
- **Smart Matching & Title Privacy**: Matching video tabs are highlighted, while tab/media title sharing can be reduced or disabled.
- **Audio Processing**: Optional local compressor settings live in the dedicated audio options page.
- **Status Diagnostics**: The Status tab exposes connection state, ping, video debug data, action history, and copyable logs.
- **Dynamic i18n**: 15 languages are supported: `en`, `de`, `fr`, `es`, `it`, `nl`, `pl`, `pt`, `pt-BR`, `tr`, `ru`, `ja`, `ko`, `zh`, and `uk`.
## Tab Overview
1. **Room**: Manage connections, view active peers, and share invitation links.
2. **Sync**: Control video playback (Play/Pause/Force Sync) and view recent activity.
3. **Settings**: Customize your Username, toggle domain-based Noise Filtering, and switch the App Language.
4. **Dev**: Monitor connection status and view real-time video element metadata for debugging.
1. **Room**: Select official/custom server, create or join rooms, view peers, share invite links, and manage Host Control when supported by the relay.
2. **Sync**: Select the video tab, send play/pause/seek/force-sync actions, and view episode lobby state.
3. **Settings**: Configure username, title sharing, noise filtering, auto episode sync, notifications, language, and audio options.
4. **Status**: Inspect connection state, latency, video debug info, history, and logs for bug reports.
5. **Dev**: Hidden developer-only controls shown for the `KoalaDev` username.
## Privacy & Permissions
KoalaSync requires `<all_urls>` permission to detect and interact with video elements (`<video>`) on websites.
- **No Browsing History**: We do not track or store your browsing history.
- **State Management**: Sensitive data (Room Passwords) is stored locally using `chrome.storage`.
- **Zero Telemetry**: No analytics or external tracking scripts.
- **Zero Runtime Dependencies**: The extension is built with pure Vanilla JS and contains no external libraries or tracking scripts, ensuring performance and privacy.
KoalaSync requires `<all_urls>` host permission so it can detect and control `<video>` elements on arbitrary sites.
- No browsing history is collected or uploaded.
- Room credentials and user settings are stored locally with `chrome.storage`.
- No analytics, external scripts, external fonts, or tracking libraries are used by the extension.
- Audio processing is local to the selected tab.
- Title privacy controls decide whether tab/media titles are shared with room peers.
## Installation
1. **Prepare Extension**: From the repository root, run:
```bash
npm run build:extension
```
2. Open Chrome and go to `chrome://extensions/`.
3. Enable **Developer mode** (top right).
4. Click **Load unpacked** and select the `dist/chrome` folder.
From the repository root:
```bash
npm install
npm run build:extension
```
Then load the generated bundle:
- Chrome/Chromium: open `chrome://extensions/`, enable Developer Mode, and load `dist/chrome`.
- Firefox: open `about:debugging`, choose **This Firefox**, and load `dist/firefox/manifest.json`.
## Development
If you modify `shared/constants.js`, you must synchronize the changes by running the build script from the root:
Run the build whenever shared protocol files or extension packaging inputs change:
```bash
npm run build:extension
```
This ensures that the `extension/shared` folder is updated with the latest protocol constants.
The build copies `shared/constants.js`, `shared/blacklist.js`, `shared/names.js`, and `shared/README.md` into `extension/shared/`, injects synchronous constants into `content.js`, generates browser manifests, and creates zip artifacts in `dist/`.
Useful focused checks from the repository root:
```bash
node -c extension/background.js
node -c extension/content.js
node -c extension/popup.js
node scripts/test-episode-utils.mjs
node scripts/test-title-privacy.mjs
node scripts/test-audio-settings.mjs
node scripts/test-locales.cjs
```
For the full suite, run:
```bash
npm run verify
```
## Do Not Break
- Keep `content.js` synchronous and IIFE-based; it cannot import ES modules directly.
- Keep the injection markers used by `scripts/build-extension.cjs`.
- Keep protocol names in `shared/constants.js` as the source of truth.
- Keep extension runtime dependencies at zero unless the project explicitly decides to introduce a bundler.
- Keep all extension assets self-hosted.
## Module Structure
| File | Purpose |
|---|---|
| `background.js` | Service worker: message routing, tab listeners, startup |
| `content.js` | Video detection, audio processing, episode transition (IIFE) |
| `popup.js` | Popup UI: join/create, tabs, status, settings |
| `bridge.js` | Landing page bridge (injected into sync.koalastuff.net) |
| `episode-utils.js` | Shared `extractEpisodeId()` / `sameEpisode()` — used by background.js, injected into content.js at build time |
| `i18n.js` | Translation loader |
| `shared/` | Constants, blacklist, name generator |
| `background.js` | Service worker: WebSocket protocol, room state, host control, tab/content routing, reconnects |
| `content.js` | Video detection/control, audio processing, episode transition, host-only guest behavior |
| `popup.js` | Popup UI: room join/create, tabs, settings, peer list, status, diagnostics |
| `popup.html` | Popup markup, tabs, onboarding, status/debug surfaces |
| `bridge.js` | Invitation bridge injected into `sync.koalastuff.net` |
| `episode-utils.js` | Shared episode-title parser imported by background and injected into content at build time |
| `title-privacy.js` | Tab/media title privacy modes and sanitization helpers |
| `audio-options.html` / `audio-options.js` / `audio-options.css` | Dedicated local audio-processing settings page |
| `page-api-seek-overrides.js` | Page-level seek bridge for site-specific player APIs |
| `modules/tab-manager.js` | Tab lifecycle helper used by the background service worker |
| `i18n.js` | Dynamic locale loader and DOM translation helper |
| `locales/` | Runtime popup translations |
| `_locales/` | Browser-store manifest translations |
| `shared/` | Generated mirror of root shared constants, blacklist, names, and README |
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Synchronisiere die Videowiedergabe auf YouTube, Netflix, Emby, Jellyfin und jeder HTML5-Seite in Echtzeit mit Freunden."
"message": "Erstelle private Watch Partys und synchronisiere Videos mit Freunden auf YouTube, Netflix, Jellyfin, Emby und fast jeder Website."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends."
"message": "Create private watch parties and sync videos with friends on YouTube, Netflix, Jellyfin, Emby, and almost any HTML5 website."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Mira videos junto a tus amigos en perfecta sincronización. Compatible con Netflix, YouTube, Twitch, Prime Video, Disney+ y cualquier reproductor HTML5."
"message": "Crea fiestas privadas y sincroniza vídeos con amigos en YouTube, Netflix, Jellyfin, Emby y casi cualquier sitio HTML5."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Synchronisez la lecture vidéo sur YouTube, Netflix, Emby, Jellyfin et tout site HTML5 en temps réel avec vos amis."
"message": "Créez des watch parties privées et regardez en synchro entre amis sur YouTube, Netflix, Jellyfin, Emby et presque tout site HTML5."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Sincronizza la riproduzione video su YouTube, Netflix, Emby, Jellyfin e qualsiasi sito HTML5 in tempo reale con gli amici."
"message": "Crea watch party private e sincronizza i video con gli amici su YouTube, Netflix, Jellyfin, Emby e quasi ogni sito HTML5."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "YouTube、Netflix、Emby、Jellyfin、その他HTML5サイトでの動画再生を友達とリアルタイムで同期します。"
"message": "プライベートなウォッチパーティーを作成し、YouTube、Netflix、Jellyfin、EmbyやほぼすべてのHTML5サイトで友達と動画を同期再生できます。"
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "YouTube, Netflix, Emby, Jellyfin 모든 HTML5 사이트에서 친구들과 실시간으로 비디오 재생을 동기화하세요."
"message": "비공개 Watch Party를 만들고 YouTube, Netflix, Jellyfin, Emby 및 거의 모든 HTML5 사이트에서 친구들과 영상을 동기화하세요."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Synchroniseer video-afspelen op YouTube, Netflix, Emby, Jellyfin en elke HTML5-site in realtime met vrienden."
"message": "Maak privéwatchparty's en kijk synchroon met vrienden op YouTube, Netflix, Jellyfin, Emby en vrijwel elke HTML5-website."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Synchronizuj odtwarzanie wideo na YouTube, Netflix, Emby, Jellyfin i dowolnej stronie HTML5 w czasie rzeczywistym ze znajomymi."
"message": "Twórz prywatne wspólne seanse i synchronizuj filmy ze znajomymi na YouTube, Netflix, Jellyfin, Emby i niemal każdej stronie HTML5."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Assista a vídeos junto com seus amigos em sincronia perfeita. Compatível com Netflix, YouTube, Twitch, Prime Video, Disney+ e qualquer player HTML5."
"message": "Crie watch parties privadas e sincronize vídeos com amigos no YouTube, Netflix, Jellyfin, Emby e em quase qualquer site HTML5."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Veja vídeos em conjunto com os seus amigos em sincronia perfeita. Compatível com Netflix, YouTube, Twitch, Prime Video, Disney+ e qualquer leitor HTML5."
"message": "Crie sessões privadas e veja vídeos sincronizados com amigos no YouTube, Netflix, Jellyfin, Emby e em quase qualquer site HTML5."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Синхронизируйте воспроизведение видео на YouTube, Netflix, Emby, Jellyfin и любых HTML5-сайтах в реальном времени с друзьями."
"message": "Создавайте закрытые Watch Party и смотрите синхронно с друзьями на YouTube, Netflix, Jellyfin, Emby и почти любом сайте HTML5."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "YouTube, Netflix, Emby, Jellyfin ve herhangi bir HTML5 sitesinde video oynatmayı arkadaşlarınızla gerçek zamanlı olarak senkronize edin."
"message": "Özel izleme partileri kurun; YouTube, Netflix, Jellyfin, Emby ve çoğu HTML5 sitesinde arkadaşlarınızla senkron izleyin."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "Синхронізуйте відтворення відео на YouTube, Netflix, Emby, Jellyfin і будь-якому сайті HTML5 у режимі реального часу з друзями."
"message": "Створюйте закриті Watch Party й дивіться синхронно з друзями на YouTube, Netflix, Jellyfin, Emby та майже будь-якому сайті HTML5."
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
{
"appName": {
"message": "KoalaSync"
"message": "Watch Party - KoalaSync"
},
"appDesc": {
"message": "与朋友实时同步YouTube、Netflix、Emby、Jellyfin任何HTML5网站上的视频播放。"
"message": "创建私人观影派对,与好友在 YouTube、Netflix、Jellyfin、Emby 及几乎任何 HTML5 视频网站同步观看。"
}
}
+54 -17
View File
@@ -1,13 +1,46 @@
:root {
--bg: #0f172a;
--card: #1e293b;
--panel: #172033;
--accent: #6366f1;
--accent-hover: #818cf8;
--text: #f8fafc;
--text-muted: #94a3b8;
--border: #334155;
/* KoalaSync nature system: forest neutrals, eucalyptus brand, warm supporting accents. */
--bg-base: oklch(0.23 0.028 155);
--surface: oklch(0.30 0.035 150);
--surface-deep: oklch(0.205 0.025 155);
--surface-alt: oklch(0.265 0.032 150);
--border-soft: oklch(0.88 0.025 145 / 0.09);
--border-strong: oklch(0.39 0.038 145);
--text-primary: oklch(0.96 0.01 90);
--text-secondary: oklch(0.78 0.02 90);
--accent-green: oklch(0.73 0.13 155);
--accent-green-hover: oklch(0.79 0.12 155);
--accent-terracotta: oklch(0.62 0.14 45);
--success-green: oklch(0.72 0.14 135);
--warning-amber: oklch(0.76 0.14 80);
--danger: oklch(0.55 0.15 25);
--text-on-green: oklch(0.14 0.02 90);
--bg: var(--bg-base);
--card: var(--surface);
--panel: var(--surface-alt);
--accent: var(--accent-green);
--accent-hover: var(--accent-green-hover);
--text: var(--text-primary);
--text-muted: var(--text-secondary);
--border: var(--border-strong);
--radius: 8px;
color-scheme: dark;
}
html.theme-light {
--bg-base: oklch(0.965 0.012 105);
--surface: oklch(0.995 0.006 100);
--surface-deep: oklch(0.935 0.018 110);
--surface-alt: oklch(0.91 0.022 115);
--border-soft: oklch(0.28 0.035 140 / 0.10);
--border-strong: oklch(0.73 0.035 125);
--text-primary: oklch(0.21 0.03 140);
--text-secondary: oklch(0.43 0.03 135);
--accent-green: oklch(0.52 0.13 155);
--accent-green-hover: oklch(0.45 0.12 155);
--text-on-green: oklch(0.98 0.005 100);
color-scheme: light;
}
* {
@@ -55,7 +88,7 @@ body {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
letter-spacing: 0.04em;
}
h1,
@@ -83,6 +116,10 @@ h2 {
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
html.theme-light .panel {
box-shadow: 0 12px 30px oklch(0.22 0.035 140 / 0.09);
}
.muted-panel {
color: var(--text-muted);
}
@@ -101,7 +138,7 @@ h2 {
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.01em;
}
.preset-group {
@@ -125,12 +162,12 @@ h2 {
transition: border-color 0.2s, box-shadow 0.2s;
}
.preset-card:hover {
border-color: rgba(99, 102, 241, 0.4);
border-color: color-mix(in oklch, var(--accent), transparent 60%);
}
.preset-card:has(input:checked) {
border-color: var(--accent);
box-shadow: 0 0 0 1px rgba(99, 102, 241, 0.35);
box-shadow: 0 0 0 1px color-mix(in oklch, var(--accent), transparent 65%);
}
.preset-card input {
@@ -141,8 +178,8 @@ h2 {
display: grid;
gap: 12px;
padding: 16px;
background: rgba(15, 23, 42, 0.58);
border: 1px solid rgba(148, 163, 184, 0.16);
background: var(--surface-deep);
border: 1px solid var(--border-soft);
border-radius: var(--radius);
transition: opacity 0.3s;
}
@@ -158,7 +195,7 @@ h2 {
color: var(--text-muted);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.01em;
}
input[type="range"] {
@@ -204,7 +241,7 @@ input[type="number"]:focus {
position: absolute;
cursor: pointer;
inset: 0;
background-color: #334155;
background-color: var(--border-strong);
transition: .3s;
border-radius: 22px;
}
@@ -216,7 +253,7 @@ input[type="number"]:focus {
width: 16px;
left: 3px;
bottom: 3px;
background-color: #94a3b8;
background-color: var(--text-secondary);
transition: .3s;
border-radius: 50%;
}
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8">
<title>KoalaSync Audio Settings</title>
<script src="theme-init.js"></script>
<link rel="stylesheet" href="audio-options.css">
</head>
<body>
+1007 -146
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -11,11 +11,12 @@ document.documentElement.dataset.koalasyncInstalled = 'true';
// 2. Listen for Join Requests from the Website
window.addEventListener('KOALASYNC_JOIN_REQUEST', (e) => {
if (!e || !e.detail) return;
const { roomId, password, useCustomServer, serverUrl } = e.detail;
const { roomId, password, chatKey, useCustomServer, serverUrl } = e.detail;
chrome.runtime.sendMessage({
type: 'WEB_JOIN_REQUEST',
roomId,
password,
chatKey,
useCustomServer,
serverUrl
}).catch(() => {});
+122
View File
@@ -0,0 +1,122 @@
const CHAT_INFO = 'KoalaSync E2E Chat v1';
const SECRET_BYTES = 16;
const IV_BYTES = 12;
const MIN_ENCRYPTED_BYTES = 29;
const MAX_ENCRYPTED_BYTES = 2028;
let cachedKeyPromise = null;
let cachedRoomId = '';
let cachedSecret = '';
let cacheGeneration = 0;
function bytesToBase64Url(bytes) {
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return globalThis.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function base64UrlToBytes(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value) || value.length % 4 === 1) return null;
const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4);
try {
const binary = globalThis.atob(padded);
const bytes = Uint8Array.from(binary, char => char.charCodeAt(0));
return bytesToBase64Url(bytes) === value ? bytes : null;
} catch (_) {
return null;
}
}
export function generateChatSecret(cryptoImpl = globalThis.crypto) {
const bytes = new Uint8Array(SECRET_BYTES);
cryptoImpl.getRandomValues(bytes);
return bytesToBase64Url(bytes);
}
export function validateChatSecret(value) {
const bytes = base64UrlToBytes(value);
return bytes?.length === SECRET_BYTES ? value : '';
}
export function countCodePoints(value) {
return [...String(value ?? '')].length;
}
export function normalizeOutgoingChatText(value) {
if (typeof value !== 'string') throw new TypeError('Chat message must be text');
const text = value.trim();
if (!text) throw new TypeError('Chat message must not be empty');
if (countCodePoints(text) > 500) throw new RangeError('Chat message exceeds 500 Unicode code points');
return text;
}
export function clearChatKeyCache() {
cacheGeneration++;
cachedKeyPromise = null;
cachedRoomId = '';
cachedSecret = '';
}
export async function deriveChatKey(roomId, secret, cryptoImpl = globalThis.crypto) {
const validSecret = validateChatSecret(secret);
if (!roomId || !validSecret) throw new TypeError('Valid roomId and chat secret are required');
if (cachedKeyPromise && cachedRoomId === roomId && cachedSecret === validSecret) return cachedKeyPromise;
const encoder = new globalThis.TextEncoder();
const generation = cacheGeneration;
const keyPromise = (async () => {
const material = await cryptoImpl.subtle.importKey(
'raw', base64UrlToBytes(validSecret), { name: 'HKDF' }, false, ['deriveKey']
);
return cryptoImpl.subtle.deriveKey({
name: 'HKDF',
hash: 'SHA-256',
salt: encoder.encode(roomId),
info: encoder.encode(CHAT_INFO)
}, material, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
})();
cachedKeyPromise = keyPromise;
cachedRoomId = roomId;
cachedSecret = validSecret;
try {
return await keyPromise;
} catch (error) {
if (generation === cacheGeneration && cachedKeyPromise === keyPromise) clearChatKeyCache();
throw error;
}
}
export async function encryptChatMessage({ text, roomId, senderId, secret }, cryptoImpl = globalThis.crypto) {
const plaintext = normalizeOutgoingChatText(text);
if (!senderId) throw new TypeError('senderId is required');
const key = await deriveChatKey(roomId, secret, cryptoImpl);
const encoder = new globalThis.TextEncoder();
const iv = new Uint8Array(IV_BYTES);
cryptoImpl.getRandomValues(iv);
const encrypted = new Uint8Array(await cryptoImpl.subtle.encrypt({
name: 'AES-GCM',
iv,
additionalData: encoder.encode(`${roomId}|${senderId}`)
}, key, encoder.encode(plaintext)));
const envelope = new Uint8Array(iv.length + encrypted.length);
envelope.set(iv);
envelope.set(encrypted, iv.length);
return bytesToBase64Url(envelope);
}
export async function decryptChatMessage({ ciphertext, roomId, senderId, secret }, cryptoImpl = globalThis.crypto) {
if (!senderId) throw new TypeError('senderId is required');
const bytes = base64UrlToBytes(ciphertext);
if (!bytes || bytes.length < MIN_ENCRYPTED_BYTES || bytes.length > MAX_ENCRYPTED_BYTES) {
throw new TypeError('Invalid chat ciphertext');
}
const key = await deriveChatKey(roomId, secret, cryptoImpl);
const encoder = new globalThis.TextEncoder();
const plaintext = await cryptoImpl.subtle.decrypt({
name: 'AES-GCM',
iv: bytes.slice(0, IV_BYTES),
additionalData: encoder.encode(`${roomId}|${senderId}`)
}, key, bytes.slice(IV_BYTES));
const text = new globalThis.TextDecoder('utf-8', { fatal: true }).decode(plaintext);
return normalizeOutgoingChatText(text);
}
+114
View File
@@ -0,0 +1,114 @@
import { webcrypto } from 'node:crypto';
import { Buffer } from 'node:buffer';
import { afterEach, describe, expect, it } from 'vitest';
import {
clearChatKeyCache,
countCodePoints,
decryptChatMessage,
deriveChatKey,
encryptChatMessage,
generateChatSecret,
normalizeOutgoingChatText,
validateChatSecret
} from './chat-crypto.js';
afterEach(clearChatKeyCache);
describe('chat crypto', () => {
it('generates canonical 128-bit secrets', () => {
const secret = generateChatSecret(webcrypto);
expect(secret).toMatch(/^[A-Za-z0-9_-]{22}$/);
expect(validateChatSecret(secret)).toBe(secret);
expect(validateChatSecret(`${secret}=`)).toBe('');
});
it('round-trips Unicode with a cached room key and fresh IVs', async () => {
const secret = generateChatSecret(webcrypto);
const firstKey = await deriveChatKey('ROOM-1', secret, webcrypto);
const secondKey = await deriveChatKey('ROOM-1', secret, webcrypto);
expect(secondKey).toBe(firstKey);
const input = { text: 'Hello 🐨 **world**', roomId: 'ROOM-1', senderId: 'alice', secret };
const first = await encryptChatMessage(input, webcrypto);
const second = await encryptChatMessage(input, webcrypto);
expect(second).not.toBe(first);
await expect(decryptChatMessage({ ciphertext: first, ...input }, webcrypto)).resolves.toBe(input.text);
});
it('deduplicates in-flight key derivations without restoring a cleared cache', async () => {
const secret = generateChatSecret(webcrypto);
let deriveCalls = 0;
let releaseDerive;
const delayedCrypto = {
...webcrypto,
subtle: {
importKey: (...args) => webcrypto.subtle.importKey(...args),
deriveKey: async (...args) => {
deriveCalls++;
await new Promise(resolve => { releaseDerive = resolve; });
return webcrypto.subtle.deriveKey(...args);
}
}
};
const first = deriveChatKey('ROOM-1', secret, delayedCrypto);
const second = deriveChatKey('ROOM-1', secret, delayedCrypto);
await Promise.resolve();
expect(deriveCalls).toBe(1);
clearChatKeyCache();
releaseDerive();
await Promise.all([first, second]);
const fresh = deriveChatKey('ROOM-1', secret, webcrypto);
expect(fresh).not.toBe(first);
await fresh;
});
it('rejects relabeling, cross-room replay, and the wrong secret', async () => {
const secret = generateChatSecret(webcrypto);
const ciphertext = await encryptChatMessage({ text: 'secret', roomId: 'ROOM-1', senderId: 'alice', secret }, webcrypto);
clearChatKeyCache();
await expect(decryptChatMessage({ ciphertext, roomId: 'ROOM-1', senderId: 'bob', secret }, webcrypto)).rejects.toThrow();
clearChatKeyCache();
await expect(decryptChatMessage({ ciphertext, roomId: 'ROOM-2', senderId: 'alice', secret }, webcrypto)).rejects.toThrow();
clearChatKeyCache();
await expect(decryptChatMessage({ ciphertext, roomId: 'ROOM-1', senderId: 'alice', secret: generateChatSecret(webcrypto) }, webcrypto)).rejects.toThrow();
});
it('enforces 500 Unicode code points before encryption', () => {
expect(countCodePoints('😀'.repeat(500))).toBe(500);
expect(normalizeOutgoingChatText(` ${'😀'.repeat(500)} `)).toBe('😀'.repeat(500));
expect(() => normalizeOutgoingChatText('😀'.repeat(501))).toThrow(RangeError);
expect(() => normalizeOutgoingChatText(' ')).toThrow(TypeError);
});
it('keeps the worst-case 500-codepoint payload within the relay byte bound', async () => {
const secret = generateChatSecret(webcrypto);
const ciphertext = await encryptChatMessage({
text: '😀'.repeat(500), roomId: 'ROOM-1', senderId: 'alice', secret
}, webcrypto);
const bytes = Buffer.from(ciphertext, 'base64url');
expect(bytes).toHaveLength(2028);
});
it('rejects non-canonical plaintext and malformed UTF-8 after authentication', async () => {
const secret = generateChatSecret(webcrypto);
const roomId = 'ROOM-1';
const senderId = 'alice';
const key = await deriveChatKey(roomId, secret, webcrypto);
const encoder = new globalThis.TextEncoder();
async function encryptRaw(plaintext) {
const iv = webcrypto.getRandomValues(new Uint8Array(12));
const encrypted = new Uint8Array(await webcrypto.subtle.encrypt({
name: 'AES-GCM',
iv,
additionalData: encoder.encode(`${roomId}|${senderId}`)
}, key, plaintext));
return Buffer.concat([Buffer.from(iv), Buffer.from(encrypted)]).toString('base64url');
}
const whitespace = await encryptRaw(encoder.encode(' '));
await expect(decryptChatMessage({ ciphertext: whitespace, roomId, senderId, secret }, webcrypto)).rejects.toThrow(TypeError);
const malformed = await encryptRaw(Uint8Array.of(0xff));
await expect(decryptChatMessage({ ciphertext: malformed, roomId, senderId, secret }, webcrypto)).rejects.toThrow();
});
});
+33
View File
@@ -0,0 +1,33 @@
(function exposeChatFormat(root) {
function escapeChatHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function formatChatText(value) {
return escapeChatHtml(value)
.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*\n]+)\*/g, '<em>$1</em>');
}
function tokenizeChatText(value) {
const text = String(value ?? '');
const tokens = [];
const pattern = /\*\*([^*\n]+)\*\*|\*([^*\n]+)\*/g;
let cursor = 0;
let match;
while ((match = pattern.exec(text)) !== null) {
if (match.index > cursor) tokens.push({ type: 'text', text: text.slice(cursor, match.index) });
tokens.push({ type: match[1] !== undefined ? 'strong' : 'em', text: match[1] ?? match[2] });
cursor = pattern.lastIndex;
}
if (cursor < text.length) tokens.push({ type: 'text', text: text.slice(cursor) });
return tokens;
}
root.KoalaSyncChatFormat = Object.freeze({ escapeChatHtml, formatChatText, tokenizeChatText });
})(globalThis);
+22
View File
@@ -0,0 +1,22 @@
import { beforeAll, describe, expect, it } from 'vitest';
beforeAll(async () => {
await import('./chat-format.js');
});
describe('chat formatting', () => {
it('escapes executable HTML before applying limited Markdown', () => {
const result = globalThis.KoalaSyncChatFormat.formatChatText('<img src=x onerror=alert(1)> **bold** *italic*');
expect(result).toBe('&lt;img src=x onerror=alert(1)&gt; <strong>bold</strong> <em>italic</em>');
expect(result).not.toContain('<img');
});
it('tokenizes only text, strong, and emphasis nodes', () => {
expect(globalThis.KoalaSyncChatFormat.tokenizeChatText('<script>x</script> **bold** *italic*')).toEqual([
{ type: 'text', text: '<script>x</script> ' },
{ type: 'strong', text: 'bold' },
{ type: 'text', text: ' ' },
{ type: 'em', text: 'italic' }
]);
});
});
+104
View File
@@ -0,0 +1,104 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
const overlaySource = fs.readFileSync(path.join(extensionDir, 'chat-overlay.js'), 'utf8');
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
const popupSource = fs.readFileSync(path.join(extensionDir, 'popup.js'), 'utf8');
const localeDir = path.join(extensionDir, 'locales');
const chatKeys = [
'LABEL_CHAT_ENABLED',
'LABEL_CHAT_ENABLED_TOOLTIP',
'CHAT_TITLE',
'CHAT_LIVE_ONLY',
'CHAT_OPEN',
'CHAT_CLOSE',
'CHAT_DOCK_LEFT',
'CHAT_DOCK_RIGHT',
'CHAT_DETACHED',
'CHAT_PLACEHOLDER',
'CHAT_SEND',
'CHAT_MISSING_KEY',
'CHAT_TOO_LONG',
'CHAT_SEND_FAILED',
'CHAT_EMPTY'
];
describe('chat overlay contract', () => {
it('isolates the overlay and never renders markup as HTML', () => {
expect(overlaySource).toContain("attachShadow({ mode: 'open' })");
expect(overlaySource).not.toMatch(/\.innerHTML\s*=|insertAdjacentHTML|\.outerHTML\s*=/);
expect(overlaySource).toContain('document.createTextNode(token.text)');
});
it('injects formatting and overlay code only with the selected-tab content script', () => {
expect(backgroundSource).toContain("files: ['chat-format.js', 'chat-overlay.js', 'content.js']");
expect(overlaySource).toContain("const storageKey = `chatOverlayLayout:${location.origin}`");
expect(overlaySource).toContain('document.fullscreenElement || document.documentElement');
});
it('supports all layout and theme combinations with bounded message DOM', () => {
expect(overlaySource).toContain("['left', 'right', 'detached']");
expect(overlaySource).toContain('!layout.detachedInitialized');
expect(overlaySource).toContain('const rect = panel.getBoundingClientRect()');
expect(overlaySource).not.toContain('contentRect');
expect(overlaySource).toContain("#app[data-palette=\"cyber\"][data-theme=\"light\"]");
expect(overlaySource).toContain("#app[data-palette=\"graphite\"][data-theme=\"light\"]");
expect(overlaySource).toContain('const MAX_MESSAGES = 200');
expect(overlaySource).toContain('while (messages.querySelectorAll(\'.message\').length > MAX_MESSAGES)');
expect(overlaySource).toContain('Math.max(1, window.innerWidth)');
expect(overlaySource).toContain('min(${MIN_WIDTH}px, calc(100vw - 16px))');
});
it('guards async refresh/send work and clears all composer state on room reset', () => {
expect(overlaySource).toContain('generation !== refreshGeneration');
expect(overlaySource).toContain('if (sending || !context?.enabled) return');
expect(overlaySource).toContain('textarea.value === submittedValue');
expect(overlaySource).toMatch(/CHAT_RESET[\s\S]*resetComposer\(\)/);
expect(overlaySource).toContain('setTimeout(() => finish(null), timeoutMs)');
expect(backgroundSource).toContain('chatReceiveQueue = chatReceiveQueue.catch(() => {}).then');
expect(backgroundSource).toContain("status: 'rate_limited'");
});
it('keeps chat hidden by default without discarding the room chat key', () => {
expect(popupSource).toContain('localData.chatEnabled === true');
expect(backgroundSource).toContain('chatEnabled: data.chatEnabled === true');
expect(backgroundSource).toContain('clientCapabilities: CLIENT_CAPABILITIES');
expect(overlaySource).toContain('all:initial;display:none;position:fixed');
expect(overlaySource).toContain("host.style.display = supported && optedIn ? '' : 'none'");
expect(overlaySource).toContain('supported && optedIn && hasKey && connected');
expect(popupSource).toContain("chrome.storage.local.set({ chatEnabled: elements.chatEnabled.checked })");
expect(popupSource).toMatch(/if \(isCreating\) \{[\s\S]*?type: 'CREATE_CHAT_KEY'[\s\S]*?chatKey = normalizeChatKey/);
expect(popupSource).not.toMatch(/chatEnabled[\s\S]{0,120}chatKey:\s*''/);
});
it('keeps unavailable chat controls discoverable to assistive technology', () => {
expect(overlaySource).toContain("launcher.setAttribute('aria-disabled'");
expect(overlaySource).not.toContain('launcher.disabled =');
expect(overlaySource).toContain("launcher.setAttribute('aria-describedby', launcherHint.id)");
expect(overlaySource).toContain("textarea.setAttribute('aria-describedby', 'chat-composer-count chat-composer-status')");
expect(overlaySource).toContain("status.setAttribute('role', 'status')");
});
it('creates a chat key for both generated-room entry points', () => {
expect(popupSource).toContain('let pendingRoomCreation = false');
expect(popupSource).toContain('const isCreating = pendingRoomCreation || !roomIdInput');
expect(popupSource).toMatch(/function handleCreateRoom\(\)[\s\S]*pendingRoomCreation = true[\s\S]*elements\.joinBtn\.click\(\)/);
expect(popupSource).toContain("type: 'CREATE_CHAT_KEY'");
});
it('contains every chat string in all 15 extension locales', () => {
const localeFiles = fs.readdirSync(localeDir).filter(file => file.endsWith('.json')).sort();
expect(localeFiles).toHaveLength(15);
for (const file of localeFiles) {
const messages = JSON.parse(fs.readFileSync(path.join(localeDir, file), 'utf8'));
for (const key of chatKeys) {
expect(messages[key], `${file}: ${key}`).toBeTypeOf('string');
expect(messages[key], `${file}: ${key}`).not.toBe('');
expect(messages[key], `${file}: ${key}`).not.toMatch(/[—–]/);
}
}
});
});
+531
View File
@@ -0,0 +1,531 @@
(function initKoalaSyncChatOverlay() {
if (window.koalaSyncChatOverlay?.refresh) {
window.koalaSyncChatOverlay.refresh();
return;
}
const MAX_MESSAGES = 200;
const MIN_WIDTH = 300;
const MIN_HEIGHT = 320;
const DEFAULT_WIDTH = 360;
const DEFAULT_HEIGHT = 520;
const storageKey = `chatOverlayLayout:${location.origin}`;
const systemTheme = window.matchMedia('(prefers-color-scheme: light)');
let context = null;
let opened = false;
let destroyed = false;
let saveTimer = null;
let applyingLayout = false;
let refreshGeneration = 0;
let sendGeneration = 0;
let sending = false;
let layout = { mode: 'right', x: 24, y: 72, width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, detachedInitialized: false };
let themeMode = 'system';
let themePalette = 'eucalyptus';
const host = document.createElement('div');
host.id = 'koalasync-chat-overlay-host';
host.style.cssText = 'all:initial;display:none;position:fixed;inset:0;z-index:2147483647;pointer-events:none;contain:layout style paint;transform:translateZ(0);';
const shadow = host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = `
:host { all: initial; }
* { box-sizing: border-box; }
#app {
--bg: oklch(0.205 0.025 155); --card: oklch(0.30 0.035 150);
--surface-alt: oklch(0.265 0.032 150); --surface-deep: oklch(0.205 0.025 155);
--accent: oklch(0.73 0.13 155); --accent-hover: oklch(0.79 0.12 155);
--text: oklch(0.96 0.01 90); --text-muted: oklch(0.78 0.02 90);
--border-soft: oklch(0.88 0.025 145 / 0.09); --border-strong: oklch(0.39 0.038 145);
--text-on-green: oklch(0.14 0.02 90); --danger: oklch(0.63 0.18 25);
position: fixed; inset: 0; pointer-events: none; color: var(--text);
font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
#app[data-theme="light"] {
--bg: oklch(0.935 0.018 110); --card: oklch(0.995 0.006 100);
--surface-alt: oklch(0.91 0.022 115); --surface-deep: oklch(0.935 0.018 110);
--accent: oklch(0.52 0.13 155); --accent-hover: oklch(0.45 0.12 155);
--text: oklch(0.21 0.03 140); --text-muted: oklch(0.43 0.03 135);
--border-soft: oklch(0.28 0.035 140 / 0.10); --border-strong: oklch(0.73 0.035 125);
--text-on-green: oklch(0.16 0.03 140);
}
#app[data-palette="cyber"] {
--bg: oklch(0.185 0.04 265); --card: oklch(0.285 0.05 265);
--surface-alt: oklch(0.25 0.048 265); --surface-deep: oklch(0.185 0.04 265);
--accent: oklch(0.59 0.20 275); --accent-hover: oklch(0.69 0.17 275);
--text: oklch(0.97 0.012 265); --text-muted: oklch(0.74 0.03 265);
--border-soft: oklch(0.9 0.03 265 / 0.10); --border-strong: oklch(0.42 0.05 265);
--text-on-green: oklch(0.99 0.006 275);
}
#app[data-palette="cyber"][data-theme="light"] {
--bg: oklch(0.945 0.014 265); --card: oklch(0.995 0.005 265);
--surface-alt: oklch(0.915 0.02 265); --surface-deep: oklch(0.945 0.014 265);
--accent: oklch(0.53 0.21 275); --accent-hover: oklch(0.46 0.20 275);
--text: oklch(0.26 0.05 265); --text-muted: oklch(0.48 0.045 265);
--border-soft: oklch(0.30 0.05 265 / 0.12); --border-strong: oklch(0.79 0.03 265);
--text-on-green: oklch(0.99 0.005 275);
}
#app[data-palette="graphite"] {
--bg: oklch(0.165 0.004 260); --card: oklch(0.255 0.005 260);
--surface-alt: oklch(0.225 0.005 260); --surface-deep: oklch(0.165 0.004 260);
--accent: oklch(0.93 0.006 260); --accent-hover: oklch(0.99 0.003 260);
--text: oklch(0.97 0.003 260); --text-muted: oklch(0.72 0.005 260);
--border-soft: oklch(0.92 0.008 260 / 0.11); --border-strong: oklch(0.42 0.006 260);
--text-on-green: oklch(0.20 0.004 260);
}
#app[data-palette="graphite"][data-theme="light"] {
--bg: oklch(0.928 0.003 260); --card: oklch(1 0 0);
--surface-alt: oklch(0.90 0.004 260); --surface-deep: oklch(0.928 0.003 260);
--accent: oklch(0.28 0.006 260); --accent-hover: oklch(0.18 0.005 260);
--text: oklch(0.22 0.005 260); --text-muted: oklch(0.46 0.006 260);
--border-soft: oklch(0.20 0.006 260 / 0.13); --border-strong: oklch(0.77 0.004 260);
--text-on-green: oklch(0.98 0.002 260);
}
button, textarea { font: inherit; }
button { color: inherit; }
.launcher {
position: fixed; width: 48px; height: 48px; border: 1px solid var(--border-strong);
border-radius: 16px; background: var(--card); color: var(--text); cursor: pointer;
pointer-events: auto; box-shadow: 0 10px 28px rgb(0 0 0 / .32); font-size: 22px;
}
.launcher:hover:not([aria-disabled="true"]) { border-color: var(--accent); transform: translateY(-1px); }
.launcher[aria-disabled="true"] { cursor: not-allowed; opacity: .58; }
.panel {
position: fixed; display: none; flex-direction: column; overflow: hidden;
min-width: min(${MIN_WIDTH}px, calc(100vw - 16px)); min-height: min(${MIN_HEIGHT}px, calc(100vh - 16px)); max-width: calc(100vw - 16px);
max-height: calc(100vh - 16px); pointer-events: auto; color: var(--text);
background: var(--card); border: 1px solid var(--border-strong); border-radius: 18px;
box-shadow: 0 18px 55px rgb(0 0 0 / .38); transform: translateZ(0);
}
.panel.open { display: flex; }
.header { display: flex; align-items: center; gap: 10px; padding: 12px; border-bottom: 1px solid var(--border-soft); user-select: none; }
.header.detached { cursor: move; }
.heading { min-width: 0; flex: 1; }
.title { font-size: 14px; font-weight: 760; }
.subtitle { color: var(--text-muted); font-size: 11px; }
.modes { display: flex; gap: 4px; }
.icon-button { width: 30px; height: 30px; border: 1px solid var(--border-soft); border-radius: 9px; background: var(--surface-alt); cursor: pointer; }
.icon-button:hover, .icon-button.active { border-color: var(--accent); color: var(--accent); }
.messages { flex: 1; min-height: 0; overflow-y: auto; padding: 12px; background: var(--bg); overscroll-behavior: contain; }
.empty { color: var(--text-muted); text-align: center; padding: 32px 12px; font-size: 12px; }
.message { margin-bottom: 11px; overflow-wrap: anywhere; }
.meta { display: flex; align-items: baseline; gap: 7px; margin-bottom: 2px; }
.sender { color: var(--accent); font-weight: 700; font-size: 12px; }
.time { color: var(--text-muted); font-size: 10px; }
.body { white-space: pre-wrap; color: var(--text); }
.composer { padding: 10px; border-top: 1px solid var(--border-soft); background: var(--card); }
textarea { width: 100%; min-height: 62px; max-height: 132px; resize: vertical; border: 1px solid var(--border-strong); border-radius: 11px; padding: 9px; background: var(--surface-deep); color: var(--text); outline: none; }
textarea:focus { border-color: var(--accent); }
textarea::placeholder { color: var(--text-muted); }
.composer-row { display: flex; align-items: center; gap: 8px; margin-top: 7px; }
.count { flex: 1; color: var(--text-muted); font-size: 10px; }
.status { color: var(--danger); font-size: 10px; min-height: 14px; }
.send { border: 0; border-radius: 10px; padding: 8px 13px; background: var(--accent); color: var(--text-on-green); font-weight: 750; cursor: pointer; }
.send:hover:not(:disabled) { background: var(--accent-hover); }
.send:disabled { opacity: .55; cursor: wait; }
.visually-hidden { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
@media (max-width: 520px) { .panel { max-width: calc(100vw - 12px); } }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
`;
function element(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
const app = element('div');
app.id = 'app';
const launcher = element('button', 'launcher', '💬');
launcher.type = 'button';
const launcherHint = element('span', 'visually-hidden');
launcherHint.id = 'chat-launcher-hint';
const panel = element('section', 'panel');
panel.setAttribute('role', 'dialog');
const header = element('header', 'header');
const heading = element('div', 'heading');
const title = element('div', 'title');
const subtitle = element('div', 'subtitle');
heading.append(title, subtitle);
const modes = element('div', 'modes');
const leftButton = element('button', 'icon-button', '←');
const detachedButton = element('button', 'icon-button', '↗');
const rightButton = element('button', 'icon-button', '→');
const closeButton = element('button', 'icon-button', '×');
for (const button of [leftButton, detachedButton, rightButton, closeButton]) button.type = 'button';
modes.append(leftButton, detachedButton, rightButton, closeButton);
header.append(heading, modes);
const messages = element('div', 'messages');
messages.setAttribute('aria-live', 'polite');
const empty = element('div', 'empty');
messages.append(empty);
const composer = element('form', 'composer');
const textareaLabel = element('label', 'visually-hidden');
textareaLabel.htmlFor = 'chat-composer-input';
const textarea = element('textarea');
textarea.id = 'chat-composer-input';
textarea.setAttribute('aria-describedby', 'chat-composer-count chat-composer-status');
const composerRow = element('div', 'composer-row');
const count = element('span', 'count', '0/500');
count.id = 'chat-composer-count';
const sendButton = element('button', 'send');
sendButton.type = 'submit';
composerRow.append(count, sendButton);
const status = element('div', 'status');
status.id = 'chat-composer-status';
status.setAttribute('role', 'status');
composer.append(textareaLabel, textarea, composerRow, status);
panel.append(header, messages, composer);
app.append(launcherHint, launcher, panel);
shadow.append(style, app);
document.documentElement.append(host);
function messageRuntime(payload, timeoutMs = 5000) {
return new Promise(resolve => {
let settled = false;
const finish = response => {
if (settled) return;
settled = true;
clearTimeout(timeout);
resolve(response || null);
};
const timeout = setTimeout(() => finish(null), timeoutMs);
try {
chrome.runtime.sendMessage(payload, response => {
if (chrome.runtime.lastError) finish(null);
else finish(response);
});
} catch (_) {
finish(null);
}
});
}
function strings() {
return context?.strings || {};
}
function applyStrings() {
const text = strings();
title.textContent = text.title || '';
subtitle.textContent = text.liveOnly || '';
launcher.setAttribute('aria-label', text.open || '');
const unavailableHint = context?.supported && !context?.hasKey
? (text.missingKey || '')
: context?.supported && !context?.connected ? (text.sendFailed || '') : '';
launcher.title = unavailableHint || text.open || '';
launcherHint.textContent = unavailableHint;
if (unavailableHint) launcher.setAttribute('aria-describedby', launcherHint.id);
else launcher.removeAttribute('aria-describedby');
panel.setAttribute('aria-label', text.title || '');
textarea.placeholder = text.placeholder || '';
textareaLabel.textContent = text.placeholder || text.title || '';
sendButton.textContent = text.send || '';
empty.textContent = text.empty || '';
leftButton.title = text.dockLeft || '';
detachedButton.title = text.detached || '';
rightButton.title = text.dockRight || '';
closeButton.title = text.close || '';
leftButton.setAttribute('aria-label', text.dockLeft || '');
detachedButton.setAttribute('aria-label', text.detached || '');
rightButton.setAttribute('aria-label', text.dockRight || '');
closeButton.setAttribute('aria-label', text.close || '');
}
function applyTheme() {
const light = themeMode === 'light' || (themeMode === 'system' && systemTheme.matches);
app.dataset.theme = light ? 'light' : 'dark';
app.dataset.palette = ['eucalyptus', 'cyber', 'graphite'].includes(themePalette) ? themePalette : 'eucalyptus';
}
function viewportBounds() {
return { width: Math.max(1, window.innerWidth), height: Math.max(1, window.innerHeight) };
}
function clampDetached() {
const viewport = viewportBounds();
const maxWidth = Math.max(1, Math.min(600, viewport.width - 16));
const maxHeight = Math.max(1, viewport.height - 16);
const horizontalInset = Math.min(8, Math.max(0, Math.floor((viewport.width - 1) / 2)));
const verticalInset = Math.min(8, Math.max(0, Math.floor((viewport.height - 1) / 2)));
layout.width = Math.min(Math.max(Number(layout.width) || DEFAULT_WIDTH, Math.min(MIN_WIDTH, maxWidth)), maxWidth);
layout.height = Math.min(Math.max(Number(layout.height) || DEFAULT_HEIGHT, Math.min(MIN_HEIGHT, maxHeight)), maxHeight);
layout.x = Math.min(Math.max(Number(layout.x) || horizontalInset, horizontalInset), Math.max(horizontalInset, viewport.width - layout.width - horizontalInset));
layout.y = Math.min(Math.max(Number(layout.y) || verticalInset, verticalInset), Math.max(verticalInset, viewport.height - layout.height - verticalInset));
}
function saveLayout() {
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
saveTimer = null;
chrome.storage.local.set({ [storageKey]: layout }).catch(() => {});
}, 150);
}
function applyLayout() {
applyingLayout = true;
panel.style.right = 'auto';
panel.style.left = 'auto';
panel.style.bottom = 'auto';
panel.style.resize = 'none';
header.classList.toggle('detached', layout.mode === 'detached');
leftButton.classList.toggle('active', layout.mode === 'left');
detachedButton.classList.toggle('active', layout.mode === 'detached');
rightButton.classList.toggle('active', layout.mode === 'right');
if (layout.mode === 'detached') {
clampDetached();
panel.style.left = `${layout.x}px`;
panel.style.top = `${layout.y}px`;
panel.style.width = `${layout.width}px`;
panel.style.height = `${layout.height}px`;
panel.style.resize = 'both';
launcher.style.left = `${layout.x}px`;
launcher.style.right = 'auto';
launcher.style.top = `${layout.y}px`;
} else {
const viewport = viewportBounds();
const gutter = Math.min(16, Math.max(0, Math.floor((viewport.width - 1) / 2)));
const panelTop = viewport.height < MIN_HEIGHT + 80 ? Math.min(8, Math.max(0, viewport.height - 1)) : 64;
panel.style.top = `${panelTop}px`;
panel.style.width = `${Math.max(1, Math.min(DEFAULT_WIDTH, viewport.width - gutter * 2))}px`;
panel.style.height = `${Math.max(1, Math.min(560, viewport.height - panelTop - Math.min(16, Math.max(0, viewport.height - panelTop - 1))))}px`;
panel.style[layout.mode] = `${gutter}px`;
launcher.style.top = `${Math.max(0, Math.min(viewport.height - 48, viewport.height / 2 - 24))}px`;
launcher.style.left = layout.mode === 'left' ? `${gutter}px` : 'auto';
launcher.style.right = layout.mode === 'right' ? `${gutter}px` : 'auto';
}
globalThis.queueMicrotask(() => { applyingLayout = false; });
}
function setMode(mode) {
if (!['left', 'right', 'detached'].includes(mode)) return;
if (mode === 'detached' && !layout.detachedInitialized) {
layout.x = Math.max(8, Math.round((window.innerWidth - DEFAULT_WIDTH) / 2));
layout.y = Math.max(8, Math.round((window.innerHeight - DEFAULT_HEIGHT) / 2));
layout.detachedInitialized = true;
}
layout.mode = mode;
applyLayout();
saveLayout();
}
function setOpened(next) {
opened = !!next && !!context?.enabled;
panel.classList.toggle('open', opened);
launcher.style.display = opened ? 'none' : '';
if (opened) {
textarea.focus();
messages.scrollTop = messages.scrollHeight;
}
}
function applyContext(next) {
const previousRoomId = context?.roomId;
context = next || null;
const supported = !!context?.supported;
const optedIn = !!context?.enabled;
const hasKey = !!context?.hasKey;
const connected = !!context?.connected;
context = context ? { ...context, enabled: supported && optedIn && hasKey && connected } : null;
if (previousRoomId && previousRoomId !== context?.roomId) clearMessages();
host.style.display = supported && optedIn ? '' : 'none';
launcher.setAttribute('aria-disabled', String(!context?.enabled));
if (!context?.enabled) setOpened(false);
applyStrings();
applyLayout();
}
async function refresh() {
if (destroyed) return;
const generation = ++refreshGeneration;
const next = await messageRuntime({ type: 'GET_CHAT_CONTEXT' });
if (destroyed || generation !== refreshGeneration) return;
applyContext(next);
}
function appendMessage(message) {
if (!context?.enabled || !message || typeof message.text !== 'string') return;
if (empty.isConnected) empty.remove();
const wrapper = element('article', 'message');
const meta = element('div', 'meta');
const own = message.senderId === context.peerId;
const sender = element('span', 'sender', own ? (strings().you || '') : (message.username || message.senderId || ''));
const time = element('time', 'time');
const date = new Date(message.timestamp);
time.textContent = Number.isFinite(date.getTime()) ? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
meta.append(sender, time);
const body = element('div', 'body');
const tokens = globalThis.KoalaSyncChatFormat?.tokenizeChatText(message.text) || [{ type: 'text', text: message.text }];
for (const token of tokens) {
const node = token.type === 'strong' ? document.createElement('strong') : token.type === 'em' ? document.createElement('em') : document.createTextNode(token.text);
if (node.nodeType === 1) node.textContent = token.text;
body.append(node);
}
wrapper.append(meta, body);
messages.append(wrapper);
while (messages.querySelectorAll('.message').length > MAX_MESSAGES) messages.querySelector('.message')?.remove();
messages.scrollTop = messages.scrollHeight;
}
function clearMessages() {
messages.replaceChildren(empty);
}
function resetComposer() {
sendGeneration++;
sending = false;
textarea.value = '';
count.textContent = '0/500';
count.style.color = '';
status.textContent = '';
sendButton.disabled = false;
}
launcher.addEventListener('click', () => setOpened(true));
closeButton.addEventListener('click', () => setOpened(false));
leftButton.addEventListener('click', () => setMode('left'));
detachedButton.addEventListener('click', () => setMode('detached'));
rightButton.addEventListener('click', () => setMode('right'));
textarea.addEventListener('input', () => {
const length = [...textarea.value].length;
count.textContent = `${length}/500`;
count.style.color = length > 500 ? 'var(--danger)' : '';
status.textContent = length > 500 ? (strings().tooLong || '') : '';
});
textarea.addEventListener('keydown', event => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
if (!sending) composer.requestSubmit();
}
});
composer.addEventListener('submit', async event => {
event.preventDefault();
if (sending || !context?.enabled) return;
const submittedValue = textarea.value;
const text = textarea.value.trim();
if (!text) return;
if ([...text].length > 500) {
status.textContent = strings().tooLong || '';
return;
}
const generation = ++sendGeneration;
sending = true;
sendButton.disabled = true;
status.textContent = '';
const response = await messageRuntime({ type: 'CHAT_SEND', text });
if (destroyed || generation !== sendGeneration) return;
sending = false;
sendButton.disabled = false;
if (response?.status === 'ok') {
if (textarea.value === submittedValue) {
textarea.value = '';
count.textContent = '0/500';
}
} else {
status.textContent = response?.status === 'too_long' ? (strings().tooLong || '') : (strings().sendFailed || '');
}
});
let drag = null;
header.addEventListener('pointerdown', event => {
if (layout.mode !== 'detached' || event.target.closest('button')) return;
drag = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, x: layout.x, y: layout.y };
header.setPointerCapture(event.pointerId);
});
header.addEventListener('pointermove', event => {
if (!drag || event.pointerId !== drag.pointerId) return;
layout.x = drag.x + event.clientX - drag.startX;
layout.y = drag.y + event.clientY - drag.startY;
clampDetached();
applyLayout();
});
header.addEventListener('pointerup', event => {
if (!drag || event.pointerId !== drag.pointerId) return;
drag = null;
saveLayout();
});
const resizeObserver = new globalThis.ResizeObserver(() => {
if (applyingLayout || layout.mode !== 'detached') return;
const rect = panel.getBoundingClientRect();
if (!opened || !rect?.width || !rect.height) return;
layout.width = rect.width;
layout.height = rect.height;
clampDetached();
saveLayout();
});
resizeObserver.observe(panel);
function moveIntoFullscreen() {
const parent = document.fullscreenElement || document.documentElement;
if (parent && host.parentNode !== parent) parent.append(host);
globalThis.requestAnimationFrame(applyLayout);
}
function handleResize() {
if (layout.mode === 'detached') {
clampDetached();
applyLayout();
saveLayout();
}
}
function handleSystemTheme() {
if (themeMode === 'system') applyTheme();
}
function handleStorage(changes, area) {
if (area !== 'local') return;
if (changes.themeMode) themeMode = changes.themeMode.newValue || 'system';
if (changes.themePalette) themePalette = changes.themePalette.newValue || 'eucalyptus';
if (changes.themeMode || changes.themePalette) applyTheme();
if (changes.locale) refresh();
}
function handleRuntime(message) {
if (message?.type === 'CHAT_MESSAGE') appendMessage(message.message);
if (message?.type === 'CHAT_CONTEXT_UPDATE' || message?.type === 'CONNECTION_STATUS') refresh();
if (message?.type === 'CHAT_RESET') {
clearMessages();
resetComposer();
refresh();
}
if (message?.type === 'CHAT_DESTROY') destroy();
}
function destroy() {
if (destroyed) return;
destroyed = true;
refreshGeneration++;
sendGeneration++;
if (saveTimer) clearTimeout(saveTimer);
resizeObserver.disconnect();
document.removeEventListener('fullscreenchange', moveIntoFullscreen);
window.removeEventListener('resize', handleResize);
systemTheme.removeEventListener('change', handleSystemTheme);
chrome.storage.onChanged.removeListener(handleStorage);
chrome.runtime.onMessage.removeListener(handleRuntime);
host.remove();
window.koalaSyncChatOverlay = null;
}
document.addEventListener('fullscreenchange', moveIntoFullscreen);
window.addEventListener('resize', handleResize, { passive: true });
systemTheme.addEventListener('change', handleSystemTheme);
chrome.storage.onChanged.addListener(handleStorage);
chrome.runtime.onMessage.addListener(handleRuntime);
chrome.storage.local.get([storageKey, 'themeMode', 'themePalette'], data => {
if (data[storageKey] && typeof data[storageKey] === 'object') layout = { ...layout, ...data[storageKey] };
if (!['left', 'right', 'detached'].includes(layout.mode)) layout.mode = 'right';
if (layout.mode === 'detached') layout.detachedInitialized = true;
themeMode = data.themeMode || 'system';
themePalette = data.themePalette || 'eucalyptus';
applyTheme();
applyLayout();
});
window.koalaSyncChatOverlay = { refresh, destroy };
refresh();
})();
+58
View File
@@ -0,0 +1,58 @@
export const MAX_ROOM_ID_LENGTH = 64;
export const CHAT_SEND_LIMIT = 9;
export const CHAT_SEND_WINDOW_MS = 10000;
export function normalizeRoomId(value) {
if (typeof value !== 'string') return '';
return value.trim().replace(/[^a-zA-Z0-9\-]/g, '').slice(0, MAX_ROOM_ID_LENGTH);
}
export function createChatSendLimiter({
limit = CHAT_SEND_LIMIT,
windowMs = CHAT_SEND_WINDOW_MS,
now = () => Date.now()
} = {}) {
let timestamps = [];
return {
take() {
const current = now();
timestamps = timestamps.filter(timestamp => current - timestamp < windowMs);
if (timestamps.length >= limit) {
return {
allowed: false,
retryAfterMs: Math.max(1, windowMs - (current - timestamps[0]))
};
}
timestamps.push(current);
return { allowed: true, retryAfterMs: 0 };
},
reset() {
timestamps = [];
}
};
}
export function createLatestTaskQueue() {
let generation = 0;
let tail = Promise.resolve();
return {
invalidate() {
generation++;
},
async run(task) {
const requestGeneration = ++generation;
const previous = tail;
let release;
tail = new Promise(resolve => { release = resolve; });
await previous.catch(() => {});
const isCurrent = () => requestGeneration === generation;
try {
return await task(isCurrent);
} finally {
release();
}
}
};
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import {
CHAT_SEND_LIMIT,
createChatSendLimiter,
createLatestTaskQueue,
MAX_ROOM_ID_LENGTH,
normalizeRoomId
} from './chat-session.js';
describe('chat session boundaries', () => {
it('matches the relay room ID sanitizer and length limit', () => {
expect(normalizeRoomId(' ROOM_!42 ')).toBe('ROOM42');
expect(normalizeRoomId('A'.repeat(MAX_ROOM_ID_LENGTH + 10))).toBe('A'.repeat(MAX_ROOM_ID_LENGTH));
expect(normalizeRoomId(null)).toBe('');
});
it('keeps client chat bursts below the relay disconnect threshold', () => {
let current = 1000;
const limiter = createChatSendLimiter({ now: () => current });
for (let index = 0; index < CHAT_SEND_LIMIT; index++) {
expect(limiter.take()).toEqual({ allowed: true, retryAfterMs: 0 });
}
expect(limiter.take()).toEqual({ allowed: false, retryAfterMs: 10000 });
current += 10000;
expect(limiter.take()).toEqual({ allowed: true, retryAfterMs: 0 });
limiter.reset();
expect(limiter.take()).toEqual({ allowed: true, retryAfterMs: 0 });
});
it('serializes join work and prevents an older request from winning storage races', async () => {
const queue = createLatestTaskQueue();
let releaseFirst;
const writes = [];
const first = queue.run(async isCurrent => {
await new Promise(resolve => { releaseFirst = resolve; });
if (isCurrent()) writes.push('first');
return { status: isCurrent() ? 'ok' : 'superseded' };
});
await new Promise(resolve => setTimeout(resolve, 0));
const second = queue.run(async isCurrent => {
if (isCurrent()) writes.push('second');
return { status: 'ok' };
});
releaseFirst();
await expect(first).resolves.toEqual({ status: 'superseded' });
await expect(second).resolves.toEqual({ status: 'ok' });
expect(writes).toEqual(['second']);
});
});
+11
View File
@@ -0,0 +1,11 @@
export function buildChatRelayPayload(ciphertext) {
return { ciphertext };
}
export function encodeSocketEvent(event, data, forbiddenSecret = '') {
const payload = JSON.stringify([event, data]);
if (forbiddenSecret && payload.includes(forbiddenSecret)) {
throw new Error('Refusing to send chat secret to relay');
}
return `42${payload}`;
}
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { buildChatRelayPayload, encodeSocketEvent } from './chat-wire.js';
describe('chat wire boundary', () => {
const secret = 'R5Ti1nxp0crfAFHf3gVncw';
it('sends only ciphertext for chat messages', () => {
expect(buildChatRelayPayload('ciphertext')).toEqual({ ciphertext: 'ciphertext' });
});
it('rejects the secret anywhere in any outgoing relay event', () => {
for (const [event, payload] of [
['join_room', { roomId: 'ROOM', password: 'PASS', chatKey: secret }],
['peer_status', { status: 'heartbeat', nested: { secret } }],
['chat_message', { ciphertext: `prefix-${secret}-suffix` }]
]) {
expect(() => encodeSocketEvent(event, payload, secret)).toThrow('Refusing to send chat secret');
}
expect(encodeSocketEvent('join_room', { roomId: 'ROOM', password: 'PASS' }, secret)).not.toContain(secret);
});
});
+14 -9
View File
@@ -15,7 +15,7 @@
window.koalaSyncInjected = true;
// --- SHARED_EVENTS_INJECT_START ---
// This block is automatically updated by /scripts/build-extension.js
// This block is automatically updated by /scripts/build-extension.cjs
const EVENTS = {
PLAY: "play",
PAUSE: "pause",
@@ -424,13 +424,13 @@
const host = hcmEl('div', 'all:initial');
const root = host.attachShadow({ mode: 'open' });
const wrap = hcmEl('div', 'position:fixed;z-index:2147483647;left:50%;bottom:32px;transform:translateX(-50%);background:#1f2937;color:#f9fafb;font:14px/1.4 system-ui,sans-serif;padding:16px 18px;border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,.45);max-width:360px;border:1px solid #374151');
const wrap = hcmEl('div', 'position:fixed;z-index:2147483647;left:50%;bottom:32px;transform:translateX(-50%);background:#212a17;color:#f4f2ea;font:14px/1.4 system-ui,sans-serif;padding:16px 18px;border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,.45);max-width:360px;border:1px solid #2f3625');
wrap.setAttribute('role', 'dialog');
const title = hcmEl('div', 'font-weight:600;margin-bottom:6px', hcmStrings.title);
const body = hcmEl('div', 'margin-bottom:12px;color:#d1d5db', hcmStrings.body);
const body = hcmEl('div', 'margin-bottom:12px;color:#bcb7a9', hcmStrings.body);
const btnRow = hcmEl('div', 'display:flex;gap:8px;justify-content:flex-end');
const soloBtn = hcmEl('button', 'background:#374151;color:#f9fafb;border:0;padding:8px 12px;border-radius:8px;cursor:pointer', hcmStrings.solo);
const stayBtn = hcmEl('button', 'background:#10b981;color:#062a20;border:0;padding:8px 12px;border-radius:8px;cursor:pointer;font-weight:600', hcmStrings.stay);
const soloBtn = hcmEl('button', 'background:#2f3625;color:#f4f2ea;border:0;padding:8px 12px;border-radius:8px;cursor:pointer', hcmStrings.solo);
const stayBtn = hcmEl('button', 'background:#56ae6c;color:#0a1a0d;border:0;padding:8px 12px;border-radius:8px;cursor:pointer;font-weight:600', hcmStrings.stay);
btnRow.append(soloBtn, stayBtn);
wrap.append(title, body, btnRow);
root.appendChild(wrap);
@@ -494,7 +494,7 @@
}
const host = hcmEl('div', 'all:initial');
const root = host.attachShadow({ mode: 'open' });
const b = hcmEl('div', 'position:fixed;z-index:2147483646;right:16px;bottom:16px;background:#b45309;color:#fff;font:13px/1.3 system-ui,sans-serif;padding:8px 12px;border-radius:10px;box-shadow:0 6px 20px rgba(0,0,0,.4);cursor:pointer;display:flex;align-items:center;gap:8px');
const b = hcmEl('div', 'position:fixed;z-index:2147483646;right:16px;bottom:16px;background:#c96736;color:#fff;font:13px/1.3 system-ui,sans-serif;padding:8px 12px;border-radius:10px;box-shadow:0 6px 20px rgba(0,0,0,.4);cursor:pointer;display:flex;align-items:center;gap:8px');
b.append(hcmEl('span', null, '● ' + hcmStrings.badge), hcmEl('span', 'text-decoration:underline', hcmStrings.resync));
b.addEventListener('click', hcmExitDesync);
root.appendChild(b);
@@ -717,7 +717,7 @@
// Handles: S01E01, S1E1, S01 - E01, Season 1 Episode 1, "Folge 5", "Episode 5", "Ep. 5", "#5"
// Returns null if no episode pattern found.
// --- SHARED_EPISODE_UTILS_INJECT_START ---
// This block is automatically replaced by /scripts/build-extension.js
// This block is automatically replaced by /scripts/build-extension.cjs
function extractEpisodeId(title) {
if (!title || typeof title !== 'string') return null;
const se = title.match(/S(?:eason\s*)?(\d+)[^a-zA-Z0-9]*E(?:pisode\s*)?(\d+)/i);
@@ -758,7 +758,8 @@
// Only trigger if: we had a previous title, the title changed,
// a video exists, and we're near the start of new content.
if (lastKnownMediaTitle && currentTitle
&& !sameEpisode(currentTitle, lastKnownMediaTitle)
&& !sameEpisode(currentTitle, lastKnownMediaTitle)
&& extractEpisodeId(currentTitle) !== null
&& video
&& current !== null && current < 5
&& video.readyState >= 1) {
@@ -798,7 +799,11 @@
const current = video ? getSyncCurrentTime(video) : null;
if (video && currentTitle && sameEpisode(currentTitle, expectedTitle)
&& current !== null && current < 5 && video.readyState >= 1) {
&& current !== null && video.readyState >= 1) {
if (current >= 5) {
expectedSeekTime = 0;
video.currentTime = 0;
}
// Match! Pause at start and report ready.
if (!video.paused) {
_setSuppress('paused');
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* KoalaSync Episode Title Utilities
* Single source of truth synced to content.js by build-extension.js.
* Single source of truth synced to content.js by build-extension.cjs.
* Keep in sync with the injection block in content.js!
*/
+194
View File
@@ -0,0 +1,194 @@
export const HOST_ACCESS_REQUIRED_STATUS = 'host_permission_required';
export function normalizeTabId(value) {
if (typeof value === 'number') {
return Number.isSafeInteger(value) && value > 0 ? value : null;
}
if (typeof value !== 'string') return null;
const normalized = value.trim();
if (!/^[1-9]\d*$/.test(normalized)) return null;
const tabId = Number(normalized);
return Number.isSafeInteger(tabId) ? tabId : null;
}
function browserSupportsPortMatchPatterns(chromeApi) {
// runtime.getBrowserInfo is a Firefox-only WebExtension API. Firefox does
// not accept ports in match patterns, while Chromium does.
return typeof chromeApi?.runtime?.getBrowserInfo !== 'function';
}
export function describeTabUrl(rawUrl, { includePort = true } = {}) {
if (typeof rawUrl !== 'string' || !rawUrl) return null;
try {
const url = new URL(rawUrl);
if (url.protocol === 'http:' || url.protocol === 'https:') {
const permissionHost = includePort ? url.host : url.hostname;
return {
url: rawUrl,
host: url.host,
originPattern: `${url.protocol}//${permissionHost}/*`
};
}
if (url.protocol === 'file:') {
return {
url: rawUrl,
host: 'local file',
originPattern: 'file:///*'
};
}
} catch (_e) {
// Invalid and browser-internal URLs cannot receive host access.
}
return null;
}
export async function inspectTabHostAccess(chromeApi, tabId) {
const tab = await chromeApi.tabs.get(tabId);
// executeScript targets the committed document. pendingUrl may already point
// at another origin while tab.url is still the document being injected into.
const descriptor = describeTabUrl(tab?.url || tab?.pendingUrl || '', {
includePort: browserSupportsPortMatchPatterns(chromeApi)
});
if (!descriptor) {
return {
tab,
url: tab?.url || '',
host: null,
originPattern: null,
granted: null
};
}
if (typeof chromeApi.permissions?.contains !== 'function') {
return { tab, ...descriptor, granted: null };
}
try {
const granted = await callBooleanPermissionMethod(
chromeApi,
'contains',
{ origins: [descriptor.originPattern] },
{ timeoutMs: 1000 }
);
return {
tab,
...descriptor,
granted: granted === null ? null : granted === true
};
} catch (_e) {
// Permission inspection is advisory. The actual script injection below
// remains the source of truth, including temporary activeTab access.
return { tab, ...descriptor, granted: null };
}
}
function callBooleanPermissionMethod(chromeApi, methodName, request, { timeoutMs = null } = {}) {
const method = chromeApi.permissions?.[methodName];
if (typeof method !== 'function') return Promise.resolve(null);
return new Promise((resolve, reject) => {
let settled = false;
let timeout = null;
const clearSettlementTimeout = () => {
if (timeout !== null) {
clearTimeout(timeout);
timeout = null;
}
};
const finish = (value) => {
if (settled) return;
settled = true;
clearSettlementTimeout();
resolve(value === true ? true : value === false ? false : null);
};
const fail = (error) => {
if (settled) return;
settled = true;
clearSettlementTimeout();
reject(error instanceof Error ? error : new Error(String(error || 'Permission request failed')));
};
const callback = (value) => {
const lastError = chromeApi.runtime?.lastError;
if (lastError) {
fail(new Error(lastError.message || String(lastError)));
return;
}
finish(value);
};
if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
timeout = setTimeout(() => finish(null), timeoutMs);
}
try {
const result = method.call(chromeApi.permissions, request, callback);
if (result && typeof result.then === 'function') {
result.then(finish, fail);
} else if (typeof result === 'boolean') {
finish(result);
}
// Callback-based Chromium/Firefox APIs return undefined and settle
// through callback. Promise-based implementations settle above.
} catch (error) {
fail(error);
}
});
}
export function requestOriginPermission(chromeApi, originPattern) {
if (typeof originPattern !== 'string' || !originPattern) {
return Promise.resolve(null);
}
return callBooleanPermissionMethod(
chromeApi,
'request',
{ origins: [originPattern] },
{ timeoutMs: 60000 }
).catch(() => false);
}
export function isHostAccessError(error) {
const message = typeof error?.message === 'string' ? error.message : String(error || '');
return /host permission|permission to access (?:this|the|respective) host|cannot access contents of/i.test(message);
}
function hostAccessRequestDetails(tabId, originPattern) {
const request = { tabId };
if (typeof originPattern === 'string' && originPattern) {
request.pattern = originPattern;
}
return request;
}
export async function addTabHostAccessRequest(chromeApi, tabId, originPattern = null) {
if (typeof chromeApi.permissions?.addHostAccessRequest !== 'function') {
return false;
}
try {
await chromeApi.permissions.addHostAccessRequest(
hostAccessRequestDetails(tabId, originPattern)
);
return true;
} catch (_e) {
return false;
}
}
export async function removeTabHostAccessRequest(chromeApi, tabId, originPattern = null) {
if (typeof chromeApi.permissions?.removeHostAccessRequest !== 'function') {
return false;
}
try {
await chromeApi.permissions.removeHostAccessRequest(
hostAccessRequestDetails(tabId, originPattern)
);
return true;
} catch (_e) {
return false;
}
}
+5
View File
@@ -12,6 +12,9 @@ let currentLanguage = null;
*/
export async function loadLocale(langCode) {
const resolvedLang = SUPPORTED_LANGUAGES.includes(langCode) ? langCode : DEFAULT_LANGUAGE;
if (typeof document !== 'undefined') {
document.documentElement.lang = resolvedLang;
}
if (currentLanguage === resolvedLang && Object.keys(activeDictionary).length > 0) {
return;
@@ -90,6 +93,8 @@ export function getMessage(key, placeholders = null) {
* Performs dynamic DOM replacements for elements carrying data-i18n attributes.
*/
export function translateDOM() {
if (typeof document === 'undefined') return;
// 1. Text Content
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path d="M3 2.5h4v11H3zm6 0h4v11H9z"/>
</svg>

After

Width:  |  Height:  |  Size: 109 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path d="M4 2.6v10.8L13 8z"/>
</svg>

After

Width:  |  Height:  |  Size: 100 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path d="M13.4 5.5A5.8 5.8 0 0 0 3 4.2L1.7 2.9v4.4h4.4L4.4 5.6a4 4 0 0 1 7.2.8zM2.6 10.5A5.8 5.8 0 0 0 13 11.8l1.3 1.3V8.7H9.9l1.7 1.7a4 4 0 0 1-7.2-.8z"/>
</svg>

After

Width:  |  Height:  |  Size: 226 B

+26 -4
View File
@@ -70,11 +70,11 @@
"LABEL_REMOTE_CONTROL": "Fernsteuerung",
"BTN_COPY_INVITE": "📋 Einladungslink",
"BTN_COPY_INVITE_TOOLTIP": "Einladungslink kopieren",
"BTN_PLAY": "Abspielen",
"BTN_PLAY": "Abspielen",
"BTN_PLAY_TOOLTIP": "Sende einen Play-Befehl an alle",
"BTN_PAUSE": "Pause",
"BTN_PAUSE": "Pause",
"BTN_PAUSE_TOOLTIP": "Sende einen Pause-Befehl an alle",
"BTN_SYNC": "⚡ SYNC",
"BTN_SYNC": "Sync",
"BTN_SYNC_TOOLTIP": "Erzwinge die Synchronisation aller Teilnehmer",
"OPTION_JUMP_TO_OTHERS": "Zu anderen springen",
"OPTION_JUMP_TO_ME": "Zu mir springen",
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Identität noch nicht geladen. Warte kurz und versuche es erneut.",
"ERR_NO_PEERS_TIME": "Keine anderen Teilnehmer mit bekannter Zeit. Wechsle zu 'Zu mir springen'.",
"ERR_NO_VIDEO_TAB": "Verbindung zum Video-Tab fehlgeschlagen.",
"SITE_ACCESS_REQUIRED": "Dein Browser blockiert KoalaSync auf {host}. Klicke unten auf „Zugriff erlauben“. Falls kein Dialog erscheint, erlaube KoalaSync in den Erweiterungs- oder Websiteberechtigungen für diese Seite.",
"BTN_RETRY_ACCESS": "Zugriff erlauben",
"ERR_SELECT_VIDEO": "Bitte wähle zuerst ein Video aus!",
"TOAST_INVITE_COPIED": "Einladungslink kopiert!",
"TOAST_COPY_FAILED": "Kopieren in Zwischenablage fehlgeschlagen",
@@ -209,6 +211,7 @@
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Wendet Audioeffekte wie Kompression auf die Videowiedergabe an",
"AUDIO_OPEN_SETTINGS": "Öffnen",
"NEW_FEATURE_AUDIO": "Neu: Audio-Verarbeitung — probiere den Kompressor aus!",
"NEW_FEATURE_THEME": "Neu: Themes — wechsle zum klassischen Look oder minimalem Graphit.",
"AUDIO_BACK": "← Zurück",
"AUDIO_PAGE_TITLE": "Audio-Einstellungen",
"AUDIO_MASTER_TOGGLE": "Audio-Verarbeitung",
@@ -238,5 +241,24 @@
"LABEL_SEND_TAB_TITLE": "Tab-Titel senden",
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Sendet den Titel des ausgewählten Browser-Tabs an den Raum.",
"LABEL_MEDIA_TITLE_PRIVACY": "Medientitel",
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Lege fest, wie Medientitel an den Raum gesendet werden."
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Lege fest, wie Medientitel an den Raum gesendet werden.",
"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_CHAT_ENABLED": "Raum-Chat aktivieren",
"LABEL_CHAT_ENABLED_TOOLTIP": "Verschlüsselten Raum-Chat im ausgewählten Video-Tab anzeigen.",
"CHAT_TITLE": "Raum-Chat",
"CHAT_LIVE_ONLY": "Nur live. Kein Verlauf.",
"CHAT_OPEN": "Raum-Chat öffnen",
"CHAT_CLOSE": "Chat schließen",
"CHAT_DOCK_LEFT": "Chat links andocken",
"CHAT_DOCK_RIGHT": "Chat rechts andocken",
"CHAT_DETACHED": "Chat frei positionieren",
"CHAT_PLACEHOLDER": "Nachricht schreiben...",
"CHAT_SEND": "Senden",
"CHAT_MISSING_KEY": "Chat ist nur über einen aktuellen Einladungslink verfügbar.",
"CHAT_TOO_LONG": "Maximal 500 Zeichen.",
"CHAT_SEND_FAILED": "Nachricht konnte nicht gesendet werden.",
"CHAT_EMPTY": "Noch keine Nachrichten. Der Chat ist nur live."
}
+26 -4
View File
@@ -70,11 +70,11 @@
"LABEL_REMOTE_CONTROL": "Remote Control",
"BTN_COPY_INVITE": "📋 Invite Link",
"BTN_COPY_INVITE_TOOLTIP": "Copy Invite Link",
"BTN_PLAY": "Play",
"BTN_PLAY": "Play",
"BTN_PLAY_TOOLTIP": "Send a Play command to everyone",
"BTN_PAUSE": "Pause",
"BTN_PAUSE": "Pause",
"BTN_PAUSE_TOOLTIP": "Send a Pause command to everyone",
"BTN_SYNC": "⚡ SYNC",
"BTN_SYNC": "Sync",
"BTN_SYNC_TOOLTIP": "Force all users to sync up",
"OPTION_JUMP_TO_OTHERS": "Jump to Others",
"OPTION_JUMP_TO_ME": "Jump to Me",
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Identity not yet loaded. Wait a moment and try again.",
"ERR_NO_PEERS_TIME": "No other peers with a known time. Switch to 'Jump to Me'.",
"ERR_NO_VIDEO_TAB": "Could not connect to video tab.",
"SITE_ACCESS_REQUIRED": "Your browser is blocking KoalaSync on {host}. Click \"Allow access\" below. If no dialog appears, allow KoalaSync on this site in your extension or site permissions.",
"BTN_RETRY_ACCESS": "Allow access",
"ERR_SELECT_VIDEO": "Please select a video first!",
"TOAST_INVITE_COPIED": "Invite link copied!",
"TOAST_COPY_FAILED": "Failed to copy to clipboard",
@@ -209,6 +211,7 @@
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Apply audio effects like compression to video playback",
"AUDIO_OPEN_SETTINGS": "Open",
"NEW_FEATURE_AUDIO": "New: Audio Processing — try the compressor!",
"NEW_FEATURE_THEME": "New: Themes — switch to the classic look or minimal graphite.",
"AUDIO_BACK": "← Back",
"AUDIO_PAGE_TITLE": "Audio Settings",
"AUDIO_MASTER_TOGGLE": "Audio Processing",
@@ -238,5 +241,24 @@
"LABEL_SEND_TAB_TITLE": "Send tab title",
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Send the selected browser tab title to the room.",
"LABEL_MEDIA_TITLE_PRIVACY": "Media title",
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Choose how media titles are sent to the room."
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Choose how media titles are sent to the room.",
"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_CHAT_ENABLED": "Enable Room Chat",
"LABEL_CHAT_ENABLED_TOOLTIP": "Show encrypted room chat on the selected video tab.",
"CHAT_TITLE": "Room Chat",
"CHAT_LIVE_ONLY": "Live only. No history.",
"CHAT_OPEN": "Open room chat",
"CHAT_CLOSE": "Close chat",
"CHAT_DOCK_LEFT": "Dock chat left",
"CHAT_DOCK_RIGHT": "Dock chat right",
"CHAT_DETACHED": "Detach chat",
"CHAT_PLACEHOLDER": "Write a message...",
"CHAT_SEND": "Send",
"CHAT_MISSING_KEY": "Chat is available only through a current invite link.",
"CHAT_TOO_LONG": "Maximum 500 characters.",
"CHAT_SEND_FAILED": "Message could not be sent.",
"CHAT_EMPTY": "No messages yet. Chat is live only."
}
+26 -4
View File
@@ -70,11 +70,11 @@
"LABEL_REMOTE_CONTROL": "Control remoto",
"BTN_COPY_INVITE": "📋 Enlace de invitación",
"BTN_COPY_INVITE_TOOLTIP": "Copiar enlace de invitación",
"BTN_PLAY": "Reproducir",
"BTN_PLAY": "Reproducir",
"BTN_PLAY_TOOLTIP": "Enviar un comando de reproducción a todos",
"BTN_PAUSE": "Pausar",
"BTN_PAUSE": "Pausar",
"BTN_PAUSE_TOOLTIP": "Enviar un comando de pausa a todos",
"BTN_SYNC": "⚡ SYNC",
"BTN_SYNC": "Sync",
"BTN_SYNC_TOOLTIP": "Forzar la sincronización de todos los usuarios",
"OPTION_JUMP_TO_OTHERS": "Ir a la posición de otros",
"OPTION_JUMP_TO_ME": "Traerlos a mi posición",
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Identidad no cargada aún. Espera un momento e inténtalo de nuevo.",
"ERR_NO_PEERS_TIME": "No hay otros participantes con posición conocida. Cambia a 'Traerlos a mi posición'.",
"ERR_NO_VIDEO_TAB": "No se pudo conectar a la pestaña de video.",
"SITE_ACCESS_REQUIRED": "Tu navegador está bloqueando KoalaSync en {host}. Haz clic en «Permitir acceso». Si no aparece ningún diálogo, permite KoalaSync en este sitio desde los permisos de extensiones o del sitio.",
"BTN_RETRY_ACCESS": "Permitir acceso",
"ERR_SELECT_VIDEO": "¡Selecciona un video primero!",
"TOAST_INVITE_COPIED": "¡Enlace de invitación copiado!",
"TOAST_COPY_FAILED": "Error al copiar al portapapeles",
@@ -209,6 +211,7 @@
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Aplica efectos de audio como compresión a la reproducción de video",
"AUDIO_OPEN_SETTINGS": "Abrir",
"NEW_FEATURE_AUDIO": "Nuevo: Procesamiento de audio — ¡prueba el compresor!",
"NEW_FEATURE_THEME": "Nuevo: Temas — cambia al aspecto clásico o al grafito minimalista.",
"AUDIO_BACK": "← Volver",
"AUDIO_PAGE_TITLE": "Configuración de audio",
"AUDIO_MASTER_TOGGLE": "Procesamiento de audio",
@@ -238,5 +241,24 @@
"LABEL_SEND_TAB_TITLE": "Enviar título de pestaña",
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Envía a la sala el título de la pestaña del navegador seleccionada.",
"LABEL_MEDIA_TITLE_PRIVACY": "Título multimedia",
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Elige cómo se envían los títulos multimedia a la sala."
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Elige cómo se envían los títulos multimedia a la sala.",
"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_CHAT_ENABLED": "Activar chat de la sala",
"LABEL_CHAT_ENABLED_TOOLTIP": "Mostrar el chat cifrado de la sala en la pestaña de vídeo seleccionada.",
"CHAT_TITLE": "Chat de la sala",
"CHAT_LIVE_ONLY": "Solo en directo. Sin historial.",
"CHAT_OPEN": "Abrir chat de la sala",
"CHAT_CLOSE": "Cerrar chat",
"CHAT_DOCK_LEFT": "Acoplar chat a la izquierda",
"CHAT_DOCK_RIGHT": "Acoplar chat a la derecha",
"CHAT_DETACHED": "Separar chat",
"CHAT_PLACEHOLDER": "Escribe un mensaje...",
"CHAT_SEND": "Enviar",
"CHAT_MISSING_KEY": "El chat solo está disponible mediante un enlace de invitación actual.",
"CHAT_TOO_LONG": "Máximo 500 caracteres.",
"CHAT_SEND_FAILED": "No se pudo enviar el mensaje.",
"CHAT_EMPTY": "Aún no hay mensajes. El chat solo es en directo."
}
+26 -4
View File
@@ -70,11 +70,11 @@
"LABEL_REMOTE_CONTROL": "Contrôle à distance",
"BTN_COPY_INVITE": "📋 Lien d'invitation",
"BTN_COPY_INVITE_TOOLTIP": "Copier le lien d'invitation",
"BTN_PLAY": "Lecture",
"BTN_PLAY": "Lecture",
"BTN_PLAY_TOOLTIP": "Envoyer une commande Lecture à tout le monde",
"BTN_PAUSE": "Pause",
"BTN_PAUSE": "Pause",
"BTN_PAUSE_TOOLTIP": "Envoyer une commande Pause à tout le monde",
"BTN_SYNC": "⚡ SYNC",
"BTN_SYNC": "Sync",
"BTN_SYNC_TOOLTIP": "Forcer tous les utilisateurs à se synchroniser",
"OPTION_JUMP_TO_OTHERS": "Rejoindre les autres",
"OPTION_JUMP_TO_ME": "Les amener à moi",
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Identifiant non encore chargé. Veuillez patienter et réessayer.",
"ERR_NO_PEERS_TIME": "Aucun autre membre avec une position connue. Basculez sur 'Les amener à moi'.",
"ERR_NO_VIDEO_TAB": "Impossible de se connecter à l'onglet vidéo.",
"SITE_ACCESS_REQUIRED": "Votre navigateur bloque KoalaSync sur {host}. Cliquez sur « Autoriser laccès ». Si aucune boîte de dialogue napparaît, autorisez KoalaSync sur ce site dans les permissions des extensions ou du site.",
"BTN_RETRY_ACCESS": "Autoriser laccès",
"ERR_SELECT_VIDEO": "Veuillez d'abord sélectionner une vidéo !",
"TOAST_INVITE_COPIED": "Lien d'invitation copié !",
"TOAST_COPY_FAILED": "Échec de la copie dans le presse-papiers",
@@ -209,6 +211,7 @@
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Applique des effets audio comme la compression à la lecture vidéo",
"AUDIO_OPEN_SETTINGS": "Ouvrir",
"NEW_FEATURE_AUDIO": "Nouveau : Traitement audio — essayez le compresseur !",
"NEW_FEATURE_THEME": "Nouveau : Thèmes — passez au look classique ou au graphite minimal.",
"AUDIO_BACK": "← Retour",
"AUDIO_PAGE_TITLE": "Paramètres audio",
"AUDIO_MASTER_TOGGLE": "Traitement audio",
@@ -238,5 +241,24 @@
"LABEL_SEND_TAB_TITLE": "Envoyer le titre de longlet",
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Envoie au salon le titre de longlet de navigateur sélectionné.",
"LABEL_MEDIA_TITLE_PRIVACY": "Titre du média",
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Choisissez comment les titres des médias sont envoyés au salon."
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Choisissez comment les titres des médias sont envoyés au salon.",
"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_CHAT_ENABLED": "Activer le chat du salon",
"LABEL_CHAT_ENABLED_TOOLTIP": "Afficher le chat chiffré du salon dans l'onglet vidéo sélectionné.",
"CHAT_TITLE": "Chat du salon",
"CHAT_LIVE_ONLY": "En direct uniquement. Aucun historique.",
"CHAT_OPEN": "Ouvrir le chat du salon",
"CHAT_CLOSE": "Fermer le chat",
"CHAT_DOCK_LEFT": "Ancrer le chat à gauche",
"CHAT_DOCK_RIGHT": "Ancrer le chat à droite",
"CHAT_DETACHED": "Détacher le chat",
"CHAT_PLACEHOLDER": "Écrire un message...",
"CHAT_SEND": "Envoyer",
"CHAT_MISSING_KEY": "Le chat est disponible uniquement via un lien d'invitation actuel.",
"CHAT_TOO_LONG": "500 caractères maximum.",
"CHAT_SEND_FAILED": "Le message n'a pas pu être envoyé.",
"CHAT_EMPTY": "Aucun message. Le chat est uniquement en direct."
}
+26 -4
View File
@@ -70,11 +70,11 @@
"LABEL_REMOTE_CONTROL": "Telecomando",
"BTN_COPY_INVITE": "📋 Link di Invito",
"BTN_COPY_INVITE_TOOLTIP": "Copia il link di invito",
"BTN_PLAY": "Riproduci",
"BTN_PLAY": "Riproduci",
"BTN_PLAY_TOOLTIP": "Avvia la riproduzione per tutti",
"BTN_PAUSE": "Pausa",
"BTN_PAUSE": "Pausa",
"BTN_PAUSE_TOOLTIP": "Metti in pausa per tutti",
"BTN_SYNC": "⚡ SINCRO",
"BTN_SYNC": "Sincro",
"BTN_SYNC_TOOLTIP": "Forza la sincronizzazione per tutti",
"OPTION_JUMP_TO_OTHERS": "Vai alla posizione degli altri",
"OPTION_JUMP_TO_ME": "Portali alla mia posizione",
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Identità non ancora caricata. Attendi un momento.",
"ERR_NO_PEERS_TIME": "Nessun altro partecipante con posizione nota. Passa a 'Portali alla mia posizione'.",
"ERR_NO_VIDEO_TAB": "Impossibile connettersi alla scheda del video.",
"SITE_ACCESS_REQUIRED": "Il browser sta bloccando KoalaSync su {host}. Fai clic su «Consenti accesso». Se non appare alcuna finestra, consenti KoalaSync su questo sito nelle autorizzazioni dellestensione o del sito.",
"BTN_RETRY_ACCESS": "Consenti accesso",
"ERR_SELECT_VIDEO": "Seleziona prima un video!",
"TOAST_INVITE_COPIED": "Link di invito copiato!",
"TOAST_COPY_FAILED": "Impossibile copiare negli appunti",
@@ -209,6 +211,7 @@
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Applica effetti sonori (come il compressore) ai video",
"AUDIO_OPEN_SETTINGS": "Apri",
"NEW_FEATURE_AUDIO": "Novità: Elaborazione Audio — prova il compressore!",
"NEW_FEATURE_THEME": "Nuovo: Temi — passa all'aspetto classico o al grafite minimale.",
"AUDIO_BACK": "← Indietro",
"AUDIO_PAGE_TITLE": "Impostazioni Audio",
"AUDIO_MASTER_TOGGLE": "Elaborazione Audio",
@@ -238,5 +241,24 @@
"LABEL_SEND_TAB_TITLE": "Invia titolo scheda",
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Invia alla stanza il titolo della scheda del browser selezionata.",
"LABEL_MEDIA_TITLE_PRIVACY": "Titolo multimediale",
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Scegli come inviare alla stanza i titoli multimediali."
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Scegli come inviare alla stanza i titoli multimediali.",
"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_CHAT_ENABLED": "Attiva chat della stanza",
"LABEL_CHAT_ENABLED_TOOLTIP": "Mostra la chat crittografata della stanza nella scheda video selezionata.",
"CHAT_TITLE": "Chat della stanza",
"CHAT_LIVE_ONLY": "Solo in diretta. Nessuna cronologia.",
"CHAT_OPEN": "Apri la chat della stanza",
"CHAT_CLOSE": "Chiudi chat",
"CHAT_DOCK_LEFT": "Aggancia chat a sinistra",
"CHAT_DOCK_RIGHT": "Aggancia chat a destra",
"CHAT_DETACHED": "Sgancia chat",
"CHAT_PLACEHOLDER": "Scrivi un messaggio...",
"CHAT_SEND": "Invia",
"CHAT_MISSING_KEY": "La chat è disponibile solo tramite un link di invito attuale.",
"CHAT_TOO_LONG": "Massimo 500 caratteri.",
"CHAT_SEND_FAILED": "Impossibile inviare il messaggio.",
"CHAT_EMPTY": "Nessun messaggio. La chat è solo in diretta."
}
+26 -4
View File
@@ -70,11 +70,11 @@
"LABEL_REMOTE_CONTROL": "リモートコントロール",
"BTN_COPY_INVITE": "📋 招待リンク",
"BTN_COPY_INVITE_TOOLTIP": "招待リンクをコピー",
"BTN_PLAY": "再生",
"BTN_PLAY": "再生",
"BTN_PLAY_TOOLTIP": "全員に再生コマンドを送信",
"BTN_PAUSE": "一時停止",
"BTN_PAUSE": "一時停止",
"BTN_PAUSE_TOOLTIP": "全員に一時停止コマンドを送信",
"BTN_SYNC": "同期",
"BTN_SYNC": "同期",
"BTN_SYNC_TOOLTIP": "すべてのユーザーを強制的に同期",
"OPTION_JUMP_TO_OTHERS": "他の人に合わせる",
"OPTION_JUMP_TO_ME": "自分に合わせる",
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "IDがまだロードされていません。しばらく待ってから再試行してください。",
"ERR_NO_PEERS_TIME": "既知の時間を持つ他のメンバーがいません。「自分に合わせる」に切り替えてください。",
"ERR_NO_VIDEO_TAB": "ビデオタブに接続できませんでした。",
"SITE_ACCESS_REQUIRED": "ブラウザーが {host} で KoalaSync をブロックしています。下の「アクセスを許可」をクリックしてください。ダイアログが表示されない場合は、拡張機能またはサイトの権限でこのサイトの KoalaSync を許可してください。",
"BTN_RETRY_ACCESS": "アクセスを許可",
"ERR_SELECT_VIDEO": "最初にビデオを選択してください!",
"TOAST_INVITE_COPIED": "招待リンクをコピーしました!",
"TOAST_COPY_FAILED": "クリップボードへのコピーに失敗しました",
@@ -209,6 +211,7 @@
"LABEL_AUDIO_PROCESSING_TOOLTIP": "動画再生に圧縮などのオーディオエフェクトを適用します",
"AUDIO_OPEN_SETTINGS": "開く",
"NEW_FEATURE_AUDIO": "新機能: オーディオ処理 — コンプレッサーを試してみよう!",
"NEW_FEATURE_THEME": "新機能:テーマ — クラシックな外観やミニマルなグラファイトに切り替え。",
"AUDIO_BACK": "← 戻る",
"AUDIO_PAGE_TITLE": "オーディオ設定",
"AUDIO_MASTER_TOGGLE": "オーディオ処理",
@@ -238,5 +241,24 @@
"LABEL_SEND_TAB_TITLE": "タブタイトルを送信",
"LABEL_SEND_TAB_TITLE_TOOLTIP": "選択中のブラウザータブのタイトルをルームに送信します。",
"LABEL_MEDIA_TITLE_PRIVACY": "メディアタイトル",
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "メディアタイトルをルームに送信する方法を選択します。"
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "メディアタイトルをルームに送信する方法を選択します。",
"LABEL_SETTINGS_GROUP_PROFILE": "プロフィールと外観",
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "ユーザー名、テーマ、言語設定を変更します。",
"LABEL_SETTINGS_GROUP_SYNC": "再生と同期",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "再生、通知、オーディオ設定をカスタマイズします。",
"LABEL_CHAT_ENABLED": "ルームチャットを有効化",
"LABEL_CHAT_ENABLED_TOOLTIP": "選択した動画タブに暗号化されたルームチャットを表示します。",
"CHAT_TITLE": "ルームチャット",
"CHAT_LIVE_ONLY": "ライブのみ。履歴はありません。",
"CHAT_OPEN": "ルームチャットを開く",
"CHAT_CLOSE": "チャットを閉じる",
"CHAT_DOCK_LEFT": "チャットを左に固定",
"CHAT_DOCK_RIGHT": "チャットを右に固定",
"CHAT_DETACHED": "チャットを切り離す",
"CHAT_PLACEHOLDER": "メッセージを入力...",
"CHAT_SEND": "送信",
"CHAT_MISSING_KEY": "チャットは最新の招待リンクからのみ利用できます。",
"CHAT_TOO_LONG": "最大500文字です。",
"CHAT_SEND_FAILED": "メッセージを送信できませんでした。",
"CHAT_EMPTY": "まだメッセージはありません。チャットはライブのみです。"
}
+26 -4
View File
@@ -70,11 +70,11 @@
"LABEL_REMOTE_CONTROL": "원격 제어",
"BTN_COPY_INVITE": "📋 초대 링크",
"BTN_COPY_INVITE_TOOLTIP": "초대 링크 복사",
"BTN_PLAY": "재생",
"BTN_PLAY": "재생",
"BTN_PLAY_TOOLTIP": "모든 사람에게 재생 명령 전송",
"BTN_PAUSE": "일시정지",
"BTN_PAUSE": "일시정지",
"BTN_PAUSE_TOOLTIP": "모든 사람에게 일시정지 명령 전송",
"BTN_SYNC": "동기화",
"BTN_SYNC": "동기화",
"BTN_SYNC_TOOLTIP": "모든 사용자의 강제 동기화",
"OPTION_JUMP_TO_OTHERS": "다른 사람에게 이동",
"OPTION_JUMP_TO_ME": "나에게 이동",
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "ID가 아직 로드되지 않았습니다. 잠시 후 다시 시도해 주세요.",
"ERR_NO_PEERS_TIME": "시간을 알고 있는 다른 피어가 없습니다. '나에게 이동'으로 전환하세요.",
"ERR_NO_VIDEO_TAB": "비디오 탭에 연결할 수 없습니다.",
"SITE_ACCESS_REQUIRED": "브라우저가 {host}에서 KoalaSync를 차단하고 있습니다. 아래의 ‘액세스 허용’을 클릭하세요. 대화상자가 표시되지 않으면 확장 프로그램 또는 사이트 권한에서 이 사이트의 KoalaSync를 허용하세요.",
"BTN_RETRY_ACCESS": "액세스 허용",
"ERR_SELECT_VIDEO": "먼저 비디오를 선택하세요!",
"TOAST_INVITE_COPIED": "초대 링크가 복사되었습니다!",
"TOAST_COPY_FAILED": "클립보드 복사에 실패했습니다",
@@ -209,6 +211,7 @@
"LABEL_AUDIO_PROCESSING_TOOLTIP": "비디오 재생에 압축과 같은 오디오 효과를 적용합니다",
"AUDIO_OPEN_SETTINGS": "열기",
"NEW_FEATURE_AUDIO": "신규: 오디오 처리 — 컴프레서를 사용해보세요!",
"NEW_FEATURE_THEME": "신규: 테마 — 클래식 룩이나 미니멀 그래파이트로 전환하세요.",
"AUDIO_BACK": "← 뒤로",
"AUDIO_PAGE_TITLE": "오디오 설정",
"AUDIO_MASTER_TOGGLE": "오디오 처리",
@@ -238,5 +241,24 @@
"LABEL_SEND_TAB_TITLE": "탭 제목 보내기",
"LABEL_SEND_TAB_TITLE_TOOLTIP": "선택한 브라우저 탭 제목을 방에 보냅니다.",
"LABEL_MEDIA_TITLE_PRIVACY": "미디어 제목",
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "미디어 제목을 방에 보내는 방식을 선택합니다."
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "미디어 제목을 방에 보내는 방식을 선택합니다.",
"LABEL_SETTINGS_GROUP_PROFILE": "프로필 및 테마",
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "사용자 이름, 테마 및 언어 설정을 변경합니다.",
"LABEL_SETTINGS_GROUP_SYNC": "재생 및 동기화",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "재생, 알림 및 오디오 설정을 맞춤설정합니다.",
"LABEL_CHAT_ENABLED": "방 채팅 사용",
"LABEL_CHAT_ENABLED_TOOLTIP": "선택한 동영상 탭에 암호화된 방 채팅을 표시합니다.",
"CHAT_TITLE": "방 채팅",
"CHAT_LIVE_ONLY": "실시간 전용. 기록 없음.",
"CHAT_OPEN": "방 채팅 열기",
"CHAT_CLOSE": "채팅 닫기",
"CHAT_DOCK_LEFT": "채팅을 왼쪽에 고정",
"CHAT_DOCK_RIGHT": "채팅을 오른쪽에 고정",
"CHAT_DETACHED": "채팅 분리",
"CHAT_PLACEHOLDER": "메시지를 입력하세요...",
"CHAT_SEND": "보내기",
"CHAT_MISSING_KEY": "채팅은 최신 초대 링크를 통해서만 사용할 수 있습니다.",
"CHAT_TOO_LONG": "최대 500자입니다.",
"CHAT_SEND_FAILED": "메시지를 보낼 수 없습니다.",
"CHAT_EMPTY": "아직 메시지가 없습니다. 채팅은 실시간 전용입니다."
}
+26 -4
View File
@@ -70,11 +70,11 @@
"LABEL_REMOTE_CONTROL": "Afstandsbediening",
"BTN_COPY_INVITE": "📋 Uitnodigingslink",
"BTN_COPY_INVITE_TOOLTIP": "Uitnodigingslink kopiëren",
"BTN_PLAY": "Afspelen",
"BTN_PLAY": "Afspelen",
"BTN_PLAY_TOOLTIP": "Stuur een afspeelopdracht naar iedereen",
"BTN_PAUSE": "Pauzeren",
"BTN_PAUSE": "Pauzeren",
"BTN_PAUSE_TOOLTIP": "Stuur een pauzeeropdracht naar iedereen",
"BTN_SYNC": "⚡ SYNC",
"BTN_SYNC": "Sync",
"BTN_SYNC_TOOLTIP": "Dwing alle gebruikers om te synchroniseren",
"OPTION_JUMP_TO_OTHERS": "Spring naar anderen",
"OPTION_JUMP_TO_ME": "Spring naar mij",
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Identiteit nog niet geladen. Wacht een moment en probeer het opnieuw.",
"ERR_NO_PEERS_TIME": "Geen andere deelnemers met een bekende tijd. Schakel over naar 'Spring naar mij'.",
"ERR_NO_VIDEO_TAB": "Kon geen verbinding maken met videotabblad.",
"SITE_ACCESS_REQUIRED": "Je browser blokkeert KoalaSync op {host}. Klik hieronder op Toegang toestaan. Als er geen dialoog verschijnt, sta KoalaSync voor deze site toe via de extensie- of siterechten.",
"BTN_RETRY_ACCESS": "Toegang toestaan",
"ERR_SELECT_VIDEO": "Selecteer eerst een video!",
"TOAST_INVITE_COPIED": "Uitnodigingslink gekopieerd!",
"TOAST_COPY_FAILED": "Kopiëren naar klembord mislukt",
@@ -209,6 +211,7 @@
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Past audio-effecten zoals compressie toe op video-afspelen",
"AUDIO_OPEN_SETTINGS": "Openen",
"NEW_FEATURE_AUDIO": "Nieuw: Audioverwerking — probeer de compressor!",
"NEW_FEATURE_THEME": "Nieuw: Thema's — schakel naar de klassieke look of minimaal grafiet.",
"AUDIO_BACK": "← Terug",
"AUDIO_PAGE_TITLE": "Audio-instellingen",
"AUDIO_MASTER_TOGGLE": "Audioverwerking",
@@ -238,5 +241,24 @@
"LABEL_SEND_TAB_TITLE": "Tabtitel verzenden",
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Stuurt de titel van het geselecteerde browsertabblad naar de kamer.",
"LABEL_MEDIA_TITLE_PRIVACY": "Mediatitel",
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Kies hoe mediatitels naar de kamer worden gestuurd."
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Kies hoe mediatitels naar de kamer worden gestuurd.",
"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_CHAT_ENABLED": "Kamerchat inschakelen",
"LABEL_CHAT_ENABLED_TOOLTIP": "Versleutelde kamerchat tonen in het geselecteerde videotabblad.",
"CHAT_TITLE": "Kamerchat",
"CHAT_LIVE_ONLY": "Alleen live. Geen geschiedenis.",
"CHAT_OPEN": "Kamerchat openen",
"CHAT_CLOSE": "Chat sluiten",
"CHAT_DOCK_LEFT": "Chat links vastzetten",
"CHAT_DOCK_RIGHT": "Chat rechts vastzetten",
"CHAT_DETACHED": "Chat losmaken",
"CHAT_PLACEHOLDER": "Schrijf een bericht...",
"CHAT_SEND": "Verzenden",
"CHAT_MISSING_KEY": "Chat is alleen beschikbaar via een actuele uitnodigingslink.",
"CHAT_TOO_LONG": "Maximaal 500 tekens.",
"CHAT_SEND_FAILED": "Bericht kon niet worden verzonden.",
"CHAT_EMPTY": "Nog geen berichten. Chat is alleen live."
}
+26 -4
View File
@@ -70,11 +70,11 @@
"LABEL_REMOTE_CONTROL": "Zdalne sterowanie",
"BTN_COPY_INVITE": "📋 Link do zaproszenia",
"BTN_COPY_INVITE_TOOLTIP": "Kopiuj link zaproszenia",
"BTN_PLAY": "Odtwórz",
"BTN_PLAY": "Odtwórz",
"BTN_PLAY_TOOLTIP": "Wyślij polecenie odtwarzania do wszystkich",
"BTN_PAUSE": "Pauza",
"BTN_PAUSE": "Pauza",
"BTN_PAUSE_TOOLTIP": "Wyślij polecenie pauzy do wszystkich",
"BTN_SYNC": "⚡ SYNC",
"BTN_SYNC": "Sync",
"BTN_SYNC_TOOLTIP": "Wymuś synchronizację wszystkich użytkowników",
"OPTION_JUMP_TO_OTHERS": "Skocz do innych",
"OPTION_JUMP_TO_ME": "Skocz do mnie",
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Tożsamość nie została jeszcze załadowana. Odczekaj chwilę i spróbuj ponownie.",
"ERR_NO_PEERS_TIME": "Brak innych uczestników ze znanym czasem. Przełącz na 'Skocz do mnie'.",
"ERR_NO_VIDEO_TAB": "Nie można połączyć się z kartą wideo.",
"SITE_ACCESS_REQUIRED": "Przeglądarka blokuje KoalaSync na {host}. Kliknij poniżej „Zezwól na dostęp”. Jeśli nie pojawi się okno, zezwól KoalaSync na tej stronie w uprawnieniach rozszerzenia lub witryny.",
"BTN_RETRY_ACCESS": "Zezwól na dostęp",
"ERR_SELECT_VIDEO": "Najpierw wybierz wideo!",
"TOAST_INVITE_COPIED": "Link zaproszenia skopiowany!",
"TOAST_COPY_FAILED": "Nie udało się skopiować do schowka",
@@ -209,6 +211,7 @@
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Stosuje efekty dźwiękowe, takie jak kompresja, do odtwarzania wideo",
"AUDIO_OPEN_SETTINGS": "Otwórz",
"NEW_FEATURE_AUDIO": "Nowość: Przetwarzanie dźwięku — wypróbuj kompresor!",
"NEW_FEATURE_THEME": "Nowość: Motywy — przełącz na klasyczny wygląd lub minimalny grafit.",
"AUDIO_BACK": "← Wstecz",
"AUDIO_PAGE_TITLE": "Ustawienia dźwięku",
"AUDIO_MASTER_TOGGLE": "Przetwarzanie dźwięku",
@@ -238,5 +241,24 @@
"LABEL_SEND_TAB_TITLE": "Wysyłaj tytuł karty",
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Wysyła do pokoju tytuł wybranej karty przeglądarki.",
"LABEL_MEDIA_TITLE_PRIVACY": "Tytuł multimediów",
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Wybierz, jak tytuły multimediów są wysyłane do pokoju."
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Wybierz, jak tytuły multimediów są wysyłane do pokoju.",
"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_CHAT_ENABLED": "Włącz czat pokoju",
"LABEL_CHAT_ENABLED_TOOLTIP": "Pokaż zaszyfrowany czat pokoju na wybranej karcie wideo.",
"CHAT_TITLE": "Czat pokoju",
"CHAT_LIVE_ONLY": "Tylko na żywo. Bez historii.",
"CHAT_OPEN": "Otwórz czat pokoju",
"CHAT_CLOSE": "Zamknij czat",
"CHAT_DOCK_LEFT": "Przypnij czat po lewej",
"CHAT_DOCK_RIGHT": "Przypnij czat po prawej",
"CHAT_DETACHED": "Odłącz czat",
"CHAT_PLACEHOLDER": "Napisz wiadomość...",
"CHAT_SEND": "Wyślij",
"CHAT_MISSING_KEY": "Czat jest dostępny tylko przez aktualny link zaproszenia.",
"CHAT_TOO_LONG": "Maksymalnie 500 znaków.",
"CHAT_SEND_FAILED": "Nie udało się wysłać wiadomości.",
"CHAT_EMPTY": "Brak wiadomości. Czat działa tylko na żywo."
}
+26 -4
View File
@@ -70,11 +70,11 @@
"LABEL_REMOTE_CONTROL": "Controle remoto",
"BTN_COPY_INVITE": "📋 Link de convite",
"BTN_COPY_INVITE_TOOLTIP": "Copiar link de convite",
"BTN_PLAY": "Reproduzir",
"BTN_PLAY": "Reproduzir",
"BTN_PLAY_TOOLTIP": "Iniciar a reprodução para todos",
"BTN_PAUSE": "Pausar",
"BTN_PAUSE": "Pausar",
"BTN_PAUSE_TOOLTIP": "Pausar o vídeo para todos",
"BTN_SYNC": "⚡ SINCRO",
"BTN_SYNC": "Sincro",
"BTN_SYNC_TOOLTIP": "Forçar todos os usuários a sincronizar",
"OPTION_JUMP_TO_OTHERS": "Ir para a posição dos outros",
"OPTION_JUMP_TO_ME": "Trazer para a minha posição",
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Identidade não carregada ainda. Aguarde um momento.",
"ERR_NO_PEERS_TIME": "Nenhum participante com posição conhecida. Mude para 'Trazer para a minha posição'.",
"ERR_NO_VIDEO_TAB": "Não foi possível conectar à aba do vídeo.",
"SITE_ACCESS_REQUIRED": "Seu navegador está bloqueando o KoalaSync em {host}. Clique em «Permitir acesso» abaixo. Se nenhuma caixa de diálogo aparecer, permita o KoalaSync neste site nas permissões da extensão ou do site.",
"BTN_RETRY_ACCESS": "Permitir acesso",
"ERR_SELECT_VIDEO": "Selecione um vídeo primeiro!",
"TOAST_INVITE_COPIED": "Link de convite copiado!",
"TOAST_COPY_FAILED": "Falha ao copiar para a área de transferência",
@@ -209,6 +211,7 @@
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Aplica efeitos de áudio (como compressão) aos vídeos",
"AUDIO_OPEN_SETTINGS": "Abrir",
"NEW_FEATURE_AUDIO": "Novo: Processamento de áudio — experimente o compressor!",
"NEW_FEATURE_THEME": "Novo: Temas — mude para o visual clássico ou grafite minimalista.",
"AUDIO_BACK": "← Voltar",
"AUDIO_PAGE_TITLE": "Configurações de áudio",
"AUDIO_MASTER_TOGGLE": "Processamento de áudio",
@@ -238,5 +241,24 @@
"LABEL_SEND_TAB_TITLE": "Enviar título da aba",
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Envia para a sala o título da aba selecionada do navegador.",
"LABEL_MEDIA_TITLE_PRIVACY": "Título da mídia",
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Escolha como os títulos de mídia são enviados para a sala."
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Escolha como os títulos de mídia são enviados para a sala.",
"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_CHAT_ENABLED": "Ativar chat da sala",
"LABEL_CHAT_ENABLED_TOOLTIP": "Mostrar o chat criptografado da sala na guia de vídeo selecionada.",
"CHAT_TITLE": "Chat da sala",
"CHAT_LIVE_ONLY": "Somente ao vivo. Sem histórico.",
"CHAT_OPEN": "Abrir chat da sala",
"CHAT_CLOSE": "Fechar chat",
"CHAT_DOCK_LEFT": "Fixar chat à esquerda",
"CHAT_DOCK_RIGHT": "Fixar chat à direita",
"CHAT_DETACHED": "Desanexar chat",
"CHAT_PLACEHOLDER": "Escreva uma mensagem...",
"CHAT_SEND": "Enviar",
"CHAT_MISSING_KEY": "O chat está disponível apenas por um link de convite atual.",
"CHAT_TOO_LONG": "Máximo de 500 caracteres.",
"CHAT_SEND_FAILED": "Não foi possível enviar a mensagem.",
"CHAT_EMPTY": "Ainda não há mensagens. O chat é somente ao vivo."
}
+26 -4
View File
@@ -70,11 +70,11 @@
"LABEL_REMOTE_CONTROL": "Controlo Remoto",
"BTN_COPY_INVITE": "📋 Link de Convite",
"BTN_COPY_INVITE_TOOLTIP": "Copiar Link de Convite",
"BTN_PLAY": "Reproduzir",
"BTN_PLAY": "Reproduzir",
"BTN_PLAY_TOOLTIP": "Iniciar a reprodução para todos",
"BTN_PAUSE": "Pausa",
"BTN_PAUSE": "Pausa",
"BTN_PAUSE_TOOLTIP": "Pausar o vídeo para todos",
"BTN_SYNC": "⚡ SINCRO",
"BTN_SYNC": "Sincro",
"BTN_SYNC_TOOLTIP": "Forçar sincronização de todos os utilizadores",
"OPTION_JUMP_TO_OTHERS": "Ir para a posição dos outros",
"OPTION_JUMP_TO_ME": "Trazer para a minha posição",
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Identidade ainda não carregada. Aguarde um momento.",
"ERR_NO_PEERS_TIME": "Nenhum participante com hora conhecida. Mude para 'Trazer para a minha posição'.",
"ERR_NO_VIDEO_TAB": "Não foi possível aceder ao separador do vídeo.",
"SITE_ACCESS_REQUIRED": "O seu navegador está a bloquear o KoalaSync em {host}. Clique em «Permitir acesso» abaixo. Se não aparecer uma caixa de diálogo, permita o KoalaSync neste site nas permissões da extensão ou do site.",
"BTN_RETRY_ACCESS": "Permitir acesso",
"ERR_SELECT_VIDEO": "Selecione primeiro um vídeo!",
"TOAST_INVITE_COPIED": "Link de convite copiado!",
"TOAST_COPY_FAILED": "Falha ao copiar para a área de transferência",
@@ -209,6 +211,7 @@
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Aplica efeitos de áudio (como compressão) aos vídeos",
"AUDIO_OPEN_SETTINGS": "Abrir",
"NEW_FEATURE_AUDIO": "Novo: Processamento de áudio — experimente o compressor!",
"NEW_FEATURE_THEME": "Novo: Temas — mude para o visual clássico ou grafite minimalista.",
"AUDIO_BACK": "← Voltar",
"AUDIO_PAGE_TITLE": "Configurações de Áudio",
"AUDIO_MASTER_TOGGLE": "Processamento de Áudio",
@@ -238,5 +241,24 @@
"LABEL_SEND_TAB_TITLE": "Enviar título do separador",
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Envia para a sala o título do separador do navegador selecionado.",
"LABEL_MEDIA_TITLE_PRIVACY": "Título multimédia",
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Escolhe como os títulos multimédia são enviados para a sala."
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Escolhe como os títulos multimédia são enviados para a sala.",
"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_CHAT_ENABLED": "Ativar chat da sala",
"LABEL_CHAT_ENABLED_TOOLTIP": "Mostrar o chat encriptado da sala no separador de vídeo selecionado.",
"CHAT_TITLE": "Chat da sala",
"CHAT_LIVE_ONLY": "Apenas em direto. Sem histórico.",
"CHAT_OPEN": "Abrir chat da sala",
"CHAT_CLOSE": "Fechar chat",
"CHAT_DOCK_LEFT": "Fixar chat à esquerda",
"CHAT_DOCK_RIGHT": "Fixar chat à direita",
"CHAT_DETACHED": "Desanexar chat",
"CHAT_PLACEHOLDER": "Escreva uma mensagem...",
"CHAT_SEND": "Enviar",
"CHAT_MISSING_KEY": "O chat está disponível apenas através de um link de convite atual.",
"CHAT_TOO_LONG": "Máximo de 500 caracteres.",
"CHAT_SEND_FAILED": "Não foi possível enviar a mensagem.",
"CHAT_EMPTY": "Ainda não há mensagens. O chat funciona apenas em direto."
}
+26 -4
View File
@@ -70,11 +70,11 @@
"LABEL_REMOTE_CONTROL": "Дистанционное управление",
"BTN_COPY_INVITE": "📋 Ссылка",
"BTN_COPY_INVITE_TOOLTIP": "Копировать ссылку-приглашение",
"BTN_PLAY": "Запуск",
"BTN_PLAY": "Запуск",
"BTN_PLAY_TOOLTIP": "Отправить команду воспроизведения всем",
"BTN_PAUSE": "Пауза",
"BTN_PAUSE": "Пауза",
"BTN_PAUSE_TOOLTIP": "Отправить команду паузы всем",
"BTN_SYNC": "⚡ СИНХРО",
"BTN_SYNC": "Синхро",
"BTN_SYNC_TOOLTIP": "Принудительно синхронизировать всех участников",
"OPTION_JUMP_TO_OTHERS": "Перейти к другим",
"OPTION_JUMP_TO_ME": "Синхронизировать по мне",
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Идентификатор еще не загружен. Подождите немного и повторите попытку.",
"ERR_NO_PEERS_TIME": "Нет других участников с известной позицией плеера. Выберите 'Синхронизировать по мне'.",
"ERR_NO_VIDEO_TAB": "Не удалось подключиться к вкладке с видео.",
"SITE_ACCESS_REQUIRED": "Браузер блокирует KoalaSync на {host}. Нажмите «Разрешить доступ» ниже. Если диалог не появится, разрешите KoalaSync для этого сайта в настройках расширения или разрешениях сайта.",
"BTN_RETRY_ACCESS": "Разрешить доступ",
"ERR_SELECT_VIDEO": "Пожалуйста, сначала выберите вкладку с видео!",
"TOAST_INVITE_COPIED": "Ссылка скопирована в буфер обмена!",
"TOAST_COPY_FAILED": "Не удалось скопировать ссылку в буфер обмена",
@@ -209,6 +211,7 @@
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Применяет аудиоэффекты, такие как сжатие, к воспроизведению видео",
"AUDIO_OPEN_SETTINGS": "Открыть",
"NEW_FEATURE_AUDIO": "Новое: Обработка звука — попробуйте компрессор!",
"NEW_FEATURE_THEME": "Новое: Темы — переключитесь на классический вид или минимальный графит.",
"AUDIO_BACK": "← Назад",
"AUDIO_PAGE_TITLE": "Настройки звука",
"AUDIO_MASTER_TOGGLE": "Обработка звука",
@@ -238,5 +241,24 @@
"LABEL_SEND_TAB_TITLE": "Отправлять название вкладки",
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Отправляет в комнату название выбранной вкладки браузера.",
"LABEL_MEDIA_TITLE_PRIVACY": "Название медиа",
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Выберите, как названия медиа отправляются в комнату."
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Выберите, как названия медиа отправляются в комнату.",
"LABEL_SETTINGS_GROUP_PROFILE": "Профиль и Оформление",
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Измените имя пользователя, тему и настройки языка.",
"LABEL_SETTINGS_GROUP_SYNC": "Воспроизведение и Синхронизация",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Настройте воспроизведение, уведомления и параметры звука.",
"LABEL_CHAT_ENABLED": "Включить чат комнаты",
"LABEL_CHAT_ENABLED_TOOLTIP": "Показывать зашифрованный чат комнаты на выбранной вкладке с видео.",
"CHAT_TITLE": "Чат комнаты",
"CHAT_LIVE_ONLY": "Только в реальном времени. Без истории.",
"CHAT_OPEN": "Открыть чат комнаты",
"CHAT_CLOSE": "Закрыть чат",
"CHAT_DOCK_LEFT": "Закрепить чат слева",
"CHAT_DOCK_RIGHT": "Закрепить чат справа",
"CHAT_DETACHED": "Открепить чат",
"CHAT_PLACEHOLDER": "Напишите сообщение...",
"CHAT_SEND": "Отправить",
"CHAT_MISSING_KEY": "Чат доступен только по актуальной ссылке-приглашению.",
"CHAT_TOO_LONG": "Не более 500 символов.",
"CHAT_SEND_FAILED": "Не удалось отправить сообщение.",
"CHAT_EMPTY": "Сообщений пока нет. Чат работает только в реальном времени."
}
+26 -4
View File
@@ -70,11 +70,11 @@
"LABEL_REMOTE_CONTROL": "Uzaktan Kumanda",
"BTN_COPY_INVITE": "📋 Davet Linki",
"BTN_COPY_INVITE_TOOLTIP": "Davet Linkini Kopyala",
"BTN_PLAY": "Oynat",
"BTN_PLAY": "Oynat",
"BTN_PLAY_TOOLTIP": "Herkese Oynat komutu gönder",
"BTN_PAUSE": "Duraklat",
"BTN_PAUSE": "Duraklat",
"BTN_PAUSE_TOOLTIP": "Herkese Duraklat komutu gönder",
"BTN_SYNC": "⚡ SYNC",
"BTN_SYNC": "Sync",
"BTN_SYNC_TOOLTIP": "Tüm kullanıcıları senkronize etmeye zorla",
"OPTION_JUMP_TO_OTHERS": "Diğerlerine Atla",
"OPTION_JUMP_TO_ME": "Bana Atla",
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Kimlik henüz yüklenmedi. Biraz bekleyin ve tekrar deneyin.",
"ERR_NO_PEERS_TIME": "Bilinen bir zamanı olan başka bağlantı yok. 'Bana Atla' seçeneğine geçin.",
"ERR_NO_VIDEO_TAB": "Video sekmesine bağlanılamadı.",
"SITE_ACCESS_REQUIRED": "Tarayıcınız {host} üzerinde KoalaSync'i engelliyor. Aşağıdaki “Erişime izin ver” düğmesine tıklayın. İletişim kutusu görünmezse uzantı veya site izinlerinden bu site için KoalaSync'e izin verin.",
"BTN_RETRY_ACCESS": "Erişime izin ver",
"ERR_SELECT_VIDEO": "Lütfen önce bir video seçin!",
"TOAST_INVITE_COPIED": "Davet bağlantısı kopyalandı!",
"TOAST_COPY_FAILED": "Panoya kopyalanamadı",
@@ -209,6 +211,7 @@
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Video oynatımına sıkıştırma gibi ses efektleri uygular",
"AUDIO_OPEN_SETTINGS": "Aç",
"NEW_FEATURE_AUDIO": "Yeni: Ses işleme — kompresörü deneyin!",
"NEW_FEATURE_THEME": "Yeni: Temalar — klasik görünüme veya minimal grafite geçin.",
"AUDIO_BACK": "← Geri",
"AUDIO_PAGE_TITLE": "Ses ayarları",
"AUDIO_MASTER_TOGGLE": "Ses işleme",
@@ -238,5 +241,24 @@
"LABEL_SEND_TAB_TITLE": "Sekme başlığını gönder",
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Seçili tarayıcı sekmesinin başlığını odaya gönderir.",
"LABEL_MEDIA_TITLE_PRIVACY": "Medya başlığı",
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Medya başlıklarının odaya nasıl gönderileceğini seçin."
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Medya başlıklarının odaya nasıl gönderileceğini seçin.",
"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_CHAT_ENABLED": "Oda sohbetini etkinleştir",
"LABEL_CHAT_ENABLED_TOOLTIP": "Şifreli oda sohbetini seçili video sekmesinde göster.",
"CHAT_TITLE": "Oda Sohbeti",
"CHAT_LIVE_ONLY": "Yalnızca canlı. Geçmiş yok.",
"CHAT_OPEN": "Oda sohbetini aç",
"CHAT_CLOSE": "Sohbeti kapat",
"CHAT_DOCK_LEFT": "Sohbeti sola sabitle",
"CHAT_DOCK_RIGHT": "Sohbeti sağa sabitle",
"CHAT_DETACHED": "Sohbeti ayır",
"CHAT_PLACEHOLDER": "Bir mesaj yazın...",
"CHAT_SEND": "Gönder",
"CHAT_MISSING_KEY": "Sohbet yalnızca güncel bir davet bağlantısıyla kullanılabilir.",
"CHAT_TOO_LONG": "En fazla 500 karakter.",
"CHAT_SEND_FAILED": "Mesaj gönderilemedi.",
"CHAT_EMPTY": "Henüz mesaj yok. Sohbet yalnızca canlıdır."
}
+26 -4
View File
@@ -70,11 +70,11 @@
"LABEL_REMOTE_CONTROL": "Пульт дистанційного керування",
"BTN_COPY_INVITE": "📋 Посилання на запрошення",
"BTN_COPY_INVITE_TOOLTIP": "Копіювати посилання для запрошення",
"BTN_PLAY": "Відтворити",
"BTN_PLAY": "Відтворити",
"BTN_PLAY_TOOLTIP": "Надішліть усім команду Play",
"BTN_PAUSE": "Пауза",
"BTN_PAUSE": "Пауза",
"BTN_PAUSE_TOOLTIP": "Надішліть команду паузи всім",
"BTN_SYNC": "⚡ СИНХР",
"BTN_SYNC": "Синхр",
"BTN_SYNC_TOOLTIP": "Змусити всіх користувачів синхронізуватися",
"OPTION_JUMP_TO_OTHERS": "Перейти до інших",
"OPTION_JUMP_TO_ME": "Перейти до мене",
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "Посвідчення особи ще не завантажено. Зачекайте хвилинку та повторіть спробу.",
"ERR_NO_PEERS_TIME": "Немає інших аналогів із відомим часом. Переключіться на «Перейти до мене».",
"ERR_NO_VIDEO_TAB": "Не вдалося підключитися до вкладки відео.",
"SITE_ACCESS_REQUIRED": "Браузер блокує KoalaSync на {host}. Натисніть «Дозволити доступ» нижче. Якщо діалог не з’явиться, дозвольте KoalaSync для цього сайту в налаштуваннях розширення або дозволах сайту.",
"BTN_RETRY_ACCESS": "Дозволити доступ",
"ERR_SELECT_VIDEO": "Спочатку виберіть відео!",
"TOAST_INVITE_COPIED": "Посилання на запрошення скопійовано!",
"TOAST_COPY_FAILED": "Не вдалося скопіювати в буфер обміну",
@@ -209,6 +211,7 @@
"LABEL_AUDIO_PROCESSING_TOOLTIP": "Застосуйте звукові ефекти, такі як стиснення, до відтворення відео",
"AUDIO_OPEN_SETTINGS": "Відкрити",
"NEW_FEATURE_AUDIO": "Новинка: обробка звуку — спробуйте компресор!",
"NEW_FEATURE_THEME": "Нове: Теми — перемкніться на класичний вигляд або мінімальний графіт.",
"AUDIO_BACK": "← Назад",
"AUDIO_PAGE_TITLE": "Параметри звуку",
"AUDIO_MASTER_TOGGLE": "Обробка аудіо",
@@ -238,5 +241,24 @@
"LABEL_SEND_TAB_TITLE": "Надсилати назву вкладки",
"LABEL_SEND_TAB_TITLE_TOOLTIP": "Надсилає до кімнати назву вибраної вкладки браузера.",
"LABEL_MEDIA_TITLE_PRIVACY": "Назва медіа",
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Виберіть, як назви медіа надсилати до кімнати."
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "Виберіть, як назви медіа надсилати до кімнати.",
"LABEL_SETTINGS_GROUP_PROFILE": "Профіль та Вигляд",
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "Змініть ім'я користувача, тему та мовні налаштування.",
"LABEL_SETTINGS_GROUP_SYNC": "Відтворення та Синхронізація",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "Налаштуйте відтворення, сповіщення та параметри звуку.",
"LABEL_CHAT_ENABLED": "Увімкнути чат кімнати",
"LABEL_CHAT_ENABLED_TOOLTIP": "Показувати зашифрований чат кімнати на вибраній вкладці з відео.",
"CHAT_TITLE": "Чат кімнати",
"CHAT_LIVE_ONLY": "Лише наживо. Без історії.",
"CHAT_OPEN": "Відкрити чат кімнати",
"CHAT_CLOSE": "Закрити чат",
"CHAT_DOCK_LEFT": "Закріпити чат ліворуч",
"CHAT_DOCK_RIGHT": "Закріпити чат праворуч",
"CHAT_DETACHED": "Від'єднати чат",
"CHAT_PLACEHOLDER": "Напишіть повідомлення...",
"CHAT_SEND": "Надіслати",
"CHAT_MISSING_KEY": "Чат доступний лише за актуальним посиланням-запрошенням.",
"CHAT_TOO_LONG": "Не більше 500 символів.",
"CHAT_SEND_FAILED": "Не вдалося надіслати повідомлення.",
"CHAT_EMPTY": "Повідомлень ще немає. Чат працює лише наживо."
}
+26 -4
View File
@@ -70,11 +70,11 @@
"LABEL_REMOTE_CONTROL": "遥控",
"BTN_COPY_INVITE": "📋 邀请链接",
"BTN_COPY_INVITE_TOOLTIP": "复制邀请链接",
"BTN_PLAY": "播放",
"BTN_PLAY": "播放",
"BTN_PLAY_TOOLTIP": "向所有人发送播放命令",
"BTN_PAUSE": "暂停",
"BTN_PAUSE": "暂停",
"BTN_PAUSE_TOOLTIP": "向所有人发送暂停命令",
"BTN_SYNC": "同步",
"BTN_SYNC": "同步",
"BTN_SYNC_TOOLTIP": "强制所有用户同步",
"OPTION_JUMP_TO_OTHERS": "跳转到其他",
"OPTION_JUMP_TO_ME": "跳到我这里",
@@ -148,6 +148,8 @@
"ERR_IDENTITY_NOT_LOADED": "身份尚未加载。稍等片刻,然后重试。",
"ERR_NO_PEERS_TIME": "没有其他已知时间的同行。切换到“跳到我这里”。",
"ERR_NO_VIDEO_TAB": "无法连接到视频选项卡。",
"SITE_ACCESS_REQUIRED": "浏览器正在阻止 KoalaSync 访问 {host}。请点击下方的“允许访问”。如果未显示对话框,请在扩展程序或网站权限中允许 KoalaSync 访问此网站。",
"BTN_RETRY_ACCESS": "允许访问",
"ERR_SELECT_VIDEO": "请先选择视频!",
"TOAST_INVITE_COPIED": "邀请链接已复制!",
"TOAST_COPY_FAILED": "无法复制到剪贴板",
@@ -209,6 +211,7 @@
"LABEL_AUDIO_PROCESSING_TOOLTIP": "将压缩等音频效果应用于视频播放",
"AUDIO_OPEN_SETTINGS": "打开",
"NEW_FEATURE_AUDIO": "新功能:音频处理 - 尝试压缩器!",
"NEW_FEATURE_THEME": "新功能:主题 — 切换到经典外观或极简石墨风格。",
"AUDIO_BACK": "← 返回",
"AUDIO_PAGE_TITLE": "音频设置",
"AUDIO_MASTER_TOGGLE": "音频处理",
@@ -238,5 +241,24 @@
"LABEL_SEND_TAB_TITLE": "发送标签页标题",
"LABEL_SEND_TAB_TITLE_TOOLTIP": "将所选浏览器标签页的标题发送到房间。",
"LABEL_MEDIA_TITLE_PRIVACY": "媒体标题",
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "选择媒体标题发送到房间的方式。"
"LABEL_MEDIA_TITLE_PRIVACY_TOOLTIP": "选择媒体标题发送到房间的方式。",
"LABEL_SETTINGS_GROUP_PROFILE": "个人资料与外观",
"LABEL_SETTINGS_GROUP_PROFILE_TOOLTIP": "更改您的用户名、主题和语言首选项。",
"LABEL_SETTINGS_GROUP_SYNC": "播放与同步",
"LABEL_SETTINGS_GROUP_SYNC_TOOLTIP": "自定义播放、通知和音频首选项。",
"LABEL_CHAT_ENABLED": "启用房间聊天",
"LABEL_CHAT_ENABLED_TOOLTIP": "在选定的视频标签页中显示加密的房间聊天。",
"CHAT_TITLE": "房间聊天",
"CHAT_LIVE_ONLY": "仅实时显示,无历史记录。",
"CHAT_OPEN": "打开房间聊天",
"CHAT_CLOSE": "关闭聊天",
"CHAT_DOCK_LEFT": "将聊天固定在左侧",
"CHAT_DOCK_RIGHT": "将聊天固定在右侧",
"CHAT_DETACHED": "浮动聊天",
"CHAT_PLACEHOLDER": "输入消息...",
"CHAT_SEND": "发送",
"CHAT_MISSING_KEY": "聊天仅可通过当前邀请链接使用。",
"CHAT_TOO_LONG": "最多500个字符。",
"CHAT_SEND_FAILED": "无法发送消息。",
"CHAT_EMPTY": "暂无消息。聊天仅实时显示。"
}
+4 -3
View File
@@ -1,9 +1,10 @@
{
"manifest_version": 3,
"default_locale": "en",
"name": "KoalaSync",
"version": "2.5.2",
"description": "Synchronize video playback on YouTube, Netflix, Emby, Jellyfin, and any HTML5 site in real-time with friends.",
"name": "__MSG_appName__",
"short_name": "KoalaSync",
"version": "2.6.4",
"description": "__MSG_appDesc__",
"permissions": [
"storage",
"tabs",
+8 -72
View File
@@ -1,20 +1,7 @@
export function initTabManager({
getCurrentTabId,
setCurrentTabId,
setCurrentTabTitle,
setLastContentHeartbeatAt,
setRoomIdleSince,
getCurrentRoom,
getPeerId,
getStorageInitialized,
updateBadgeStatus,
addLog,
getSettings,
emit,
applyAudioSettingsToTab,
injectContentScript,
ensureState,
EVENTS
reactivateCurrentTarget,
ensureState
}) {
chrome.storage.onChanged.addListener(async (changes, area) => {
if (area !== 'local' || !changes.audioSettings) return;
@@ -28,65 +15,14 @@ export function initTabManager({
}).catch(() => {});
});
chrome.tabs.onRemoved.addListener(async (tabId) => {
await ensureState();
if (tabId === getCurrentTabId()) {
const wasInRoom = !!getCurrentRoom();
setCurrentTabId(null);
setCurrentTabTitle(null);
setLastContentHeartbeatAt(null);
const now = Date.now();
setRoomIdleSince(now);
chrome.storage.session.set({
currentTabId: null,
currentTabTitle: null,
roomIdleSince: now,
lastContentHeartbeatAt: null
});
updateBadgeStatus();
addLog('Target tab closed.', 'warn');
if (wasInRoom) {
const roomAtClose = getCurrentRoom();
getSettings().then(settings => {
if (getCurrentRoom() !== roomAtClose) return;
emit(EVENTS.PEER_STATUS, {
peerId: getPeerId(),
playbackState: 'paused',
currentTime: null,
mediaTitle: null,
username: settings.username,
tabTitle: null
});
const room = getCurrentRoom();
if (room && Array.isArray(room.peers)) {
const me = room.peers.find(p => (p.peerId || p) === getPeerId());
if (me && typeof me === 'object') {
me.playbackState = 'paused';
me.currentTime = null;
me.mediaTitle = null;
me.tabTitle = null;
me.lastHeartbeat = Date.now();
if (getStorageInitialized()) {
chrome.storage.session.set({ currentRoom: room });
}
chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: room.peers }).catch(() => {});
}
}
}).catch(() => {});
}
}
});
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, _tab) => {
await ensureState();
const curTabId = getCurrentTabId();
if (curTabId && tabId === parseInt(curTabId) && changeInfo.status === 'complete') {
injectContentScript(tabId)
.then(() => applyAudioSettingsToTab(tabId))
.catch(() => {});
const currentTabId = Number(getCurrentTabId());
if (Number.isInteger(currentTabId)
&& currentTabId > 0
&& tabId === currentTabId
&& changeInfo.status === 'complete') {
reactivateCurrentTarget(tabId).catch(() => {});
}
});
}
+1219 -197
View File
File diff suppressed because it is too large Load Diff
+577 -150
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
(() => {
const systemTheme = window.matchMedia('(prefers-color-scheme: light)');
let mode = 'system';
let palette = 'eucalyptus';
const normalize = value => ['system', 'dark', 'light'].includes(value) ? value : 'system';
const normalizePalette = value => ['eucalyptus', 'cyber', 'graphite'].includes(value) ? value : 'eucalyptus';
const apply = value => {
mode = normalize(value);
const light = mode === 'light' || (mode === 'system' && systemTheme.matches);
document.documentElement.classList.toggle('theme-light', light);
document.documentElement.dataset.theme = light ? 'light' : 'dark';
document.documentElement.style.colorScheme = light ? 'light' : 'dark';
};
const applyPalette = value => {
palette = normalizePalette(value);
document.documentElement.dataset.palette = palette;
};
apply('system');
applyPalette('eucalyptus');
window.koalaTheme = {
getMode: () => mode,
setMode: apply,
getPalette: () => palette,
setPalette: applyPalette
};
systemTheme.addEventListener('change', () => {
if (mode === 'system') apply('system');
});
if (!globalThis.chrome?.storage?.local) return;
chrome.storage.local.get(['themeMode', 'themePalette'], data => {
apply(data.themeMode);
applyPalette(data.themePalette);
});
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== 'local') return;
if (changes.themeMode) apply(changes.themeMode.newValue);
if (changes.themePalette) applyPalette(changes.themePalette.newValue);
});
})();
+1 -1
View File
@@ -19,7 +19,7 @@ export function normalizeSendTabTitle(sendTabTitle, legacyMode = TITLE_PRIVACY_M
export function normalizeTabTitle(title) {
if (typeof title !== 'string') return null;
const normalized = title.replace(/^\s*(?:\(\d{1,2}\)|\[\d{1,2}\])\s+/, '').trim();
const normalized = title.replace(/^\s*(?:\(\d+\+?\)|\[\d+\+?\])\s+/, '').trim();
return normalized.length > 0 ? normalized : null;
}
+2126 -611
View File
File diff suppressed because it is too large Load Diff
+18 -4
View File
@@ -1,25 +1,39 @@
{
"name": "koalasync",
"version": "2.5.2",
"version": "2.6.4",
"description": "KoalaSync Build Scripts",
"private": true,
"type": "module",
"engines": {
"node": ">=20.9.0"
},
"scripts": {
"build:extension": "node scripts/build-extension.cjs",
"indexnow": "node website/submit-indexnow.cjs",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"subset-flags": "node website/tools/subset-flag-font.mjs",
"test": "npm run verify",
"test:unit": "vitest run",
"verify": "node scripts/verify-release.mjs"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@vitest/coverage-v8": "^4.1.9",
"archiver": "^7.0.1",
"addons-linter": "^10.9.0",
"archiver": "^8.0.0",
"esbuild": "^0.28.1",
"eslint": "^10.4.0",
"eslint-plugin-no-unsanitized": "^4.1.5",
"fs-extra": "^11.2.0",
"sharp": "^0.34.5",
"svgo": "^4.0.1",
"htmlnano": "^3.4.0",
"sharp": "^0.35.3",
"svgo": "^4.0.2",
"vitest": "^4.1.9"
},
"overrides": {
"brace-expansion": "5.0.8",
"fast-uri": "3.1.4",
"postcss": "8.5.18"
}
}
+78 -18
View File
@@ -1,33 +1,93 @@
# Development Scripts
This directory contains utility scripts for the KoalaSync development workflow.
This directory contains build, synchronization, and verification scripts for the KoalaSync workspace. Run all commands from the repository root unless a script says otherwise.
## Main Commands
```bash
npm run build:extension
npm run verify
npm run lint
npm run test:unit
```
- `npm run build:extension` runs `scripts/build-extension.cjs`.
- `npm run verify` runs the full release-safety suite in `scripts/verify-release.mjs`.
- `npm run lint` runs ESLint across the repository.
- `npm run test:unit` runs Vitest tests.
## build-extension.cjs
The primary build tool for KoalaSync. This Node.js script automates two critical tasks:
The primary extension build tool performs these steps:
1. **Protocol Synchronization**: Copies the "Single Source of Truth" constants (`shared/constants.js`) and the domain blacklist (`shared/blacklist.js`) from the root `/shared` directory into the `extension/shared/` directory.
2. **Content Script Injection**: Injects protocol constants directly into `content.js` using marker-based replacement. This is necessary because `content.js` executes synchronously and cannot use ES module imports.
3. **Artifact Generation**: Compiles the extension into browser-specific bundles for Chrome and Firefox, located in the `dist/` directory.
### Usage
From the **repository root**, run:
1. Recreates `dist/`.
2. Copies `shared/constants.js`, `shared/blacklist.js`, `shared/names.js`, and `shared/README.md` into `extension/shared/`.
3. Injects synchronous shared values into `content.js`.
4. Injects browser-specific uninstall URL constants into `background.js`.
5. Injects the build timestamp into `popup.html`.
6. Generates browser-specific manifests for Chrome and Firefox.
7. Creates `dist/koalasync-chrome.zip` and `dist/koalasync-firefox.zip`.
Usage:
```bash
node scripts/build-extension.cjs
# or
npm run build:extension
```
### Why this script exists
KoalaSync uses **Vanilla JS** in the extension to maintain zero runtime dependencies and maximum privacy. Since we don't use a bundler (like Webpack or Vite) inside the extension, this script serves as our lightweight "pre-build" step to ensure that the protocol constants remain synchronized between the extension and the relay server.
## Injection Markers
### Content Injection Markers
The build script uses marker comments/placeholders. Missing markers are a hard build failure so release artifacts cannot silently contain stale protocol data.
The build script uses marker comments in `content.js` to locate and replace constant blocks:
| Target | Marker / Placeholder | Injected Value | Source |
|:---|:---|:---|:---|
| `content.js` | `SHARED_EVENTS_INJECT_START` / `END` | Full `EVENTS` object | `shared/constants.js` |
| `content.js` | `SHARED_HEARTBEAT_INJECT_START` / `END` | `HEARTBEAT_INTERVAL` | `shared/constants.js` |
| `content.js` | `SHARED_EPISODE_UTILS_INJECT_START` / `END` | `extractEpisodeId()` and `sameEpisode()` | `extension/episode-utils.js` |
| `background.js` | `UNINSTALL_URL_INJECT_START` / `END` | Uninstall URL and browser type | `scripts/build-extension.cjs` |
| `popup.html` | `__BUILD_TIMESTAMP__` | UTC build timestamp | Build time |
| Marker Pair | Injected Value | Source |
|:---|:---|:---|
| `SHARED_EVENTS_INJECT_START` / `END` | The full `EVENTS` object | `shared/constants.js` |
| `SHARED_HEARTBEAT_INJECT_START` / `END` | `HEARTBEAT_INTERVAL` value | `shared/constants.js` |
Do not remove or rename these markers without updating the build script and tests.
> **⚠️ Do NOT remove or modify these marker comments in `content.js`.** They are required for the build script to function. If the markers are missing, the build will fail with a clear error message.
## Verification Suite
`scripts/verify-release.mjs` is the best single command before release, PR review, or handoff:
```bash
npm run verify
```
It currently runs:
- Vitest unit tests.
- Server ops, route, WebSocket, and rate-limiter checks.
- Episode parser, title privacy, audio settings, popup cooldown, names, and content-video-finder checks.
- JavaScript syntax checks for server and extension entry points.
- Extension and website locale coverage checks.
- ESLint.
- Production `npm audit` checks for root and server dependencies.
- Extension build and website build.
## Focused Scripts
| Script | Purpose |
|:---|:---|
| `test-server-ops.mjs` | Health payload and admin metrics helpers |
| `test-server-routes.mjs` | HTTP health routes, caching, and admin metrics access |
| `test-server-ws.mjs` | Socket.IO relay integration, including host-control behavior |
| `test-rate-limiter.mjs` | Rate-limiter map and cooldown behavior |
| `test-episode-utils.mjs` | Episode-title extraction and comparison |
| `test-title-privacy.mjs` | Tab/media title privacy sanitization |
| `test-audio-settings.mjs` | Audio settings defaults and normalization |
| `test-popup-refresh-cooldown.mjs` | Popup refresh throttling behavior |
| `test-names.mjs` | Generated username format and coverage |
| `test-content-video-finder.cjs` | Content-script video selection helpers |
| `test-locales.cjs` | Extension runtime and browser-store locale coverage |
| `test-website-locales.mjs` | Website locale coverage |
## Do Not Break
- Keep scripts runnable from the repository root.
- Keep build output under `dist/` and generated website output under `website/www/`.
- Keep shared protocol sync automated; do not add manual copy steps.
- Treat warnings in verification scripts as release blockers unless the script explicitly documents them as informational.
+85 -38
View File
@@ -1,11 +1,63 @@
const fs = require('fs');
const path = require('path');
const archiver = require('archiver');
const rootDir = path.join(__dirname, '..');
const extDir = path.join(rootDir, 'extension');
const distDir = path.join(rootDir, 'dist');
const baseManifestPath = path.join(extDir, 'manifest.base.json');
const localesDir = path.join(extDir, '_locales');
const requiredAppName = 'Watch Party - KoalaSync';
const maxAppNameLength = 75;
const maxAppDescLength = 132;
function countUnicodeCharacters(value) {
return Array.from(value).length;
}
function validateExtensionMetadata() {
const localeDirectories = fs.readdirSync(localesDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
for (const locale of localeDirectories) {
const messagesPath = path.join(localesDir, locale, 'messages.json');
let messages;
try {
messages = JSON.parse(fs.readFileSync(messagesPath, 'utf8'));
} catch (error) {
throw new Error(`CRITICAL: Invalid locale JSON at _locales/${locale}/messages.json: ${error.message}`);
}
if (!messages || typeof messages !== 'object' || Array.isArray(messages)) {
throw new Error(`CRITICAL: _locales/${locale}/messages.json must contain a JSON object`);
}
for (const key of ['appName', 'appDesc']) {
if (!messages[key] || typeof messages[key].message !== 'string' || messages[key].message.trim().length === 0) {
throw new Error(`CRITICAL: _locales/${locale}/messages.json is missing a valid ${key}.message`);
}
}
const appNameLength = countUnicodeCharacters(messages.appName.message);
const appDescLength = countUnicodeCharacters(messages.appDesc.message);
if (appNameLength > maxAppNameLength) {
throw new Error(`CRITICAL: _locales/${locale}/messages.json appName.message is ${appNameLength} characters; maximum is ${maxAppNameLength}`);
}
if (messages.appName.message !== requiredAppName) {
throw new Error(`CRITICAL: _locales/${locale}/messages.json appName.message must exactly equal "${requiredAppName}"`);
}
if (appDescLength > maxAppDescLength) {
throw new Error(`CRITICAL: _locales/${locale}/messages.json appDesc.message is ${appDescLength} characters; maximum is ${maxAppDescLength}`);
}
}
console.log(`✓ Validated localized extension metadata for ${localeDirectories.length} locales`);
}
validateExtensionMetadata();
// Ensure dist directory exists
if (fs.existsSync(distDir)) {
@@ -22,7 +74,7 @@ if (!fs.existsSync(extSharedDir)) {
fs.mkdirSync(extSharedDir, { recursive: true });
}
const sharedFiles = ['constants.js', 'blacklist.js', 'names.js', 'README.md'];
const sharedFiles = ['constants.js', 'blacklist.js', 'names.js', 'invite-links.js', 'README.md'];
for (const file of sharedFiles) {
const src = path.join(masterSharedDir, file);
const dest = path.join(extSharedDir, file);
@@ -31,11 +83,18 @@ 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'));
function replaceRequiredBlock(content, pattern, replacement, description) {
if (!pattern.test(content)) {
throw new Error(`CRITICAL: ${description} markers not found. Aborting build to prevent stale artifacts.`);
}
return content.replace(pattern, replacement);
}
// Helper to copy files, ignoring manifest.json and manifest.base.json
// Also injects shared constants into content.js
function copyExtensionFiles(targetDir, browserName) {
@@ -79,11 +138,7 @@ function copyExtensionFiles(targetDir, browserName) {
const ePattern = new RegExp(`${eStart}[\\s\\S]+?${eEnd}`);
const eRep = `${eStart}\n // This block is automatically updated by /scripts/build-extension.cjs\n const EVENTS = ${eventsObject};\n ${eEnd}`;
if (ePattern.test(content)) {
content = content.replace(ePattern, eRep);
} else {
console.warn('⚠️ WARNING: Event markers not found in content.js');
}
content = replaceRequiredBlock(content, ePattern, eRep, 'Event injection');
// 2. Inject Heartbeat
const hStart = '// --- SHARED_HEARTBEAT_INJECT_START ---';
@@ -91,30 +146,23 @@ function copyExtensionFiles(targetDir, browserName) {
const hPattern = new RegExp(`${hStart}[\\s\\S]+?${hEnd}`);
const hRep = `${hStart}\n const HEARTBEAT_INTERVAL_VAL = ${heartbeatVal};\n ${hEnd}`;
if (hPattern.test(content)) {
content = content.replace(hPattern, hRep);
} else {
console.warn('⚠️ WARNING: Heartbeat markers not found in content.js');
}
content = replaceRequiredBlock(content, hPattern, hRep, 'Heartbeat injection');
// 3. Inject Episode Utils
const euStart = '// --- SHARED_EPISODE_UTILS_INJECT_START ---';
const euEnd = '// --- SHARED_EPISODE_UTILS_INJECT_END ---';
const euPattern = new RegExp(euStart.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[\\s\\S]+?' + euEnd.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const euPath = path.join(rootDir, 'extension', 'episode-utils.js');
if (fs.existsSync(euPath)) {
const euContent = fs.readFileSync(euPath, 'utf8');
const stripped = euContent
.replace(/^\/\*\*[\s\S]*?\*\/\s*/m, '')
.replace(/export function /g, 'function ')
.trim();
const euRep = `${euStart}\n // This block is automatically updated by /scripts/build-extension.cjs\n${stripped.split('\n').map(l => ' ' + l).join('\n')}\n ${euEnd}`;
if (euPattern.test(content)) {
content = content.replace(euPattern, euRep);
} else {
console.warn('⚠ WARNING: Episode utils markers not found in content.js');
}
if (!fs.existsSync(euPath)) {
throw new Error(`CRITICAL: Episode utils source missing: ${euPath}. Aborting build.`);
}
const euContent = fs.readFileSync(euPath, 'utf8');
const stripped = euContent
.replace(/^\/\*\*[\s\S]*?\*\/\s*/m, '')
.replace(/export function /g, 'function ')
.trim();
const euRep = `${euStart}\n // This block is automatically updated by /scripts/build-extension.cjs\n${stripped.split('\n').map(l => ' ' + l).join('\n')}\n ${euEnd}`;
content = replaceRequiredBlock(content, euPattern, euRep, 'Episode utils injection');
fs.writeFileSync(destPath, content);
console.log('✓ Injected shared constants into content.js');
@@ -132,17 +180,16 @@ function copyExtensionFiles(targetDir, browserName) {
uRep += ` const BROWSER_TYPE = "${browserName}";\n`;
uRep += ` ${uEnd}`;
if (uPattern.test(content)) {
content = content.replace(uPattern, uRep);
} else {
console.warn('⚠️ WARNING: Uninstall URL markers not found in background.js');
}
content = replaceRequiredBlock(content, uPattern, uRep, 'Uninstall URL injection');
fs.writeFileSync(destPath, content);
console.log(`✓ Injected uninstall URL constants for ${browserName} into background.js`);
} else if (item === 'popup.html') {
let content = fs.readFileSync(srcPath, 'utf8');
const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
if (!content.includes('__BUILD_TIMESTAMP__')) {
throw new Error('CRITICAL: Build timestamp placeholder not found in popup.html. Aborting build.');
}
content = content.replace(/__BUILD_TIMESTAMP__/g, timestamp);
fs.writeFileSync(destPath, content);
console.log(`✓ Injected build timestamp into popup.html: ${timestamp}`);
@@ -154,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);
});
}
+180
View File
@@ -0,0 +1,180 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { cwd } from 'node:process';
import {
HOST_ACCESS_REQUIRED_STATUS,
addTabHostAccessRequest,
describeTabUrl,
inspectTabHostAccess,
isHostAccessError,
normalizeTabId,
removeTabHostAccessRequest,
requestOriginPermission
} from '../extension/host-access.js';
assert.equal(HOST_ACCESS_REQUIRED_STATUS, 'host_permission_required');
assert.equal(normalizeTabId(null), null);
assert.equal(normalizeTabId(undefined), null);
assert.equal(normalizeTabId(''), null);
assert.equal(normalizeTabId(0), null);
assert.equal(normalizeTabId('42'), 42);
assert.equal(normalizeTabId(true), null);
assert.equal(normalizeTabId([42]), null);
assert.equal(normalizeTabId('42.5'), null);
assert.equal(normalizeTabId(' 42 '), 42);
assert.equal(normalizeTabId(Number.MAX_SAFE_INTEGER + 1), null);
assert.deepEqual(describeTabUrl('https://emby.example:8443/web/index.html'), {
url: 'https://emby.example:8443/web/index.html',
host: 'emby.example:8443',
originPattern: 'https://emby.example:8443/*'
});
assert.deepEqual(describeTabUrl('http://localhost:8096/web/'), {
url: 'http://localhost:8096/web/',
host: 'localhost:8096',
originPattern: 'http://localhost:8096/*'
});
assert.deepEqual(describeTabUrl('http://localhost:8096/web/', { includePort: false }), {
url: 'http://localhost:8096/web/',
host: 'localhost:8096',
originPattern: 'http://localhost/*'
});
assert.equal(describeTabUrl('chrome://extensions/'), null);
assert.equal(describeTabUrl('not a url'), null);
let containsRequest = null;
const deniedChrome = {
tabs: {
get: async tabId => ({ id: tabId, url: 'https://video.example/watch' })
},
permissions: {
contains: async request => {
containsRequest = request;
return false;
}
}
};
const access = await inspectTabHostAccess(deniedChrome, 42);
assert.equal(access.granted, false);
assert.equal(access.host, 'video.example');
assert.deepEqual(containsRequest, { origins: ['https://video.example/*'] });
let firefoxContainsRequest = null;
const firefoxChrome = {
runtime: { getBrowserInfo: async () => ({ name: 'Firefox' }) },
tabs: {
get: async tabId => ({
id: tabId,
url: 'http://localhost:8096/web/',
pendingUrl: 'https://different.example/loading'
})
},
permissions: {
contains: async request => {
firefoxContainsRequest = request;
return false;
}
}
};
const firefoxAccess = await inspectTabHostAccess(firefoxChrome, 42);
assert.equal(firefoxAccess.host, 'localhost:8096');
assert.equal(firefoxAccess.originPattern, 'http://localhost/*');
assert.deepEqual(firefoxContainsRequest, { origins: ['http://localhost/*'] });
const unknownPermissionChrome = {
runtime: {},
tabs: {
get: async tabId => ({ id: tabId, url: 'https://video.example/watch' })
},
permissions: {
contains: (_request, callback) => { callback(undefined); }
}
};
assert.equal((await inspectTabHostAccess(unknownPermissionChrome, 42)).granted, null);
let requestedTabId = null;
const requestChrome = {
permissions: {
addHostAccessRequest: async request => { requestedTabId = request; }
}
};
assert.equal(await addTabHostAccessRequest(requestChrome, 42, 'https://video.example/*'), true);
assert.deepEqual(requestedTabId, { tabId: 42, pattern: 'https://video.example/*' });
assert.equal(await addTabHostAccessRequest({ permissions: {} }, 42), false);
let removedTabId = null;
const removeRequestChrome = {
permissions: {
removeHostAccessRequest: async request => { removedTabId = request; }
}
};
assert.equal(await removeTabHostAccessRequest(removeRequestChrome, 42, 'https://video.example/*'), true);
assert.deepEqual(removedTabId, { tabId: 42, pattern: 'https://video.example/*' });
assert.equal(await removeTabHostAccessRequest({ permissions: {} }, 42), false);
assert.equal(isHostAccessError(new Error('Missing host permission for the tab')), true);
assert.equal(isHostAccessError(new Error('No tab with id: 42')), false);
const callbackPermissionChrome = {
runtime: {},
permissions: {
request: (_request, callback) => { callback(true); }
}
};
assert.equal(await requestOriginPermission(callbackPermissionChrome, 'https://video.example/*'), true);
assert.equal(await requestOriginPermission({ permissions: {} }, 'https://video.example/*'), null);
const background = fs.readFileSync(path.join(cwd(), 'extension', 'background.js'), 'utf8');
const popup = fs.readFileSync(path.join(cwd(), 'extension', 'popup.js'), 'utf8');
const popupHtml = fs.readFileSync(path.join(cwd(), 'extension', 'popup.html'), 'utf8');
const tabManager = fs.readFileSync(path.join(cwd(), 'extension', 'modules', 'tab-manager.js'), 'utf8');
assert.match(background, /await activateTargetTab\(message\.tabId, message\.tabTitle\)/,
'SET_TARGET_TAB must await successful activation before acknowledging it');
assert.match(background, /addTabHostAccessRequest\(chrome, tabId, access\.originPattern\)/,
'failed injection must register Chrome host-access request');
assert.match(background, /retryPendingTarget\(\)/,
'pending target must resume after the user grants access');
assert.match(background, /activationGeneration !== targetActivationGeneration/,
'stale concurrent tab activations must not overwrite the newest selection');
assert.match(background, /pendingTargetRequestId/,
'pending access recovery must use an identity token');
assert.match(background, /addedOrigins\.includes\(pending\.originPattern\)/,
'unrelated permission grants must not activate a pending target');
assert.match(background, /isCurrentTargetIdentity\(tabId, targetGeneration\)/,
'stale content-routing retries must not reactivate an old target');
assert.match(background, /message\.expectedTabId/,
'popup playback events must be rejected after their target changes');
assert.match(background, /completeForceSyncBeforeTargetChange\(selectedTabId\)/,
'a target switch must finish an in-flight force sync on the old target');
assert.match(background, /FORCE_SYNC_ACK'[\s\S]*ignored_unselected_tab/,
'stale content scripts must not acknowledge force sync for a new target');
const activateTargetBody = background.slice(
background.indexOf('async function activateTargetTab'),
background.indexOf('async function retryPendingTarget')
);
assert.ok(
activateTargetBody.indexOf('await injectContentScript') < activateTargetBody.indexOf('currentTabId = selectedTabId'),
'a tab must not become current until its content script injection succeeds'
);
assert.match(background, /removeTabHostAccessRequest\([\s\S]*pendingTabId/,
'clearing a pending target must also clear Chrome toolbar access requests');
assert.match(popup, /response\?\.status === 'host_permission_required'/,
'popup must render the structured host-access failure');
assert.match(popup, /requestOriginPermission\(chrome, requestedOriginPattern\)/,
'retry button must request withheld host access directly');
assert.match(popup, /expectedCurrentTabId: tabId/,
'manual reinjection must be tied to the selected target identity');
assert.match(popup, /expectedTabId: tabId/,
'force sync must be tied to the tab whose time was sampled');
assert.doesNotMatch(tabManager, /injectContentScript/,
'tab reload recovery must use the guarded background activation path');
assert.equal(
(background.match(/tabs\.onRemoved\.addListener/g) || []).length
+ (tabManager.match(/tabs\.onRemoved\.addListener/g) || []).length,
1,
'target-tab closure must have exactly one state owner'
);
assert.match(popupHtml, /id="siteAccessNotice"/,
'popup must contain a persistent site-access notice');
console.log('host access recovery tests passed');
+69 -1
View File
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import { Buffer } from 'node:buffer';
import http from 'node:http';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
@@ -22,7 +23,10 @@ async function c() {
function s(ws, evt, d={}) { ws.send(`42${JSON.stringify([evt,d])}`); }
function a(ws) { if (ws._m.length) { const r=ws._m.shift(); return r.startsWith('42') ? JSON.parse(r.substring(2)) : r; } return new Promise((resolve, reject) => { const t=setTimeout(()=>reject(Error('timeout')),3e3); const h=(d)=>{clearTimeout(t);ws.removeListener('message',h);const r=d.toString();resolve(r.startsWith('42')?JSON.parse(r.substring(2)):r);};ws.on('message',h);}); }
async function w(ws, evt, ms=3000) { const st=Date.now(); while(Date.now()-st<ms) { for(let i=0;i<ws._m.length;i++){const r=ws._m[i];ws._m.splice(i,1);if(r.startsWith('42')){try{const[e]=JSON.parse(r.substring(2));if(e===evt)return e}catch{/* skip */}}} await new Promise(r=>setTimeout(r,50));} throw Error(`wait:${evt}`); }
async function j(ws, rid, pid, pw=null) { s(ws,'join_room',{roomId:rid,peerId:pid,password:pw,protocolVersion:'1.0.0'}); assert.equal((await a(ws))[0],'room_data'); }
async function j(ws, rid, pid, pw=null, clientCapabilities=undefined) {
s(ws,'join_room',{roomId:rid,peerId:pid,password:pw,protocolVersion:'1.0.0',clientCapabilities});
assert.equal((await a(ws))[0],'room_data');
}
function close() { clients.forEach(w=>{try{w.close()}catch{/* ignore */}}); clients.length=0; }
// Test suite opens >10 connections/min — clear the IP connection counter so the
// connection rate limiter doesn't mask test failures (test-only, never at runtime).
@@ -70,6 +74,70 @@ try {
assert.equal(capEv, 'room_data');
assert.ok(Array.isArray(capData.capabilities) && capData.capabilities.includes('host-control'),
'ROOM_DATA advertises the host-control capability');
assert.ok(capData.capabilities.includes('chat'), 'ROOM_DATA advertises the chat capability');
assert.ok(capData.capabilities.includes('chat-v1'), 'ROOM_DATA advertises the versioned chat capability');
assert.equal(capData.chatHistory, undefined, 'ROOM_DATA never contains chat history');
close();
resetConnectionRate();
// --- Encrypted chat is a live-only canonical relay ---
const chatRoom = 'chat-'+Date.now();
const chat1 = await c(), chat2 = await c();
await j(chat1, chatRoom, 'alice', null, ['chat-v1']);
await j(chat2, chatRoom, 'bob', null, ['chat-v1']);
chat1._m.length = chat2._m.length = 0;
const ciphertext = Buffer.alloc(64, 7).toString('base64url');
s(chat1, 'chat_message', {
ciphertext,
id: 'spoofed-id', senderId: 'mallory', timestamp: 1, text: 'plaintext'
});
const [chat1Event, chat1Data] = await a(chat1);
const [chat2Event, chat2Data] = await a(chat2);
assert.equal(chat1Event, 'chat_message', 'sender receives canonical chat envelope');
assert.equal(chat2Event, 'chat_message', 'peer receives canonical chat envelope');
assert.deepEqual(chat2Data, chat1Data, 'all peers receive the same canonical envelope');
assert.equal(chat1Data.senderId, 'alice', 'relay stamps senderId');
assert.equal(chat1Data.ciphertext, ciphertext, 'relay preserves ciphertext');
assert.equal(chat1Data.text, undefined, 'relay drops plaintext fields');
assert.notEqual(chat1Data.id, 'spoofed-id', 'relay replaces client IDs');
assert.ok(Number.isFinite(chat1Data.timestamp) && chat1Data.timestamp > 1, 'relay stamps timestamp');
const late = await c();
s(late, 'join_room', { roomId: chatRoom, peerId: 'late', protocolVersion: '1.0.0' });
const [lateEvent, lateRoomData] = await a(late);
assert.equal(lateEvent, 'room_data');
assert.equal(lateRoomData.chatHistory, undefined, 'late joiner gets no chat backlog');
assert.equal(late._m.some(raw => raw.includes('chat_message')), false, 'late joiner receives no old message');
close();
resetConnectionRate();
// --- Mixed rollout: old non-chat clients never receive unknown chat events ---
const mixedChatRoom = 'chat-mixed-'+Date.now();
const newChat = await c(), oldNoChat = await c();
await j(newChat, mixedChatRoom, 'new-chat', null, ['chat-v1', 'chat-v1', 'future-chat', 42]);
await j(oldNoChat, mixedChatRoom, 'old-no-chat', null, ['chat-v2']);
newChat._m.length = oldNoChat._m.length = 0;
s(newChat, 'chat_message', { ciphertext });
await w(newChat, 'chat_message');
let oldReceivedChat = false;
try { await w(oldNoChat, 'chat_message', 500); oldReceivedChat = true; } catch { /* expected */ }
assert.equal(oldReceivedChat, false, 'old client receives no unknown chat event');
// The same old client remains fully usable for the pre-chat protocol.
newChat._m.length = oldNoChat._m.length = 0;
s(oldNoChat, 'play', { currentTime: 12 });
await w(newChat, 'play');
s(newChat, 'pause', { currentTime: 13 });
await w(oldNoChat, 'pause');
// First chat beta compatibility: sending a valid chat frame dynamically
// proves receive support even when that beta omitted clientCapabilities.
const firstBeta = await c();
await j(firstBeta, mixedChatRoom, 'first-beta');
firstBeta._m.length = newChat._m.length = 0;
s(firstBeta, 'chat_message', { ciphertext });
await w(firstBeta, 'chat_message');
await w(newChat, 'chat_message');
close();
resetConnectionRate();
+6 -1
View File
@@ -18,7 +18,12 @@ assert.equal(normalizeSendTabTitle(true, TITLE_PRIVACY_MODES.HIDDEN), true);
assert.equal(normalizeSendTabTitle(false, TITLE_PRIVACY_MODES.FULL), false);
assert.equal(normalizeTabTitle('(12) Testvideo - YouTube'), 'Testvideo - YouTube');
assert.equal(normalizeTabTitle('[7] Testvideo - YouTube'), 'Testvideo - YouTube');
assert.equal(normalizeTabTitle('(500) Days of Summer'), '(500) Days of Summer');
assert.equal(normalizeTabTitle('(99+) Testvideo - YouTube'), 'Testvideo - YouTube');
assert.equal(normalizeTabTitle('(999+) Testvideo - YouTube'), 'Testvideo - YouTube');
assert.equal(normalizeTabTitle('[999+] Testvideo - YouTube'), 'Testvideo - YouTube');
assert.equal(normalizeTabTitle('(500) Days of Summer'), 'Days of Summer');
assert.equal(normalizeTabTitle('(101) Days of Summer'), 'Days of Summer');
assert.equal(normalizeTabTitle('[101] Days of Summer'), 'Days of Summer');
assert.equal(normalizeTabTitle(' '), null);
assert.equal(sanitizeTabTitle('Private Tab', true), 'Private Tab');
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const template = fs.readFileSync(path.join(repoRoot, 'website', 'template.html'), 'utf8');
const app = fs.readFileSync(path.join(repoRoot, 'website', 'app.js'), 'utf8');
const langInit = fs.readFileSync(path.join(repoRoot, 'website', 'lang-init.js'), 'utf8');
const demoCss = fs.readFileSync(path.join(repoRoot, 'website', 'styles', 'demo.css'), 'utf8');
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);
if (mockupStart === -1 || mockupEnd === -1) {
throw new Error('Could not locate the extension mockup in website/template.html');
}
const mockup = template.slice(mockupStart, mockupEnd);
const themeSensitiveControls = [
['video selector', /<select[^>]+id="demo-video-select"[^>]+>/],
['sync target', /<div[^>]+class="mock-input"[^>]+title="Choose sync target"[^>]*>/],
['WebSocket status', /<span style="[^"]*flex:1;[^"]*">\s*\{\{MOCK_27\}\}/]
];
for (const [label, pattern] of themeSensitiveControls) {
const match = mockup.match(pattern);
if (!match) throw new Error(`Could not locate ${label} in the extension mockup`);
if (!/color:\s*var\(--text\)/.test(match[0])) {
throw new Error(`${label} must use var(--text) so it remains readable in both themes`);
}
}
const landingStylesheets = [...template.matchAll(/<link\b[^>]*\brel="stylesheet"[^>]*\bhref="\{\{ASSET_PATH\}\}landing[^">]*"[^>]*>/g)];
if (landingStylesheets.length !== 1) {
throw new Error(`Landing must load exactly one render-blocking stylesheet; found ${landingStylesheets.length}`);
}
const llmsLinks = [...template.matchAll(/<link\b[^>]*\brel="alternate"[^>]*\btype="text\/markdown"[^>]*\bhref="https:\/\/sync\.koalastuff\.net\/llms\.txt"[^>]*>/g)];
if (llmsLinks.length !== 1) {
throw new Error(`Landing head must link exactly one canonical llms.txt document; found ${llmsLinks.length}`);
}
const requiredLlmsSections = [
'## Quick fit assessment',
'## How it works',
'## Privacy and security',
'## Supported environments',
'## Install KoalaSync',
'## Technical information'
];
for (const section of requiredLlmsSections) {
if (!llmsText.includes(section)) throw new Error(`llms.txt is missing ${section}`);
}
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:' &&
url.hostname === 'syncserver.koalastuff.net' &&
url.port === '' &&
url.pathname === '/' &&
url.search === '' &&
url.hash === ''
));
if (!hasCanonicalPublicRelay || !llmsText.includes('internally on port 3000')) {
throw new Error('llms.txt must distinguish the public TLS relay URL from the internal self-hosting port');
}
const selfHostingStart = template.indexOf('<div id="pane-docker"');
const selfHostingEnd = template.indexOf('</section>', selfHostingStart);
if (selfHostingStart === -1 || selfHostingEnd === -1) {
throw new Error('Could not locate the self-hosting examples in website/template.html');
}
const selfHostingExamples = template.slice(selfHostingStart, selfHostingEnd);
const requiredSelfHostingValues = [
'SERVER_SALT:',
'REPLACE_WITH_OPENSSL_RAND_BASE64_32_OUTPUT',
'ADMIN_METRICS_TOKEN:',
'DEBUG_LOGGING:',
'127.0.0.1:3000:3000'
];
for (const value of requiredSelfHostingValues) {
if (!selfHostingExamples.includes(value)) {
throw new Error(`Website self-hosting compose example is missing ${value}`);
}
}
if ((selfHostingExamples.match(/localhost:3000/g) || []).length !== 2 || selfHostingExamples.includes('KoalaSync:3000')) {
throw new Error('Both Caddy examples must match the compose loopback port binding');
}
const landingStylesheet = landingStylesheets[0][0];
if (!/href="\{\{ASSET_PATH\}\}landing\.min\.css"/.test(landingStylesheet)) {
throw new Error('Landing stylesheet must use the landing.min.css build placeholder');
}
if (/\bmedia=|data-(?:landing-)?deferred/.test(landingStylesheet)) {
throw new Error('Landing stylesheet must not be activated after first paint');
}
const runtimeCssActivation = [
['app.js', app],
['lang-init.js', langInit]
];
for (const [file, source] of runtimeCssActivation) {
if (/createElement\(['"]link['"]\)|requestIdleCallback|data-landing-deferred|data-deferred-css|\.media\s*=\s*['"]all['"]/.test(source)) {
throw new Error(`${file} must not dynamically load or activate structural CSS`);
}
}
// Film birds were removed entirely (commit 16cb138); guard against remnants
if (/film-hero-bird|film-bird-wing|film-birds/.test(template) || /filmBirdGlide|filmWingFlap/.test(demoCss)) {
throw new Error('Film birds were removed from the demo mockups; no bird markup or animations may remain');
}
const gettingStartedLightSurfaces = [
'step-illustration-1',
'step-illustration-2',
'step-illustration-3',
'illus-popup-card',
'illus-player-card',
'popup-select-mock'
];
for (const className of gettingStartedLightSurfaces) {
const pattern = new RegExp(`html\\.theme-light[^{]*\\.${className}[^}]*\\{`);
if (!pattern.test(landingPrimaryCss)) {
throw new Error(`Getting Started .${className} must define an explicit light-theme surface`);
}
}
const stepNumberLightRule = landingPrimaryCss.match(/html\.theme-light \.step-num\s*\{([^}]*)\}/)?.[1] || '';
if (!/color:\s*oklch\(/.test(stepNumberLightRule)
|| !/background:\s*none/.test(stepNumberLightRule)
|| !/-webkit-text-fill-color:\s*currentColor/.test(stepNumberLightRule)) {
throw new Error('Getting Started step numbers must use solid readable text in light mode');
}
if (!/class="footer-disclaimer"/.test(joinPage) || /footer-disclaimer[^>]*opacity:/.test(joinPage)) {
throw new Error('Join-page disclaimer must use the contrast-safe footer-disclaimer colors without opacity');
}
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 (!/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`);
}
}
if (/<h3\b/.test(alternativesIndex)
|| (alternativesIndex.match(/<h2\b/g) || []).length !== 6
|| (alternativesIndex.match(/<h2[^>]+color:\s*var\(--text\)/g) || []).length !== 6) {
throw new Error('Alternatives overview cards must use h2 headings directly below the page h1');
}
if (!/html\.theme-light \.guide-card\s*\{[^}]*background-color:\s*var\(--card-surface\)\s*!important/s.test(alternativesCss)) {
throw new Error('Alternatives cards must define a readable light-theme surface');
}
const reducedMotionRule = demoCss.match(/@media \(prefers-reduced-motion: reduce\)\s*\{\s*\.step-illustration-3 \.film-mtn-scroll,\s*\.step-illustration-3 \.film-mid-scroll,\s*\.step-illustration-3 \.film-fore-scroll\s*\{([^}]*)\}/)?.[1] || '';
if (!/animation:\s*none\s*!important/.test(reducedMotionRule)) {
throw new Error('Getting Started film layers must stop when reduced motion is requested');
}
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');
console.log('Getting Started step numbers stay readable in light mode');
console.log('Accessibility regressions stay fixed across legal, alternatives, and reduced-motion views');
+113
View File
@@ -0,0 +1,113 @@
/**
* KoalaSync Locale Translation Script
*
* This script is stored in `scripts/translate-locales-tool.cjs`
* so future LLM agents can easily reuse and adapt it to translate new keys.
*
* Usage:
* node scripts/translate-locales-tool.cjs
*/
const fs = require('fs');
const path = require('path');
const http = require('https');
const localesDir = path.join(__dirname, '../website/locales');
const enFile = path.join(localesDir, 'en.json');
const enLocale = JSON.parse(fs.readFileSync(enFile, 'utf8'));
// Keys starting with these prefixes need to be translated.
// Update this list when translating new pages or features.
const prefixes = ['ALT_W2G_', 'ALT_SCENER_', 'ALT_KOSMI_', 'ALT_TWOSEVEN_'];
const keysToTranslate = Object.keys(enLocale).filter(key => {
return prefixes.some(prefix => key.startsWith(prefix));
});
console.log(`Found ${keysToTranslate.length} keys to translate.`);
function translate(text, targetLang) {
return new Promise((resolve, reject) => {
// We use Google's free translation endpoint which is highly robust for standard text segment translations
const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=en&tl=${targetLang}&dt=t&q=${encodeURIComponent(text)}`;
http.get(url, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
const parsed = JSON.parse(data);
const translation = parsed[0].map(item => item[0]).join('');
resolve(translation);
} catch (_e) {
reject(new Error(`Failed to parse response for target ${targetLang}: ${data}`));
}
});
}).on('error', reject);
});
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
const fileToLangMap = {
'es.json': 'es',
'fr.json': 'fr',
'it.json': 'it',
'ja.json': 'ja',
'ko.json': 'ko',
'nl.json': 'nl',
'pl.json': 'pl',
'pt-BR.json': 'pt',
'pt.json': 'pt',
'ru.json': 'ru',
'tr.json': 'tr',
'uk.json': 'uk',
'zh.json': 'zh-CN'
};
async function run() {
const files = fs.readdirSync(localesDir).filter(f => f.endsWith('.json') && f !== 'en.json' && f !== 'de.json');
for (const file of files) {
const targetLang = fileToLangMap[file];
if (!targetLang) {
console.log(`Skipping unknown file mapping: ${file}`);
continue;
}
const filePath = path.join(localesDir, file);
const content = JSON.parse(fs.readFileSync(filePath, 'utf8'));
console.log(`\nTranslating ${file} (${targetLang})...`);
let translatedCount = 0;
for (const key of keysToTranslate) {
const englishValue = enLocale[key];
// Translate if missing or currently identical to English value (which means it's a fallback)
if (!content[key] || content[key] === englishValue) {
try {
const translatedValue = await translate(englishValue, targetLang);
content[key] = translatedValue;
translatedCount++;
console.log(` [${translatedCount}/${keysToTranslate.length}] Translated key: ${key}`);
await sleep(200); // 200ms delay to prevent rate limits
} catch (err) {
console.error(` Error translating key ${key} to ${targetLang}:`, err.message);
// Wait longer on error
await sleep(2000);
}
}
}
// Save back keeping exact same structure and format
fs.writeFileSync(filePath, JSON.stringify(content, null, 2) + '\n', 'utf8');
console.log(`Saved ${file} (${translatedCount} keys updated).`);
}
console.log('\nAll translations completed successfully!');
}
run().catch(err => {
console.error('Translation script failed:', err);
});
+50
View File
@@ -0,0 +1,50 @@
const fs = require('fs');
const path = require('path');
const localesDir = path.join(__dirname, '../website/locales');
const files = fs.readdirSync(localesDir).filter(f => f.endsWith('.json') && f !== 'en.json');
const brandTranslations = [
'dos siete', 'dos-siete', 'deux sept', 'deux-sept', 'zwei sieben', 'zwei-sieben',
'due sette', 'due-sette', 'dwa siedem', 'dwa-siedem', 'twee zeven', 'twee-zeven',
'iki yedi', 'iki-yedi', 'два семь', 'два-семь', '二七', '二 七', '이칠', '이 칠',
'koala sync', 'koala-sync', 'watch 2 gether', 'watch-2-gether', 'watch 2gether',
'watch-2gether', 'jelly fin', 'jelly-fin'
];
let failed = false;
for (const file of files) {
const filePath = path.join(localesDir, file);
const content = JSON.parse(fs.readFileSync(filePath, 'utf8'));
for (const [key, value] of Object.entries(content)) {
if (typeof value !== 'string') continue;
// Check brand translations (case-insensitive)
const lowerValue = value.toLowerCase();
for (const pattern of brandTranslations) {
if (lowerValue.includes(pattern)) {
console.error(`Error in ${file} [key: ${key}]: Found translated brand pattern "${pattern}" in value: "${value}"`);
failed = true;
}
}
// Check tag validation
const tags = ['strong', 'em', 'span', 'b', 'i', 'p', 'a'];
for (const tag of tags) {
const openCount = (value.match(new RegExp(`<${tag}\\b[^>]*>`, 'g')) || []).length;
const closeCount = (value.match(new RegExp(`</${tag}\\b[^>]*>`, 'g')) || []).length;
if (openCount !== closeCount) {
console.error(`Error in ${file} [key: ${key}]: Unbalanced tag <${tag}> (open: ${openCount}, close: ${closeCount}) in value: "${value}"`);
failed = true;
}
}
}
}
if (!failed) {
console.log("All brand names and HTML tags are perfectly valid and intact across all languages!");
} else {
process.exit(1);
}
+6
View File
@@ -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']],
['host access recovery', 'node', ['scripts/test-host-access.mjs']],
['server syntax index', 'node', ['-c', 'server/index.js']],
['server syntax ops', 'node', ['-c', 'server/ops.js']],
['server syntax rate-limiter', 'node', ['-c', 'server/rate-limiter.js']],
@@ -28,10 +29,15 @@ const checks = [
['background syntax', 'node', ['-c', 'extension/background.js']],
['locale coverage', 'node', ['scripts/test-locales.cjs']],
['website locale coverage', 'node', ['scripts/test-website-locales.mjs']],
['website theme coverage', 'node', ['scripts/test-website-theme.mjs']],
['lint', 'npm', ['run', 'lint']],
['root production audit', 'npm', ['audit', '--omit=dev']],
['server production audit', 'npm', ['audit', '--omit=dev'], { cwd: path.join(repoRoot, 'server') }],
['extension build', 'npm', ['run', 'build:extension']],
// Must run after the build: validates the exact artifact uploaded to AMO.
// --warnings-as-errors is required: addons-linter exits 0 on warnings, and AMO
// rejects on them, so without it this check would pass a rejectable build.
['AMO validation (firefox)', 'npx', ['addons-linter', '--warnings-as-errors', 'dist/koalasync-firefox.zip']],
['website build', 'node', ['website/build.cjs']]
];
+5
View File
@@ -2,6 +2,11 @@ PORT="3000"
MIN_VERSION="1.0.0"
MAX_ROOMS="1000"
MAX_PEERS_PER_ROOM="25"
# Required for production/self-hosting: unique random salt used for room-password hashes.
# Generate one with: openssl rand -base64 32
SERVER_SALT=""
# Optional: enables aggregate-only admin metrics on /health with Authorization: Bearer <token>
# Use a long random token, 32+ characters recommended.
ADMIN_METRICS_TOKEN=""
+27
View File
@@ -16,6 +16,11 @@ PORT="3000"
MAX_ROOMS="1000"
MAX_PEERS_PER_ROOM="25"
MIN_VERSION="1.0.0"
# Required for production/self-hosting: unique random salt used for room-password hashes.
# Generate one with: openssl rand -base64 32
SERVER_SALT=""
# Optional: enables aggregate-only admin metrics on /health with Authorization: Bearer <token>
# Use a long random token, 32+ characters recommended.
ADMIN_METRICS_TOKEN=""
@@ -24,6 +29,19 @@ ADMIN_METRICS_TOKEN=""
DEBUG_LOGGING="0"
```
### Room Password Salt
`SERVER_SALT` is used to HMAC room passwords before they are stored in RAM. The relay has a built-in fallback so development keeps working, but that fallback is public in the repository and the server logs a startup warning when it is used.
For every real self-hosted deployment, set a unique random value:
```bash
openssl rand -base64 32
```
Then add it to `server/.env` or your Compose environment:
```bash
SERVER_SALT=replace-with-a-long-random-salt
```
### Health & Metrics
`GET /` and `GET /health` are IP-rate-limited to 10 requests per minute per client IP. These health-style responses are cached server-side for 60 seconds and refreshed lazily on request. By default `/health` returns only basic service status, uptime, room count, connection count, and a timestamp.
@@ -63,11 +81,20 @@ npm install
npm start
```
### Verification
The server is covered by the root verification suite. From the repository root, run:
```bash
npm run verify
```
For focused server checks, see `scripts/test-server-ops.mjs`, `scripts/test-server-routes.mjs`, `scripts/test-server-ws.mjs`, and `scripts/test-rate-limiter.mjs`.
## Security
- **Rate Limiting**: IP-based connection limits and socket-based event limits.
- **Health Endpoint Throttle**: `GET /` and `GET /health` are limited to 10 requests per minute per IP, with 60-second lazy server-side response caching and stricter throttling for wrong admin bearer attempts.
- **Room Discovery Throttle**: Room-list refreshes are rate-limited server-side to one request every 10 seconds per socket.
- **Token Handshake**: Requires a valid token defined in the root `shared/constants.js`.
- **Password Hash Salt**: Set `SERVER_SALT` in every deployment so room-password hashes are not derived with the public fallback salt.
- **Single Source of Truth**: The server imports constants directly from the root `shared/` directory.
- **In-Memory**: Rooms are automatically pruned after 2 hours of inactivity.
- **Reverse Proxy Boundary**: The server trusts one reverse proxy hop for client IP detection. Keep the Node port private/firewalled so clients can only reach it through Caddy or another trusted proxy.
+24
View File
@@ -0,0 +1,24 @@
import { randomUUID } from 'node:crypto';
export const CHAT_CIPHERTEXT_MAX_BYTES = 2028;
export const CHAT_CIPHERTEXT_MIN_BYTES = 29;
export function normalizeChatCiphertext(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]+$/.test(value) || value.length % 4 === 1) return null;
const bytes = Buffer.from(value, 'base64url');
if (bytes.length < CHAT_CIPHERTEXT_MIN_BYTES || bytes.length > CHAT_CIPHERTEXT_MAX_BYTES) return null;
if (bytes.toString('base64url') !== value) return null;
return value;
}
export function createChatEnvelope(data, senderId, now = Date.now, createId = randomUUID) {
if (!data || typeof data !== 'object' || typeof senderId !== 'string' || !senderId) return null;
const ciphertext = normalizeChatCiphertext(data.ciphertext);
if (!ciphertext) return null;
return {
id: createId(),
senderId,
timestamp: now(),
ciphertext
};
}
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { Buffer } from 'node:buffer';
import {
CHAT_CIPHERTEXT_MAX_BYTES,
createChatEnvelope,
normalizeChatCiphertext
} from './chat.js';
const encoded = (length) => Buffer.alloc(length, 7).toString('base64url');
describe('encrypted chat relay envelopes', () => {
it('accepts canonical base64url within the encrypted message bounds', () => {
expect(normalizeChatCiphertext(encoded(29))).toBe(encoded(29));
expect(normalizeChatCiphertext(encoded(CHAT_CIPHERTEXT_MAX_BYTES))).toBe(encoded(CHAT_CIPHERTEXT_MAX_BYTES));
});
it('rejects malformed, padded, too-small, and oversized payloads', () => {
expect(normalizeChatCiphertext('<script>')).toBeNull();
expect(normalizeChatCiphertext(`${encoded(29)}=`)).toBeNull();
expect(normalizeChatCiphertext(encoded(28))).toBeNull();
expect(normalizeChatCiphertext(encoded(CHAT_CIPHERTEXT_MAX_BYTES + 1))).toBeNull();
expect(normalizeChatCiphertext('A')).toBeNull();
});
it('stamps authoritative metadata and keeps only ciphertext from the sender', () => {
const ciphertext = encoded(64);
const envelope = createChatEnvelope({
ciphertext,
id: 'spoofed',
senderId: 'mallory',
timestamp: 1,
text: 'plaintext must not survive'
}, 'alice', () => 1234, () => 'server-id');
expect(envelope).toEqual({
id: 'server-id',
senderId: 'alice',
timestamp: 1234,
ciphertext
});
expect(JSON.stringify(envelope)).not.toContain('plaintext');
});
});
+63 -1
View File
@@ -5,6 +5,7 @@ import { Server } from 'socket.io';
import crypto from 'crypto';
import dotenv from 'dotenv';
import { EVENTS, OFFICIAL_SERVER_TOKEN, PROTOCOL_VERSION, CONTROL_MODES, CAPABILITIES } from '../shared/constants.js';
import { createChatEnvelope } from './chat.js';
import {
buildHealthPayload,
checkCooldown,
@@ -19,6 +20,7 @@ import {
connectionCounts,
failedAuthAttempts,
eventCounts,
chatMessageCounts,
healthCounts,
adminMetricsAuthCounts,
roomListCooldowns,
@@ -28,6 +30,7 @@ import {
recordAuthFailure,
checkConnectionRate,
checkEventRate,
checkChatMessageRate,
checkHealthRate,
checkAdminMetricsAuthRate,
checkLeaveRoomRate,
@@ -171,7 +174,26 @@ const HOST_ONLY_GATED_EVENTS = new Set([
// Features this relay supports, advertised to clients in ROOM_DATA so they can
// enable matching UI/behavior only when the server actually backs it. Append a
// flag here when a new server-gated feature ships (e.g. co-host promotion).
const SERVER_CAPABILITIES = [CAPABILITIES.HOST_CONTROL, CAPABILITIES.CO_HOST];
const SERVER_CAPABILITIES = [
CAPABILITIES.HOST_CONTROL,
CAPABILITIES.CO_HOST,
CAPABILITIES.CHAT,
CAPABILITIES.CHAT_V1
];
function normalizeClientCapabilities(value) {
if (!Array.isArray(value)) return [];
return [...new Set(value.slice(0, 16)
.filter(capability => typeof capability === 'string')
.map(capability => capability.substring(0, 32))
.filter(capability => capability === CAPABILITIES.CHAT_V1)
)];
}
function clientSupportsChat(socket) {
return Array.isArray(socket?.data?.clientCapabilities) &&
socket.data.clientCapabilities.includes(CAPABILITIES.CHAT_V1);
}
// M-4: minimum interval between CONTROL_MODE changes per room. Stops a rapidly
// toggling host from thrashing every guest's UI (locked/unlocked/locked...) and
@@ -353,6 +375,7 @@ io.on('connection', (socket) => {
const username = typeof payload.username === 'string' ? payload.username.substring(0, 30) : null;
const tabTitle = typeof payload.tabTitle === 'string' ? payload.tabTitle.substring(0, 100) : null;
const mediaTitle = typeof payload.mediaTitle === 'string' ? payload.mediaTitle.substring(0, 100) : null;
const clientCapabilities = normalizeClientCapabilities(payload.clientCapabilities);
if (!roomId || !peerId) return; // Guard: empty or invalid after sanitization
@@ -367,6 +390,7 @@ io.on('connection', (socket) => {
// Cleanup old room if re-joining
const oldMapping = socketToRoom.get(socket.id);
if (oldMapping && oldMapping.roomId === roomId && oldMapping.peerId === peerId) {
socket.data.clientCapabilities = clientCapabilities;
return; // Already in this room with same peerId, ignore to prevent spam
}
if (oldMapping && oldMapping.roomId !== roomId) {
@@ -490,6 +514,7 @@ io.on('connection', (socket) => {
}
socket.join(roomId);
socket.data.clientCapabilities = clientCapabilities;
room.peers.add(socket.id);
room.peerIds.set(socket.id, peerId);
room.peerData.set(socket.id, {
@@ -844,8 +869,45 @@ io.on('connection', (socket) => {
}
});
socket.on(EVENTS.CHAT_MESSAGE, (data) => {
try {
if (!checkEventRate(socket.id) || !checkChatMessageRate(socket.id)) {
log('SECURITY', `Encrypted chat rate limit exceeded for socket: ${socket.id}`);
socket.disconnect(true);
return;
}
const mapping = socketToRoom.get(socket.id);
if (!mapping) return;
const room = rooms.get(mapping.roomId);
if (!room) return;
const envelope = createChatEnvelope(data, mapping.peerId);
if (!envelope) return;
// Transitional compatibility: the first chat beta did not announce
// clientCapabilities. Successfully sending a canonical chat frame is
// sufficient proof that this socket understands the v1 receive event.
if (!clientSupportsChat(socket)) {
socket.data.clientCapabilities = [
...(socket.data.clientCapabilities || []),
CAPABILITIES.CHAT_V1
];
}
room.lastActivity = Date.now();
for (const socketId of room.peers) {
const recipient = io.sockets.sockets.get(socketId);
if (clientSupportsChat(recipient)) {
recipient.emit(EVENTS.CHAT_MESSAGE, envelope);
}
}
} catch (err) {
log('ERROR', `CHAT_MESSAGE handler error: ${err.message}`);
}
});
socket.on('disconnect', () => {
eventCounts.delete(socket.id);
chatMessageCounts.delete(socket.id);
roomListCooldowns.delete(socket.id);
leaveRoomCounts.delete(socket.id);
const mapping = socketToRoom.get(socket.id);
+44 -14
View File
@@ -70,20 +70,20 @@
}
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"content-type": "^2.0.0",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.2",
"on-finished": "^2.4.1",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
"qs": "^6.15.2",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
},
"engines": {
"node": ">=18"
@@ -93,6 +93,19 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1025,17 +1038,34 @@
}
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"content-type": "^2.0.0",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/type-is/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/undici-types": {

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