revert(extension): drop post-3.1.2 frame-targeting band-aids

Restores extension/ to the state directly after webNavigation was removed
(4d78970). The six follow-up commits layered heuristics on an unverified
premise (frame-ID sweeps, multi-phase probes, retry loops) without fixing
the underlying resolver. They are removed so the real fix can be built on
a known state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Timo
2026-08-17 23:53:10 +02:00
parent 804c36c694
commit 04694d4439
23 changed files with 245 additions and 744 deletions
+151 -340
View File
@@ -11,6 +11,7 @@ import { createChatActivityStore } from './chat-activity.js';
import { HOST_ACCESS_REQUIRED_STATUS, normalizeTabId, inspectTabHostAccess, isHostAccessError, addTabHostAccessRequest, removeTabHostAccessRequest } from './host-access.js';
import {
MEDIA_FRAME_ACCESS_REQUIRED,
MEDIA_FRAME_AMBIGUOUS,
listMediaFrameScriptTargets,
resolveMediaContentTarget
} from './media-frame-target.js';
@@ -74,15 +75,6 @@ let currentTabTitle = null; // New: for Smart Matching
let currentTargetFrameId = 0;
let currentTargetDocumentId = null;
let currentTargetHasVideo = false;
// The user's selection is kept separately from the currently injected media
// target. Dynamic player pages can be between frame documents while the
// popup is closed; losing the selection in that window makes reopening the
// popup look as if the user never selected a tab.
let requestedTargetTabId = null;
let requestedTargetTitle = null;
let requestedTargetRetryBlockedTabId = null;
let requestedTargetRetryBlockedMessage = null;
let pendingRequestedActivationCount = 0;
let targetActivationGeneration = 0;
let activeTargetActivation = null;
let mediaTargetRefreshTask = null;
@@ -225,8 +217,6 @@ function ensureState() {
'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks',
'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'reconnectAttempts', 'currentTabId', 'currentTabTitle',
'currentTargetFrameId', 'currentTargetDocumentId', 'currentTargetHasVideo',
'requestedTargetTabId', 'requestedTargetTitle',
'requestedTargetRetryBlockedTabId', 'requestedTargetRetryBlockedMessage',
'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount', 'roomIdleSince', 'lastContentHeartbeatAt',
'hcmDesynced', 'chatActivityTimeline'
], (data) => {
@@ -244,16 +234,6 @@ function ensureState() {
? data.currentTargetDocumentId
: null;
currentTargetHasVideo = currentTabId !== null && data.currentTargetHasVideo === true;
requestedTargetTabId = normalizeTabId(data.requestedTargetTabId);
requestedTargetTitle = requestedTargetTabId !== null
&& typeof data.requestedTargetTitle === 'string'
? data.requestedTargetTitle
: null;
requestedTargetRetryBlockedTabId = normalizeTabId(data.requestedTargetRetryBlockedTabId);
requestedTargetRetryBlockedMessage = requestedTargetRetryBlockedTabId !== null
&& typeof data.requestedTargetRetryBlockedMessage === 'string'
? data.requestedTargetRetryBlockedMessage
: null;
if (data.currentTabTitle !== undefined) {
currentTabTitle = currentTabId !== null && typeof data.currentTabTitle === 'string'
? data.currentTabTitle
@@ -683,14 +663,6 @@ function sendMessageToContentTab(tabId, message, callback = null) {
return chrome.tabs.sendMessage(tabId, message);
}
function isMissingContentReceiverError(error) {
const message = String(error?.message || error || '');
return message.includes('Receiving end does not exist')
|| message.includes('Could not establish connection')
|| message.includes('No document with id')
|| message.includes('No document with ID');
}
function isCurrentContentSender(sender) {
if (!sender?.tab) return false;
const senderTabId = normalizeTabId(sender.tab.id);
@@ -721,6 +693,38 @@ function clearCurrentContentTarget() {
currentTargetHasVideo = false;
}
function clearTargetTabForIdle(expectedTabId = null, expectedGeneration = null) {
if (expectedTabId !== null && normalizeTabId(currentTabId) !== normalizeTabId(expectedTabId)) {
return false;
}
if (expectedGeneration !== null && targetActivationGeneration !== expectedGeneration) {
return false;
}
completeForceSyncBeforeTargetChange(null);
invalidateTargetActivations();
clearPendingTarget().catch(() => {});
if (currentTabId) deactivateTargetTab(currentTabId).catch(() => {});
currentTabId = null;
currentTabTitle = null;
clearCurrentContentTarget();
lastContentHeartbeatAt = null;
if (currentRoom) {
roomIdleSince = Date.now();
}
chrome.storage.session.set({
currentTabId,
currentTabTitle,
currentTargetFrameId,
currentTargetDocumentId,
currentTargetHasVideo,
roomIdleSince,
lastContentHeartbeatAt
}).catch(() => {});
updateBadgeStatus();
return true;
}
async function leaveRoomAfterIdleGrace(reason) {
if (!currentRoom) return;
connectIntent = false;
@@ -742,7 +746,6 @@ async function leaveRoomAfterIdleGrace(reason) {
broadcastControlMode();
if (currentTabId) await deactivateTargetTab(currentTabId);
invalidateTargetActivations();
await clearRequestedTarget();
currentTabId = null;
currentTabTitle = null;
clearCurrentContentTarget();
@@ -1125,48 +1128,6 @@ function chatActivityDisplayName(senderId) {
return typeof peer === 'object' ? peer.username || senderId : senderId;
}
async function deliverChatActivity(entry) {
const tabId = normalizeTabId(currentTabId);
if (tabId === null) return;
const generation = targetActivationGeneration;
try {
await sendMessageToCurrentContent({
type: 'CHAT_EVENT',
event: entry
});
return;
} catch (error) {
if (!isMissingContentReceiverError(error)) {
addLog(`Chat activity delivery failed: ${error.message}`, 'warn');
return;
}
if (!isCurrentTargetIdentity(tabId, generation)) return;
}
for (let attempt = 0; attempt < 3; attempt++) {
try {
const activation = await refreshCurrentMediaTarget(tabId, { queueIfRunning: true });
if (activation?.status === 'activation_in_progress') {
await new Promise(resolve => setTimeout(resolve, 150));
continue;
}
if (activation?.status !== 'ok') return;
if (!isCurrentTargetIdentity(tabId, activation.generation)) return;
await sendMessageToCurrentContent({
type: 'CHAT_EVENT',
event: entry
});
return;
} catch (error) {
if (!isMissingContentReceiverError(error) || isCurrentTargetIdentity(tabId, generation)) {
addLog(`Chat activity delivery failed after target recovery: ${error.message}`, 'warn');
}
return;
}
}
}
function sendChatActivity(action, senderId, timestamp = Date.now()) {
if (!currentRoom || !serverSupportsChat()) return;
if (![EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK, EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE, 'joined', 'left'].includes(action)) return;
@@ -1179,9 +1140,10 @@ function sendChatActivity(action, senderId, timestamp = Date.now()) {
if (!entry) return;
if (storageInitialized) chrome.storage.session.set({ chatActivityTimeline: chatActivityStore.snapshot() }).catch(() => {});
if (!currentTabId) return;
deliverChatActivity(entry).catch(error => {
addLog(`Chat activity delivery failed: ${error.message}`, 'warn');
});
sendMessageToCurrentContent({
type: 'CHAT_EVENT',
event: entry
}).catch(error => addLog(`Chat activity delivery failed: ${error.message}`, 'warn'));
}
function scheduleReconnect() {
@@ -2067,32 +2029,20 @@ async function getReadyTabVideoState(tabId, expectedGeneration = targetActivatio
if (!isCurrentTargetIdentity(tabId, expectedGeneration)) {
return { error: 'Target tab changed before video state could be read' };
}
let state = null;
let targetGeneration = expectedGeneration;
for (let attempt = 0; attempt < 3; attempt++) {
state = await getTabVideoState(tabId);
if (state && !state.error && state.found !== false) break;
if (!isCurrentTargetIdentity(tabId, targetGeneration)) {
return { error: 'Target tab changed before video state could be read' };
}
const activation = await refreshCurrentMediaTarget(tabId, { queueIfRunning: true });
if (activation?.status === 'activation_in_progress') {
await new Promise(resolve => setTimeout(resolve, 150));
if (normalizeTabId(currentTabId) !== tabId) {
return { error: 'Target tab changed before video state could be read' };
}
targetGeneration = targetActivationGeneration;
continue;
}
let state = await getTabVideoState(tabId);
if (!state || state.error || state.found === false) {
const activation = await refreshCurrentMediaTarget(tabId);
if (activation?.status !== 'ok') {
return { error: 'Target tab changed before content script recovery completed' };
}
targetGeneration = activation.generation;
if (!isCurrentTargetIdentity(tabId, targetGeneration)) {
await new Promise(resolve => setTimeout(resolve, 250));
if (!isCurrentTargetIdentity(tabId, activation.generation)) {
return { error: 'Target tab changed before video state could be read' };
}
await new Promise(resolve => setTimeout(resolve, 150));
state = await getTabVideoState(tabId);
if (!isCurrentTargetIdentity(tabId, activation.generation)) {
return { error: 'Target tab changed while video state was being read' };
}
}
return decorateVideoState(tabId, state);
}
@@ -2239,44 +2189,38 @@ function setPageApiSeekEnabled(enabled) {
window.KOALA_PAGE_API_SEEK_ENABLED = enabled === true;
}
function uniqueScriptTargets(targets) {
const seen = new Set();
return targets.filter(target => {
const key = JSON.stringify(target);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function deactivateMediaFrameMonitor() {
try { window.__koalaMediaFrameMonitorCleanup?.(); } catch { /* detached frame */ }
}
async function deactivateMediaFrameMonitors(tabId, contentTarget = null) {
const targets = uniqueScriptTargets([
contentTarget?.scriptTarget,
...(contentTarget?.monitorTargets || []),
{ tabId },
...listMediaFrameScriptTargets(tabId)
].filter(Boolean));
for (const target of targets) {
async function deactivateMediaFrameMonitors(tabId) {
const targets = listMediaFrameScriptTargets(tabId);
await Promise.all(targets.map(async target => {
const documentId = target.documentIds?.[0];
const frameId = target.frameIds?.[0];
try {
await chrome.scripting.executeScript({
target,
func: deactivateMediaFrameMonitor
});
if (typeof documentId === 'string') {
await chrome.tabs.sendMessage(
tabId,
{ type: 'MEDIA_MONITOR_DEACTIVATE' },
{ documentId }
);
} else if (Number.isInteger(frameId)) {
await chrome.tabs.sendMessage(
tabId,
{ type: 'MEDIA_MONITOR_DEACTIVATE' },
{ frameId }
);
} else {
await chrome.tabs.sendMessage(tabId, { type: 'MEDIA_MONITOR_DEACTIVATE' });
}
} catch {
// Denied or already-navigated frames have no installed monitor.
}
}
}));
}
async function deactivateTargetTab(tabId, contentTarget = null, { deactivateMonitor = true } = {}) {
const normalizedTabId = normalizeTabId(tabId);
if (normalizedTabId === null) return;
if (deactivateMonitor) {
await deactivateMediaFrameMonitors(normalizedTabId, contentTarget);
await deactivateMediaFrameMonitors(normalizedTabId);
}
const target = contentTarget
|| (normalizedTabId === normalizeTabId(currentTabId) ? currentContentTarget() : null)
@@ -2287,7 +2231,7 @@ async function deactivateTargetTab(tabId, contentTarget = null, { deactivateMoni
}
: null)
|| { frameId: 0, documentId: null };
await resetAudioProcessingInTab(normalizedTabId, target);
resetAudioProcessingInTab(normalizedTabId, target);
await sendMessageToFrame(
normalizedTabId,
target.frameId,
@@ -2348,57 +2292,38 @@ function createTargetActivationSupersededError() {
return error;
}
const SCRIPT_INJECTION_TIMEOUT_MS = 5000;
function executeScriptWithTimeout(options, timeoutMs = SCRIPT_INJECTION_TIMEOUT_MS) {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
return chrome.scripting.executeScript(options);
}
let timeoutId = null;
const label = Array.isArray(options?.files) && options.files.length > 0
? options.files.join(', ')
: 'function injection';
const timeout = new Promise((_, reject) => {
timeoutId = setTimeout(() => {
const error = new Error(`Script injection timed out after ${timeoutMs}ms (${label})`);
error.code = 'script_injection_timeout';
reject(error);
}, timeoutMs);
});
return Promise.race([
chrome.scripting.executeScript(options),
timeout
]).finally(() => {
if (timeoutId !== null) clearTimeout(timeoutId);
});
}
async function injectMediaFrameMonitors(tabId, contentTarget) {
// The all-frames target is only a best-effort sweep: one inaccessible
// frame can make Chromium reject the entire sweep. Always include the
// selected document explicitly so its lifecycle monitor is guaranteed.
const targets = uniqueScriptTargets([
contentTarget?.scriptTarget,
...(contentTarget?.monitorTargets || []),
{ tabId },
...listMediaFrameScriptTargets(tabId)
].filter(Boolean));
const targets = listMediaFrameScriptTargets(tabId);
let injectedCount = 0;
for (const target of targets) {
await Promise.all(targets.map(async target => {
try {
await executeScriptWithTimeout({
await chrome.scripting.executeScript({
target,
files: ['media-frame-monitor.js']
}, 2000);
});
injectedCount++;
} catch (error) {
if (error?.code === 'script_injection_timeout') {
addLog(`Media-frame monitor injection timed out for ${JSON.stringify(target)}`, 'warn');
}
} catch {
// One denied widget frame must not block the selected player.
}
}));
if (injectedCount > 0) return;
const fallbackTargets = [{ tabId }, contentTarget.scriptTarget];
const seen = new Set();
for (const target of fallbackTargets) {
const key = JSON.stringify(target);
if (seen.has(key)) continue;
seen.add(key);
try {
await chrome.scripting.executeScript({
target,
files: ['media-frame-monitor.js']
});
injectedCount++;
} catch {
// Main injection below reports a real selected-target failure.
}
}
return injectedCount;
}
async function injectContentScript(tabId, {
@@ -2440,7 +2365,8 @@ async function injectContentScript(tabId, {
originPattern: error.originPattern
}, requestAdded, error);
}
throw error;
if (error?.code === MEDIA_FRAME_AMBIGUOUS) throw error;
addLog(`Media frame probe fell back to the top frame: ${error.message}`, 'warn');
}
const scriptTarget = contentTarget.scriptTarget;
@@ -2460,12 +2386,12 @@ async function injectContentScript(tabId, {
}
if (needsPageApiSeek) {
try {
await executeScriptWithTimeout({
await chrome.scripting.executeScript({
target: scriptTarget,
world: 'MAIN',
files: ['page-api-seek-overrides.js']
});
await executeScriptWithTimeout({
await chrome.scripting.executeScript({
target: scriptTarget,
world: 'MAIN',
func: installPageApiSeekBridge
@@ -2476,16 +2402,16 @@ async function injectContentScript(tabId, {
}
}
await executeScriptWithTimeout({
await chrome.scripting.executeScript({
target: scriptTarget,
files: ['page-api-seek-overrides.js']
});
await executeScriptWithTimeout({
await chrome.scripting.executeScript({
target: scriptTarget,
func: setPageApiSeekEnabled,
args: [pageApiSeekReady]
});
const injectionResults = await executeScriptWithTimeout({
const injectionResults = await chrome.scripting.executeScript({
target: scriptTarget,
files: ['chat-format.js', 'chat-overlay.js', 'content.js']
});
@@ -2683,83 +2609,6 @@ async function clearPendingTarget({ expectedRequestId = null, expectedTabId = nu
});
}
async function rememberRequestedTarget(tabId, tabTitle) {
const normalizedTabId = normalizeTabId(tabId);
if (normalizedTabId === null) return false;
requestedTargetTabId = normalizedTabId;
requestedTargetTitle = typeof tabTitle === 'string' ? tabTitle : null;
requestedTargetRetryBlockedTabId = null;
requestedTargetRetryBlockedMessage = null;
await chrome.storage.session.set({
requestedTargetTabId,
requestedTargetTitle,
requestedTargetRetryBlockedTabId: null,
requestedTargetRetryBlockedMessage: null
});
return true;
}
async function clearRequestedTarget(expectedTabId = null) {
if (expectedTabId !== null
&& normalizeTabId(requestedTargetTabId) !== normalizeTabId(expectedTabId)) {
return false;
}
requestedTargetTabId = null;
requestedTargetTitle = null;
requestedTargetRetryBlockedTabId = null;
requestedTargetRetryBlockedMessage = null;
await chrome.storage.session.set({
requestedTargetTabId: null,
requestedTargetTitle: null,
requestedTargetRetryBlockedTabId: null,
requestedTargetRetryBlockedMessage: null
});
return true;
}
async function retryRequestedTarget() {
const selectedTabId = normalizeTabId(requestedTargetTabId);
if (selectedTabId === null
|| normalizeTabId(currentTabId) === selectedTabId
|| pendingRequestedActivationCount > 0
|| activeTargetActivation
|| normalizeTabId(requestedTargetRetryBlockedTabId) === selectedTabId) {
return null;
}
const pending = await readPendingTarget();
if (pending) return null;
try {
await chrome.tabs.get(selectedTabId);
} catch {
await clearRequestedTarget(selectedTabId);
return { status: 'target_closed' };
}
try {
const response = await activateTargetTab(selectedTabId, requestedTargetTitle, {
requestHostAccess: true,
expectedGeneration: targetActivationGeneration
});
if (response?.status === 'ok') {
await clearRequestedTarget(selectedTabId);
}
return response;
} catch (error) {
if (error?.code !== HOST_ACCESS_REQUIRED_STATUS) {
requestedTargetRetryBlockedTabId = selectedTabId;
requestedTargetRetryBlockedMessage = error?.message || 'Script injection failed';
await chrome.storage.session.set({
requestedTargetRetryBlockedTabId,
requestedTargetRetryBlockedMessage
});
}
addLog(`Requested target retry failed: ${error.message}`, 'warn');
return injectionFailureResponse(error);
}
}
async function activateTargetTab(tabId, tabTitle, {
requestHostAccess = true,
expectedGeneration = null,
@@ -2785,6 +2634,9 @@ async function activateTargetTab(tabId, tabTitle, {
let injectedContentTarget = { frameId: 0, documentId: null, hasVideo: false };
try {
if (previousTabId && previousTabId !== selectedTabId) {
await deactivateTargetTab(previousTabId);
}
if (activationGeneration !== targetActivationGeneration) {
return { status: 'superseded' };
}
@@ -2809,37 +2661,32 @@ async function activateTargetTab(tabId, tabTitle, {
}
return { status: 'superseded' };
}
const isCurrentTargetRefresh = previousTabId === selectedTabId
&& expectedCurrentTabId === selectedTabId;
if (isCurrentTargetRefresh) {
addLog(
isMediaTargetNavigationError(error)
? 'Media document changed during refresh; keeping the previous target until navigation completes'
: `Media target refresh failed (${error.message}); keeping the selected target for recovery`,
'warn'
);
if (previousTabId === selectedTabId
&& expectedCurrentTabId === selectedTabId
&& isMediaTargetNavigationError(error)) {
addLog('Media document changed during refresh; keeping the previous target until navigation completes', 'warn');
throw error;
}
currentTabId = null;
currentTabTitle = null;
clearCurrentContentTarget();
lastContentHeartbeatAt = null;
if (currentRoom) roomIdleSince = Date.now();
const failedContentTarget = error?.contentTarget || injectedContentTarget;
await deactivateTargetTab(selectedTabId, failedContentTarget);
if (previousTabId === null) {
currentTabId = null;
currentTabTitle = null;
clearCurrentContentTarget();
lastContentHeartbeatAt = null;
if (currentRoom) roomIdleSince = Date.now();
await chrome.storage.session.set({
currentTabId: null,
currentTabTitle: null,
currentTargetFrameId: 0,
currentTargetDocumentId: null,
currentTargetHasVideo: false,
roomIdleSince,
lastContentHeartbeatAt: null
});
} else {
addLog(`Target switch to tab ${selectedTabId} failed; keeping tab ${previousTabId} selected`, 'warn');
if (previousTabId && (previousTabId !== selectedTabId
|| !sameContentTarget(previousContentTarget, failedContentTarget))) {
await deactivateTargetTab(previousTabId, previousContentTarget);
}
await chrome.storage.session.set({
currentTabId: null,
currentTabTitle: null,
currentTargetFrameId: 0,
currentTargetDocumentId: null,
currentTargetHasVideo: false,
roomIdleSince,
lastContentHeartbeatAt: null
});
if (activationGeneration !== targetActivationGeneration) {
return { status: 'superseded' };
}
@@ -2889,9 +2736,7 @@ async function activateTargetTab(tabId, tabTitle, {
if (currentTabId !== selectedTabId) await deactivateTargetTab(selectedTabId, injectedContentTarget);
return { status: 'superseded' };
}
if (previousTabId && previousTabId !== selectedTabId) {
await deactivateTargetTab(previousTabId, previousContentTarget);
} else if (previousTabId === selectedTabId
if (previousTabId === selectedTabId
&& !sameContentTarget(previousContentTarget, injectedContentTarget)) {
await deactivateTargetTab(previousTabId, previousContentTarget, { deactivateMonitor: false });
}
@@ -2913,7 +2758,6 @@ async function activateTargetTab(tabId, tabTitle, {
roomIdleSince,
lastContentHeartbeatAt
});
await clearRequestedTarget(selectedTabId);
if (activationGeneration !== targetActivationGeneration) {
return { status: 'superseded' };
}
@@ -3088,8 +2932,7 @@ if (chrome.tabs?.onRemoved?.addListener) {
const isCurrent = normalizeTabId(currentTabId) === tabId;
const isPending = pending?.tabId === tabId;
const isActivating = activeTargetActivation?.tabId === tabId;
const isRequested = normalizeTabId(requestedTargetTabId) === tabId;
if (!isCurrent && !isPending && !isActivating && !isRequested) return;
if (!isCurrent && !isPending && !isActivating) return;
const hasReplacementActivation = activeTargetActivation
&& activeTargetActivation.tabId !== tabId;
@@ -3110,7 +2953,6 @@ if (chrome.tabs?.onRemoved?.addListener) {
expectedTabId: tabId
});
}
if (isRequested) await clearRequestedTarget(tabId);
await chrome.storage.session.set({
currentTabId,
currentTabTitle,
@@ -3189,12 +3031,16 @@ async function _routeToContentInternal(tabId, action, payload, actionTimestamp,
return;
}
if (retries >= 3) {
addLog(`Content Script not responding in tab ${tabId} after ${retries} retries; keeping the selected target for recovery`, 'warn');
addLog(`Content Script not responding in tab ${tabId} after ${retries} retries`, 'warn');
clearTargetTabForIdle(tabId, targetGeneration);
return;
}
const message = String(error?.message || '');
if (isMissingContentReceiverError(error) || message.includes('Extension context invalidated')) {
if (message.includes('Receiving end does not exist')
|| message.includes('Extension context invalidated')
|| message.includes('No document with id')
|| message.includes('No document with ID')) {
try {
const response = await refreshCurrentMediaTarget(tabId);
if (response?.status !== 'ok' && response?.status !== 'activation_in_progress') return;
@@ -3214,6 +3060,7 @@ async function _routeToContentInternal(tabId, action, payload, actionTimestamp,
}
addLog(`Content Script not responding in tab ${tabId}`, 'warn');
clearTargetTabForIdle(tabId, targetGeneration);
}
}
@@ -3291,27 +3138,27 @@ function leaveOldRoomIfSwitching(newRoomId) {
}
}
async function resetAudioProcessingInTab(tabId, contentTarget = null) {
const normalizedTabId = normalizeTabId(tabId);
if (normalizedTabId === null) return null;
function resetAudioProcessingInTab(tabId, contentTarget = null) {
if (!tabId) return;
if (contentTarget) {
return sendMessageToFrame(
normalizedTabId,
sendMessageToFrame(
tabId,
contentTarget.frameId,
{ action: 'RESET_AUDIO_PROCESSING' },
null,
contentTarget.documentId
).catch(() => null);
).catch(() => {});
return;
}
if (normalizedTabId === normalizeTabId(currentTabId)) {
return sendMessageToCurrentContent({ action: 'RESET_AUDIO_PROCESSING' }).catch(() => null);
if (normalizeTabId(tabId) === normalizeTabId(currentTabId)) {
sendMessageToCurrentContent({ action: 'RESET_AUDIO_PROCESSING' }).catch(() => {});
return;
}
return chrome.tabs.sendMessage(normalizedTabId, { action: 'RESET_AUDIO_PROCESSING' }).catch(() => null);
chrome.tabs.sendMessage(tabId, { action: 'RESET_AUDIO_PROCESSING' }).catch(() => {});
}
async function applyAudioSettingsToTab(tabId, contentTarget = null) {
const normalizedTabId = normalizeTabId(tabId);
if (normalizedTabId === null) return;
if (!tabId) return;
// Local-only: audioSettings are never read from storage.sync.
const data = await chrome.storage.local.get(['audioSettings']);
const message = {
@@ -3319,20 +3166,20 @@ async function applyAudioSettingsToTab(tabId, contentTarget = null) {
settings: data.audioSettings
};
if (contentTarget) {
await sendMessageToFrame(
normalizedTabId,
sendMessageToFrame(
tabId,
contentTarget.frameId,
message,
null,
contentTarget.documentId
).catch(() => null);
).catch(() => {});
return;
}
if (normalizedTabId === normalizeTabId(currentTabId)) {
await sendMessageToCurrentContent(message).catch(() => null);
if (normalizeTabId(tabId) === normalizeTabId(currentTabId)) {
sendMessageToCurrentContent(message).catch(() => {});
return;
}
await chrome.tabs.sendMessage(normalizedTabId, message).catch(() => null);
chrome.tabs.sendMessage(tabId, message).catch(() => {});
}
// --- Extension Message Listeners ---
@@ -3424,7 +3271,6 @@ async function handleAsyncMessage(message, sender, sendResponse) {
if (message.retryPendingTarget === true) {
await retryPendingTarget();
}
await retryRequestedTarget();
const pendingTarget = await readPendingTarget();
const settings = await getSettings();
const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined;
@@ -3432,36 +3278,15 @@ async function handleAsyncMessage(message, sender, sendResponse) {
let status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : (isReconnecting ? 'reconnecting' : 'disconnected'));
// Distinguish the normal "not in a room" resting state from a real drop.
if (status === 'disconnected' && !currentRoom && !connectIntent) status = 'idle';
const targetTabId = normalizeTabId(requestedTargetTabId)
?? normalizeTabId(activeTargetActivation?.tabId)
?? normalizeTabId(currentTabId);
const targetReady = targetTabId !== null
&& normalizeTabId(currentTabId) === targetTabId
&& !activeTargetActivation;
const targetActivationState = targetTabId === null
? 'none'
: targetReady
? 'ready'
: pendingTarget?.tabId === targetTabId
? 'access_required'
: normalizeTabId(requestedTargetRetryBlockedTabId) === targetTabId
? 'error'
: 'activating';
const targetActivationError = normalizeTabId(requestedTargetRetryBlockedTabId) === targetTabId
? requestedTargetRetryBlockedMessage
: null;
sendResponse({
status,
peerId,
peers: currentRoom ? currentRoom.peers : [],
lastActionState,
targetTabId,
targetTabId: currentTabId,
targetFrameId: currentTargetFrameId,
targetDocumentId: currentTargetDocumentId,
targetHasVideo: currentTargetHasVideo,
targetReady,
targetActivationState,
targetActivationError,
pendingTargetTabId: pendingTarget?.tabId ?? null,
pendingTargetHost: pendingTarget?.host ?? null,
pendingTargetOriginPattern: pendingTarget?.originPattern ?? null,
@@ -3692,7 +3517,6 @@ async function handleAsyncMessage(message, sender, sendResponse) {
broadcastControlMode();
if (currentTabId) await deactivateTargetTab(currentTabId, currentContentTarget());
invalidateTargetActivations();
await clearRequestedTarget();
currentTabId = null;
currentTabTitle = null;
clearCurrentContentTarget();
@@ -4134,7 +3958,6 @@ async function handleAsyncMessage(message, sender, sendResponse) {
const previousContentTarget = currentContentTarget();
completeForceSyncBeforeTargetChange(null);
invalidateTargetActivations();
await clearRequestedTarget();
currentTabId = null;
currentTabTitle = null;
clearCurrentContentTarget();
@@ -4159,19 +3982,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
}
try {
const selectedTabId = normalizeTabId(message.tabId);
if (selectedTabId === null) {
sendResponse({ status: 'invalid_tab' });
return;
}
pendingRequestedActivationCount++;
let response;
try {
await rememberRequestedTarget(selectedTabId, message.tabTitle);
response = await activateTargetTab(selectedTabId, message.tabTitle);
} finally {
pendingRequestedActivationCount = Math.max(0, pendingRequestedActivationCount - 1);
}
const response = await activateTargetTab(message.tabId, message.tabTitle);
if (response?.status === 'ok') {
chrome.runtime.sendMessage({
type: 'TARGET_TAB_READY',
-12
View File
@@ -173,18 +173,6 @@ describe('chat overlay contract', () => {
expect(overlaySource).toContain("if (destroyed || area !== 'local') return");
});
it('persists the last manual open/closed state across context refreshes', () => {
expect(overlaySource).toContain('const openStateKey = `chatOverlayOpen:${location.origin}`');
expect(overlaySource).toContain('let lastUserOpenState = null');
expect(overlaySource).toContain('function setOpened(next, persistPreference = true)');
expect(overlaySource).toContain('setLocalStorage({ [openStateKey]: opened })');
expect(overlaySource).toContain('setOpened(false, false)');
expect(overlaySource).toContain('setOpened(lastUserOpenState ?? (chatStartMode === \'open\'), false)');
expect(overlaySource).toContain('typeof data[openStateKey] === \'boolean\'');
expect(overlaySource).toContain('const previousEnabled = context?.enabled === true');
expect(overlaySource).toContain('(!startStateApplied || !previousEnabled)');
});
it('keeps chat hidden by default without discarding the room chat key', () => {
expect(popupSource).toContain('localData.chatEnabled === true');
expect(backgroundSource).toContain('chatEnabled: data.chatEnabled === true');
+5 -13
View File
@@ -28,7 +28,6 @@
large: Object.freeze({ width: 440, height: 640 })
});
const storageKey = `chatOverlayLayout:${location.origin}`;
const openStateKey = `chatOverlayOpen:${location.origin}`;
const systemTheme = window.matchMedia('(prefers-color-scheme: light)');
let context = null;
let opened = false;
@@ -58,7 +57,6 @@
let chatSize = 'standard';
let chatStartMode = 'bubble';
let chatReactionDisplay = 'chat';
let lastUserOpenState = null;
let themeMode = 'system';
let themePalette = 'eucalyptus';
let pageDockTarget = null;
@@ -578,12 +576,8 @@
if (persistPreference) setLocalStorage({ chatSize });
}
function setOpened(next, persistPreference = true) {
function setOpened(next) {
opened = !!next && !!context?.enabled;
if (persistPreference) {
lastUserOpenState = opened;
setLocalStorage({ [openStateKey]: opened });
}
panel.classList.toggle('open', opened);
launcher.style.display = opened ? 'none' : '';
if (opened) {
@@ -596,7 +590,6 @@
function applyContext(next) {
const previousRoomId = context?.roomId;
const previousEnabled = context?.enabled === true;
context = next || null;
const supported = !!context?.supported;
const optedIn = !!context?.enabled;
@@ -611,10 +604,10 @@
launcher.setAttribute('aria-disabled', String(!context?.enabled));
if (!optedIn) startStateApplied = false;
if (!context?.enabled) {
setOpened(false, false);
} else if (preferencesLoaded && (!startStateApplied || !previousEnabled)) {
setOpened(false);
} else if (preferencesLoaded && !startStateApplied) {
startStateApplied = true;
setOpened(lastUserOpenState ?? (chatStartMode === 'open'), false);
setOpened(chatStartMode === 'open');
}
applyStrings();
applyLayout();
@@ -974,7 +967,7 @@
systemTheme.addEventListener('change', handleSystemTheme);
chrome.storage.onChanged.addListener(handleStorage);
chrome.runtime.onMessage.addListener(handleRuntime);
chrome.storage.local.get([storageKey, openStateKey, 'themeMode', 'themePalette', 'chatPosition', 'chatSize', 'chatStartMode', 'chatReactionDisplay'], data => {
chrome.storage.local.get([storageKey, 'themeMode', 'themePalette', 'chatPosition', 'chatSize', 'chatStartMode', 'chatReactionDisplay'], data => {
if (destroyed) return;
const storedLayout = data[storageKey];
if (storedLayout && typeof storedLayout === 'object') {
@@ -985,7 +978,6 @@
chatPosition = normalizePosition(data.chatPosition);
chatSize = normalizeSize(data.chatSize);
chatStartMode = data.chatStartMode === 'open' ? 'open' : 'bubble';
lastUserOpenState = typeof data[openStateKey] === 'boolean' ? data[openStateKey] : null;
chatReactionDisplay = data.chatReactionDisplay === 'video' ? 'video' : 'chat';
layout.mode = chatPosition;
if (layout.mode === 'detached') layout.detachedInitialized = true;
+17 -29
View File
@@ -867,30 +867,19 @@
if (wasDesynced) {
runtimeMessage({ type: 'HCM_DESYNC_STATE', desynced: false }).catch(() => {});
}
// Resync to the host's current position (retries if host state not yet known).
hcmRequestHostSyncWithRetry();
reportLog('Host-only: resynced with the host', 'info');
}
}
// Resync to the host's current position (retries if host state not yet known).
hcmRequestHostSyncWithRetry();
reportLog('Host-only: resynced with the host', 'info');
}
if (hcmBadgeHost) return;
if (!document.body) {
// Body not ready yet (very early injection). Defer until DOMReady,
// otherwise the desynced user silently never sees the badge (L-4).
if (!hcmBadgePending) {
function hcmShowBadge() {
if (hcmBadgeHost) return;
@@ -918,9 +907,8 @@
}
}
b.append(hcmEl('span', null, '● ' + hcmStrings.badge), hcmEl('span', 'text-decoration:underline', hcmStrings.resync));
b.addEventListener('click', hcmExitDesync);
return;
}
@@ -1250,7 +1238,7 @@
let bestRank = null;
for (const video of candidates) {
if (!video || video.tagName !== 'VIDEO' || !isVideoRendered(video)) continue;
const rank = VIDEO_RANKING_SIGNALS.map(signal => signal(video));
@@ -2074,7 +2062,7 @@
// host commands. Only ACK FORCE_SYNC_PREPARE — that's the one the host's
runtimeMessage({ type: 'FORCE_SYNC_ACK' }).catch(() => {});
// force-sync flow actually waits on. Skipping CMD_ACKs for PLAY/PAUSE/SEEK
// is intentional so the host's UI honestly reflects that we didn't apply
@@ -2129,7 +2117,7 @@
return;
}
tryMediaAction(EVENTS.PAUSE);
}
-1
View File
@@ -190,7 +190,6 @@
"NOTIF_FORCE_PREPARE": "eine erzwungene Synchronisation gestartet",
"NOTIF_FORCE_EXECUTE": "alle Teilnehmer synchronisiert",
"DEBUG_NO_TAB": "Kein Ziel-Tab ausgewählt.",
"DEBUG_TARGET_ACTIVATING": "Ziel-Tab ausgewählt; Video-Injection wird vorbereitet.",
"DEBUG_COMM_FAIL": "Kommunikation mit dem Tab-Video fehlgeschlagen.",
"EMPTY_PEERS_TITLE": "Noch keine Teilnehmer",
"EMPTY_PEERS_HINT": "Teile deinen Einladungslink, um loszulegen",
-1
View File
@@ -190,7 +190,6 @@
"NOTIF_FORCE_PREPARE": "started force sync",
"NOTIF_FORCE_EXECUTE": "synchronized everyone",
"DEBUG_NO_TAB": "No target tab selected.",
"DEBUG_TARGET_ACTIVATING": "Target tab selected; preparing video injection.",
"DEBUG_COMM_FAIL": "Could not communicate with tab video.",
"EMPTY_PEERS_TITLE": "No peers yet",
"EMPTY_PEERS_HINT": "Share your invite link to get started",
-1
View File
@@ -190,7 +190,6 @@
"NOTIF_FORCE_PREPARE": "ha iniciado una sincronización forzada",
"NOTIF_FORCE_EXECUTE": "ha sincronizado a todos",
"DEBUG_NO_TAB": "No hay pestaña objetivo seleccionada.",
"DEBUG_TARGET_ACTIVATING": "Pestaña objetivo seleccionada; preparando la inyección de vídeo.",
"DEBUG_COMM_FAIL": "No se pudo comunicar con el video de la pestaña.",
"EMPTY_PEERS_TITLE": "Sin participantes aún",
"EMPTY_PEERS_HINT": "Comparte tu enlace de invitación para comenzar",
-1
View File
@@ -190,7 +190,6 @@
"NOTIF_FORCE_PREPARE": "lancé une synchronisation forcée",
"NOTIF_FORCE_EXECUTE": "synchronisé tout le monde",
"DEBUG_NO_TAB": "Aucun onglet cible sélectionné.",
"DEBUG_TARGET_ACTIVATING": "Onglet cible sélectionné ; préparation de linjection vidéo.",
"DEBUG_COMM_FAIL": "Impossible de communiquer avec l'onglet vidéo.",
"EMPTY_PEERS_TITLE": "Aucun membre pour l'instant",
"EMPTY_PEERS_HINT": "Partagez votre lien d'invitation pour commencer",
-1
View File
@@ -190,7 +190,6 @@
"NOTIF_FORCE_PREPARE": "ha avviato una sincronizzazione forzata",
"NOTIF_FORCE_EXECUTE": "ha sincronizzato tutti",
"DEBUG_NO_TAB": "Nessuna scheda selezionata.",
"DEBUG_TARGET_ACTIVATING": "Scheda target selezionata; preparazione delliniezione video.",
"DEBUG_COMM_FAIL": "Errore di comunicazione con il video.",
"EMPTY_PEERS_TITLE": "Nessun partecipante",
"EMPTY_PEERS_HINT": "Condividi il tuo link per iniziare",
-1
View File
@@ -190,7 +190,6 @@
"NOTIF_FORCE_PREPARE": "強制同期を開始しました",
"NOTIF_FORCE_EXECUTE": "全員を同期しました",
"DEBUG_NO_TAB": "対象のタブが選択されていません。",
"DEBUG_TARGET_ACTIVATING": "対象のタブを選択しました。動画スクリプトを準備しています。",
"DEBUG_COMM_FAIL": "タブのビデオと通信できませんでした。",
"EMPTY_PEERS_TITLE": "メンバーはまだいません",
"EMPTY_PEERS_HINT": "招待リンクを共有して始めましょう",
-1
View File
@@ -190,7 +190,6 @@
"NOTIF_FORCE_PREPARE": "강제 동기화를 시작했습니다",
"NOTIF_FORCE_EXECUTE": "모든 사용자를 동기화했습니다",
"DEBUG_NO_TAB": "대상 탭이 선택되지 않았습니다.",
"DEBUG_TARGET_ACTIVATING": "대상 탭이 선택되었습니다. 동영상 주입을 준비하는 중입니다.",
"DEBUG_COMM_FAIL": "탭 비디오와 통신할 수 없습니다.",
"EMPTY_PEERS_TITLE": "참여자 없음",
"EMPTY_PEERS_HINT": "시작하려면 초대 링크를 공유하세요",
-1
View File
@@ -190,7 +190,6 @@
"NOTIF_FORCE_PREPARE": "is een geforceerde sync gestart",
"NOTIF_FORCE_EXECUTE": "heeft iedereen gesynchroniseerd",
"DEBUG_NO_TAB": "Geen doeltabblad geselecteerd.",
"DEBUG_TARGET_ACTIVATING": "Doeltabblad geselecteerd; video-injectie wordt voorbereid.",
"DEBUG_COMM_FAIL": "Kon niet communiceren met de videotab.",
"EMPTY_PEERS_TITLE": "Nog geen deelnemers",
"EMPTY_PEERS_HINT": "Deel uw uitnodigingslink om te beginnen",
-1
View File
@@ -190,7 +190,6 @@
"NOTIF_FORCE_PREPARE": "wymusił synchronizację",
"NOTIF_FORCE_EXECUTE": "zsynchronizował wszystkich",
"DEBUG_NO_TAB": "Nie wybrano karty docelowej.",
"DEBUG_TARGET_ACTIVATING": "Wybrano kartę docelową; przygotowywanie wstrzyknięcia wideo.",
"DEBUG_COMM_FAIL": "Nie można skomunikować się z wideo w karcie.",
"EMPTY_PEERS_TITLE": "Brak uczestników",
"EMPTY_PEERS_HINT": "Udostępnij link zaproszenia, aby rozpocząć",
-1
View File
@@ -190,7 +190,6 @@
"NOTIF_FORCE_PREPARE": "iniciou uma sincronização forçada",
"NOTIF_FORCE_EXECUTE": "sincronizou todos",
"DEBUG_NO_TAB": "Nenhuma aba selecionada.",
"DEBUG_TARGET_ACTIVATING": "Aba de destino selecionada; preparando a injeção de vídeo.",
"DEBUG_COMM_FAIL": "Erro ao se comunicar com o vídeo.",
"EMPTY_PEERS_TITLE": "Nenhum participante",
"EMPTY_PEERS_HINT": "Compartilhe seu link de convite para começar",
-1
View File
@@ -190,7 +190,6 @@
"NOTIF_FORCE_PREPARE": "iniciou uma sincronização forçada",
"NOTIF_FORCE_EXECUTE": "sincronizou todos",
"DEBUG_NO_TAB": "Nenhum separador selecionado.",
"DEBUG_TARGET_ACTIVATING": "Separador de destino selecionado; a preparar a injeção de vídeo.",
"DEBUG_COMM_FAIL": "Erro ao comunicar com o vídeo.",
"EMPTY_PEERS_TITLE": "Nenhum participante",
"EMPTY_PEERS_HINT": "Partilhe o seu link de convite para começar",
-1
View File
@@ -190,7 +190,6 @@
"NOTIF_FORCE_PREPARE": "запустил принудительный синхрон",
"NOTIF_FORCE_EXECUTE": "синхронизировал воспроизведение у всех",
"DEBUG_NO_TAB": "Целевая вкладка не выбрана.",
"DEBUG_TARGET_ACTIVATING": "Целевая вкладка выбрана; подготовка внедрения видео.",
"DEBUG_COMM_FAIL": "Не удалось связаться с плеером на вкладке.",
"EMPTY_PEERS_TITLE": "Участников пока нет",
"EMPTY_PEERS_HINT": "Поделитесь ссылкой-приглашением, чтобы начать",
-1
View File
@@ -190,7 +190,6 @@
"NOTIF_FORCE_PREPARE": "zorunlu eşitleme başlattı",
"NOTIF_FORCE_EXECUTE": "herkesi eşitledi",
"DEBUG_NO_TAB": "Hedef sekme seçilmedi.",
"DEBUG_TARGET_ACTIVATING": "Hedef sekme seçildi; video enjeksiyonu hazırlanıyor.",
"DEBUG_COMM_FAIL": "Sekme videosuyla iletişim kurulamadı.",
"EMPTY_PEERS_TITLE": "Henüz kimse yok",
"EMPTY_PEERS_HINT": "Başlamak için davet bağlantınızı paylaşın",
-1
View File
@@ -190,7 +190,6 @@
"NOTIF_FORCE_PREPARE": "почав примусову синхронізацію",
"NOTIF_FORCE_EXECUTE": "синхронізував усіх",
"DEBUG_NO_TAB": "Цільова вкладка не вибрана.",
"DEBUG_TARGET_ACTIVATING": "Цільову вкладку вибрано; готується впровадження відео.",
"DEBUG_COMM_FAIL": "Не вдалося зв’язатися з відео вкладки.",
"EMPTY_PEERS_TITLE": "Учасників ще немає",
"EMPTY_PEERS_HINT": "Поділіться своїм запрошенням, щоб почати",
-1
View File
@@ -190,7 +190,6 @@
"NOTIF_FORCE_PREPARE": "开始强制同步",
"NOTIF_FORCE_EXECUTE": "同步所有人",
"DEBUG_NO_TAB": "未选择目标选项卡。",
"DEBUG_TARGET_ACTIVATING": "已选择目标标签页;正在准备注入视频脚本。",
"DEBUG_COMM_FAIL": "无法与标签视频通信。",
"EMPTY_PEERS_TITLE": "还没有同行",
"EMPTY_PEERS_HINT": "分享您的邀请链接以开始使用",
+42 -115
View File
@@ -1,8 +1,9 @@
export const MEDIA_FRAME_ACCESS_REQUIRED = 'media_frame_access_required';
export const MEDIA_FRAME_AMBIGUOUS = 'media_frame_ambiguous';
const MIN_PLAYER_FRAME_AREA = 320 * 180;
const MIN_PLAYER_ASPECT_RATIO = 1.15;
const MAX_PLAYER_ASPECT_RATIO = 2.6;
const DEFAULT_PROBE_TIMEOUT_MS = 1500;
function normalizeFrameId(value) {
return Number.isInteger(value) && value >= 0 ? value : 0;
@@ -406,10 +407,16 @@ function accessRequiredError(access) {
return error;
}
function contentTarget(tabId, selected, monitorTargets = null) {
function ambiguousFrameError() {
const error = new Error('The active embedded video frame could not be identified safely');
error.code = MEDIA_FRAME_AMBIGUOUS;
return error;
}
function contentTarget(tabId, selected) {
const frameId = normalizeFrameId(selected?.frameId);
const documentId = typeof selected?.documentId === 'string' ? selected.documentId : null;
const target = {
return {
frameId,
documentId,
frameUrl: typeof selected?.result?.href === 'string' ? selected.result.href : null,
@@ -418,76 +425,16 @@ function contentTarget(tabId, selected, monitorTargets = null) {
? { tabId, documentIds: [documentId] }
: (frameId === 0 ? { tabId } : { tabId, frameIds: [frameId] })
};
if (Array.isArray(monitorTargets) && monitorTargets.length > 0) {
target.monitorTargets = monitorTargets;
}
return target;
}
export function listMediaFrameScriptTargets(tabId) {
return [{ tabId, allFrames: true }];
}
function listFrameProbeTargets(tabId, embeddedFrameCount = 0) {
// Chromium can reject one all-frames executeScript call when a single
// child frame is browser-owned or temporarily unavailable. Frame IDs are
// not exposed without webNavigation, so probe a bounded range individually
// after the top frame tells us that embedded frames exist. Each rejected
// probe is isolated and cannot hide the other frames.
const maxFrameId = Math.min(64, Math.max(8, (embeddedFrameCount * 4) + 4));
return Array.from({ length: maxFrameId }, (_, frameId) => ({
tabId,
frameIds: [frameId]
}));
}
function frameScriptTarget(tabId, entry) {
const frameId = normalizeFrameId(entry?.frameId);
return typeof entry?.documentId === 'string' && entry.documentId
? { tabId, documentIds: [entry.documentId] }
: (frameId === 0 ? { tabId, frameIds: [0] } : { tabId, frameIds: [frameId] });
}
function mergeFrameResults(...groups) {
const merged = new Map();
for (const group of groups) {
for (const entry of Array.isArray(group) ? group : []) {
if (!Number.isInteger(entry?.frameId)) continue;
// A frame ID identifies the current slot. If its document changed
// between the broad probe and the exact probe, the exact result
// must replace the stale document rather than create a duplicate
// candidate that can trigger a false ambiguity.
const key = `frame:${entry.frameId}`;
merged.set(key, entry);
}
}
return Array.from(merged.values());
}
function executeWithTimeout(task, timeoutMs, label) {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return task();
let timeoutId = null;
const timeout = new Promise((_, reject) => {
timeoutId = setTimeout(() => {
const error = new Error(`${label} timed out after ${timeoutMs}ms`);
error.code = 'media_frame_probe_timeout';
reject(error);
}, timeoutMs);
});
return Promise.race([task(), timeout]).finally(() => {
if (timeoutId !== null) clearTimeout(timeoutId);
});
}
async function executeInAccessibleFrames(chromeApi, targets, func, args, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS) {
async function executeInAccessibleFrames(chromeApi, targets, func, args) {
const settled = await Promise.all(targets.map(async target => {
try {
const result = await executeWithTimeout(
() => chromeApi.scripting.executeScript({ target, func, args }),
timeoutMs,
`Frame probe for ${JSON.stringify(target)}`
);
return Array.isArray(result) ? result : [];
return await chromeApi.scripting.executeScript({ target, func, args });
} catch {
return [];
}
@@ -498,93 +445,76 @@ async function executeInAccessibleFrames(chromeApi, targets, func, args, timeout
export async function resolveMediaContentTarget(chromeApi, tabId, {
attempts = 8,
retryDelayMs = 200,
probeDelayMs = 60,
probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS
probeDelayMs = 60
} = {}) {
let fallback = null;
let missingAccess = null;
let monitorTargets = [];
let ambiguous = false;
for (let attempt = 0; attempt < attempts; attempt++) {
const topResults = await executeInAccessibleFrames(
const scriptTargets = listMediaFrameScriptTargets(tabId);
let results = await executeInAccessibleFrames(
chromeApi,
[{ tabId, frameIds: [0] }],
scriptTargets,
inspectMediaFrame,
[null],
probeTimeoutMs
[null]
);
const allFrameResults = await executeInAccessibleFrames(
chromeApi,
listMediaFrameScriptTargets(tabId),
inspectMediaFrame,
[null],
probeTimeoutMs
);
let results = mergeFrameResults(topResults, allFrameResults);
const embeddedFrameCount = topResults.reduce(
(count, entry) => Math.max(count, entry?.result?.embeddedFrames?.length || 0),
0
);
if (embeddedFrameCount > 0) {
const individuallyProbed = await executeInAccessibleFrames(
chromeApi,
listFrameProbeTargets(tabId, embeddedFrameCount),
inspectMediaFrame,
[null],
probeTimeoutMs
);
results = mergeFrameResults(results, individuallyProbed);
if (results.length === 0) {
try {
results = await chromeApi.scripting.executeScript({
target: { tabId },
func: inspectMediaFrame,
args: [null]
});
} catch {
return contentTarget(tabId, null);
}
}
if (results.length === 0) return contentTarget(tabId, null);
if (results.length > 1) {
const token = `${tabId}:${attempt}:${Date.now()}:${Math.random()}`;
const frameTargets = results.map(entry => frameScriptTarget(tabId, entry));
try {
await executeInAccessibleFrames(
chromeApi,
frameTargets,
scriptTargets,
installParentFrameVisibilityProbe,
[token],
probeTimeoutMs
[token]
);
// Four passes match the maximum same-origin recursion depth.
for (let pass = 0; pass < 4; pass++) {
await executeInAccessibleFrames(
chromeApi,
frameTargets,
scriptTargets,
dispatchParentFrameVisibilityProbe,
[token],
probeTimeoutMs
[token]
);
await new Promise(resolve => setTimeout(resolve, probeDelayMs));
}
const inspected = await executeInAccessibleFrames(
chromeApi,
frameTargets,
scriptTargets,
inspectMediaFrame,
[token],
probeTimeoutMs
[token]
);
if (inspected.length > 0) results = mergeFrameResults(results, inspected);
if (inspected.length > 0) results = inspected;
} catch {
// Initial results remain usable, but equally-ranked unknown
// frames will be rejected below rather than guessed.
}
}
// Rebuild these after the visibility refresh so a frame navigation that
// replaced its document ID cannot leave a stale monitor target behind.
monitorTargets = results.map(entry => frameScriptTarget(tabId, entry));
const selected = selectMediaFrame(results);
const videoCandidates = results.filter(entry => entry?.result?.bestVideo?.rendered === true
&& entry.result.parentFrameVisible !== false);
const currentMissingAccess = findMissingPlayerAccess(results);
missingAccess = currentMissingAccess;
fallback = selected;
ambiguous = !selected && videoCandidates.length > 1;
if (selected) {
if (selected.result.bestVideo.hasSource
&& selected.result.bestVideo.rendered
&& !shouldPreferMissingAccess(currentMissingAccess, selected)) {
return contentTarget(tabId, selected, monitorTargets);
return contentTarget(tabId, selected);
}
}
@@ -594,10 +524,7 @@ export async function resolveMediaContentTarget(chromeApi, tabId, {
}
if (missingAccess) throw accessRequiredError(missingAccess);
if (fallback) return contentTarget(tabId, fallback, monitorTargets);
// Selecting a tab must not depend on video detection. A page can be a
// valid target before its player exists, and an ambiguous frame layout is
// recoverable through the injected lifecycle monitor. Keep the top-frame
// target active instead of discarding the user's selection.
return contentTarget(tabId, null, monitorTargets);
if (fallback) return contentTarget(tabId, fallback);
if (ambiguous) throw ambiguousFrameError();
return contentTarget(tabId, null);
}
+8 -118
View File
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import {
MEDIA_FRAME_ACCESS_REQUIRED,
MEDIA_FRAME_AMBIGUOUS,
inspectMediaFrame,
resolveMediaContentTarget,
selectMediaFrame
@@ -127,16 +128,12 @@ describe('cross-origin media-frame targeting', () => {
documentId: 'document-8',
frameUrl: 'https://player-8.example/embed',
hasVideo: true,
scriptTarget: { tabId: 42, documentIds: ['document-8'] },
monitorTargets: [
{ tabId: 42, documentIds: ['document-0'] },
{ tabId: 42, documentIds: ['document-8'] }
]
scriptTarget: { tabId: 42, documentIds: ['document-8'] }
});
const visibilityDispatches = executeScript.mock.calls.filter(([options]) => (
options.func?.name === 'dispatchParentFrameVisibilityProbe'
));
expect(visibilityDispatches.length).toBeGreaterThanOrEqual(4);
expect(visibilityDispatches).toHaveLength(4);
});
it('keeps the top target inactive when the only discovered video is hidden', async () => {
@@ -154,11 +151,7 @@ describe('cross-origin media-frame targeting', () => {
documentId: null,
frameUrl: null,
hasVideo: false,
scriptTarget: { tabId: 42 },
monitorTargets: [
{ tabId: 42, documentIds: ['document-0'] },
{ tabId: 42, documentIds: ['document-6'] }
]
scriptTarget: { tabId: 42 }
});
});
@@ -172,108 +165,9 @@ describe('cross-origin media-frame targeting', () => {
)).resolves.toMatchObject({
frameId: 8,
hasVideo: true,
scriptTarget: { tabId: 42, documentIds: ['document-8'] },
monitorTargets: expect.arrayContaining([
{ tabId: 42, documentIds: ['document-8'] }
])
});
expect(executeScript.mock.calls.some(([options]) => (
options.target?.tabId === 42 && options.target?.allFrames === true
))).toBe(true);
});
it('isolates a rejected all-frame sweep and still finds an embedded player', async () => {
const top = frame(0, {
href: 'https://anime.example/watch',
origin: 'https://anime.example',
bestVideo: null,
videoCount: 0,
embeddedFrames: [{
href: 'https://player.example/embed',
origin: 'https://player.example',
area: 860 * 490,
width: 860,
height: 490,
visible: true,
mediaHint: true
}]
});
const player = frame(1, {
href: 'https://player.example/embed'
});
const executeScript = vi.fn(async options => {
if (options.target?.allFrames === true) throw new Error('one frame rejected');
const frameId = options.target?.frameIds?.[0];
if (options.func?.name === 'inspectMediaFrame') {
if (frameId === 0) return [top];
if (frameId === 1) return [player];
return [];
}
return [];
});
await expect(resolveMediaContentTarget(
{ scripting: { executeScript } },
47,
{ attempts: 1, probeDelayMs: 0 }
)).resolves.toMatchObject({
frameId: 1,
documentId: 'document-1',
hasVideo: true,
scriptTarget: { tabId: 47, documentIds: ['document-1'] },
monitorTargets: expect.arrayContaining([
{ tabId: 47, documentIds: ['document-0'] },
{ tabId: 47, documentIds: ['document-1'] }
])
});
});
it('sweeps individual frame IDs when an all-frame result is partial', async () => {
const top = frame(0, {
href: 'https://anime.example/watch',
origin: 'https://anime.example',
bestVideo: null,
videoCount: 0,
embeddedFrames: [{
href: 'https://player.example/embed',
origin: 'https://player.example',
area: 860 * 490,
width: 860,
height: 490,
visible: true,
mediaHint: true
}]
});
const partialFrame = frame(2, { bestVideo: null, videoCount: 0 });
const player = frame(3, { href: 'https://player.example/embed' });
const executeScript = vi.fn(async options => {
if (options.target?.allFrames === true) {
return options.func?.name === 'inspectMediaFrame' ? [top, partialFrame] : [];
}
const frameId = options.target?.frameIds?.[0];
if (options.func?.name === 'inspectMediaFrame') {
if (frameId === 0) return [top];
if (frameId === 3) return [player];
return [];
}
return [];
});
await expect(resolveMediaContentTarget(
{ scripting: { executeScript } },
48,
{ attempts: 1, probeDelayMs: 0 }
)).resolves.toMatchObject({
frameId: 3,
documentId: 'document-3',
hasVideo: true,
scriptTarget: { tabId: 48, documentIds: ['document-3'] },
monitorTargets: expect.arrayContaining([
{ tabId: 48, documentIds: ['document-0'] },
{ tabId: 48, documentIds: ['document-2'] },
{ tabId: 48, documentIds: ['document-3'] }
])
scriptTarget: { tabId: 42, documentIds: ['document-8'] }
});
expect(executeScript.mock.calls[0][0].target).toEqual({ tabId: 42, allFrames: true });
});
it('does not trust parent visibility from an older probe token', () => {
@@ -538,7 +432,7 @@ describe('cross-origin media-frame targeting', () => {
)).resolves.toMatchObject({ frameId: 0, scriptTarget: { tabId: 44 } });
});
it('keeps the tab target active when equal player frames are ambiguous', async () => {
it('reports ambiguity rather than controlling an arbitrary equal player', async () => {
const results = [
frame(3, { parentFrameVisible: null }),
frame(4, { parentFrameVisible: null })
@@ -548,10 +442,6 @@ describe('cross-origin media-frame targeting', () => {
{ scripting: { executeScript } },
45,
{ attempts: 1, probeDelayMs: 0 }
)).resolves.toMatchObject({
frameId: 0,
hasVideo: false,
scriptTarget: { tabId: 45 }
});
)).rejects.toMatchObject({ code: MEDIA_FRAME_AMBIGUOUS });
});
});
+15 -47
View File
@@ -468,12 +468,12 @@ async function init() {
// Keep a denied selection visible while Chrome waits for the user
// to grant access; it becomes active automatically after approval.
await populateTabs(res.peers, res.targetTabId);
await populateTabs(res.peers, res.targetTabId || res.pendingTargetTabId);
// Render lobby status if active
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
if (res.status === 'connected' && normalizeTabId(res.targetTabId) === null && localData.roomId) {
if (res.status === 'connected' && !res.targetTabId && !res.pendingTargetTabId && localData.roomId) {
const syncTabBtn = document.querySelector('.tab-btn[data-tab="tab-sync"]');
if (syncTabBtn) syncTabBtn.click();
showSelectVideoHint();
@@ -682,7 +682,7 @@ async function refreshTargetAccessState() {
}
await populateTabs(
status.peers,
status.targetTabId
status.targetTabId ?? status.pendingTargetTabId ?? null
);
}
@@ -1222,14 +1222,14 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
const blacklistDomains = getEffectiveBlacklistDomains(await readBlacklistOverrides());
const isFilterActive = data.filterNoise !== false;
let currentTargetTabId = normalizeTabId(providedTargetTabId);
let currentTargetTabId = providedTargetTabId;
if (currentTargetTabId === null) {
const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r));
if (chrome.runtime.lastError) {
if (populateTabsToken !== token) return;
currentTargetTabId = null;
} else {
currentTargetTabId = status?.targetTabId ?? null;
currentTargetTabId = status?.targetTabId || status?.pendingTargetTabId;
}
}
@@ -1260,7 +1260,7 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
const filteredTabs = tabs.filter(tab => {
if (!tab.url || tab.url.startsWith('chrome://')) return false;
if (isFilterActive && currentTargetTabId !== null && tab.id !== currentTargetTabId) {
if (isFilterActive && tab.id !== parseInt(currentTargetTabId)) {
if (isUrlBlacklisted(tab.url, blacklistDomains)) return false;
}
return true;
@@ -1311,7 +1311,7 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
// Sort: 1. Current tab first, 2. Matches, 3. Rest alphabetically
const options = Array.from(elements.targetTab.options);
const placeholder = options.shift();
const currentTabId = currentTargetTabId;
const currentTabId = providedTargetTabId ? parseInt(providedTargetTabId) : null;
options.sort((a, b) => {
const aId = parseInt(a.value);
@@ -1331,7 +1331,7 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) {
elements.targetTab.appendChild(placeholder);
options.forEach(opt => elements.targetTab.appendChild(opt));
if (currentTargetTabId !== null) {
if (currentTargetTabId) {
elements.targetTab.value = currentTargetTabId;
} else {
const matchOpt = options.find(o => o.textContent.includes('⭐ MATCH:'));
@@ -1745,7 +1745,7 @@ if (elements.langSelector) {
} else {
hideSiteAccessNotice();
}
await populateTabs(res.peers, res.targetTabId);
await populateTabs(res.peers, res.targetTabId || res.pendingTargetTabId);
if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers);
} else {
applyConnectionStatus('disconnected');
@@ -2094,7 +2094,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
elements.forceSyncBtn.disabled = true;
const status = await new Promise(r => chrome.runtime.sendMessage({ type: 'GET_STATUS' }, r));
if (chrome.runtime.lastError || !status || status.targetReady !== true || normalizeTabId(status.targetTabId) === null) {
if (chrome.runtime.lastError || !status || !status.targetTabId) {
elements.forceSyncBtn.disabled = false;
return;
}
@@ -2160,8 +2160,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
resolve(false);
return;
}
resolve(currentStatus?.targetReady === true
&& normalizeTabId(currentStatus?.targetTabId) === tabId);
resolve(normalizeTabId(currentStatus?.targetTabId) === tabId);
});
});
@@ -2580,9 +2579,8 @@ elements.copyLogs.addEventListener('click', () => {
logs = logs || [];
history = history || [];
const targetTabId = normalizeTabId(status.targetTabId);
const videoPromise = (targetTabId !== null && status.targetReady === true)
? new Promise(resolve => chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId: targetTabId }, resolve))
const videoPromise = (status && status.targetTabId)
? new Promise(resolve => chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId: status.targetTabId }, resolve))
: Promise.resolve(null);
videoPromise.then(rawVideo => {
@@ -2602,11 +2600,6 @@ elements.copyLogs.addEventListener('click', () => {
lines.push(`- **User Agent:** ${userAgent}`);
lines.push('');
lines.push('## Target');
lines.push(`- **Target Tab ID:** ${targetTabId ?? 'none'}`);
lines.push(`- **Activation:** ${safe(status.targetActivationState, 'unknown')}`);
lines.push('');
// ── Tab ──
if (rawVideo) {
lines.push('## Tab');
@@ -2659,9 +2652,7 @@ elements.copyLogs.addEventListener('click', () => {
// ── Video ──
lines.push('## Video');
if (!rawVideo) {
lines.push(targetTabId !== null
? '- *Target tab selected; video communication is not ready yet*'
: '- *No tab selected / communication failed*');
lines.push('- *No tab selected / communication failed*');
} else if (!vs.found) {
lines.push('- **Found:** \u274C NO VIDEO ELEMENT');
if (vs.videoCount != null) lines.push(`- **Video Tags:** ${vs.videoCount}`);
@@ -2801,34 +2792,11 @@ function refreshDebugInfo() {
if (!devTab || devTab.style.display === 'none') return;
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
if (!res || normalizeTabId(res.targetTabId) === null) {
if (res?.targetActivationState === 'activating' || res?.targetActivationState === 'access_required') {
if (elements.videoDebug) {
elements.videoDebug.textContent = getMessage('DEBUG_TARGET_ACTIVATING');
}
return;
}
if (res?.targetActivationState === 'error') {
if (elements.videoDebug) {
elements.videoDebug.textContent = res.targetActivationError
? `Injection fehlgeschlagen: ${res.targetActivationError}`
: 'Video-Injection fehlgeschlagen.';
}
return;
}
if (!res || !res.targetTabId) {
if (elements.videoDebug) elements.videoDebug.textContent = getMessage('DEBUG_NO_TAB');
return;
}
if (res.targetReady !== true) {
if (elements.videoDebug) {
elements.videoDebug.textContent = res.targetActivationState === 'error'
? `Injection fehlgeschlagen: ${res.targetActivationError || 'unbekannter Fehler'}`
: getMessage('DEBUG_TARGET_ACTIVATING');
}
return;
}
// Request direct state from the content script via background
chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId: res.targetTabId }, (state) => {
if (!state || (!state.found && state.error)) {
+7 -55
View File
@@ -8,7 +8,6 @@ const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'
const contentSource = fs.readFileSync(path.join(extensionDir, 'content.js'), 'utf8');
const overlaySource = fs.readFileSync(path.join(extensionDir, 'chat-overlay.js'), 'utf8');
const monitorSource = fs.readFileSync(path.join(extensionDir, 'media-frame-monitor.js'), 'utf8');
const popupSource = fs.readFileSync(path.join(extensionDir, 'popup.js'), 'utf8');
const manifest = JSON.parse(fs.readFileSync(path.join(extensionDir, 'manifest.base.json'), 'utf8'));
describe('target tab lifecycle', () => {
@@ -23,10 +22,9 @@ describe('target tab lifecycle', () => {
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 injectContentScript(selectedTabId'))
.toBeLessThan(activationSource.indexOf('await deactivateTargetTab(previousTabId, previousContentTarget)'));
expect(activationSource.indexOf('await deactivateTargetTab(previousTabId)'))
.toBeLessThan(activationSource.indexOf('await injectContentScript(selectedTabId'));
expect(activationSource).toContain('previousTabId !== selectedTabId');
expect(activationSource).toContain('keeping tab ${previousTabId} selected');
expect(contentSource).toContain('if (window.koalaSyncInjected && chrome.runtime.id)');
expect(overlaySource).toContain('if (window.koalaSyncChatOverlay?.refresh)');
});
@@ -34,12 +32,8 @@ describe('target tab lifecycle', () => {
it('fully deactivates old and superseded target injections', () => {
expect(backgroundSource).toContain("{ type: 'TARGET_DEACTIVATE' }");
expect(backgroundSource).toContain('target.documentId');
expect(backgroundSource).toContain('await resetAudioProcessingInTab(normalizedTabId, target);');
expect(backgroundSource).toContain("{ action: 'RESET_AUDIO_PROCESSING' }");
expect(backgroundSource.match(/await deactivateTargetTab\(selectedTabId,/g)?.length).toBeGreaterThanOrEqual(6);
expect(contentSource).toContain("if (message.type === 'TARGET_DEACTIVATE')");
expect(contentSource).toContain('destroyContentScript({ preserveAudioRoute: true });');
expect(contentSource).toContain('window.__koalaSyncAudioRoute');
expect(overlaySource).toContain("message?.type === 'TARGET_DEACTIVATE'");
});
@@ -52,11 +46,12 @@ describe('target tab lifecycle', () => {
it('uses all-frame probing for cross-origin targets without navigation permissions', () => {
expect(backgroundSource).toContain("files: ['media-frame-monitor.js']");
expect(backgroundSource).toContain('...listMediaFrameScriptTargets(tabId)');
expect(backgroundSource).toContain('const targets = listMediaFrameScriptTargets(tabId)');
expect(backgroundSource).toContain('One denied widget frame must not block the selected player');
expect(backgroundSource).toContain("navigationError.code = 'media_target_navigated'");
expect(backgroundSource).toContain('async function deactivateMediaFrameMonitors(tabId, contentTarget');
expect(backgroundSource).toContain('func: deactivateMediaFrameMonitor');
expect(backgroundSource).toContain("{ type: 'MEDIA_MONITOR_DEACTIVATE' }");
expect(backgroundSource).toContain('async function deactivateMediaFrameMonitors(tabId)');
expect(backgroundSource).toContain('{ documentId }');
expect(monitorSource).toContain("type: 'MEDIA_FRAME_CANDIDATE_CHANGED'");
expect(monitorSource).toContain("attributeFilter: ['class', 'style', 'hidden', 'src', 'controls']");
expect(monitorSource).toContain('if (!force && nextSignature === lastCandidateSignature) return');
@@ -73,49 +68,6 @@ describe('target tab lifecycle', () => {
expect(backgroundSource).not.toMatch(/chrome\.(?:web)?Navigation/);
});
it('keeps the selected frame recoverable when an all-frame sweep is rejected', () => {
expect(backgroundSource).toContain('contentTarget?.scriptTarget');
expect(backgroundSource).toContain('...(contentTarget?.monitorTargets || [])');
expect(backgroundSource).toContain('function uniqueScriptTargets(targets)');
expect(backgroundSource).toContain('function deactivateMediaFrameMonitor()');
expect(backgroundSource).toContain('func: deactivateMediaFrameMonitor');
expect(backgroundSource).toContain('isMissingContentReceiverError(error)');
expect(backgroundSource).toContain('await refreshCurrentMediaTarget(tabId, { queueIfRunning: true })');
expect(backgroundSource).toContain("activation?.status === 'activation_in_progress'");
expect(backgroundSource).not.toContain('Media frame probe fell back to the top frame');
});
it('does not discard a selected tab when its media frame refresh is transiently unavailable', () => {
const refreshFailureGuard = backgroundSource.slice(
backgroundSource.indexOf('const isCurrentTargetRefresh'),
backgroundSource.indexOf('currentTabId = null', backgroundSource.indexOf('const isCurrentTargetRefresh'))
);
expect(refreshFailureGuard).toContain('keeping the selected target for recovery');
expect(refreshFailureGuard).not.toContain('currentTabId = null');
const routeSource = backgroundSource.slice(
backgroundSource.indexOf('async function _routeToContentInternal'),
backgroundSource.indexOf('// --- Keep-Alive Mechanism ---')
);
expect(routeSource).toContain('keeping the selected target for recovery');
expect(routeSource).not.toContain('clearTargetTabForIdle(tabId, targetGeneration)');
});
it('persists the user target while dynamic-frame activation is still retrying', () => {
expect(backgroundSource).toContain('let requestedTargetTabId = null;');
expect(backgroundSource).toContain('let pendingRequestedActivationCount = 0;');
expect(backgroundSource).toContain('await rememberRequestedTarget(selectedTabId, message.tabTitle);');
expect(backgroundSource).toContain('pendingRequestedActivationCount > 0');
expect(backgroundSource).toContain('await retryRequestedTarget();');
expect(backgroundSource).toContain('targetTabId,');
expect(backgroundSource).toContain('targetReady');
expect(backgroundSource).toContain("targetActivationState");
expect(backgroundSource).toContain('await clearRequestedTarget(selectedTabId);');
expect(popupSource).not.toContain('getSelectedTargetTabId');
expect(popupSource).toContain('await populateTabs(res.peers, res.targetTabId);');
expect(popupSource).toContain('res.targetReady !== true');
});
it('serializes content commands and coalesces target refreshes', () => {
expect(backgroundSource).toContain('contentCommandQueue.catch(() => {}).then(deliver)');
expect(backgroundSource).toContain('if (mediaTargetRefreshTask && mediaTargetRefreshTabId === selectedTabId)');
@@ -127,7 +79,7 @@ describe('target tab lifecycle', () => {
});
it('tears down every persistent content-script resource', () => {
expect(contentSource).toContain('function destroyContentScript({ preserveAudioRoute = false } = {})');
expect(contentSource).toContain('function destroyContentScript()');
expect(contentSource).toContain('observer.disconnect()');
expect(contentSource).toContain('keepAlivePort.disconnect()');
expect(contentSource).toContain('for (const video of [...attachedVideos]) detachVideoListeners(video);');