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
+137 -20
View File
@@ -653,6 +653,18 @@ function sendMessageToCurrentContent(message, callback = null) {
);
}
/**
* Chat is page UI, not player UI. The controlled video can live in a nested
* cross-origin frame (Drive, YummyAnime), but the overlay always belongs to the
* tab's top document: inside the player frame it renders on top of the video,
* and closing or minimizing it only affects that frame.
*/
function sendMessageToChatOverlay(message) {
const tabId = normalizeTabId(currentTabId);
if (tabId === null) return Promise.reject(new Error('No target tab selected'));
return sendMessageToFrame(tabId, 0, message);
}
function sendMessageToContentTab(tabId, message, callback = null) {
if (normalizeTabId(tabId) === normalizeTabId(currentTabId)) {
return sendMessageToCurrentContent(message, callback);
@@ -1140,7 +1152,7 @@ function sendChatActivity(action, senderId, timestamp = Date.now()) {
if (!entry) return;
if (storageInitialized) chrome.storage.session.set({ chatActivityTimeline: chatActivityStore.snapshot() }).catch(() => {});
if (!currentTabId) return;
sendMessageToCurrentContent({
sendMessageToChatOverlay({
type: 'CHAT_EVENT',
event: entry
}).catch(error => addLog(`Chat activity delivery failed: ${error.message}`, 'warn'));
@@ -1351,7 +1363,7 @@ async function handleServerEvent(event, data) {
hostPeerId = data.hostPeerId || null;
controllers = Array.isArray(data.controllers) ? data.controllers : [];
serverCapabilities = Array.isArray(data.capabilities) ? data.capabilities : [];
if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
if (currentTabId) sendMessageToChatOverlay({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
hcmEnforceDesyncInvariant();
broadcastControlMode();
markRoomPotentiallyIdle();
@@ -1450,7 +1462,7 @@ async function handleServerEvent(event, data) {
(typeof candidate === 'object' ? candidate.peerId : candidate) === received.senderId
);
if (Number.isInteger(tabId)) {
sendMessageToCurrentContent({
sendMessageToChatOverlay({
type: 'CHAT_MESSAGE',
message: {
id: received.id,
@@ -2246,6 +2258,15 @@ async function deactivateTargetTab(tabId, contentTarget = null, { deactivateMoni
null,
target.documentId
).catch(() => {});
// The overlay lives in the top document whenever the player is nested, so
// clearing only the media frame would leave a stale chat behind on Drive.
if (normalizeFrameId(target.frameId) !== 0) {
await sendMessageToFrame(
normalizedTabId,
0,
{ type: 'CHAT_DESTROY' }
).catch(() => {});
}
}
function createHostAccessRequiredError(access, requestAdded, cause) {
@@ -2411,10 +2432,38 @@ async function injectContentScript(tabId, {
func: setPageApiSeekEnabled,
args: [pageApiSeekReady]
});
const injectionResults = await chrome.scripting.executeScript({
target: scriptTarget,
files: ['chat-format.js', 'chat-overlay.js', 'content.js']
});
// The chat overlay is standalone page UI and carries its own runtime
// message listener, so it is installed in the top document regardless of
// where the player lives. Only the playback controller goes into the
// selected media frame.
let injectionResults;
if (contentTarget.frameId === 0) {
injectionResults = await chrome.scripting.executeScript({
target: scriptTarget,
files: ['chat-format.js', 'chat-overlay.js', 'content.js']
});
} else {
try {
await chrome.scripting.executeScript({
target: { tabId, frameIds: [0] },
files: ['chat-format.js', 'chat-overlay.js']
});
} catch (err) {
addLog(`Chat overlay injection failed in the top frame: ${err.message}`, 'warn');
}
// A pre-3.1.3 build may have left an overlay inside the player.
await sendMessageToFrame(
tabId,
contentTarget.frameId,
{ type: 'CHAT_DESTROY' },
null,
contentTarget.documentId
).catch(() => {});
injectionResults = await chrome.scripting.executeScript({
target: scriptTarget,
files: ['content.js']
});
}
const frameResult = Array.isArray(injectionResults)
? injectionResults.find(result => normalizeFrameId(result?.frameId) === contentTarget.frameId)
: null;
@@ -2791,7 +2840,32 @@ async function reactivateCurrentTarget(tabId, { expectedGeneration = targetActiv
});
}
function refreshCurrentMediaTarget(tabId, { queueIfRunning = false } = {}) {
/**
* Cheap pre-check for lifecycle-driven refreshes.
*
* Reactivation tears down and re-injects the content script, which interrupts
* playback and audio routing. That price is only worth paying when the selected
* frame or document actually moved — not for the constant DOM churn that pages
* like Drive and YouTube produce while simply playing.
*/
async function selectedMediaTargetMoved(tabId) {
try {
const resolved = await resolveMediaContentTarget(chrome, tabId, { attempts: 1 });
if (normalizeTabId(currentTabId) !== normalizeTabId(tabId)) return false;
const frameMoved = normalizeFrameId(resolved.frameId) !== normalizeFrameId(currentTargetFrameId);
const documentMoved = typeof resolved.documentId === 'string'
&& typeof currentTargetDocumentId === 'string'
&& resolved.documentId !== currentTargetDocumentId;
const gainedVideo = resolved.hasVideo === true && currentTargetHasVideo !== true;
return frameMoved || documentMoved || gainedVideo;
} catch {
// Access-required and ambiguity errors must reach the full activation
// path so the popup can surface them.
return true;
}
}
function refreshCurrentMediaTarget(tabId, { queueIfRunning = false, onlyIfTargetMoved = false } = {}) {
const selectedTabId = normalizeTabId(tabId);
if (selectedTabId === null || normalizeTabId(currentTabId) !== selectedTabId) {
return Promise.resolve({ status: 'superseded' });
@@ -2810,6 +2884,10 @@ function refreshCurrentMediaTarget(tabId, { queueIfRunning = false } = {}) {
do {
pass++;
mediaTargetRefreshDirty = false;
if (onlyIfTargetMoved && !(await selectedMediaTargetMoved(selectedTabId))) {
result = { status: 'unchanged' };
break;
}
const expectedGeneration = targetActivationGeneration;
result = await reactivateCurrentTarget(selectedTabId, { expectedGeneration });
// Let lifecycle messages queued during the final probe/injection
@@ -3104,7 +3182,7 @@ chrome.alarms.onAlarm.addListener(async (alarm) => {
function leaveOldRoomIfSwitching(newRoomId) {
if (currentRoom && currentRoom.roomId !== newRoomId) {
if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_RESET' }).catch(() => {});
if (currentTabId) sendMessageToChatOverlay({ type: 'CHAT_RESET' }).catch(() => {});
addLog(`Switching rooms: leaving ${currentRoom.roomId} to join ${newRoomId}`, 'info');
forceDisconnect();
currentRoom = null;
@@ -3194,12 +3272,12 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== 'local') return;
if (changes.browserNotifications && currentTabId) {
sendMessageToCurrentContent({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
sendMessageToChatOverlay({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
}
if (!changes.roomId && !changes.chatKey && !changes.chatEnabled) return;
if (changes.chatKey) chatSecretGuard = validateChatSecret(changes.chatKey.newValue);
invalidateChatSession();
if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
if (currentTabId) sendMessageToChatOverlay({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
});
async function handleAsyncMessage(message, sender, sendResponse) {
@@ -3217,7 +3295,11 @@ async function handleAsyncMessage(message, sender, sendResponse) {
&& isCurrentContentSender(sender)
&& (message.type === 'CONTENT_EVENT' || message.type === 'HEARTBEAT');
if (mustRevalidateEmbeddedSender) {
await refreshCurrentMediaTarget(senderTabId).catch(() => {});
// Heartbeats and content events arrive continuously. Revalidating a
// nested target is only about confirming the frame still holds the
// player, so it must not reinject the content script every time: that
// put Drive- and anime-style targets into a permanent activation loop.
await refreshCurrentMediaTarget(senderTabId, { onlyIfTargetMoved: true }).catch(() => {});
}
if (message.type === 'CONNECT') {
@@ -3228,7 +3310,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
if (settings.roomId && currentRoom && currentRoom.roomId === settings.roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
broadcastConnectionStatus('connected');
if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
if (currentTabId) sendMessageToChatOverlay({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
await broadcastJoinStatus({ type: 'JOIN_STATUS', success: true, message: 'Already in room' });
if (typeof sendResponse === 'function') sendResponse({ status: 'ok' });
return;
@@ -3278,12 +3360,26 @@ 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';
sendResponse({
status,
peerId,
// One public selection, one derived state. Activation is always terminal:
// it settles in ready or access_required, never in an open-ended
// "activating" that the popup keeps retrying on every open.
const selectedTargetTabId = normalizeTabId(currentTabId);
const targetReady = selectedTargetTabId !== null && !activeTargetActivation;
const targetActivationState = targetReady
? 'ready'
: activeTargetActivation
? 'activating'
: pendingTarget
? 'access_required'
: 'none';
sendResponse({
status,
peerId,
peers: currentRoom ? currentRoom.peers : [],
lastActionState,
targetTabId: currentTabId,
targetReady,
targetActivationState,
targetFrameId: currentTargetFrameId,
targetDocumentId: currentTargetDocumentId,
targetHasVideo: currentTargetHasVideo,
@@ -3607,7 +3703,7 @@ async function handleAsyncMessage(message, sender, sendResponse) {
if (roomId && currentRoom && currentRoom.roomId === roomId && socket && socket.readyState === WebSocket.OPEN && isNamespaceJoined && desiredUrl === currentServerUrl) {
broadcastConnectionStatus('connected');
if (currentTabId) sendMessageToCurrentContent({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
if (currentTabId) sendMessageToChatOverlay({ type: 'CHAT_CONTEXT_UPDATE' }).catch(() => {});
const statusSent = await broadcastJoinStatus(
{ type: 'JOIN_STATUS', success: true, message: 'Already in room' },
isCurrentJoin
@@ -4151,7 +4247,16 @@ async function handleAsyncMessage(message, sender, sendResponse) {
sendResponse({ status: 'ignored_stale_tab' });
return;
}
const activation = await refreshCurrentMediaTarget(tabId, { queueIfRunning: true });
// A page-driven notification must never surface as a handler failure.
// Reporting it that way turned one unreachable player frame into an
// endless error cascade in the popup.
const activation = await refreshCurrentMediaTarget(tabId, {
queueIfRunning: true,
onlyIfTargetMoved: true
}).catch(error => {
addLog(`Media frame candidate refresh failed: ${error.message}`, 'warn');
return { status: 'error', message: error.message };
});
sendResponse(activation || { status: 'invalid_tab' });
} else if (message.type === 'MEDIA_FRAME_VISIBILITY') {
if (!isCurrentContentSender(sender)) {
@@ -4165,7 +4270,13 @@ async function handleAsyncMessage(message, sender, sendResponse) {
const tabId = normalizeTabId(sender.tab?.id);
const activation = tabId === null
? null
: await refreshCurrentMediaTarget(tabId, { queueIfRunning: true });
: await refreshCurrentMediaTarget(tabId, {
queueIfRunning: true,
onlyIfTargetMoved: true
}).catch(error => {
addLog(`Media frame visibility refresh failed: ${error.message}`, 'warn');
return { status: 'error', message: error.message };
});
sendResponse(activation || { status: 'invalid_tab' });
} else if (message.type === 'MEDIA_TARGET_REFRESH') {
if (!isCurrentContentSender(sender)) {
@@ -4175,7 +4286,13 @@ async function handleAsyncMessage(message, sender, sendResponse) {
const tabId = normalizeTabId(sender.tab?.id);
const activation = tabId === null
? null
: await refreshCurrentMediaTarget(tabId, { queueIfRunning: true });
: await refreshCurrentMediaTarget(tabId, {
queueIfRunning: true,
onlyIfTargetMoved: true
}).catch(error => {
addLog(`Media target refresh failed: ${error.message}`, 'warn');
return { status: 'error', message: error.message };
});
sendResponse(activation || { status: 'invalid_tab' });
} else if (message.type === 'CONTENT_BOOT') {
if (sender.tab) {
+1422 -1403
View File
File diff suppressed because it is too large Load Diff
+7 -4
View File
@@ -62,12 +62,15 @@
const source = element.tagName === 'VIDEO'
? (element.currentSrc || element.src || element.querySelector?.('source[src]')?.src || '')
: (element.src || '');
// Deliberately coarse. This signature answers "which frame is a
// candidate", not "what is it doing". Including paused/readyState
// /duration made every play, pause and buffering tick look like a
// layout change, so ordinary playback retriggered a full target
// reactivation and re-injected the content script under the user.
const mediaState = element.tagName === 'VIDEO'
? [
element.paused ? 0 : 1,
element.controls ? 1 : 0,
Number.isInteger(element.readyState) ? element.readyState : 0,
Number.isFinite(element.duration) ? Math.round(element.duration) : 0
element.readyState > 0 ? 1 : 0,
Number.isFinite(element.duration) && element.duration > 0 ? 1 : 0
].join(',')
: '';
parts.push([
+113 -19
View File
@@ -1,9 +1,11 @@
export const MEDIA_FRAME_ACCESS_REQUIRED = 'media_frame_access_required';
export const MEDIA_FRAME_AMBIGUOUS = 'media_frame_ambiguous';
export const MEDIA_FRAME_PROBE_TIMEOUT = 'media_frame_probe_timeout';
const MIN_PLAYER_FRAME_AREA = 320 * 180;
const MIN_PLAYER_ASPECT_RATIO = 1.15;
const MAX_PLAYER_ASPECT_RATIO = 2.6;
const DEFAULT_PROBE_TIMEOUT_MS = 2000;
function normalizeFrameId(value) {
return Number.isInteger(value) && value >= 0 ? value : 0;
@@ -388,13 +390,19 @@ function findMissingPlayerAccess(results) {
function shouldPreferMissingAccess(access, selected) {
if (!access) return false;
if (access.drivePlayer || !selected?.result?.bestVideo) return true;
if (!selected?.result?.bestVideo) return true;
const video = selected.result.bestVideo;
if (!video.hasSource || !video.rendered || video.background) return true;
const selectedArea = Number.isFinite(video.renderedArea) ? video.renderedArea : 0;
const weakAccessibleCandidate = !video.controls
&& video.duration > 0
&& video.duration < 300;
// Drive never plays the file in its own document, so its embedded player
// outranks a weak local candidate. It must not outrank a real one: a Drive
// tab can host an ordinary accessible video next to a file preview.
if (access.drivePlayer) {
return weakAccessibleCandidate || selectedArea < MIN_PLAYER_FRAME_AREA;
}
return weakAccessibleCandidate
&& access.area >= Math.max(MIN_PLAYER_FRAME_AREA, selectedArea * 1.5);
}
@@ -431,41 +439,106 @@ export function listMediaFrameScriptTargets(tabId) {
return [{ tabId, allFrames: true }];
}
async function executeInAccessibleFrames(chromeApi, targets, func, args) {
function probeTimeoutError(label, timeoutMs) {
const error = new Error(`${label} timed out after ${timeoutMs}ms`);
error.code = MEDIA_FRAME_PROBE_TIMEOUT;
return error;
}
function executeWithTimeout(task, timeoutMs, label) {
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return task();
let timeoutId = null;
const timeout = new Promise((_, reject) => {
timeoutId = setTimeout(() => reject(probeTimeoutError(label, timeoutMs)), timeoutMs);
});
return Promise.race([task(), timeout]).finally(() => {
if (timeoutId !== null) clearTimeout(timeoutId);
});
}
async function executeInAccessibleFrames(chromeApi, targets, func, args, timeoutMs) {
const errors = [];
const settled = await Promise.all(targets.map(async target => {
try {
return await chromeApi.scripting.executeScript({ target, func, args });
} catch {
const result = await executeWithTimeout(
() => chromeApi.scripting.executeScript({ target, func, args }),
timeoutMs,
`Frame probe ${JSON.stringify(target)}`
);
return Array.isArray(result) ? result : [];
} catch (error) {
// A failed probe is recorded, never silently dropped. Only the
// caller can tell "withheld origin" from "frame is still loading",
// and guessing that difference is what produced false permission
// prompts for players the extension was already allowed to touch.
errors.push({ target, error });
return [];
}
}));
return settled.flat();
return { results: settled.flat(), errors };
}
/**
* Asks the browser whether an origin is genuinely withheld.
*
* Returns true when the grant is missing, false when it is held, and null when
* the browser cannot answer. A frame that did not respond to a probe is not
* evidence of a missing grant: that inference is what made Drive and
* YummyAnime demand access for an origin the extension already had.
*/
async function originAccessIsWithheld(chromeApi, originPattern) {
if (typeof originPattern !== 'string' || !originPattern) return null;
if (typeof chromeApi?.permissions?.contains !== 'function') return null;
try {
const granted = await executeWithTimeout(
() => Promise.resolve(chromeApi.permissions.contains({ origins: [originPattern] })),
1000,
`Permission check for ${originPattern}`
);
if (granted === true) return false;
if (granted === false) return true;
return null;
} catch {
return null;
}
}
export async function resolveMediaContentTarget(chromeApi, tabId, {
attempts = 8,
attempts = 3,
retryDelayMs = 200,
probeDelayMs = 60
probeDelayMs = 60,
probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS
} = {}) {
let fallback = null;
let missingAccess = null;
let ambiguous = false;
let unresolvedGrantedHost = null;
for (let attempt = 0; attempt < attempts; attempt++) {
const scriptTargets = listMediaFrameScriptTargets(tabId);
let results = await executeInAccessibleFrames(
let { results } = await executeInAccessibleFrames(
chromeApi,
scriptTargets,
inspectMediaFrame,
[null]
[null],
probeTimeoutMs
);
if (results.length === 0) {
// The all-frames sweep answered for nothing at all, so fall back to
// the top document alone. Every probe is time-boxed: an unreachable
// player frame must never stall the whole activation.
try {
results = await chromeApi.scripting.executeScript({
target: { tabId },
func: inspectMediaFrame,
args: [null]
});
const topResults = await executeWithTimeout(
() => chromeApi.scripting.executeScript({
target: { tabId },
func: inspectMediaFrame,
args: [null]
}),
probeTimeoutMs,
'Top-frame probe'
);
results = Array.isArray(topResults) ? topResults : [];
if (results.length === 0) return contentTarget(tabId, null);
} catch {
return contentTarget(tabId, null);
}
@@ -478,7 +551,8 @@ export async function resolveMediaContentTarget(chromeApi, tabId, {
chromeApi,
scriptTargets,
installParentFrameVisibilityProbe,
[token]
[token],
probeTimeoutMs
);
// Four passes match the maximum same-origin recursion depth.
for (let pass = 0; pass < 4; pass++) {
@@ -486,15 +560,17 @@ export async function resolveMediaContentTarget(chromeApi, tabId, {
chromeApi,
scriptTargets,
dispatchParentFrameVisibilityProbe,
[token]
[token],
probeTimeoutMs
);
await new Promise(resolve => setTimeout(resolve, probeDelayMs));
}
const inspected = await executeInAccessibleFrames(
const { results: inspected } = await executeInAccessibleFrames(
chromeApi,
scriptTargets,
inspectMediaFrame,
[token]
[token],
probeTimeoutMs
);
if (inspected.length > 0) results = inspected;
} catch {
@@ -506,7 +582,20 @@ export async function resolveMediaContentTarget(chromeApi, tabId, {
const selected = selectMediaFrame(results);
const videoCandidates = results.filter(entry => entry?.result?.bestVideo?.rendered === true
&& entry.result.parentFrameVisible !== false);
const currentMissingAccess = findMissingPlayerAccess(results);
let currentMissingAccess = findMissingPlayerAccess(results);
if (currentMissingAccess) {
const withheld = await originAccessIsWithheld(
chromeApi,
currentMissingAccess.originPattern
);
if (withheld === false) {
// The grant is already held, so the player frame is merely slow,
// still navigating, or gone. Retrying is correct here; prompting
// for a permission the user already gave is not.
unresolvedGrantedHost = currentMissingAccess.host;
currentMissingAccess = null;
}
}
missingAccess = currentMissingAccess;
fallback = selected;
ambiguous = !selected && videoCandidates.length > 1;
@@ -525,6 +614,11 @@ export async function resolveMediaContentTarget(chromeApi, tabId, {
if (missingAccess) throw accessRequiredError(missingAccess);
if (fallback) return contentTarget(tabId, fallback);
// A player whose origin is already granted but which never answered is a
// timing problem, not a user decision. Keep the tab selected on its top
// frame so the injected monitor can promote the real player once it loads,
// instead of failing the activation or prompting for nothing.
if (unresolvedGrantedHost) return contentTarget(tabId, null);
if (ambiguous) throw ambiguousFrameError();
return contentTarget(tabId, null);
}
+107
View File
@@ -445,3 +445,110 @@ describe('cross-origin media-frame targeting', () => {
)).rejects.toMatchObject({ code: MEDIA_FRAME_AMBIGUOUS });
});
});
describe('embedded player access diagnosis', () => {
function driveTop() {
return frame(0, {
href: 'https://drive.google.com/file/d/abc/view',
origin: 'https://drive.google.com',
bestVideo: null,
videoCount: 0,
embeddedFrames: [{
href: 'https://youtube.googleapis.com/embed/abc?origin=https%3A%2F%2Fdrive.google.com',
origin: 'https://youtube.googleapis.com',
area: 640 * 360,
width: 640,
height: 360,
visible: true,
depth: 1,
mediaHint: true
}]
});
}
it('does not demand access for a player origin the extension already holds', async () => {
const executeScript = vi.fn().mockResolvedValue([driveTop()]);
const contains = vi.fn().mockResolvedValue(true);
// The player frame never answered the probe, but the grant exists. That
// is a loading race, not a user decision, so the tab stays selected on
// its top frame instead of raising a permission prompt.
await expect(resolveMediaContentTarget(
{ scripting: { executeScript }, permissions: { contains } },
42,
{ attempts: 1, probeDelayMs: 0 }
)).resolves.toEqual({
frameId: 0,
documentId: null,
frameUrl: null,
hasVideo: false,
scriptTarget: { tabId: 42 }
});
expect(contains).toHaveBeenCalledWith({
origins: ['https://youtube.googleapis.com/*']
});
});
it('demands access only when the browser confirms the origin is withheld', async () => {
const executeScript = vi.fn().mockResolvedValue([driveTop()]);
const contains = vi.fn().mockResolvedValue(false);
await expect(resolveMediaContentTarget(
{ scripting: { executeScript }, permissions: { contains } },
42,
{ attempts: 1, probeDelayMs: 0 }
)).rejects.toMatchObject({
code: MEDIA_FRAME_ACCESS_REQUIRED,
host: 'youtube.googleapis.com'
});
});
it('keeps demanding access when the browser cannot answer', async () => {
const executeScript = vi.fn().mockResolvedValue([driveTop()]);
const contains = vi.fn().mockRejectedValue(new Error('unavailable'));
await expect(resolveMediaContentTarget(
{ scripting: { executeScript }, permissions: { contains } },
42,
{ attempts: 1, probeDelayMs: 0 }
)).rejects.toMatchObject({ code: MEDIA_FRAME_ACCESS_REQUIRED });
});
it('resolves instead of hanging when a frame probe never settles', async () => {
const executeScript = vi.fn()
.mockImplementationOnce(() => new Promise(() => {}))
.mockResolvedValue([frame(0, { bestVideo: null, videoCount: 0 })]);
await expect(resolveMediaContentTarget(
{ scripting: { executeScript } },
42,
{ attempts: 1, probeDelayMs: 0, probeTimeoutMs: 20 }
)).resolves.toMatchObject({ frameId: 0, scriptTarget: { tabId: 42 } });
});
it('prefers a real accessible player over a Drive embed', async () => {
const top = frame(0, {
href: 'https://drive.google.com/file/d/abc/view',
origin: 'https://drive.google.com',
bestVideo: video({ controls: true, duration: 2400, renderedArea: 900 * 506 }),
embeddedFrames: [{
href: 'https://youtube.googleapis.com/embed/abc?origin=https%3A%2F%2Fdrive.google.com',
origin: 'https://youtube.googleapis.com',
area: 320 * 180,
width: 320,
height: 180,
visible: true,
depth: 1,
mediaHint: true
}]
});
const executeScript = vi.fn().mockResolvedValue([top]);
const contains = vi.fn().mockResolvedValue(false);
await expect(resolveMediaContentTarget(
{ scripting: { executeScript }, permissions: { contains } },
42,
{ attempts: 1, probeDelayMs: 0 }
)).resolves.toMatchObject({ frameId: 0, hasVideo: true });
});
});
+41 -1
View File
@@ -29,6 +29,43 @@ describe('target tab lifecycle', () => {
expect(overlaySource).toContain('if (window.koalaSyncChatOverlay?.refresh)');
});
it('keeps the chat overlay in the top document when the player is nested', () => {
expect(backgroundSource).toContain('function sendMessageToChatOverlay(message)');
expect(backgroundSource).toContain('return sendMessageToFrame(tabId, 0, message)');
// Every chat-facing message must reach the overlay's frame, not the
// player's. A stray sendMessageToCurrentContent here renders the chat
// inside the video on Drive.
expect(backgroundSource).not.toMatch(/sendMessageToCurrentContent\(\{\s*type: 'CHAT/);
expect(backgroundSource).toMatch(
/target: \{ tabId, frameIds: \[0\] \},\s*files: \['chat-format\.js', 'chat-overlay\.js'\]/
);
expect(backgroundSource).toContain("files: ['content.js']");
expect(backgroundSource).toContain('if (normalizeFrameId(target.frameId) !== 0)');
});
it('does not reactivate the target for ordinary playback churn', () => {
expect(backgroundSource).toContain('async function selectedMediaTargetMoved(tabId)');
expect(backgroundSource).toContain('onlyIfTargetMoved = false');
expect(backgroundSource.match(/onlyIfTargetMoved: true/g)?.length).toBe(4);
// Playback state must stay out of the candidate signature, otherwise
// every play/pause looks like a frame layout change.
expect(monitorSource).not.toContain('element.paused ? 0 : 1');
expect(monitorSource).toContain('element.readyState > 0 ? 1 : 0');
});
it('bounds every frame probe and verifies withheld origins', () => {
const resolverSource = fs.readFileSync(
path.join(extensionDir, 'media-frame-target.js'),
'utf8'
);
expect(resolverSource).toContain('async function originAccessIsWithheld(chromeApi, originPattern)');
expect(resolverSource).toContain('probeTimeoutMs = DEFAULT_PROBE_TIMEOUT_MS');
expect(resolverSource).toContain('attempts = 3');
// A swallowed probe error is what turned a slow player frame into a
// permission prompt for an origin the extension already held.
expect(resolverSource).toContain('errors.push({ target, error })');
});
it('fully deactivates old and superseded target injections', () => {
expect(backgroundSource).toContain("{ type: 'TARGET_DEACTIVATE' }");
expect(backgroundSource).toContain('target.documentId');
@@ -79,7 +116,10 @@ describe('target tab lifecycle', () => {
});
it('tears down every persistent content-script resource', () => {
expect(contentSource).toContain('function destroyContentScript()');
expect(contentSource).toContain('function destroyContentScript({ preserveAudioRoute = false } = {})');
// Deselecting a tab hands the page back to itself; it must not go mute.
expect(contentSource).toContain('destroyContentScript({ preserveAudioRoute: true });');
expect(contentSource).toContain('if (!preserveAudioRoute) closeAudioContext();');
expect(contentSource).toContain('observer.disconnect()');
expect(contentSource).toContain('keepAlivePort.disconnect()');
expect(contentSource).toContain('for (const video of [...attachedVideos]) detachVideoListeners(video);');
+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>