[mv3] Try to address Safari's webextensions API issues

1) Reset "seen realms" when modifying enabled rulesets. The "seen
realms" code path is an attempt at mitigating an issue whereas
the DNR rules would not be enforced under certain circumstances.

2) Re-write the scripting manager to always wholly
unregister/register all content scripts instead of only the
differences before/after following a change affecting content
scripts.
This commit is contained in:
Raymond Hill 2025-12-27 14:12:31 -05:00
parent d3024ac309
commit 0101a47d25
No known key found for this signature in database
GPG key ID: F5630CAE62A14316
4 changed files with 63 additions and 195 deletions

View file

@ -19,8 +19,8 @@
Home: https://github.com/gorhill/uBlock
*/
import { matchesFromHostnames, strArrayEq } from './utils.js';
import { browser } from './ext.js';
import { matchesFromHostnames } from './utils.js';
/******************************************************************************/
@ -91,9 +91,6 @@ export async function registerToolbarIconToggler(context) {
if ( toToggle.size === 0 ) { return; }
const registered = context.before.get('toolbar-icon');
context.before.delete('toolbar-icon'); // Important!
const directive = {
id: 'toolbar-icon',
js: [ '/js/scripting/toolbar-icon.js' ],
@ -101,10 +98,5 @@ export async function registerToolbarIconToggler(context) {
runAt: 'document_start',
};
if ( registered === undefined ) {
context.toAdd.push(directive);
} else if ( strArrayEq(registered.matches, directive.matches) === false ) {
context.toRemove.push('toolbar-icon');
context.toAdd.push(directive);
}
context.toAdd.push(directive);
}

View file

@ -30,7 +30,6 @@ import {
import {
intersectHostnameIters,
matchesFromHostnames,
strArrayEq,
subtractHostnameIters,
} from './utils.js';
@ -189,9 +188,6 @@ export async function registerCustomFilters(context) {
}
if ( hostnames.length === 0 ) { return; }
const registered = context.before.get('css-user');
context.before.delete('css-user'); // Important!
const directive = {
id: 'css-user',
js: [ '/js/scripting/css-user.js' ],
@ -199,12 +195,7 @@ export async function registerCustomFilters(context) {
runAt: 'document_start',
};
if ( registered === undefined ) {
context.toAdd.push(directive);
} else if ( strArrayEq(registered.matches, directive.matches) === false ) {
context.toRemove.push('css-user');
context.toAdd.push(directive);
}
context.toAdd.push(directive);
}
/******************************************************************************/

View file

@ -71,29 +71,6 @@ const normalizeMatches = matches => {
/******************************************************************************/
// The extensions API does not always return exactly what we fed it, so we
// need to normalize some entries to be sure we properly detect changes when
// comparing registered entries vs. entries to register.
const normalizeRegisteredContentScripts = registered => {
for ( const entry of registered ) {
const { css = [], js = [] } = entry;
for ( let i = 0; i < css.length; i++ ) {
const path = css[i];
if ( path.startsWith('/') ) { continue; }
css[i] = `/${path}`;
}
for ( let i = 0; i < js.length; i++ ) {
const path = js[i];
if ( path.startsWith('/') ) { continue; }
js[i] = `/${path}`;
}
}
return registered;
};
/******************************************************************************/
async function resetCSSCache() {
const keys = await sessionKeys();
return Promise.all(
@ -104,7 +81,7 @@ async function resetCSSCache() {
/******************************************************************************/
function registerHighGeneric(context, genericDetails) {
const { before, filteringModeDetails, rulesetsDetails } = context;
const { filteringModeDetails, rulesetsDetails } = context;
const excludeHostnames = [];
const includeHostnames = [];
@ -145,12 +122,8 @@ function registerHighGeneric(context, genericDetails) {
)
);
}
if ( matches.length === 0 ) { return; }
const registered = before.get('css-generichigh');
before.delete('css-generichigh'); // Important!
// https://github.com/w3c/webextensions/issues/414#issuecomment-1623992885
// Once supported, add:
// cssOrigin: 'USER',
@ -166,26 +139,13 @@ function registerHighGeneric(context, genericDetails) {
}
// register
if ( registered === undefined ) {
context.toAdd.push(directive);
return;
}
// update
if (
ut.strArrayEq(registered.css, css, false) === false ||
ut.strArrayEq(registered.matches, matches) === false ||
ut.strArrayEq(registered.excludeMatches, excludeMatches) === false
) {
context.toRemove.push('css-generichigh');
context.toAdd.push(directive);
}
context.toAdd.push(directive);
}
/******************************************************************************/
function registerGeneric(context, genericDetails) {
const { before, filteringModeDetails, rulesetsDetails } = context;
const { filteringModeDetails, rulesetsDetails } = context;
const excludedByFilter = [];
const includedByFilter = [];
@ -224,8 +184,6 @@ function registerGeneric(context, genericDetails) {
),
];
if ( matches.length === 0 ) { return; }
const registered = before.get('css-generic-some');
before.delete('css-generic-some'); // Important!
const directive = {
id: 'css-generic-some',
js,
@ -233,15 +191,7 @@ function registerGeneric(context, genericDetails) {
matches,
runAt: 'document_idle',
};
if ( registered === undefined ) { // register
context.toAdd.push(directive);
} else if ( // update
ut.strArrayEq(registered.js, js, false) === false ||
ut.strArrayEq(registered.matches, directive.matches) === false
) {
context.toRemove.push('css-generic-some');
context.toAdd.push(directive);
}
context.toAdd.push(directive);
return;
}
@ -249,8 +199,6 @@ function registerGeneric(context, genericDetails) {
...ut.matchesFromHostnames(excludedByMode),
...ut.matchesFromHostnames(excludedByFilter),
];
const registeredAll = before.get('css-generic-all');
before.delete('css-generic-all'); // Important!
const directiveAll = {
id: 'css-generic-all',
js,
@ -261,24 +209,14 @@ function registerGeneric(context, genericDetails) {
if ( excludeMatches.length !== 0 ) {
directiveAll.excludeMatches = excludeMatches;
}
context.toAdd.push(directiveAll);
if ( registeredAll === undefined ) { // register
context.toAdd.push(directiveAll);
} else if ( // update
ut.strArrayEq(registeredAll.js, js, false) === false ||
ut.strArrayEq(registeredAll.excludeMatches, directiveAll.excludeMatches) === false
) {
context.toRemove.push('css-generic-all');
context.toAdd.push(directiveAll);
}
const matches = [
...ut.matchesFromHostnames(
ut.subtractHostnameIters(includedByFilter, excludedByMode)
),
];
if ( matches.length === 0 ) { return; }
const registeredSome = before.get('css-generic-some');
before.delete('css-generic-some'); // Important!
const directiveSome = {
id: 'css-generic-some',
js,
@ -286,21 +224,13 @@ function registerGeneric(context, genericDetails) {
matches,
runAt: 'document_idle',
};
if ( registeredSome === undefined ) { // register
context.toAdd.push(directiveSome);
} else if ( // update
ut.strArrayEq(registeredSome.js, js, false) === false ||
ut.strArrayEq(registeredSome.matches, directiveSome.matches) === false
) {
context.toRemove.push('css-generic-some');
context.toAdd.push(directiveSome);
}
context.toAdd.push(directiveSome);
}
/******************************************************************************/
async function registerCosmetic(realm, context) {
const { before, filteringModeDetails, rulesetsDetails } = context;
const { filteringModeDetails, rulesetsDetails } = context;
{
const keys = await localKeys();
@ -355,9 +285,6 @@ async function registerCosmetic(realm, context) {
}
}
const registered = before.get(realmid);
before.delete(realmid); // Important!
const directive = {
id: realmid,
js,
@ -370,26 +297,13 @@ async function registerCosmetic(realm, context) {
}
// register
if ( registered === undefined ) {
context.toAdd.push(directive);
return;
}
// update
if (
ut.strArrayEq(registered.js, js, false) === false ||
ut.strArrayEq(registered.matches, matches) === false ||
ut.strArrayEq(registered.excludeMatches, excludeMatches) === false
) {
context.toRemove.push(realmid);
context.toAdd.push(directive);
}
context.toAdd.push(directive);
}
/******************************************************************************/
function registerScriptlet(context, scriptletDetails) {
const { before, filteringModeDetails, rulesetsDetails } = context;
const { filteringModeDetails, rulesetsDetails } = context;
const hasBroadHostPermission =
filteringModeDetails.optimal.has('all-urls') ||
@ -431,9 +345,6 @@ function registerScriptlet(context, scriptletDetails) {
matches.push(...ut.matchesFromHostnames(targetHostnames));
normalizeMatches(matches);
const registered = before.get(id);
before.delete(id); // Important!
const directive = {
id,
js: [ `/rulesets/scripting/scriptlet/${world.toLowerCase()}/${rulesetId}.js` ],
@ -448,19 +359,7 @@ function registerScriptlet(context, scriptletDetails) {
}
// register
if ( registered === undefined ) {
context.toAdd.push(directive);
continue;
}
// update
if (
ut.strArrayEq(registered.matches, matches) === false ||
ut.strArrayEq(registered.excludeMatches, excludeMatches) === false
) {
context.toRemove.push(id);
context.toAdd.push(directive);
}
context.toAdd.push(directive);
}
}
}
@ -481,26 +380,17 @@ export async function registerInjectables() {
rulesetsDetails,
scriptletDetails,
genericDetails,
registered,
] = await Promise.all([
getFilteringModeDetails(),
getEnabledRulesetsDetails(),
getScriptletDetails(),
getGenericDetails(),
browser.scripting.getRegisteredContentScripts(),
]);
const before = new Map(
normalizeRegisteredContentScripts(registered).map(
entry => [ entry.id, entry ]
)
);
const toAdd = [], toRemove = [];
const toAdd = [];
const context = {
filteringModeDetails,
rulesetsDetails,
before,
toAdd,
toRemove,
};
await Promise.all([
@ -513,23 +403,17 @@ export async function registerInjectables() {
registerToolbarIconToggler(context),
]);
toRemove.push(...Array.from(before.keys()));
if ( toRemove.length !== 0 ) {
ubolLog(`Unregistered ${toRemove} content (css/js)`);
try {
await browser.scripting.unregisterContentScripts({ ids: toRemove });
localRemove('$scripting.unregisterContentScripts');
} catch(reason) {
ubolErr(`unregisterContentScripts/${reason}`);
}
ubolLog(`Unregistered all content (css/js)`);
try {
await browser.scripting.unregisterContentScripts();
} catch(reason) {
ubolErr(`unregisterContentScripts/${reason}`);
}
if ( toAdd.length !== 0 ) {
ubolLog(`Registered ${toAdd.map(v => v.id)} content (css/js)`);
try {
await browser.scripting.registerContentScripts(toAdd);
localRemove('$scripting.registerContentScripts');
} catch(reason) {
ubolErr(`registerContentScripts/${reason}`);
}

View file

@ -24,6 +24,46 @@ export const webext = self.browser;
/******************************************************************************/
// Workaround for:
// https://github.com/uBlockOrigin/uBOL-home/issues/515
// https://bugs.webkit.org/show_bug.cgi?id=300236
//
// For each realm, we will force-reload registered rulesets once.
const { windows } = webext;
const NORMAL_REALM = 0b01;
const PRIVATE_REALM = 0b10;
const ALL_REALMS = NORMAL_REALM | PRIVATE_REALM;
let seenRealms = 0b00;
let seenRealmsReady = webext.storage.session.get('safari.seenRealms').then(bin => {
seenRealms |= bin?.['safari.seenRealms'] ?? 0;
}).catch(( ) => {
});
async function forceEnableRulesets(windowId) {
await seenRealmsReady;
if ( seenRealms === ALL_REALMS ) { return; }
if ( windowId === windows.WINDOW_ID_NONE ) { return; }
const details = await windows.get(windowId, { windowTypes: [ 'normal' ] });
const incognito = details?.incognito;
if ( typeof incognito !== 'boolean' ) { return; }
const currentRealm = incognito ? PRIVATE_REALM : NORMAL_REALM;
if ( (seenRealms & currentRealm) !== 0 ) { return; }
seenRealms |= currentRealm;
webext.storage.session.set({ 'safari.seenRealms': seenRealms });
const ids = await nativeDNR.getEnabledRulesets();
if ( ids.length === 0 ) { return; }
nativeDNR.updateEnabledRulesets({
disableRulesetIds: ids.slice(),
enableRulesetIds: ids.slice(),
});
}
windows.onFocusChanged.addListener(forceEnableRulesets);
/******************************************************************************/
// https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/declarativeNetRequest/
const nativeDNR = webext.declarativeNetRequest;
@ -142,8 +182,10 @@ export const dnr = {
if ( optionsAfter === undefined ) { return; }
return nativeDNR.updateDynamicRules(optionsAfter);
},
updateEnabledRulesets(...args) {
return nativeDNR.updateEnabledRulesets(...args);
async updateEnabledRulesets(...args) {
await nativeDNR.updateEnabledRulesets(...args);
seenRealms = 0b00;
await webext.storage.session.remove('safari.seenRealms');
},
async updateSessionRules(optionsBefore) {
const optionsAfter = prepareUpdateRules(optionsBefore);
@ -181,44 +223,3 @@ export const dnr = {
return nativeDNR.setExtensionActionOptions(...args);
},
};
/******************************************************************************/
// Workaround for:
// https://github.com/uBlockOrigin/uBOL-home/issues/515
// https://bugs.webkit.org/show_bug.cgi?id=300236
//
// For each realm, we will force-reload registered rulesets once.
const { windows } = webext;
const NORMAL_REALM = 0b01;
const PRIVATE_REALM = 0b10;
const ALL_REALMS = NORMAL_REALM | PRIVATE_REALM;
let seenRealms = 0b00;
async function forceEnableRulesets(windowId) {
await seenRealmsReady;
if ( seenRealms === ALL_REALMS ) { return; }
if ( windowId === windows.WINDOW_ID_NONE ) { return; }
const details = await windows.get(windowId, { windowTypes: [ 'normal' ] });
const incognito = details?.incognito;
if ( typeof incognito !== 'boolean' ) { return; }
const currentRealm = incognito ? PRIVATE_REALM : NORMAL_REALM;
if ( (seenRealms & currentRealm) !== 0 ) { return; }
seenRealms |= currentRealm;
webext.storage.session.set({ 'safari.seenRealms': seenRealms });
const ids = await nativeDNR.getEnabledRulesets();
if ( ids.length === 0 ) { return; }
nativeDNR.updateEnabledRulesets({
disableRulesetIds: ids.slice(),
enableRulesetIds: ids.slice(),
});
}
windows.onFocusChanged.addListener(forceEnableRulesets);
const seenRealmsReady = webext.storage.session.get('safari.seenRealms').then(bin => {
seenRealms |= bin?.['safari.seenRealms'] ?? 0;
}).catch(( ) => {
});