Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b5db85a
fix(background): keep a request's headers across the page's reset (#142)
Nawid3333 Oct 3, 2026
18a8964
fix(security): take a player's parent frame only from the page that o…
Nawid3333 Oct 3, 2026
b7ffa8f
fix(background): six small defects of the background script (#144)
Nawid3333 Oct 3, 2026
93dd083
fix(content): answer the caption scrape once every track was asked fo…
Nawid3333 Oct 3, 2026
5929060
fix(mpv): a stream found while the play's first one is read is checke…
Nawid3333 Oct 3, 2026
863f143
fix(mpv): the hand-off's "!" and a waiting play outlive the event pag…
Nawid3333 Oct 3, 2026
655a19d
perf(content): windowed fullscreen in linear time, and leave the <hea…
Nawid3333 Oct 3, 2026
ef3b59e
fix(mpv): the 3 s wait after a play on a page Back gave back is that …
Nawid3333 Oct 3, 2026
aef7880
fix(content): player lifecycle: late link, windowed fullscreen on rem…
Nawid3333 Oct 3, 2026
862cacc
fix(player): a player reloaded in its frame gets its video again, and…
Nawid3333 Oct 3, 2026
2cbeaef
fix(background): a start by address waits for the new page to name it…
Nawid3333 Oct 3, 2026
04c982a
fix(background): keep no streams for requests outside any tab, nor ac…
Nawid3333 Oct 3, 2026
e3f01d1
fix(overlay-guard): leave a site's dialogs alone; measure "half the p…
Nawid3333 Oct 3, 2026
b8a1b3a
refactor(background): drop WAIT_UNTIL_MAIN_LOADED, which nothing send…
Nawid3333 Oct 3, 2026
d552676
fix(background): a frame keeps a bounded number of its page's streams…
Nawid3333 Oct 3, 2026
98434c3
fix(custom): site scripts check the shapes they read, encode any mani…
Nawid3333 Oct 3, 2026
cb54627
fix(background): a media element's file is a video by its Content-Typ…
Nawid3333 Oct 3, 2026
9621d9d
fix(background): a change of the URL's fragment alone is the same page
Nawid3333 Oct 3, 2026
1557760
feat(content): find a page's stream past the first 250 requests after…
Nawid3333 Oct 3, 2026
9e4f314
Merge the background branch (#305) into the content branch
Nawid3333 Oct 3, 2026
5410ff2
fix(background): a start by address leaves the new page's own stream …
Nawid3333 Oct 3, 2026
2265155
Merge remote-tracking branch 'origin/claude/issues-background' into c…
Nawid3333 Oct 3, 2026
26b8c54
test(content): the stand-in XMLHttpRequest answers an arraybuffer req…
Nawid3333 Oct 3, 2026
1d9097c
test(content): the scripts the tests run come with vitest (?raw), not…
claude Oct 3, 2026
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
16 changes: 14 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -676,6 +678,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
Expand Down Expand Up @@ -1554,7 +1566,7 @@ Opted in: `background.mjs` (2026-10-01) and the rest of `chrome/background/` but
(`native-host/faststream-mpv-host.mjs`). The types are Chrome's (`@types/chrome`,
which matches the `chrome.*` calls, callbacks included) plus Node's (the host, tests
and tools), and `types/firefox-chrome.d.ts` adds the Firefox-only fields read here
(`cookieStoreId`, `originUrl`). background.mjs's own fixes were JSDoc, a few
(`cookieStoreId`). background.mjs's own fixes were JSDoc, a few
`undefined` checks that return what the code returned before (through a throw), and
one guard: a message from a page outside any tab is no longer handled as a tab's. The
player's files are next; fix what tsc reports only with the playback suites to hand.
Expand Down
35 changes: 35 additions & 0 deletions chrome/background/BackgroundUtils.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
// @ts-check
import {URLUtils} from '../player/utils/URLUtils.mjs';

const PlayerURL = chrome.runtime.getURL('player/index.html');

export class BackgroundUtils {
Expand All @@ -21,6 +23,20 @@ export class BackgroundUtils {
return BackgroundUtils.PlayerOpeningResponses.includes(response);
}

/**
* The player mode of a source a site script reports (DETECTED_SOURCE, custom/*.js). Its
* page can post such a report too (Instagram's): one without an address, or of a type
* the player does not play, was recorded as a source of no mode.
* @param {*} msg - The message.
* @return {?string} The mode, or null for no source.
*/
static detectedSourceMode(msg) {
if (!msg || typeof msg.url !== 'string' || !msg.url) {
return null;
}
return URLUtils.getModeFromExtension(msg.ext) || null;
}

static checkMessageError(message, suppress = false) {
if (chrome.runtime.lastError) {
if (!suppress) console.warn(`Unable to send message '${message}'`, chrome.runtime.lastError);
Expand Down Expand Up @@ -136,6 +152,25 @@ export class BackgroundUtils {
return url.substring(0, PlayerURL.length) === PlayerURL;
}

/**
* Whether a tab's new URL (tabs.onUpdated) is still the same page: only its fragment
* changed, to an anchor (#comments) or a time (#t=120), which Firefox reports as it
* reports a new page. A hash route (#/episode/2, #!/...) is a page of its own, as is
* the same URL again (a reload).
* @param {?string|undefined} oldUrl - The tab's URL before.
* @param {string} newUrl - Its URL now.
* @return {boolean}
*/
static isSamePageUrlChange(oldUrl, newUrl) {
if (!oldUrl || !newUrl || oldUrl === newUrl) {
return false;
}
const hash = (url) => (url.includes('#') ? url.slice(url.indexOf('#') + 1) : '');
const strip = (url) => url.split('#')[0];
const isRoute = (url) => hash(url).startsWith('/') || hash(url).startsWith('!');
return strip(oldUrl) === strip(newUrl) && !isRoute(oldUrl) && !isRoute(newUrl);
}

static getPlayerUrl() {
return PlayerURL;
}
Expand Down
23 changes: 23 additions & 0 deletions chrome/background/ManifestTypes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,26 @@ export function contentTypeOf(headers) {
export function modeFromContentType(headers) {
return MANIFEST_TYPES.get(contentTypeOf(headers));
}

// Files a media element plays that the player's MP4 mode cannot read (it reads MP4 only):
// played as they are, as a .webm URL already is (URLUtils).
const DIRECT_TYPES = ['video/webm', 'video/ogg'];

/**
* The player mode for a file a <video> or <audio> element loaded, whose URL names no type:
* by its Content-Type. Every such file used to be taken for an MP4 video: a site's
* notification sound or a podcast's MP3 too, which on the MPV allowlist went to mpv as the
* page's first stream, and on a tab that is on opened the player.
* @param {Array<{name: string, value?: string}>} [headers] - webRequest's responseHeaders.
* @return {string|null} The mode, or null for audio, which is no video.
*/
export function modeFromMediaType(headers) {
const type = contentTypeOf(headers);
if (type.startsWith('audio/')) {
return null;
}
if (DIRECT_TYPES.includes(type)) {
return PlayerModes.DIRECT;
}
return PlayerModes.ACCELERATED_MP4;
}
88 changes: 61 additions & 27 deletions chrome/background/MultiRegexMatcher.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, *>}}
*/
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;
}
}
}
Expand Down
25 changes: 20 additions & 5 deletions chrome/background/NetRequestRuleManager.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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() {
Expand All @@ -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) {
Expand Down
Loading
Loading