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 @@

- +


@@ -98,6 +98,15 @@

+
+
+

_

+

_

+

+   + +

+
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() {