Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 57 additions & 21 deletions chrome/background/background.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, 'own'|'any'>} */
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) {
Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -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) => {
Expand All @@ -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.
Expand Down
26 changes: 23 additions & 3 deletions chrome/player/FastStreamClient.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand All @@ -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;
Expand Down Expand Up @@ -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 + ')');
Expand All @@ -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)) {
Expand All @@ -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'));
});

Expand Down
86 changes: 86 additions & 0 deletions tests/unit/FastStreamClient.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
5 changes: 3 additions & 2 deletions tests/unit/backgroundAutoplayNext.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});

Expand Down
7 changes: 7 additions & 0 deletions tests/unit/backgroundDetection.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
9 changes: 7 additions & 2 deletions tests/unit/backgroundHarness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -307,8 +307,10 @@ export async function loadBackground({

const log = console.log;
console.log = () => {};
/** @type {Object<string, 'own'|'any'>} */
let senders = {};
try {
await import('../../chrome/background/background.mjs');
({MessageSenders: senders} = await import('../../chrome/background/background.mjs'));
await settle();
} finally {
console.log = log;
Expand All @@ -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) => {
Expand Down
Loading
Loading