From 04694d4439fdc711d927fa4ef90a67e30fd612af Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Mon, 17 Aug 2026 23:53:10 +0200 Subject: [PATCH] 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 --- extension/background.js | 491 +++++++---------------- extension/chat-overlay-contract.test.mjs | 12 - extension/chat-overlay.js | 18 +- extension/content.js | 46 +-- extension/locales/de.json | 1 - extension/locales/en.json | 1 - extension/locales/es.json | 1 - extension/locales/fr.json | 1 - extension/locales/it.json | 1 - extension/locales/ja.json | 1 - extension/locales/ko.json | 1 - extension/locales/nl.json | 1 - extension/locales/pl.json | 1 - extension/locales/pt-BR.json | 1 - extension/locales/pt.json | 1 - extension/locales/ru.json | 1 - extension/locales/tr.json | 1 - extension/locales/uk.json | 1 - extension/locales/zh.json | 1 - extension/media-frame-target.js | 157 ++------ extension/media-frame-target.test.mjs | 126 +----- extension/popup.js | 62 +-- extension/target-tab-lifecycle.test.mjs | 62 +-- 23 files changed, 245 insertions(+), 744 deletions(-) diff --git a/extension/background.js b/extension/background.js index d5ad3fc..179908f 100644 --- a/extension/background.js +++ b/extension/background.js @@ -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', diff --git a/extension/chat-overlay-contract.test.mjs b/extension/chat-overlay-contract.test.mjs index 3eca8be..5989f3b 100644 --- a/extension/chat-overlay-contract.test.mjs +++ b/extension/chat-overlay-contract.test.mjs @@ -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'); diff --git a/extension/chat-overlay.js b/extension/chat-overlay.js index 48eb69b..22a4273 100644 --- a/extension/chat-overlay.js +++ b/extension/chat-overlay.js @@ -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; diff --git a/extension/content.js b/extension/content.js index ad0c412..9a41c01 100644 --- a/extension/content.js +++ b/extension/content.js @@ -867,30 +867,19 @@ return audioCtx; } - function closeAudioContext() { - const closingContext = audioCtx; - if (audioCtx) { - audioCtx.close().catch(() => {}); - audioCtx = null; - } - if (window.__koalaSyncAudioRoute?.audioCtx === closingContext) { - delete window.__koalaSyncAudioRoute; - } - audioChains = new WeakMap(); - currentAudioVideo = null; - } + function closeAudioContext() { + if (audioCtx) { + audioCtx.close().catch(() => {}); + audioCtx = null; + } + audioChains = new WeakMap(); + currentAudioVideo = null; + } - function setupAudioChain(videoEl) { - if (audioChains.has(videoEl)) return audioChains.get(videoEl); - const retainedRoute = window.__koalaSyncAudioRoute; - if (retainedRoute?.video === videoEl && retainedRoute.audioCtx && retainedRoute.chain) { - audioCtx = retainedRoute.audioCtx; - audioChains.set(videoEl, retainedRoute.chain); - currentAudioVideo = videoEl; - return retainedRoute.chain; - } - const ctx = initAudioContext(); - if (!ctx) return null; + function setupAudioChain(videoEl) { + if (audioChains.has(videoEl)) return audioChains.get(videoEl); + const ctx = initAudioContext(); + if (!ctx) return null; try { const src = ctx.createMediaElementSource(videoEl); @@ -918,9 +907,8 @@ limiter.release.value = 0.1; const chain = { compressor, dryGain, compGain, outputGain, limiter, active: false, signature: '' }; - audioChains.set(videoEl, chain); - currentAudioVideo = videoEl; - window.__koalaSyncAudioRoute = { video: videoEl, audioCtx: ctx, chain }; + audioChains.set(videoEl, chain); + currentAudioVideo = videoEl; return chain; } catch (e) { reportLog(`Audio Processing setup failed: ${e.message}`, 'warn'); @@ -1250,7 +1238,7 @@ function handleRuntimeMessage(message, sender, sendResponse) { if (!message) return; if (message.type === 'TARGET_DEACTIVATE') { - destroyContentScript({ preserveAudioRoute: true }); + destroyContentScript(); sendResponse({ ok: true }); return true; } @@ -2074,7 +2062,7 @@ } } - function destroyContentScript({ preserveAudioRoute = false } = {}) { + function destroyContentScript() { if (destroyed) return; destroyed = true; @@ -2129,7 +2117,7 @@ hcmRemoveDialog(); hcmRemoveBadge(); bypassCurrentAudioProcessing(); - if (!preserveAudioRoute) closeAudioContext(); + closeAudioContext(); try { window.koalaSyncChatOverlay?.destroy?.(); } catch (_e) { /* invalidated chat context */ } try { chrome.storage.onChanged.removeListener(handleStorageChanged); } catch (_e) { /* invalidated context */ } diff --git a/extension/locales/de.json b/extension/locales/de.json index 135d6e1..d9e218a 100644 --- a/extension/locales/de.json +++ b/extension/locales/de.json @@ -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", diff --git a/extension/locales/en.json b/extension/locales/en.json index 8babf44..d9bc1ec 100644 --- a/extension/locales/en.json +++ b/extension/locales/en.json @@ -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", diff --git a/extension/locales/es.json b/extension/locales/es.json index 99d9b86..f512f00 100644 --- a/extension/locales/es.json +++ b/extension/locales/es.json @@ -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", diff --git a/extension/locales/fr.json b/extension/locales/fr.json index 985fdb0..da60a9c 100644 --- a/extension/locales/fr.json +++ b/extension/locales/fr.json @@ -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 l’injection 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", diff --git a/extension/locales/it.json b/extension/locales/it.json index fafdd6a..ca5cb8d 100644 --- a/extension/locales/it.json +++ b/extension/locales/it.json @@ -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 dell’iniezione video.", "DEBUG_COMM_FAIL": "Errore di comunicazione con il video.", "EMPTY_PEERS_TITLE": "Nessun partecipante", "EMPTY_PEERS_HINT": "Condividi il tuo link per iniziare", diff --git a/extension/locales/ja.json b/extension/locales/ja.json index 48ba1e4..68ad760 100644 --- a/extension/locales/ja.json +++ b/extension/locales/ja.json @@ -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": "招待リンクを共有して始めましょう", diff --git a/extension/locales/ko.json b/extension/locales/ko.json index ede88bd..2354f2f 100644 --- a/extension/locales/ko.json +++ b/extension/locales/ko.json @@ -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": "시작하려면 초대 링크를 공유하세요", diff --git a/extension/locales/nl.json b/extension/locales/nl.json index dc9a581..4cac4c9 100644 --- a/extension/locales/nl.json +++ b/extension/locales/nl.json @@ -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", diff --git a/extension/locales/pl.json b/extension/locales/pl.json index 6479e06..abf7622 100644 --- a/extension/locales/pl.json +++ b/extension/locales/pl.json @@ -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ąć", diff --git a/extension/locales/pt-BR.json b/extension/locales/pt-BR.json index be76600..31645e1 100644 --- a/extension/locales/pt-BR.json +++ b/extension/locales/pt-BR.json @@ -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", diff --git a/extension/locales/pt.json b/extension/locales/pt.json index 6b127dd..e5b5eff 100644 --- a/extension/locales/pt.json +++ b/extension/locales/pt.json @@ -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", diff --git a/extension/locales/ru.json b/extension/locales/ru.json index a2d4c44..80db8cb 100644 --- a/extension/locales/ru.json +++ b/extension/locales/ru.json @@ -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": "Поделитесь ссылкой-приглашением, чтобы начать", diff --git a/extension/locales/tr.json b/extension/locales/tr.json index afadf12..d9bc14b 100644 --- a/extension/locales/tr.json +++ b/extension/locales/tr.json @@ -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", diff --git a/extension/locales/uk.json b/extension/locales/uk.json index 0357dc9..12b14d7 100644 --- a/extension/locales/uk.json +++ b/extension/locales/uk.json @@ -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": "Поділіться своїм запрошенням, щоб почати", diff --git a/extension/locales/zh.json b/extension/locales/zh.json index b87c784..b735cae 100644 --- a/extension/locales/zh.json +++ b/extension/locales/zh.json @@ -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": "分享您的邀请链接以开始使用", diff --git a/extension/media-frame-target.js b/extension/media-frame-target.js index 7661b82..1af799e 100644 --- a/extension/media-frame-target.js +++ b/extension/media-frame-target.js @@ -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); } diff --git a/extension/media-frame-target.test.mjs b/extension/media-frame-target.test.mjs index 0f91c2d..fd534a9 100644 --- a/extension/media-frame-target.test.mjs +++ b/extension/media-frame-target.test.mjs @@ -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 }); }); }); diff --git a/extension/popup.js b/extension/popup.js index f44bc11..df1076e 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -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)) { diff --git a/extension/target-tab-lifecycle.test.mjs b/extension/target-tab-lifecycle.test.mjs index 4794389..123e0c2 100644 --- a/extension/target-tab-lifecycle.test.mjs +++ b/extension/target-tab-lifecycle.test.mjs @@ -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);');