From 2581004e8f22b3a21a11f9f486a4c360060b640e Mon Sep 17 00:00:00 2001
From: Raymond Hill
Date: Sat, 27 Sep 2025 12:53:58 -0400
Subject: [PATCH] [mv3] Add ability to backup/restore settings
Related issue:
https://github.com/uBlockOrigin/uBOL-home/issues/482
---
.../mv3/extension/_locales/en/messages.json | 20 +++
platform/mv3/extension/dashboard.html | 11 +-
platform/mv3/extension/js/background.js | 30 +++-
platform/mv3/extension/js/backup-restore.js | 149 ++++++++++++++++++
platform/mv3/extension/js/config.js | 2 +
platform/mv3/extension/js/filter-manager.js | 8 +
platform/mv3/extension/js/mode-manager.js | 9 +-
platform/mv3/extension/js/ruleset-manager.js | 15 +-
platform/mv3/extension/js/settings.js | 51 ++++++
9 files changed, 278 insertions(+), 17 deletions(-)
create mode 100644 platform/mv3/extension/js/backup-restore.js
diff --git a/platform/mv3/extension/_locales/en/messages.json b/platform/mv3/extension/_locales/en/messages.json
index 94b67ea9e..4d95465e4 100644
--- a/platform/mv3/extension/_locales/en/messages.json
+++ b/platform/mv3/extension/_locales/en/messages.json
@@ -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"
diff --git a/platform/mv3/extension/dashboard.html b/platform/mv3/extension/dashboard.html
index 8b362bb70..dcb6cef69 100644
--- a/platform/mv3/extension/dashboard.html
+++ b/platform/mv3/extension/dashboard.html
@@ -44,7 +44,7 @@
-
+
+
+
+
_
+
_
+
+
+
+
+
diff --git a/platform/mv3/extension/js/background.js b/platform/mv3/extension/js/background.js
index 863fe1c8c..ea96b5563 100644
--- a/platform/mv3/extension/js/background.js
+++ b/platform/mv3/extension/js/background.js
@@ -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(),
diff --git a/platform/mv3/extension/js/backup-restore.js b/platform/mv3/extension/js/backup-restore.js
new file mode 100644
index 000000000..75e548d54
--- /dev/null
+++ b/platform/mv3/extension/js/backup-restore.js
@@ -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' });
+
+}
diff --git a/platform/mv3/extension/js/config.js b/platform/mv3/extension/js/config.js
index b0c393794..988223a75 100644
--- a/platform/mv3/extension/js/config.js
+++ b/platform/mv3/extension/js/config.js
@@ -37,6 +37,8 @@ export const rulesetConfig = {
hasBroadHostPermissions: true,
};
+export const defaultConfig = Object.assign({}, rulesetConfig);
+
export const process = {
firstRun: false,
wakeupRun: false,
diff --git a/platform/mv3/extension/js/filter-manager.js b/platform/mv3/extension/js/filter-manager.js
index 506075d47..c7deaab93 100644
--- a/platform/mv3/extension/js/filter-manager.js
+++ b/platform/mv3/extension/js/filter-manager.js
@@ -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);
diff --git a/platform/mv3/extension/js/mode-manager.js b/platform/mv3/extension/js/mode-manager.js
index 0e74559a2..1190cd216 100644
--- a/platform/mv3/extension/js/mode-manager.js
+++ b/platform/mv3/extension/js/mode-manager.js
@@ -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([
diff --git a/platform/mv3/extension/js/ruleset-manager.js b/platform/mv3/extension/js/ruleset-manager.js
index 22d48f385..b6393ea06 100644
--- a/platform/mv3/extension/js/ruleset-manager.js
+++ b/platform/mv3/extension/js/ruleset-manager.js
@@ -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 = [];
diff --git a/platform/mv3/extension/js/settings.js b/platform/mv3/extension/js/settings.js
index 14b902f46..30d311047 100644
--- a/platform/mv3/extension/js/settings.js
+++ b/platform/mv3/extension/js/settings.js
@@ -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() {