From b5db85a4fa0c990c959d4d8073c10258a264e5a0 Mon Sep 17 00:00:00 2001 From: Nawid Salehie <102778966+Nawid3333@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:45:09 +0200 Subject: [PATCH 01/22] fix(background): keep a request's headers across the page's reset (#142) The background kept each request's headers on its frame until the response came, and the frame's reset threw them away: the page naming itself (FRAME_ADDED) or the tab going to another site. A request sent before that and answered after - a preload, 103 Early Hints - was detected without its Referer/Origin, and a CDN that checks them gave the player a 403. The headers are now kept on the tab, by request id (unique in the session), and go when the response or the error comes; at most 500 per tab. Forgetting them on a request's error no longer makes a holder again for a tab already closed (part of #235). Tests: tests/unit/backgroundHarness.mjs runs background.mjs in Node against a stand-in for the WebExtension API, so its listeners can be driven in order. backgroundDetection.test.mjs fails on the old code (headers undefined after FRAME_ADDED and after the new-site reset); TabTracker.test.mjs covers the map itself (the closed-tab case fails with getTabOrCreate back in). Co-Authored-By: Claude Opus 5.5 --- chrome/background/TabTracker.mjs | 36 ++- chrome/background/background.mjs | 12 +- tests/unit/TabTracker.test.mjs | 38 +++ tests/unit/backgroundDetection.test.mjs | 58 ++++ tests/unit/backgroundHarness.mjs | 398 ++++++++++++++++++++++++ 5 files changed, 533 insertions(+), 9 deletions(-) create mode 100644 tests/unit/backgroundDetection.test.mjs create mode 100644 tests/unit/backgroundHarness.mjs diff --git a/chrome/background/TabTracker.mjs b/chrome/background/TabTracker.mjs index e193a436..bb923d41 100644 --- a/chrome/background/TabTracker.mjs +++ b/chrome/background/TabTracker.mjs @@ -21,7 +21,6 @@ export class FrameHolder { this.isPlayer = false; this.trackedSubtitles = []; this.trackedSources = []; - this.requestHeaders = new Map(); this.url = ''; /** * The name content.js gave the page it shows (FRAME_ADDED); null when unknown. @@ -95,6 +94,10 @@ export class FrameHolder { // How many gone pages a tab remembers (TabHolder.goneDocuments). Navigating a tab would // otherwise grow it without end; the oldest go first. const GoneDocumentLimit = 16; +// How many requests' headers a tab keeps while their responses are awaited +// (TabHolder.requestHeaders). Each goes when its response or its error comes; this only +// bounds those that never get either. +const RequestHeaderLimit = 500; /** * @param {string} a - A URL, or anything else. @@ -119,6 +122,13 @@ export class TabHolder { // (restoreGoneDocument), and to refuse a player still starting that names one // (isPlayerOfGoneDocument). Not cleared by reset(), see there. this.goneDocuments = new Map(); + // The headers of the tab's requests still waiting for a response, by request id + // (rememberRequestHeaders). The tab's, not a frame's, and not cleared by reset(): a + // request can be sent before the page that made it named itself (a preload, 103 + // Early Hints) or before the reset on a new site, and answered after, and a stream + // found without the Referer its CDN checks got a 403 in the player. + /** @type {Map} */ + this.requestHeaders = new Map(); this.isOn = false; this.isMpv = false; @@ -213,6 +223,19 @@ export class TabHolder { this.playerCount = players; } + /** + * Keeps a request's headers until its response or its error comes (forgetRequestHeaders). + * @param {string} requestId - webRequest's id, unique in the session. + * @param {*} headers - Its requestHeaders. + */ + rememberRequestHeaders(requestId, headers) { + this.requestHeaders.delete(requestId); + this.requestHeaders.set(requestId, headers); + while (this.requestHeaders.size > RequestHeaderLimit) { + this.requestHeaders.delete(this.requestHeaders.keys().next().value); + } + } + getFrames() { return this.frames.values(); } @@ -484,6 +507,17 @@ export class TabTracker { chrome.storage.session.remove(TabStateKeyPrefix + tabId).catch(() => {}); } + /** + * Forgets a request's headers once its response or its error came. A tab this tracker + * does not know (closed, its requests cancelled after tabs.onRemoved) is not made again + * for it: Firefox never reuses a tab id, so it would stay for the background's life. + * @param {number} tabId - The request's tab. + * @param {string} requestId - Its id. + */ + forgetRequestHeaders(tabId, requestId) { + this.getTab(tabId)?.requestHeaders.delete(requestId); + } + getFrame(tabId, frameId) { const tab = this.getTab(tabId); return tab && tab.getFrame(frameId); diff --git a/chrome/background/background.mjs b/chrome/background/background.mjs index 10d6ef63..2b22aee1 100644 --- a/chrome/background/background.mjs +++ b/chrome/background/background.mjs @@ -1826,7 +1826,7 @@ async function onSourceRecieved(details, frame, mode) { // a second onHeadersReceived listener, so it runs the moment this function // yields at its first await; reading them after that always returns // undefined and the Referer/Origin the CDN needs is lost. - const customHeaders = details.customHeaders || frame.requestHeaders.get(details.requestId); + const customHeaders = details.customHeaders || frame.tab.requestHeaders.get(details.requestId); await ensureOptions(); @@ -2380,9 +2380,7 @@ chrome.webRequest.onBeforeRequest.addListener((details) => { }); chrome.webRequest.onBeforeSendHeaders.addListener((details) => { - const tab = Tabs.getTabOrCreate(details.tabId); - const frame = tab.getFrameOrCreate(details.frameId); - frame.requestHeaders.set(details.requestId, details.requestHeaders); + Tabs.getTabOrCreate(details.tabId).rememberRequestHeaders(details.requestId, details.requestHeaders); }, { urls: [''], }, webRequestPerms); @@ -2404,7 +2402,7 @@ chrome.webRequest.onHeadersReceived.addListener( const ext = urlType(url); if (BackgroundUtils.isSubtitles(ext)) { - handleSubtitles(url, frame, frame.requestHeaders.get(details.requestId)); + handleSubtitles(url, frame, tab.requestHeaders.get(details.requestId)); return; } @@ -2454,9 +2452,7 @@ chrome.webRequest.onErrorOccurred.addListener(deleteHeaderCache, { * @return {undefined} */ function deleteHeaderCache(details) { - const tab = Tabs.getTabOrCreate(details.tabId); - const frame = tab.getFrameOrCreate(details.frameId); - frame.requestHeaders.delete(details.requestId); + Tabs.forgetRequestHeaders(details.tabId, details.requestId); } ensureOptions(); diff --git a/tests/unit/TabTracker.test.mjs b/tests/unit/TabTracker.test.mjs index 49e04bc4..f9c36ef8 100644 --- a/tests/unit/TabTracker.test.mjs +++ b/tests/unit/TabTracker.test.mjs @@ -427,3 +427,41 @@ describe('restoredFromCache', () => { expect(main.restoredFromCache).toBe(false); }); }); + +// A request's headers wait for its response by request id, which is unique in the session: +// on the tab, so the page's reset (FRAME_ADDED, a new site) does not lose those of a request +// still in flight (backgroundDetection.test.mjs drives that through the background). +describe('request headers', () => { + it('outlive the tab\'s resets', () => { + const tab = new TabTracker().getTabOrCreate(7); + tab.rememberRequestHeaders('r1', [{name: 'Referer', value: 'https://a.test/'}]); + tab.getFrameOrCreate(0).resetSelfAndChildren(); + tab.resetForReload(); + tab.resetForNewSite('https://b.test/'); + expect(tab.requestHeaders.get('r1')).toEqual([{name: 'Referer', value: 'https://a.test/'}]); + }); + + it('go when their response comes', () => { + const tracker = new TabTracker(); + tracker.getTabOrCreate(7).rememberRequestHeaders('r1', []); + tracker.forgetRequestHeaders(7, 'r1'); + expect(tracker.getTab(7).requestHeaders.size).toBe(0); + }); + + it('do not bring back a closed tab', () => { + // A closing tab's requests are cancelled, and the errors can come after tabs.onRemoved. + const tracker = new TabTracker(); + tracker.forgetRequestHeaders(7, 'r1'); + expect(tracker.getTab(7)).toBeUndefined(); + }); + + it('are kept for the latest 500 requests at most', () => { + const tab = new TabTracker().getTabOrCreate(7); + for (let i = 0; i < 520; i++) { + tab.rememberRequestHeaders('r' + i, []); + } + expect(tab.requestHeaders.size).toBe(500); + expect(tab.requestHeaders.has('r19')).toBe(false); + expect(tab.requestHeaders.has('r20')).toBe(true); + }); +}); diff --git a/tests/unit/backgroundDetection.test.mjs b/tests/unit/backgroundDetection.test.mjs new file mode 100644 index 00000000..35ea40de --- /dev/null +++ b/tests/unit/backgroundDetection.test.mjs @@ -0,0 +1,58 @@ +import {afterEach, describe, expect, it} from 'vitest'; + +import {loadBackground} from './backgroundHarness.mjs'; + +// What the background detects from a page's requests (onBeforeSendHeaders, then +// onHeadersReceived), and what it keeps of it, driven through its real listeners. + +const PAGE = 'https://site.test/watch/1'; +const PLAYER = 'moz-extension://bg-test/player/index.html'; + +let bg; + +afterEach(() => { + bg?.unload(); + bg = null; +}); + +/** + * A player that opened in frame 5, inside the page's frame 0, asks for the page's streams. + * @return {Promise>} The sources the background sent it. + */ +async function sourcesForPlayer() { + await bg.message({type: 'PLAYER_LOADED', url: PLAYER, parentFrameId: 0}, {tabId: 1, frameId: 5}); + bg.sentToTabs.length = 0; + await bg.message({type: 'REQUEST_SOURCES'}, {tabId: 1, frameId: 5}); + await bg.wait(3000); + const sent = bg.sent('SOURCES').filter((m) => m.frameId === 5); + expect(sent).toHaveLength(1); + return sent[0].message.sources; +} + +describe('request headers', () => { + it('keeps those of a request the page sent before it named itself', async () => { + // A preload (Link: rel=preload, 103 Early Hints) goes out before the content script + // runs, and its response can come after the page's FRAME_ADDED. The headers the CDN + // checks (Referer) went with the page's reset, and the player's requests got a 403. + bg = await loadBackground({tabs: [{id: 1, url: PAGE}]}); + const manifest = bg.requestSent({tabId: 1, url: 'https://cdn.test/v/master.m3u8'}); + await bg.frameAdded(1, 0, PAGE, 'page-1'); + await bg.responded(manifest); + + const sources = await sourcesForPlayer(); + expect(sources.map((s) => s.url)).toEqual(['https://cdn.test/v/master.m3u8']); + expect(sources[0].headers).toEqual([{name: 'Referer', value: 'https://site.test/'}]); + }); + + it('keeps them across the reset when the tab goes to another site', async () => { + bg = await loadBackground({tabs: [{id: 1, url: 'https://old.test/'}]}); + await bg.navigated(1, 'https://old.test/'); + const manifest = bg.requestSent({tabId: 1, url: 'https://cdn.test/v/master.m3u8'}); + await bg.navigated(1, PAGE); + await bg.frameAdded(1, 0, PAGE, 'page-1'); + await bg.responded(manifest); + + const sources = await sourcesForPlayer(); + expect(sources[0].headers).toEqual([{name: 'Referer', value: 'https://site.test/'}]); + }); +}); diff --git a/tests/unit/backgroundHarness.mjs b/tests/unit/backgroundHarness.mjs new file mode 100644 index 00000000..cd364848 --- /dev/null +++ b/tests/unit/backgroundHarness.mjs @@ -0,0 +1,398 @@ +import {vi} from 'vitest'; + +// Runs chrome/background/background.mjs in Node against a stand-in for the WebExtension +// API, for what its listeners do together: a page's request, the page naming itself, a +// toolbar click, a play. The pure helpers have their own tests; these cover the order of +// events between them, which no single function shows. Firefox itself is the e2e suites'. +// +// Each load is a fresh background (vi.resetModules), with fake timers: a test moves time +// itself (bg.wait), and nothing left over from a load fires in the next test. + +/** + * An event a listener can be added to. + * @return {{addListener: function(Function): void, listeners: Array}} + */ +function event() { + const listeners = []; + return { + addListener: (fn) => listeners.push(fn), + removeListener: (fn) => { + const i = listeners.indexOf(fn); + if (i !== -1) listeners.splice(i, 1); + }, + hasListener: (fn) => listeners.includes(fn), + listeners, + }; +} + +/** + * A response for the background's own reads of a stream's length (StreamLengths), which + * reads a body through arrayBuffer() when it has no stream. + * @param {string|Uint8Array} body - What the server sends. + * @param {number} [status] - Its status. + * @return {Object} A stand-in for fetch()'s Response. + */ +export function response(body, status = 200) { + const bytes = typeof body === 'string' ? new TextEncoder().encode(body) : body; + return { + ok: status >= 200 && status < 300, + status, + url: '', + body: null, + arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + }; +} + +/** + * An HLS media playlist that plays for the given seconds, in 10 s segments. + * @param {number} seconds - Its length. + * @return {string} The playlist. + */ +export function hlsPlaylist(seconds) { + const lines = ['#EXTM3U', '#EXT-X-TARGETDURATION:10', '#EXT-X-VERSION:3']; + for (let left = seconds; left > 0; left -= 10) { + lines.push(`#EXTINF:${Math.min(10, left)}.0,`, `seg-${left}.ts`); + } + lines.push('#EXT-X-ENDLIST'); + return lines.join('\n'); +} + +let requestCounter = 0; + +/** + * Loads a fresh background. + * @param {Object} [setup] + * @param {Object} [setup.options] - The saved options (over the defaults). + * @param {Array} [setup.tabs] - What tabs.query answers: {id, url, incognito, ...}. + * @param {Object} [setup.session] - storage.session as the last background left it. + * @param {function(string): (Object|Promise)} [setup.fetch] - The network, for + * the background's own reads; a 404 for everything by default. + * @param {function({tabId: number, frameId: (number|undefined), message: Object}): *} [setup.onTabMessage] - + * A page's answer to a message the background sends it (tabs.sendMessage). + * @param {function(Object): *} [setup.onNative] - The mpv host's answer; {ok: true} by default. + * @param {function(): void} [setup.beforeImport] - Runs once the stand-in is in place, + * before the background loads: to make an API fail from the start. + * @return {Promise} The background's handle. + */ +export async function loadBackground({ + options = {}, + tabs = [], + session = {}, + fetch = async () => response('', 404), + onTabMessage = () => undefined, + onNative = () => ({ok: true}), + beforeImport = () => {}, +} = {}) { + vi.useFakeTimers(); + vi.resetModules(); + + const bg = { + options: {...options}, + tabs, + session, + sentToTabs: [], + native: [], + badges: new Map(), + titles: new Map(), + dynamicRules: [], + sessionRuleUpdates: [], + reloaded: [], + removedTabs: [], + createdTabs: [], + }; + + const onMessage = event(); + const webRequest = { + onBeforeRequest: event(), + onBeforeSendHeaders: event(), + onHeadersReceived: event(), + onErrorOccurred: event(), + }; + const tabEvents = { + onRemoved: event(), + onCreated: event(), + onUpdated: event(), + }; + + /** + * Answers a callback after the caller's own code has run, as the browser does. + * @param {Function|undefined} callback - The callback, if any. + * @param {*} value - What it gets, or a promise of it. + * @return {Promise<*>} The value. + */ + const answer = (callback, value) => Promise.resolve(value).then((resolved) => { + if (typeof callback === 'function') callback(resolved); + return resolved; + }); + + const chrome = { + runtime: { + id: 'faststream@test', + lastError: undefined, + getURL: (file) => 'moz-extension://bg-test/' + file, + getManifest: () => ({version: '1.0.0'}), + onInstalled: event(), + onMessage, + sendNativeMessage: (name, message, callback) => { + bg.native.push(message); + answer(callback, onNative(message)); + }, + }, + extension: {inIncognitoContext: false}, + management: {getSelf: async () => ({installType: 'normal'})}, + i18n: {getMessage: () => ''}, + permissions: {contains: (query, callback) => answer(callback, true)}, + storage: { + local: { + get: (key, callback) => answer(callback, {[key]: key === 'options' ? JSON.stringify(bg.options) : undefined}), + set: (items, callback) => answer(callback, undefined), + }, + session: { + get: async () => structuredClone(session), + set: async (items) => { + Object.assign(session, structuredClone(items)); + }, + remove: async (key) => { + delete session[key]; + }, + }, + }, + action: { + onClicked: event(), + setBadgeText: ({text, tabId}) => bg.badges.set(tabId, text), + setTitle: ({title, tabId}) => bg.titles.set(tabId, title), + setIcon: () => {}, + }, + commands: {onCommand: event(), getAll: async () => []}, + tabs: { + TAB_ID_NONE: -1, + ...tabEvents, + query: (query, callback) => answer(callback, bg.tabs.map((t) => ({...t}))), + get: async (tabId) => ({...(bg.tabs.find((t) => t.id === tabId) || {id: tabId})}), + sendMessage: (tabId, message, ...rest) => { + const callback = typeof rest[rest.length - 1] === 'function' ? rest.pop() : undefined; + const frameId = rest[0] && typeof rest[0].frameId === 'number' ? rest[0].frameId : undefined; + bg.sentToTabs.push({tabId, frameId, message}); + return answer(callback, onTabMessage({tabId, frameId, message})); + }, + reload: (tabId) => { + bg.reloaded.push(tabId); + }, + update: (tabId, props, callback) => answer(callback, {id: tabId, ...props}), + create: (props, callback) => { + const tab = {id: 1000 + bg.createdTabs.length, ...props}; + bg.createdTabs.push(tab); + return answer(callback, tab); + }, + remove: async (tabId) => { + bg.removedTabs.push(tabId); + }, + }, + webRequest, + declarativeNetRequest: { + updateDynamicRules: async (update) => { + bg.dynamicRules.push(update); + }, + updateSessionRules: async (update) => { + bg.sessionRuleUpdates.push(update); + }, + getSessionRules: async () => [], + }, + downloads: {download: async () => 1}, + }; + + globalThis.chrome = chrome; + bg.chrome = chrome; + const realFetch = globalThis.fetch; + globalThis.fetch = async (url, init) => fetch(String(url), init); + beforeImport(chrome); + + const log = console.log; + console.log = () => {}; + try { + await import('../../chrome/background/background.mjs'); + await settle(); + } finally { + console.log = log; + } + + /** + * Lets the background's pending work run: promises, and timers due by then. + * @param {number} [ms] - How far to move the clock. + * @return {Promise} + */ + async function settle(ms = 0) { + await vi.advanceTimersByTimeAsync(ms); + for (let i = 0; i < 50; i++) { + await Promise.resolve(); + } + await vi.advanceTimersByTimeAsync(0); + } + bg.wait = settle; + + /** + * Sends the background a message from a page. + * @param {Object} message - The message. + * @param {{tabId?: number, frameId?: number, url?: string, tab?: Object}} [from] - Its sender. + * @return {Promise<*>} The background's answer, once given (undefined for none). + */ + bg.message = async (message, {tabId = 1, frameId = 0, url, tab} = {}) => { + const sender = { + tab: tab || {id: tabId, url: (bg.tabs.find((t) => t.id === tabId) || {}).url}, + frameId, + url, + }; + let reply; + const answered = new Promise((resolve) => { + reply = resolve; + }); + let async = false; + for (const listener of onMessage.listeners) { + if (listener(message, sender, reply) === true) { + async = true; + } + } + if (!async) { + reply(undefined); + } + const result = await answered; + await settle(); + return result; + }; + + /** + * A page names itself in a frame, as content.js does as it starts (FRAME_ADDED). + * @param {number} tabId - The tab. + * @param {number} frameId - The frame. + * @param {string} url - The page. + * @param {string} document - Its name. + * @return {Promise<*>} + */ + bg.frameAdded = (tabId, frameId, url, document) => + bg.message({type: 'FRAME_ADDED', url, document}, {tabId, frameId}); + + /** + * A request a page sends, up to the moment its headers are sent. + * @param {Object} details - webRequest's details; requestId is made up when missing. + * @return {Object} The details, for the response to the same request. + */ + bg.requestSent = (details) => { + const full = { + requestId: String(++requestCounter), + frameId: 0, + parentFrameId: -1, + type: 'xmlhttprequest', + requestHeaders: [{name: 'Referer', value: 'https://site.test/'}], + ...details, + }; + for (const listener of webRequest.onBeforeRequest.listeners) listener(full); + for (const listener of webRequest.onBeforeSendHeaders.listeners) listener(full); + return full; + }; + + /** + * The response to a request: its headers arrive. + * @param {Object} details - The request's details (bg.requestSent). + * @param {Object} [extra] - statusCode, responseHeaders. + * @return {Promise} + */ + bg.responded = async (details, extra = {}) => { + const full = {statusCode: 200, responseHeaders: [], ...details, ...extra}; + for (const listener of webRequest.onHeadersReceived.listeners) listener(full); + await settle(); + }; + + /** + * A request that fails, or is cancelled, before its response. + * @param {Object} details - The request's details (bg.requestSent). + * @return {Promise} + */ + bg.failed = async (details) => { + for (const listener of webRequest.onErrorOccurred.listeners) listener({...details, error: 'NS_BINDING_ABORTED'}); + await settle(); + }; + + /** + * A whole request: sent, then answered. + * @param {Object} details - webRequest's details. + * @param {Object} [extra] - statusCode, responseHeaders. + * @return {Promise} The details. + */ + bg.request = async (details, extra) => { + const sent = bg.requestSent(details); + await bg.responded(sent, extra); + return sent; + }; + + /** + * The tab's address changes (tabs.onUpdated with a url). + * @param {number} tabId - The tab. + * @param {string} url - Its new address. + * @return {Promise} + */ + bg.navigated = async (tabId, url) => { + const tab = bg.tabs.find((t) => t.id === tabId); + if (tab) tab.url = url; + for (const listener of tabEvents.onUpdated.listeners) listener(tabId, {url}, {id: tabId, url}); + await settle(); + }; + + /** + * The tab is closed. + * @param {number} tabId - The tab. + * @return {Promise} + */ + bg.closed = async (tabId) => { + bg.tabs = bg.tabs.filter((t) => t.id !== tabId); + for (const listener of tabEvents.onRemoved.listeners) listener(tabId, {}); + await settle(); + }; + + /** + * The toolbar button is clicked in a tab. + * @param {number} tabId - The tab. + * @return {Promise} + */ + bg.click = async (tabId) => { + const tab = bg.tabs.find((t) => t.id === tabId) || {id: tabId}; + for (const listener of chrome.action.onClicked.listeners) listener({...tab}); + await settle(); + }; + + /** + * A keyboard shortcut is pressed in a tab (commands.onCommand). + * @param {string} command - toggle_player or toggle_mpv. + * @param {number} tabId - The tab. + * @return {Promise} + */ + bg.command = async (command, tabId) => { + const tab = bg.tabs.find((t) => t.id === tabId) || {id: tabId}; + for (const listener of chrome.commands.onCommand.listeners) listener(command, {...tab}); + await settle(); + }; + + /** + * The messages the background sent to tabs, of one type. + * @param {string} type - The message type. + * @return {Array<{tabId: number, frameId: (number|undefined), message: Object}>} + */ + bg.sent = (type) => bg.sentToTabs.filter((m) => m.message.type === type); + + /** + * The URLs the background handed to mpv. + * @return {Array} + */ + bg.toMpv = () => bg.native.filter((m) => m.type === 'open').map((m) => m.url); + + /** + * Puts the real timers and network back. + */ + bg.unload = () => { + vi.clearAllTimers(); + vi.useRealTimers(); + globalThis.fetch = realFetch; + delete globalThis.chrome; + }; + + return bg; +} From 18a8964d0ade29b4201b735dc2db3b6a3fb429a5 Mon Sep 17 00:00:00 2001 From: Nawid Salehie <102778966+Nawid3333@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:47:56 +0200 Subject: [PATCH 02/22] fix(security): take a player's parent frame only from the page that opened it The player page is web-accessible, so any page, or an ad's iframe in it, can frame moz-extension:///player/index.html?parent_frame_id=0. The player's own load is a moz-extension request that webRequest never sees, so the background adopted that frame id on trust: the top frame then counted as holding a player, every stream the page requested was dropped, and no player opened on it until the page navigated. A made-up id (parseInt of junk) became a NaN frame of the tab. PLAYER_LOADED now adopts the named parent only when the player's opener (the page name content.js puts in each player URL, known only to its content script) is that frame's page or the player frame's own (TabHolder.playerParentProof). When the background restarted and lost the page's name, it asks the frame's content script (new IS_PLAYER_OPENER message). An unproven player is answered null and forgotten, like a player of a gone page. The rest of the handler moved unchanged into acceptPlayer. Proven by tests/unit/TabTracker.test.mjs "playerParentProof" (fails with the opener check or the refusal removed) and tests/unit/contentScript.test.mjs "IS_PLAYER_OPENER" (fails when content.js does not answer for its page), on a new stand-in DOM for content.js (tests/unit/contentDom.mjs). mpv-shortcut's new /framed e2e case frames the player page the way the issue describes. Issue #225. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 10 + chrome/background/TabTracker.mjs | 26 + chrome/background/background.mjs | 179 ++++--- chrome/content.js | 6 + chrome/player/enums/MessageTypes.mjs | 1 + tests/e2e/classic-specs/mpv-shortcut.e2e.mjs | 43 +- tests/unit/TabTracker.test.mjs | 62 +++ tests/unit/contentDom.mjs | 480 +++++++++++++++++++ tests/unit/contentScript.test.mjs | 34 ++ types/messages.d.ts | 18 +- 10 files changed, 788 insertions(+), 71 deletions(-) create mode 100644 tests/unit/contentDom.mjs create mode 100644 tests/unit/contentScript.test.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 63931934..e777ecb6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -676,6 +676,16 @@ forgotten. A frame whose page never named itself to this background (it restarte since) proves nothing and the player counts. mpv-shortcut's `/late` page pins the state the race left. +**A player a page frames itself (2026-10-03, #225).** `player/index.html` is +web-accessible, so a page, or an ad's iframe in it, can frame it with any +`parent_frame_id`. The player's own load is a moz-extension request that webRequest never +sees, so the background took that id on trust: the named frame (the top one) counted as +holding a player, with the effect above. Now `PLAYER_LOADED` adopts the named parent only +when the opener is that frame's page or the player frame's own +(`TabHolder.playerParentProof`); when the background does not know the frame's name (it +restarted since), it asks the frame's content script (`IS_PLAYER_OPENER`). Otherwise the +player is answered null and forgotten. mpv-shortcut's `/framed` page pins it. + **Gone pages, and a page Back brings back (2026-09-30).** `TabHolder.goneDocuments` keeps the 16 latest pages that left a tab, by name, with what each had detected. A page leaves when its `FRAME_REMOVED` is taken, when another page names itself in its frame diff --git a/chrome/background/TabTracker.mjs b/chrome/background/TabTracker.mjs index e193a436..8d5243e0 100644 --- a/chrome/background/TabTracker.mjs +++ b/chrome/background/TabTracker.mjs @@ -260,6 +260,32 @@ export class TabHolder { return pages.length > 0 && !pages.some((frame) => frame.documentKey === opener); } + /** + * Whether a player may be taken for a child of the frame it names (PLAYER_LOADED's + * parentFrameId, from the player's own URL). The player page is web-accessible, so any + * page can frame it with any parent_frame_id: one naming the top frame made the top + * frame count as holding a player (FrameHolder.hasPlayer), and the background dropped + * every stream of the page and opened no player there until it navigated. content.js + * names its page in each player's URL (opener), a name only its content script knows, + * so that name must be the named frame's page, or the player frame's own (a page that + * went to the player). + * @param {FrameHolder} playerFrame - The player's frame. + * @param {*} parentFrameId - The frame it names. + * @param {?string} opener - The page its URL names. + * @return {'proven'|'refused'|'ask'} 'ask' when the named frame's page never named + * itself to this background (it started again since): its content script can tell. + */ + playerParentProof(playerFrame, parentFrameId, opener) { + if (!opener || !Number.isInteger(parentFrameId) || parentFrameId < 0) { + return 'refused'; + } + const parent = this.getFrame(parentFrameId); + if ([playerFrame, parent].some((frame) => frame && frame.documentKey === opener)) { + return 'proven'; + } + return parent && parent.documentKey ? 'refused' : 'ask'; + } + /** * Marks the page a frame shows gone, with the pages in the frames inside it, and keeps * what each had detected under its name. A frame whose page never named itself has diff --git a/chrome/background/background.mjs b/chrome/background/background.mjs index 10d6ef63..c665809d 100644 --- a/chrome/background/background.mjs +++ b/chrome/background/background.mjs @@ -677,7 +677,8 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { const frame = tab.getFrameOrCreate(sender.frameId); if (msg.type === MessageTypes.PLAYER_LOADED) { - if (tab.isPlayerOfGoneDocument(frame, msg.parentFrameId, playerOpener(msg.url || sender.url))) { + const opener = playerOpener(msg.url || sender.url); + if (tab.isPlayerOfGoneDocument(frame, msg.parentFrameId, opener)) { // Its page reloaded while it started (TabHolder.isPlayerOfGoneDocument): it goes // with that page, and is no player of the one there now. if (Logging) console.log('Ignoring a player whose page is gone', frame); @@ -685,65 +686,27 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { sendResponse(null); return; } - if (Logging) console.log('Found FastStream window', frame); - frame.isPlayer = true; - - if (tab.downloadInfo) { - chrome.tabs.sendMessage(frame.tab.tabId, { - type: MessageTypes.HANDLE_DOWNLOAD, - url: tab.downloadInfo.url, - filename: tab.downloadInfo.filename, - }, { - frameId: frame.frameId, - }, (response) => { - BackgroundUtils.checkMessageError('download'); - tab.downloadInfo.resolve(response); - tab.downloadInfo = null; - - // Close tab - chrome.tabs.remove(frame.tab.tabId); - }); - sendResponse(null); - return; - } - if (!frame.parent && msg.parentFrameId !== undefined) { - const parentFrame = tab.getFrameOrCreate(msg.parentFrameId); - frame.setParentFrame(parentFrame); - } - - if (frame.playerOpening) { - frame.playerOpening = false; - } else if (frame.parent) { - frame.parent.playerOpening = false; - } - tab.playerCount++; - const isMainPlayer = tab.playerCount === 1; - - getPageFrame(frame).then((pageFrame) => { - if (pageFrame) { - frame.pageFrame = pageFrame; - } else { - frame.pageFrame = tab.getFrameOrCreate(0); - } - - const response = { - mediaInfo: getMediaInfoFromTab(sender?.tab), - analyzerData: tab.analyzerData, - isMainPlayer, - }; - - sendResponse(response); - }).catch((e) => { - // The player waits for this answer before it starts; the top frame stands in. - console.error('Finding the page frame of a player failed', e); - frame.pageFrame = tab.getFrameOrCreate(0); - sendResponse({ - mediaInfo: getMediaInfoFromTab(sender?.tab), - analyzerData: tab.analyzerData, - isMainPlayer, + // The player names the frame it is in (TabHolder.playerParentProof): taken on trust, + // a page framing the player page could make any frame of the tab hold a player. + isPlayerParentProven(tab, frame, msg.parentFrameId, opener).then((proven) => { + if (!proven) { + if (Logging) console.log('Ignoring a player whose page did not open it', frame); + tab.forgetFrame(frame); + sendResponse(null); + return; + } + frame.setParentFrame(tab.getFrameOrCreate(msg.parentFrameId)); + acceptPlayer(tab, frame, sender, sendResponse); + }).catch((e) => { + // The player waits for an answer; without a proof it is no page's player. + console.error('Checking the page of a player failed', e); + tab.forgetFrame(frame); + sendResponse(null); }); - }); + return true; + } + acceptPlayer(tab, frame, sender, sendResponse); return true; } else if (msg.type === MessageTypes.FRAME_ADDED) { // Preserve sources key to this frame's new URL @@ -1763,6 +1726,106 @@ function playerOpener(url) { } } +/** + * Whether the frame a player names (PLAYER_LOADED's parentFrameId) holds the page that + * opened it (TabHolder.playerParentProof). When this background does not know that page's + * name (it started again since the page loaded), the frame's content script is asked. + * @param {TabHolder} tab - The tab. + * @param {FrameHolder} frame - The player's frame. + * @param {*} parentFrameId - The frame it names. + * @param {?string} opener - The page its URL names. + * @return {Promise} + */ +async function isPlayerParentProven(tab, frame, parentFrameId, opener) { + const proof = tab.playerParentProof(frame, parentFrameId, opener); + if (proof !== 'ask') { + return proof === 'proven'; + } + const answer = await new Promise((resolve) => { + chrome.tabs.sendMessage(tab.tabId, { + type: MessageTypes.IS_PLAYER_OPENER, + document: opener, + }, { + frameId: parentFrameId, + }, (response) => { + BackgroundUtils.checkMessageError('is_player_opener'); + resolve(response); + }); + }); + if (answer !== true) { + return false; + } + const parent = tab.getFrameOrCreate(parentFrameId); + if (!parent.documentKey) { + parent.documentKey = opener; + } + return true; +} + +/** + * Takes a player that said it loaded (PLAYER_LOADED) for one, and answers it. + * @param {Object} tab - TabHolder of the player. + * @param {Object} frame - FrameHolder of the player, its parent set when it has one. + * @param {chrome.runtime.MessageSender} sender - The message's sender. + * @param {Function} sendResponse - The answer. + */ +function acceptPlayer(tab, frame, sender, sendResponse) { + if (Logging) console.log('Found FastStream window', frame); + frame.isPlayer = true; + + if (tab.downloadInfo) { + chrome.tabs.sendMessage(frame.tab.tabId, { + type: MessageTypes.HANDLE_DOWNLOAD, + url: tab.downloadInfo.url, + filename: tab.downloadInfo.filename, + }, { + frameId: frame.frameId, + }, (response) => { + BackgroundUtils.checkMessageError('download'); + tab.downloadInfo.resolve(response); + tab.downloadInfo = null; + + // Close tab + chrome.tabs.remove(frame.tab.tabId); + }); + sendResponse(null); + return; + } + + if (frame.playerOpening) { + frame.playerOpening = false; + } else if (frame.parent) { + frame.parent.playerOpening = false; + } + tab.playerCount++; + const isMainPlayer = tab.playerCount === 1; + + getPageFrame(frame).then((pageFrame) => { + if (pageFrame) { + frame.pageFrame = pageFrame; + } else { + frame.pageFrame = tab.getFrameOrCreate(0); + } + + const response = { + mediaInfo: getMediaInfoFromTab(sender?.tab), + analyzerData: tab.analyzerData, + isMainPlayer, + }; + + sendResponse(response); + }).catch((e) => { + // The player waits for this answer before it starts; the top frame stands in. + console.error('Finding the page frame of a player failed', e); + frame.pageFrame = tab.getFrameOrCreate(0); + sendResponse({ + mediaInfo: getMediaInfoFromTab(sender?.tab), + analyzerData: tab.analyzerData, + isMainPlayer, + }); + }); +} + async function openPlayer(frame) { if (frame.playerOpening || frame.hasPlayer()) { return; diff --git a/chrome/content.js b/chrome/content.js index 1d897e9c..4905a056 100644 --- a/chrome/content.js +++ b/chrome/content.js @@ -30,6 +30,7 @@ REPORT_LOADED_MEDIA: 'REPORT_LOADED_MEDIA', LOADED_MEDIA: 'LOADED_MEDIA', PLAYER_OPEN_GONE: 'PLAYER_OPEN_GONE', + IS_PLAYER_OPENER: 'IS_PLAYER_OPENER', }; const iframeMap = new Map(); @@ -103,6 +104,11 @@ sendResponse(true); } return; + } else if (request.type === MessageTypes.IS_PLAYER_OPENER) { + // Whether a player naming this frame as its parent was opened by this page: only + // this content script knows the page's name (background.mjs, PLAYER_LOADED). + sendResponse(request.document === DocumentKey); + return; } else if (request.type === MessageTypes.PING_TAB) { sendResponse(MessageTypes.PONG_TAB); } else if (request.type === MessageTypes.OPEN_PLAYER) { diff --git a/chrome/player/enums/MessageTypes.mjs b/chrome/player/enums/MessageTypes.mjs index 9da036f0..659e3fd1 100644 --- a/chrome/player/enums/MessageTypes.mjs +++ b/chrome/player/enums/MessageTypes.mjs @@ -51,4 +51,5 @@ export const MessageTypes = { REPORT_LOADED_MEDIA: 'REPORT_LOADED_MEDIA', LOADED_MEDIA: 'LOADED_MEDIA', PLAYER_OPEN_GONE: 'PLAYER_OPEN_GONE', + IS_PLAYER_OPENER: 'IS_PLAYER_OPENER', }; diff --git a/tests/e2e/classic-specs/mpv-shortcut.e2e.mjs b/tests/e2e/classic-specs/mpv-shortcut.e2e.mjs index a3b85767..961b8757 100644 --- a/tests/e2e/classic-specs/mpv-shortcut.e2e.mjs +++ b/tests/e2e/classic-specs/mpv-shortcut.e2e.mjs @@ -521,12 +521,15 @@ describe('The MPV keyboard shortcut (Ctrl+Shift+U)', function() { `); return; } - if (req.url.startsWith('/late')) { + if (req.url.startsWith('/late') || req.url.startsWith('/framed')) { // A player of a page that is gone: its URL names another page as its opener, as a - // player still starting when its page reloaded does. The page's own video loads - // only when the test says so, once the player has said it loaded. + // player still starting when its page reloaded does. Or (/framed) the player page + // framed by the page itself, naming the top frame as its parent and no opener. The + // page's own video loads only when the test says so, once the player has said it + // loaded. + const query = req.url.startsWith('/late') ? 'parent_frame_id=0&opener=gone' : 'parent_frame_id=0'; res.end(`mpv shortcut test, a late player - @@ -1139,14 +1142,14 @@ describe('The MPV keyboard shortcut (Ctrl+Shift+U)', function() { await expectMode('off', 'pressing Ctrl+Shift+U again'); }); - // Ctrl+Shift+U with the player just opened reloads the page (startMpv). On Windows CI a - // player still starting then said it loaded after the new page had, and counted as the - // new page's: the page's streams were dropped as the player's own, and no player opened - // on it - mpv got nothing on a play, and Ctrl+Shift+F showed nothing. Here the late - // player is one that names a page never in this tab, the state that race left. - it('takes a player whose page is gone for no player of the page there now', async function() { + /** + * Loads a page that frames a player FastStream did not open there (#late), waits until + * that player has started, and checks that the page's own video still opens a player. + * @param {string} pathname - The page: /late or /framed. + */ + async function expectPagePlayerBesidePlanted(pathname) { await browser.switchToWindow(siteHandle); - await browser.url(`${SITE}/late`); + await browser.url(`${SITE}${pathname}`); await expectMode('off', 'the start of this test'); // The late player has said it loaded: it marks its options applied just before. @@ -1177,11 +1180,27 @@ describe('The MPV keyboard shortcut (Ctrl+Shift+U)', function() { await browser.waitUntil(async () => { await browser.switchToWindow(siteHandle); return await browser.execute(() => Array.from(document.querySelectorAll('iframe')) - .some((f) => f.src.includes('player/index.html') && !f.src.includes('opener=gone'))); + .some((f) => f.src.includes('player/index.html') && f.id !== 'late')); }, {timeout: 15000, timeoutMsg: 'no player opened for the page\'s video'}); await clickToolbar(); await expectMode('off', 'clicking the toolbar button again'); + } + + // Ctrl+Shift+U with the player just opened reloads the page (startMpv). On Windows CI a + // player still starting then said it loaded after the new page had, and counted as the + // new page's: the page's streams were dropped as the player's own, and no player opened + // on it - mpv got nothing on a play, and Ctrl+Shift+F showed nothing. Here the late + // player is one that names a page never in this tab, the state that race left. + it('takes a player whose page is gone for no player of the page there now', async function() { + await expectPagePlayerBesidePlanted('/late'); + }); + + // The player page is web-accessible, so a page (or an ad's iframe in it) can frame it + // and name any frame of the tab as its parent. Taken on trust, the top frame counted as + // holding a player: its streams were dropped, and no player opened on it (#225). + it('takes the player page a page framed itself for no player of the page', async function() { + await expectPagePlayerBesidePlanted('/framed'); }); // Back brings a page out of Firefox's back-forward cache without loading its video diff --git a/tests/unit/TabTracker.test.mjs b/tests/unit/TabTracker.test.mjs index 49e04bc4..23944027 100644 --- a/tests/unit/TabTracker.test.mjs +++ b/tests/unit/TabTracker.test.mjs @@ -144,6 +144,68 @@ describe('isPlayerOfGoneDocument', () => { }); }); +// The player page is web-accessible: a page (or an ad's iframe in it) that frames it can +// put any parent_frame_id in its URL. Taken on trust, the frame it named counted as +// holding a player, so the background dropped that page's streams and opened no player +// there until it navigated (#225). The page that opened a player names itself in the +// player's URL, with a name only its content script knows. +describe('playerParentProof', () => { + /** + * A top frame (0) that named itself 'top', and a player frame (5) naming it. + * @return {{tab: Object, top: Object, player: Object}} + */ + function topWithPlayer() { + const tab = new TabTracker().getTabOrCreate(7); + const top = tab.getFrameOrCreate(0); + top.documentKey = 'top'; + const player = tab.getFrameOrCreate(5); + return {tab, top, player}; + } + + it('takes a player the named frame\'s page opened', () => { + const {tab, player} = topWithPlayer(); + expect(tab.playerParentProof(player, 0, 'top')).toBe('proven'); + }); + + it('takes a player a page sent its own frame to', () => { + // handlePlayerOpen's redirect: the player loads in the page's own frame. + const {tab} = topWithPlayer(); + const embed = tab.getFrameOrCreate(3); + embed.documentKey = 'embed'; + expect(tab.playerParentProof(embed, 0, 'embed')).toBe('proven'); + }); + + it('refuses a player that names no page that opened it', () => { + // A page framing the player page itself: nothing in its URL but the frame it names. + const {tab, player} = topWithPlayer(); + expect(tab.playerParentProof(player, 0, null)).toBe('refused'); + expect(tab.playerParentProof(player, 0, '')).toBe('refused'); + }); + + it('refuses a player whose opener is not the named frame\'s page', () => { + const {tab, player} = topWithPlayer(); + expect(tab.playerParentProof(player, 0, 'made-up')).toBe('refused'); + }); + + it('refuses a frame id that is no frame id', () => { + // parseInt of a made-up parent_frame_id: NaN became a frame of the tab. + const {tab, player} = topWithPlayer(); + for (const id of [NaN, -1, 1.5, '0', null]) { + expect(tab.playerParentProof(player, id, 'top'), String(id)).toBe('refused'); + } + }); + + it('asks the named frame when its page never named itself here', () => { + // The background started again after the page loaded: only the page's content script + // knows its name now. + const tab = new TabTracker().getTabOrCreate(7); + const player = tab.getFrameOrCreate(5); + expect(tab.playerParentProof(player, 0, 'top')).toBe('ask'); + tab.getFrameOrCreate(0); + expect(tab.playerParentProof(player, 0, 'top')).toBe('ask'); + }); +}); + /** * A tab with a main frame (0) named 'main' holding a frame (1) named 'embed', which * detected a stream and a subtitle. diff --git a/tests/unit/contentDom.mjs b/tests/unit/contentDom.mjs new file mode 100644 index 00000000..ac3f3f61 --- /dev/null +++ b/tests/unit/contentDom.mjs @@ -0,0 +1,480 @@ +// A small stand-in DOM for the content scripts (content.js, overlay-guard.js), which are +// classic scripts written for a page: enough of a document, its elements and their inline +// styles to run their message handlers in Node (vm), with layout given by each test. It +// is no browser: what it proves is the scripts' own bookkeeping, not how Firefox lays a +// page out. The e2e suites (tests/e2e/classic-specs) cover that. + +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import {webcrypto} from 'node:crypto'; + +const ROOT = path.resolve(import.meta.dirname, '../..'); + +const CAMEL_PROPERTIES = ['width', 'height', 'overflow', 'margin', 'contain', 'display', 'position', + 'zIndex', 'border', 'borderRadius', 'boxShadow', 'transition', 'visibility', 'top', 'left', + 'right', 'bottom', 'opacity', 'padding']; + +/** An element's inline style (CSSStyleDeclaration). */ +export class FakeStyle { + constructor(owner) { + this.owner = owner; + /** @type {Map} */ + this.props = new Map(); + } + + setProperty(name, value, priority = '') { + this.owner.hasStyle = true; + if (value === null || value === undefined || value === '') { + this.props.delete(name); + return; + } + this.props.set(name, {value: String(value), priority: priority || ''}); + } + + getPropertyValue(name) { + return this.props.has(name) ? this.props.get(name).value : ''; + } + + getPropertyPriority(name) { + return this.props.has(name) ? this.props.get(name).priority : ''; + } + + removeProperty(name) { + const value = this.getPropertyValue(name); + this.props.delete(name); + return value; + } + + get cssText() { + return Array.from(this.props, ([name, {value, priority}]) => + `${name}: ${value}${priority ? ' !important' : ''};`).join(' '); + } + + set cssText(text) { + this.props.clear(); + for (const part of String(text).split(';')) { + const colon = part.indexOf(':'); + if (colon === -1) continue; + const name = part.slice(0, colon).trim(); + let value = part.slice(colon + 1).trim(); + let priority = ''; + if (value.endsWith('!important')) { + priority = 'important'; + value = value.slice(0, -'!important'.length).trim(); + } + if (name && value) this.props.set(name, {value, priority}); + } + } +} + +for (const camel of CAMEL_PROPERTIES) { + const name = camel.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase()); + Object.defineProperty(FakeStyle.prototype, camel, { + get() { + return this.getPropertyValue(name); + }, + set(value) { + this.setProperty(name, value); + }, + }); +} + +/** + * Whether an element matches a selector: a tag or '*', with an optional [attr^="value"], + * or a comma-separated list of those. + * @param {FakeElement} el - The element. + * @param {string} selector - The selector. + * @return {boolean} + */ +function matches(el, selector) { + return selector.split(',').some((one) => { + const simple = /^\s*([a-zA-Z*]+)?(?:\[([a-z-]+)\^="([^"]*)"\])?\s*$/.exec(one); + if (!simple) throw new Error('contentDom: unsupported selector ' + one); + const [, tag, attr, prefix] = simple; + if (tag && tag !== '*' && el.tagName !== tag.toUpperCase()) return false; + if (attr && !String(el.getAttribute(attr) || '').startsWith(prefix)) return false; + return true; + }); +} + +/** An element. Its box is `rect` ({x, y, width, height}, or a function giving one). */ +export class FakeElement { + constructor(doc, tagName) { + this.ownerDocument = doc; + this.tagName = tagName.toUpperCase(); + this.nodeType = 1; + this.parentNode = null; + this.children = []; + this.attrs = new Map(); + this.hasStyle = false; + this.style = new FakeStyle(this); + this.dataset = {}; + this.classList = []; + this.listeners = []; + this.shadowRoot = null; + this.id = ''; + this.rect = {x: 0, y: 0, width: 0, height: 0}; + // Paint order: later in the document is painted above, unless set. + this.z = doc.nextZ++; + if (this.tagName === 'IFRAME') { + this.contentWindow = {postMessage() {}}; + } + } + + get parentElement() { + return this.parentNode && this.parentNode.nodeType === 1 ? this.parentNode : null; + } + + get isConnected() { + let node = this; + while (node.parentNode) node = node.parentNode; + return node === this.ownerDocument; + } + + get src() { + return this.getAttribute('src') || ''; + } + + set src(value) { + this.setAttribute('src', value); + } + + getAttribute(name) { + if (name === 'style') return this.hasStyle ? this.style.cssText : null; + if (name === 'id') return this.id || null; + return this.attrs.has(name) ? this.attrs.get(name) : null; + } + + setAttribute(name, value) { + if (name === 'style') { + this.hasStyle = true; + this.style.cssText = value; + } else if (name === 'id') { + this.id = String(value); + } else { + this.attrs.set(name, String(value)); + } + } + + hasAttribute(name) { + return this.getAttribute(name) !== null; + } + + removeAttribute(name) { + if (name === 'style') { + this.hasStyle = false; + this.style.props.clear(); + } else { + this.attrs.delete(name); + } + } + + getBoundingClientRect() { + const r = typeof this.rect === 'function' ? this.rect() : this.rect; + return {x: r.x, y: r.y, left: r.x, top: r.y, width: r.width, height: r.height, + right: r.x + r.width, bottom: r.y + r.height}; + } + + get clientWidth() { + return this.getBoundingClientRect().width; + } + + get clientHeight() { + return this.getBoundingClientRect().height; + } + + /** @return {Array} Every element below this one, in document order. */ + descendants() { + const found = []; + const walk = (el) => { + for (const child of el.children) { + found.push(child); + walk(child); + } + }; + walk(this); + return found; + } + + querySelectorAll(selector) { + return this.descendants().filter((el) => matches(el, selector)); + } + + querySelector(selector) { + return this.querySelectorAll(selector)[0] || null; + } + + matches(selector) { + return matches(this, selector); + } + + contains(other) { + for (let node = other; node; node = node.parentNode) { + if (node === this) return true; + } + return false; + } + + appendChild(node) { + return this.insertBefore(node, null); + } + + insertBefore(node, ref) { + if (node.parentNode) node.parentNode.removeChild(node); + const at = ref ? this.children.indexOf(ref) : this.children.length; + if (at === -1) throw new Error('contentDom: insertBefore a node that is not a child'); + this.children.splice(at, 0, node); + node.parentNode = this; + this.ownerDocument.mutated(); + return node; + } + + removeChild(node) { + const at = this.children.indexOf(node); + if (at === -1) throw new Error('contentDom: removeChild of a node that is not a child'); + this.children.splice(at, 1); + node.parentNode = null; + this.ownerDocument.mutated(); + return node; + } + + replaceChild(node, old) { + this.insertBefore(node, old); + return this.removeChild(old); + } + + remove() { + if (this.parentNode) this.parentNode.removeChild(this); + } + + replaceWith(node) { + if (this.parentNode) this.parentNode.replaceChild(node, this); + } + + addEventListener(type, listener) { + this.listeners.push({type, listener}); + } + + removeEventListener(type, listener) { + this.listeners = this.listeners.filter((l) => l.type !== type || l.listener !== listener); + } + + pause() { + this.paused = true; + } +} + +/** A document: with and . */ +export class FakeDocument { + constructor() { + this.nodeType = 9; + this.nextZ = 0; + this.listeners = []; + this.observers = new Set(); + this.parentNode = null; + this.fullscreenEnabled = true; + this.fullscreenElement = null; + this.baseURI = 'https://site.example/page'; + this.documentElement = this.createElement('html'); + this.documentElement.parentNode = this; + this.head = this.createElement('head'); + this.body = this.createElement('body'); + this.documentElement.appendChild(this.head); + this.documentElement.appendChild(this.body); + this.activeElement = this.body; + } + + createElement(tagName) { + return new FakeElement(this, tagName); + } + + querySelectorAll(selector) { + return [this.documentElement, ...this.documentElement.descendants()].filter((el) => matches(el, selector)); + } + + /** + * The elements whose box holds a point, topmost first (by `z`); an element with + * pointer-events: none is left out, as Firefox's hit test leaves it out. + * @param {number} x - The point. + * @param {number} y - The point. + * @return {Array} + */ + elementsFromPoint(x, y) { + return this.querySelectorAll('*').filter((el) => { + if (el.style.getPropertyValue('pointer-events') === 'none' || el.pointerEventsNone) return false; + const r = el.getBoundingClientRect(); + return x >= r.left && x < r.right && y >= r.top && y < r.bottom; + }).sort((a, b) => b.z - a.z); + } + + addEventListener(type, listener) { + this.listeners.push({type, listener}); + } + + removeEventListener() {} + + /** Runs the MutationObservers watching the document (synchronously, for the tests). */ + mutated() { + for (const observer of [...this.observers]) observer.callback([], observer); + } +} + +/** + * A page with content.js (and overlay-guard.js, which it uses) loaded in it. + * @param {Object} [options] + * @param {string} [options.hostname] - The page's host. + * @param {Array} [options.entries] - Its Resource Timing entries. + * @return {Object} The page: its document, what content.js sent, and how to talk to it. + */ +export function loadContentScript({hostname = 'site.example', entries = []} = {}) { + const document = new FakeDocument(); + const sent = []; + let onMessage = null; + const windowListeners = []; + const timers = []; + let now = 0; + + class FakeMutationObserver { + constructor(callback) { + this.callback = callback; + } + observe() { + document.observers.add(this); + } + disconnect() { + document.observers.delete(this); + } + } + + class FakeResizeObserver { + constructor(callback) { + this.callback = callback; + } + observe() {} + disconnect() {} + } + + class FakeIntersectionObserver { + constructor(callback) { + this.callback = callback; + } + observe(el) { + // Everything with a box is on screen. + const r = el.getBoundingClientRect(); + Promise.resolve().then(() => this.callback([{intersectionRatio: r.width > 0 && r.height > 0 ? 1 : 0}], this)); + } + disconnect() {} + } + + const context = { + document, + location: {hostname, href: `https://${hostname}/page`, origin: `https://${hostname}`, pathname: '/page', search: ''}, + navigator: {userActivation: {isActive: false}}, + performance: { + timeOrigin: 1000, + getEntriesByType: (type) => (type === 'resource' ? entries.slice(0, 250) : []), + }, + screen: {width: 1920, height: 1080}, + innerWidth: 1280, + innerHeight: 720, + crypto: webcrypto, + URL, + URLSearchParams, + Node: {ELEMENT_NODE: 1}, + ShadowRoot: class ShadowRoot {}, + MutationObserver: FakeMutationObserver, + ResizeObserver: FakeResizeObserver, + IntersectionObserver: FakeIntersectionObserver, + XMLHttpRequest: class { + open() { + throw new Error('contentDom: no network'); + } + }, + console: {log() {}, error() {}, warn() {}, debug() {}, info() {}}, + setTimeout: (fn, ms = 0) => { + timers.push({at: now + ms, fn}); + return timers.length; + }, + clearTimeout: () => {}, + setInterval: () => 0, + clearInterval: () => {}, + addEventListener: (type, listener) => windowListeners.push({type, listener}), + removeEventListener: () => {}, + parent: {postMessage() {}}, + getComputedStyle: (el) => ({ + display: el.style.getPropertyValue('display') || el.computed?.display || 'block', + position: el.style.getPropertyValue('position') || el.computed?.position || 'static', + overflow: el.computed?.overflow || 'visible', + zIndex: 'auto', + border: '', + borderRadius: '', + boxShadow: '', + }), + chrome: { + runtime: { + getURL: (file) => 'moz-extension://test/' + file, + lastError: undefined, + onMessage: {addListener: (listener) => { + onMessage = listener; + }}, + sendMessage: (message, callback) => { + sent.push(message); + if (callback) { + callback(undefined); + return undefined; + } + return Promise.resolve(undefined); + }, + }, + }, + }; + context.window = context; + vm.createContext(context); + for (const file of ['chrome/overlay-guard.js', 'chrome/content.js']) { + vm.runInContext(fs.readFileSync(path.join(ROOT, file), 'utf8'), context, {filename: file}); + } + + return { + document, + window: context, + sent, + timers, + /** + * Sends content.js a message, as the background does. + * @param {Object} request - The message. + * @return {Promise<*>} Its answer; undefined when it gives none. + */ + send(request) { + return new Promise((resolve) => { + const async = onMessage(request, {id: 'test'}, resolve); + if (async !== true) resolve(undefined); + }); + }, + /** + * Posts a message to the page's window, as a frame in it does. + * @param {*} data - The message. + * @param {Object} source - The window it comes from. + */ + postMessage(data, source) { + for (const {type, listener} of windowListeners) { + if (type === 'message') listener({data, source}); + } + }, + /** + * Runs the timers due within this many milliseconds, in order. + * @param {number} ms - How far to move the clock. + */ + advance(ms) { + const until = now + ms; + for (;;) { + const due = timers.filter((t) => !t.done && t.at <= until).sort((a, b) => a.at - b.at)[0]; + if (!due) break; + due.done = true; + now = due.at; + due.fn(); + } + now = until; + }, + }; +} diff --git a/tests/unit/contentScript.test.mjs b/tests/unit/contentScript.test.mjs new file mode 100644 index 00000000..fe51519c --- /dev/null +++ b/tests/unit/contentScript.test.mjs @@ -0,0 +1,34 @@ +import {describe, expect, it} from 'vitest'; +import {loadContentScript} from './contentDom.mjs'; + +// content.js's message handlers, run against a stand-in page (contentDom.mjs). + +/** + * The name content.js gave its page (FRAME_ADDED's document). + * @param {Object} page - From loadContentScript. + * @return {string} + */ +function pageName(page) { + return page.sent.find((message) => message.type === 'FRAME_ADDED').document; +} + +// A player naming a frame as its parent is taken for that frame's only when the frame's +// page opened it (#225). The background asks the frame's content script when it does not +// know the page's name itself. +describe('IS_PLAYER_OPENER', () => { + it('answers yes for the name this page put in its players\' URLs', async () => { + const page = loadContentScript(); + expect(await page.send({type: 'IS_PLAYER_OPENER', document: pageName(page)})).toBe(true); + }); + + it('answers no for any other name', async () => { + const page = loadContentScript(); + for (const name of ['made-up', '', null, undefined]) { + expect(await page.send({type: 'IS_PLAYER_OPENER', document: name}), String(name)).toBe(false); + } + }); + + it('gives each page a name of its own', () => { + expect(pageName(loadContentScript())).not.toBe(pageName(loadContentScript())); + }); +}); diff --git a/types/messages.d.ts b/types/messages.d.ts index 673dd241..7a1e1484 100644 --- a/types/messages.d.ts +++ b/types/messages.d.ts @@ -58,10 +58,25 @@ interface FSPlayerLoaded extends FSMessageBase { url: string; /** False on faststream.online, true inside the extension. */ isExt: boolean; - /** Absent when the player was not opened from a parent frame. */ + /** + * The frame the player's URL names as its parent (parent_frame_id); absent when the + * player was not opened from a parent frame. The URL is the framing page's to write, + * so the background takes it only when the page its opener names is that frame's + * (TabHolder.playerParentProof, IS_PLAYER_OPENER). + */ parentFrameId?: number; } +/** + * Background asks a frame whether its page is the one a player names as its opener. + * content.js answers `true` or `false`. + */ +interface FSIsPlayerOpener extends FSMessageBase { + type: 'IS_PLAYER_OPENER'; + /** The opener the player's URL names. */ + document: string; +} + /** * Registers declarativeNetRequest rules that rewrite request headers. * @@ -107,6 +122,7 @@ type FSMessage = | FSUpdateOptions | FSOpenPlayer | FSPlayerLoaded + | FSIsPlayerOpener | FSSetHeaders | FSGetVideoSize | FSMpvTest From b7ffa8f1206d7f3f719398dbd94a4f9dec61de3c Mon Sep 17 00:00:00 2001 From: Nawid Salehie <102778966+Nawid3333@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:51:00 +0200 Subject: [PATCH 03/22] fix(background): six small defects of the background script (#144) 1. A failed first options load was kept: until the event page unloaded, every event ran with no options (MPV mode and both URL lists off). The next event now loads them again. 2. A container tab's download: the 30 s timeout could clear the download while the hidden tab's player saved it, and the player's answer then threw a TypeError. The download is taken when the player is told, and answered once, by the player or by the timeout. 3. The toolbar's On sorted frames by a video size that is undefined for a frame without a content script: NaN, an arbitrary order, and the first player opened is the one that plays. A missing size is 0. 4. Custom source patterns: a pattern with its own group named like the matcher's (o0) made compile() throw, unhandled, and the old patterns stayed in use; a named group in patterns of two outputs gave no output; a backreference meant another group once joined. The matcher reads its own groups only, compiles a backreference pattern on its own, and falls back to one regex per pattern when the joined one is refused. 5. The stream-link redirect's `[\?|#]` held a literal `|`; now `[?#]`. 6. RuleManager's two background promises were unhandled, and a refused removal of an expired header rule freed its id while the browser still held the rule. Now warned, and the rule stays listed and is tried again (three times). Tests: backgroundRobustness.test.mjs (1, 2, 3, 5 through the background's listeners), MultiRegexMatcher.test.mjs (4), NetRequestRuleManager.test.mjs (6). Each new test was run against the code before the fix and failed. Co-Authored-By: Claude Opus 5.5 --- chrome/background/MultiRegexMatcher.mjs | 88 +++++++++---- chrome/background/NetRequestRuleManager.mjs | 25 +++- chrome/background/background.mjs | 46 +++++-- tests/unit/MultiRegexMatcher.test.mjs | 34 +++++ tests/unit/NetRequestRuleManager.test.mjs | 73 ++++++++++ tests/unit/backgroundHarness.mjs | 10 +- tests/unit/backgroundRobustness.test.mjs | 139 ++++++++++++++++++++ 7 files changed, 368 insertions(+), 47 deletions(-) create mode 100644 tests/unit/backgroundRobustness.test.mjs diff --git a/chrome/background/MultiRegexMatcher.mjs b/chrome/background/MultiRegexMatcher.mjs index b0ba2769..bcbab7da 100644 --- a/chrome/background/MultiRegexMatcher.mjs +++ b/chrome/background/MultiRegexMatcher.mjs @@ -53,42 +53,76 @@ export class MultiRegexMatcher { this.compiledRegexes.length = 0; regexesByFlags.forEach((regexes, flags) => { - const regexesByOutput = new Map(); - for (const {regex, output} of regexes) { - if (!regexesByOutput.has(output)) { - regexesByOutput.set(output, []); + // A backreference counts groups by position, and the groups the joined form adds + // move them: \1 came to mean the group wrapped around the pattern. Such a pattern + // is compiled on its own. + const alone = regexes.filter(({regex}) => /\\(?:[1-9]|k<)/.test(regex)); + const joinable = regexes.filter((entry) => !alone.includes(entry)); + if (joinable.length > 0) { + try { + this.compiledRegexes.push(MultiRegexMatcher.join(joinable, flags)); + } catch (e) { + // A pattern valid on its own that the joined form refuses - a group of its own + // named like the ones added (o0) - left all of them out, and the matcher before + // stayed in use. Each on its own instead. + alone.push(...joinable); } - regexesByOutput.get(output).push(regex); } + for (const {regex, output} of alone) { + this.compiledRegexes.push({regex: new RegExp(regex, flags), output}); + } + }); + } - const joinedRegexes = []; - const outputByGroupName = new Map(); - let groupIndex = 0; - regexesByOutput.forEach((regexes, output) => { - // Named group, not a plain wrapping group: a raw regex with its own - // capturing group(s) would otherwise shift every later output's - // positional group index, silently misrouting the match to the - // wrong (or a nonexistent) output. - const groupName = 'o' + groupIndex++; - outputByGroupName.set(groupName, output); - joinedRegexes.push(`(?<${groupName}>${regexes.join('|')})`); - }); + /** + * One regex for many patterns: each output's patterns in a named group of its own. + * @param {Array<{regex: string, output: *}>} regexes - The patterns, all with one set + * of flags. + * @param {string} flags - Those flags. + * @return {{regex: RegExp, outputByGroupName: Map}} + */ + static join(regexes, flags) { + const regexesByOutput = new Map(); + for (const {regex, output} of regexes) { + if (!regexesByOutput.has(output)) { + regexesByOutput.set(output, []); + } + regexesByOutput.get(output).push(regex); + } - this.compiledRegexes.push({ - regex: new RegExp(joinedRegexes.join('|'), flags), - outputByGroupName, - }); + const joinedRegexes = []; + const outputByGroupName = new Map(); + let groupIndex = 0; + regexesByOutput.forEach((regexes, output) => { + // Named group, not a plain wrapping group: a raw regex with its own + // capturing group(s) would otherwise shift every later output's + // positional group index, silently misrouting the match to the + // wrong (or a nonexistent) output. + const groupName = 'o' + groupIndex++; + outputByGroupName.set(groupName, output); + joinedRegexes.push(`(?<${groupName}>${regexes.join('|')})`); }); + + return { + regex: new RegExp(joinedRegexes.join('|'), flags), + outputByGroupName, + }; } match(str) { - for (const {regex, outputByGroupName} of this.compiledRegexes) { + for (const {regex, output, outputByGroupName} of this.compiledRegexes) { const match = str.match(regex); - if (match?.groups) { - for (const groupName in match.groups) { - if (match.groups[groupName] !== undefined) { - return outputByGroupName.get(groupName); - } + if (!match) { + continue; + } + if (!outputByGroupName) { + return output; + } + // By the groups this matcher added, not every group the match has: a pattern's own + // named group came first in a match of a later output, and gave no output at all. + for (const [groupName, groupOutput] of outputByGroupName) { + if (match.groups?.[groupName] !== undefined) { + return groupOutput; } } } diff --git a/chrome/background/NetRequestRuleManager.mjs b/chrome/background/NetRequestRuleManager.mjs index e2c3eb86..005c1a2e 100644 --- a/chrome/background/NetRequestRuleManager.mjs +++ b/chrome/background/NetRequestRuleManager.mjs @@ -33,7 +33,7 @@ export class RuleManager { this.idStart = 10 + Math.floor(Math.random() * 1000) * 1000; this.rules = []; this.isLoopRunning = false; - this.dumpRules(); + this.dumpRules().catch((e) => console.warn('Could not clear the header rules left from before', e)); } getInsertionIndex(id) { @@ -66,7 +66,7 @@ export class RuleManager { return; } setTimeout(() => this.mainLoop(), 1000); - this.filterRules(); + this.filterRules().catch((e) => console.warn('Could not remove expired header rules', e)); } async filterRules() { @@ -80,9 +80,24 @@ export class RuleManager { return true; }); - return chrome.declarativeNetRequest.updateSessionRules({ - removeRuleIds: removed.map((rule) => rule.id), - }); + try { + await chrome.declarativeNetRequest.updateSessionRules({ + removeRuleIds: removed.map((rule) => rule.id), + }); + } catch (e) { + // The browser may still hold them. Back in the list, their ids are not handed out + // again (getNextID), which a new rule would be refused for, and the next round tries + // again - three times, then they are given up. + for (const rule of removed) { + rule.removeAttempts = (rule.removeAttempts || 0) + 1; + const index = this.getInsertionIndex(rule.id); + if (rule.removeAttempts < 3 && index !== -1) { + this.rules.splice(index, 0, rule); + } + } + this.startLoop(); + throw e; + } } async addHeaderRule(url, tabId, requestHeaderCommands) { diff --git a/chrome/background/background.mjs b/chrome/background/background.mjs index 2b22aee1..1d8ceb53 100644 --- a/chrome/background/background.mjs +++ b/chrome/background/background.mjs @@ -91,7 +91,12 @@ function ensureOptions() { OptionsLoadPromise = Promise.all([ loadOptions(), Tabs.restoreTabStates(), - ]).catch(console.error); + ]).catch((e) => { + console.error('Loading the options failed', e); + // The next event tries again. Kept, the failure stood until the event page unloaded, + // and every event acted on no options: MPV mode and both URL lists off. + OptionsLoadPromise = null; + }); } return OptionsLoadPromise; } @@ -689,19 +694,22 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { frame.isPlayer = true; if (tab.downloadInfo) { + // Taken now: the DOWNLOAD's 30 s timeout can end it while the player saves, and the + // answer then found no download to give it to (a TypeError, and no answer at all). + const info = tab.downloadInfo; + tab.downloadInfo = null; chrome.tabs.sendMessage(frame.tab.tabId, { type: MessageTypes.HANDLE_DOWNLOAD, - url: tab.downloadInfo.url, - filename: tab.downloadInfo.filename, + url: info.url, + filename: info.filename, }, { frameId: frame.frameId, }, (response) => { BackgroundUtils.checkMessageError('download'); - tab.downloadInfo.resolve(response); - tab.downloadInfo = null; + info.resolve(response); // Close tab - chrome.tabs.remove(frame.tab.tabId); + chrome.tabs.remove(frame.tab.tabId).catch(() => {}); }); sendResponse(null); return; @@ -869,17 +877,25 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { return; } const tab2 = Tabs.getTabOrCreate(tabobj2.id); + // Answered once: by the player's HANDLE_DOWNLOAD answer, or by the timeout below. + let answered = false; + const answer = (value) => { + if (answered) return false; + answered = true; + sendResponse(value); + return true; + }; tab2.downloadInfo = { url: url, filename: filename, - resolve: sendResponse, + resolve: answer, }; // If the player in this hidden tab never sends PLAYER_LOADED (blocked - // page, redirect failure, site error before injection), the tab and - // the caller's sendResponse would otherwise hang forever. + // page, redirect failure, site error before injection), or never answers + // HANDLE_DOWNLOAD, the tab and the caller's sendResponse would otherwise + // hang forever. setTimeout(() => { - if (tab2.downloadInfo) { - tab2.downloadInfo.resolve(null); + if (answer(null)) { tab2.downloadInfo = null; chrome.tabs.remove(/** @type {number} */ (tabobj2.id)).catch(() => {}); } @@ -1348,8 +1364,8 @@ async function setupRedirectRule(ruleID, filetypes) { condition: { // exclude self excludedRequestDomains, - // only match m3u8 or mpds - regexFilter: '^.+\\.(' + filetypes.join('|') + ')([\\?|#].*)?$', + // only match m3u8 or mpds, up to a query or a fragment + regexFilter: '^.+\\.(' + filetypes.join('|') + ')([?#].*)?$', resourceTypes: ['main_frame'], }, }; @@ -1959,8 +1975,10 @@ async function openPlayersWithSources(tab) { return; } + // A frame that did not answer (no content script) has no size: 0, not undefined, whose + // NaN made the order arbitrary - and the first player opened is the one that plays. framesWithSources.sort((a, b) => { - return b.videoSize - a.videoSize; + return (b.videoSize || 0) - (a.videoSize || 0); }); for (let i = 0; i < framesWithSources.length; i++) { diff --git a/tests/unit/MultiRegexMatcher.test.mjs b/tests/unit/MultiRegexMatcher.test.mjs index 63bf5951..a9546256 100644 --- a/tests/unit/MultiRegexMatcher.test.mjs +++ b/tests/unit/MultiRegexMatcher.test.mjs @@ -126,3 +126,37 @@ describe('flags and empty patterns', () => { expect(() => new MultiRegexMatcher().addRegex('', '', 'hls')).toThrow(/Empty regex/); }); }); + +// A pattern that is valid on its own has to work the same once joined with the others. Two +// ways it did not: a group of its own named like the joined form's (o0) made compile() throw, +// so the background kept the patterns from before with no word; and a named group in +// patterns of two outputs gave no output at all. A backreference meant another group. +describe('patterns with groups of their own', () => { + it('compiles a pattern whose group is named like the matcher\'s own', () => { + const m = build([ + ['\\/live\\/(?\\d+)', '', 'hls'], + ['\\.mpd', '', 'dash'], + ]); + expect(m.match('https://e.com/live/12')).toBe('hls'); + expect(m.match('https://e.com/a.mpd')).toBe('dash'); + }); + + it('routes a named group used by patterns of two outputs to each one\'s output', () => { + const m = build([ + ['\\/a\\/(?\\d+)', '', 'hls'], + ['\\/b\\/(?\\d+)', '', 'dash'], + ]); + expect(m.match('https://e.com/a/1')).toBe('hls'); + expect(m.match('https://e.com/b/2')).toBe('dash'); + }); + + it('matches a pattern with a backreference as it does on its own', () => { + const m = build([ + ['\\.mpd', '', 'dash'], + ['\\/(x+)-\\1\\.m3u8', '', 'hls'], + ]); + expect(m.match('https://e.com/xx-xx.m3u8')).toBe('hls'); + expect(m.match('https://e.com/x-xx.m3u8')).toBeNull(); + expect(m.match('https://e.com/a.mpd')).toBe('dash'); + }); +}); diff --git a/tests/unit/NetRequestRuleManager.test.mjs b/tests/unit/NetRequestRuleManager.test.mjs index 7c6b7b00..6b6d60a5 100644 --- a/tests/unit/NetRequestRuleManager.test.mjs +++ b/tests/unit/NetRequestRuleManager.test.mjs @@ -115,3 +115,76 @@ describe('RuleManager.getInsertionIndex', () => { expect(insertionIndex([10], 10)).toBe(-1); }); }); + +// Both of RuleManager's background promises went unhandled: the clearing of old rules at +// start, and the removal of expired ones each second. A refused removal also took the rule +// out of the list, so its id went to the next rule while the browser still held the old +// one, and that rule was refused as a duplicate. +describe('RuleManager when the browser refuses', () => { + let warn; + let refuseRemovals; + let removed; + + beforeEach(() => { + vi.useFakeTimers(); + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + refuseRemovals = 0; + removed = []; + globalThis.chrome = { + runtime: {getURL: (path) => 'moz-extension://test-uuid' + path}, + declarativeNetRequest: { + getSessionRules: async () => [], + updateSessionRules: async (update) => { + const ids = update.removeRuleIds || []; + if (ids.length > 0) { + if (refuseRemovals > 0) { + refuseRemovals--; + throw new Error('refused'); + } + removed.push(...ids); + } + }, + }, + }; + }); + + afterEach(() => { + warn.mockRestore(); + vi.useRealTimers(); + delete globalThis.chrome; + }); + + it('says so when the rules left from before cannot be cleared', async () => { + globalThis.chrome.declarativeNetRequest.getSessionRules = async () => { + throw new Error('not now'); + }; + new RuleManager(); + await vi.advanceTimersByTimeAsync(0); + expect(warn).toHaveBeenCalledWith('Could not clear the header rules left from before', expect.any(Error)); + }); + + it('keeps an expired rule it could not remove, and removes it on the next round', async () => { + const manager = new RuleManager(); + const rule = await manager.addHeaderRule('https://cdn.test/a.m3u8', 7, [{operation: 'set', header: 'referer', value: 'https://site.test/'}]); + refuseRemovals = 1; + // It expires after 5 s; the round after that is refused. + await vi.advanceTimersByTimeAsync(6000); + expect(removed).toEqual([]); + expect(warn).toHaveBeenCalledWith('Could not remove expired header rules', expect.any(Error)); + expect(manager.getNextID()).not.toBe(rule.id); + + await vi.advanceTimersByTimeAsync(1000); + expect(removed).toEqual([rule.id]); + expect(manager.rules).toEqual([]); + }); + + it('gives a rule up after three refused removals', async () => { + const manager = new RuleManager(); + const rule = await manager.addHeaderRule('https://cdn.test/a.m3u8', 7, [{operation: 'set', header: 'referer', value: 'https://site.test/'}]); + refuseRemovals = 100; + await vi.advanceTimersByTimeAsync(20000); + expect(manager.rules).toEqual([]); + expect(manager.isLoopRunning).toBe(false); + expect(manager.getNextID()).toBe(rule.id); + }); +}); diff --git a/tests/unit/backgroundHarness.mjs b/tests/unit/backgroundHarness.mjs index cd364848..07788ad7 100644 --- a/tests/unit/backgroundHarness.mjs +++ b/tests/unit/backgroundHarness.mjs @@ -99,6 +99,7 @@ export async function loadBackground({ reloaded: [], removedTabs: [], createdTabs: [], + callbackErrors: [], }; const onMessage = event(); @@ -121,7 +122,14 @@ export async function loadBackground({ * @return {Promise<*>} The value. */ const answer = (callback, value) => Promise.resolve(value).then((resolved) => { - if (typeof callback === 'function') callback(resolved); + if (typeof callback === 'function') { + try { + callback(resolved); + } catch (e) { + // The browser reports it on the console; a test can check none was thrown. + bg.callbackErrors.push(e); + } + } return resolved; }); diff --git a/tests/unit/backgroundRobustness.test.mjs b/tests/unit/backgroundRobustness.test.mjs new file mode 100644 index 00000000..eae762c3 --- /dev/null +++ b/tests/unit/backgroundRobustness.test.mjs @@ -0,0 +1,139 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; + +import {loadBackground} from './backgroundHarness.mjs'; + +// Small defects of the background script (#144), each through its real listeners. + +const PAGE = 'https://site.test/watch/1'; + +let bg; + +afterEach(() => { + bg?.unload(); + bg = null; + vi.restoreAllMocks(); +}); + +describe('the options', () => { + it('are read again after a load that failed', async () => { + // The failed first load was kept: until the event page unloaded, every event acted on + // no options at all - MPV mode and both URL lists off. + vi.spyOn(console, 'error').mockImplementation(() => {}); + let failures = 1; + bg = await loadBackground({ + options: {mpvMode: true, mpvAllowlist: ['https://site.test/']}, + tabs: [{id: 1, url: 'about:blank'}], + beforeImport: (chrome) => { + const get = chrome.storage.local.get; + chrome.storage.local.get = (key, callback) => { + if (failures-- > 0) { + throw new Error('storage is not ready'); + } + return get(key, callback); + }; + }, + }); + await bg.navigated(1, PAGE); + expect(bg.session['tabState:1']).toMatchObject({isOn: true, isMpv: true}); + }); +}); + +describe('a download from a container tab', () => { + // A container tab's download goes through a hidden player tab in the same container, + // which the background gives 30 s. + const container = {id: 1, url: PAGE, cookieStoreId: 'firefox-container-1'}; + + /** + * Starts a download from the player in the container tab, and has the hidden tab's + * player load after the given time. + * @param {number} loadedAfterMs - When the hidden tab's player says it loaded. + * @return {Promise>} The answers the download got. + */ + async function download(loadedAfterMs) { + const answers = []; + for (const listener of bg.chrome.runtime.onMessage.listeners) { + listener({type: 'DOWNLOAD', url: 'blob:moz-extension://bg-test/1', filename: 'a.mp4'}, + {tab: container, frameId: 3}, (value) => answers.push(value)); + } + await bg.wait(loadedAfterMs); + const hidden = bg.createdTabs[0]; + expect(hidden.cookieStoreId).toBe('firefox-container-1'); + for (const listener of bg.chrome.runtime.onMessage.listeners) { + listener({type: 'PLAYER_LOADED', url: 'moz-extension://bg-test/player/index.html'}, + {tab: {id: hidden.id, url: hidden.url}, frameId: 0}, () => {}); + } + return answers; + } + + it('gets the player\'s answer, and the hidden tab is closed', async () => { + bg = await loadBackground({ + tabs: [container], + onTabMessage: ({message}) => message.type === 'HANDLE_DOWNLOAD' ? 42 : undefined, + }); + const answers = await download(1000); + await bg.wait(0); + expect(answers).toEqual([42]); + expect(bg.removedTabs).toEqual([bg.createdTabs[0].id]); + await bg.wait(30000); + expect(answers).toEqual([42]); + }); + + it('is answered once when the player answers after the 30 s ran out', async () => { + // The timeout cleared the download while the player saved it, and the player's answer + // then threw a TypeError on it. + bg = await loadBackground({ + tabs: [container], + onTabMessage: ({message}) => message.type === 'HANDLE_DOWNLOAD' ? + new Promise((resolve) => setTimeout(() => resolve(42), 1000)) : undefined, + }); + const answers = await download(29500); + await bg.wait(2000); + expect(bg.callbackErrors).toEqual([]); + expect(answers).toEqual([null]); + }); + + it('is answered when the player never answers', async () => { + bg = await loadBackground({ + tabs: [container], + onTabMessage: ({message}) => message.type === 'HANDLE_DOWNLOAD' ? new Promise(() => {}) : undefined, + }); + const answers = await download(1000); + await bg.wait(30000); + expect(answers).toEqual([null]); + expect(bg.removedTabs).toEqual([bg.createdTabs[0].id]); + }); +}); + +describe('the toolbar\'s On with players for several frames', () => { + it('opens the one over the largest video first, also when a frame gives no size', async () => { + // A frame without a content script answers nothing: its size was undefined, the sort + // compared NaN, and the order came out arbitrary. The first player opened is the one + // that plays without a click (isMainPlayer). + const sizes = {1: 100, 2: undefined, 3: 300}; + bg = await loadBackground({ + tabs: [{id: 1, url: PAGE}], + onTabMessage: ({frameId, message}) => message.type === 'GET_VIDEO_SIZE' ? sizes[frameId] : undefined, + }); + for (const frameId of [1, 2, 3]) { + await bg.request({tabId: 1, frameId, parentFrameId: 0, url: `https://cdn.test/${frameId}/master.m3u8`}); + } + await bg.click(1); + await bg.wait(3000); + expect(bg.sent('OPEN_PLAYER').map((m) => m.frameId)).toEqual([3, 1, 2]); + }); +}); + +describe('the "use the player for stream links" redirect', () => { + it('takes a link up to its query or fragment, and no other character', async () => { + // The rule's `[\?|#]` held a literal `|`: a.mp4|anything was redirected too. + bg = await loadBackground({options: {playMP4URLs: true, playStreamURLs: true}}); + const rules = bg.dynamicRules.flatMap((update) => update.addRules || []); + const filter = (id) => new RegExp(rules.find((rule) => rule.id === id).condition.regexFilter); + expect(filter(1).test('https://cdn.test/a.mp4')).toBe(true); + expect(filter(1).test('https://cdn.test/a.mp4?t=1')).toBe(true); + expect(filter(1).test('https://cdn.test/a.mp4#t=1')).toBe(true); + expect(filter(1).test('https://cdn.test/a.mp4|t=1')).toBe(false); + expect(filter(2).test('https://cdn.test/a.m3u8?x=1')).toBe(true); + expect(filter(2).test('https://cdn.test/a.mpd|x')).toBe(false); + }); +}); From 93dd083a1ab753c851251cee66f4b1e3b5c69c9f Mon Sep 17 00:00:00 2001 From: Nawid Salehie <102778966+Nawid3333@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:51:16 +0200 Subject: [PATCH 04/22] fix(content): answer the caption scrape once every track was asked for; drop dead messages Three parts of the content.js review (issue #233): - SCRAPE_CAPTIONS counted each only as it asked for it, and a request that fails at once (a src XMLHttpRequest.open refuses) calls back inside the loop. With such a track first, the answer went out empty before the next track was even requested, and the player opened without captions it could have had. All tracks are counted first now. - Opening a player and removing it each sent SEND_TO_CONTENT 'active-state', which the background passed back to the same frame with destination 'custom'; nothing reads it since the YouTube site script was removed. Two round trips per player for nothing; gone. The other dormant hooks the issue lists (Config's custom* fields, OPEN_PLAYER's force/softReplace, the POST branch of httpRequest, the player's key_down/seek_to) cost nothing at run time and are upstream code; they stay. - types/messages.d.ts: GET_VIDEO_SIZE's answer is the video's visible area (a number), not {width, height}; OPEN_PLAYER carries attempt; noRedirect's comment had the top frame backwards. Proven by tests/unit/contentScript.test.mjs: "waits for every track when one request fails at once" and "sends the background nothing for the site scripts on opening and removing a player" fail against the previous content.js; GET_VIDEO_SIZE's tests pin the answer the type now describes. Co-Authored-By: Claude Opus 5.5 --- chrome/content.js | 63 +++++---------- tests/unit/contentDom.mjs | 36 +++++++-- tests/unit/contentScript.test.mjs | 128 ++++++++++++++++++++++++++++++ types/messages.d.ts | 21 +++-- 4 files changed, 192 insertions(+), 56 deletions(-) diff --git a/chrome/content.js b/chrome/content.js index 4905a056..539d77c2 100644 --- a/chrome/content.js +++ b/chrome/content.js @@ -40,7 +40,6 @@ const elementsChangedByFillscreen = []; const linkRequests = new Map(); let MiniplayerCooldown = 0; - let Activated = false; // Set when this frame is sent to the player (handlePlayerOpen's redirect). The frame is // not going away: the player takes it over, and asks the background for the sources // detected in it. @@ -333,14 +332,6 @@ }); } - async function sendToOtherContents(message) { - notifyBackground({ - type: 'SEND_TO_CONTENT', - data: message, - destination: 'custom', - }); - } - function handleContentMessage(request, sender, sendResponse) { const data = request.data; if (data.type === 'config') { @@ -486,13 +477,6 @@ }); pobj.resizeObserver.observe(iframe.parentNode); } - if (!Activated) { - Activated = true; - sendToOtherContents({ - type: 'active-state', - value: true, - }); - } }).catch((e) => { // Whatever went wrong, the background hears back: without an answer it kept the // frame's player opening, and opened no player there again until a navigation. A @@ -668,30 +652,31 @@ const ScrapedTrackKinds = ['subtitles', 'captions']; function handleCaptionsScrape(request, sender, sendResponse) { - const trackElements = querySelectorAllIncludingShadows('track'); - let pending = 0; + // Every track is counted before any is asked for: a request that fails at once (a src + // XMLHttpRequest refuses) answers inside the loop, and the answer went out then, with + // the tracks after it never asked for. + const trackElements = querySelectorAllIncludingShadows('track') + .filter((track) => track.src && ScrapedTrackKinds.includes(track.kind)); + const pending = trackElements.length; let done = 0; const tracks = []; for (let i = 0; i < trackElements.length; i++) { const track = trackElements[i]; - if (track.src && ScrapedTrackKinds.includes(track.kind)) { - pending++; - const source = track.src; - httpRequest(source, (err, req, body) => { - done++; - if (body) { - tracks.push({ - data: body, - source: source, - label: track.label, - language: track.srclang, - }); - } - if (done === pending) sendResponse(tracks); - }); - } + const source = track.src; + httpRequest(source, (err, req, body) => { + done++; + if (body) { + tracks.push({ + data: body, + source: source, + label: track.label, + language: track.srclang, + }); + } + if (done === pending) sendResponse(tracks); + }); } - if (done === pending) sendResponse(tracks); + if (pending === 0) sendResponse(tracks); return true; } @@ -753,14 +738,6 @@ }); } }); - - if (Activated) { - Activated = false; - sendToOtherContents({ - type: 'active-state', - value: false, - }); - } } /** diff --git a/tests/unit/contentDom.mjs b/tests/unit/contentDom.mjs index ac3f3f61..72af99cd 100644 --- a/tests/unit/contentDom.mjs +++ b/tests/unit/contentDom.mjs @@ -314,9 +314,17 @@ export class FakeDocument { removeEventListener() {} - /** Runs the MutationObservers watching the document (synchronously, for the tests). */ + /** + * Runs the MutationObservers watching the document once the current task is done, as + * Firefox does (with no records: the scripts' own lists are what is tested). + */ mutated() { - for (const observer of [...this.observers]) observer.callback([], observer); + if (this.mutationQueued) return; + this.mutationQueued = true; + queueMicrotask(() => { + this.mutationQueued = false; + for (const observer of [...this.observers]) observer.callback([], observer); + }); } } @@ -325,9 +333,10 @@ export class FakeDocument { * @param {Object} [options] * @param {string} [options.hostname] - The page's host. * @param {Array} [options.entries] - Its Resource Timing entries. + * @param {Object} [options.responses] - What its requests get, by URL. * @return {Object} The page: its document, what content.js sent, and how to talk to it. */ -export function loadContentScript({hostname = 'site.example', entries = []} = {}) { +export function loadContentScript({hostname = 'site.example', entries = [], responses = {}} = {}) { const document = new FakeDocument(); const sent = []; let onMessage = null; @@ -386,9 +395,22 @@ export function loadContentScript({hostname = 'site.example', entries = []} = {} MutationObserver: FakeMutationObserver, ResizeObserver: FakeResizeObserver, IntersectionObserver: FakeIntersectionObserver, + // Answers with `responses` (URL -> text; 404 for any other), a moment after send(). A + // URL with a bad host makes open() throw, as Firefox's does. XMLHttpRequest: class { - open() { - throw new Error('contentDom: no network'); + open(method, url) { + if (url.startsWith('http://[')) throw new SyntaxError('An invalid or illegal string was specified'); + this.url = url; + } + setRequestHeader() {} + abort() {} + send() { + Promise.resolve().then(() => { + this.readyState = 4; + this.status = this.url in responses ? 200 : 404; + this.responseText = responses[this.url] || ''; + this.onreadystatechange(); + }); } }, console: {log() {}, error() {}, warn() {}, debug() {}, info() {}}, @@ -447,7 +469,9 @@ export function loadContentScript({hostname = 'site.example', entries = []} = {} */ send(request) { return new Promise((resolve) => { - const async = onMessage(request, {id: 'test'}, resolve); + // Copied when sent, as Firefox copies it: what the script changes after is not in it. + const sendResponse = (response) => resolve(response === undefined ? undefined : structuredClone(response)); + const async = onMessage(request, {id: 'test'}, sendResponse); if (async !== true) resolve(undefined); }); }, diff --git a/tests/unit/contentScript.test.mjs b/tests/unit/contentScript.test.mjs index fe51519c..fb0ba0fb 100644 --- a/tests/unit/contentScript.test.mjs +++ b/tests/unit/contentScript.test.mjs @@ -32,3 +32,131 @@ describe('IS_PLAYER_OPENER', () => { expect(pageName(loadContentScript())).not.toBe(pageName(loadContentScript())); }); }); + +/** + * Puts a in the page's body. + * @param {Object} page - From loadContentScript. + * @param {string} src - Its URL. + * @param {string} [kind] - Its kind. + * @return {Object} The track. + */ +function addTrack(page, src, kind = 'subtitles') { + const track = page.document.createElement('track'); + Object.assign(track, {kind, label: src, srclang: 'en'}); + track.src = src; + page.document.body.appendChild(track); + return track; +} + +// SCRAPE_CAPTIONS reads the page's files for the player. A request that failed at +// once (a src XMLHttpRequest refuses) answered inside the loop, before the next track was +// even asked for: the player opened without a track it could have had (#233). +describe('SCRAPE_CAPTIONS', () => { + it('waits for every track when one request fails at once', async () => { + const page = loadContentScript({responses: {'https://cdn.example/en.vtt': 'WEBVTT'}}); + addTrack(page, 'http://[bad'); + addTrack(page, 'https://cdn.example/en.vtt'); + const tracks = await page.send({type: 'SCRAPE_CAPTIONS'}); + expect(tracks.map((track) => track.source)).toEqual(['https://cdn.example/en.vtt']); + expect(tracks[0].data).toBe('WEBVTT'); + }); + + it('answers once, with nothing, for a page without tracks', async () => { + const page = loadContentScript(); + expect(await page.send({type: 'SCRAPE_CAPTIONS'})).toEqual([]); + }); + + it('leaves out tracks that are not text to read', async () => { + const page = loadContentScript({responses: {'https://cdn.example/ch.vtt': 'WEBVTT', 'https://cdn.example/cc.vtt': 'WEBVTT'}}); + addTrack(page, 'https://cdn.example/ch.vtt', 'chapters'); + addTrack(page, 'https://cdn.example/cc.vtt', 'captions'); + const tracks = await page.send({type: 'SCRAPE_CAPTIONS'}); + expect(tracks.map((track) => track.source)).toEqual(['https://cdn.example/cc.vtt']); + }); +}); + +const PLAYER_URL = 'moz-extension://test/player/index.html'; + +/** + * A page with a 640x360 video in a wrapper of the same size (#wrap), which a player + * replaces. + * @param {Object} [options] - For loadContentScript. + * @return {{page: Object, wrap: Object, video: Object}} + */ +function pageWithVideo(options) { + const page = loadContentScript(options); + const {document} = page; + document.documentElement.rect = {x: 0, y: 0, width: 1280, height: 2000}; + document.body.rect = {x: 0, y: 0, width: 1280, height: 2000}; + const wrap = document.createElement('div'); + wrap.id = 'wrap'; + wrap.rect = {x: 0, y: 0, width: 640, height: 360}; + const video = document.createElement('video'); + video.rect = {x: 0, y: 0, width: 640, height: 360}; + wrap.appendChild(video); + document.body.appendChild(wrap); + return {page, wrap, video}; +} + +/** + * Opens a player in the page, as the background does. + * @param {Object} page - From loadContentScript. + * @return {Promise<{answer: *, iframe: Object}>} content.js's answer, and the player's + * iframe. + */ +async function openPlayer(page) { + const answer = await page.send({type: 'OPEN_PLAYER', url: PLAYER_URL, noRedirect: true, frameId: 0, parentFrameId: -1, attempt: 1}); + const iframe = page.document.querySelectorAll('iframe').find((f) => f.src.startsWith(PLAYER_URL)); + return {answer, iframe}; +} + +/** + * Links a player that loaded in an iframe to it, as the background and the player do + * (FRAME_LINK_RECEIVER here, then the player posts the key to its parent). + * @param {Object} page - From loadContentScript. + * @param {Object} iframe - The player's iframe. + * @param {number} frameId - The player's frame. + */ +async function linkPlayer(page, iframe, frameId) { + const key = 'key-' + Math.random(); + await page.send({type: 'FRAME_LINK_RECEIVER', key, frameId}); + page.postMessage(key, iframe.contentWindow); + await Promise.resolve(); +} + +// Opening a player sent the background a message for the site scripts' 'active-state', +// and removing it another, which it passed back to the same frame, where nothing reads +// them (the YouTube script that did is gone): two round trips per player for nothing. +describe('OPEN_PLAYER', () => { + it('puts the player in place of the page\'s video', async () => { + const {page, wrap} = pageWithVideo(); + const {answer, iframe} = await openPlayer(page); + expect(answer).toBe('replace'); + expect(iframe.parentNode).toBe(page.document.body); + // The soft replace: the page's player is kept, at no size, and the iframe has its id. + expect(wrap.style.width).toBe('0px'); + expect(iframe.id).toBe('wrap'); + }); + + it('sends the background nothing for the site scripts on opening and removing a player', async () => { + const {page} = pageWithVideo(); + const {iframe} = await openPlayer(page); + await linkPlayer(page, iframe, 5); + expect(await page.send({type: 'REMOVE_PLAYERS'})).toBe('ok'); + expect(page.sent.map((message) => message.type).filter((type) => type === 'SEND_TO_CONTENT')).toEqual([]); + }); +}); + +// types/messages.d.ts described the answer as {width, height}; it is the visible area, a +// number the background sorts frames by (#233). +describe('GET_VIDEO_SIZE', () => { + it('answers the largest video\'s visible area, as a number', async () => { + const {page} = pageWithVideo(); + expect(await page.send({type: 'GET_VIDEO_SIZE'})).toBe(640 * 360); + }); + + it('answers 0 for a frame without a video', async () => { + const page = loadContentScript(); + expect(await page.send({type: 'GET_VIDEO_SIZE'})).toBe(0); + }); +}); diff --git a/types/messages.d.ts b/types/messages.d.ts index 7a1e1484..d66c340a 100644 --- a/types/messages.d.ts +++ b/types/messages.d.ts @@ -45,11 +45,17 @@ interface FSOpenPlayer extends FSMessageBase { type: 'OPEN_PLAYER'; /** Extension URL of the player page. */ url: string; - /** True only for the top frame, which navigates rather than redirects. */ + /** + * True only for the top frame, which never goes to the player itself: it puts the + * player in place of its video, or lays it over the page. Another frame whose video + * fills it, and which cannot go fullscreen, navigates to the player instead. + */ noRedirect: boolean; frameId: number; /** -1 when the frame has no parent. */ parentFrameId: number; + /** Numbers this opening; content.js names it in PLAYER_OPEN_GONE. */ + attempt: number; } /** The player announcing it has finished loading. */ @@ -97,16 +103,17 @@ interface FSHeaderCommand { value?: string; } -/** Background asks a frame for its video element dimensions. */ +/** Background asks a frame how big its largest visible video is. */ interface FSGetVideoSize extends FSMessageBase { type: 'GET_VIDEO_SIZE'; } -/** Reply to GET_VIDEO_SIZE. */ -interface FSVideoSize { - width: number; - height: number; -} +/** + * Reply to GET_VIDEO_SIZE: the video's visible area in CSS pixels (width x height x the + * share of it on screen); 0 when the frame has none. The background opens the player in + * the frame with the biggest. + */ +type FSVideoSizeReply = number; /** * Any message crossing a context boundary. From 5929060ce6da66c596ea025018070750f0c077c8 Mon Sep 17 00:00:00 2001 From: Nawid Salehie <102778966+Nawid3333@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:53:56 +0200 Subject: [PATCH 05/22] fix(mpv): a stream found while the play's first one is read is checked next (#282) Shortcut MPV, the user presses play before the page asks for the video's stream. The first stream after the play took the play's slot while its length was read; a second one in that time (the episode's manifest 200 ms after an ad's) found the slot taken and was only tracked. When the first turned out to be another video's length, the slot came back, and nothing looked at the second again: the play sent nothing, and said nothing. Streams detected while one is checked now wait in the tab's mpvPlayChecking and are checked in turn (sendPendingPlay); the first that can be the video's goes. A new page, a reload or MPV started again drop the queue with the play. Tests: tests/unit/backgroundMpv.test.mjs. "an ad's came first" fails on the old code (nothing sent) and with the queue push taken out; "the next page's play" fails when the URL change does not drop the queue. Co-Authored-By: Claude Opus 5.5 --- chrome/background/TabTracker.mjs | 4 + chrome/background/background.mjs | 78 +++++++++++++----- tests/unit/backgroundMpv.test.mjs | 129 ++++++++++++++++++++++++++++++ 3 files changed, 191 insertions(+), 20 deletions(-) create mode 100644 tests/unit/backgroundMpv.test.mjs diff --git a/chrome/background/TabTracker.mjs b/chrome/background/TabTracker.mjs index bb923d41..98c3f002 100644 --- a/chrome/background/TabTracker.mjs +++ b/chrome/background/TabTracker.mjs @@ -173,6 +173,10 @@ export class TabHolder { // What that play plays (content.js's playedVideo): a stream plainly another length is // not its stream. this.mpvPlayedVideo = null; + // While a stream is checked for such a play, the streams detected meanwhile, to be + // checked next (background.mjs sendPendingPlay); null otherwise. + /** @type {?Array} */ + this.mpvPlayChecking = null; } /** diff --git a/chrome/background/background.mjs b/chrome/background/background.mjs index 1d8ceb53..884374cd 100644 --- a/chrome/background/background.mjs +++ b/chrome/background/background.mjs @@ -235,6 +235,7 @@ async function startMpv(tab, onPlay = false) { tab.mpvLastPlaySend = null; tab.mpvPlayPendingUntil = 0; tab.mpvPlayedVideo = null; + tab.mpvPlayChecking = null; chrome.tabs.sendMessage(tab.tabId, { type: MessageTypes.MPV_REPORT_PLAYING, }, () => { @@ -531,6 +532,7 @@ chrome.tabs.onUpdated.addListener(async (tabid, changeInfo, tabobj) => { tab.mpvError = null; tab.mpvPlayPendingUntil = 0; tab.mpvPlayedVideo = null; + tab.mpvPlayChecking = null; chrome.tabs.sendMessage(tabid, { type: MessageTypes.REMOVE_PLAYERS, @@ -1864,26 +1866,15 @@ async function onSourceRecieved(details, frame, mode) { // The shortcut's MPV: streams are only tracked, for onUserPlay to pick // from - unless the user already pressed play and the player asked // for its stream only afterwards, in which case this is that stream. - if (frame.tab.mpvPlayPendingUntil > Date.now()) { - const tab = frame.tab; - const until = tab.mpvPlayPendingUntil; - const page = {url: tab.url, document: frame.documentKey}; - // Taken now, so another stream found while this one's length is read waits. - tab.mpvPlayPendingUntil = 0; - // Its length, read above, tells whether it is the video the user started: the - // first stream after the play went unchecked, a preview's or an ad's as well. - await Lengths.settle(() => [{url, mode, headers: customHeaders}], SourceLengthWaitMs); - if (tab.url !== page.url || frame.documentKey !== page.document) { - return; - } - if (StreamPick.conflicts(tab.mpvPlayedVideo, Lengths.lengthOf(url))) { - // Another video's: the next one may be the video's, while the wait lasts. - tab.mpvPlayPendingUntil = until; - return; - } - if (tab.isOn && tab.isMpv && tab.mpvOnPlay) { - sendPlayedToMpv(tab, {url, headers: customHeaders}); - } + const tab = frame.tab; + const candidate = {frame, document: frame.documentKey, url, mode, headers: customHeaders}; + if (tab.mpvPlayChecking) { + // Another stream is being checked for the play: this one is next, should that one + // be another video's. Dropped, an ad's manifest and then the episode's left the + // play with nothing sent. + tab.mpvPlayChecking.push(candidate); + } else if (tab.mpvPlayPendingUntil > Date.now()) { + await sendPendingPlay(tab, candidate); } return; } @@ -2174,6 +2165,53 @@ async function onUserPlay(sender, src, video) { } } +/** + * The shortcut's MPV, after a play whose stream was not detected yet (onUserPlay): checks + * the streams detected since, in the order the page asked for them, and sends the first + * whose length can be the video's. The length tells: the first stream after the play + * went unchecked once, a preview's or an ad's as well. Streams found while one is checked + * wait their turn in tab.mpvPlayChecking. None that fits: the play waits on for the next, + * while its time lasts. + * + * @param {TabHolder} tab - The tab the video plays in. + * @param {{frame: FrameHolder, document: ?string, url: string, mode: string, headers: *}} first - + * The stream that came first, and the page its frame showed then. + * @return {Promise} + */ +async function sendPendingPlay(tab, first) { + const until = tab.mpvPlayPendingUntil; + const url = tab.url; + const queue = [first]; + tab.mpvPlayChecking = queue; + tab.mpvPlayPendingUntil = 0; + try { + while (queue.length > 0) { + const candidate = /** @type {typeof first} */ (queue.shift()); + // Its length, being read since it was detected (onSourceRecieved's probe). + await Lengths.settle(() => [candidate], SourceLengthWaitMs); + // A new page, a reload or MPV started again drop the play (and this queue). + if (tab.mpvPlayChecking !== queue || tab.url !== url) { + return; + } + // Its frame went on to another page meanwhile, or its length is another video's. + if (candidate.frame.documentKey !== candidate.document || + StreamPick.conflicts(tab.mpvPlayedVideo, Lengths.lengthOf(candidate.url))) { + continue; + } + if (tab.isOn && tab.isMpv && tab.mpvOnPlay) { + sendPlayedToMpv(tab, candidate); + } + return; + } + // Each was another video's: the next one may be the video's, while the wait lasts. + tab.mpvPlayPendingUntil = until; + } finally { + if (tab.mpvPlayChecking === queue) { + tab.mpvPlayChecking = null; + } + } +} + /** * The detected source a started video plays. Its own URL first: a progressive * file, often preloaded long before the click, which no later request would diff --git a/tests/unit/backgroundMpv.test.mjs b/tests/unit/backgroundMpv.test.mjs new file mode 100644 index 00000000..a0389f27 --- /dev/null +++ b/tests/unit/backgroundMpv.test.mjs @@ -0,0 +1,129 @@ +import {afterEach, describe, expect, it, vi} from 'vitest'; + +import {hlsPlaylist, loadBackground, response} from './backgroundHarness.mjs'; + +// MPV mode through the background's real listeners: the shortcut's MPV (only a video the +// user starts goes to mpv), the allowlist's (a page's first stream goes by itself), and +// what of it outlives the event page. + +const PAGE = 'https://site.test/watch/1'; +const AD = 'https://cdn.test/ad/master.m3u8'; +const EPISODE = 'https://cdn.test/episode/master.m3u8'; + +let bg; + +afterEach(() => { + bg?.unload(); + bg = null; + vi.restoreAllMocks(); +}); + +/** + * The network: each playlist with its length, answered after a delay. + * @param {Object} lengths - Seconds by URL. + * @param {number} [delayMs] - How long each answer takes. + * @return {function(string): Promise} fetch() for loadBackground. + */ +function playlists(lengths, delayMs = 300) { + return (url) => new Promise((resolve) => { + setTimeout(() => { + resolve(url in lengths ? response(hlsPlaylist(lengths[url])) : response('', 404)); + }, delayMs); + }); +} + +/** + * Turns on the shortcut's MPV in tab 1, and has the user start a video there that plays + * for the given time, whose stream the page has not asked for yet (an MSE player's blob:). + * @param {number} duration - The video's length. + * @return {Promise} + */ +async function playWithShortcutMpv(duration) { + await bg.command('toggle_mpv', 1); + expect(bg.session['tabState:1']).toMatchObject({isOn: true, isMpv: true, mpvOnPlay: true}); + await bg.message({type: 'MPV_USER_PLAY', src: 'blob:https://site.test/1', video: {src: 'blob:https://site.test/1', duration}}, + {tabId: 1, frameId: 0}); +} + +describe('the shortcut\'s MPV, a play before its stream', () => { + it('sends the video\'s stream when an ad\'s came first and the video\'s while it was read', async () => { + // The ad's manifest took the play's slot while its length was read; the episode's, + // 200 ms later, found the slot taken and was only tracked. The ad was another video's + // length, the slot came back, and nothing looked at the episode again. + bg = await loadBackground({ + options: {mpvMode: true}, + tabs: [{id: 1, url: PAGE}], + fetch: playlists({[AD]: 30, [EPISODE]: 1400}), + }); + await playWithShortcutMpv(1400); + await bg.request({tabId: 1, url: AD}); + await bg.wait(200); + await bg.request({tabId: 1, url: EPISODE}); + await bg.wait(3000); + expect(bg.toMpv()).toEqual([EPISODE]); + }); + + it('sends the first stream that can be the video, and only that one', async () => { + bg = await loadBackground({ + options: {mpvMode: true}, + tabs: [{id: 1, url: PAGE}], + fetch: playlists({[AD]: 1400, [EPISODE]: 1400}), + }); + await playWithShortcutMpv(1400); + await bg.request({tabId: 1, url: AD}); + await bg.wait(100); + await bg.request({tabId: 1, url: EPISODE}); + await bg.wait(3000); + expect(bg.toMpv()).toEqual([AD]); + }); + + it('waits on when none of them can be the video, and takes the next', async () => { + const LATER = 'https://cdn.test/later/master.m3u8'; + bg = await loadBackground({ + options: {mpvMode: true}, + tabs: [{id: 1, url: PAGE}], + fetch: playlists({[AD]: 30, [EPISODE]: 45, [LATER]: 1400}), + }); + await playWithShortcutMpv(1400); + await bg.request({tabId: 1, url: AD}); + await bg.wait(100); + await bg.request({tabId: 1, url: EPISODE}); + await bg.wait(3000); + expect(bg.toMpv()).toEqual([]); + await bg.request({tabId: 1, url: LATER}); + await bg.wait(3000); + expect(bg.toMpv()).toEqual([LATER]); + }); + + it('drops what was waiting when the tab goes to another page', async () => { + bg = await loadBackground({ + options: {mpvMode: true}, + tabs: [{id: 1, url: PAGE}], + fetch: playlists({[AD]: 30, [EPISODE]: 1400}), + }); + await playWithShortcutMpv(1400); + await bg.request({tabId: 1, url: AD}); + await bg.wait(100); + await bg.request({tabId: 1, url: EPISODE}); + await bg.navigated(1, 'https://site.test/watch/2'); + await bg.wait(3000); + expect(bg.toMpv()).toEqual([]); + }); + + it('takes the next page\'s play by itself while the last page\'s stream is still read', async () => { + bg = await loadBackground({ + options: {mpvMode: true}, + tabs: [{id: 1, url: PAGE}], + fetch: playlists({[AD]: 30, [EPISODE]: 1400}), + }); + await playWithShortcutMpv(1400); + await bg.request({tabId: 1, url: AD}); + await bg.navigated(1, 'https://site.test/watch/2'); + await bg.frameAdded(1, 0, 'https://site.test/watch/2', 'page-2'); + await bg.message({type: 'MPV_USER_PLAY', src: 'blob:https://site.test/2', video: {src: 'blob:https://site.test/2', duration: 1400}}, + {tabId: 1, frameId: 0}); + await bg.request({tabId: 1, url: EPISODE}); + await bg.wait(3000); + expect(bg.toMpv()).toEqual([EPISODE]); + }); +}); From 863f14328687c4fd15e8ec016607a3c652c90c24 Mon Sep 17 00:00:00 2001 From: Nawid Salehie <102778966+Nawid3333@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:55:47 +0200 Subject: [PATCH 06/22] fix(mpv): the hand-off's "!" and a waiting play outlive the event page (#158) Firefox stops an idle background, and restarts it for the next event. The tab state that has to survive that is kept in storage.session (PersistedTabFields), but four MPV fields were not: - mpvError: a woken background redraws every tab's toolbar button from what it knows, so the "!" of a failed hand-off went away with the failure unresolved. - mpvPlayPendingUntil / mpvPlayedVideo: a play in shortcut MPV whose stream the player asks for only afterwards; after a restart that stream was only tracked, and nothing went to mpv. - mpvLastPlaySend: the same video played again within 10 s only pauses the page; after a restart it opened a second mpv window. They are saved where they change and put back with the rest. Tests: tests/unit/backgroundMpv.test.mjs loads the background twice with the same session storage. The three restart tests fail with the fields left out of PersistedTabFields; "no '!' once a later hand-off worked" fails without the save in setMpvError. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +- chrome/background/TabTracker.mjs | 8 ++- chrome/background/background.mjs | 6 +++ tests/unit/backgroundMpv.test.mjs | 90 +++++++++++++++++++++++++++++++ 4 files changed, 105 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 63931934..1103a7e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -618,9 +618,11 @@ then woke a fresh background with no record of the user's toolbar choice, so an allowlisted site auto-started MPV again even after the user had picked the in-page player or Off. The same loss dropped the one-hand-off-per-page latch, so the page's next stream request after a wake opened a second mpv window. -`TabTracker.saveTabState` now writes `url`, `isOn`, `isMpv`, `regexMatched`, +`TabTracker.saveTabState` now writes `url`, `isOn`, `isMpv`, `mpvOnPlay`, `regexMatched`, `mpvMatched` and `mpvAutoOpened` per tab to `chrome.storage.session` whenever one of them changes (toolbar click, URL change, mpv hand-off and its failure), +and since 2026-10-03 (#158) the failure shown as "!" (`mpvError`) and the shortcut's +waiting play (`mpvPlayPendingUntil`, `mpvPlayedVideo`, `mpvLastPlaySend`), and `restoreTabStates` puts them back inside `ensureOptions()`, which every state-changing listener already awaits. A new field that has to survive a wake goes into `PersistedTabFields`, and every place that sets it saves. Within one diff --git a/chrome/background/TabTracker.mjs b/chrome/background/TabTracker.mjs index 98c3f002..f33a5d6b 100644 --- a/chrome/background/TabTracker.mjs +++ b/chrome/background/TabTracker.mjs @@ -437,10 +437,14 @@ const TabStateKeyPrefix = 'tabState:'; // the same lifetime tab ids have) so a woken background still knows it. // mpvAutoOpened goes with it: without it the page's next stream request after a // wake opens a second mpv window for a page already handed off. mpvOnPlay too, -// or a woken background forwards the page's first stream after all. The rest of a +// or a woken background forwards the page's first stream after all. mpvError, or the +// toolbar's "!" for a failed hand-off went at the wake. And a play still waiting for its +// stream (mpvPlayPendingUntil, mpvPlayedVideo) and the last one sent (mpvLastPlaySend, +// which keeps a player's second play() from opening a second window). The rest of a // TabHolder - frames, detected sources - describes the current page and is // rebuilt as that page makes requests. -const PersistedTabFields = ['url', 'isOn', 'isMpv', 'mpvOnPlay', 'regexMatched', 'mpvMatched', 'mpvAutoOpened']; +const PersistedTabFields = ['url', 'isOn', 'isMpv', 'mpvOnPlay', 'regexMatched', 'mpvMatched', 'mpvAutoOpened', + 'mpvError', 'mpvPlayPendingUntil', 'mpvPlayedVideo', 'mpvLastPlaySend']; export class TabTracker { constructor() { diff --git a/chrome/background/background.mjs b/chrome/background/background.mjs index 884374cd..c5ee27f7 100644 --- a/chrome/background/background.mjs +++ b/chrome/background/background.mjs @@ -2052,6 +2052,7 @@ function setMpvError(tab, result) { if (tab.mpvError !== error) { tab.mpvError = error; BackgroundUtils.updateTabIcon(tab); + Tabs.saveTabState(tab); } } @@ -2162,6 +2163,7 @@ async function onUserPlay(sender, src, video) { // page asks for decides, when its length can be the video's (onSourceRecieved). tab.mpvPlayedVideo = video || null; tab.mpvPlayPendingUntil = Date.now() + MpvPlayPendingMs; + Tabs.saveTabState(tab); } } @@ -2184,6 +2186,7 @@ async function sendPendingPlay(tab, first) { const queue = [first]; tab.mpvPlayChecking = queue; tab.mpvPlayPendingUntil = 0; + Tabs.saveTabState(tab); try { while (queue.length > 0) { const candidate = /** @type {typeof first} */ (queue.shift()); @@ -2205,6 +2208,7 @@ async function sendPendingPlay(tab, first) { } // Each was another video's: the next one may be the video's, while the wait lasts. tab.mpvPlayPendingUntil = until; + Tabs.saveTabState(tab); } finally { if (tab.mpvPlayChecking === queue) { tab.mpvPlayChecking = null; @@ -2330,6 +2334,7 @@ function sendPlayedToMpv(tab, source) { } tab.mpvLastPlaySend = {url: source.url, time: now}; + Tabs.saveTabState(tab); tabTitle(tab.tabId).then((title) => Mpv.openStream(source.url, null, source.headers, resolveMpvContentType(null, tab.url), tab.url, title)).then((result) => { if (Logging) console.log('[MPV] user play result:', source.url, JSON.stringify(result)); @@ -2339,6 +2344,7 @@ function sendPlayedToMpv(tab, source) { } else if (tab.mpvLastPlaySend && tab.mpvLastPlaySend.url === source.url) { // The host never launched mpv, so let the next play try again. tab.mpvLastPlaySend = null; + Tabs.saveTabState(tab); } }).catch((e) => { console.error('Handing the played video to mpv failed', e); diff --git a/tests/unit/backgroundMpv.test.mjs b/tests/unit/backgroundMpv.test.mjs index a0389f27..cd597c0b 100644 --- a/tests/unit/backgroundMpv.test.mjs +++ b/tests/unit/backgroundMpv.test.mjs @@ -110,6 +110,51 @@ describe('the shortcut\'s MPV, a play before its stream', () => { expect(bg.toMpv()).toEqual([]); }); + it('still takes the video\'s stream after the event page restarted', async () => { + // Firefox stops an idle background, and a restart for any reason took the waiting + // play with it: the stream the player asked for next was only tracked. + bg = await loadBackground({ + options: {mpvMode: true}, + tabs: [{id: 1, url: PAGE}], + fetch: playlists({[EPISODE]: 1400}), + }); + await playWithShortcutMpv(1400); + const session = bg.session; + bg.unload(); + + bg = await loadBackground({options: {mpvMode: true}, tabs: [{id: 1, url: PAGE}], session, + fetch: playlists({[EPISODE]: 1400})}); + await bg.request({tabId: 1, url: EPISODE}); + await bg.wait(3000); + expect(bg.toMpv()).toEqual([EPISODE]); + }); + + it('sends a video once when the player plays it again after a restart', async () => { + // A player calls play() twice, or resumes after the page was paused: within 10 s the + // same stream only pauses the page again. Forgotten at a restart, it opened a second + // mpv window. + bg = await loadBackground({ + options: {mpvMode: true}, + tabs: [{id: 1, url: PAGE}], + fetch: playlists({[EPISODE]: 1400}), + }); + await playWithShortcutMpv(1400); + await bg.request({tabId: 1, url: EPISODE}); + await bg.wait(1000); + expect(bg.toMpv()).toEqual([EPISODE]); + const session = bg.session; + bg.unload(); + + bg = await loadBackground({options: {mpvMode: true}, tabs: [{id: 1, url: PAGE}], session, + fetch: playlists({[EPISODE]: 1400})}); + await bg.message({type: 'MPV_USER_PLAY', src: 'blob:https://site.test/1', video: {src: 'blob:https://site.test/1', duration: 1400}}, + {tabId: 1, frameId: 0}); + await bg.request({tabId: 1, url: EPISODE}); + await bg.wait(1000); + expect(bg.toMpv()).toEqual([]); + expect(bg.sent('PAUSE_MEDIA')).toHaveLength(1); + }); + it('takes the next page\'s play by itself while the last page\'s stream is still read', async () => { bg = await loadBackground({ options: {mpvMode: true}, @@ -127,3 +172,48 @@ describe('the shortcut\'s MPV, a play before its stream', () => { expect(bg.toMpv()).toEqual([EPISODE]); }); }); + +describe('a failed hand-off', () => { + it('keeps the toolbar\'s "!" after the event page restarted', async () => { + // The badge is the browser's, but a woken background redraws every tab's button from + // what it knows, and it did not know of the failure any more. + bg = await loadBackground({ + options: {mpvMode: true, mpvAllowlist: ['https://site.test/']}, + tabs: [{id: 1, url: PAGE}], + onNative: () => ({ok: false, error: 'mpv executable not found'}), + }); + await bg.navigated(1, PAGE); + await bg.request({tabId: 1, url: EPISODE}); + expect(bg.toMpv()).toEqual([EPISODE]); + expect(bg.badges.get(1)).toBe('!'); + const session = bg.session; + bg.unload(); + + bg = await loadBackground({options: {mpvMode: true, mpvAllowlist: ['https://site.test/']}, + tabs: [{id: 1, url: PAGE}], session}); + expect(bg.badges.get(1)).toBe('!'); + expect(bg.titles.get(1)).toBe('FastStream - MPV - the stream did not open: mpv executable not found'); + }); + + it('shows no "!" after a restart once a later hand-off worked', async () => { + let answer = {ok: false, error: 'mpv executable not found'}; + bg = await loadBackground({ + options: {mpvMode: true, mpvAllowlist: ['https://site.test/']}, + tabs: [{id: 1, url: PAGE}], + onNative: () => answer, + }); + await bg.navigated(1, PAGE); + await bg.request({tabId: 1, url: AD}); + expect(bg.badges.get(1)).toBe('!'); + answer = {ok: true}; + await bg.request({tabId: 1, url: EPISODE}); + expect(bg.toMpv()).toEqual([AD, EPISODE]); + expect(bg.badges.get(1)).toBe(''); + const session = bg.session; + bg.unload(); + + bg = await loadBackground({options: {mpvMode: true, mpvAllowlist: ['https://site.test/']}, + tabs: [{id: 1, url: PAGE}], session}); + expect(bg.badges.get(1)).toBe(''); + }); +}); From 655a19d4863401c6f6094793d9c50a20dad6d6e1 Mon Sep 17 00:00:00 2001 From: Nawid Salehie <102778966+Nawid3333@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:57:17 +0200 Subject: [PATCH 07/22] perf(content): windowed fullscreen in linear time, and leave the alone fillScreenIframe (windowed fullscreen, and the overlay player) hides every element of the page but the player's ancestors. Each element was looked up in the array of elements already changed, and undoFillScreenIframe took each one out with a splice inside a forward loop, plus an includes() on the whitelist: quadratic both ways. On a page of 50,000 elements, entering and leaving took 3.7 s on the page's main thread in the unit test, inside the message handler. The record is a Map now (it is only ever looked up by element), the whitelist a Set, and the ancestor trace a Set. The walk also put display: none on every , `; +// A site's own dialog, opened while the player is up: a backdrop over the whole page with +// the dialog in it, the geometry of an ad layer. It stays (#226). The ad that opens with +// it, over the player and above the dialog's layer, goes: that shows the guard looked +// again after the dialog opened. +const dialogPage = (t) => `dialog + +
+
+ +
+`; + // The embedding page: the player's iframe fills it, and its bar lies on top. const embeddingPage = (t) => `embedding