diff --git a/extension/background.js b/extension/background.js index 40d8ee6..dcb2a68 100644 --- a/extension/background.js +++ b/extension/background.js @@ -2330,15 +2330,46 @@ function createTargetActivationSupersededError() { return error; } +const SCRIPT_INJECTION_TIMEOUT_MS = 5000; + +/** + * chrome.scripting.executeScript can stay pending indefinitely when a target + * frame is busy or navigating — an embedded player or ad frame is enough, and + * an allFrames call only needs one of them. An activation that never settles + * leaves the popup on "activating" forever, so every injection is bounded. + */ +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) { const targets = listMediaFrameScriptTargets(tabId); let injectedCount = 0; await Promise.all(targets.map(async target => { try { - await chrome.scripting.executeScript({ + await executeScriptWithTimeout({ target, files: ['media-frame-monitor.js'] - }); + }, 2000); injectedCount++; } catch { // One denied widget frame must not block the selected player. @@ -2353,10 +2384,10 @@ async function injectMediaFrameMonitors(tabId, contentTarget) { if (seen.has(key)) continue; seen.add(key); try { - await chrome.scripting.executeScript({ + await executeScriptWithTimeout({ target, files: ['media-frame-monitor.js'] - }); + }, 2000); injectedCount++; } catch { // Main injection below reports a real selected-target failure. @@ -2425,12 +2456,12 @@ async function injectContentScript(tabId, { } if (needsPageApiSeek) { try { - await chrome.scripting.executeScript({ + await executeScriptWithTimeout({ target: scriptTarget, world: 'MAIN', files: ['page-api-seek-overrides.js'] }); - await chrome.scripting.executeScript({ + await executeScriptWithTimeout({ target: scriptTarget, world: 'MAIN', func: installPageApiSeekBridge @@ -2441,11 +2472,11 @@ async function injectContentScript(tabId, { } } - await chrome.scripting.executeScript({ + await executeScriptWithTimeout({ target: scriptTarget, files: ['page-api-seek-overrides.js'] }); - await chrome.scripting.executeScript({ + await executeScriptWithTimeout({ target: scriptTarget, func: setPageApiSeekEnabled, args: [pageApiSeekReady] @@ -2456,13 +2487,13 @@ async function injectContentScript(tabId, { // selected media frame. let injectionResults; if (contentTarget.frameId === 0) { - injectionResults = await chrome.scripting.executeScript({ + injectionResults = await executeScriptWithTimeout({ target: scriptTarget, files: ['chat-format.js', 'chat-overlay.js', 'content.js'] }); } else { try { - await chrome.scripting.executeScript({ + await executeScriptWithTimeout({ target: { tabId, frameIds: [0] }, files: ['chat-format.js', 'chat-overlay.js'] }); @@ -2477,7 +2508,7 @@ async function injectContentScript(tabId, { null, contentTarget.documentId ).catch(() => {}); - injectionResults = await chrome.scripting.executeScript({ + injectionResults = await executeScriptWithTimeout({ target: scriptTarget, files: ['content.js'] }); @@ -2684,6 +2715,35 @@ async function clearPendingTarget({ expectedRequestId = null, expectedTabId = nu }); } +const ACTIVATION_DEADLINE_MS = 30000; + +/** + * Last line of defence for the "activating" state. + * + * Every known way an activation can stall is bounded by now, but a browser call + * that never settles would still pin activeTargetActivation and leave the popup + * spinning with nothing in the log. Past the deadline the attempt is declared + * dead so the selection can report a real error and be retried deliberately. + */ +function expireStuckActivation() { + const startedAt = activeTargetActivation?.startedAt; + if (!Number.isFinite(startedAt) || Date.now() - startedAt < ACTIVATION_DEADLINE_MS) { + return false; + } + const stalledTabId = normalizeTabId(activeTargetActivation.tabId); + addLog(`Target activation for tab ${stalledTabId} exceeded ${ACTIVATION_DEADLINE_MS}ms; abandoning it`, 'warn'); + activeTargetActivation = null; + if (stalledTabId !== null && normalizeTabId(userSelectedTabId) === stalledTabId) { + userSelectionErrorTabId = stalledTabId; + userSelectionErrorMessage = 'The page never finished responding to script injection'; + chrome.storage.session.set({ + selectionErrorTabId: userSelectionErrorTabId, + selectionErrorMessage: userSelectionErrorMessage + }).catch(() => {}); + } + return true; +} + async function rememberUserSelection(tabId, tabTitle) { const normalizedTabId = normalizeTabId(tabId); if (normalizedTabId === null) return false; @@ -2758,7 +2818,11 @@ async function activateTargetTab(tabId, tabTitle, { completeForceSyncBeforeTargetChange(selectedTabId); const activationGeneration = ++targetActivationGeneration; - activeTargetActivation = { generation: activationGeneration, tabId: selectedTabId }; + activeTargetActivation = { + generation: activationGeneration, + tabId: selectedTabId, + startedAt: Date.now() + }; const previousTabId = normalizeTabId(currentTabId); const previousContentTarget = currentContentTarget(); let injectedContentTarget = { frameId: 0, documentId: null, hasVideo: false }; @@ -3444,6 +3508,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { if (message.retryPendingTarget === true) { await retryPendingTarget(); } + expireStuckActivation(); const pendingTarget = await readPendingTarget(); const settings = await getSettings(); const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined; diff --git a/extension/media-frame-target.js b/extension/media-frame-target.js index 052998b..9062cfd 100644 --- a/extension/media-frame-target.js +++ b/extension/media-frame-target.js @@ -437,6 +437,30 @@ export function listMediaFrameScriptTargets(tabId) { return [{ tabId, allFrames: true }]; } +/** Pins one probe to one frame, preferring the exact document when known. */ +function frameScriptTarget(tabId, entry) { + const frameId = normalizeFrameId(entry?.frameId); + return typeof entry?.documentId === 'string' && entry.documentId + ? { tabId, documentIds: [entry.documentId] } + : { tabId, frameIds: [frameId] }; +} + +/** + * Later results replace earlier ones for the same frame. A frame that navigated + * between two probes must not appear twice, because two stale copies of one + * frame look exactly like two competing players. + */ +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; + merged.set(`frame:${entry.frameId}`, entry); + } + } + return Array.from(merged.values()); +} + function probeTimeoutError(label, timeoutMs) { const error = new Error(`${label} timed out after ${timeoutMs}ms`); error.code = MEDIA_FRAME_PROBE_TIMEOUT; @@ -544,10 +568,17 @@ export async function resolveMediaContentTarget(chromeApi, tabId, { if (results.length > 1) { const token = `${tabId}:${attempt}:${Date.now()}:${Math.random()}`; + // Address each discovered frame on its own from here on. A single + // allFrames call is all-or-nothing: one player or ad frame that + // never answers takes the whole probe down with it. v3.1.2 avoided + // that by listing frames through webNavigation — but the sweep + // above already reports frameId and documentId for every frame it + // reached, so the same isolation costs no permission at all. + const frameTargets = results.map(entry => frameScriptTarget(tabId, entry)); try { await executeInAccessibleFrames( chromeApi, - scriptTargets, + frameTargets, installParentFrameVisibilityProbe, [token], probeTimeoutMs @@ -556,7 +587,7 @@ export async function resolveMediaContentTarget(chromeApi, tabId, { for (let pass = 0; pass < 4; pass++) { await executeInAccessibleFrames( chromeApi, - scriptTargets, + frameTargets, dispatchParentFrameVisibilityProbe, [token], probeTimeoutMs @@ -565,12 +596,12 @@ export async function resolveMediaContentTarget(chromeApi, tabId, { } const { results: inspected } = await executeInAccessibleFrames( chromeApi, - scriptTargets, + frameTargets, inspectMediaFrame, [token], probeTimeoutMs ); - if (inspected.length > 0) results = inspected; + if (inspected.length > 0) results = mergeFrameResults(results, inspected); } catch { // Initial results remain usable, but equally-ranked unknown // frames will be rejected below rather than guessed. diff --git a/extension/media-frame-target.test.mjs b/extension/media-frame-target.test.mjs index bcf0254..7539456 100644 --- a/extension/media-frame-target.test.mjs +++ b/extension/media-frame-target.test.mjs @@ -132,7 +132,10 @@ describe('cross-origin media-frame targeting', () => { const visibilityDispatches = executeScript.mock.calls.filter(([options]) => ( options.func?.name === 'dispatchParentFrameVisibilityProbe' )); - expect(visibilityDispatches).toHaveLength(4); + // Four passes across both discovered frames, each addressed on its own + // so a frame that never answers cannot cancel the others. + expect(visibilityDispatches).toHaveLength(8); + expect(visibilityDispatches.every(([options]) => options.target.allFrames !== true)).toBe(true); }); it('keeps the top target inactive when the only discovered video is hidden', async () => {