mirror of
https://github.com/Shik3i/KoalaSync.git
synced 2026-08-19 15:46:15 +00:00
75a9ba5d3d
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>
625 lines
26 KiB
JavaScript
625 lines
26 KiB
JavaScript
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;
|
|
}
|
|
|
|
function safeOrigin(value) {
|
|
try {
|
|
const url = new URL(value);
|
|
return (url.protocol === 'http:' || url.protocol === 'https:') ? url.origin : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function originPattern(value) {
|
|
try {
|
|
const url = new URL(value);
|
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
|
// WebExtension match patterns intentionally omit ports. Chromium treats
|
|
// the host pattern port-independently; Firefox rejects explicit ports.
|
|
return `${url.protocol}//${url.hostname}/*`;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isGoogleDrivePlayerUrl(value) {
|
|
try {
|
|
const url = new URL(value);
|
|
if (url.hostname.toLowerCase() !== 'youtube.googleapis.com'
|
|
|| (url.pathname !== '/embed' && !url.pathname.startsWith('/embed/'))) {
|
|
return false;
|
|
}
|
|
const parentOrigin = url.searchParams.get('origin') || url.searchParams.get('post_message_origin');
|
|
return parentOrigin === 'https://drive.google.com';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Runs inside every frame through chrome.scripting.executeScript. Keep this
|
|
* function self-contained: extension functions outside its body are not
|
|
* available in the injected isolated world.
|
|
*/
|
|
export function inspectMediaFrame(expectedVisibilityToken = null) {
|
|
const elementIsVisible = (element, rect) => {
|
|
if (!rect || rect.width <= 0 || rect.height <= 0) return false;
|
|
const view = element.ownerDocument?.defaultView || window;
|
|
const style = view.getComputedStyle(element);
|
|
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) {
|
|
return false;
|
|
}
|
|
if (typeof element.checkVisibility === 'function'
|
|
&& !element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) {
|
|
return false;
|
|
}
|
|
const layoutWidth = Math.max(view.innerWidth, element.ownerDocument?.documentElement?.scrollWidth || 0);
|
|
const layoutHeight = Math.max(view.innerHeight, element.ownerDocument?.documentElement?.scrollHeight || 0);
|
|
const scrollX = Number(view.scrollX) || 0;
|
|
const scrollY = Number(view.scrollY) || 0;
|
|
return rect.bottom + scrollY > 0
|
|
&& rect.right + scrollX > 0
|
|
&& rect.top + scrollY < layoutHeight
|
|
&& rect.left + scrollX < layoutWidth;
|
|
};
|
|
|
|
const collectVideos = (doc, depth = 0, ancestorVisible = true, videos = [], seen = new Set()) => {
|
|
if (depth >= 4 || typeof doc.querySelectorAll !== 'function') return videos;
|
|
for (const video of doc.querySelectorAll('video')) {
|
|
if (!seen.has(video)) {
|
|
seen.add(video);
|
|
videos.push({ video, ancestorVisible });
|
|
}
|
|
}
|
|
const hosts = doc.querySelectorAll('[id*="player" i], [class*="player" i], [id*="video" i], [class*="video" i], [id*="media" i], [class*="media" i], [id*="stream" i], [class*="stream" i], ytd-player, netflix-player, emby-player, jellyfin-player, video-player');
|
|
for (const host of hosts) {
|
|
if (!host.shadowRoot) continue;
|
|
for (const video of host.shadowRoot.querySelectorAll('video')) {
|
|
if (!seen.has(video)) {
|
|
seen.add(video);
|
|
videos.push({ video, ancestorVisible });
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const frame of doc.querySelectorAll('iframe, frame')) {
|
|
try {
|
|
const frameRect = frame.getBoundingClientRect();
|
|
const frameVisible = ancestorVisible && elementIsVisible(frame, frameRect);
|
|
const frameDoc = frame.contentDocument;
|
|
if (frameDoc) collectVideos(frameDoc, depth + 1, frameVisible, videos, seen);
|
|
} catch {
|
|
// Cross-origin media is inspected in its own execution result.
|
|
}
|
|
}
|
|
return videos;
|
|
};
|
|
|
|
const videoDetails = collectVideos(document).map(({ video, ancestorVisible }) => {
|
|
const rect = video.getBoundingClientRect();
|
|
const rendered = ancestorVisible && elementIsVisible(video, rect);
|
|
const hasSource = !!(video.currentSrc || video.src || video.srcObject
|
|
|| video.querySelector?.('source[src]'));
|
|
const background = !!video.loop && !!video.muted && !video.controls;
|
|
const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 0;
|
|
const shortUncontrolled = !video.controls && duration > 0 && duration < 300;
|
|
const renderedArea = Math.max(0, rect.width) * Math.max(0, rect.height);
|
|
return {
|
|
hasSource,
|
|
rendered,
|
|
background,
|
|
shortUncontrolled,
|
|
sizeBucket: Math.round(Math.sqrt(renderedArea) / 40),
|
|
playing: video.paused === false && video.ended !== true,
|
|
controls: !!video.controls,
|
|
readyState: Number.isInteger(video.readyState) ? video.readyState : 0,
|
|
duration,
|
|
renderedArea
|
|
};
|
|
});
|
|
|
|
const compareVideo = (left, right) => {
|
|
const leftRank = [
|
|
left.hasSource ? 1 : 0,
|
|
left.rendered ? 1 : 0,
|
|
left.background ? 0 : 1,
|
|
left.shortUncontrolled ? 0 : 1,
|
|
left.playing ? 1 : 0,
|
|
left.controls ? 1 : 0,
|
|
left.readyState,
|
|
left.duration,
|
|
left.sizeBucket
|
|
];
|
|
const rightRank = [
|
|
right.hasSource ? 1 : 0,
|
|
right.rendered ? 1 : 0,
|
|
right.background ? 0 : 1,
|
|
right.shortUncontrolled ? 0 : 1,
|
|
right.playing ? 1 : 0,
|
|
right.controls ? 1 : 0,
|
|
right.readyState,
|
|
right.duration,
|
|
right.sizeBucket
|
|
];
|
|
for (let index = 0; index < leftRank.length; index++) {
|
|
if (leftRank[index] !== rightRank[index]) return rightRank[index] - leftRank[index];
|
|
}
|
|
return 0;
|
|
};
|
|
videoDetails.sort(compareVideo);
|
|
|
|
// Recursively list direct and same-origin-descendant frame elements. This
|
|
// lets the background identify a large inaccessible player origin even if
|
|
// an all-frame probe is rejected by the browser's site-access policy.
|
|
const embeddedFrames = [];
|
|
const collectEmbeddedFrames = (doc, depth = 0, ancestorVisible = true) => {
|
|
if (depth >= 4 || typeof doc.querySelectorAll !== 'function') return;
|
|
for (const frame of doc.querySelectorAll('iframe, frame')) {
|
|
const rect = frame.getBoundingClientRect();
|
|
const directVisible = elementIsVisible(frame, rect);
|
|
const visible = ancestorVisible && directVisible;
|
|
let href = '';
|
|
try { href = new URL(frame.src || '', doc.location.href).href; } catch { href = ''; }
|
|
embeddedFrames.push({
|
|
href,
|
|
origin: (() => { try { return new URL(href).origin; } catch { return null; } })(),
|
|
area: Math.max(0, rect.width) * Math.max(0, rect.height),
|
|
width: Math.max(0, rect.width),
|
|
height: Math.max(0, rect.height),
|
|
visible,
|
|
depth: depth + 1,
|
|
mediaHint: frame.allowFullscreen === true
|
|
|| frame.hasAttribute?.('allowfullscreen')
|
|
|| /autoplay|fullscreen|picture-in-picture|encrypted-media/i.test(frame.getAttribute('allow') || '')
|
|
|| /player|video|stream|watch|embed|media|xfp/i.test([
|
|
frame.id,
|
|
frame.name,
|
|
frame.className,
|
|
frame.title,
|
|
href
|
|
].join(' '))
|
|
});
|
|
try {
|
|
const frameDoc = frame.contentDocument;
|
|
if (frameDoc) collectEmbeddedFrames(frameDoc, depth + 1, visible);
|
|
} catch {
|
|
// Cross-origin descendants are represented by their frame URL.
|
|
}
|
|
}
|
|
};
|
|
collectEmbeddedFrames(document);
|
|
|
|
const storedParentVisibility = window.__koalaParentFrameVisibility;
|
|
const parentVisibility = expectedVisibilityToken
|
|
&& storedParentVisibility?.token === expectedVisibilityToken
|
|
? storedParentVisibility
|
|
: null;
|
|
return {
|
|
href: window.location.href,
|
|
origin: window.location.origin,
|
|
isTop: window.top === window,
|
|
videoCount: videoDetails.length,
|
|
bestVideo: videoDetails[0] || null,
|
|
frameArea: Math.max(0, window.innerWidth) * Math.max(0, window.innerHeight),
|
|
parentFrameVisible: window.top === window
|
|
? true
|
|
: parentVisibility?.visible ?? null,
|
|
parentFrameArea: window.top === window
|
|
? Math.max(0, window.innerWidth) * Math.max(0, window.innerHeight)
|
|
: (Number.isFinite(parentVisibility?.area) ? parentVisibility.area : null),
|
|
embeddedFrames
|
|
};
|
|
}
|
|
|
|
/** Runs inside every frame before the visibility dispatch. */
|
|
export function installParentFrameVisibilityProbe(token) {
|
|
try { window.__koalaFrameVisibilityCleanup?.(); } catch { /* stale probe */ }
|
|
window.__koalaParentFrameVisibility = { token, visible: null, area: null };
|
|
let timeout = null;
|
|
const cleanup = () => {
|
|
window.removeEventListener('message', handler);
|
|
if (timeout !== null) clearTimeout(timeout);
|
|
if (window.__koalaFrameVisibilityCleanup === cleanup) {
|
|
delete window.__koalaFrameVisibilityCleanup;
|
|
}
|
|
};
|
|
const handler = (event) => {
|
|
if (event.source !== window.parent
|
|
|| event.data?.type !== 'KOALASYNC_FRAME_VISIBILITY'
|
|
|| event.data?.token !== token) {
|
|
return;
|
|
}
|
|
window.__koalaParentFrameVisibility = {
|
|
token,
|
|
visible: event.data.visible === true,
|
|
area: Number.isFinite(event.data.area) ? event.data.area : 0
|
|
};
|
|
};
|
|
window.addEventListener('message', handler);
|
|
timeout = setTimeout(cleanup, 1000);
|
|
window.__koalaFrameVisibilityCleanup = cleanup;
|
|
}
|
|
|
|
/** Runs inside every frame; each parent reports geometry to its direct children. */
|
|
export function dispatchParentFrameVisibilityProbe(token) {
|
|
const ancestor = window.top === window ? { visible: true, area: Infinity } : window.__koalaParentFrameVisibility;
|
|
for (const frame of document.querySelectorAll('iframe, frame')) {
|
|
try {
|
|
const rect = frame.getBoundingClientRect();
|
|
const style = window.getComputedStyle(frame);
|
|
const area = Math.max(0, rect.width) * Math.max(0, rect.height);
|
|
const layoutWidth = Math.max(window.innerWidth, document.documentElement?.scrollWidth || 0);
|
|
const layoutHeight = Math.max(window.innerHeight, document.documentElement?.scrollHeight || 0);
|
|
const scrollX = Number(window.scrollX) || 0;
|
|
const scrollY = Number(window.scrollY) || 0;
|
|
const intersectsLayout = rect.bottom + scrollY > 0
|
|
&& rect.right + scrollX > 0
|
|
&& rect.top + scrollY < layoutHeight
|
|
&& rect.left + scrollX < layoutWidth;
|
|
const browserReportsVisible = typeof frame.checkVisibility === 'function'
|
|
? frame.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
|
|
: true;
|
|
const directlyVisible = area > 0
|
|
&& intersectsLayout
|
|
&& browserReportsVisible
|
|
&& style.display !== 'none'
|
|
&& style.visibility !== 'hidden'
|
|
&& Number(style.opacity) !== 0;
|
|
const visible = directlyVisible && ancestor?.visible !== false;
|
|
const inheritedArea = Number.isFinite(ancestor?.area) ? ancestor.area : area;
|
|
const effectiveArea = Math.min(area, inheritedArea);
|
|
frame.contentWindow?.postMessage({
|
|
type: 'KOALASYNC_FRAME_VISIBILITY',
|
|
token,
|
|
visible,
|
|
area: effectiveArea
|
|
}, '*');
|
|
} catch {
|
|
// A detached or browser-owned frame is not a candidate.
|
|
}
|
|
}
|
|
}
|
|
|
|
function mediaCandidateRank(entry) {
|
|
const result = entry.result;
|
|
const video = result.bestVideo;
|
|
const visibility = result.parentFrameVisible === true
|
|
? 2
|
|
: result.parentFrameVisible === false
|
|
? 0
|
|
: 1;
|
|
return [
|
|
visibility,
|
|
video.hasSource ? 1 : 0,
|
|
video.rendered ? 1 : 0,
|
|
video.background ? 0 : 1,
|
|
video.shortUncontrolled ? 0 : 1,
|
|
video.playing ? 1 : 0,
|
|
video.controls ? 1 : 0,
|
|
video.readyState,
|
|
video.duration,
|
|
video.sizeBucket,
|
|
result.isTop ? 1 : 0,
|
|
Number.isFinite(result.parentFrameArea) ? result.parentFrameArea : result.frameArea
|
|
];
|
|
}
|
|
|
|
function compareRanks(left, right) {
|
|
const leftRank = mediaCandidateRank(left);
|
|
const rightRank = mediaCandidateRank(right);
|
|
for (let index = 0; index < leftRank.length; index++) {
|
|
if (leftRank[index] !== rightRank[index]) return rightRank[index] - leftRank[index];
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function sameMeaningfulRank(left, right) {
|
|
const leftRank = mediaCandidateRank(left);
|
|
const rightRank = mediaCandidateRank(right);
|
|
// Ignore duration, size, top-frame preference, and raw frame area. Two
|
|
// otherwise identical frames are unsafe to distinguish by preload metadata.
|
|
return leftRank.slice(0, 8).every((value, index) => value === rightRank[index]);
|
|
}
|
|
|
|
export function selectMediaFrame(injectionResults) {
|
|
const candidates = (Array.isArray(injectionResults) ? injectionResults : [])
|
|
.filter(entry => Number.isInteger(entry?.frameId)
|
|
&& entry?.result?.bestVideo?.rendered === true)
|
|
.filter(entry => entry.result.parentFrameVisible !== false)
|
|
.sort(compareRanks);
|
|
if (candidates.length === 0) return null;
|
|
if (candidates.length > 1
|
|
&& candidates[0].result.parentFrameVisible !== true
|
|
&& candidates[1].result.parentFrameVisible !== true
|
|
&& sameMeaningfulRank(candidates[0], candidates[1])) {
|
|
return null;
|
|
}
|
|
return candidates[0];
|
|
}
|
|
|
|
function findMissingPlayerAccess(results) {
|
|
const accessibleOrigins = new Set();
|
|
for (const entry of results) {
|
|
const origin = safeOrigin(entry?.result?.href);
|
|
if (origin) accessibleOrigins.add(origin);
|
|
}
|
|
|
|
const missingByOrigin = new Map();
|
|
for (const entry of results) {
|
|
for (const frame of entry?.result?.embeddedFrames || []) {
|
|
if (!frame.visible || accessibleOrigins.has(frame.origin) || !frame.origin) continue;
|
|
const aspectRatio = frame.height > 0 ? frame.width / frame.height : 0;
|
|
const drivePlayer = isGoogleDrivePlayerUrl(frame.href);
|
|
const looksLikePlayer = frame.mediaHint === true
|
|
&& frame.area >= MIN_PLAYER_FRAME_AREA
|
|
&& aspectRatio >= MIN_PLAYER_ASPECT_RATIO
|
|
&& aspectRatio <= MAX_PLAYER_ASPECT_RATIO;
|
|
if (!drivePlayer && !looksLikePlayer) continue;
|
|
const previous = missingByOrigin.get(frame.origin);
|
|
if (!previous || frame.area > previous.area || drivePlayer) {
|
|
missingByOrigin.set(frame.origin, { ...frame, drivePlayer });
|
|
}
|
|
}
|
|
}
|
|
|
|
const missing = Array.from(missingByOrigin.values()).sort((left, right) => {
|
|
if (left.drivePlayer !== right.drivePlayer) return left.drivePlayer ? -1 : 1;
|
|
return right.area - left.area;
|
|
});
|
|
if (missing.length === 0) return null;
|
|
if (!missing[0].drivePlayer && missing.length > 1 && missing[0].area < missing[1].area * 1.5) {
|
|
return null;
|
|
}
|
|
return {
|
|
host: new URL(missing[0].origin).hostname,
|
|
originPattern: originPattern(missing[0].origin),
|
|
area: missing[0].area,
|
|
drivePlayer: missing[0].drivePlayer === true
|
|
};
|
|
}
|
|
|
|
function shouldPreferMissingAccess(access, selected) {
|
|
if (!access) return false;
|
|
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);
|
|
}
|
|
|
|
function accessRequiredError(access) {
|
|
const error = new Error(`Embedded player access is required for ${access.host}`);
|
|
error.code = MEDIA_FRAME_ACCESS_REQUIRED;
|
|
error.host = access.host;
|
|
error.originPattern = access.originPattern;
|
|
return error;
|
|
}
|
|
|
|
function ambiguousFrameError() {
|
|
const error = new Error('The active embedded video frame could not be identified safely');
|
|
error.code = MEDIA_FRAME_AMBIGUOUS;
|
|
return error;
|
|
}
|
|
|
|
function contentTarget(tabId, selected) {
|
|
const frameId = normalizeFrameId(selected?.frameId);
|
|
const documentId = typeof selected?.documentId === 'string' ? selected.documentId : null;
|
|
return {
|
|
frameId,
|
|
documentId,
|
|
frameUrl: typeof selected?.result?.href === 'string' ? selected.result.href : null,
|
|
hasVideo: !!selected?.result?.bestVideo,
|
|
scriptTarget: documentId
|
|
? { tabId, documentIds: [documentId] }
|
|
: (frameId === 0 ? { tabId } : { tabId, frameIds: [frameId] })
|
|
};
|
|
}
|
|
|
|
export function listMediaFrameScriptTargets(tabId) {
|
|
return [{ tabId, allFrames: true }];
|
|
}
|
|
|
|
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 {
|
|
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 { 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 = 3,
|
|
retryDelayMs = 200,
|
|
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(
|
|
chromeApi,
|
|
scriptTargets,
|
|
inspectMediaFrame,
|
|
[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 {
|
|
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);
|
|
}
|
|
}
|
|
|
|
if (results.length > 1) {
|
|
const token = `${tabId}:${attempt}:${Date.now()}:${Math.random()}`;
|
|
try {
|
|
await executeInAccessibleFrames(
|
|
chromeApi,
|
|
scriptTargets,
|
|
installParentFrameVisibilityProbe,
|
|
[token],
|
|
probeTimeoutMs
|
|
);
|
|
// Four passes match the maximum same-origin recursion depth.
|
|
for (let pass = 0; pass < 4; pass++) {
|
|
await executeInAccessibleFrames(
|
|
chromeApi,
|
|
scriptTargets,
|
|
dispatchParentFrameVisibilityProbe,
|
|
[token],
|
|
probeTimeoutMs
|
|
);
|
|
await new Promise(resolve => setTimeout(resolve, probeDelayMs));
|
|
}
|
|
const { results: inspected } = await executeInAccessibleFrames(
|
|
chromeApi,
|
|
scriptTargets,
|
|
inspectMediaFrame,
|
|
[token],
|
|
probeTimeoutMs
|
|
);
|
|
if (inspected.length > 0) results = inspected;
|
|
} catch {
|
|
// Initial results remain usable, but equally-ranked unknown
|
|
// frames will be rejected below rather than guessed.
|
|
}
|
|
}
|
|
|
|
const selected = selectMediaFrame(results);
|
|
const videoCandidates = results.filter(entry => entry?.result?.bestVideo?.rendered === true
|
|
&& entry.result.parentFrameVisible !== false);
|
|
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;
|
|
if (selected) {
|
|
if (selected.result.bestVideo.hasSource
|
|
&& selected.result.bestVideo.rendered
|
|
&& !shouldPreferMissingAccess(currentMissingAccess, selected)) {
|
|
return contentTarget(tabId, selected);
|
|
}
|
|
}
|
|
|
|
if (attempt < attempts - 1) {
|
|
await new Promise(resolve => setTimeout(resolve, retryDelayMs));
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|