[mv3] Add ability to backup/restore settings

Related issue:
https://github.com/uBlockOrigin/uBOL-home/issues/482
This commit is contained in:
Raymond Hill 2025-09-27 12:53:58 -04:00
parent f89de42364
commit 2581004e8f
No known key found for this signature in database
GPG key ID: F5630CAE62A14316
9 changed files with 278 additions and 17 deletions

View file

@ -263,6 +263,18 @@
"message": "Enables access to features suitable for technical users.",
"description": "Short description for a checkbox in the options page"
},
"settingsBackupRestoreLabel": {
"message": "Backup / Restore",
"description": "The header text for the backup/restore section"
},
"settingsBackupRestoreSummary": {
"message": "Backup your custom settings to a file, or restore your custom settings from a file.",
"description": "A summary description of the backup/restore section."
},
"settingsBackupRestoreLegend": {
"message": "Restoring will overwrite all your current custom settings.",
"description": "Important information about the backup/restore section."
},
"findListsPlaceholder": {
"message": "Find lists",
"description": "Placeholder for the input field used to find lists"
@ -363,6 +375,14 @@
"message": "Export…",
"description": "Text for buttons used to export content"
},
"backupButton": {
"message": "Backup…",
"description": "Text for buttons used to backup content"
},
"restoreButton": {
"message": "Restore…",
"description": "Text for buttons used to restore content"
},
"dnrRulesWarning": {
"message": "Do not add content from untrusted sources",
"description": "Short description of the DNR rules editor pane"

View file

@ -44,7 +44,7 @@
</p>
<p><label id="showBlockedCount" data-i18n="showBlockedCountLabel"><span class="input checkbox"><input type="checkbox"><svg viewBox="0 0 24 24"><path d="M1.73,12.91 8.1,19.28 22.79,4.59"/></svg></span>_</label>
</div>
<hr>
<div>
<h3 data-i18n="defaultFilteringModeSectionLabel"></h3>
<p data-i18n="defaultFilteringModeDescription"></p>
@ -98,6 +98,15 @@
<p data-platform-exclude="safari"><label id="strictBlockMode" data-i18n="enableStrictBlockLabel"><span class="input checkbox"><input type="checkbox"><svg viewBox="0 0 24 24"><path d="M1.73,12.91 8.1,19.28 22.79,4.59"/></svg></span>_</label><legend data-i18n="enableStrictBlockLegend"></legend>
<p id="developerMode"><label data-i18n="developerModeLabel"><span class="input checkbox"><input type="checkbox"><svg viewBox="0 0 24 24"><path d="M1.73,12.91 8.1,19.28 22.79,4.59"/></svg></span>_</label><legend data-i18n="developerModeLegend"></legend>
</div>
<hr>
<div>
<h3 data-i18n="settingsBackupRestoreLabel">_</h3>
<p data-i18n="settingsBackupRestoreSummary">_</p>
<p>
<button class="iconified dontshrink" type="button"><span class="fa-icon">download-alt</span><span data-i18n="backupButton">_</span><span class="hover"></span></button>&ensp;
<button class="iconified dontshrink" type="button"><span class="fa-icon">upload-alt</span><span data-i18n="restoreButton">_</span><span class="hover"></span></button><input type="file" accept="application/json">
</p>
</div>
</section>
<!-- -------- -->
<section data-pane="rulesets">

View file

@ -22,6 +22,7 @@
import {
MODE_BASIC,
MODE_OPTIMAL,
defaultFilteringModes,
getDefaultFilteringMode,
getFilteringMode,
getFilteringModeDetails,
@ -63,9 +64,18 @@ import {
webextFlavor,
} from './ext.js';
import {
defaultConfig,
loadRulesetConfig,
process,
rulesetConfig,
saveRulesetConfig,
} from './config.js';
import {
enableRulesets,
excludeFromStrictBlock,
getDefaultRulesetsFromEnv,
getEffectiveDynamicRules,
getEffectiveSessionRules,
getEffectiveUserRules,
@ -85,13 +95,6 @@ import {
ubolLog,
} from './debug.js';
import {
loadRulesetConfig,
process,
rulesetConfig,
saveRulesetConfig,
} from './config.js';
import { dnr } from './ext-compat.js';
import { getTroubleshootingInfo } from './troubleshooting.js';
import { registerInjectables } from './scripting-manager.js';
@ -272,6 +275,19 @@ function onMessage(request, sender, callback) {
return true;
}
case 'getDefaultConfig':
getDefaultRulesetsFromEnv().then(rulesets => {
callback({
autoReload: defaultConfig.autoReload,
developerMode: defaultConfig.developerMode,
showBlockedCount: defaultConfig.showBlockedCount,
strictBlockMode: defaultConfig.strictBlockMode,
rulesets,
filteringModes: Object.assign(defaultFilteringModes),
});
});
return true;
case 'getOptionsPageData':
Promise.all([
hasBroadHostPermissions(),

View file

@ -0,0 +1,149 @@
/*******************************************************************************
uBlock Origin Lite - a comprehensive, MV3-compliant content blocker
Copyright (C) 2022-present Raymond Hill
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see {http://www.gnu.org/licenses/}.
Home: https://github.com/gorhill/uBlock
*/
import {
localRead, localWrite,
runtime,
sendMessage,
} from './ext.js';
/******************************************************************************/
export async function backupToObject(currentConfig) {
const out = {};
const manifest = runtime.getManifest();
out.version = manifest.versionName ?? manifest.version;
const defaultConfig = await sendMessage({ what: 'getDefaultConfig' });
if ( currentConfig.autoReload !== defaultConfig.autoReload ) {
out.autoReload = currentConfig.autoReload;
}
if ( currentConfig.developerMode !== defaultConfig.developerMode ) {
out.developerMode = currentConfig.developerMode;
}
if ( currentConfig.showBlockedCount !== defaultConfig.showBlockedCount ) {
out.showBlockedCount = currentConfig.showBlockedCount;
}
if ( currentConfig.strictBlockMode !== defaultConfig.strictBlockMode ) {
out.strictBlockMode = currentConfig.strictBlockMode;
}
const { enabledRulesets } = currentConfig;
const customRulesets = [];
for ( const id of enabledRulesets ) {
if ( defaultConfig.rulesets.includes(id) ) { continue; }
customRulesets.push(`+${id}`);
}
for ( const id of defaultConfig.rulesets ) {
if ( enabledRulesets.includes(id) ) { continue; }
customRulesets.push(`-${id}`);
}
if ( customRulesets.length !== 0 ) {
out.rulesets = customRulesets;
}
out.filteringModes = await sendMessage({ what: 'getFilteringModeDetails' });
const customFilters = await sendMessage({ what: 'getAllCustomFilters' });
const filters = [];
for ( const [ hostname, selectors ] of customFilters ) {
for ( const selector of selectors ) {
filters.push(`${hostname}##${selector}`);
}
}
if ( filters.length !== 0 ) {
out.cosmeticFilters = filters;
}
const dnrRules = await localRead('userDnrRules');
if ( typeof dnrRules === 'string' && dnrRules.length !== 0 ) {
out.dnrRules = dnrRules.split(/\n+/);
}
return out;
}
/******************************************************************************/
export async function restoreFromObject(targetConfig) {
const defaultConfig = await sendMessage({ what: 'getDefaultConfig' });
await sendMessage({
what: 'setAutoReload',
state: targetConfig.autoReload ?? defaultConfig.autoReload
});
await sendMessage({
what: 'setShowBlockedCount',
state: targetConfig.showBlockedCount ?? defaultConfig.showBlockedCount
});
await sendMessage({
what: 'setDeveloperMode',
state: targetConfig.developerMode ?? defaultConfig.developerMode
});
await sendMessage({
what: 'setStrictBlockMode',
state: targetConfig.strictBlockMode ?? defaultConfig.strictBlockMode
});
const enabledRulesets = new Set(defaultConfig.rulesets);
for ( const entry of targetConfig.rulesets ) {
const id = entry.slice(1);
if ( entry.startsWith('+') ) {
enabledRulesets.add(id);
} else if ( entry.startsWith('-') ) {
enabledRulesets.delete(id);
}
}
await sendMessage({
what: 'applyRulesets',
enabledRulesets: Array.from(enabledRulesets),
});
await sendMessage({
what: 'setFilteringModeDetails',
modes: targetConfig.filteringModes ?? defaultConfig.filteringModes,
});
await sendMessage({ what: 'removeAllCustomFilters', hostname: '*' });
const hostnameMap = new Map();
for ( const line of targetConfig.cosmeticFilters ?? [] ) {
const i = line.indexOf('##');
if ( i === -1 ) { continue; }
const hostname = line.slice(0, i);
if ( hostname === '' ) { continue; }
const selector = line.slice(i+2);
if ( selector === '' ) { continue; }
const selectors = hostnameMap.get(hostname) || [];
if ( selectors.length === 0 ) {
hostnameMap.set(hostname, selectors)
}
selectors.push(selector);
}
const promises = [];
for ( const [ hostname, selectors ] of hostnameMap ) {
promises.push(
sendMessage({ what: 'addCustomFilters', hostname, selectors })
);
}
await Promise.all(promises);
const dnrRules = targetConfig.dnrRules ?? [];
await localWrite('userDnrRules', dnrRules.join('\n'));
await sendMessage({ what: 'updateUserDnrRules' });
}

View file

@ -37,6 +37,8 @@ export const rulesetConfig = {
hasBroadHostPermissions: true,
};
export const defaultConfig = Object.assign({}, rulesetConfig);
export const process = {
firstRun: false,
wakeupRun: false,

View file

@ -227,6 +227,14 @@ export async function addCustomFilters(hostname, toAdd) {
/******************************************************************************/
export async function removeAllCustomFilters(hostname) {
if ( hostname === '*' ) {
const keys = await getAllCustomFilterKeys();
if ( keys.length === 0 ) { return false; }
for ( const key of keys ) {
removeFromStorage(key);
}
return true;
}
const key = `site.${hostname}`;
const selectors = await readFromStorage(key) || [];
removeFromStorage(key);

View file

@ -53,6 +53,13 @@ export const MODE_BASIC = 1;
export const MODE_OPTIMAL = 2;
export const MODE_COMPLETE = 3;
export const defaultFilteringModes = {
none: [],
basic: [],
optimal: [ 'all-urls' ],
complete: [],
};
/******************************************************************************/
const pruneDescendantHostnamesFromSet = (hostname, hnSet) => {
@ -225,7 +232,7 @@ export async function readFilteringModeDetails(bypassCache = false) {
}
}
let [
userModes = { optimal: [ 'all-urls' ] },
userModes = structuredClone(defaultFilteringModes),
adminDefaultFiltering,
adminNoFiltering,
] = await Promise.all([

View file

@ -75,12 +75,11 @@ function getRulesetDetails() {
if ( getRulesetDetails.rulesetDetailsPromise !== undefined ) {
return getRulesetDetails.rulesetDetailsPromise;
}
getRulesetDetails.rulesetDetailsPromise = fetchJSON('/rulesets/ruleset-details').then(entries => {
const rulesMap = new Map(
entries.map(entry => [ entry.id, entry ])
);
return rulesMap;
});
getRulesetDetails.rulesetDetailsPromise =
fetchJSON('/rulesets/ruleset-details').then(entries => {
const rulesMap = new Map(entries.map(entry => [ entry.id, entry ]));
return rulesMap;
});
return getRulesetDetails.rulesetDetailsPromise;
}
@ -410,7 +409,7 @@ async function filteringModesToDNR(modes) {
/******************************************************************************/
async function defaultRulesetsFromEnv() {
export async function getDefaultRulesetsFromEnv() {
const dropCountry = lang => {
const pos = lang.indexOf('-');
if ( pos === -1 ) { return lang; }
@ -466,7 +465,7 @@ async function patchDefaultRulesets() {
staticRulesetIds,
] = await Promise.all([
localRead('defaultRulesetIds'),
defaultRulesetsFromEnv(),
getDefaultRulesetsFromEnv(),
getStaticRulesets().then(r => r.map(a => a.id)),
]);
const toAdd = [];

View file

@ -132,6 +132,49 @@ dom.on(
/******************************************************************************/
async function backupSettings() {
const api = await import('./backup-restore.js');
const data = await api.backupToObject(cachedRulesetData);
if ( data instanceof Object === false ) { return; }
const json = JSON.stringify(data, null, 2) + '\n';
const a = document.createElement('a');
a.href = `data:text/plain;charset=utf-8,${encodeURIComponent(json)}`;
dom.attr(a, 'download', 'my-ubol-settings.json');
dom.attr(a, 'type', 'application/json');
a.click();
}
async function restoreSettings() {
const input = qs$('section[data-pane="settings"] input[type="file"]');
input.onchange = ev => {
input.onchange = null;
const file = ev.target.files[0];
if ( file === undefined || file.name === '' ) { return; }
const fr = new FileReader();
fr.onload = ( ) => {
fr.onload = null;
if ( typeof fr.result !== 'string' ) { return; }
let data;
try {
data = JSON.parse(fr.result);
} catch {
}
if ( data instanceof Object === false ) { return; }
import('./backup-restore.js').then(api => {
api.restoreFromObject(data);
});
};
fr.readAsText(file);
};
// Reset to empty string, this will ensure a change event is properly
// triggered if the user pick a file, even if it's the same as the last
// one picked.
input.value = '';
input.click();
}
/******************************************************************************/
dom.on('#autoReload input[type="checkbox"]', 'change', ev => {
sendMessage({
what: 'setAutoReload',
@ -159,6 +202,14 @@ dom.on('#developerMode input[type="checkbox"]', 'change', ev => {
dom.body.dataset.develop = `${state}`;
});
dom.on('section[data-pane="settings"] [data-i18n="backupButton"]', 'click', ( ) => {
backupSettings();
});
dom.on('section[data-pane="settings"] [data-i18n="restoreButton"]', 'click', ( ) => {
restoreSettings();
});
/******************************************************************************/
function listen() {