diff --git a/extension/background.js b/extension/background.js index 4d97d25..d5ad3fc 100644 --- a/extension/background.js +++ b/extension/background.js @@ -80,6 +80,8 @@ let currentTargetHasVideo = false; // 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; @@ -224,6 +226,7 @@ function ensureState() { 'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'reconnectAttempts', 'currentTabId', 'currentTabTitle', 'currentTargetFrameId', 'currentTargetDocumentId', 'currentTargetHasVideo', 'requestedTargetTabId', 'requestedTargetTitle', + 'requestedTargetRetryBlockedTabId', 'requestedTargetRetryBlockedMessage', 'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount', 'roomIdleSince', 'lastContentHeartbeatAt', 'hcmDesynced', 'chatActivityTimeline' ], (data) => { @@ -246,6 +249,11 @@ function ensureState() { && 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 @@ -2340,6 +2348,31 @@ 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 @@ -2353,12 +2386,15 @@ async function injectMediaFrameMonitors(tabId, contentTarget) { let injectedCount = 0; for (const target of targets) { try { - await chrome.scripting.executeScript({ + await executeScriptWithTimeout({ target, files: ['media-frame-monitor.js'] - }); + }, 2000); injectedCount++; - } catch { + } catch (error) { + if (error?.code === 'script_injection_timeout') { + addLog(`Media-frame monitor injection timed out for ${JSON.stringify(target)}`, 'warn'); + } // One denied widget frame must not block the selected player. } } @@ -2424,12 +2460,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 @@ -2440,16 +2476,16 @@ 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] }); - const injectionResults = await chrome.scripting.executeScript({ + const injectionResults = await executeScriptWithTimeout({ target: scriptTarget, files: ['chat-format.js', 'chat-overlay.js', 'content.js'] }); @@ -2652,9 +2688,13 @@ async function rememberRequestedTarget(tabId, tabTitle) { if (normalizedTabId === null) return false; requestedTargetTabId = normalizedTabId; requestedTargetTitle = typeof tabTitle === 'string' ? tabTitle : null; + requestedTargetRetryBlockedTabId = null; + requestedTargetRetryBlockedMessage = null; await chrome.storage.session.set({ requestedTargetTabId, - requestedTargetTitle + requestedTargetTitle, + requestedTargetRetryBlockedTabId: null, + requestedTargetRetryBlockedMessage: null }); return true; } @@ -2666,9 +2706,13 @@ async function clearRequestedTarget(expectedTabId = null) { } requestedTargetTabId = null; requestedTargetTitle = null; + requestedTargetRetryBlockedTabId = null; + requestedTargetRetryBlockedMessage = null; await chrome.storage.session.set({ requestedTargetTabId: null, - requestedTargetTitle: null + requestedTargetTitle: null, + requestedTargetRetryBlockedTabId: null, + requestedTargetRetryBlockedMessage: null }); return true; } @@ -2678,7 +2722,8 @@ async function retryRequestedTarget() { if (selectedTabId === null || normalizeTabId(currentTabId) === selectedTabId || pendingRequestedActivationCount > 0 - || activeTargetActivation) { + || activeTargetActivation + || normalizeTabId(requestedTargetRetryBlockedTabId) === selectedTabId) { return null; } @@ -2702,6 +2747,14 @@ async function retryRequestedTarget() { } 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); } @@ -3391,7 +3444,12 @@ async function handleAsyncMessage(message, sender, sendResponse) { ? 'ready' : pendingTarget?.tabId === targetTabId ? 'access_required' + : normalizeTabId(requestedTargetRetryBlockedTabId) === targetTabId + ? 'error' : 'activating'; + const targetActivationError = normalizeTabId(requestedTargetRetryBlockedTabId) === targetTabId + ? requestedTargetRetryBlockedMessage + : null; sendResponse({ status, peerId, @@ -3403,6 +3461,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { targetHasVideo: currentTargetHasVideo, targetReady, targetActivationState, + targetActivationError, pendingTargetTabId: pendingTarget?.tabId ?? null, pendingTargetHost: pendingTarget?.host ?? null, pendingTargetOriginPattern: pendingTarget?.originPattern ?? null, diff --git a/extension/media-frame-target.js b/extension/media-frame-target.js index 167fd30..7661b82 100644 --- a/extension/media-frame-target.js +++ b/extension/media-frame-target.js @@ -2,6 +2,7 @@ export const MEDIA_FRAME_ACCESS_REQUIRED = 'media_frame_access_required'; 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; @@ -463,10 +464,29 @@ function mergeFrameResults(...groups) { return Array.from(merged.values()); } -async function executeInAccessibleFrames(chromeApi, targets, func, args) { +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) { const settled = await Promise.all(targets.map(async target => { try { - const result = await chromeApi.scripting.executeScript({ target, func, args }); + const result = await executeWithTimeout( + () => chromeApi.scripting.executeScript({ target, func, args }), + timeoutMs, + `Frame probe for ${JSON.stringify(target)}` + ); return Array.isArray(result) ? result : []; } catch { return []; @@ -478,7 +498,8 @@ async function executeInAccessibleFrames(chromeApi, targets, func, args) { export async function resolveMediaContentTarget(chromeApi, tabId, { attempts = 8, retryDelayMs = 200, - probeDelayMs = 60 + probeDelayMs = 60, + probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS } = {}) { let fallback = null; let missingAccess = null; @@ -489,13 +510,15 @@ export async function resolveMediaContentTarget(chromeApi, tabId, { chromeApi, [{ tabId, frameIds: [0] }], inspectMediaFrame, - [null] + [null], + probeTimeoutMs ); const allFrameResults = await executeInAccessibleFrames( chromeApi, listMediaFrameScriptTargets(tabId), inspectMediaFrame, - [null] + [null], + probeTimeoutMs ); let results = mergeFrameResults(topResults, allFrameResults); const embeddedFrameCount = topResults.reduce( @@ -507,7 +530,8 @@ export async function resolveMediaContentTarget(chromeApi, tabId, { chromeApi, listFrameProbeTargets(tabId, embeddedFrameCount), inspectMediaFrame, - [null] + [null], + probeTimeoutMs ); results = mergeFrameResults(results, individuallyProbed); } @@ -521,7 +545,8 @@ export async function resolveMediaContentTarget(chromeApi, tabId, { chromeApi, frameTargets, installParentFrameVisibilityProbe, - [token] + [token], + probeTimeoutMs ); // Four passes match the maximum same-origin recursion depth. for (let pass = 0; pass < 4; pass++) { @@ -529,7 +554,8 @@ export async function resolveMediaContentTarget(chromeApi, tabId, { chromeApi, frameTargets, dispatchParentFrameVisibilityProbe, - [token] + [token], + probeTimeoutMs ); await new Promise(resolve => setTimeout(resolve, probeDelayMs)); } @@ -537,7 +563,8 @@ export async function resolveMediaContentTarget(chromeApi, tabId, { chromeApi, frameTargets, inspectMediaFrame, - [token] + [token], + probeTimeoutMs ); if (inspected.length > 0) results = mergeFrameResults(results, inspected); } catch { diff --git a/extension/popup.js b/extension/popup.js index 0f4e5f1..f44bc11 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -2808,13 +2808,23 @@ function refreshDebugInfo() { } return; } + if (res?.targetActivationState === 'error') { + if (elements.videoDebug) { + elements.videoDebug.textContent = res.targetActivationError + ? `Injection fehlgeschlagen: ${res.targetActivationError}` + : 'Video-Injection fehlgeschlagen.'; + } + return; + } if (elements.videoDebug) elements.videoDebug.textContent = getMessage('DEBUG_NO_TAB'); return; } if (res.targetReady !== true) { if (elements.videoDebug) { - elements.videoDebug.textContent = getMessage('DEBUG_TARGET_ACTIVATING'); + elements.videoDebug.textContent = res.targetActivationState === 'error' + ? `Injection fehlgeschlagen: ${res.targetActivationError || 'unbekannter Fehler'}` + : getMessage('DEBUG_TARGET_ACTIVATING'); } return; }