diff --git a/chrome/background/background.mjs b/chrome/background/background.mjs index 5203ae02..cddabc3a 100644 --- a/chrome/background/background.mjs +++ b/chrome/background/background.mjs @@ -957,25 +957,67 @@ function fromOwnPage(sender) { return String(sender.url || '').startsWith(chrome.runtime.getURL('')); } +// Who may send each message the listener below answers. 'own': FastStream's own pages only +// (the player, also framed in a site, and the options page: fromOwnPage). 'any': its content +// scripts too, which run in every page. A content script sends none of the 'own' ones, so one +// that does is not FastStream's to obey: what they do - the options read anew, the mpv test, +// header rules, downloads, the tab's sources cleared, the next episode - is the player's or +// the options page's to ask for. Some of them checked their sender where they were answered and +// others not (LOAD_OPTIONS, MPV_TEST, CLEAR_SOURCES, REQUEST_PLAYLIST_NAVIGATION: glm's review, +// 2026-10-28); every type the listener answers is listed here (backgroundMessages.test.mjs). +/** @type {Object} */ +export const MessageSenders = { + [MessageTypes.PING]: 'own', + [MessageTypes.VPN_STATUS]: 'own', + [MessageTypes.VPN_OPEN_PERMISSION]: 'own', + [MessageTypes.LOAD_OPTIONS]: 'own', + [MessageTypes.MPV_TEST]: 'own', + [MessageTypes.MPV_OPEN]: 'own', + [MessageTypes.PLAYER_LOADED]: 'own', + [MessageTypes.PLAYER_PLAYING]: 'own', + [MessageTypes.REQUEST_FRESH_LINK]: 'own', + [MessageTypes.BUFFER_STASH]: 'own', + [MessageTypes.BUFFER_TAKE]: 'own', + [MessageTypes.REQUEST_SOURCES]: 'own', + [MessageTypes.CLEAR_SOURCES]: 'own', + [MessageTypes.SET_HEADERS]: 'own', + [MessageTypes.DOWNLOAD]: 'own', + [MessageTypes.REQUEST_FULLSCREEN]: 'own', + [MessageTypes.REQUEST_WINDOWED_FULLSCREEN]: 'own', + [MessageTypes.REQUEST_KEYBOARD]: 'own', + [MessageTypes.REQUEST_WINDOW_FULLSCREEN]: 'own', + [MessageTypes.REQUEST_MINIPLAYER]: 'own', + [MessageTypes.REQUEST_PLAYLIST_NAVIGATION]: 'own', + [MessageTypes.REPORT_AUTOPLAY_PROBLEM]: 'own', + [MessageTypes.REQUEST_PLAYLIST_POLL]: 'own', + [MessageTypes.POPUP_GUARD_ARM]: 'any', + [MessageTypes.MPV_USER_PLAY]: 'any', + [MessageTypes.SHORTCUT_CANCELLED]: 'any', + [MessageTypes.FRAME_ADDED]: 'any', + [MessageTypes.FRAME_REMOVED]: 'any', + [MessageTypes.PLAYER_OPEN_GONE]: 'any', + [MessageTypes.LOADED_MEDIA]: 'any', + [MessageTypes.DETECTED_SOURCE]: 'any', + [MessageTypes.SEND_TO_PLAYER]: 'any', + [MessageTypes.EXIT_CARRIED_FULLSCREEN]: 'any', +}; + chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + if (MessageSenders[msg?.type] === 'own' && !fromOwnPage(sender)) { + return; + } if (msg.type === MessageTypes.PING) { sendResponse(MessageTypes.PONG); return; } else if (msg.type === MessageTypes.VPN_STATUS) { // The player, about its source: did the page reach it through Firefox VPN, and may - // FastStream follow (VpnProxyMirror)? Only from FastStream's own pages: the answer adds - // the URL's host to those that follow the VPN. - if (!fromOwnPage(sender)) { - return; - } + // FastStream follow (VpnProxyMirror)? Only from FastStream's own pages (MessageSenders): + // the answer adds the URL's host to those that follow the VPN. sendResponse(VpnMirror.status(String(msg.url || ''), !!sender.tab?.incognito, sender.tab?.id)); return; } else if (msg.type === MessageTypes.VPN_OPEN_PERMISSION) { // The player's Firefox VPN button: a player in a page has no permissions API, so the // permissions page asks, at its proxy row, in a tab of its own next to the video's. - if (!fromOwnPage(sender)) { - return; - } /** @type {chrome.tabs.CreateProperties} */ const create = {url: chrome.runtime.getURL('perms.html') + '#proxy'}; if (sender.tab?.id !== undefined) { @@ -1028,11 +1070,9 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { // seconds are swallowed: each one spawns its own mpv window, and a user // who clicks again because nothing appeared yet should not end up with a // stack of them. - // Only from the player: a manual send skips the MPV allowlist, and this one names the - // URL, headers and subtitles the helper gets (#470: a content script could send it). - if (!fromOwnPage(sender)) { - return; - } + // Only from the player (MessageSenders): a manual send skips the MPV allowlist, and this + // one names the URL, headers and subtitles the helper gets (#470: a content script could + // send it). if (Logging) console.log('[MPV] MPV_OPEN request:', msg.url); const now = Date.now(); if (msg.url === LastManualMpvUrl && @@ -1268,10 +1308,8 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { sendResponse('cleared'); return; } else if (msg.type === MessageTypes.SET_HEADERS) { - // Only from FastStream's own pages: the rule sets the headers the message names (#470). - if (!fromOwnPage(sender)) { - return; - } + // Only from FastStream's own pages (MessageSenders): the rule sets the headers the + // message names (#470). if (msg.commands.length) { // A tab that sends a message has an id. The rule's lifetime is capped (RuleEntry). ruleManager.addHeaderRule(msg.url, /** @type {number} */ (senderTab.id), msg.commands, msg.lifetimeMs).then((rule) => { @@ -1296,10 +1334,8 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { }, frame, mode); } } else if (msg.type === MessageTypes.DOWNLOAD) { - // Only from FastStream's own pages: it downloads whatever URL the message names (#470). - if (!fromOwnPage(sender)) { - return; - } + // Only from FastStream's own pages (MessageSenders): it downloads whatever URL the + // message names (#470). const url = msg.url; // Firefox refuses a name with a colon and some other characters, and the download then // silently did not happen: every screenshot ("@00:05") and any title with a colon. diff --git a/chrome/player/FastStreamClient.mjs b/chrome/player/FastStreamClient.mjs index c7a51d78..45b2545b 100644 --- a/chrome/player/FastStreamClient.mjs +++ b/chrome/player/FastStreamClient.mjs @@ -2040,6 +2040,20 @@ export class FastStreamClient extends EventEmitter { return true; } + /** + * Whether a player's failure is about the source being set now. setSource counts the next + * source at once, and the last source's player stays this.player until resetPlayer tears it + * down: what it reported in between was taken for the new source's - its hand-over to + * Firefox's own player played the old URL over the one the user had just picked, and its + * error showed the load error over the source on its way in (glm's review, 2026-10-28). + * tryNextSource and recoverPlayer asked this already; every failure route now does. + * @param {Player} player - The player whose event it is. + * @return {boolean} + */ + isCurrentAttempt(player) { + return player === this.player && this.fallbacks.request === this.sourceRequests; + } + /** * A source's URL without the time recoverPlayer gives it, so its recoveries count as one * source's. @@ -2072,7 +2086,10 @@ export class FastStreamClient extends EventEmitter { */ async renewSourceLink(status) { const source = this.source; - if (!source || !EnvUtils.isExtension() || source.loadedFromArchive || !/^https?:/i.test(source.url)) { + // A refusal of the last source's request, with the next one on its way in, is none of it. + const request = this.sourceRequests; + if (!source || this.fallbacks.request !== request || !EnvUtils.isExtension() || source.loadedFromArchive || + !/^https?:/i.test(source.url)) { return false; } const now = Date.now(); @@ -2096,8 +2113,8 @@ export class FastStreamClient extends EventEmitter { } finally { renewals.asking = false; } - // Another video meanwhile, or the page has no other link. - if (this.source !== source || typeof fresh !== 'string') { + // Another video meanwhile (asked for, or already set), or the page has no other link. + if (this.source !== source || this.sourceRequests !== request || typeof fresh !== 'string') { return false; } const time = this.currentTime; @@ -2485,6 +2502,7 @@ export class FastStreamClient extends EventEmitter { // A source this player cannot load (MP4Player, a server that ignores Range): Firefox's own // player plays it, once per source. this.context.on(DefaultPlayerEvents.PLAY_DIRECTLY, (/** @type {string} */ reason) => { + if (!this.isCurrentAttempt(player)) return; if (!this.playDirectly(player, reason)) { const message = Localize.getMessage('player_error_load'); this.failedToLoad(message + ' (' + reason + ')'); @@ -2493,6 +2511,7 @@ export class FastStreamClient extends EventEmitter { this.context.on(DefaultPlayerEvents.ERROR, (/** @type {unknown} */ reason) => { console.error('ERROR', reason); + if (!this.isCurrentAttempt(player)) return; // A video codec that just failed to decode for good: the same source again, without it. // Only a DashPlayer has takeCodecReload. if (/** @type {Player & {takeCodecReload?: () => boolean}} */ (player).takeCodecReload?.() && this.reloadWithoutFailedCodec(player)) { @@ -2514,6 +2533,7 @@ export class FastStreamClient extends EventEmitter { }); this.context.on(DefaultPlayerEvents.NEED_KEY, (/** @type {undefined} */ event) => { + if (!this.isCurrentAttempt(player)) return; this.failedToLoad(Localize.getMessage('player_error_drm')); }); diff --git a/tests/unit/FastStreamClient.test.mjs b/tests/unit/FastStreamClient.test.mjs index 13b12032..bc1ee92e 100644 --- a/tests/unit/FastStreamClient.test.mjs +++ b/tests/unit/FastStreamClient.test.mjs @@ -1979,3 +1979,89 @@ describe('FastStreamClient, a link the server refuses', () => { expect(asked()).toHaveLength(3); }); }); + +// setSource counts the next source at once, and the last source's player stays the player until +// resetPlayer tears it down. A failure it reported in between was taken for the new source's: +// its hand-over to Firefox's own player played the old URL over the source the user had just +// picked, its error showed the load error over it, and a refused link of the old source was +// asked for anew and rebuilt over it (glm's review, 2026-10-28). +describe('FastStreamClient, the last source\'s player failing while the next source is on its way in', () => { + const nextSourceAskedFor = (client) => { + client.sourceRequests++; + }; + + it('does not hand the last source to Firefox\'s own player', async () => { + const client = makeClient(); + const old = await setSource(client, makeSource('https://cdn.example/old.mp4', {mode: 'accelerated_mp4'})); + const set = vi.spyOn(client, 'setSource'); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + nextSourceAskedFor(client); + + old.emit(DefaultPlayerEvents.PLAY_DIRECTLY, 'The file is not an MP4 file'); + await settle(); + + expect(set).not.toHaveBeenCalled(); + expect(client.interfaceController.failedToLoad).not.toHaveBeenCalled(); + }); + + it('shows no load error for its error, nor for a key it lacks', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const client = makeClient(); + const old = await setSource(client, makeSource('https://cdn.example/old.m3u8')); + nextSourceAskedFor(client); + + old.emit(DefaultPlayerEvents.ERROR, {}); + old.emit(DefaultPlayerEvents.NEED_KEY); + await settle(); + + expect(client.interfaceController.failedToLoad).not.toHaveBeenCalled(); + }); + + it('still fails as before while its source is the one being played', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const client = makeClient(); + const current = await setSource(client, makeSource('https://cdn.example/a.m3u8')); + + current.emit(DefaultPlayerEvents.NEED_KEY); + await settle(); + + expect(client.interfaceController.failedToLoad).toHaveBeenCalledExactlyOnceWith('player_error_drm'); + }); + + it('does not ask for the last source\'s link anew, nor rebuild it with one that comes late', async () => { + const OLD = 'https://cdn.example/hls/master.m3u8?sig=old'; + const client = makeClient(); + client.linkRenewals = {identifier: null, times: [], asking: false}; + client.downloadManager.keepStorageOnce = vi.fn(); + client.downloadManager.relink = vi.fn(); + await setSource(client, makeSource(OLD)); + const built = vi.fn(); + client.playerLoader.createPlayer = built; + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const asked = () => chrome.runtime.sendMessage.mock.calls.filter(([message]) => message.type === MessageTypes.REQUEST_FRESH_LINK); + + nextSourceAskedFor(client); + client.linkRefused(403, OLD); + await settle(); + expect(asked()).toEqual([]); + + // Refused while it was the source; the next one asked for before the new link came. + client.fallbacks.request = client.sourceRequests; + let answer; + chrome.runtime.sendMessage.mockImplementation((message) => (message.type === MessageTypes.REQUEST_FRESH_LINK ? + new Promise((resolve) => { + answer = resolve; + }) : Promise.resolve())); + client.linkRefused(403, OLD); + await settle(); + expect(asked()).toHaveLength(1); + nextSourceAskedFor(client); + answer('https://cdn.example/hls/master.m3u8?sig=new'); + await settle(); + // A rebuild would be under way there (the first case's wait). + await client.sourceChange; + await settle(); + expect(built).not.toHaveBeenCalled(); + expect(client.source.url).toBe(OLD); + }); +}); diff --git a/tests/unit/backgroundAutoplayNext.test.mjs b/tests/unit/backgroundAutoplayNext.test.mjs index 2bbd2003..7306f7ec 100644 --- a/tests/unit/backgroundAutoplayNext.test.mjs +++ b/tests/unit/backgroundAutoplayNext.test.mjs @@ -89,8 +89,9 @@ describe('the player\'s autoplay next', () => { await restarted(session); expect(await bg.message(NEXT, {tabId: 1, frameId: 5, url: PLAYER_IN_PAGE})).toBe('error'); expect(await bg.message(NEXT, {tabId: 1, frameId: 6, url: PLAYER_IN_PAGE})).toBe('error'); - // Nor for the page itself in the player's frame: only FastStream's player page names it. - expect(await bg.message(NEXT, {tabId: 1, frameId: 5, url: EP1})).toBe('error'); + // Nor for the page itself in the player's frame: only FastStream's player page names it, + // and a content script's request goes unanswered (MessageSenders). + expect(await bg.message(NEXT, {tabId: 1, frameId: 5, url: EP1})).toBe(undefined); expect(bg.sent('PLAYLIST_NAVIGATION')).toEqual([]); }); diff --git a/tests/unit/backgroundDetection.test.mjs b/tests/unit/backgroundDetection.test.mjs index 7eeacae5..901b8ed9 100644 --- a/tests/unit/backgroundDetection.test.mjs +++ b/tests/unit/backgroundDetection.test.mjs @@ -140,6 +140,13 @@ describe('what a player was handed', () => { await bg.message({type: 'CLEAR_SOURCES'}, {tabId: 1, frameId: 5}); expect(await askAgain()).toEqual([]); }); + + it('is sent again after a content script asked for the list to be cleared', async () => { + // Only the player's "Clear Sources" button clears it (MessageSenders). + await playerHandedAStream(); + await bg.message({type: 'CLEAR_SOURCES'}, {tabId: 1, frameId: 5, url: 'https://site.test/watch'}); + expect(await askAgain()).toEqual(['https://cdn.test/v/master.m3u8']); + }); }); describe('a page that is never left', () => { diff --git a/tests/unit/backgroundHarness.mjs b/tests/unit/backgroundHarness.mjs index 2e3194fd..a51bfe3a 100644 --- a/tests/unit/backgroundHarness.mjs +++ b/tests/unit/backgroundHarness.mjs @@ -307,8 +307,10 @@ export async function loadBackground({ const log = console.log; console.log = () => {}; + /** @type {Object} */ + let senders = {}; try { - await import('../../chrome/background/background.mjs'); + ({MessageSenders: senders} = await import('../../chrome/background/background.mjs')); await settle(); } finally { console.log = log; @@ -334,13 +336,16 @@ export async function loadBackground({ * @param {{tabId?: number, frameId?: number, url?: string, tab?: Object, * onAnswer?: function(*): void}} [from] - Its sender; onAnswer runs the moment the answer * comes, as the sender's callback does, before anything else the background has started. + * Without a url, one of FastStream's own messages comes from its player page. * @return {Promise<*>} The background's answer, once given (undefined for none). */ bg.message = async (message, {tabId = 1, frameId = 0, url, tab, onAnswer} = {}) => { const sender = { tab: tab || {id: tabId, url: (bg.tabs.find((t) => t.id === tabId) || {}).url}, frameId, - url, + // A message only FastStream's own pages send (MessageSenders) comes from the player, + // unless the test names another sender: a content script's is ignored. + url: url ?? (senders[message?.type] === 'own' ? 'moz-extension://bg-test/player/index.html' : undefined), }; let reply; const answered = new Promise((resolve) => { diff --git a/tests/unit/backgroundMessages.test.mjs b/tests/unit/backgroundMessages.test.mjs new file mode 100644 index 00000000..71a9eb18 --- /dev/null +++ b/tests/unit/backgroundMessages.test.mjs @@ -0,0 +1,93 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import {afterEach, describe, expect, it, vi} from 'vitest'; + +import {loadBackground} from './backgroundHarness.mjs'; + +// Who may send the background each message it answers (background.mjs's MessageSenders): +// FastStream's own pages only, or its content scripts too, which run in every page. Some +// handlers checked their sender and others did not: LOAD_OPTIONS, MPV_TEST, CLEAR_SOURCES and +// REQUEST_PLAYLIST_NAVIGATION, which only the options page or the player send, did what a +// content script asked (glm's review, 2026-10-28). One list, read before any handler, is the +// check now, and a new message cannot be answered without saying who may send it. + +const chromeDir = path.resolve(import.meta.dirname, '../../chrome'); +const PAGE = 'https://site.test/watch'; +const PLAYER = 'moz-extension://bg-test/player/index.html'; + +let bg; + +afterEach(() => { + bg?.unload(); + bg = null; + vi.restoreAllMocks(); +}); + +/** @return {Set} The types runtime.onMessage's listener answers: its branches. */ +function answeredTypes() { + const source = fs.readFileSync(path.join(chromeDir, 'background/background.mjs'), 'utf8'); + const start = source.indexOf('chrome.runtime.onMessage.addListener('); + const end = source.indexOf('\n});\n', start); + return new Set([...source.slice(start, end).matchAll(/msg\.type === MessageTypes\.([A-Z_]+)/g)].map((m) => m[1])); +} + +/** @return {Set} The types the content scripts send (a message's `type:` field). */ +function contentScriptTypes() { + const manifest = JSON.parse(fs.readFileSync(path.join(chromeDir, 'manifest.json'), 'utf8')); + const files = new Set(manifest.content_scripts.flatMap((entry) => entry.js)); + const types = new Set(); + for (const file of files) { + const source = fs.readFileSync(path.join(chromeDir, file), 'utf8'); + for (const m of source.matchAll(/type:\s*(?:MessageTypes\.([A-Z_]+)|['"]([A-Z_]+)['"])/g)) types.add(m[1] || m[2]); + } + return types; +} + +describe('Who may send the background a message', () => { + it('is said for every type the listener answers, and for no other', async () => { + bg = await loadBackground(); + const {MessageSenders} = await import('../../chrome/background/background.mjs'); + const answered = answeredTypes(); + // A pattern that stopped matching would make this pass on nothing. + expect(answered.size).toBeGreaterThanOrEqual(30); + expect([...answered].filter((type) => !(type in MessageSenders))).toEqual([]); + expect(Object.keys(MessageSenders).filter((type) => !answered.has(type))).toEqual([]); + expect(new Set(Object.values(MessageSenders))).toEqual(new Set(['own', 'any'])); + }); + + it('is FastStream\'s own pages only for no type a content script sends', async () => { + bg = await loadBackground(); + const {MessageSenders} = await import('../../chrome/background/background.mjs'); + const sent = contentScriptTypes(); + expect(sent.has('LOADED_MEDIA')).toBe(true); + expect(sent.has('DETECTED_SOURCE')).toBe(true); + expect([...sent].filter((type) => MessageSenders[type] === 'own')).toEqual([]); + }); + + it('ignores a content script that asks to read the options anew', async () => { + bg = await loadBackground({tabs: [{id: 1, url: PAGE}]}); + await bg.message({type: 'LOAD_OPTIONS', time: 7}, {tabId: 1, url: PAGE}); + await bg.wait(1000); + expect(bg.sent('UPDATE_OPTIONS')).toEqual([]); + + await bg.message({type: 'LOAD_OPTIONS', time: 8}, {tabId: 1, url: PLAYER}); + await bg.wait(1000); + expect(bg.sent('UPDATE_OPTIONS').map((m) => m.message.time)).toEqual([8]); + }); + + it('ignores a content script that asks for the mpv test, which tells the mpv path', async () => { + bg = await loadBackground({options: {mpvMode: true, mpvPath: 'C:/mpv/mpv.exe'}, + onNative: (m) => ({ok: true, mpv: true, path: m.mpvPath})}); + expect(await bg.message({type: 'MPV_TEST'}, {tabId: 1, url: PAGE})).toBe(undefined); + await bg.wait(500); + expect(bg.native).toEqual([]); + }); + + it('answers its own pages as before, and a content script\'s own messages too', async () => { + bg = await loadBackground(); + expect(await bg.message({type: 'PING'}, {tabId: 1, url: PLAYER})).toBe('PONG'); + expect(await bg.message({type: 'PING'}, {tabId: 1, url: PAGE})).toBe(undefined); + // LOADED_MEDIA is a content script's: answered from a page. + expect(await bg.message({type: 'LOADED_MEDIA', resources: []}, {tabId: 1, url: PAGE})).not.toBe(undefined); + }); +}); diff --git a/tests/unit/backgroundRobustness.test.mjs b/tests/unit/backgroundRobustness.test.mjs index 441db2f0..9386d3e5 100644 --- a/tests/unit/backgroundRobustness.test.mjs +++ b/tests/unit/backgroundRobustness.test.mjs @@ -60,7 +60,7 @@ describe('a download from a container tab', () => { 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}, () => {}); + {tab: {id: hidden.id, url: hidden.url}, frameId: 0, url: 'moz-extension://bg-test/player/index.html'}, () => {}); } return answers; }