From fe13275b5d9590732b941da3f2eeae5b43929378 Mon Sep 17 00:00:00 2001 From: Timo <6156589+Shik3i@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:31:56 +0200 Subject: [PATCH] Harden host access recovery races --- extension/background.js | 560 ++++++++++++++++++++++++------- extension/host-access.js | 40 ++- extension/modules/tab-manager.js | 80 +---- extension/popup.js | 127 +++++-- scripts/test-host-access.mjs | 63 ++++ 5 files changed, 637 insertions(+), 233 deletions(-) diff --git a/extension/background.js b/extension/background.js index ce93f85..150a872 100644 --- a/extension/background.js +++ b/extension/background.js @@ -63,6 +63,7 @@ let currentRoom = null; let currentTabId = null; let currentTabTitle = null; // New: for Smart Matching let targetActivationGeneration = 0; +let activeTargetActivation = null; let logs = []; let history = []; // New: for Action History let storageInitialized = false; @@ -522,8 +523,27 @@ function markRoomPotentiallyIdle() { } } -function clearTargetTabForIdle() { +function invalidateTargetActivations() { targetActivationGeneration++; + activeTargetActivation = null; + return targetActivationGeneration; +} + +function isCurrentTargetIdentity(tabId, generation) { + return normalizeTabId(currentTabId) === normalizeTabId(tabId) + && targetActivationGeneration === generation; +} + +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(() => {}); currentTabId = null; currentTabTitle = null; @@ -538,6 +558,7 @@ function clearTargetTabForIdle() { lastContentHeartbeatAt }).catch(() => {}); updateBadgeStatus(); + return true; } async function leaveRoomAfterIdleGrace(reason) { @@ -546,6 +567,7 @@ async function leaveRoomAfterIdleGrace(reason) { reconnectFailed = false; reconnectAttempts = 0; chrome.storage.session.set({ reconnectFailed: false, reconnectAttempts: 0, reconnectStartTime: null }); + completeForceSyncBeforeTargetChange(null); emit(EVENTS.LEAVE_ROOM, { peerId }); forceDisconnect(); currentRoom = null; @@ -557,7 +579,7 @@ async function leaveRoomAfterIdleGrace(reason) { // Notify content.js/popup BEFORE currentTabId is cleared so they can reset // any stale guest-side HCM state (dialog/badge/desync) — H-2. broadcastControlMode(); - targetActivationGeneration++; + invalidateTargetActivations(); currentTabId = null; currentTabTitle = null; roomIdleSince = null; @@ -1455,6 +1477,16 @@ function executeForceSync() { addLog('Force Sync Executed', 'success'); } +function completeForceSyncBeforeTargetChange(nextTabId) { + if (!isForceSyncInitiator) return; + const selectedTabId = normalizeTabId(currentTabId); + const normalizedNextTabId = normalizeTabId(nextTabId); + if (selectedTabId !== null && selectedTabId === normalizedNextTabId) return; + + addLog('Finishing Force Sync before target change', 'info'); + executeForceSync(); +} + // --- Episode Auto-Sync Lobby Functions --- function persistEpisodeLobby() { if (storageInitialized) chrome.storage.session.set({ episodeLobby }); @@ -1602,13 +1634,14 @@ function updateLastAction(action, senderId, timestamp = Date.now()) { async function routeToContent(action, payload) { if (!currentTabId) return; - const tabId = parseInt(currentTabId); - if (isNaN(tabId)) return; + const tabId = normalizeTabId(currentTabId); + if (tabId === null) return; + const targetGeneration = targetActivationGeneration; const actionTimestamp = payload?.actionTimestamp || Date.now(); const commandSenderId = payload?.senderId || null; - _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, 0); + _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, 0, targetGeneration); } function getTabVideoState(tabId) { @@ -1623,12 +1656,21 @@ function getTabVideoState(tabId) { }); } -async function getReadyTabVideoState(tabId) { +async function getReadyTabVideoState(tabId, expectedGeneration = targetActivationGeneration) { let state = await getTabVideoState(tabId); if (!state || state.error) { - await injectContentScript(tabId); + const activation = await reactivateCurrentTarget(tabId, { expectedGeneration }); + if (activation?.status !== 'ok') { + return { error: 'Target tab changed before content script recovery completed' }; + } await new Promise(resolve => setTimeout(resolve, 250)); + if (!isCurrentTargetIdentity(tabId, activation.generation)) { + return { error: 'Target tab changed before video state could be read' }; + } state = await getTabVideoState(tabId); + if (!isCurrentTargetIdentity(tabId, activation.generation)) { + return { error: 'Target tab changed while video state was being read' }; + } } return state; } @@ -1845,18 +1887,80 @@ async function injectContentScript(tabId, { requestHostAccess = true } = {}) { let pendingTargetMutation = Promise.resolve(); +const PENDING_TARGET_KEYS = [ + 'pendingTargetTabId', + 'pendingTargetTabTitle', + 'pendingTargetHost', + 'pendingTargetOriginPattern', + 'pendingTargetRequestId' +]; + +function createPendingTargetRequestId() { + return globalThis.crypto?.randomUUID?.() + || `${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +function emptyPendingTargetState() { + return { + pendingTargetTabId: null, + pendingTargetTabTitle: null, + pendingTargetHost: null, + pendingTargetOriginPattern: null, + pendingTargetRequestId: null + }; +} + function mutatePendingTarget(operation) { const result = pendingTargetMutation.catch(() => {}).then(operation); pendingTargetMutation = result.catch(() => {}); return result; } -async function rememberPendingTarget(tabId, tabTitle, error) { +async function readPendingTarget() { return mutatePendingTarget(async () => { - const previous = await chrome.storage.session.get([ - 'pendingTargetTabId', - 'pendingTargetOriginPattern' - ]); + const stored = await chrome.storage.session.get(PENDING_TARGET_KEYS); + const tabId = normalizeTabId(stored.pendingTargetTabId); + const originPattern = typeof stored.pendingTargetOriginPattern === 'string' + && stored.pendingTargetOriginPattern.length > 0 + ? stored.pendingTargetOriginPattern + : null; + + if (tabId === null || originPattern === null) { + if (Object.values(stored).some(value => value !== null && value !== undefined)) { + if (tabId !== null) { + await removeTabHostAccessRequest(chrome, tabId, originPattern); + } + await chrome.storage.session.set(emptyPendingTargetState()); + } + return null; + } + + const requestId = typeof stored.pendingTargetRequestId === 'string' + && stored.pendingTargetRequestId.length > 0 + ? stored.pendingTargetRequestId + : createPendingTargetRequestId(); + if (stored.pendingTargetRequestId !== requestId) { + await chrome.storage.session.set({ pendingTargetRequestId: requestId }); + } + + return { + tabId, + tabTitle: typeof stored.pendingTargetTabTitle === 'string' + ? stored.pendingTargetTabTitle + : null, + host: typeof stored.pendingTargetHost === 'string' + ? stored.pendingTargetHost + : null, + originPattern, + requestId + }; + }); +} + +async function rememberPendingTarget(tabId, tabTitle, error, expectedGeneration) { + return mutatePendingTarget(async () => { + if (targetActivationGeneration !== expectedGeneration) return null; + const previous = await chrome.storage.session.get(PENDING_TARGET_KEYS); const previousTabId = normalizeTabId(previous.pendingTargetTabId); const nextOriginPattern = error?.originPattern || null; if (previousTabId !== null && ( @@ -1869,22 +1973,48 @@ async function rememberPendingTarget(tabId, tabTitle, error) { previous.pendingTargetOriginPattern || null ); } + if (targetActivationGeneration !== expectedGeneration) return null; + const requestId = previousTabId === tabId + && previous.pendingTargetOriginPattern === nextOriginPattern + && typeof previous.pendingTargetRequestId === 'string' + && previous.pendingTargetRequestId.length > 0 + ? previous.pendingTargetRequestId + : createPendingTargetRequestId(); await chrome.storage.session.set({ pendingTargetTabId: tabId, pendingTargetTabTitle: typeof tabTitle === 'string' ? tabTitle : null, pendingTargetHost: error?.host || null, - pendingTargetOriginPattern: nextOriginPattern + pendingTargetOriginPattern: nextOriginPattern, + pendingTargetRequestId: requestId }); + if (targetActivationGeneration !== expectedGeneration) { + const current = await chrome.storage.session.get(PENDING_TARGET_KEYS); + if (current.pendingTargetRequestId === requestId) { + await removeTabHostAccessRequest(chrome, tabId, nextOriginPattern); + await chrome.storage.session.set(emptyPendingTargetState()); + } + return null; + } + return { + tabId, + tabTitle: typeof tabTitle === 'string' ? tabTitle : null, + host: error?.host || null, + originPattern: nextOriginPattern, + requestId + }; }); } -async function clearPendingTarget() { +async function clearPendingTarget({ expectedRequestId = null, expectedTabId = null } = {}) { return mutatePendingTarget(async () => { - const pending = await chrome.storage.session.get([ - 'pendingTargetTabId', - 'pendingTargetOriginPattern' - ]); + const pending = await chrome.storage.session.get(PENDING_TARGET_KEYS); const pendingTabId = normalizeTabId(pending.pendingTargetTabId); + if (expectedRequestId !== null && pending.pendingTargetRequestId !== expectedRequestId) { + return false; + } + if (expectedTabId !== null && pendingTabId !== normalizeTabId(expectedTabId)) { + return false; + } if (pendingTabId !== null) { await removeTabHostAccessRequest( chrome, @@ -1892,112 +2022,200 @@ async function clearPendingTarget() { pending.pendingTargetOriginPattern || null ); } - await chrome.storage.session.set({ - pendingTargetTabId: null, - pendingTargetTabTitle: null, - pendingTargetHost: null, - pendingTargetOriginPattern: null - }); + await chrome.storage.session.set(emptyPendingTargetState()); + return true; }); } -async function activateTargetTab(tabId, tabTitle, { requestHostAccess = true } = {}) { +async function activateTargetTab(tabId, tabTitle, { + requestHostAccess = true, + expectedGeneration = null, + expectedCurrentTabId = null +} = {}) { const selectedTabId = normalizeTabId(tabId); if (selectedTabId === null) { return { status: 'invalid_tab' }; } + if (expectedGeneration !== null && targetActivationGeneration !== expectedGeneration) { + return { status: 'superseded' }; + } + if (expectedCurrentTabId !== null + && normalizeTabId(currentTabId) !== normalizeTabId(expectedCurrentTabId)) { + return { status: 'superseded' }; + } + completeForceSyncBeforeTargetChange(selectedTabId); const activationGeneration = ++targetActivationGeneration; + activeTargetActivation = { generation: activationGeneration, tabId: selectedTabId }; const previousTabId = normalizeTabId(currentTabId); try { - await injectContentScript(selectedTabId, { requestHostAccess }); - } catch (error) { + try { + await injectContentScript(selectedTabId, { requestHostAccess }); + } catch (error) { + if (activationGeneration !== targetActivationGeneration) { + if (error?.code === HOST_ACCESS_REQUIRED_STATUS + && error.requestAdded === true + && activeTargetActivation?.tabId !== selectedTabId) { + await removeTabHostAccessRequest( + chrome, + selectedTabId, + error.originPattern || null + ); + } + return { status: 'superseded' }; + } + currentTabId = null; + currentTabTitle = null; + lastContentHeartbeatAt = null; + if (currentRoom) roomIdleSince = Date.now(); + if (previousTabId) { + resetAudioProcessingInTab(previousTabId); + } + await chrome.storage.session.set({ + currentTabId: null, + currentTabTitle: null, + roomIdleSince, + lastContentHeartbeatAt: null + }); + if (activationGeneration !== targetActivationGeneration) { + return { status: 'superseded' }; + } + updateBadgeStatus(); + + if (error?.code === HOST_ACCESS_REQUIRED_STATUS) { + const pending = await rememberPendingTarget( + selectedTabId, + tabTitle, + error, + activationGeneration + ); + if (!pending || activationGeneration !== targetActivationGeneration) { + return { status: 'superseded' }; + } + chrome.runtime.sendMessage({ + type: 'TARGET_TAB_ACCESS_REQUIRED', + requestId: pending.requestId, + ...injectionFailureResponse(error) + }).catch(() => {}); + } else { + await clearPendingTarget(); + if (activationGeneration !== targetActivationGeneration) { + return { status: 'superseded' }; + } + } + throw error; + } + if (activationGeneration !== targetActivationGeneration) { + if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId); return { status: 'superseded' }; } - currentTabId = null; - currentTabTitle = null; + + await applyAudioSettingsToTab(selectedTabId); + if (activationGeneration !== targetActivationGeneration) { + if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId); + return { status: 'superseded' }; + } + await removeTabHostAccessRequest(chrome, selectedTabId); + if (activationGeneration !== targetActivationGeneration) { + if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId); + return { status: 'superseded' }; + } + await clearPendingTarget(); + if (activationGeneration !== targetActivationGeneration) { + if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId); + return { status: 'superseded' }; + } + currentTabId = selectedTabId; + currentTabTitle = typeof tabTitle === 'string' ? tabTitle : null; lastContentHeartbeatAt = null; if (currentRoom) roomIdleSince = Date.now(); - if (previousTabId) { + if (previousTabId && previousTabId !== selectedTabId) { resetAudioProcessingInTab(previousTabId); } await chrome.storage.session.set({ - currentTabId: null, - currentTabTitle: null, + currentTabId, + currentTabTitle, roomIdleSince, - lastContentHeartbeatAt: null + lastContentHeartbeatAt }); if (activationGeneration !== targetActivationGeneration) { return { status: 'superseded' }; } updateBadgeStatus(); - - if (error?.code === HOST_ACCESS_REQUIRED_STATUS) { - await rememberPendingTarget(selectedTabId, tabTitle, error); - chrome.runtime.sendMessage({ - type: 'TARGET_TAB_ACCESS_REQUIRED', - ...injectionFailureResponse(error) - }).catch(() => {}); - } else { - await clearPendingTarget(); + return { status: 'ok', tabId: selectedTabId, generation: activationGeneration }; + } finally { + if (activeTargetActivation?.generation === activationGeneration) { + activeTargetActivation = null; } - throw error; } - - if (activationGeneration !== targetActivationGeneration) { - if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId); - return { status: 'superseded' }; - } - - await applyAudioSettingsToTab(selectedTabId); - if (activationGeneration !== targetActivationGeneration) { - if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId); - return { status: 'superseded' }; - } - await clearPendingTarget(); - if (activationGeneration !== targetActivationGeneration) { - if (currentTabId !== selectedTabId) resetAudioProcessingInTab(selectedTabId); - return { status: 'superseded' }; - } - currentTabId = selectedTabId; - currentTabTitle = typeof tabTitle === 'string' ? tabTitle : null; - lastContentHeartbeatAt = null; - if (currentRoom) roomIdleSince = Date.now(); - if (previousTabId && previousTabId !== selectedTabId) { - resetAudioProcessingInTab(previousTabId); - } - await chrome.storage.session.set({ - currentTabId, - currentTabTitle, - roomIdleSince, - lastContentHeartbeatAt - }); - updateBadgeStatus(); - return { status: 'ok', tabId: selectedTabId }; } -async function retryPendingTarget() { - const pending = await chrome.storage.session.get([ - 'pendingTargetTabId', - 'pendingTargetTabTitle' - ]); - const tabId = normalizeTabId(pending.pendingTargetTabId); - if (tabId === null) return null; +async function reactivateCurrentTarget(tabId, { expectedGeneration = targetActivationGeneration } = {}) { + const selectedTabId = normalizeTabId(tabId); + if (selectedTabId === null || !isCurrentTargetIdentity(selectedTabId, expectedGeneration)) { + return { status: 'superseded' }; + } + if (activeTargetActivation && activeTargetActivation.tabId !== selectedTabId) { + return { status: 'superseded' }; + } + return activateTargetTab(selectedTabId, currentTabTitle, { + expectedGeneration, + expectedCurrentTabId: selectedTabId + }); +} + +async function retryPendingTarget({ expectedRequestId = null, requireGrantedAccess = false } = {}) { + let pending = await readPendingTarget(); + if (!pending || (expectedRequestId !== null && pending.requestId !== expectedRequestId)) { + return null; + } + if (activeTargetActivation && activeTargetActivation.tabId !== pending.tabId) { + return { status: 'superseded' }; + } + + if (requireGrantedAccess) { + let access; + try { + access = await inspectTabHostAccess(chrome, pending.tabId); + } catch { + await clearPendingTarget({ + expectedRequestId: pending.requestId, + expectedTabId: pending.tabId + }); + return { status: 'invalid_tab' }; + } + if (access.granted !== true || access.originPattern !== pending.originPattern) { + return { status: 'permission_not_granted' }; + } + pending = await readPendingTarget(); + if (!pending || pending.requestId !== expectedRequestId) { + return { status: 'superseded' }; + } + } try { - const response = await activateTargetTab(tabId, pending.pendingTargetTabTitle, { - requestHostAccess: false + const expectedGeneration = targetActivationGeneration; + const response = await activateTargetTab(pending.tabId, pending.tabTitle, { + requestHostAccess: false, + expectedGeneration }); if (response.status === 'ok') { - addLog(`Website access granted; selected tab ${tabId}`, 'success'); - chrome.runtime.sendMessage({ type: 'TARGET_TAB_READY', tabId }).catch(() => {}); + addLog(`Website access granted; selected tab ${pending.tabId}`, 'success'); + chrome.runtime.sendMessage({ + type: 'TARGET_TAB_READY', + tabId: pending.tabId, + requestId: pending.requestId + }).catch(() => {}); } return response; } catch (error) { if (error?.code !== HOST_ACCESS_REQUIRED_STATUS) { - await clearPendingTarget(); + await clearPendingTarget({ + expectedRequestId: pending.requestId, + expectedTabId: pending.tabId + }); addLog(`Pending tab activation failed: ${error.message}`, 'warn'); } return injectionFailureResponse(error); @@ -2005,10 +2223,21 @@ async function retryPendingTarget() { } if (chrome.permissions?.onAdded?.addListener) { - chrome.permissions.onAdded.addListener(() => { - ensureState() - .then(() => retryPendingTarget()) - .catch(error => addLog(`Website access retry failed: ${error.message}`, 'warn')); + chrome.permissions.onAdded.addListener((addedPermissions) => { + ensureState().then(async () => { + const pending = await readPendingTarget(); + if (!pending) return; + const addedOrigins = Array.isArray(addedPermissions?.origins) + ? addedPermissions.origins + : []; + if (!addedOrigins.includes(pending.originPattern) && !addedOrigins.includes('')) { + return; + } + await retryPendingTarget({ + expectedRequestId: pending.requestId, + requireGrantedAccess: true + }); + }).catch(error => addLog(`Website access retry failed: ${error.message}`, 'warn')); }); } @@ -2017,19 +2246,30 @@ if (chrome.tabs?.onRemoved?.addListener) { ensureState().then(async () => { const tabId = normalizeTabId(removedTabId); if (tabId === null) return; - const pending = await chrome.storage.session.get('pendingTargetTabId'); + const pending = await readPendingTarget(); const isCurrent = normalizeTabId(currentTabId) === tabId; - const isPending = normalizeTabId(pending.pendingTargetTabId) === tabId; - if (!isCurrent && !isPending) return; + const isPending = pending?.tabId === tabId; + const isActivating = activeTargetActivation?.tabId === tabId; + if (!isCurrent && !isPending && !isActivating) return; - targetActivationGeneration++; + const hasReplacementActivation = activeTargetActivation + && activeTargetActivation.tabId !== tabId; + if (isCurrent) completeForceSyncBeforeTargetChange(null); + if (isActivating || (isCurrent && !hasReplacementActivation)) { + invalidateTargetActivations(); + } if (isCurrent) { currentTabId = null; currentTabTitle = null; lastContentHeartbeatAt = null; if (currentRoom) roomIdleSince = Date.now(); } - if (isPending) await clearPendingTarget(); + if (isPending) { + await clearPendingTarget({ + expectedRequestId: pending.requestId, + expectedTabId: tabId + }); + } await chrome.storage.session.set({ currentTabId, currentTabTitle, @@ -2038,11 +2278,44 @@ if (chrome.tabs?.onRemoved?.addListener) { }); updateBadgeStatus(); chrome.runtime.sendMessage({ type: 'TARGET_TAB_CLEARED', tabId }).catch(() => {}); + if (isCurrent) { + addLog('Target tab closed.', 'warn'); + if (currentRoom) { + const roomAtClose = currentRoom; + getSettings().then(settings => { + if (currentRoom !== roomAtClose) return; + emit(EVENTS.PEER_STATUS, { + peerId, + playbackState: 'paused', + currentTime: null, + mediaTitle: null, + username: settings.username, + tabTitle: null + }); + const me = currentRoom?.peers?.find(p => (p.peerId || p) === peerId); + if (me && typeof me === 'object') { + me.playbackState = 'paused'; + me.currentTime = null; + me.mediaTitle = null; + me.tabTitle = null; + me.lastHeartbeat = Date.now(); + if (storageInitialized) { + chrome.storage.session.set({ currentRoom }); + } + chrome.runtime.sendMessage({ + type: 'PEER_UPDATE', + peers: currentRoom.peers + }).catch(() => {}); + } + }).catch(() => {}); + } + } }).catch(error => addLog(`Closed target-tab cleanup failed: ${error.message}`, 'warn')); }); } -function _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries) { +function _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries, targetGeneration) { + if (!isCurrentTargetIdentity(tabId, targetGeneration)) return; chrome.tabs.sendMessage(tabId, { type: 'SERVER_COMMAND', action, @@ -2050,21 +2323,30 @@ function _routeToContentInternal(tabId, action, payload, actionTimestamp, comman actionTimestamp, commandSenderId }).catch(err => { + if (!isCurrentTargetIdentity(tabId, targetGeneration)) return; if (retries >= 3) { addLog(`Content Script not responding in tab ${tabId} after ${retries} retries`, 'warn'); - clearTargetTabForIdle(); + clearTargetTabForIdle(tabId, targetGeneration); return; } if (err.message.includes('Receiving end does not exist') || err.message.includes('Extension context invalidated')) { - activateTargetTab(tabId, currentTabTitle).then(response => { + reactivateCurrentTarget(tabId, { expectedGeneration: targetGeneration }).then(response => { if (response?.status !== 'ok') return; - setTimeout(() => _routeToContentInternal(tabId, action, payload, actionTimestamp, commandSenderId, retries + 1), 500); + setTimeout(() => _routeToContentInternal( + tabId, + action, + payload, + actionTimestamp, + commandSenderId, + retries + 1, + response.generation + ), 500); }).catch(_err => { addLog(`Auto-reinject failed for tab ${tabId}`, 'warn'); }); } else { addLog(`Content Script not responding in tab ${tabId}`, 'warn'); - clearTargetTabForIdle(); + clearTargetTabForIdle(tabId, targetGeneration); } }); } @@ -2216,11 +2498,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { if (message.retryPendingTarget === true) { await retryPendingTarget(); } - const pendingTarget = await chrome.storage.session.get([ - 'pendingTargetTabId', - 'pendingTargetHost', - 'pendingTargetOriginPattern' - ]); + const pendingTarget = await readPendingTarget(); const isConnected = socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined; const isReconnecting = !isConnected && reconnectAttempts > 0; let status = isConnected ? 'connected' : (isConnecting || (socket && socket.readyState === WebSocket.CONNECTING) ? 'connecting' : (isReconnecting ? 'reconnecting' : 'disconnected')); @@ -2232,9 +2510,10 @@ async function handleAsyncMessage(message, sender, sendResponse) { peers: currentRoom ? currentRoom.peers : [], lastActionState, targetTabId: currentTabId, - pendingTargetTabId: pendingTarget.pendingTargetTabId || null, - pendingTargetHost: pendingTarget.pendingTargetHost || null, - pendingTargetOriginPattern: pendingTarget.pendingTargetOriginPattern || null, + pendingTargetTabId: pendingTarget?.tabId ?? null, + pendingTargetHost: pendingTarget?.host ?? null, + pendingTargetOriginPattern: pendingTarget?.originPattern ?? null, + pendingTargetRequestId: pendingTarget?.requestId ?? null, episodeLobby: episodeLobby, reconnectAttempts, reconnectSlowMode: reconnectFailed, @@ -2286,6 +2565,10 @@ async function handleAsyncMessage(message, sender, sendResponse) { // "Solo" to the host (stale-badge split-brain). sendResponse({ controlMode, hostPeerId, controllers, amHost: amHost(), amController: amController(), desynced: hcmDesynced, hostControlSupported: serverSupports(CAPABILITIES.HOST_CONTROL), coHostSupported: serverSupports(CAPABILITIES.CO_HOST) }); } else if (message.type === 'REQUEST_HOST_SYNC') { + if (sender.tab && normalizeTabId(currentTabId) !== normalizeTabId(sender.tab.id)) { + sendResponse({ status: 'ignored_unselected_tab', target: null }); + return; + } // content.js resync: hand back the host's extrapolated current position. sendResponse({ target: getHostSyncTarget() }); } else if (message.type === 'GET_HCM_STRINGS') { @@ -2309,7 +2592,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { } else if (message.type === 'HCM_DESYNC_STATE') { // content.js tells us whether the local user chose to watch on their own. // Only accept from the currently selected tab. - if (sender.tab && currentTabId && currentTabId !== sender.tab.id) { + if (sender.tab && normalizeTabId(currentTabId) !== normalizeTabId(sender.tab.id)) { sendResponse({ status: 'ignored_unselected_tab' }); return; } @@ -2320,6 +2603,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { if (storageInitialized) chrome.storage.session.set({ hcmDesynced }); sendResponse({ status: 'ok' }); } else if (message.type === 'LEAVE_ROOM') { + completeForceSyncBeforeTargetChange(null); connectIntent = false; reconnectFailed = false; reconnectAttempts = 0; @@ -2335,7 +2619,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { // Notify content.js/popup BEFORE currentTabId is cleared so they drop any // stale guest-side HCM state (dialog/badge/desync) — H-2/H-3. broadcastControlMode(); - targetActivationGeneration++; + invalidateTargetActivations(); currentTabId = null; currentTabTitle = null; roomIdleSince = null; @@ -2475,6 +2759,13 @@ async function handleAsyncMessage(message, sender, sendResponse) { sendResponse({ status: 'error', message: err.message }); }); } else if (message.type === 'CONTENT_EVENT') { + if (!sender?.tab && message.expectedTabId !== undefined) { + const expectedTabId = normalizeTabId(message.expectedTabId); + if (expectedTabId === null || normalizeTabId(currentTabId) !== expectedTabId) { + sendResponse({ status: 'stale_target' }); + return; + } + } const processEvent = async () => { // Host Control Mode (sender-side): a non-controller in host-only mode must // not drive the room. Don't broadcast; hand the action back to content.js so @@ -2618,6 +2909,10 @@ async function handleAsyncMessage(message, sender, sendResponse) { }); } } else if (message.type === 'FORCE_SYNC_ACK') { + if (sender.tab && normalizeTabId(currentTabId) !== normalizeTabId(sender.tab.id)) { + sendResponse({ status: 'ignored_unselected_tab' }); + return; + } if (isForceSyncInitiator) { forceSyncAcks.add(peerId); chrome.storage.session.set({ forceSyncAcks: Array.from(forceSyncAcks) }); @@ -2642,6 +2937,10 @@ async function handleAsyncMessage(message, sender, sendResponse) { } sendResponse({ status: 'ok' }); } else if (message.type === 'CMD_ACK') { + if (sender.tab && normalizeTabId(currentTabId) !== normalizeTabId(sender.tab.id)) { + sendResponse({ status: 'ignored_unselected_tab' }); + return; + } const commandSenderId = message.commandSenderId; // Only ACK if the command sender is still a known peer in our room. // If we've already seen their PEER_STATUS 'left', skip the ACK — it would @@ -2711,7 +3010,17 @@ async function handleAsyncMessage(message, sender, sendResponse) { return true; } - activateTargetTab(tabId, currentTabTitle).then(response => { + const expectedCurrentTabId = normalizeTabId(message.expectedCurrentTabId); + if (expectedCurrentTabId === null + || tabId !== expectedCurrentTabId + || normalizeTabId(currentTabId) !== expectedCurrentTabId) { + sendResponse({ status: 'stale_target' }); + return true; + } + + reactivateCurrentTarget(tabId, { + expectedGeneration: targetActivationGeneration + }).then(response => { sendResponse(response); }).catch(err => { addLog(`Failed to inject into tab: ${err.message}`, 'warn'); @@ -2721,7 +3030,8 @@ async function handleAsyncMessage(message, sender, sendResponse) { } else if (message.type === 'SET_TARGET_TAB') { if (message.tabId === null || message.tabId === undefined || message.tabId === '') { const previousTabId = currentTabId; - targetActivationGeneration++; + completeForceSyncBeforeTargetChange(null); + invalidateTargetActivations(); currentTabId = null; currentTabTitle = null; lastContentHeartbeatAt = null; @@ -2741,6 +3051,12 @@ async function handleAsyncMessage(message, sender, sendResponse) { try { const response = await activateTargetTab(message.tabId, message.tabTitle); + if (response?.status === 'ok') { + chrome.runtime.sendMessage({ + type: 'TARGET_TAB_READY', + tabId: response.tabId + }).catch(() => {}); + } sendResponse(response); } catch (error) { addLog(`Failed to select tab: ${error.message}`, 'warn'); @@ -2928,24 +3244,8 @@ async function handleAsyncMessage(message, sender, sendResponse) { initTabManager({ getCurrentTabId: () => currentTabId, - setCurrentTabId: (val) => { - if (val !== currentTabId) targetActivationGeneration++; - currentTabId = val; - }, - setCurrentTabTitle: (val) => { currentTabTitle = val; }, - setLastContentHeartbeatAt: (val) => { lastContentHeartbeatAt = val; }, - setRoomIdleSince: (val) => { roomIdleSince = val; }, - getCurrentRoom: () => currentRoom, - getPeerId: () => peerId, - getStorageInitialized: () => storageInitialized, - updateBadgeStatus, - addLog, - getSettings, - emit, - applyAudioSettingsToTab, - injectContentScript, - ensureState, - EVENTS + reactivateCurrentTarget, + ensureState }); // Initial Connect — only if user has an active room configuration diff --git a/extension/host-access.js b/extension/host-access.js index 9f801b1..4f66ed3 100644 --- a/extension/host-access.js +++ b/extension/host-access.js @@ -12,16 +12,23 @@ export function normalizeTabId(value) { return Number.isSafeInteger(tabId) ? tabId : null; } -export function describeTabUrl(rawUrl) { +function browserSupportsPortMatchPatterns(chromeApi) { + // runtime.getBrowserInfo is a Firefox-only WebExtension API. Firefox does + // not accept ports in match patterns, while Chromium does. + return typeof chromeApi?.runtime?.getBrowserInfo !== 'function'; +} + +export function describeTabUrl(rawUrl, { includePort = true } = {}) { if (typeof rawUrl !== 'string' || !rawUrl) return null; try { const url = new URL(rawUrl); if (url.protocol === 'http:' || url.protocol === 'https:') { + const permissionHost = includePort ? url.host : url.hostname; return { url: rawUrl, host: url.host, - originPattern: `${url.origin}/*` + originPattern: `${url.protocol}//${permissionHost}/*` }; } if (url.protocol === 'file:') { @@ -40,7 +47,11 @@ export function describeTabUrl(rawUrl) { export async function inspectTabHostAccess(chromeApi, tabId) { const tab = await chromeApi.tabs.get(tabId); - const descriptor = describeTabUrl(tab?.pendingUrl || tab?.url || ''); + // executeScript targets the committed document. pendingUrl may already point + // at another origin while tab.url is still the document being injected into. + const descriptor = describeTabUrl(tab?.url || tab?.pendingUrl || '', { + includePort: browserSupportsPortMatchPatterns(chromeApi) + }); if (!descriptor) { return { tab, @@ -59,7 +70,8 @@ export async function inspectTabHostAccess(chromeApi, tabId) { const granted = await callBooleanPermissionMethod( chromeApi, 'contains', - { origins: [descriptor.originPattern] } + { origins: [descriptor.originPattern] }, + { timeoutMs: 1000 } ); return { tab, @@ -73,20 +85,29 @@ export async function inspectTabHostAccess(chromeApi, tabId) { } } -function callBooleanPermissionMethod(chromeApi, methodName, request) { +function callBooleanPermissionMethod(chromeApi, methodName, request, { timeoutMs = null } = {}) { const method = chromeApi.permissions?.[methodName]; if (typeof method !== 'function') return Promise.resolve(null); return new Promise((resolve, reject) => { let settled = false; + let timeout = null; + const clearSettlementTimeout = () => { + if (timeout !== null) { + clearTimeout(timeout); + timeout = null; + } + }; const finish = (value) => { if (settled) return; settled = true; - resolve(value === true); + clearSettlementTimeout(); + resolve(value === true ? true : value === false ? false : null); }; const fail = (error) => { if (settled) return; settled = true; + clearSettlementTimeout(); reject(error instanceof Error ? error : new Error(String(error || 'Permission request failed'))); }; const callback = (value) => { @@ -98,6 +119,10 @@ function callBooleanPermissionMethod(chromeApi, methodName, request) { finish(value); }; + if (Number.isFinite(timeoutMs) && timeoutMs > 0) { + timeout = setTimeout(() => finish(null), timeoutMs); + } + try { const result = method.call(chromeApi.permissions, request, callback); if (result && typeof result.then === 'function') { @@ -120,7 +145,8 @@ export function requestOriginPermission(chromeApi, originPattern) { return callBooleanPermissionMethod( chromeApi, 'request', - { origins: [originPattern] } + { origins: [originPattern] }, + { timeoutMs: 60000 } ).catch(() => false); } diff --git a/extension/modules/tab-manager.js b/extension/modules/tab-manager.js index 8fcbc22..18cde2f 100644 --- a/extension/modules/tab-manager.js +++ b/extension/modules/tab-manager.js @@ -1,20 +1,7 @@ export function initTabManager({ getCurrentTabId, - setCurrentTabId, - setCurrentTabTitle, - setLastContentHeartbeatAt, - setRoomIdleSince, - getCurrentRoom, - getPeerId, - getStorageInitialized, - updateBadgeStatus, - addLog, - getSettings, - emit, - applyAudioSettingsToTab, - injectContentScript, - ensureState, - EVENTS + reactivateCurrentTarget, + ensureState }) { chrome.storage.onChanged.addListener(async (changes, area) => { if (area !== 'local' || !changes.audioSettings) return; @@ -28,65 +15,14 @@ export function initTabManager({ }).catch(() => {}); }); - chrome.tabs.onRemoved.addListener(async (tabId) => { - await ensureState(); - if (tabId === getCurrentTabId()) { - const wasInRoom = !!getCurrentRoom(); - setCurrentTabId(null); - setCurrentTabTitle(null); - setLastContentHeartbeatAt(null); - const now = Date.now(); - setRoomIdleSince(now); - chrome.storage.session.set({ - currentTabId: null, - currentTabTitle: null, - roomIdleSince: now, - lastContentHeartbeatAt: null - }); - updateBadgeStatus(); - addLog('Target tab closed.', 'warn'); - - if (wasInRoom) { - const roomAtClose = getCurrentRoom(); - getSettings().then(settings => { - if (getCurrentRoom() !== roomAtClose) return; - - emit(EVENTS.PEER_STATUS, { - peerId: getPeerId(), - playbackState: 'paused', - currentTime: null, - mediaTitle: null, - username: settings.username, - tabTitle: null - }); - - const room = getCurrentRoom(); - if (room && Array.isArray(room.peers)) { - const me = room.peers.find(p => (p.peerId || p) === getPeerId()); - if (me && typeof me === 'object') { - me.playbackState = 'paused'; - me.currentTime = null; - me.mediaTitle = null; - me.tabTitle = null; - me.lastHeartbeat = Date.now(); - if (getStorageInitialized()) { - chrome.storage.session.set({ currentRoom: room }); - } - chrome.runtime.sendMessage({ type: 'PEER_UPDATE', peers: room.peers }).catch(() => {}); - } - } - }).catch(() => {}); - } - } - }); - chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, _tab) => { await ensureState(); - const curTabId = getCurrentTabId(); - if (curTabId && tabId === parseInt(curTabId) && changeInfo.status === 'complete') { - injectContentScript(tabId) - .then(() => applyAudioSettingsToTab(tabId)) - .catch(() => {}); + const currentTabId = Number(getCurrentTabId()); + if (Number.isInteger(currentTabId) + && currentTabId > 0 + && tabId === currentTabId + && changeInfo.status === 'complete') { + reactivateCurrentTarget(tabId).catch(() => {}); } }); } diff --git a/extension/popup.js b/extension/popup.js index 22abc97..028a47e 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -3,7 +3,7 @@ import { BLACKLIST_DOMAINS } from './shared/blacklist.js'; import { getAvatarForName, generateUsername, USERNAME_ADJECTIVES, USERNAME_NOUNS } from './shared/names.js'; import { loadLocale, translateDOM, getMessage, getSystemLanguage } from './i18n.js'; import { TITLE_PRIVACY_MODES, normalizeSendTabTitle, normalizeTabTitle } from './title-privacy.js'; -import { requestOriginPermission } from './host-access.js'; +import { normalizeTabId, requestOriginPermission } from './host-access.js'; const elements = { @@ -415,7 +415,11 @@ async function init() { } if (res.pendingTargetTabId) { - showSiteAccessNotice(res.pendingTargetHost, res.pendingTargetOriginPattern); + showSiteAccessNotice( + res.pendingTargetHost, + res.pendingTargetOriginPattern, + res.pendingTargetTabId + ); } else { hideSiteAccessNotice(); } @@ -477,6 +481,7 @@ let hcmGuestLocked = false; let siteAccessBlocked = false; let siteAccessHost = null; let siteAccessOriginPattern = null; +let siteAccessTabId = null; // Co-Host state mirrored for the peer-list renderer (promote/demote + role badges). let hcmAmOwner = false; @@ -557,15 +562,21 @@ function setRemoteControlsLocked(locked) { if (elements.pauseBtn) elements.pauseBtn.textContent = getMessage('BTN_PAUSE') || 'Pause'; } -function showSiteAccessNotice(host, originPattern = null) { +function showSiteAccessNotice(host, originPattern = null, tabId = null) { siteAccessBlocked = true; - siteAccessHost = host || siteAccessHost || 'website'; - siteAccessOriginPattern = originPattern || siteAccessOriginPattern; + siteAccessHost = typeof host === 'string' && host.length > 0 ? host : 'website'; + siteAccessOriginPattern = typeof originPattern === 'string' && originPattern.length > 0 + ? originPattern + : null; + siteAccessTabId = normalizeTabId(tabId); if (elements.siteAccessMessage) { elements.siteAccessMessage.textContent = getMessage('SITE_ACCESS_REQUIRED', { host: siteAccessHost }); } + if (elements.siteAccessRetry) { + elements.siteAccessRetry.disabled = !siteAccessOriginPattern || siteAccessTabId === null; + } if (elements.siteAccessNotice) elements.siteAccessNotice.style.display = 'block'; setRemoteControlsLocked(hcmGuestLocked); } @@ -574,13 +585,15 @@ function hideSiteAccessNotice() { siteAccessBlocked = false; siteAccessHost = null; siteAccessOriginPattern = null; + siteAccessTabId = null; + if (elements.siteAccessRetry) elements.siteAccessRetry.disabled = false; if (elements.siteAccessNotice) elements.siteAccessNotice.style.display = 'none'; setRemoteControlsLocked(hcmGuestLocked); } function handleTargetTabResponse(response) { if (response?.status === 'host_permission_required') { - showSiteAccessNotice(response.host, response.originPattern); + showSiteAccessNotice(response.host, response.originPattern, response.tabId); return false; } if (response?.status === 'ok') { @@ -605,6 +618,32 @@ function selectTargetTab(tabId, tabTitle) { }); } +async function refreshTargetAccessState() { + const status = await new Promise(resolve => { + chrome.runtime.sendMessage({ type: 'GET_STATUS' }, response => { + if (chrome.runtime.lastError) { + resolve(null); + return; + } + resolve(response || null); + }); + }); + if (!status) return; + if (status.pendingTargetTabId) { + showSiteAccessNotice( + status.pendingTargetHost, + status.pendingTargetOriginPattern, + status.pendingTargetTabId + ); + } else { + hideSiteAccessNotice(); + } + await populateTabs( + status.peers, + status.targetTabId ?? status.pendingTargetTabId ?? null + ); +} + if (elements.hostControlToggle) { elements.hostControlToggle.addEventListener('change', () => { const mode = elements.hostControlToggle.checked ? 'host-only' : 'everyone'; @@ -1537,7 +1576,11 @@ if (elements.langSelector) { updateUI(data.roomId, data.password, data.useCustomServer, data.serverUrl); if (res.pendingTargetTabId) { - showSiteAccessNotice(res.pendingTargetHost, res.pendingTargetOriginPattern); + showSiteAccessNotice( + res.pendingTargetHost, + res.pendingTargetOriginPattern, + res.pendingTargetTabId + ); } else { hideSiteAccessNotice(); } @@ -1831,26 +1874,29 @@ elements.targetTab.addEventListener('change', async () => { if (elements.siteAccessRetry) { elements.siteAccessRetry.addEventListener('click', async () => { - const val = elements.targetTab.value; - const tabId = val ? parseInt(val) : null; + const tabId = normalizeTabId(elements.targetTab.value); const tabTitle = elements.targetTab.options[elements.targetTab.selectedIndex]?.dataset.originalTitle || null; - if (!tabId) { + if (tabId === null || siteAccessTabId !== tabId || !siteAccessOriginPattern) { showToast(getMessage('ERR_SELECT_VIDEO'), 'warning'); return; } const requestedOriginPattern = siteAccessOriginPattern; + const requestedTabId = siteAccessTabId; elements.siteAccessRetry.disabled = true; elements.targetTab.disabled = true; try { - await requestOriginPermission(chrome, requestedOriginPattern); - const selectedTabId = parseInt(elements.targetTab.value); + const granted = await requestOriginPermission(chrome, requestedOriginPattern); + if (granted !== true) return; + const selectedTabId = normalizeTabId(elements.targetTab.value); // permissions.onAdded may already have completed activation, or a // newer selection may have replaced this request while it was open. if (!siteAccessBlocked || selectedTabId !== tabId + || siteAccessTabId !== requestedTabId || siteAccessOriginPattern !== requestedOriginPattern) return; await selectTargetTab(tabId, tabTitle); } finally { - elements.siteAccessRetry.disabled = false; + elements.siteAccessRetry.disabled = siteAccessBlocked + && (!siteAccessOriginPattern || siteAccessTabId === null); elements.targetTab.disabled = false; } }); @@ -1868,6 +1914,11 @@ elements.forceSyncBtn.addEventListener('click', async () => { elements.forceSyncBtn.disabled = false; return; } + const tabId = normalizeTabId(status.targetTabId); + if (tabId === null) { + elements.forceSyncBtn.disabled = false; + return; + } const mode = elements.forceSyncMode.value; let targetTime = null; @@ -1909,9 +1960,9 @@ elements.forceSyncBtn.addEventListener('click', async () => { } }; forceSyncResetTimer = setTimeout(forceSyncReset, syncTimeoutMs); - const tabId = parseInt(status.targetTabId); const failForceSyncTime = () => { + if (forceSyncDone) return; if (forceSyncResetTimer) { clearTimeout(forceSyncResetTimer); forceSyncResetTimer = null; } showError(getMessage('ERR_NO_VIDEO_TAB')); forceSyncDone = true; @@ -1919,7 +1970,17 @@ elements.forceSyncBtn.addEventListener('click', async () => { elements.forceSyncBtn.textContent = originalText; }; - const sendForceSync = (time) => { + const targetIsStillCurrent = () => new Promise(resolve => { + chrome.runtime.sendMessage({ type: 'GET_STATUS' }, currentStatus => { + if (chrome.runtime.lastError) { + resolve(false); + return; + } + resolve(normalizeTabId(currentStatus?.targetTabId) === tabId); + }); + }); + + const sendForceSync = async (time) => { if (time === null || time === undefined) { failForceSyncTime(); return; @@ -1929,15 +1990,28 @@ elements.forceSyncBtn.addEventListener('click', async () => { failForceSyncTime(); return; } + if (!(await targetIsStillCurrent())) { + failForceSyncTime(); + return; + } chrome.runtime.sendMessage({ type: 'CONTENT_EVENT', action: EVENTS.FORCE_SYNC_PREPARE, + expectedTabId: tabId, payload: { targetTime: target } + }, response => { + if (chrome.runtime.lastError || response?.status === 'stale_target') { + failForceSyncTime(); + } }); }; if (mode === 'jump-to-me') { - const retryQueryTime = () => { + const retryQueryTime = async () => { + if (!(await targetIsStillCurrent())) { + failForceSyncTime(); + return; + } chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (retryResponse) => { if (chrome.runtime.lastError || !retryResponse || !Number.isFinite(retryResponse.currentTime)) { failForceSyncTime(); @@ -1952,10 +2026,18 @@ elements.forceSyncBtn.addEventListener('click', async () => { return; } if (chrome.runtime.lastError || !response) { - chrome.runtime.sendMessage({ type: 'INJECT_CONTENT_SCRIPT', tabId }, (injectResponse) => { + chrome.runtime.sendMessage({ + type: 'INJECT_CONTENT_SCRIPT', + tabId, + expectedCurrentTabId: tabId + }, (injectResponse) => { if (chrome.runtime.lastError || !injectResponse || injectResponse.status !== 'ok') { if (injectResponse?.status === 'host_permission_required') { - showSiteAccessNotice(injectResponse.host, injectResponse.originPattern); + showSiteAccessNotice( + injectResponse.host, + injectResponse.originPattern, + injectResponse.tabId + ); } failForceSyncTime(); return; @@ -2251,14 +2333,11 @@ chrome.runtime.onMessage.addListener((msg) => { }); } } else if (msg.type === 'TARGET_TAB_READY') { - hideSiteAccessNotice(); - populateTabs(null, msg.tabId); + refreshTargetAccessState().catch(() => {}); } else if (msg.type === 'TARGET_TAB_ACCESS_REQUIRED') { - showSiteAccessNotice(msg.host, msg.originPattern); - populateTabs(null, msg.tabId); + refreshTargetAccessState().catch(() => {}); } else if (msg.type === 'TARGET_TAB_CLEARED') { - hideSiteAccessNotice(); - populateTabs(); + refreshTargetAccessState().catch(() => {}); } else if (msg.type === 'PING_UPDATE') { updatePingDisplay(msg.ping); } else if (msg.type === 'HISTORY_UPDATE') { diff --git a/scripts/test-host-access.mjs b/scripts/test-host-access.mjs index 8866abd..f62c524 100644 --- a/scripts/test-host-access.mjs +++ b/scripts/test-host-access.mjs @@ -34,6 +34,11 @@ assert.deepEqual(describeTabUrl('http://localhost:8096/web/'), { host: 'localhost:8096', originPattern: 'http://localhost:8096/*' }); +assert.deepEqual(describeTabUrl('http://localhost:8096/web/', { includePort: false }), { + url: 'http://localhost:8096/web/', + host: 'localhost:8096', + originPattern: 'http://localhost/*' +}); assert.equal(describeTabUrl('chrome://extensions/'), null); assert.equal(describeTabUrl('not a url'), null); @@ -54,6 +59,39 @@ assert.equal(access.granted, false); assert.equal(access.host, 'video.example'); assert.deepEqual(containsRequest, { origins: ['https://video.example/*'] }); +let firefoxContainsRequest = null; +const firefoxChrome = { + runtime: { getBrowserInfo: async () => ({ name: 'Firefox' }) }, + tabs: { + get: async tabId => ({ + id: tabId, + url: 'http://localhost:8096/web/', + pendingUrl: 'https://different.example/loading' + }) + }, + permissions: { + contains: async request => { + firefoxContainsRequest = request; + return false; + } + } +}; +const firefoxAccess = await inspectTabHostAccess(firefoxChrome, 42); +assert.equal(firefoxAccess.host, 'localhost:8096'); +assert.equal(firefoxAccess.originPattern, 'http://localhost/*'); +assert.deepEqual(firefoxContainsRequest, { origins: ['http://localhost/*'] }); + +const unknownPermissionChrome = { + runtime: {}, + tabs: { + get: async tabId => ({ id: tabId, url: 'https://video.example/watch' }) + }, + permissions: { + contains: (_request, callback) => { callback(undefined); } + } +}; +assert.equal((await inspectTabHostAccess(unknownPermissionChrome, 42)).granted, null); + let requestedTabId = null; const requestChrome = { permissions: { @@ -88,6 +126,7 @@ assert.equal(await requestOriginPermission({ permissions: {} }, 'https://video.e const background = fs.readFileSync(path.join(cwd(), 'extension', 'background.js'), 'utf8'); const popup = fs.readFileSync(path.join(cwd(), 'extension', 'popup.js'), 'utf8'); const popupHtml = fs.readFileSync(path.join(cwd(), 'extension', 'popup.html'), 'utf8'); +const tabManager = fs.readFileSync(path.join(cwd(), 'extension', 'modules', 'tab-manager.js'), 'utf8'); assert.match(background, /await activateTargetTab\(message\.tabId, message\.tabTitle\)/, 'SET_TARGET_TAB must await successful activation before acknowledging it'); @@ -97,6 +136,18 @@ assert.match(background, /retryPendingTarget\(\)/, 'pending target must resume after the user grants access'); assert.match(background, /activationGeneration !== targetActivationGeneration/, 'stale concurrent tab activations must not overwrite the newest selection'); +assert.match(background, /pendingTargetRequestId/, + 'pending access recovery must use an identity token'); +assert.match(background, /addedOrigins\.includes\(pending\.originPattern\)/, + 'unrelated permission grants must not activate a pending target'); +assert.match(background, /isCurrentTargetIdentity\(tabId, targetGeneration\)/, + 'stale content-routing retries must not reactivate an old target'); +assert.match(background, /message\.expectedTabId/, + 'popup playback events must be rejected after their target changes'); +assert.match(background, /completeForceSyncBeforeTargetChange\(selectedTabId\)/, + 'a target switch must finish an in-flight force sync on the old target'); +assert.match(background, /FORCE_SYNC_ACK'[\s\S]*ignored_unselected_tab/, + 'stale content scripts must not acknowledge force sync for a new target'); const activateTargetBody = background.slice( background.indexOf('async function activateTargetTab'), background.indexOf('async function retryPendingTarget') @@ -111,6 +162,18 @@ assert.match(popup, /response\?\.status === 'host_permission_required'/, 'popup must render the structured host-access failure'); assert.match(popup, /requestOriginPermission\(chrome, requestedOriginPattern\)/, 'retry button must request withheld host access directly'); +assert.match(popup, /expectedCurrentTabId: tabId/, + 'manual reinjection must be tied to the selected target identity'); +assert.match(popup, /expectedTabId: tabId/, + 'force sync must be tied to the tab whose time was sampled'); +assert.doesNotMatch(tabManager, /injectContentScript/, + 'tab reload recovery must use the guarded background activation path'); +assert.equal( + (background.match(/tabs\.onRemoved\.addListener/g) || []).length + + (tabManager.match(/tabs\.onRemoved\.addListener/g) || []).length, + 1, + 'target-tab closure must have exactly one state owner' +); assert.match(popupHtml, /id="siteAccessNotice"/, 'popup must contain a persistent site-access notice');