fix(extension): harden chat and target tab lifecycle

This commit is contained in:
Timo
2026-07-31 09:48:22 +02:00
parent 0b6ae803c8
commit 98cac1e8a7
26 changed files with 776 additions and 226 deletions
+6 -1
View File
@@ -4,7 +4,7 @@ All notable changes to the KoalaSync browser extension and relay server.
---
## Unreleased
## [v3.0.2] — 2026-07-31
### Added
- **Extension: Quick reactions** — Adds six encrypted one-click reactions with a local choice between chat-only display and bounded falling reactions over the video.
@@ -13,6 +13,11 @@ All notable changes to the KoalaSync browser extension and relay server.
### Changed
- **Extension: Real dock behavior** — Anchors the collapsed Koala chat launcher to the selected side, reserves a page column in normal and fullscreen layouts, and clips viewport-bound page layers away from the open dock; detached mode remains freely movable.
- **Extension: Koala chat launcher** — Replaces the generic speech-bubble emoji with the KoalaSync extension icon plus a chat marker.
- **Extension: Reliable long chat sends** — Accepts up to 5000 Unicode code points, splits them into at most ten compatible 500-codepoint messages, waits for each relay echo before clearing text, and suppresses chat notifications while the selected video tab is focused.
### Fixed
- **Extension: Strict target-tab lifecycle** — Fully deactivates playback, audio, heartbeat, observer, seek-bridge, and chat code before changing targets, cleans up superseded injections, and limits website-bridge status delivery to the KoalaSync site instead of broadcasting to every open tab.
- **Extension: Tab switching and page restore** — Keeps ordinary tab changes idle and injection-free, makes selecting the same target idempotent, blocks delayed callbacks after teardown, and restores the existing target cleanly after Firefox back/forward-cache suspension.
## [v3.0.1] — 2026-07-26
+7 -1
View File
@@ -49,7 +49,9 @@ the stamped value as AAD.
## Client policy
- Maximum plaintext length: 500 Unicode code points.
- Maximum plaintext length per relay message: 500 Unicode code points.
- The composer accepts up to 5000 Unicode code points and sends them sequentially
as at most ten independently encrypted `chat-v1` messages.
- Decrypted text is untrusted. Escape HTML before applying the supported limited
Markdown formatting.
- Quick reactions use the same encrypted message path as text. They remain readable
@@ -84,6 +86,10 @@ the stamped value as AAD.
- 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.
- System notifications are suppressed while the selected video tab and its browser
window are focused.
- A send succeeds only after the relay echoes the authenticated ciphertext back to
the sender. Unconfirmed and remaining split messages stay in the composer.
## Mixed-version rollout
+132 -50
View File
@@ -6,7 +6,7 @@ import { applyTitlePrivacyToPayload, sanitizeSharedTitle, sanitizeTabTitle, norm
import { initTabManager } from './modules/tab-manager.js';
import { clearChatKeyCache, decryptChatMessage, encryptChatMessage, generateChatSecret, validateChatSecret } from './chat-crypto.js';
import { buildChatRelayPayload, encodeSocketEvent } from './chat-wire.js';
import { createChatSendLimiter, createLatestTaskQueue, normalizeRoomId } from './chat-session.js';
import { createChatEchoTracker, createChatSendLimiter, createLatestTaskQueue, normalizeRoomId, shouldShowChatNotification } from './chat-session.js';
import { createChatActivityStore } from './chat-activity.js';
import { HOST_ACCESS_REQUIRED_STATUS, normalizeTabId, inspectTabHostAccess, isHostAccessError, addTabHostAccessRequest, removeTabHostAccessRequest } from './host-access.js';
import './page-api-seek-overrides.js';
@@ -90,6 +90,7 @@ let chatSecretGuard = '';
let chatSessionGeneration = 0;
let chatReceiveQueue = Promise.resolve();
const chatSendLimiter = createChatSendLimiter();
const chatEchoTracker = createChatEchoTracker();
const chatActivityStore = createChatActivityStore();
const webJoinCoordinator = createLatestTaskQueue();
function serverSupports(cap) { return Array.isArray(serverCapabilities) && serverCapabilities.includes(cap); }
@@ -102,6 +103,7 @@ function invalidateChatSession() {
chatSessionGeneration++;
chatReceiveQueue = Promise.resolve();
chatSendLimiter.reset();
chatEchoTracker.reset();
clearChatKeyCache();
}
@@ -589,7 +591,7 @@ function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null)
completeForceSyncBeforeTargetChange(null);
invalidateTargetActivations();
clearPendingTarget().catch(() => {});
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {});
if (currentTabId) deactivateTargetTab(currentTabId).catch(() => {});
currentTabId = null;
currentTabTitle = null;
lastContentHeartbeatAt = null;
@@ -625,7 +627,7 @@ async function leaveRoomAfterIdleGrace(reason) {
// Notify content.js/popup BEFORE currentTabId is cleared so they can reset
// any stale guest-side HCM state (dialog/badge/desync) — H-2.
broadcastControlMode();
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {});
if (currentTabId) await deactivateTargetTab(currentTabId);
invalidateTargetActivations();
currentTabId = null;
currentTabTitle = null;
@@ -873,6 +875,21 @@ function broadcastConnectionStatus(status) {
updateBadgeStatus();
}
async function broadcastJoinStatus(message, shouldSend = () => true) {
let websiteTabs = [];
try {
websiteTabs = await chrome.tabs.query({ url: 'https://sync.koalastuff.net/*' });
} catch (_) {
// The website bridge is optional and may be unavailable.
}
if (!shouldSend()) return false;
chrome.runtime.sendMessage(message).catch(() => {});
await Promise.all(websiteTabs.map(tab =>
chrome.tabs.sendMessage(tab.id, message).catch(() => {})
));
return true;
}
function updateBadgeStatus() {
const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined;
const isReconnecting = !isConnected && reconnectAttempts > 0;
@@ -934,9 +951,42 @@ function showNotification(senderName, action) {
});
}
function getTabForNotification(tabId) {
return new Promise(resolve => {
try {
chrome.tabs.get(tabId, tab => {
if (chrome.runtime.lastError) resolve(null);
else resolve(tab || null);
});
} catch (_) {
resolve(null);
}
});
}
function getWindowForNotification(windowId) {
return new Promise(resolve => {
try {
chrome.windows.get(windowId, windowInfo => {
if (chrome.runtime.lastError) resolve(null);
else resolve(windowInfo || null);
});
} catch (_) {
resolve(null);
}
});
}
function showChatNotification(displayName, text) {
chrome.storage.local.get(['chatNotifications', 'locale'], async (settings) => {
if (settings.chatNotifications === false) return;
const enabled = settings.chatNotifications !== false;
if (!enabled) return;
const targetTabId = normalizeTabId(currentTabId);
const tab = targetTabId === null ? null : await getTabForNotification(targetTabId);
const windowInfo = Number.isInteger(tab?.windowId)
? await getWindowForNotification(tab.windowId)
: null;
if (!shouldShowChatNotification({ enabled, targetTabId, tab, windowInfo })) return;
await loadLocale(settings.locale || getSystemLanguage());
chrome.notifications.create(`chat_${Date.now()}`, {
@@ -1233,12 +1283,7 @@ async function handleServerEvent(event, data) {
// Inform Website Bridge & Popup
const joinStatusMsg = { type: 'JOIN_STATUS', success: true, message: 'Joined' };
chrome.runtime.sendMessage(joinStatusMsg).catch(() => {});
chrome.tabs.query({}, (tabs) => {
tabs.forEach(tab => {
chrome.tabs.sendMessage(tab.id, joinStatusMsg).catch(() => {});
});
});
await broadcastJoinStatus(joinStatusMsg);
break;
case EVENTS.CONTROL_MODE:
// Host Control Mode changed (toggle or host-leave fallback).
@@ -1279,6 +1324,7 @@ async function handleServerEvent(event, data) {
secret: chatKey
});
if (!isCurrentSession() || chatSecretGuard !== chatKey) return;
if (received.senderId === peerId) chatEchoTracker.acknowledge(received.ciphertext);
const senderPeer = currentRoom.peers?.find(candidate =>
(typeof candidate === 'object' ? candidate.peerId : candidate) === received.senderId
);
@@ -1333,12 +1379,7 @@ async function handleServerEvent(event, data) {
});
// Inform Website Bridge & Popup
const errStatusMsg = { type: 'JOIN_STATUS', success: false, message: data.message };
chrome.runtime.sendMessage(errStatusMsg).catch(() => {});
chrome.tabs.query({}, (tabs) => {
tabs.forEach(tab => {
chrome.tabs.sendMessage(tab.id, errStatusMsg).catch(() => {});
});
});
await broadcastJoinStatus(errStatusMsg);
break;
case EVENTS.PLAY:
case EVENTS.PAUSE:
@@ -1896,8 +1937,12 @@ function shouldUsePageApiSeek(url) {
}
function installPageApiSeekBridge() {
if (window.__koalaPageApiSeekBridgeInstalled) return;
window.__koalaPageApiSeekBridgeInstalled = true;
if (window.__koalaPageApiSeekBridge?.activate) {
window.__koalaPageApiSeekBridge.activate();
return;
}
let active = true;
function currentMatch() {
return typeof window.koalaFindPageApiSeekProvider === 'function'
@@ -1933,17 +1978,23 @@ function installPageApiSeekBridge() {
}
}
window.addEventListener('message', (event) => {
function handleBridgeMessage(event) {
if (event.source !== window) return;
const data = event.data;
if (!data || data.__koalaPageApiSeek !== 1 || data.kind !== 'seek' || typeof data.time !== 'number') return;
if (!data || data.__koalaPageApiSeek !== 1) return;
if (data.kind === 'destroy') {
destroy();
return;
}
if (!active || data.kind !== 'seek' || typeof data.time !== 'number') return;
seekWithPageApi(data.time);
});
}
// Disney+'s <video> currentTime is blob-relative and its scrubber lags, so
// the isolated-world content script can't read an accurate position. Push
// the real playhead/duration (seconds) from the page's media player.
setInterval(() => {
const timelineInterval = setInterval(() => {
if (!active) return;
try {
const match = currentMatch();
if (!match || match.provider !== 'disney') return;
@@ -1961,12 +2012,36 @@ function installPageApiSeekBridge() {
// Ignore transient errors (player teardown / element swap).
}
}, 250);
function destroy() {
if (!active) return;
active = false;
clearInterval(timelineInterval);
window.removeEventListener('message', handleBridgeMessage);
delete window.__koalaPageApiSeekBridge;
}
window.addEventListener('message', handleBridgeMessage);
window.__koalaPageApiSeekBridge = {
activate() {
active = true;
},
destroy
};
}
function setPageApiSeekEnabled(enabled) {
window.KOALA_PAGE_API_SEEK_ENABLED = enabled === true;
}
async function deactivateTargetTab(tabId) {
const normalizedTabId = normalizeTabId(tabId);
if (normalizedTabId === null) return;
resetAudioProcessingInTab(normalizedTabId);
await chrome.tabs.sendMessage(normalizedTabId, { type: 'TARGET_DEACTIVATE' }).catch(() => {});
await chrome.tabs.sendMessage(normalizedTabId, { type: 'CHAT_DESTROY' }).catch(() => {});
}
function createHostAccessRequiredError(access, requestAdded, cause) {
const error = new Error(`Host access required for ${access.host || 'this website'}`);
error.code = HOST_ACCESS_REQUIRED_STATUS;
@@ -2226,10 +2301,19 @@ async function activateTargetTab(tabId, tabTitle, {
const previousTabId = normalizeTabId(currentTabId);
try {
if (previousTabId && previousTabId !== selectedTabId) {
await deactivateTargetTab(previousTabId);
}
if (activationGeneration !== targetActivationGeneration) {
return { status: 'superseded' };
}
try {
await injectContentScript(selectedTabId, { requestHostAccess });
} catch (error) {
if (activationGeneration !== targetActivationGeneration) {
if (normalizeTabId(currentTabId) !== selectedTabId) {
await deactivateTargetTab(selectedTabId);
}
if (error?.code === HOST_ACCESS_REQUIRED_STATUS
&& error.requestAdded === true
&& activeTargetActivation?.tabId !== selectedTabId) {
@@ -2246,8 +2330,7 @@ async function activateTargetTab(tabId, tabTitle, {
lastContentHeartbeatAt = null;
if (currentRoom) roomIdleSince = Date.now();
if (previousTabId) {
resetAudioProcessingInTab(previousTabId);
chrome.tabs.sendMessage(previousTabId, { type: 'CHAT_DESTROY' }).catch(() => {});
await deactivateTargetTab(previousTabId);
}
await chrome.storage.session.set({
currentTabId: null,
@@ -2285,33 +2368,29 @@ async function activateTargetTab(tabId, tabTitle, {
}
if (activationGeneration !== targetActivationGeneration) {
if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId);
if (currentTabId !== selectedTabId) await deactivateTargetTab(selectedTabId);
return { status: 'superseded' };
}
await applyAudioSettingsToTab(selectedTabId);
if (activationGeneration !== targetActivationGeneration) {
if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId);
if (currentTabId !== selectedTabId) await deactivateTargetTab(selectedTabId);
return { status: 'superseded' };
}
await removeTabHostAccessRequest(chrome, selectedTabId);
if (activationGeneration !== targetActivationGeneration) {
if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId);
if (currentTabId !== selectedTabId) await deactivateTargetTab(selectedTabId);
return { status: 'superseded' };
}
await clearPendingTarget();
if (activationGeneration !== targetActivationGeneration) {
if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId);
if (currentTabId !== selectedTabId) await deactivateTargetTab(selectedTabId);
return { status: 'superseded' };
}
currentTabId = selectedTabId;
currentTabTitle = typeof tabTitle === 'string' ? tabTitle : null;
lastContentHeartbeatAt = null;
if (currentRoom) roomIdleSince = Date.now();
if (previousTabId && previousTabId !== selectedTabId) {
resetAudioProcessingInTab(previousTabId);
chrome.tabs.sendMessage(previousTabId, { type: 'CHAT_DESTROY' }).catch(() => {});
}
await chrome.storage.session.set({
currentTabId,
currentTabTitle,
@@ -2651,10 +2730,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
if (settings.roomId && currentRoom && currentRoom.roomId === settings.roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
broadcastConnectionStatus('connected');
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve));
for (const tab of tabs) {
chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {});
}
await broadcastJoinStatus({ type: 'JOIN_STATUS', success: true, message: 'Already in room' });
if (typeof sendResponse === 'function') sendResponse({ status: 'ok' });
return;
}
@@ -2827,8 +2903,19 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse({ status: 'session_changed' });
return;
}
const echoPromise = chatEchoTracker.waitFor(ciphertext);
const sent = emitLive(EVENTS.CHAT_MESSAGE, buildChatRelayPayload(ciphertext));
sendResponse({ status: sent ? 'ok' : 'disconnected' });
if (!sent) {
chatEchoTracker.cancel(ciphertext);
sendResponse({ status: 'disconnected' });
return;
}
const acknowledged = await echoPromise;
if (!isCurrentSession() || chatSecretGuard !== chatKey) {
sendResponse({ status: 'session_changed' });
return;
}
sendResponse({ status: acknowledged ? 'ok' : 'unconfirmed' });
} catch (err) {
sendResponse({ status: err instanceof RangeError ? 'too_long' : 'invalid_message' });
}
@@ -2917,7 +3004,6 @@ async function handleAsyncMessage(message, sender, sendResponse) {
reconnectFailed = false;
reconnectAttempts = 0;
chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null });
resetAudioProcessingInTab(currentTabId);
emit(EVENTS.LEAVE_ROOM, { peerId });
currentRoom = null;
clearChatActivity();
@@ -2929,7 +3015,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
// Notify content.js/popup BEFORE currentTabId is cleared so they drop any
// stale guest-side HCM state (dialog/badge/desync) — H-2/H-3.
broadcastControlMode();
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_DESTROY' }).catch(() => {});
if (currentTabId) await deactivateTargetTab(currentTabId);
invalidateTargetActivations();
currentTabId = null;
currentTabTitle = null;
@@ -2984,10 +3070,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
const chatKey = validateChatSecret(rawChatKey);
if (!roomId) {
const errMsg = { type: 'JOIN_STATUS', success: false, message: 'Invalid room ID' };
chrome.runtime.sendMessage(errMsg).catch(() => {});
chrome.tabs.query({}, (tabs) => {
tabs.forEach(tab => chrome.tabs.sendMessage(tab.id, errMsg).catch(() => {}));
});
await broadcastJoinStatus(errMsg);
sendResponse({ status: 'invalid_room_id' });
return;
}
@@ -3021,14 +3104,14 @@ async function handleAsyncMessage(message, sender, sendResponse) {
if (roomId && currentRoom && currentRoom.roomId === roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
broadcastConnectionStatus('connected');
if (currentTabId) chrome.tabs.sendMessage(Number(currentTabId), { type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
const tabs = await new Promise(resolve => chrome.tabs.query({}, resolve));
if (!isCurrentJoin()) {
const statusSent = await broadcastJoinStatus(
{ type: 'JOIN_STATUS', success: true, message: 'Already in room' },
isCurrentJoin
);
if (!statusSent || !isCurrentJoin()) {
sendResponse({ status: 'superseded' });
return { status: 'superseded' };
}
for (const tab of tabs) {
chrome.tabs.sendMessage(tab.id, { type: 'JOIN_STATUS', success: true, message: 'Already in room' }).catch(() => {});
}
sendResponse({ status: 'already_joined' });
return { status: 'already_joined' };
}
@@ -3382,8 +3465,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
lastContentHeartbeatAt = null;
if (currentRoom) roomIdleSince = Date.now();
if (previousTabId) {
resetAudioProcessingInTab(previousTabId);
chrome.tabs.sendMessage(Number(previousTabId), { type: 'CHAT_DESTROY' }).catch(() => {});
await deactivateTargetTab(previousTabId);
}
await clearPendingTarget();
await chrome.storage.session.set({
+37 -1
View File
@@ -29,5 +29,41 @@
return tokens;
}
root.KoalaSyncChatFormat = Object.freeze({ escapeChatHtml, formatChatText, tokenizeChatText });
function splitChatText(value, maxCodePoints = 500, maxChunks = 10) {
if (!Number.isInteger(maxCodePoints) || maxCodePoints < 1 || !Number.isInteger(maxChunks) || maxChunks < 1) {
throw new TypeError('Chat chunk limits must be positive integers');
}
let remaining = [...String(value ?? '').trim()];
if (remaining.length === 0) return [];
if (remaining.length > maxCodePoints * maxChunks) {
throw new RangeError('Chat composer text exceeds the chunk limit');
}
const chunks = [];
while (remaining.length > 0) {
if (remaining.length <= maxCodePoints) {
chunks.push(remaining.join('').trim());
break;
}
const remainingSlots = maxChunks - chunks.length;
const minimumSplit = Math.max(1, remaining.length - (remainingSlots - 1) * maxCodePoints);
let splitAt = maxCodePoints;
for (let index = maxCodePoints - 1; index >= minimumSplit; index--) {
if (/\s/u.test(remaining[index])) {
splitAt = index;
break;
}
}
const chunk = remaining.slice(0, splitAt).join('').trim();
remaining = remaining.slice(splitAt);
while (remaining.length > 0 && /\s/u.test(remaining[0])) remaining.shift();
if (chunk) chunks.push(chunk);
}
return chunks;
}
root.KoalaSyncChatFormat = Object.freeze({ escapeChatHtml, formatChatText, tokenizeChatText, splitChatText });
})(globalThis);
+21
View File
@@ -19,4 +19,25 @@ describe('chat formatting', () => {
{ type: 'em', text: 'italic' }
]);
});
it('splits up to 5000 Unicode code points into at most ten ordered chat-v1 messages', () => {
const split = globalThis.KoalaSyncChatFormat.splitChatText;
expect(split('hello world', 500, 10)).toEqual(['hello world']);
const emojiChunks = split('😀'.repeat(1001), 500, 10);
expect(emojiChunks.map(chunk => [...chunk].length)).toEqual([500, 500, 1]);
const maximumChunks = split('x'.repeat(5000), 500, 10);
expect(maximumChunks).toHaveLength(10);
expect(maximumChunks.every(chunk => [...chunk].length === 500)).toBe(true);
expect(maximumChunks.join('')).toBe('x'.repeat(5000));
expect(() => split('x'.repeat(5001), 500, 10)).toThrow(RangeError);
});
it('prefers whitespace boundaries without creating more chunks than the limit allows', () => {
const split = globalThis.KoalaSyncChatFormat.splitChatText;
const chunks = split(`${'a'.repeat(420)} ${'b'.repeat(179)}`, 500, 10);
expect(chunks).toEqual(['a'.repeat(420), 'b'.repeat(179)]);
});
});
+30 -2
View File
@@ -1,5 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
import vm from 'node:vm';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
@@ -34,6 +35,15 @@ const chatKeys = [
];
describe('chat overlay contract', () => {
it('skips SVG and other non-HTML documents before creating the overlay host', () => {
const documentGuard = "document.documentElement?.namespaceURI !== 'http://www.w3.org/1999/xhtml'";
expect(overlaySource).toContain(documentGuard);
expect(overlaySource.indexOf(documentGuard)).toBeLessThan(overlaySource.indexOf("document.createElement('div')"));
expect(() => vm.runInNewContext(overlaySource, {
document: { documentElement: { namespaceURI: 'http://www.w3.org/2000/svg' } }
})).not.toThrow();
});
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*=/);
@@ -85,6 +95,7 @@ describe('chat overlay contract', () => {
expect(overlaySource).toContain('margin-right: var(${PAGE_DOCK_WIDTH}) !important');
expect(overlaySource).toContain('html[${PAGE_DOCK_ATTRIBUTE}] > body');
expect(overlaySource).toContain('clip-path: inset(0) !important');
expect(overlaySource).toContain('contain: layout paint !important');
expect(overlaySource).toContain('> :not(#koalasync-chat-overlay-host)');
expect(overlaySource).toContain('const target = document.fullscreenElement || document.documentElement');
expect(overlaySource).toContain('pageDockTarget = target');
@@ -100,7 +111,9 @@ describe('chat overlay contract', () => {
expect(overlaySource).toContain("unreadBadge.classList.toggle('visible', unreadCount > 0)");
expect(backgroundSource).toMatch(/received\.senderId !== peerId[\s\S]*showChatNotification\([\s\S]*senderPeer\.username[\s\S]*received\.senderId/);
expect(backgroundSource).toContain("chrome.notifications.create(`chat_${Date.now()}`");
expect(backgroundSource).toContain("if (settings.chatNotifications === false) return");
expect(backgroundSource).toContain('getTabForNotification(targetTabId)');
expect(backgroundSource).toContain('getWindowForNotification(tab.windowId)');
expect(backgroundSource).toContain('shouldShowChatNotification({ enabled, targetTabId, tab, windowInfo })');
});
it('renders timestamped room activity and keeps activity notifications opt-in', () => {
@@ -125,12 +138,24 @@ describe('chat overlay contract', () => {
expect(overlaySource).toContain('launcherIcon.src = LAUNCHER_ICON_DATA_URL');
expect(manifestSource).not.toContain('web_accessible_resources');
expect(overlaySource).toContain("const QUICK_REACTIONS = Object.freeze(['❤️', '😂', '😮', '😢', '👏', '🔥'])");
expect(overlaySource).toContain("messageRuntime({ type: 'CHAT_SEND', text })");
expect(overlaySource).toContain("messageRuntime({ type: 'CHAT_SEND', text: chunk })");
expect(overlaySource).toContain("chatReactionDisplay !== 'video'");
expect(overlaySource).toContain('MAX_REACTION_PARTICLES - reactionLayer.childElementCount');
expect(overlaySource).toContain('reducedMotion.matches');
});
it('splits a 5000-codepoint composer value into at most ten acknowledged chat-v1 messages', () => {
expect(overlaySource).toContain('const MAX_MESSAGE_CODE_POINTS = 500');
expect(overlaySource).toContain('const MAX_CHAT_CHUNKS = 10');
expect(overlaySource).toContain('MAX_MESSAGE_CODE_POINTS * MAX_CHAT_CHUNKS');
expect(overlaySource).toContain('KoalaSyncChatFormat?.splitChatText(');
expect(overlaySource).toMatch(/for \(const chunk of chunks\)[\s\S]*messageRuntime\(\{ type: 'CHAT_SEND', text: chunk \}\)/);
expect(overlaySource).toContain("textarea.value = chunks.slice(completedChunks).join('\\n')");
expect(backgroundSource).toContain('const echoPromise = chatEchoTracker.waitFor(ciphertext)');
expect(backgroundSource).toContain('chatEchoTracker.acknowledge(received.ciphertext)');
expect(backgroundSource).toContain("status: acknowledged ? 'ok' : 'unconfirmed'");
});
it('excludes test-only modules from production extension artifacts', () => {
expect(buildSource).toContain("/\\.test\\.[cm]?js$/u.test(item)");
});
@@ -143,6 +168,9 @@ describe('chat overlay contract', () => {
expect(overlaySource).toContain('setTimeout(() => finish(null), timeoutMs)');
expect(backgroundSource).toContain('chatReceiveQueue = chatReceiveQueue.catch(() => {}).then');
expect(backgroundSource).toContain("status: 'rate_limited'");
expect(overlaySource).toContain('function setLocalStorage(values)');
expect(overlaySource).toMatch(/chrome\.storage\.local\.get\([\s\S]*data => \{\s*if \(destroyed\) return;/);
expect(overlaySource).toContain("if (destroyed || area !== 'local') return");
});
it('keeps chat hidden by default without discarding the room chat key', () => {
+65 -22
View File
@@ -1,4 +1,6 @@
(function initKoalaSyncChatOverlay() {
if (document.documentElement?.namespaceURI !== 'http://www.w3.org/1999/xhtml') return;
if (window.koalaSyncChatOverlay?.refresh) {
window.koalaSyncChatOverlay.refresh();
return;
@@ -11,6 +13,9 @@
const DEFAULT_HEIGHT = 520;
const LAUNCHER_SIZE = 48;
const EDGE_INSET = 8;
const MAX_MESSAGE_CODE_POINTS = 500;
const MAX_CHAT_CHUNKS = 10;
const MAX_COMPOSER_CODE_POINTS = MAX_MESSAGE_CODE_POINTS * MAX_CHAT_CHUNKS;
const PAGE_DOCK_ATTRIBUTE = 'data-koalasync-chat-dock';
const PAGE_DOCK_WIDTH = '--koalasync-chat-dock-width';
const QUICK_REACTIONS = Object.freeze(['❤️', '😂', '😮', '😢', '👏', '🔥']);
@@ -268,7 +273,7 @@
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');
const count = element('span', 'count', `0/${MAX_COMPOSER_CODE_POINTS}`);
count.id = 'chat-composer-count';
const sendButton = element('button', 'send');
sendButton.type = 'submit';
@@ -306,6 +311,7 @@
min-height: 100vh !important;
overflow-x: clip !important;
clip-path: inset(0) !important;
contain: layout paint !important;
}
[${PAGE_DOCK_ATTRIBUTE}]:not(html) > :not(#koalasync-chat-overlay-host) {
box-sizing: border-box !important;
@@ -313,6 +319,7 @@
max-width: calc(100% - var(${PAGE_DOCK_WIDTH})) !important;
overflow-x: clip !important;
clip-path: inset(0) !important;
contain: layout paint !important;
}
[${PAGE_DOCK_ATTRIBUTE}="left"]:not(html) > :not(#koalasync-chat-overlay-host) {
margin-left: var(${PAGE_DOCK_WIDTH}) !important;
@@ -346,6 +353,16 @@
});
}
function setLocalStorage(values) {
if (destroyed) return;
try {
const result = chrome.storage.local.set(values);
if (result?.catch) result.catch(() => {});
} catch (_) {
// Extension context was invalidated while the page remained open.
}
}
function strings() {
return context?.strings || {};
}
@@ -477,11 +494,12 @@
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
saveTimer = null;
chrome.storage.local.set({ [storageKey]: layout }).catch(() => {});
setLocalStorage({ [storageKey]: layout });
}, 150);
}
function applyLayout() {
if (destroyed) return;
applyingLayout = true;
const size = preferredSize();
launcher.classList.toggle('docked-left', layout.mode === 'left');
@@ -539,7 +557,7 @@
layout.mode = mode;
applyLayout();
saveLayout();
if (persistPreference) chrome.storage.local.set({ chatPosition: mode }).catch(() => {});
if (persistPreference) setLocalStorage({ chatPosition: mode });
}
function setSize(size, persistPreference = true) {
@@ -555,7 +573,7 @@
if (layout.mode === 'detached') clampDetached();
applyLayout();
saveLayout();
if (persistPreference) chrome.storage.local.set({ chatSize }).catch(() => {});
if (persistPreference) setLocalStorage({ chatSize });
}
function setOpened(next) {
@@ -699,12 +717,19 @@
}
}
function updateComposerState() {
const length = [...textarea.value].length;
count.textContent = `${length}/${MAX_COMPOSER_CODE_POINTS}`;
count.style.color = length > MAX_COMPOSER_CODE_POINTS ? 'var(--danger)' : '';
status.textContent = length > MAX_COMPOSER_CODE_POINTS ? (strings().tooLong || '') : '';
return length;
}
function resetComposer() {
sendGeneration++;
sending = false;
textarea.value = '';
count.textContent = '0/500';
count.style.color = '';
updateComposerState();
status.textContent = '';
sendButton.disabled = false;
for (const button of reactionButtons) button.disabled = false;
@@ -758,12 +783,7 @@
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('input', updateComposerState);
const stopPageKeyboardShortcut = event => {
const path = typeof event.composedPath === 'function' ? event.composedPath() : [];
if (!path.includes(textarea) && !path.includes(composer)) return;
@@ -780,26 +800,48 @@
if (sending || !context?.enabled) return;
text = String(text || '').trim();
if (!text) return;
if ([...text].length > 500) {
if ([...text].length > MAX_COMPOSER_CODE_POINTS) {
status.textContent = strings().tooLong || '';
return;
}
let chunks;
try {
chunks = globalThis.KoalaSyncChatFormat?.splitChatText(
text,
MAX_MESSAGE_CODE_POINTS,
MAX_CHAT_CHUNKS
);
} catch (_) {
status.textContent = strings().tooLong || '';
return;
}
if (!Array.isArray(chunks) || chunks.length === 0) return;
const generation = ++sendGeneration;
sending = true;
sendButton.disabled = true;
for (const button of reactionButtons) button.disabled = true;
status.textContent = '';
const response = await messageRuntime({ type: 'CHAT_SEND', text });
if (destroyed || generation !== sendGeneration) return;
let completedChunks = 0;
let response = null;
for (const chunk of chunks) {
response = await messageRuntime({ type: 'CHAT_SEND', text: chunk });
if (destroyed || generation !== sendGeneration) return;
if (response?.status !== 'ok') break;
completedChunks++;
}
sending = false;
sendButton.disabled = false;
for (const button of reactionButtons) button.disabled = false;
if (response?.status === 'ok') {
if (completedChunks === chunks.length) {
if (clearComposerValue && textarea.value.trim() === text) {
textarea.value = '';
count.textContent = '0/500';
updateComposerState();
}
} else {
if (clearComposerValue && textarea.value.trim() === text) {
textarea.value = chunks.slice(completedChunks).join('\n');
updateComposerState();
}
status.textContent = response?.status === 'too_long' ? (strings().tooLong || '') : (strings().sendFailed || '');
}
}
@@ -841,7 +883,7 @@
layout.customHeight = rect.height;
if (chatSize !== 'custom') {
chatSize = 'custom';
chrome.storage.local.set({ chatSize }).catch(() => {});
setLocalStorage({ chatSize });
}
clampDetached();
saveLayout();
@@ -868,7 +910,7 @@
}
function handleStorage(changes, area) {
if (area !== 'local') return;
if (destroyed || 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();
@@ -896,7 +938,7 @@
resetComposer();
refresh();
}
if (message?.type === 'CHAT_DESTROY') destroy();
if (message?.type === 'CHAT_DESTROY' || message?.type === 'TARGET_DEACTIVATE') destroy();
}
function destroy() {
@@ -914,8 +956,8 @@
document.removeEventListener('fullscreenchange', moveIntoFullscreen);
window.removeEventListener('resize', handleResize);
systemTheme.removeEventListener('change', handleSystemTheme);
chrome.storage.onChanged.removeListener(handleStorage);
chrome.runtime.onMessage.removeListener(handleRuntime);
try { chrome.storage.onChanged.removeListener(handleStorage); } catch (_) { /* invalidated context */ }
try { chrome.runtime.onMessage.removeListener(handleRuntime); } catch (_) { /* invalidated context */ }
host.remove();
window.koalaSyncChatOverlay = null;
}
@@ -926,6 +968,7 @@
chrome.storage.onChanged.addListener(handleStorage);
chrome.runtime.onMessage.addListener(handleRuntime);
chrome.storage.local.get([storageKey, 'themeMode', 'themePalette', 'chatPosition', 'chatSize', 'chatStartMode', 'chatReactionDisplay'], data => {
if (destroyed) return;
const storedLayout = data[storageKey];
if (storedLayout && typeof storedLayout === 'object') {
layout = { ...layout, ...storedLayout };
+54 -1
View File
@@ -1,6 +1,7 @@
export const MAX_ROOM_ID_LENGTH = 64;
export const CHAT_SEND_LIMIT = 9;
export const CHAT_SEND_LIMIT = 10;
export const CHAT_SEND_WINDOW_MS = 10000;
export const CHAT_ECHO_TIMEOUT_MS = 4000;
export function normalizeRoomId(value) {
if (typeof value !== 'string') return '';
@@ -33,6 +34,58 @@ export function createChatSendLimiter({
};
}
export function createChatEchoTracker({
timeoutMs = CHAT_ECHO_TIMEOUT_MS,
setTimer = globalThis.setTimeout,
clearTimer = globalThis.clearTimeout
} = {}) {
const pending = new Map();
function settle(ciphertext, acknowledged) {
const entry = pending.get(ciphertext);
if (!entry) return false;
pending.delete(ciphertext);
clearTimer(entry.timer);
entry.resolve(acknowledged);
return true;
}
return {
waitFor(ciphertext) {
if (typeof ciphertext !== 'string' || !ciphertext) return Promise.resolve(false);
settle(ciphertext, false);
return new Promise(resolve => {
const timer = setTimer(() => {
pending.delete(ciphertext);
resolve(false);
}, timeoutMs);
pending.set(ciphertext, { resolve, timer });
});
},
acknowledge(ciphertext) {
return settle(ciphertext, true);
},
cancel(ciphertext) {
return settle(ciphertext, false);
},
reset() {
for (const ciphertext of [...pending.keys()]) settle(ciphertext, false);
}
};
}
export function shouldShowChatNotification({ enabled, targetTabId, tab, windowInfo }) {
if (!enabled) return false;
const normalizedTargetTabId = Number(targetTabId);
const targetIsFocused = Number.isInteger(normalizedTargetTabId)
&& tab?.id === normalizedTargetTabId
&& tab.active === true
&& Number.isInteger(tab.windowId)
&& windowInfo?.id === tab.windowId
&& windowInfo.focused === true;
return !targetIsFocused;
}
export function createLatestTaskQueue() {
let generation = 0;
let tail = Promise.resolve();
+50 -2
View File
@@ -1,10 +1,14 @@
import { describe, expect, it } from 'vitest';
import { CHAT_MESSAGE_RATE_LIMIT } from '../server/rate-limiter.js';
import {
CHAT_ECHO_TIMEOUT_MS,
CHAT_SEND_LIMIT,
createChatEchoTracker,
createChatSendLimiter,
createLatestTaskQueue,
MAX_ROOM_ID_LENGTH,
normalizeRoomId
normalizeRoomId,
shouldShowChatNotification
} from './chat-session.js';
describe('chat session boundaries', () => {
@@ -14,7 +18,8 @@ describe('chat session boundaries', () => {
expect(normalizeRoomId(null)).toBe('');
});
it('keeps client chat bursts below the relay disconnect threshold', () => {
it('allows ten-message client bursts without exceeding the relay threshold', () => {
expect(CHAT_SEND_LIMIT).toBe(CHAT_MESSAGE_RATE_LIMIT);
let current = 1000;
const limiter = createChatSendLimiter({ now: () => current });
for (let index = 0; index < CHAT_SEND_LIMIT; index++) {
@@ -27,6 +32,49 @@ describe('chat session boundaries', () => {
expect(limiter.take()).toEqual({ allowed: true, retryAfterMs: 0 });
});
it('waits for the matching relay echo and clears pending sends on reset or timeout', async () => {
const timers = new Map();
let nextTimerId = 1;
const tracker = createChatEchoTracker({
setTimer(callback, timeoutMs) {
const id = nextTimerId++;
timers.set(id, { callback, timeoutMs });
return id;
},
clearTimer(id) {
timers.delete(id);
}
});
const acknowledged = tracker.waitFor('ciphertext-1');
expect(tracker.acknowledge('ciphertext-1')).toBe(true);
await expect(acknowledged).resolves.toBe(true);
const reset = tracker.waitFor('ciphertext-2');
tracker.reset();
await expect(reset).resolves.toBe(false);
const timedOut = tracker.waitFor('ciphertext-3');
const timeout = [...timers.values()][0];
expect(timeout.timeoutMs).toBe(CHAT_ECHO_TIMEOUT_MS);
timeout.callback();
await expect(timedOut).resolves.toBe(false);
});
it('suppresses chat notifications only while the target tab and its window are focused', () => {
const focused = {
enabled: true,
targetTabId: 7,
tab: { id: 7, active: true, windowId: 3 },
windowInfo: { id: 3, focused: true }
};
expect(shouldShowChatNotification(focused)).toBe(false);
expect(shouldShowChatNotification({ ...focused, windowInfo: { id: 3, focused: false } })).toBe(true);
expect(shouldShowChatNotification({ ...focused, tab: { id: 7, active: false, windowId: 3 } })).toBe(true);
expect(shouldShowChatNotification({ ...focused, targetTabId: 8 })).toBe(true);
expect(shouldShowChatNotification({ ...focused, enabled: false })).toBe(false);
});
it('serializes join work and prevents an older request from winning storage races', async () => {
const queue = createLatestTaskQueue();
let releaseFirst;
+289 -131
View File
@@ -12,7 +12,34 @@
// Injection Guard: Check if already injected AND context is valid
try {
try {
if (window.koalaSyncInjected && chrome.runtime.id) {
return;
}
} catch (_e) {
// Context invalidated, proceed with re-injection
}
window.koalaSyncInjected = true;
let destroyed = false;
const lifecycleTimeouts = new Set();
const seekPollTimers = new Set();
const attachedVideos = new Set();
function scheduleLifecycleTimeout(callback, delay) {
const timer = setTimeout(() => {
lifecycleTimeouts.delete(timer);
if (!destroyed) callback();
}, delay);
lifecycleTimeouts.add(timer);
return timer;
}
function runtimeMessage(message, callback) {
if (destroyed) return Promise.resolve(undefined);
@@ -62,15 +89,16 @@
// Each entry is a per-type timer (key = 'playing'|'paused'|'seek').
// While a timer exists, matching native events are consumed and not relayed.
// Timers self-clean after 300ms if the native event never fires.
let _suppressTimers = {};
function _setSuppress(state) {
// While a timer exists, matching native events are consumed and not relayed.
// Timers self-clean after 300ms if the native event never fires.
let _suppressTimers = {};
if (_suppressTimers[state]) clearTimeout(_suppressTimers[state]);
_suppressTimers[state] = setTimeout(() => {
@@ -210,17 +238,20 @@
if (!Number.isFinite(targetTime)) return targetTime;
const siteTimeline = getSiteQuirkTimeline(video);
if (!siteTimeline) return targetTime;
if (!siteTimeline) return targetTime;
const nativeTarget = siteTimeline.nativeStart + targetTime * siteTimeline.nativeScale;
const max = Number.isFinite(siteTimeline.end) ? siteTimeline.end : nativeTarget;
const min = Number.isFinite(siteTimeline.start) ? siteTimeline.start : 0;
return Math.max(min, Math.min(max, nativeTarget));
}
function shouldUsePageApiSeek() {
function shouldUsePageApiSeek() {
return window.KOALA_PAGE_API_SEEK_ENABLED === true &&
typeof window.koalaFindPageApiSeekProvider === 'function' &&
!!window.koalaFindPageApiSeekProvider(window.location.hostname);
return window.KOALA_PAGE_API_SEEK_ENABLED === true &&
typeof window.koalaFindPageApiSeekProvider === 'function' &&
!!window.koalaFindPageApiSeekProvider(window.location.hostname);
}
function seekVideo(video, targetTime) {
// Prefer a precise page-level seek API when available (Netflix, Disney+);
// for those players the DOM/button seek path is imprecise or impossible.
if (shouldUsePageApiSeek()) {
expectedSeekTime = targetTime;
@@ -234,8 +265,9 @@
// --- Play/Pause Coalescing (leading + trailing) ---
// Media players (HLS/DASH, ad insertion, ABR/quality switches, source swaps,
// page teardown) fire bursts of native play/pause events within a few hundred
// ms. Relaying each as a distinct command spams peers and the relay.
@@ -320,9 +352,9 @@
// --- Host Control Mode (guest-side) ---
}
// When a room is in 'host-only' mode and we're a guest, a deliberate local
});
// pause/seek must not drive the room (background/server already drop it). Here
// we handle the *local* UX: snap back to the host's position, or — if the user
@@ -355,7 +387,7 @@
body: 'Only the host can control playback in this room. Keep watching together, or watch on your own?',
stay: 'Stay in sync',
solo: 'Watch on my own',
@@ -457,7 +489,7 @@
}
// Snap the local player back to the host's current position/state.
@@ -466,7 +498,7 @@
if (hcmDesynced) return; // user opted out — never yank them back automatically
hcmSnapBackCooldownUntil = Date.now() + HCM_SNAP_BACK_COOLDOWN_MS;
const video = findVideo();
if (!video) return;
@@ -480,14 +512,16 @@
// Adopt the host's play/pause state — but ONLY if we actually know it.
// Defaulting to PLAY when the state is unknown would auto-resume a paused
if (target && Number.isFinite(target.targetTime)) {
tryMediaAction(EVENTS.SEEK, { targetTime: target.targetTime });
}
// video against the host's real state (H-1).
if (target && target.playbackState === 'paused') {
tryMediaAction(EVENTS.PAUSE);
} else if (target && target.playbackState === 'playing') {
tryMediaAction(EVENTS.PLAY);
}
@@ -518,12 +552,12 @@
else reportLog('Host-only: resync requested but host state unavailable', 'warn');
return;
let attempts = 0;
}
hcmSnapBackToHost(res.target);
});
};
@@ -786,7 +820,7 @@
hcmShowBadge();
}
hcmDesynced = true;
function hcmExitDesync() {
@@ -810,7 +844,7 @@
}
hcmRemoveBadge();
function hcmShowBadge() {
@@ -923,36 +957,52 @@
// Scan likely media hosts even when light-DOM videos exist; many players
function reportLog(message, level = 'info') {
chrome.runtime.sendMessage({ type: 'LOG', message, level }).catch(() => {});
}
// expose a tiny preview/ad video outside Shadow DOM and the real player inside.
const potentialHosts = root.querySelectorAll('[id*="player" i], [class*="player" i], [id*="video" i], [class*="video" i], [id*="media" i], [class*="media" i], [id*="stream" i], [class*="stream" i], ytd-player, netflix-player, emby-player, jellyfin-player, video-player');
for (const el of potentialHosts) {
if (el.shadowRoot) {
const found = findVideo(el.shadowRoot);
if (found) candidates.push(found);
}
}
if (candidates.length === 0) return null;
// Multiple videos found → pick the best one
const candidates = Array.from(root.querySelectorAll('video'));
// Scan likely media hosts even when light-DOM videos exist; many players
// expose a tiny preview/ad video outside Shadow DOM and the real player inside.
const potentialHosts = root.querySelectorAll('[id*="player" i], [class*="player" i], [id*="video" i], [class*="video" i], [id*="media" i], [class*="media" i], [id*="stream" i], [class*="stream" i], ytd-player, netflix-player, emby-player, jellyfin-player, video-player');
if (candidates.length === 1) return candidates[0];
let best = null;
let bestScore = -1;
for (const v of candidates) {
if (v.tagName !== 'VIDEO') continue;
// Score: visible area + bonus for unmuted + bonus for longer duration
const area = (v.videoWidth || v.offsetWidth || 0) * (v.videoHeight || v.offsetHeight || 0);
const unmutedBonus = v.muted ? 0 : 100000;
const durationBonus = (v.duration && isFinite(v.duration) ? v.duration : 0) * 100;
const score = area + unmutedBonus + durationBonus;
if (score > bestScore) {
bestScore = score;
@@ -1019,7 +1069,7 @@
...safeSettings,
compressor: {
...DEFAULT_AUDIO_SETTINGS.compressor,
@@ -1037,7 +1087,7 @@
};
}
@@ -1045,15 +1095,15 @@
if (!audioCtx) {
try {
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) return null;
audioCtx = new AudioContextClass({ latencyHint: 'interactive' });
} catch (e) {
reportLog(`Audio Processing unavailable: ${e.message}`, 'warn');
@@ -1071,7 +1121,7 @@
return audioCtx;
}
@@ -1082,7 +1132,7 @@
audioCtx.close().catch(() => {});
audioCtx = null;
if (!audioCtx) {
}
audioChains = new WeakMap();
@@ -1265,14 +1315,16 @@
: null;
}
// Extract a canonical episode identifier from a title string.
// Handles: S01E01, S1E1, S01 - E01, Season 1 Episode 1, "Folge 5", "Episode 5", "Ep. 5", "#5"
chain.compressor.release.value = params.release ?? 0.300;
// Returns null if no episode pattern found.
// --- SHARED_EPISODE_UTILS_INJECT_START ---
// This block is automatically replaced by /scripts/build-extension.cjs
@@ -1280,7 +1332,7 @@
if (!title || typeof title !== 'string') return null;
rampGain(chain.dryGain, 0, t);
const se = title.match(/S(?:eason\s*)?(\d+)[^a-zA-Z0-9]*E(?:pisode\s*)?(\d+)/i);
if (se) return `S${String(se[1]).padStart(2, '0')}E${String(se[2]).padStart(2, '0')}`;
@@ -1308,8 +1360,9 @@
if (idA || idB) return false;
// Extract a canonical episode identifier from a title string.
return titleA === titleB;
}
// --- SHARED_EPISODE_UTILS_INJECT_END ---
@@ -1373,42 +1426,64 @@
function onEpisodeTransition(newTitle) {
return idA !== idB; // Both parseable → only block if different
// Debounce: prevent duplicate fires from multiple signals
if (episodeTransitionDebounce) return;
episodeTransitionDebounce = setTimeout(() => {
function checkEpisodeTransition() {
const currentTitle = getMediaTitle();
episodeTransitionDebounce = null;
}, 2000);
reportLog(`Episode transition detected: "${newTitle}"`, 'info');
// EC-12: a new episode dissolves any solo/desync state — the guest rejoins
// the room for the fresh content rather than staying stuck on the old one.
if (hcmDesynced) hcmReset();
const current = video ? getSyncCurrentTime(video) : null;
// 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)
&& extractEpisodeId(currentTitle) !== null
// Do NOT pause here. We notify background.js first.
// Background checks the setting; if enabled it creates a lobby
// and sends back PAUSE_FOR_LOBBY so we only freeze if the feature is on.
runtimeMessage({
type: 'EPISODE_CHANGED',
payload: { newTitle }
}).catch(() => {});
}
onEpisodeTransition(currentTitle);
}
// Always track the latest known title
if (currentTitle) lastKnownMediaTitle = currentTitle;
function checkAndReportLobbyReady(expectedTitle) {
const video = findVideo();
const currentTitle = getMediaTitle();
const current = video ? getSyncCurrentTime(video) : null;
if (video && currentTitle && sameEpisode(currentTitle, expectedTitle)
&& 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');
video.pause();
@@ -1475,8 +1550,9 @@
}
video.pause();
function stopLobbyPoll() {
_pendingLobbyTitle = null;
@@ -1492,9 +1568,10 @@
}
return true;
}
function getPlayerActionFixes() {
return [
{
name: 'youtube-player-buttons',
urls: ['youtube.com'],
playPauseButtonSelector: '.ytp-play-button'
@@ -1509,8 +1586,9 @@
function getActivePlayerActionFix() {
return getPlayerActionFixes().find(fix => matchesPlayerUrls(fix.urls)) || null;
// NOTE: Do NOT pause here. Three callers reach this function:
}
function tryPlayerActionFix(fix, action, video, data) {
if (!fix) return false;
const button = document.querySelector(fix.playPauseButtonSelector);
if (!button) return false;
@@ -1532,8 +1610,9 @@
if (!video) return;
lobbyPollTimer = setInterval(() => {
if (action === EVENTS.SEEK) {
const target = data ? (data.targetTime !== undefined ? data.targetTime : data.currentTime) : undefined;
if (!Number.isFinite(target)) {
@@ -1552,12 +1631,13 @@
const actionFix = getActivePlayerActionFix();
if (tryPlayerActionFix(actionFix, action, video, data)) {
return;
lobbyPollTimer = null;
}
// Fallback for native HTML5
if (action === EVENTS.PLAY) {
_setSuppress('playing');
video.play().catch((e) => {
reportLog(`Playback prevented: ${e.message}`, 'warn');
@@ -1579,18 +1659,22 @@
} catch (e) {
reportLog(`Media Action Error: ${e.message}`, 'error');
function tryPlayerActionFix(fix, action, video, data) {
if (!fix) return false;
const button = document.querySelector(fix.playPauseButtonSelector);
if (!button) return false;
}
}
// --- Helper: Wait until video is ready for playback (buffered & seeked) ---
function pollSeekReady(targetTime, timeoutMs = 8000) {
button.click();
}
if (action === EVENTS.SEEK) {
seekVideo(video, data.targetTime, data.delta);
return new Promise((resolve) => {
const interval = 150;
let elapsed = 0;
const timer = setInterval(() => {
if (destroyed) {
clearInterval(timer);
seekPollTimers.delete(timer);
resolve(false);
return;
@@ -1599,30 +1683,104 @@
if (!video) {
clearInterval(timer);
seekPollTimers.delete(timer);
if (action === EVENTS.SEEK) {
resolve(false);
return;
}
if (!Number.isFinite(target)) {
reportLog(`Media Action Error: Invalid seek payload - ${JSON.stringify(data)}`, 'error');
elapsed += interval;
const current = getSyncCurrentTime(video);
const timeDiff = current !== null ? Math.abs(current - targetTime) : Infinity;
const ready = video.readyState >= 3 && timeDiff < 2.0;
if (ready) {
}
data = { ...data, targetTime: target };
clearInterval(timer);
seekPollTimers.delete(timer);
resolve(true);
} else if (elapsed >= timeoutMs) {
clearInterval(timer);
seekPollTimers.delete(timer);
resolve(false);
}
}, interval);
seekPollTimers.add(timer);
});
}
// Listen for commands from background.js
function handleRuntimeMessage(message, sender, sendResponse) {
if (!message) return;
if (message.type === 'TARGET_DEACTIVATE') {
destroyContentScript();
sendResponse({ ok: true });
return true;
}
if (destroyed) return;
if (message.action === 'get_current_time') {
const video = findVideo();
sendResponse({ currentTime: video ? getSyncCurrentTime(video) : null });
return true;
}
if (message.action === 'APPLY_AUDIO_SETTINGS') {
_audioProcessingAllowed = true;
_audioSettings = mergeAudioSettings(message.settings);
const video = findVideo();
if (video) applyAudioSettings(video, _audioSettings);
sendResponse({ ok: true });
return true;
}
if (message.action === 'RESET_AUDIO_PROCESSING') {
_audioProcessingAllowed = false;
bypassCurrentAudioProcessing();
sendResponse({ ok: true });
return true;
}
// Host Control Mode: room mode/role changed.
if (message.type === 'CONTROL_MODE') {
const wasGated = hcmIsGuestGated();
const prevHostPeerId = hcmHostPeerId;
hcmControlMode = message.controlMode || 'everyone';
hcmAmController = !!message.amController;
hcmHostPeerId = message.hostPeerId || null;
// Reset guest-side state when leaving the gated state, OR when the
// host identity changes (room switch, host-leave fallback, missed
try {
// teardown broadcast) — clears stale desync so a rejoin starts clean (H-3).
const hostChanged = prevHostPeerId !== null && hcmHostPeerId !== prevHostPeerId;
return;
}
if ((wasGated && !hcmIsGuestGated()) || hostChanged) hcmReset();
sendResponse({ ok: true });
@@ -1631,8 +1789,8 @@
}
reportLog(`Playback prevented: ${e.message}`, 'warn');
// Host Control Mode: background blocked our local action — handle UX locally.
if (message.type === 'HOST_BLOCKED') {
@@ -1647,8 +1805,8 @@
// Background asks for an immediate state push (e.g. the first peer just
} catch (e) {
// joined while we were solo) so the newcomer syncs without waiting.
if (message.type === 'REQUEST_HEARTBEAT') {
+1 -1
View File
@@ -277,7 +277,7 @@
"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_TOO_LONG": "Maximal 5000 Zeichen.",
"CHAT_SEND_FAILED": "Nachricht konnte nicht gesendet werden.",
"CHAT_EMPTY": "Noch keine Nachrichten. Der Chat ist nur live.",
"LABEL_CHAT_NOTIFICATIONS": "Chat-Benachrichtigungen",
+1 -1
View File
@@ -277,7 +277,7 @@
"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_TOO_LONG": "Maximum 5000 characters.",
"CHAT_SEND_FAILED": "Message could not be sent.",
"CHAT_EMPTY": "No messages yet. Chat is live only.",
"LABEL_CHAT_NOTIFICATIONS": "Chat Notifications",
+1 -1
View File
@@ -277,7 +277,7 @@
"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_TOO_LONG": "Máximo 5000 caracteres.",
"CHAT_SEND_FAILED": "No se pudo enviar el mensaje.",
"CHAT_EMPTY": "Aún no hay mensajes. El chat solo es en directo.",
"LABEL_CHAT_NOTIFICATIONS": "Notificaciones del chat",
+1 -1
View File
@@ -277,7 +277,7 @@
"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_TOO_LONG": "5000 caractères maximum.",
"CHAT_SEND_FAILED": "Le message n'a pas pu être envoyé.",
"CHAT_EMPTY": "Aucun message. Le chat est uniquement en direct.",
"LABEL_CHAT_NOTIFICATIONS": "Notifications du chat",
+1 -1
View File
@@ -277,7 +277,7 @@
"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_TOO_LONG": "Massimo 5000 caratteri.",
"CHAT_SEND_FAILED": "Impossibile inviare il messaggio.",
"CHAT_EMPTY": "Nessun messaggio. La chat è solo in diretta.",
"LABEL_CHAT_NOTIFICATIONS": "Notifiche chat",
+1 -1
View File
@@ -277,7 +277,7 @@
"CHAT_PLACEHOLDER": "メッセージを入力...",
"CHAT_SEND": "送信",
"CHAT_MISSING_KEY": "チャットは最新の招待リンクからのみ利用できます。",
"CHAT_TOO_LONG": "最大500文字です。",
"CHAT_TOO_LONG": "最大5000文字です。",
"CHAT_SEND_FAILED": "メッセージを送信できませんでした。",
"CHAT_EMPTY": "まだメッセージはありません。チャットはライブのみです。",
"LABEL_CHAT_NOTIFICATIONS": "チャット通知",
+1 -1
View File
@@ -277,7 +277,7 @@
"CHAT_PLACEHOLDER": "메시지를 입력하세요...",
"CHAT_SEND": "보내기",
"CHAT_MISSING_KEY": "채팅은 최신 초대 링크를 통해서만 사용할 수 있습니다.",
"CHAT_TOO_LONG": "최대 500자입니다.",
"CHAT_TOO_LONG": "최대 5000자입니다.",
"CHAT_SEND_FAILED": "메시지를 보낼 수 없습니다.",
"CHAT_EMPTY": "아직 메시지가 없습니다. 채팅은 실시간 전용입니다.",
"LABEL_CHAT_NOTIFICATIONS": "채팅 알림",
+1 -1
View File
@@ -277,7 +277,7 @@
"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_TOO_LONG": "Maximaal 5000 tekens.",
"CHAT_SEND_FAILED": "Bericht kon niet worden verzonden.",
"CHAT_EMPTY": "Nog geen berichten. Chat is alleen live.",
"LABEL_CHAT_NOTIFICATIONS": "Chatmeldingen",
+1 -1
View File
@@ -277,7 +277,7 @@
"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_TOO_LONG": "Maksymalnie 5000 znaków.",
"CHAT_SEND_FAILED": "Nie udało się wysłać wiadomości.",
"CHAT_EMPTY": "Brak wiadomości. Czat działa tylko na żywo.",
"LABEL_CHAT_NOTIFICATIONS": "Powiadomienia czatu",
+1 -1
View File
@@ -277,7 +277,7 @@
"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_TOO_LONG": "Máximo de 5000 caracteres.",
"CHAT_SEND_FAILED": "Não foi possível enviar a mensagem.",
"CHAT_EMPTY": "Ainda não há mensagens. O chat é somente ao vivo.",
"LABEL_CHAT_NOTIFICATIONS": "Notificações do chat",
+1 -1
View File
@@ -277,7 +277,7 @@
"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_TOO_LONG": "Máximo de 5000 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.",
"LABEL_CHAT_NOTIFICATIONS": "Notificações do chat",
+1 -1
View File
@@ -277,7 +277,7 @@
"CHAT_PLACEHOLDER": "Напишите сообщение...",
"CHAT_SEND": "Отправить",
"CHAT_MISSING_KEY": "Чат доступен только по актуальной ссылке-приглашению.",
"CHAT_TOO_LONG": "Не более 500 символов.",
"CHAT_TOO_LONG": "Не более 5000 символов.",
"CHAT_SEND_FAILED": "Не удалось отправить сообщение.",
"CHAT_EMPTY": "Сообщений пока нет. Чат работает только в реальном времени.",
"LABEL_CHAT_NOTIFICATIONS": "Уведомления чата",
+1 -1
View File
@@ -277,7 +277,7 @@
"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_TOO_LONG": "En fazla 5000 karakter.",
"CHAT_SEND_FAILED": "Mesaj gönderilemedi.",
"CHAT_EMPTY": "Henüz mesaj yok. Sohbet yalnızca canlıdır.",
"LABEL_CHAT_NOTIFICATIONS": "Sohbet bildirimleri",
+1 -1
View File
@@ -277,7 +277,7 @@
"CHAT_PLACEHOLDER": "Напишіть повідомлення...",
"CHAT_SEND": "Надіслати",
"CHAT_MISSING_KEY": "Чат доступний лише за актуальним посиланням-запрошенням.",
"CHAT_TOO_LONG": "Не більше 500 символів.",
"CHAT_TOO_LONG": "Не більше 5000 символів.",
"CHAT_SEND_FAILED": "Не вдалося надіслати повідомлення.",
"CHAT_EMPTY": "Повідомлень ще немає. Чат працює лише наживо.",
"LABEL_CHAT_NOTIFICATIONS": "Сповіщення чату",
+1 -1
View File
@@ -277,7 +277,7 @@
"CHAT_PLACEHOLDER": "输入消息...",
"CHAT_SEND": "发送",
"CHAT_MISSING_KEY": "聊天仅可通过当前邀请链接使用。",
"CHAT_TOO_LONG": "最多500个字符。",
"CHAT_TOO_LONG": "最多5000个字符。",
"CHAT_SEND_FAILED": "无法发送消息。",
"CHAT_EMPTY": "暂无消息。聊天仅实时显示。",
"LABEL_CHAT_NOTIFICATIONS": "聊天通知",
+70
View File
@@ -0,0 +1,70 @@
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 backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
const contentSource = fs.readFileSync(path.join(extensionDir, 'content.js'), 'utf8');
const overlaySource = fs.readFileSync(path.join(extensionDir, 'chat-overlay.js'), 'utf8');
describe('target tab lifecycle', () => {
it('injects playback and chat scripts only into the explicitly selected tab', () => {
expect(backgroundSource).not.toContain('chrome.tabs.onActivated');
expect(backgroundSource).not.toContain('chrome.tabs.query({})');
expect(backgroundSource).toContain("target: { tabId }");
expect(backgroundSource).toContain("files: ['chat-format.js', 'chat-overlay.js', 'content.js']");
expect(backgroundSource).toContain("chrome.tabs.query({ url: 'https://sync.koalastuff.net/*' })");
const activationStart = backgroundSource.indexOf('async function activateTargetTab');
const activationEnd = backgroundSource.indexOf('async function reactivateCurrentTarget', activationStart);
const activationSource = backgroundSource.slice(activationStart, activationEnd);
expect(activationSource.indexOf('await deactivateTargetTab(previousTabId)'))
.toBeLessThan(activationSource.indexOf('await injectContentScript(selectedTabId'));
expect(activationSource).toContain('previousTabId !== selectedTabId');
expect(contentSource).toContain('if (window.koalaSyncInjected && chrome.runtime.id)');
expect(overlaySource).toContain('if (window.koalaSyncChatOverlay?.refresh)');
});
it('fully deactivates old and superseded target injections', () => {
expect(backgroundSource).toContain("chrome.tabs.sendMessage(normalizedTabId, { type: 'TARGET_DEACTIVATE' })");
expect(backgroundSource.match(/await deactivateTargetTab\(selectedTabId\)/g)?.length).toBeGreaterThanOrEqual(4);
expect(contentSource).toContain("if (message.type === 'TARGET_DEACTIVATE')");
expect(overlaySource).toContain("message?.type === 'TARGET_DEACTIVATE'");
});
it('tears down every persistent content-script resource', () => {
expect(contentSource).toContain('function destroyContentScript()');
expect(contentSource).toContain('observer.disconnect()');
expect(contentSource).toContain('keepAlivePort.disconnect()');
expect(contentSource).toContain('for (const video of attachedVideos)');
expect(contentSource).toContain("document.removeEventListener('visibilitychange', handleVisibilityChange)");
expect(contentSource).toContain("window.removeEventListener('pagehide', handlePageHide)");
expect(contentSource).toContain("window.removeEventListener('pageshow', handlePageShow)");
expect(contentSource).toContain('chrome.storage.onChanged.removeListener(handleStorageChanged)');
expect(contentSource).toContain('chrome.runtime.onMessage.removeListener(handleRuntimeMessage)');
expect(contentSource).toContain('window.koalaSyncInjected = false');
});
it('stops the MAIN-world seek bridge when its target is deactivated', () => {
expect(backgroundSource).toContain('const timelineInterval = setInterval');
expect(backgroundSource).toContain('clearInterval(timelineInterval)');
expect(backgroundSource).toContain("window.removeEventListener('message', handleBridgeMessage)");
expect(contentSource).toContain("kind: 'destroy'");
});
it('self-cleans instead of throwing when an extension reload invalidates the context', () => {
expect(contentSource).toContain('if (!chrome.runtime?.id)');
expect(contentSource).toContain('destroyContentScript()');
expect(contentSource).toContain('return chrome.runtime.sendMessage(message, callback) || Promise.resolve(undefined)');
expect(contentSource).toContain('function handleVisibilityChange()');
});
it('suspends background work for bfcache and restores it without duplicate injection', () => {
expect(contentSource).toContain('pageSuspended = true');
expect(contentSource).toContain('if (destroyed || pageSuspended) return');
expect(contentSource).toContain('if (!destroyed && !pageSuspended) scheduleLifecycleTimeout(connectKeepAlivePort, 1000)');
expect(contentSource).toMatch(/function handlePageShow\(event\)[\s\S]*if \(!event\.persisted\) return;[\s\S]*pageSuspended = false/);
expect(contentSource).toMatch(/function handlePageShow\(event\)[\s\S]*observer\.observe\(document\.documentElement[\s\S]*setupListeners\(\)[\s\S]*connectKeepAlivePort\(\)/);
});
});