fix(extension): recover stale media frame targets

This commit is contained in:
Timo
2026-08-17 21:14:56 +02:00
parent d8978577ad
commit 1e6778ba93
2 changed files with 124 additions and 59 deletions
+111 -55
View File
@@ -663,6 +663,14 @@ function sendMessageToContentTab(tabId, message, callback = null) {
return chrome.tabs.sendMessage(tabId, message);
}
function isMissingContentReceiverError(error) {
const message = String(error?.message || error || '');
return message.includes('Receiving end does not exist')
|| message.includes('Could not establish connection')
|| message.includes('No document with id')
|| message.includes('No document with ID');
}
function isCurrentContentSender(sender) {
if (!sender?.tab) return false;
const senderTabId = normalizeTabId(sender.tab.id);
@@ -1128,6 +1136,48 @@ function chatActivityDisplayName(senderId) {
return typeof peer === 'object' ? peer.username || senderId : senderId;
}
async function deliverChatActivity(entry) {
const tabId = normalizeTabId(currentTabId);
if (tabId === null) return;
const generation = targetActivationGeneration;
try {
await sendMessageToCurrentContent({
type: 'CHAT_EVENT',
event: entry
});
return;
} catch (error) {
if (!isMissingContentReceiverError(error)) {
addLog(`Chat activity delivery failed: ${error.message}`, 'warn');
return;
}
if (!isCurrentTargetIdentity(tabId, generation)) return;
}
for (let attempt = 0; attempt < 3; attempt++) {
try {
const activation = await refreshCurrentMediaTarget(tabId, { queueIfRunning: true });
if (activation?.status === 'activation_in_progress') {
await new Promise(resolve => setTimeout(resolve, 150));
continue;
}
if (activation?.status !== 'ok') return;
if (!isCurrentTargetIdentity(tabId, activation.generation)) return;
await sendMessageToCurrentContent({
type: 'CHAT_EVENT',
event: entry
});
return;
} catch (error) {
if (!isMissingContentReceiverError(error) || isCurrentTargetIdentity(tabId, generation)) {
addLog(`Chat activity delivery failed after target recovery: ${error.message}`, 'warn');
}
return;
}
}
}
function sendChatActivity(action, senderId, timestamp = Date.now()) {
if (!currentRoom || !serverSupportsChat()) return;
if (![EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK, EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE, 'joined', 'left'].includes(action)) return;
@@ -1140,10 +1190,9 @@ function sendChatActivity(action, senderId, timestamp = Date.now()) {
if (!entry) return;
if (storageInitialized) chrome.storage.session.set({ chatActivityTimeline: chatActivityStore.snapshot() }).catch(() => {});
if (!currentTabId) return;
sendMessageToCurrentContent({
type: 'CHAT_EVENT',
event: entry
}).catch(error => addLog(`Chat activity delivery failed: ${error.message}`, 'warn'));
deliverChatActivity(entry).catch(error => {
addLog(`Chat activity delivery failed: ${error.message}`, 'warn');
});
}
function scheduleReconnect() {
@@ -2029,20 +2078,32 @@ async function getReadyTabVideoState(tabId, expectedGeneration = targetActivatio
if (!isCurrentTargetIdentity(tabId, expectedGeneration)) {
return { error: 'Target tab changed before video state could be read' };
}
let state = await getTabVideoState(tabId);
if (!state || state.error || state.found === false) {
const activation = await refreshCurrentMediaTarget(tabId);
let state = null;
let targetGeneration = expectedGeneration;
for (let attempt = 0; attempt < 3; attempt++) {
state = await getTabVideoState(tabId);
if (state && !state.error && state.found !== false) break;
if (!isCurrentTargetIdentity(tabId, targetGeneration)) {
return { error: 'Target tab changed before video state could be read' };
}
const activation = await refreshCurrentMediaTarget(tabId, { queueIfRunning: true });
if (activation?.status === 'activation_in_progress') {
await new Promise(resolve => setTimeout(resolve, 150));
if (normalizeTabId(currentTabId) !== tabId) {
return { error: 'Target tab changed before video state could be read' };
}
targetGeneration = targetActivationGeneration;
continue;
}
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)) {
targetGeneration = activation.generation;
if (!isCurrentTargetIdentity(tabId, targetGeneration)) {
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' };
}
await new Promise(resolve => setTimeout(resolve, 150));
}
return decorateVideoState(tabId, state);
}
@@ -2189,38 +2250,43 @@ function setPageApiSeekEnabled(enabled) {
window.KOALA_PAGE_API_SEEK_ENABLED = enabled === true;
}
async function deactivateMediaFrameMonitors(tabId) {
const targets = listMediaFrameScriptTargets(tabId);
await Promise.all(targets.map(async target => {
const documentId = target.documentIds?.[0];
const frameId = target.frameIds?.[0];
function uniqueScriptTargets(targets) {
const seen = new Set();
return targets.filter(target => {
const key = JSON.stringify(target);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function deactivateMediaFrameMonitor() {
try { window.__koalaMediaFrameMonitorCleanup?.(); } catch { /* detached frame */ }
}
async function deactivateMediaFrameMonitors(tabId, contentTarget = null) {
const targets = uniqueScriptTargets([
contentTarget?.scriptTarget,
{ tabId },
...listMediaFrameScriptTargets(tabId)
].filter(Boolean));
for (const target of targets) {
try {
if (typeof documentId === 'string') {
await chrome.tabs.sendMessage(
tabId,
{ type: 'MEDIA_MONITOR_DEACTIVATE' },
{ documentId }
);
} else if (Number.isInteger(frameId)) {
await chrome.tabs.sendMessage(
tabId,
{ type: 'MEDIA_MONITOR_DEACTIVATE' },
{ frameId }
);
} else {
await chrome.tabs.sendMessage(tabId, { type: 'MEDIA_MONITOR_DEACTIVATE' });
}
await chrome.scripting.executeScript({
target,
func: deactivateMediaFrameMonitor
});
} catch {
// Denied or already-navigated frames have no installed monitor.
}
}));
}
}
async function deactivateTargetTab(tabId, contentTarget = null, { deactivateMonitor = true } = {}) {
const normalizedTabId = normalizeTabId(tabId);
if (normalizedTabId === null) return;
if (deactivateMonitor) {
await deactivateMediaFrameMonitors(normalizedTabId);
await deactivateMediaFrameMonitors(normalizedTabId, contentTarget);
}
const target = contentTarget
|| (normalizedTabId === normalizeTabId(currentTabId) ? currentContentTarget() : null)
@@ -2293,9 +2359,16 @@ function createTargetActivationSupersededError() {
}
async function injectMediaFrameMonitors(tabId, contentTarget) {
const targets = listMediaFrameScriptTargets(tabId);
// The all-frames target is only a best-effort sweep: one inaccessible
// frame can make Chromium reject the entire sweep. Always include the
// selected document explicitly so its lifecycle monitor is guaranteed.
const targets = uniqueScriptTargets([
contentTarget?.scriptTarget,
{ tabId },
...listMediaFrameScriptTargets(tabId)
].filter(Boolean));
let injectedCount = 0;
await Promise.all(targets.map(async target => {
for (const target of targets) {
try {
await chrome.scripting.executeScript({
target,
@@ -2305,25 +2378,8 @@ async function injectMediaFrameMonitors(tabId, contentTarget) {
} catch {
// One denied widget frame must not block the selected player.
}
}));
if (injectedCount > 0) return;
const fallbackTargets = [{ tabId }, contentTarget.scriptTarget];
const seen = new Set();
for (const target of fallbackTargets) {
const key = JSON.stringify(target);
if (seen.has(key)) continue;
seen.add(key);
try {
await chrome.scripting.executeScript({
target,
files: ['media-frame-monitor.js']
});
injectedCount++;
} catch {
// Main injection below reports a real selected-target failure.
}
}
return injectedCount;
}
async function injectContentScript(tabId, {
+13 -4
View File
@@ -46,12 +46,11 @@ describe('target tab lifecycle', () => {
it('uses all-frame probing for cross-origin targets without navigation permissions', () => {
expect(backgroundSource).toContain("files: ['media-frame-monitor.js']");
expect(backgroundSource).toContain('const targets = listMediaFrameScriptTargets(tabId)');
expect(backgroundSource).toContain('...listMediaFrameScriptTargets(tabId)');
expect(backgroundSource).toContain('One denied widget frame must not block the selected player');
expect(backgroundSource).toContain("navigationError.code = 'media_target_navigated'");
expect(backgroundSource).toContain("{ type: 'MEDIA_MONITOR_DEACTIVATE' }");
expect(backgroundSource).toContain('async function deactivateMediaFrameMonitors(tabId)');
expect(backgroundSource).toContain('{ documentId }');
expect(backgroundSource).toContain('async function deactivateMediaFrameMonitors(tabId, contentTarget');
expect(backgroundSource).toContain('func: deactivateMediaFrameMonitor');
expect(monitorSource).toContain("type: 'MEDIA_FRAME_CANDIDATE_CHANGED'");
expect(monitorSource).toContain("attributeFilter: ['class', 'style', 'hidden', 'src', 'controls']");
expect(monitorSource).toContain('if (!force && nextSignature === lastCandidateSignature) return');
@@ -68,6 +67,16 @@ describe('target tab lifecycle', () => {
expect(backgroundSource).not.toMatch(/chrome\.(?:web)?Navigation/);
});
it('keeps the selected frame recoverable when an all-frame sweep is rejected', () => {
expect(backgroundSource).toContain('contentTarget?.scriptTarget');
expect(backgroundSource).toContain('function uniqueScriptTargets(targets)');
expect(backgroundSource).toContain('function deactivateMediaFrameMonitor()');
expect(backgroundSource).toContain('func: deactivateMediaFrameMonitor');
expect(backgroundSource).toContain('isMissingContentReceiverError(error)');
expect(backgroundSource).toContain('await refreshCurrentMediaTarget(tabId, { queueIfRunning: true })');
expect(backgroundSource).toContain("activation?.status === 'activation_in_progress'");
});
it('serializes content commands and coalesces target refreshes', () => {
expect(backgroundSource).toContain('contentCommandQueue.catch(() => {}).then(deliver)');
expect(backgroundSource).toContain('if (mediaTargetRefreshTask && mediaTargetRefreshTabId === selectedTabId)');