diff --git a/Makefile b/Makefile index 194a3af43..b308dc4be 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,8 @@ # https://stackoverflow.com/a/6273809 run_options := $(filter-out $@,$(MAKECMDGOALS)) -.PHONY: all clean cleanassets test lint chromium opera firefox npm dig mv3 mv3-quick \ +.PHONY: all clean cleanassets test lint chromium opera firefox npm dig \ + mv3 mv3-quick mv3-chromium mv3-firefox \ compare maxcost medcost mincost modifiers record wasm sources := $(wildcard assets/* assets/*/* dist/version src/* src/*/* src/*/*/* src/*/*/*/*) @@ -55,12 +56,16 @@ dig: dist/build/uBlock0.dig dig-snfe: dig cd dist/build/uBlock0.dig && npm run snfe $(run_options) -mv3-chromium: tools/make-mv3.sh $(sources) $(platform) +dist/build/uBOLite.chromium: tools/make-mv3.sh $(sources) $(platform) tools/make-mv3.sh chromium -mv3-firefox: tools/make-mv3.sh $(sources) $(platform) +mv3-chromium: dist/build/uBOLite.chromium + +dist/build/uBOLite.firefox: tools/make-mv3.sh $(sources) $(platform) tools/make-mv3.sh firefox +mv3-firefox: dist/build/uBOLite.firefox + mv3-quick: tools/make-mv3.sh $(sources) $(platform) tools/make-mv3.sh quick diff --git a/platform/mv3/extension/css/settings.css b/platform/mv3/extension/css/settings.css index b81e79a52..3de13560c 100644 --- a/platform/mv3/extension/css/settings.css +++ b/platform/mv3/extension/css/settings.css @@ -114,11 +114,19 @@ h3[data-i18n="filteringMode0Name"]::first-letter { .groupEntry:not([data-groupkey="user"]) .listEntry:not(.isDefault).unused { display: none; } + +.listEntry.fromAdmin:has(input[disabled]:not(:checked)) { + display: none; + } .listEntry > * { margin-left: 0; margin-right: 0; unicode-bidi: embed; } +.listEntry .checkbox:has(input[disabled]), +.listEntry .checkbox:has(input[disabled]) ~ span { + filter: var(--checkbox-disabled-filter); + } .listEntry .listname { white-space: nowrap; } diff --git a/platform/mv3/extension/js/admin.js b/platform/mv3/extension/js/admin.js new file mode 100644 index 000000000..8a6b91fb7 --- /dev/null +++ b/platform/mv3/extension/js/admin.js @@ -0,0 +1,121 @@ +/******************************************************************************* + + 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 { + adminRead, + localRead, localWrite, + sessionRead, sessionWrite, +} from './ext.js'; + +import { + enableRulesets, + getRulesetDetails, +} from './ruleset-manager.js'; + +import { + getTrustedSites, + readFilteringModeDetails, +} from './mode-manager.js'; + +import { broadcastMessage } from './utils.js'; +import { dnr } from './ext.js'; +import { registerInjectables } from './scripting-manager.js'; +import { rulesetConfig } from './config.js'; +import { ubolLog } from './debug.js'; + +/******************************************************************************/ + +const adminSettings = { + keys: new Set(), + timer: undefined, + change(key) { + this.keys.add(key); + if ( this.timer !== undefined ) { return; } + this.timer = self.setTimeout(( ) => { + this.timer = undefined; + this.process(); + }, 127); + }, + async process() { + if ( this.keys.has('rulesets') ) { + ubolLog('admin setting "rulesets" changed'); + await enableRulesets(rulesetConfig.enabledRulesets); + await registerInjectables(); + const results = await Promise.all([ + getAdminRulesets(), + dnr.getEnabledRulesets(), + ]); + const [ adminRulesets, enabledRulesets ] = results; + broadcastMessage({ adminRulesets, enabledRulesets }); + } + if ( this.keys.has('noFiltering') ) { + ubolLog('admin setting "noFiltering" changed'); + await readFilteringModeDetails(true); + const trustedSites = await getTrustedSites(); + broadcastMessage({ trustedSites: Array.from(trustedSites) }); + } + this.keys.clear(); + } +}; + +/******************************************************************************/ + +export async function getAdminRulesets() { + const adminList = await adminReadEx('rulesets'); + const adminRulesets = new Set(Array.isArray(adminList) && adminList || []); + if ( adminRulesets.has('-*') ) { + adminRulesets.delete('-*'); + const rulesetDetails = await getRulesetDetails(); + for ( const ruleset of rulesetDetails.values() ) { + if ( ruleset.enabled ) { continue; } + if ( adminRulesets.has(`+${ruleset.id}`) ) { continue; } + adminRulesets.add(`-${ruleset.id}`); + } + } + return Array.from(adminRulesets); +} + +/******************************************************************************/ + +export async function adminReadEx(key) { + let cacheValue; + const session = await sessionRead(`admin_${key}`); + if ( session ) { + cacheValue = session.data; + } else { + const local = await localRead(`admin_${key}`); + if ( local ) { + cacheValue = local.data; + } + } + adminRead(key).then(async value => { + const adminKey = `admin_${key}`; + await Promise.all([ + sessionWrite(adminKey, { data: value }), + localWrite(adminKey, { data: value }), + ]); + if ( JSON.stringify(value) === JSON.stringify(cacheValue) ) { return; } + adminSettings.change(key); + }); + return cacheValue; +} + +/******************************************************************************/ diff --git a/platform/mv3/extension/js/background.js b/platform/mv3/extension/js/background.js index 791625d2a..ea8c9f608 100644 --- a/platform/mv3/extension/js/background.js +++ b/platform/mv3/extension/js/background.js @@ -19,24 +19,6 @@ Home: https://github.com/gorhill/uBlock */ -import { - adminRead, - browser, - dnr, - localRead, localWrite, - runtime, - sessionRead, sessionWrite, - windows, -} from './ext.js'; - -import { - defaultRulesetsFromLanguage, - enableRulesets, - getEnabledRulesetsDetails, - getRulesetDetails, - updateDynamicRules, -} from './ruleset-manager.js'; - import { MODE_BASIC, MODE_OPTIMAL, @@ -49,75 +31,51 @@ import { syncWithBrowserPermissions, } from './mode-manager.js'; +import { + adminRead, + browser, + dnr, + localRead, localWrite, + runtime, + windows, +} from './ext.js'; + +import { + enableRulesets, + getEnabledRulesetsDetails, + getRulesetDetails, + updateDynamicRules, +} from './ruleset-manager.js'; + import { getMatchedRules, isSideloaded, ubolLog, } from './debug.js'; +import { + loadRulesetConfig, + process, + rulesetConfig, + saveRulesetConfig, +} from './config.js'; + import { broadcastMessage } from './utils.js'; +import { getAdminRulesets } from './admin.js'; import { registerInjectables } from './scripting-manager.js'; /******************************************************************************/ -const rulesetConfig = { - version: '', - enabledRulesets: [ 'default' ], - autoReload: true, - showBlockedCount: true, -}; - const UBOL_ORIGIN = runtime.getURL('').replace(/\/$/, ''); const canShowBlockedCount = typeof dnr.setExtensionActionOptions === 'function'; -let firstRun = false; -let wakeupRun = false; - /******************************************************************************/ function getCurrentVersion() { return runtime.getManifest().version; } -async function loadRulesetConfig() { - let data = await sessionRead('rulesetConfig'); - if ( data ) { - rulesetConfig.version = data.version; - rulesetConfig.enabledRulesets = data.enabledRulesets; - rulesetConfig.autoReload = typeof data.autoReload === 'boolean' - ? data.autoReload - : true; - rulesetConfig.showBlockedCount = typeof data.showBlockedCount === 'boolean' - ? data.showBlockedCount - : true; - wakeupRun = true; - return; - } - data = await localRead('rulesetConfig'); - if ( data ) { - rulesetConfig.version = data.version; - rulesetConfig.enabledRulesets = data.enabledRulesets; - rulesetConfig.autoReload = typeof data.autoReload === 'boolean' - ? data.autoReload - : true; - rulesetConfig.showBlockedCount = typeof data.showBlockedCount === 'boolean' - ? data.showBlockedCount - : true; - sessionWrite('rulesetConfig', rulesetConfig); - return; - } - rulesetConfig.enabledRulesets = await defaultRulesetsFromLanguage(); - sessionWrite('rulesetConfig', rulesetConfig); - localWrite('rulesetConfig', rulesetConfig); - firstRun = true; -} - -async function saveRulesetConfig() { - sessionWrite('rulesetConfig', rulesetConfig); - return localWrite('rulesetConfig', rulesetConfig); -} - /******************************************************************************/ async function hasGreatPowers(origin) { @@ -216,7 +174,9 @@ function onMessage(request, sender, callback) { }).then(( ) => { registerInjectables(); callback(); - broadcastMessage({ enabledRulesets: rulesetConfig.enabledRulesets }); + return dnr.getEnabledRulesets(); + }).then(enabledRulesets => { + broadcastMessage({ enabledRulesets }); }); return true; } @@ -227,25 +187,28 @@ function onMessage(request, sender, callback) { getTrustedSites(), getRulesetDetails(), dnr.getEnabledRulesets(), + getAdminRulesets(), ]).then(results => { const [ defaultFilteringMode, trustedSites, rulesetDetails, enabledRulesets, + adminRulesets, ] = results; callback({ defaultFilteringMode, trustedSites: Array.from(trustedSites), enabledRulesets, + adminRulesets, maxNumberOfEnabledRulesets: dnr.MAX_NUMBER_OF_ENABLED_STATIC_RULESETS, rulesetDetails: Array.from(rulesetDetails.values()), autoReload: rulesetConfig.autoReload, showBlockedCount: rulesetConfig.showBlockedCount, canShowBlockedCount, - firstRun, + firstRun: process.firstRun, }); - firstRun = false; + process.firstRun = false; }); return true; } @@ -367,6 +330,8 @@ function onMessage(request, sender, callback) { default: break; } + + return false; } /******************************************************************************/ @@ -374,12 +339,12 @@ function onMessage(request, sender, callback) { async function start() { await loadRulesetConfig(); - if ( wakeupRun === false ) { + if ( process.wakeupRun === false ) { await enableRulesets(rulesetConfig.enabledRulesets); } // We need to update the regex rules only when ruleset version changes. - if ( wakeupRun === false ) { + if ( process.wakeupRun === false ) { const currentVersion = getCurrentVersion(); if ( currentVersion !== rulesetConfig.version ) { ubolLog(`Version change: ${rulesetConfig.version} => ${currentVersion}`); @@ -396,7 +361,7 @@ async function start() { // Unsure whether the browser remembers correctly registered css/scripts // after we quit the browser. For now uBOL will check unconditionally at // launch time whether content css/scripts are properly registered. - if ( wakeupRun === false || permissionsChanged ) { + if ( process.wakeupRun === false || permissionsChanged ) { registerInjectables(); const enabledRulesets = await dnr.getEnabledRulesets(); @@ -409,7 +374,7 @@ async function start() { // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/declarativeNetRequest // Firefox API does not support `dnr.setExtensionActionOptions` - if ( wakeupRun === false && canShowBlockedCount ) { + if ( process.wakeupRun === false && canShowBlockedCount ) { dnr.setExtensionActionOptions({ displayActionCountAsBadgeText: rulesetConfig.showBlockedCount, }); @@ -421,7 +386,7 @@ async function start() { ( ) => { onPermissionsRemoved(); } ); - if ( firstRun ) { + if ( process.firstRun ) { const enableOptimal = await hasOmnipotence(); if ( enableOptimal ) { const afterLevel = await setDefaultFilteringMode(MODE_OPTIMAL); @@ -434,7 +399,7 @@ async function start() { if ( disableFirstRunPage !== true ) { runtime.openOptionsPage(); } else { - firstRun = false; + process.firstRun = false; } } } diff --git a/platform/mv3/extension/js/config.js b/platform/mv3/extension/js/config.js new file mode 100644 index 000000000..bb74a4416 --- /dev/null +++ b/platform/mv3/extension/js/config.js @@ -0,0 +1,81 @@ +/******************************************************************************* + + 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, + sessionRead, sessionWrite, +} from './ext.js'; + +import { defaultRulesetsFromLanguage } from './ruleset-manager.js'; + +/******************************************************************************/ + +export const rulesetConfig = { + version: '', + enabledRulesets: [ 'default' ], + autoReload: true, + showBlockedCount: true, +}; + +export const process = { + firstRun: false, + wakeupRun: false, +}; + +/******************************************************************************/ + +export async function loadRulesetConfig() { + const sessionData = await sessionRead('rulesetConfig'); + if ( sessionData ) { + rulesetConfig.version = sessionData.version; + rulesetConfig.enabledRulesets = sessionData.enabledRulesets; + rulesetConfig.autoReload = typeof sessionData.autoReload === 'boolean' + ? sessionData.autoReload + : true; + rulesetConfig.showBlockedCount = typeof sessionData.showBlockedCount === 'boolean' + ? sessionData.showBlockedCount + : true; + process.wakeupRun = true; + return; + } + const localData = await localRead('rulesetConfig'); + if ( localData ) { + rulesetConfig.version = localData.version; + rulesetConfig.enabledRulesets = localData.enabledRulesets; + rulesetConfig.autoReload = typeof localData.autoReload === 'boolean' + ? localData.autoReload + : true; + rulesetConfig.showBlockedCount = typeof localData.showBlockedCount === 'boolean' + ? localData.showBlockedCount + : true; + sessionWrite('rulesetConfig', rulesetConfig); + return; + } + rulesetConfig.enabledRulesets = await defaultRulesetsFromLanguage(); + sessionWrite('rulesetConfig', rulesetConfig); + localWrite('rulesetConfig', rulesetConfig); + process.firstRun = true; +} + +export async function saveRulesetConfig() { + sessionWrite('rulesetConfig', rulesetConfig); + return localWrite('rulesetConfig', rulesetConfig); +} diff --git a/platform/mv3/extension/js/mode-manager.js b/platform/mv3/extension/js/mode-manager.js index c3b9a01bb..335c8da16 100644 --- a/platform/mv3/extension/js/mode-manager.js +++ b/platform/mv3/extension/js/mode-manager.js @@ -24,14 +24,6 @@ import { getDynamicRules, } from './ruleset-manager.js'; -import { - adminRead, - browser, - dnr, - localRead, localRemove, localWrite, - sessionRead, sessionWrite, -} from './ext.js'; - import { broadcastMessage, hostnamesFromMatches, @@ -39,6 +31,15 @@ import { toBroaderHostname, } from './utils.js'; +import { + browser, + dnr, + localRead, localWrite, + sessionRead, sessionWrite, +} from './ext.js'; + +import { adminReadEx } from './admin.js'; + /******************************************************************************/ // 0: no filtering @@ -224,38 +225,40 @@ function applyFilteringMode(filteringModes, hostname, afterLevel) { /******************************************************************************/ -async function readFilteringModeDetails() { - if ( readFilteringModeDetails.cache ) { - return readFilteringModeDetails.cache; - } - const sessionModes = await sessionRead('filteringModeDetails'); - if ( sessionModes instanceof Object ) { - readFilteringModeDetails.cache = unserializeModeDetails(sessionModes); - return readFilteringModeDetails.cache; +export async function readFilteringModeDetails(bypassCache = false) { + if ( bypassCache === false ) { + if ( readFilteringModeDetails.cache ) { + return readFilteringModeDetails.cache; + } + const sessionModes = await sessionRead('filteringModeDetails'); + if ( sessionModes instanceof Object ) { + readFilteringModeDetails.cache = unserializeModeDetails(sessionModes); + return readFilteringModeDetails.cache; + } } let [ userModes, adminNoFiltering ] = await Promise.all([ localRead('filteringModeDetails'), - localRead('adminNoFiltering'), + adminReadEx('noFiltering'), ]); if ( userModes === undefined ) { userModes = { basic: [ 'all-urls' ] }; } userModes = unserializeModeDetails(userModes); if ( Array.isArray(adminNoFiltering) ) { + if ( adminNoFiltering.includes('-*') ) { + userModes.none.clear(); + } for ( const hn of adminNoFiltering ) { - applyFilteringMode(userModes, hn, 0); + if ( hn.charAt(0) === '-' ) { + userModes.none.delete(hn.slice(1)); + } else { + applyFilteringMode(userModes, hn, 0); + } } } filteringModesToDNR(userModes); sessionWrite('filteringModeDetails', serializeModeDetails(userModes)); readFilteringModeDetails.cache = userModes; - adminRead('noFiltering').then(results => { - if ( results ) { - localWrite('adminNoFiltering', results); - } else { - localRemove('adminNoFiltering'); - } - }); return userModes; } diff --git a/platform/mv3/extension/js/ruleset-manager.js b/platform/mv3/extension/js/ruleset-manager.js index 7f7c6e1ee..d19a9f28e 100644 --- a/platform/mv3/extension/js/ruleset-manager.js +++ b/platform/mv3/extension/js/ruleset-manager.js @@ -19,8 +19,14 @@ Home: https://github.com/gorhill/uBlock */ -import { browser, dnr, i18n } from './ext.js'; +import { + browser, + dnr, + i18n, +} from './ext.js'; + import { fetchJSON } from './fetch.js'; +import { getAdminRulesets } from './admin.js'; import { ubolLog } from './debug.js'; /******************************************************************************/ @@ -460,7 +466,22 @@ async function defaultRulesetsFromLanguage() { async function enableRulesets(ids) { const afterIds = new Set(ids); - const beforeIds = new Set(await dnr.getEnabledRulesets()); + const [ beforeIds, adminIds, rulesetDetails ] = await Promise.all([ + dnr.getEnabledRulesets().then(ids => new Set(ids)), + getAdminRulesets(), + getRulesetDetails(), + ]); + + for ( const token of adminIds ) { + const c0 = token.charAt(0); + const id = token.slice(1); + if ( c0 === '+' ) { + afterIds.add(id); + } else if ( c0 === '-' ) { + afterIds.delete(id); + } + } + const enableRulesetSet = new Set(); const disableRulesetSet = new Set(); for ( const id of afterIds ) { @@ -472,13 +493,8 @@ async function enableRulesets(ids) { disableRulesetSet.add(id); } - if ( enableRulesetSet.size === 0 && disableRulesetSet.size === 0 ) { - return; - } - // Be sure the rulesets to enable/disable do exist in the current version, // otherwise the API throws. - const rulesetDetails = await getRulesetDetails(); for ( const id of enableRulesetSet ) { if ( rulesetDetails.has(id) ) { continue; } enableRulesetSet.delete(id); @@ -487,6 +503,11 @@ async function enableRulesets(ids) { if ( rulesetDetails.has(id) ) { continue; } disableRulesetSet.delete(id); } + + if ( enableRulesetSet.size === 0 && disableRulesetSet.size === 0 ) { + return; + } + const enableRulesetIds = Array.from(enableRulesetSet); const disableRulesetIds = Array.from(disableRulesetSet); @@ -497,7 +518,7 @@ async function enableRulesets(ids) { ubolLog(`Disable ruleset: ${disableRulesetIds}`); } await dnr.updateEnabledRulesets({ enableRulesetIds, disableRulesetIds }); - + return updateDynamicRules(); } diff --git a/platform/mv3/extension/js/settings.js b/platform/mv3/extension/js/settings.js index c916c4657..a970430f3 100644 --- a/platform/mv3/extension/js/settings.js +++ b/platform/mv3/extension/js/settings.js @@ -40,6 +40,18 @@ function hashFromIterable(iter) { return Array.from(iter).sort().join('\n'); } +function isAdminRuleset(listkey) { + const { adminRulesets = [] } = cachedRulesetData; + for ( const id of adminRulesets ) { + const pos = id.indexOf(listkey); + if ( pos === 0 ) { return true; } + if ( pos !== 1 ) { continue; } + const c = id.charAt(0); + if ( c === '+' || c === '-' ) { return true; } + } + return false; +} + /******************************************************************************/ function rulesetStats(rulesetId) { @@ -94,10 +106,13 @@ function renderFilterLists() { li.title = listStatsTemplate .replace('{{ruleCount}}', renderNumber(stats.ruleCount)) .replace('{{filterCount}}', renderNumber(stats.filterCount)); + const fromAdmin = isAdminRuleset(ruleset.id); + dom.cl.toggle(li, 'fromAdmin', fromAdmin); + const disabled = stats.ruleCount === 0 || fromAdmin; dom.attr( - qs$(li, '.input.checkbox'), + qs$(li, '.input.checkbox input'), 'disabled', - stats.ruleCount === 0 ? '' : null + disabled ? '' : null ); dom.cl.remove(li, 'discard'); return li; @@ -358,7 +373,9 @@ async function applyEnabledRulesets() { const checked = qs$(liEntry, 'input[type="checkbox"]:checked') !== null; dom.cl.toggle(liEntry, 'checked', checked); if ( checked === false ) { continue; } - enabledRulesets.push(liEntry.dataset.listkey); + const { listkey } = liEntry.dataset; + if ( isAdminRuleset(listkey) ) { continue; } + enabledRulesets.push(listkey); } await sendMessage({ @@ -433,9 +450,12 @@ localRead('hideUnusedFilterLists').then(value => { /******************************************************************************/ -const bc = new self.BroadcastChannel('uBOL'); +function listen() { + const bc = new self.BroadcastChannel('uBOL'); + bc.onmessage = listen.onmessage; +} -bc.onmessage = ev => { +listen.onmessage = ev => { const message = ev.data; if ( message instanceof Object === false ) { return; } const local = cachedRulesetData; @@ -477,6 +497,13 @@ bc.onmessage = ev => { } } + if ( message.adminRulesets !== undefined ) { + if ( hashFromIterable(message.adminRulesets) !== hashFromIterable(local.adminRulesets) ) { + local.adminRulesets = message.adminRulesets; + render = true; + } + } + if ( message.enabledRulesets !== undefined ) { if ( hashFromIterable(message.enabledRulesets) !== hashFromIterable(local.enabledRulesets) ) { local.enabledRulesets = message.enabledRulesets; @@ -503,6 +530,7 @@ sendMessage({ renderWidgets(); } catch(ex) { } + listen(); }).catch(reason => { console.trace(reason); }); diff --git a/platform/mv3/extension/js/utils.js b/platform/mv3/extension/js/utils.js index cdfc98b25..b32104f94 100644 --- a/platform/mv3/extension/js/utils.js +++ b/platform/mv3/extension/js/utils.js @@ -115,7 +115,7 @@ const hostnamesFromMatches = origins => { /******************************************************************************/ -export const broadcastMessage = message => { +const broadcastMessage = message => { const bc = new self.BroadcastChannel('uBOL'); bc.postMessage(message); }; @@ -123,6 +123,7 @@ export const broadcastMessage = message => { /******************************************************************************/ export { + broadcastMessage, parsedURLromOrigin, toBroaderHostname, isDescendantHostname, diff --git a/platform/mv3/extension/managed_storage.json b/platform/mv3/extension/managed_storage.json index 8571f59db..5d645fb03 100644 --- a/platform/mv3/extension/managed_storage.json +++ b/platform/mv3/extension/managed_storage.json @@ -10,6 +10,12 @@ "disableFirstRunPage": { "title": "Disable first run page", "type": "boolean" + }, + "rulesets": { + "title": "Rulesets to add/remove", + "description": "Prefix a ruleset id with '+' to add, or '-' to remove. Use '-*' to disable all non-default lists.", + "type": "array", + "items": { "type": "string" } } } }