mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-07-31 22:52:14 +00:00
fix(extension): harden chat and target tab lifecycle
This commit is contained in:
+6
-1
@@ -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
@@ -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
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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') {
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "チャット通知",
|
||||
|
||||
@@ -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": "채팅 알림",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Уведомления чата",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Сповіщення чату",
|
||||
|
||||
@@ -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": "聊天通知",
|
||||
|
||||
@@ -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\(\)/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user