fix(extension): support cross-origin media frames

This commit is contained in:
KoalaDev
2026-08-17 16:49:53 +02:00
parent 77bdf21405
commit 082b69f509
31 changed files with 2858 additions and 263 deletions
+649 -135
View File
File diff suppressed because it is too large Load Diff
+264 -94
View File
@@ -17,6 +17,7 @@
if (window.koalaSyncInjected && chrome.runtime.id) {
return;
}
} catch (_e) {
@@ -40,6 +41,34 @@
lifecycleTimeouts.add(timer);
return timer;
}
function runtimeMessage(message, callback) {
if (destroyed) return Promise.resolve(undefined);
try {
if (!chrome.runtime?.id) {
destroyContentScript();
return Promise.resolve(undefined);
}
return chrome.runtime.sendMessage(message, callback) || Promise.resolve(undefined);
} catch (_e) {
destroyContentScript();
return Promise.resolve(undefined);
}
}
const isEmbeddedContentFrame = (() => {
try { return window.top !== window; } catch (_e) { return true; }
})();
let mediaTargetRefreshTimeout = null;
let mediaTargetRefreshBlockedUntil = 0;
let lastMediaFrameVisible = window.innerWidth > 0 && window.innerHeight > 0;
function requestMediaTargetRefresh(reason) {
if (destroyed || mediaTargetRefreshTimeout || Date.now() < mediaTargetRefreshBlockedUntil) return;
mediaTargetRefreshTimeout = scheduleLifecycleTimeout(() => {
mediaTargetRefreshTimeout = null;
mediaTargetRefreshBlockedUntil = Date.now() + 1500;
runtimeMessage({ type: 'MEDIA_TARGET_REFRESH', reason }).catch(() => {});
}, 750);
}
@@ -225,8 +254,9 @@
if (isDisneyPlusHost()) return null;
const current = video.currentTime;
return Number.isFinite(current) ? current : null;
}
}
function getSyncDuration(video) {
const siteTimeline = getSiteQuirkTimeline(video);
if (siteTimeline) return siteTimeline.duration;
if (isDisneyPlusHost()) return 0;
@@ -599,12 +629,75 @@
// Abort if the reason to snap is gone: user went solo, we're no longer a
// gated guest, or the video vanished.
// gated guest, or the video vanished.
if (hcmDesynced || !hcmIsGuestGated()) { hcmDeferredSnapPending = false; return; }
const video = findVideo();
if (hcmDesynced || !hcmIsGuestGated()) { hcmDeferredSnapPending = false; return; }
const video = findVideo();
const ready = video && video.readyState >= 3 && !video.seeking;
if (ready || Date.now() >= deadline) {
hcmDeferredSnapPending = false;
hcmRequestHostSyncWithRetry(); // fresh host position + snap once
return;
}
scheduleLifecycleTimeout(poll, 300);
};
poll();
}
// Entry point: background told us our local action was blocked in host-only.
function hcmHandleBlocked(action, target) {
// HOST_BLOCKED is only ever sent to a gated guest (background verifies
// host-only + !host before sending), so it's authoritative. Adopt the
// role/mode from it in case our CONTROL_MODE broadcast hasn't landed yet
// (join race, EC-5) — otherwise we'd miss the dialog/snap-back.
hcmControlMode = 'host-only';
hcmAmController = false;
if (hcmDesynced) return; // already solo, nothing to do
const intent = hcmClassifyIntent();
if (intent === 'live') return; // EC-15: leave the guest alone on live
if (intent === 'involuntary') {
// EC-4 loop guard: only the silent auto snap-back is suppressed by the
// cooldown — the deliberate dialog path below must still go through,
// otherwise a second deliberate pause inside the cooldown window leaves
// the user stuck paused with no UI (M-3).
if (Date.now() < hcmSnapBackCooldownUntil || hcmDeferredSnapPending) return;
// Buffering/ads/throttle — silently re-sync, no dialog spam.
const video = findVideo();
if (video && video.readyState >= 3 && !video.seeking) {
// Ready now → snap immediately. Use the captured target if it's
// usable, otherwise re-query+retry (host state may not be known yet)
@@ -612,11 +705,19 @@
// deferred and "Stay in sync" paths).
hcmRequestHostSyncWithRetry(); // fresh host position + snap once
return;
}
if (target && Number.isFinite(target.targetTime)) hcmSnapBackToHost(target);
else hcmRequestHostSyncWithRetry();
} else {
hcmDeferredSnapBack(); // buffering → wait for ready, then snap once (#3)
}
return;
}
// Deliberate: offer the choice (Teleparty-style), default = snap back.
@@ -631,22 +732,32 @@
// Built with the DOM API (CSSOM .style is CSP-safe; inline style="" in innerHTML
// is stripped by strict style-src on Netflix/YouTube/Disney+). Hosted in a
// HOST_BLOCKED is only ever sent to a gated guest (background verifies
// host-only + !host before sending), so it's authoritative. Adopt the
// Shadow DOM so the page's CSS can't restyle or hide our controls.
let hcmDialogHost = null; // shadow host element for the dialog
let hcmBadgeHost = null; // shadow host element for the persistent badge
let hcmBadgePending = false; // retry flag for early-injection badge creation (L-4)
function hcmEl(tag, css, text) {
const el = document.createElement(tag);
if (css) el.style.cssText = css; // CSSOM assignment — not gated by CSP
hcmControlMode = 'host-only';
hcmAmController = false;
if (hcmDesynced) return; // already solo, nothing to do
if (text != null) el.textContent = text;
return el;
}
function hcmRemoveDialog() {
// Cancel any pending auto-stay timer so a replaced dialog's stale closure
@@ -658,11 +769,11 @@
}
function hcmShowDesyncDialog(action, target) {
if (!document.body) { hcmSnapBackToHost(target); return; }
hcmRemoveDialog();
@@ -1307,7 +1418,10 @@
limiter.threshold.value = 0;
limiter.knee.value = 0;
limiter.ratio.value = 20;
limiter.attack.value = 0;
limiter.release.value = 0.1;
const chain = { compressor, dryGain, compGain, outputGain, limiter, active: false, signature: '' };
audioChains.set(videoEl, chain);
currentAudioVideo = videoEl;
@@ -1447,10 +1561,10 @@
if (ep) return `EP${String(ep[1]).padStart(3, '0')}`;
return null;
}
}
function sameEpisode(titleA, titleB) {
@@ -1477,12 +1591,14 @@
// Returns true only when we are CERTAIN the episodes differ.
// Permissive: only blocks if BOTH titles have parseable IDs AND they differ.
}
// Films, music, unparseable titles always pass through.
function isDifferentEpisode(titleA, titleB) {
if (!titleA || !titleB) return false; // Unknown → allow
const idA = extractEpisodeId(titleA);
const idB = extractEpisodeId(titleB);
@@ -1529,12 +1645,14 @@
function onEpisodeTransition(newTitle) {
const current = video ? getSyncCurrentTime(video) : null;
// Only trigger if: we had a previous title, the title changed,
// Debounce: prevent duplicate fires from multiple signals
if (episodeTransitionDebounce) return;
if (lastKnownMediaTitle && currentTitle
episodeTransitionDebounce = setTimeout(() => {
episodeTransitionDebounce = null;
}, 2000);
@@ -1589,7 +1707,7 @@
_setSuppress('paused');
video.pause();
// and sends back PAUSE_FOR_LOBBY so we only freeze if the feature is on.
}
stopLobbyPoll();
@@ -1600,7 +1718,12 @@
payload: { title: currentTitle }
}).catch(() => {});
function checkAndReportLobbyReady(expectedTitle) {
reportLog(`Episode lobby: Ready for "${currentTitle}"`, 'success');
return true;
}
return false;
@@ -1612,17 +1735,28 @@
stopLobbyPoll();
expectedSeekTime = 0;
video.currentTime = 0;
_pendingLobbyTitle = expectedTitle;
// NOTE: Do NOT pause here. Three callers reach this function:
// 1. PAUSE_FOR_LOBBY (initiator): already paused by that handler before calling us.
// 2. EPISODE_LOBBY (non-initiator): peer may still be on the PREVIOUS episode — pausing
// would freeze them mid-episode. The pause happens inside checkAndReportLobbyReady()
// only once their title actually matches.
// 3. CONTENT_BOOT recovery: same reasoning as (2).
// Check immediately
video.pause();
if (checkAndReportLobbyReady(expectedTitle)) return;
// Poll every 2 seconds — no log spam, internal only
@@ -1653,11 +1787,11 @@
}
// NOTE: Do NOT pause here. Three callers reach this function:
// 1. PAUSE_FOR_LOBBY (initiator): already paused by that handler before calling us.
function getPlayerActionFixes() {
return [
{
name: 'youtube-player-buttons',
urls: ['youtube.com'],
playPauseButtonSelector: '.ytp-play-button'
@@ -1672,25 +1806,48 @@
function getActivePlayerActionFix() {
return getPlayerActionFixes().find(fix => matchesPlayerUrls(fix.urls)) || null;
// Poll every 2 seconds — no log spam, internal only
}
function tryPlayerActionFix(fix, action, video, data) {
if (!fix) return false;
const button = document.querySelector(fix.playPauseButtonSelector);
if (!button) return false;
const isCurrentlyPlaying = !video.paused;
if ((action === EVENTS.PLAY && !isCurrentlyPlaying) || (action === EVENTS.PAUSE && isCurrentlyPlaying)) {
_setSuppress(action === EVENTS.PLAY ? 'playing' : 'paused');
button.click();
}
if (action === EVENTS.SEEK) {
seekVideo(video, data.targetTime, data.delta);
}
return true;
}
// --- Helper: site-specific player actions, then native HTML5 fallback ---
function tryMediaAction(action, data) {
const video = findVideo();
if (!video) return;
if (action === EVENTS.SEEK) {
const target = data ? (data.targetTime !== undefined ? data.targetTime : data.currentTime) : undefined;
if (!Number.isFinite(target)) {
reportLog(`Media Action Error: Invalid seek payload - ${JSON.stringify(data)}`, 'error');
return;
}
data = { ...data, targetTime: target };
}
function stopLobbyPoll() {
_pendingLobbyTitle = null;
}
try {
const actionFix = getActivePlayerActionFix();
if (tryPlayerActionFix(actionFix, action, video, data)) {
return;
@@ -1717,16 +1874,24 @@
} else if (action === EVENTS.SEEK) {
seekVideo(video, data.targetTime, data.delta);
}
function getActivePlayerActionFix() {
return getPlayerActionFixes().find(fix => matchesPlayerUrls(fix.urls)) || null;
}
function tryPlayerActionFix(fix, action, video, data) {
if (!fix) return false;
const button = document.querySelector(fix.playPauseButtonSelector);
if (!button) return false;
}
} catch (e) {
reportLog(`Media Action Error: ${e.message}`, 'error');
}
}
// --- Helper: Wait until video is ready for playback (buffered & seeked) ---
function pollSeekReady(targetTime, timeoutMs = 8000) {
return new Promise((resolve) => {
const interval = 150;
let elapsed = 0;
const timer = setInterval(() => {
if (destroyed) {
clearInterval(timer);
@@ -1750,7 +1915,12 @@
const timeDiff = current !== null ? Math.abs(current - targetTime) : Infinity;
const ready = video.readyState >= 3 && timeDiff < 2.0;
if (ready) {
clearInterval(timer);
seekPollTimers.delete(timer);
resolve(true);
} else if (elapsed >= timeoutMs) {
clearInterval(timer);
seekPollTimers.delete(timer);
resolve(false);
}
}, interval);
@@ -1775,7 +1945,12 @@
return true;
}
reportLog(`Playback prevented: ${e.message}`, 'warn');
if (message.action === 'APPLY_AUDIO_SETTINGS') {
_audioProcessingAllowed = true;
_audioSettings = mergeAudioSettings(message.settings);
const video = findVideo();
@@ -1790,7 +1965,12 @@
if (message.action === 'RESET_AUDIO_PROCESSING') {
_audioProcessingAllowed = false;
bypassCurrentAudioProcessing();
sendResponse({ ok: true });
return true;
@@ -1851,8 +2031,9 @@
if (message.type === 'REQUEST_HEARTBEAT') {
sendHeartbeat();
return true;
}
sendResponse({ ok: true });
return true;
}
@@ -1889,13 +2070,11 @@
}
return;
}
}
}
if (message.type === 'CONTROL_MODE') {
// Guard: Don't execute sync commands if peers are on different episodes.
@@ -1909,25 +2088,16 @@
const syncActions = [EVENTS.PLAY, EVENTS.PAUSE, EVENTS.SEEK,
EVENTS.FORCE_SYNC_PREPARE, EVENTS.FORCE_SYNC_EXECUTE];
// host identity changes (room switch, host-leave fallback, missed
// teardown broadcast) — clears stale desync so a rejoin starts clean (H-3).
const hostChanged = prevHostPeerId !== null && hcmHostPeerId !== prevHostPeerId;
if ((wasGated && !hcmIsGuestGated()) || hostChanged) hcmReset();
sendResponse({ ok: true });
return true;
if (_autoSyncEnabled && syncActions.includes(action)) {
const senderTitle = payload?.mediaTitle;
const myTitle = getMediaTitle();
if (isDifferentEpisode(senderTitle, myTitle)) {
reportLog(`Episode mismatch: sender="${senderTitle || '?'}" vs mine="${myTitle || '?'}" — skipping ${action}. Disable "Auto-Sync next Episode" in settings if this causes issues.`, 'warn');
if (action !== EVENTS.FORCE_SYNC_PREPARE && action !== EVENTS.FORCE_SYNC_EXECUTE) {
+2 -1
View File
@@ -11,7 +11,8 @@
"scripting",
"alarms",
"activeTab",
"notifications"
"notifications",
"webNavigation"
],
"host_permissions": [
"<all_urls>"
+196
View File
@@ -0,0 +1,196 @@
/**
* Lightweight per-frame sentinel. It does not control media; it only tells the
* background that the selected tab's candidate set or frame layout changed.
*/
(function installKoalaMediaFrameMonitor() {
try { window.__koalaMediaFrameMonitorCleanup?.(); } catch { /* stale monitor */ }
let destroyed = false;
let notifyTimer = null;
const hookedFrames = new Set();
let lastCandidateSignature = null;
function geometryBucket(value) {
return Math.round(value / 8);
}
function elementStylesAllowRendering(element) {
let current = element;
while (current) {
try {
const style = window.getComputedStyle(current);
if (style.display === 'none'
|| style.visibility === 'hidden'
|| Number(style.opacity) === 0) {
return false;
}
} catch { /* detached or browser-owned node */ }
const parent = current.parentElement;
if (parent) {
current = parent;
continue;
}
try { current = current.getRootNode?.().host || null; } catch { current = null; }
}
return true;
}
function candidateSignature() {
const parts = [];
for (const element of document.querySelectorAll('video, iframe, frame')) {
try {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
const browserReportsVisible = typeof element.checkVisibility === 'function'
? element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
: true;
const visible = rect.width > 0
&& rect.height > 0
&& rect.bottom > 0
&& rect.right > 0
&& rect.top < window.innerHeight
&& rect.left < window.innerWidth
&& browserReportsVisible
&& elementStylesAllowRendering(element)
&& style.display !== 'none'
&& style.visibility !== 'hidden'
&& Number(style.opacity) !== 0;
const source = element.tagName === 'VIDEO'
? (element.currentSrc || element.src || element.querySelector?.('source[src]')?.src || '')
: (element.src || '');
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
].join(',')
: '';
parts.push([
element.tagName,
source,
visible ? 1 : 0,
geometryBucket(rect.left),
geometryBucket(rect.top),
geometryBucket(rect.width),
geometryBucket(rect.height),
mediaState
].join(':'));
} catch {
parts.push('detached');
}
}
return parts.join('|');
}
function send(reason) {
if (destroyed) return;
try {
chrome.runtime.sendMessage({ type: 'MEDIA_FRAME_CANDIDATE_CHANGED', reason }).catch(() => {});
} catch {
cleanup();
}
}
function schedule(reason, { force = false } = {}) {
if (destroyed || notifyTimer !== null) return;
notifyTimer = setTimeout(() => {
notifyTimer = null;
const nextSignature = candidateSignature();
if (!force && nextSignature === lastCandidateSignature) return;
lastCandidateSignature = nextSignature;
send(reason);
}, 0);
}
function containsAddedMediaNode(node) {
return node?.nodeType === 1
&& (node.matches?.('video, iframe, frame') || node.querySelector?.('video, iframe, frame'));
}
function attributeAffectsCandidate(node) {
return node?.nodeType === 1
&& (node.matches?.('video, iframe, frame') || node.querySelector?.('video, iframe, frame'));
}
function hookFrames() {
for (const frame of hookedFrames) {
if (frame.isConnected) continue;
frame.removeEventListener('load', handleFrameLoad);
hookedFrames.delete(frame);
}
for (const frame of document.querySelectorAll('iframe, frame')) {
if (hookedFrames.has(frame)) continue;
hookedFrames.add(frame);
frame.addEventListener('load', handleFrameLoad);
}
}
function handleFrameLoad() {
hookFrames();
schedule('frame_load', { force: true });
}
const observer = new MutationObserver((mutations) => {
let relevant = false;
for (const mutation of mutations) {
if (mutation.type === 'attributes') {
if (attributeAffectsCandidate(mutation.target)) relevant = true;
} else if ([...mutation.addedNodes, ...mutation.removedNodes].some(containsAddedMediaNode)) {
relevant = true;
}
if (relevant) break;
}
if (!relevant) return;
hookFrames();
schedule('media_dom_changed');
});
function handlePageHide() { send('frame_pagehide'); }
function handlePageShow() { schedule('frame_pageshow', { force: true }); }
function handleResize() { schedule('frame_resize'); }
function handleMediaState(event) {
if (event.target?.tagName === 'VIDEO') schedule(`media_${event.type}`);
}
function handleMessage(message) {
if (message?.type === 'MEDIA_MONITOR_DEACTIVATE') cleanup();
}
function cleanup() {
if (destroyed) return;
destroyed = true;
if (notifyTimer !== null) clearTimeout(notifyTimer);
notifyTimer = null;
observer.disconnect();
for (const frame of hookedFrames) frame.removeEventListener('load', handleFrameLoad);
hookedFrames.clear();
window.removeEventListener('pagehide', handlePageHide);
window.removeEventListener('pageshow', handlePageShow);
window.removeEventListener('resize', handleResize);
for (const type of MEDIA_STATE_EVENTS) {
document.removeEventListener(type, handleMediaState, true);
}
try { chrome.runtime.onMessage.removeListener(handleMessage); } catch { /* invalidated */ }
if (window.__koalaMediaFrameMonitorCleanup === cleanup) {
delete window.__koalaMediaFrameMonitorCleanup;
}
}
observer.observe(document.documentElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class', 'style', 'hidden', 'src', 'controls']
});
hookFrames();
lastCandidateSignature = candidateSignature();
window.addEventListener('pagehide', handlePageHide);
window.addEventListener('pageshow', handlePageShow);
window.addEventListener('resize', handleResize, { passive: true });
const MEDIA_STATE_EVENTS = ['play', 'pause', 'loadedmetadata', 'loadeddata', 'canplay', 'durationchange', 'emptied'];
for (const type of MEDIA_STATE_EVENTS) {
document.addEventListener(type, handleMediaState, true);
}
chrome.runtime.onMessage.addListener(handleMessage);
window.__koalaMediaFrameMonitorCleanup = cleanup;
})();
+536
View File
@@ -0,0 +1,536 @@
export const MEDIA_FRAME_ACCESS_REQUIRED = 'media_frame_access_required';
export const MEDIA_FRAME_AMBIGUOUS = 'media_frame_ambiguous';
const MIN_PLAYER_FRAME_AREA = 320 * 180;
const MIN_PLAYER_ASPECT_RATIO = 1.15;
const MAX_PLAYER_ASPECT_RATIO = 2.6;
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;
}
return rect.bottom > 0
&& rect.right > 0
&& rect.top < view.innerHeight
&& rect.left < view.innerWidth;
};
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 intersectsViewport = rect.bottom > 0
&& rect.right > 0
&& rect.top < window.innerHeight
&& rect.left < window.innerWidth;
const browserReportsVisible = typeof frame.checkVisibility === 'function'
? frame.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
: true;
const directlyVisible = area > 0
&& intersectsViewport
&& 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 (access.drivePlayer || !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;
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 async function listMediaFrameScriptTargets(chromeApi, tabId) {
if (chromeApi.webNavigation?.getAllFrames) {
try {
const frames = await chromeApi.webNavigation.getAllFrames({ tabId });
if (Array.isArray(frames) && frames.length > 0) {
return frames
.filter(frame => Number.isInteger(frame?.frameId))
.map(frame => (typeof frame.documentId === 'string' && frame.documentId
? { tabId, documentIds: [frame.documentId] }
: { tabId, frameIds: [frame.frameId] }));
}
} catch {
// Older browsers fall back to the all-frames probe below.
}
}
return [{ tabId, allFrames: true }];
}
async function executeInAccessibleFrames(chromeApi, targets, func, args) {
const settled = await Promise.all(targets.map(async target => {
try {
return await chromeApi.scripting.executeScript({ target, func, args });
} catch {
return [];
}
}));
return settled.flat();
}
export async function resolveMediaContentTarget(chromeApi, tabId, {
attempts = 8,
retryDelayMs = 200,
probeDelayMs = 60
} = {}) {
let fallback = null;
let missingAccess = null;
let ambiguous = false;
for (let attempt = 0; attempt < attempts; attempt++) {
const scriptTargets = await listMediaFrameScriptTargets(chromeApi, tabId);
let results = await executeInAccessibleFrames(
chromeApi,
scriptTargets,
inspectMediaFrame,
[null]
);
if (results.length === 0) {
try {
results = await chromeApi.scripting.executeScript({
target: { tabId },
func: inspectMediaFrame,
args: [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]
);
// Four passes match the maximum same-origin recursion depth.
for (let pass = 0; pass < 4; pass++) {
await executeInAccessibleFrames(
chromeApi,
scriptTargets,
dispatchParentFrameVisibilityProbe,
[token]
);
await new Promise(resolve => setTimeout(resolve, probeDelayMs));
}
const inspected = await executeInAccessibleFrames(
chromeApi,
scriptTargets,
inspectMediaFrame,
[token]
);
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);
const currentMissingAccess = findMissingPlayerAccess(results);
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);
if (ambiguous) throw ambiguousFrameError();
return contentTarget(tabId, null);
}
+483
View File
@@ -0,0 +1,483 @@
import { describe, expect, it, vi } from 'vitest';
import {
MEDIA_FRAME_ACCESS_REQUIRED,
MEDIA_FRAME_AMBIGUOUS,
inspectMediaFrame,
resolveMediaContentTarget,
selectMediaFrame
} from './media-frame-target.js';
function video(overrides = {}) {
const candidate = {
hasSource: true,
rendered: true,
background: false,
shortUncontrolled: false,
sizeBucket: 18,
playing: false,
controls: true,
readyState: 4,
duration: 1200,
renderedArea: 830 * 498,
...overrides
};
candidate.shortUncontrolled = overrides.shortUncontrolled ?? (
!candidate.controls && candidate.duration > 0 && candidate.duration < 300
);
return candidate;
}
function frame(frameId, overrides = {}) {
return {
frameId,
documentId: `document-${frameId}`,
result: {
href: `https://player-${frameId}.example/embed`,
origin: `https://player-${frameId}.example`,
isTop: frameId === 0,
videoCount: 1,
bestVideo: video(),
frameArea: 830 * 498,
parentFrameVisible: true,
parentFrameArea: 830 * 498,
embeddedFrames: [],
...overrides
}
};
}
describe('cross-origin media-frame targeting', () => {
it('selects a visible cross-origin player over a hidden loaded copy', () => {
const selected = selectMediaFrame([
frame(4),
frame(5, { parentFrameVisible: false, bestVideo: video({ playing: true }) })
]);
expect(selected.frameId).toBe(4);
});
it('does not select a video hidden inside a same-origin descendant', () => {
expect(selectMediaFrame([
frame(0, { bestVideo: video({ rendered: false }) }),
frame(6, {
parentFrameVisible: false,
bestVideo: video({ rendered: true, playing: true })
})
])).toBeNull();
});
it('keeps a real player ahead of a larger muted looping background video', () => {
const selected = selectMediaFrame([
frame(2, { bestVideo: video({ sizeBucket: 24, background: true, controls: false }) }),
frame(7, { bestVideo: video({ sizeBucket: 18 }) })
]);
expect(selected.frameId).toBe(7);
});
it('keeps an active long player ahead of a larger ordinary ad video', () => {
const selected = selectMediaFrame([
frame(2, { bestVideo: video({ sizeBucket: 24, duration: 30, playing: false, controls: false }) }),
frame(7, { bestVideo: video({ sizeBucket: 18, duration: 1200, playing: true, controls: true }) })
]);
expect(selected.frameId).toBe(7);
});
it('keeps a paused long player ahead of a playing short uncontrolled ad', () => {
const selected = selectMediaFrame([
frame(2, { bestVideo: video({
sizeBucket: 24,
duration: 30,
playing: true,
controls: false,
shortUncontrolled: true
}) }),
frame(7, { bestVideo: video({
sizeBucket: 18,
duration: 1200,
playing: false,
controls: true
}) })
]);
expect(selected.frameId).toBe(7);
});
it('keeps same-origin reachable media under the top-frame controller', () => {
const sharedVideo = video();
const selected = selectMediaFrame([
frame(0, { bestVideo: sharedVideo, videoCount: 1 }),
frame(6, { bestVideo: sharedVideo, videoCount: 1 })
]);
expect(selected.frameId).toBe(0);
});
it('refuses to guess between equally-ranked frames without visibility evidence', () => {
expect(selectMediaFrame([
frame(3, { parentFrameVisible: null }),
frame(4, { parentFrameVisible: null })
])).toBeNull();
});
it('returns the exact selected frame and document after probing', async () => {
const results = [frame(0, { bestVideo: null, videoCount: 0 }), frame(8)];
const executeScript = vi.fn().mockResolvedValue(results);
await expect(resolveMediaContentTarget(
{ scripting: { executeScript } },
42,
{ attempts: 1, probeDelayMs: 0 }
)).resolves.toEqual({
frameId: 8,
documentId: 'document-8',
frameUrl: 'https://player-8.example/embed',
hasVideo: true,
scriptTarget: { tabId: 42, documentIds: ['document-8'] }
});
const visibilityDispatches = executeScript.mock.calls.filter(([options]) => (
options.func?.name === 'dispatchParentFrameVisibilityProbe'
));
expect(visibilityDispatches).toHaveLength(4);
});
it('keeps the top target inactive when the only discovered video is hidden', async () => {
const results = [
frame(0, { bestVideo: video({ rendered: false }) }),
frame(6, { parentFrameVisible: false })
];
const executeScript = vi.fn().mockResolvedValue(results);
await expect(resolveMediaContentTarget(
{ scripting: { executeScript } },
42,
{ attempts: 1, probeDelayMs: 0 }
)).resolves.toEqual({
frameId: 0,
documentId: null,
frameUrl: null,
hasVideo: false,
scriptTarget: { tabId: 42 }
});
});
it('keeps an accessible player when an unrelated child frame is denied', async () => {
const top = frame(0, {
bestVideo: null,
videoCount: 0,
embeddedFrames: [
{
href: 'https://player-8.example/embed',
origin: 'https://player-8.example',
area: 830 * 498,
width: 830,
height: 498,
visible: true,
mediaHint: true
},
{
href: 'https://widget-denied.example/frame',
origin: 'https://widget-denied.example',
area: 300 * 250,
width: 300,
height: 250,
visible: true,
mediaHint: false
}
]
});
const player = frame(8);
const getAllFrames = vi.fn().mockResolvedValue([
{ frameId: 0, documentId: 'document-0' },
{ frameId: 8, documentId: 'document-8' },
{ frameId: 9, documentId: 'document-9' }
]);
const executeScript = vi.fn().mockImplementation(async ({ target, func }) => {
const documentId = target.documentIds?.[0];
if (documentId === 'document-9') throw new Error('Cannot access contents of the page');
if (func?.name !== 'inspectMediaFrame') return [];
return documentId === 'document-8' ? [player] : [top];
});
await expect(resolveMediaContentTarget(
{ scripting: { executeScript }, webNavigation: { getAllFrames } },
42,
{ attempts: 1, probeDelayMs: 0 }
)).resolves.toMatchObject({
frameId: 8,
documentId: 'document-8',
hasVideo: true
});
expect(executeScript.mock.calls.some(([options]) => options.target.allFrames === true)).toBe(false);
expect(executeScript.mock.calls.some(([options]) => options.target.documentIds?.[0] === 'document-9')).toBe(true);
});
it('does not trust parent visibility from an older probe token', () => {
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const fakeWindow = {
top: {},
location: { href: 'https://player.example/embed', origin: 'https://player.example' },
innerWidth: 800,
innerHeight: 450,
__koalaParentFrameVisibility: { token: 'old-token', visible: true, area: 360000 }
};
const fakeDocument = {
location: fakeWindow.location,
defaultView: fakeWindow,
querySelectorAll: () => []
};
globalThis.window = fakeWindow;
globalThis.document = fakeDocument;
try {
expect(inspectMediaFrame('new-token')).toMatchObject({
parentFrameVisible: null,
parentFrameArea: null
});
} finally {
if (originalWindow === undefined) delete globalThis.window;
else globalThis.window = originalWindow;
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
}
});
it('recognizes the current Google Drive youtube.googleapis.com player', async () => {
const top = frame(0, {
href: 'https://drive.google.com/drive/u/0/search?q=video',
origin: 'https://drive.google.com',
bestVideo: null,
videoCount: 0,
embeddedFrames: [{
href: 'https://youtube.googleapis.com/embed/drive-file-id?origin=https%3A%2F%2Fdrive.google.com',
origin: 'https://youtube.googleapis.com',
area: 280 * 157,
width: 280,
height: 157,
visible: true,
depth: 1,
mediaHint: false
}]
});
const executeScript = vi.fn()
.mockResolvedValueOnce([top])
.mockResolvedValueOnce([top]);
await expect(resolveMediaContentTarget(
{ scripting: { executeScript } },
42,
{ attempts: 1, probeDelayMs: 0 }
)).rejects.toMatchObject({
code: MEDIA_FRAME_ACCESS_REQUIRED,
host: 'youtube.googleapis.com',
originPattern: 'https://youtube.googleapis.com/*'
});
});
it('recognizes a YummyAnime-style nested inaccessible player origin', async () => {
const top = frame(0, {
href: 'https://yummyanime.tv/show.html',
origin: 'https://yummyanime.tv',
bestVideo: null,
videoCount: 0,
embeddedFrames: [{
href: 'https://absciss.thealloha.club/?token=redacted',
origin: 'https://absciss.thealloha.club',
area: 830 * 498,
width: 830,
height: 498,
visible: true,
depth: 2,
mediaHint: true
}]
});
const executeScript = vi.fn()
.mockResolvedValueOnce([top])
.mockResolvedValueOnce([top]);
await expect(resolveMediaContentTarget(
{ scripting: { executeScript } },
43,
{ attempts: 1, probeDelayMs: 0 }
)).rejects.toMatchObject({
code: MEDIA_FRAME_ACCESS_REQUIRED,
host: 'absciss.thealloha.club',
originPattern: 'https://absciss.thealloha.club/*'
});
});
it('requests the inaccessible player instead of selecting an accessible background video', async () => {
const top = frame(0, {
bestVideo: video({ background: true, controls: false, renderedArea: 900 * 506 }),
embeddedFrames: [{
href: 'https://player.external.example/watch',
origin: 'https://player.external.example',
area: 900 * 506,
width: 900,
height: 506,
visible: true,
mediaHint: true
}]
});
const executeScript = vi.fn().mockResolvedValue([top]);
await expect(resolveMediaContentTarget(
{ scripting: { executeScript } },
44,
{ attempts: 1, probeDelayMs: 0 }
)).rejects.toMatchObject({
code: MEDIA_FRAME_ACCESS_REQUIRED,
originPattern: 'https://player.external.example/*'
});
});
it('requests the inaccessible main player instead of selecting a larger short ad', async () => {
const top = frame(0, {
bestVideo: video({
playing: true,
controls: false,
duration: 30,
renderedArea: 500 * 281,
sizeBucket: 24
}),
embeddedFrames: [{
href: 'https://player.external.example/watch',
origin: 'https://player.external.example',
area: 900 * 506,
width: 900,
height: 506,
visible: true,
mediaHint: true
}]
});
const executeScript = vi.fn().mockResolvedValue([top]);
await expect(resolveMediaContentTarget(
{ scripting: { executeScript } },
44,
{ attempts: 1, probeDelayMs: 0 }
)).rejects.toMatchObject({
code: MEDIA_FRAME_ACCESS_REQUIRED,
originPattern: 'https://player.external.example/*'
});
});
it('keeps a paused long custom player over a larger inaccessible heuristic frame', async () => {
const top = frame(0, {
bestVideo: video({
playing: false,
controls: false,
duration: 7200,
renderedArea: 600 * 338
}),
embeddedFrames: [{
href: 'https://widget.external.example/watch',
origin: 'https://widget.external.example',
area: 900 * 506,
width: 900,
height: 506,
visible: true,
mediaHint: true
}]
});
const executeScript = vi.fn().mockResolvedValue([top]);
await expect(resolveMediaContentTarget(
{ scripting: { executeScript } },
44,
{ attempts: 1, probeDelayMs: 0 }
)).resolves.toMatchObject({ frameId: 0, hasVideo: true });
});
it('uses Firefox-compatible portless match patterns for embedded origins', async () => {
const top = frame(0, {
bestVideo: null,
videoCount: 0,
embeddedFrames: [{
href: 'http://127.0.0.1:4173/player',
origin: 'http://127.0.0.1:4173',
area: 900 * 506,
width: 900,
height: 506,
visible: true,
mediaHint: true
}]
});
const executeScript = vi.fn().mockResolvedValue([top]);
await expect(resolveMediaContentTarget(
{ scripting: { executeScript } },
45,
{ attempts: 1, probeDelayMs: 0 }
)).rejects.toMatchObject({ originPattern: 'http://127.0.0.1/*' });
});
it('does not request access for one large non-media iframe', async () => {
const top = frame(0, {
bestVideo: null,
videoCount: 0,
embeddedFrames: [{
href: 'https://maps.example/view',
origin: 'https://maps.example',
area: 900 * 506,
width: 900,
height: 506,
visible: true,
mediaHint: false
}]
});
const executeScript = vi.fn().mockResolvedValue([top]);
await expect(resolveMediaContentTarget(
{ scripting: { executeScript } },
46,
{ attempts: 1, probeDelayMs: 0 }
)).resolves.toMatchObject({ frameId: 0, hasVideo: false });
});
it('does not retain a permission prompt for a player frame that disappeared', async () => {
const withPlayer = frame(0, {
bestVideo: null,
videoCount: 0,
embeddedFrames: [{
href: 'https://player.external.example/watch',
origin: 'https://player.external.example',
area: 900 * 506,
width: 900,
height: 506,
visible: true,
mediaHint: true
}]
});
const withoutPlayer = frame(0, { bestVideo: null, videoCount: 0, embeddedFrames: [] });
const executeScript = vi.fn()
.mockResolvedValueOnce([withPlayer])
.mockResolvedValueOnce([withoutPlayer]);
await expect(resolveMediaContentTarget(
{ scripting: { executeScript } },
46,
{ attempts: 2, retryDelayMs: 0, probeDelayMs: 0 }
)).resolves.toMatchObject({ frameId: 0, hasVideo: false });
});
it('does not request access for small or ambiguously-sized embedded frames', async () => {
const top = frame(0, {
bestVideo: null,
videoCount: 0,
embeddedFrames: [
{ href: 'https://ad-one.example', origin: 'https://ad-one.example', area: 300 * 250, width: 300, height: 250, visible: true },
{ href: 'https://ad-two.example', origin: 'https://ad-two.example', area: 300 * 250, width: 300, height: 250, visible: true }
]
});
const executeScript = vi.fn()
.mockResolvedValueOnce([top])
.mockResolvedValueOnce([top]);
await expect(resolveMediaContentTarget(
{ scripting: { executeScript } },
44,
{ attempts: 1, probeDelayMs: 0 }
)).resolves.toMatchObject({ frameId: 0, scriptTarget: { tabId: 44 } });
});
it('reports ambiguity rather than controlling an arbitrary equal player', async () => {
const results = [
frame(3, { parentFrameVisible: null }),
frame(4, { parentFrameVisible: null })
];
const executeScript = vi.fn().mockResolvedValue(results);
await expect(resolveMediaContentTarget(
{ scripting: { executeScript } },
45,
{ attempts: 1, probeDelayMs: 0 }
)).rejects.toMatchObject({ code: MEDIA_FRAME_AMBIGUOUS });
});
});
+3 -2
View File
@@ -1,7 +1,8 @@
export function initTabManager({
getCurrentTabId,
reactivateCurrentTarget,
ensureState
ensureState,
sendToCurrentContent
}) {
chrome.storage.onChanged.addListener(async (changes, area) => {
if (area !== 'local' || !changes.audioSettings) return;
@@ -9,7 +10,7 @@ export function initTabManager({
const tabId = getCurrentTabId();
if (!tabId) return;
chrome.tabs.sendMessage(tabId, {
sendToCurrentContent({
action: 'APPLY_AUDIO_SETTINGS',
settings: changes.audioSettings.newValue
}).catch(() => {});
+2 -2
View File
@@ -2196,7 +2196,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
failForceSyncTime();
return;
}
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (retryResponse) => {
chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId }, (retryResponse) => {
if (chrome.runtime.lastError || !retryResponse || !Number.isFinite(retryResponse.currentTime)) {
failForceSyncTime();
return;
@@ -2204,7 +2204,7 @@ elements.forceSyncBtn.addEventListener('click', async () => {
sendForceSync(retryResponse.currentTime);
});
};
chrome.tabs.sendMessage(tabId, { action: 'get_current_time' }, (response) => {
chrome.runtime.sendMessage({ type: 'GET_VIDEO_STATE', tabId }, (response) => {
if (Number.isFinite(response?.currentTime)) {
sendForceSync(response.currentTime);
return;
+36 -4
View File
@@ -7,12 +7,15 @@ const extensionDir = path.dirname(fileURLToPath(import.meta.url));
const backgroundSource = fs.readFileSync(path.join(extensionDir, 'background.js'), 'utf8');
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 manifest = JSON.parse(fs.readFileSync(path.join(extensionDir, 'manifest.base.json'), 'utf8'));
describe('target tab lifecycle', () => {
it('injects playback and chat scripts only into the explicitly selected tab', () => {
expect(backgroundSource).not.toContain('chrome.tabs.onActivated');
expect(backgroundSource).not.toContain('chrome.tabs.query({})');
expect(backgroundSource).toContain("target: { tabId }");
expect(backgroundSource).toContain('contentTarget = await resolveMediaContentTarget(chrome, tabId)');
expect(backgroundSource).toContain('target: scriptTarget');
expect(backgroundSource).toContain("files: ['chat-format.js', 'chat-overlay.js', 'content.js']");
expect(backgroundSource).toContain("chrome.tabs.query({ url: 'https://sync.koalastuff.net/*' })");
@@ -27,20 +30,49 @@ describe('target tab lifecycle', () => {
});
it('fully deactivates old and superseded target injections', () => {
expect(backgroundSource).toContain("chrome.tabs.sendMessage(normalizedTabId, { type: 'TARGET_DEACTIVATE' })");
expect(backgroundSource.match(/await deactivateTargetTab\(selectedTabId\)/g)?.length).toBeGreaterThanOrEqual(4);
expect(backgroundSource).toContain("{ type: 'TARGET_DEACTIVATE' }");
expect(backgroundSource).toContain('target.documentId');
expect(backgroundSource.match(/await deactivateTargetTab\(selectedTabId,/g)?.length).toBeGreaterThanOrEqual(6);
expect(contentSource).toContain("if (message.type === 'TARGET_DEACTIVATE')");
expect(overlaySource).toContain("message?.type === 'TARGET_DEACTIVATE'");
});
it('binds cross-origin targets to an exact document and monitors every accessible frame', () => {
expect(backgroundSource).toContain("files: ['media-frame-monitor.js']");
expect(backgroundSource).toContain('const targets = await listMediaFrameScriptTargets(chrome, 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(monitorSource).toContain("type: 'MEDIA_FRAME_CANDIDATE_CHANGED'");
expect(monitorSource).toContain("attributeFilter: ['class', 'style', 'hidden', 'src', 'controls']");
expect(monitorSource).toContain('if (!force && nextSignature === lastCandidateSignature) return');
expect(monitorSource).toContain("const MEDIA_STATE_EVENTS = ['play', 'pause', 'loadedmetadata'");
expect(monitorSource).toContain("node.querySelector?.('video, iframe, frame')");
expect(manifest.permissions).toContain('webNavigation');
expect(backgroundSource).toContain('chrome.webNavigation.onCompleted.addListener');
});
it('serializes content commands and coalesces target refreshes', () => {
expect(backgroundSource).toContain('contentCommandQueue.catch(() => {}).then(deliver)');
expect(backgroundSource).toContain('if (mediaTargetRefreshTask && mediaTargetRefreshTabId === selectedTabId)');
expect(backgroundSource).toContain('if (queueIfRunning) mediaTargetRefreshDirty = true');
expect(backgroundSource).toContain('&& pass < 2');
expect(backgroundSource).toContain('const needsFollowup = mediaTargetRefreshDirty');
expect(backgroundSource).not.toContain('Re-elect before every remote command');
expect(backgroundSource).toContain('await sendMessageToContentTab(tabId');
});
it('tears down every persistent content-script resource', () => {
expect(contentSource).toContain('function destroyContentScript()');
expect(contentSource).toContain('observer.disconnect()');
expect(contentSource).toContain('keepAlivePort.disconnect()');
expect(contentSource).toContain('for (const video of attachedVideos)');
expect(contentSource).toContain('for (const video of [...attachedVideos]) detachVideoListeners(video);');
expect(contentSource).toContain("document.removeEventListener('visibilitychange', handleVisibilityChange)");
expect(contentSource).toContain("window.removeEventListener('pagehide', handlePageHide)");
expect(contentSource).toContain("window.removeEventListener('pageshow', handlePageShow)");
expect(contentSource).toContain("window.removeEventListener('resize', handleMediaFrameResize)");
expect(contentSource).toContain('chrome.storage.onChanged.removeListener(handleStorageChanged)');
expect(contentSource).toContain('chrome.runtime.onMessage.removeListener(handleRuntimeMessage)');
expect(contentSource).toContain('window.koalaSyncInjected = false');