fix(extension): persist dynamic target selection

This commit is contained in:
Timo
2026-08-17 22:10:11 +02:00
parent 8eaf026487
commit 3c99efe4e4
6 changed files with 153 additions and 10 deletions
+7 -1
View File
@@ -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.
---
+100 -2
View File
@@ -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',
+15 -6
View File
@@ -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');
+13
View File
@@ -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)');
+1 -1
View File
@@ -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');
+17
View File
@@ -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();