mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-18 07:13:12 +00:00
fix(extension): persist dynamic target selection
This commit is contained in:
+7
-1
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
@@ -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)');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user