diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 20c3491..086ee3d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -15,6 +15,10 @@ recovery, failed tab switches and short connection interruptions. failed selection cannot leave the room without a target. - **Extension: Target recovery** — Keeps the selected tab recoverable when a transient content-script or embedded-player refresh fails. +- **Extension: Dynamic-frame target persistence** — Keeps the user's selected + tab visible across popup close/reopen and retries activation after a dynamic + player frame changes, without restoring the removed `webNavigation` + permission. - **Extension: Chat visibility persistence** — Remembers the user's manual open/closed state across popup reopen, content refresh and reconnect cycles. @@ -22,7 +26,9 @@ recovery, failed tab switches and short connection interruptions. - **Release gate** — Unit, server/WebSocket, locale, theme, lint, production dependency audit, Chrome/Firefox build, AMO validation and website build pass locally. -- **Browser E2E** — 35 extension and player lifecycle scenarios pass. +- **Browser E2E** — 36 extension and player lifecycle scenarios pass locally, + including popup close/reopen persistence and repeated cross-origin frame + switching. --- diff --git a/extension/background.js b/extension/background.js index 54ffd28..5d07c48 100644 --- a/extension/background.js +++ b/extension/background.js @@ -75,6 +75,13 @@ 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 pendingRequestedActivationCount = 0; let targetActivationGeneration = 0; let activeTargetActivation = null; let mediaTargetRefreshTask = null; @@ -217,6 +224,7 @@ function ensureState() { 'eventQueue', 'isForceSyncInitiator', 'forceSyncAcks', 'forceSyncDeadline', 'reconnectFailed', 'reconnectStartTime', 'reconnectAttempts', 'currentTabId', 'currentTabTitle', 'currentTargetFrameId', 'currentTargetDocumentId', 'currentTargetHasVideo', + 'requestedTargetTabId', 'requestedTargetTitle', 'episodeLobby', 'localSeq', 'lastSeqBySender', 'expectedAcksCount', 'roomIdleSince', 'lastContentHeartbeatAt', 'hcmDesynced', 'chatActivityTimeline' ], (data) => { @@ -234,6 +242,11 @@ 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; if (data.currentTabTitle !== undefined) { currentTabTitle = currentTabId !== null && typeof data.currentTabTitle === 'string' ? data.currentTabTitle @@ -722,6 +735,7 @@ async function leaveRoomAfterIdleGrace(reason) { broadcastControlMode(); if (currentTabId) await deactivateTargetTab(currentTabId); invalidateTargetActivations(); + await clearRequestedTarget(); currentTabId = null; currentTabTitle = null; clearCurrentContentTarget(); @@ -2633,6 +2647,66 @@ 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; + await chrome.storage.session.set({ + requestedTargetTabId, + requestedTargetTitle + }); + return true; +} + +async function clearRequestedTarget(expectedTabId = null) { + if (expectedTabId !== null + && normalizeTabId(requestedTargetTabId) !== normalizeTabId(expectedTabId)) { + return false; + } + requestedTargetTabId = null; + requestedTargetTitle = null; + await chrome.storage.session.set({ + requestedTargetTabId: null, + requestedTargetTitle: null + }); + return true; +} + +async function retryRequestedTarget() { + const selectedTabId = normalizeTabId(requestedTargetTabId); + if (selectedTabId === null + || normalizeTabId(currentTabId) === selectedTabId + || pendingRequestedActivationCount > 0 + || activeTargetActivation) { + 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) { + addLog(`Requested target retry failed: ${error.message}`, 'warn'); + return injectionFailureResponse(error); + } +} + async function activateTargetTab(tabId, tabTitle, { requestHostAccess = true, expectedGeneration = null, @@ -2786,6 +2860,7 @@ async function activateTargetTab(tabId, tabTitle, { roomIdleSince, lastContentHeartbeatAt }); + await clearRequestedTarget(selectedTabId); if (activationGeneration !== targetActivationGeneration) { return { status: 'superseded' }; } @@ -2960,7 +3035,8 @@ if (chrome.tabs?.onRemoved?.addListener) { const isCurrent = normalizeTabId(currentTabId) === tabId; const isPending = pending?.tabId === tabId; const isActivating = activeTargetActivation?.tabId === tabId; - if (!isCurrent && !isPending && !isActivating) return; + const isRequested = normalizeTabId(requestedTargetTabId) === tabId; + if (!isCurrent && !isPending && !isActivating && !isRequested) return; const hasReplacementActivation = activeTargetActivation && activeTargetActivation.tabId !== tabId; @@ -2981,6 +3057,7 @@ if (chrome.tabs?.onRemoved?.addListener) { expectedTabId: tabId }); } + if (isRequested) await clearRequestedTarget(tabId); await chrome.storage.session.set({ currentTabId, currentTabTitle, @@ -3294,6 +3371,7 @@ 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; @@ -3301,6 +3379,8 @@ 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 requestedTarget = normalizeTabId(requestedTargetTabId); + const activatingTarget = normalizeTabId(activeTargetActivation?.tabId); sendResponse({ status, peerId, @@ -3310,6 +3390,10 @@ async function handleAsyncMessage(message, sender, sendResponse) { targetFrameId: currentTargetFrameId, targetDocumentId: currentTargetDocumentId, targetHasVideo: currentTargetHasVideo, + selectedTargetTabId: requestedTarget ?? activatingTarget ?? normalizeTabId(currentTabId), + requestedTargetTabId: requestedTarget, + requestedTargetTitle, + activatingTargetTabId: activatingTarget, pendingTargetTabId: pendingTarget?.tabId ?? null, pendingTargetHost: pendingTarget?.host ?? null, pendingTargetOriginPattern: pendingTarget?.originPattern ?? null, @@ -3540,6 +3624,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { broadcastControlMode(); if (currentTabId) await deactivateTargetTab(currentTabId, currentContentTarget()); invalidateTargetActivations(); + await clearRequestedTarget(); currentTabId = null; currentTabTitle = null; clearCurrentContentTarget(); @@ -3981,6 +4066,7 @@ async function handleAsyncMessage(message, sender, sendResponse) { const previousContentTarget = currentContentTarget(); completeForceSyncBeforeTargetChange(null); invalidateTargetActivations(); + await clearRequestedTarget(); currentTabId = null; currentTabTitle = null; clearCurrentContentTarget(); @@ -4005,7 +4091,19 @@ async function handleAsyncMessage(message, sender, sendResponse) { } try { - const response = await activateTargetTab(message.tabId, message.tabTitle); + 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); + } if (response?.status === 'ok') { chrome.runtime.sendMessage({ type: 'TARGET_TAB_READY', diff --git a/extension/popup.js b/extension/popup.js index df1076e..1afee7a 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 || res.pendingTargetTabId); + await populateTabs(res.peers, getSelectedTargetTabId(res)); // Render lobby status if active if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers); - if (res.status === 'connected' && !res.targetTabId && !res.pendingTargetTabId && localData.roomId) { + if (res.status === 'connected' && !getSelectedTargetTabId(res) && localData.roomId) { const syncTabBtn = document.querySelector('.tab-btn[data-tab="tab-sync"]'); if (syncTabBtn) syncTabBtn.click(); showSelectVideoHint(); @@ -647,6 +647,15 @@ function handleTargetTabResponse(response) { return false; } +function getSelectedTargetTabId(status) { + return status?.selectedTargetTabId + ?? status?.requestedTargetTabId + ?? status?.targetTabId + ?? status?.pendingTargetTabId + ?? status?.activatingTargetTabId + ?? null; +} + function selectTargetTab(tabId, tabTitle) { return new Promise(resolve => { chrome.runtime.sendMessage({ type: 'SET_TARGET_TAB', tabId, tabTitle }, response => { @@ -682,7 +691,7 @@ async function refreshTargetAccessState() { } await populateTabs( status.peers, - status.targetTabId ?? status.pendingTargetTabId ?? null + getSelectedTargetTabId(status) ); } @@ -1229,7 +1238,7 @@ async function populateTabs(providedPeers = null, providedTargetTabId = null) { if (populateTabsToken !== token) return; currentTargetTabId = null; } else { - currentTargetTabId = status?.targetTabId || status?.pendingTargetTabId; + currentTargetTabId = getSelectedTargetTabId(status); } } @@ -1311,7 +1320,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 = providedTargetTabId ? parseInt(providedTargetTabId) : null; + const currentTabId = currentTargetTabId ? parseInt(currentTargetTabId) : null; options.sort((a, b) => { const aId = parseInt(a.value); @@ -1745,7 +1754,7 @@ if (elements.langSelector) { } else { hideSiteAccessNotice(); } - await populateTabs(res.peers, res.targetTabId || res.pendingTargetTabId); + await populateTabs(res.peers, getSelectedTargetTabId(res)); if (res.episodeLobby) updateLobbyUI(res.episodeLobby, res.peers); } else { applyConnectionStatus('disconnected'); diff --git a/extension/target-tab-lifecycle.test.mjs b/extension/target-tab-lifecycle.test.mjs index cb87a55..7f4770b 100644 --- a/extension/target-tab-lifecycle.test.mjs +++ b/extension/target-tab-lifecycle.test.mjs @@ -8,6 +8,7 @@ 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', () => { @@ -94,6 +95,18 @@ describe('target tab lifecycle', () => { 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('selectedTargetTabId: requestedTarget ?? activatingTarget ?? normalizeTabId(currentTabId)'); + expect(backgroundSource).toContain('await clearRequestedTarget(selectedTabId);'); + expect(popupSource).toContain('function getSelectedTargetTabId(status)'); + expect(popupSource).toContain('await populateTabs(res.peers, getSelectedTargetTabId(res));'); + }); + it('serializes content commands and coalesces target refreshes', () => { expect(backgroundSource).toContain('contentCommandQueue.catch(() => {}).then(deliver)'); expect(backgroundSource).toContain('if (mediaTargetRefreshTask && mediaTargetRefreshTabId === selectedTabId)'); diff --git a/scripts/test-host-access.mjs b/scripts/test-host-access.mjs index f62c524..69308e5 100644 --- a/scripts/test-host-access.mjs +++ b/scripts/test-host-access.mjs @@ -128,7 +128,7 @@ 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\)/, +assert.match(background, /await activateTargetTab\((?:message\.tabId|selectedTabId), message\.tabTitle\)/, 'SET_TARGET_TAB must await successful activation before acknowledging it'); assert.match(background, /addTabHostAccessRequest\(chrome, tabId, access\.originPattern\)/, 'failed injection must register Chrome host-access request'); diff --git a/tests/e2e/extension.spec.mjs b/tests/e2e/extension.spec.mjs index 1915a38..b61aa9d 100644 --- a/tests/e2e/extension.spec.mjs +++ b/tests/e2e/extension.spec.mjs @@ -93,6 +93,23 @@ test('injects into the target tab and attaches to a same-origin frame player', a ).toBe('true'); }); +test('keeps the selected tab after the popup page closes and reopens', async ({ context, extensionId, baseURL }) => { + const url = `${baseURL}/pages/simple-player.html`; + const page = await context.newPage(); + await page.goto(url); + await page.waitForFunction(() => window.__fixtureReady === true); + + const { tabId, response } = await selectTargetTab(context, extensionId, url); + expect(response).toMatchObject({ status: 'ok' }); + + const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' }); + expect(status).toMatchObject({ + targetTabId: tabId, + selectedTargetTabId: tabId, + requestedTargetTabId: null + }); +}); + test('applies remote play, pause and seek to the framed player', async ({ context, extensionId, baseURL }) => { const url = `${baseURL}/pages/iframe-player.html`; const page = await context.newPage();