fix(extension): control nested players without permission prompts or churn

Google Drive and YummyAnime host their player in a cross-origin iframe. The
3.1.2 targeting work reached those frames but misdiagnosed and destabilized
them in four separate ways. No manifest permission is added or restored;
webNavigation stays removed.

Access diagnosis was inferred, not measured. Every frame probe error was
swallowed, and any origin that failed to answer was reported as missing host
access. A slow or still-loading player frame therefore produced
"Host access required for youtube.googleapis.com" for an origin the extension
already held. The resolver now asks permissions.contains() before raising an
access error, and treats a granted-but-unresponsive origin as a retry, not a
user decision.

Probes were unbounded. Every executeScript in the resolver now runs under a
timeout, so one unreachable frame can no longer stall an activation, and the
retry budget drops from eight passes to three.

The chat overlay followed the player into its frame, which rendered it on top
of the video and scoped closing and minimizing to that frame. It is now always
installed in the tab's top document, with all chat traffic routed to frame 0,
while only the playback controller goes into the selected media frame.

Nested targets reactivated continuously. Every heartbeat and content event
revalidated the target with a full teardown and reinjection, and the media
monitor treated ordinary play, pause and buffering as frame layout changes.
Both paths now reactivate only when the selected frame or document actually
moves.

Also restores the audio-route retention that keeps a deselected tab audible:
createMediaElementSource() can only be called once per element, so a
reinjected content script must adopt the existing route rather than rebuild it.

Verified with 90 unit tests, 40 browser E2E tests including two new
Drive-shaped fixtures that assert the controller lands in the player frame
while the chat stays in the top document, and npm run verify.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Timo
2026-08-18 00:15:10 +02:00
parent 04694d4439
commit 75a9ba5d3d
8 changed files with 1933 additions and 1447 deletions
+83
View File
@@ -607,6 +607,89 @@ test('rejects a hidden cross-origin player after its iframe URL redirects', asyn
expect(await redirectedFrame.locator('video').getAttribute('data-koala-attached')).toBeNull();
});
/**
* Reads one global from the top document and from the player frame separately,
* so a test can prove which frame a script was installed in.
*/
async function readPerFrameGlobal(context, extensionId, pageUrl, globalName) {
return withExtensionPage(context, extensionId, page => page.evaluate(async ({ pageUrl, globalName }) => {
const [tab] = await chrome.tabs.query({ url: pageUrl });
if (!tab) throw new Error(`no tab matched ${pageUrl}`);
const results = await chrome.scripting.executeScript({
target: { tabId: tab.id, allFrames: true },
func: name => ({
href: location.href,
isTop: window.top === window,
present: typeof window[name] !== 'undefined' && window[name] !== null
}),
args: [globalName]
});
const entries = results.map(entry => entry.result).filter(Boolean);
return {
top: entries.find(entry => entry.isTop)?.present ?? null,
player: entries.find(entry => !entry.isTop && entry.href.includes('player-frame'))?.present ?? null
};
}, { pageUrl, globalName }));
}
test('controls a Drive-style cross-origin player without moving the chat into it', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/drive-style-player.html`;
const page = await context.newPage();
await page.goto(url);
await page.waitForFunction(() => window.__fixtureReady === true);
const { response } = await selectTargetTab(context, extensionId, url);
// The top document hosts no video, so the target must be the player frame.
expect(response).toMatchObject({ status: 'ok', hasVideo: true });
expect(response.frameId).not.toBe(0);
const playerFrame = page.frames().find(frame => frame.url().includes('player-frame'));
await expect
.poll(() => playerFrame.locator('video').getAttribute('data-koala-attached'))
.toBe('true');
// The controller belongs in the player frame...
await expect
.poll(() => readPerFrameGlobal(context, extensionId, url, 'koalaSyncInjected'))
.toMatchObject({ player: true });
// ...and the chat overlay belongs in the top document, never inside the
// video. Installing it in the player frame is what rendered the chat on top
// of the picture and made closing it affect only that frame.
await expect
.poll(() => readPerFrameGlobal(context, extensionId, url, 'koalaSyncChatOverlay'))
.toMatchObject({ top: true, player: false });
});
test('keeps controlling a Drive-style player across an ordinary play and pause', async ({ context, extensionId, baseURL }) => {
const url = `${baseURL}/pages/drive-style-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 selectedFrameId = response.frameId;
const playerFrame = page.frames().find(frame => frame.url().includes('player-frame'));
await playerFrame.locator('video').evaluate(video => video.play());
await playerFrame.locator('video').evaluate(video => video.pause());
await page.waitForTimeout(750);
// Playback state changes are not frame layout changes. If they were treated
// as such, the target would be torn down and re-injected mid-playback.
const status = await getExtensionState(context, extensionId, { type: 'GET_STATUS' });
expect(status).toMatchObject({
targetTabId: tabId,
targetReady: true,
targetActivationState: 'ready',
targetFrameId: selectedFrameId
});
const command = await sendServerCommand(context, extensionId, tabId, 'play', { time: 1 });
expect(command).toMatchObject({ status: 'ok_solo' });
await expect.poll(() => playerFrame.locator('video').evaluate(video => video.paused)).toBe(false);
});
function FRAMED_VIDEO_PAUSED() {
return document.querySelector('iframe').contentDocument.querySelector('video').paused;
}
@@ -0,0 +1,23 @@
<!doctype html>
<meta charset="utf-8">
<title>Drive-style embedded player</title>
<style>
body { margin: 0; font: 14px sans-serif; }
#shell { padding: 24px; }
iframe { border: 0; }
</style>
<!--
Mirrors the Google Drive / YummyAnime layout: the top document owns the page
chrome and hosts no video at all, while the only player lives in a visible
cross-origin iframe. Playback control belongs in that frame; the chat overlay
must stay in this document.
-->
<div id="shell">
<h1>Shared file</h1>
<iframe id="player-frame" width="854" height="480" allowfullscreen></iframe>
</div>
<script>
const frame = document.getElementById('player-frame');
frame.src = `http://127.0.0.1:${location.port}/pages/frames/player-frame.html`;
frame.addEventListener('load', () => { window.__fixtureReady = true; }, { once: true });
</script>