/* license: The MIT License, Copyright (c) 2016-2023 YUKI "Piro" Hiroshi original: http://github.com/piroor/webextensions-lib-configs */ 'use strict'; /* There are multiple level values: (higher priority) * [default] locked managed values (given via GPO or policies.json) * [default] locked default values (given to the constructor) * [user] locked user values (given via API) => [default] it should fallback to the default value if there is no user value * [user] user values (local storage) * [default] non-locked managed values (given via GPO or policies.json) * [default] overridden default values (given via API) * [default] built-in default values (given to the constructor) (lower priority) Only values different from [default] are stored and synchronized. */ const OBSERVABLE_AREA = new Set([ 'internal', // TST internal 'local', 'sync', 'managed', ]); // eslint-disable-next-line no-unused-vars class Configs { constructor( defaults, { logging, logger, localKeys, syncKeys, sync } = { syncKeys: [], logger: null } ) { this._defaultValues = { ...this._clone(defaults), __ConfigsMigration__userValeusSameToDefaultAreCleared: false, }; this._lockedDefaultKeys = new Set(); this._managedValues = {}; this._lockedManagedKeys = new Set(); this._userValues = {}; this._lockedUserKeys = new Set(); this._fetchedValues = {}; this.$default = {}; this.$all = {}; for (const key of Object.keys(this._defaultValues)) { Object.defineProperty(this.$default, key, { get: () => this._getDefaultValue(key), set: (value) => this._setDefaultValue(key, value), enumerable: true, }); const description = { get: () => this._getValue(key), set: (value) => this._setValue(key, value), enumerable: true, }; Object.defineProperty(this, key, description); Object.defineProperty(this.$all, key, description); } for (const [key, locked] of Object.entries(defaults)) { if (!key.endsWith(':locked')) continue; if (locked) this._lockedDefaultKeys.add(key.replace(/:locked$/, '')); delete defaults[key]; } this.$logging = logging || false; this.$logs = []; this.$logger = logger; this.sync = sync === undefined ? true : !!sync; this._updating = new Map(); this._observers = new Set(); this._changedObservers = new Set(); this._localLoadedObservers = new Set(); this._syncKeys = [ ...(localKeys ? Object.keys(defaults).filter(x => !localKeys.includes(x)) : (syncKeys || [])), '__ConfigsMigration__userValeusSameToDefaultAreCleared', ]; this.$loaded = this._load(); this.$preReceivedChanges = []; this.$listeningChanges = false; browser.storage.onChanged.addListener(this._onChanged.bind(this)); this.$preReceivedMessages = []; this.$listeningMessages = false; browser.runtime.onMessage.addListener(this._onMessage.bind(this)); } $reset(key, { broadcast } = {}) { if (!key) { for (const key of Object.keys(this._defaultValues)) { this.$reset(key); } return; } if (!this._defaultValues.hasOwnProperty(key)) throw new Error(`failed to reset unknown key: ${key}`); this._setValue(key, this._getDefaultValue(key), true, { broadcast }); } $cleanUp({ broadcast } = {}) { for (const [key, defaultValue] of Object.entries(this.$default)) { if (!this._userValues.hasOwnProperty(key)) continue; const value = JSON.stringify(this._getNonDefaultValue(key)); if (value == JSON.stringify(defaultValue) || (this._managedValues.hasOwnProperty(key) && value == JSON.stringify(this._managedValues[key]))) this.$reset(key, { broadcast }); } } _getDefaultValue(key) { if (this._managedValues.hasOwnProperty(key)) return this._managedValues[key]; return this._defaultValues[key]; } _setDefaultValue(key, value, { broadcast } = {}) { if (!key) throw new Error(`missing key for default value ${value}`); if (!this._defaultValues.hasOwnProperty(key)) throw new Error(`failed to set default value for unknown key: ${key}`); const currentValue = this[key]; const currentDefaultValue = this._getDefaultValue(key); this._defaultValues[key] = this._clone(value); const defaultValue = this._getDefaultValue(key); if (JSON.stringify(defaultValue) == JSON.stringify(this._getNonDefaultValue[key])) this.$reset(key, { broadcast }); const newDefaultValue = this._getDefaultValue(key); if (currentValue == currentDefaultValue && currentValue != newDefaultValue && this[key] == newDefaultValue) { const observers = [...this._observers, ...this._changedObservers]; this.$notifyToObservers(key, value, observers, 'onChangeConfig'); } if (broadcast === false) return; try { browser.runtime.sendMessage({ type: 'Configs:updateDefaultValue', key: key, value: defaultValue, }).catch(_error => {}); } catch(_error) { } } _getNonDefaultValue(key) { if (this._userValues.hasOwnProperty(key)) return this._userValues[key]; if (this._managedValues.hasOwnProperty(key) && !this._lockedManagedKeys.has(key)) return this._managedValues[key]; return undefined; } $addLocalLoadedObserver(observer) { if (!this._localLoadedObservers.has(observer)) this._localLoadedObservers.add(observer); } $removeLocalLoadedObserver(observer) { this._localLoadedObservers.delete(observer); } $addChangedObserver(observer) { if (!this._changedObservers.has(observer)) this._changedObservers.add(observer); } $removeChangedObserver(observer) { this._changedObservers.delete(observer); } $addObserver(observer) { // for backward compatibility if (typeof observer == 'function') this.$addChangedObserver(observer); else if (!this._observers.has(observer)) this._observers.add(observer); } $removeObserver(observer) { // for backward compatibility if (typeof observer == 'function') this.$removeChangedObserver(observer); else this._observers.delete(observer); } _log(message, ...args) { message = `Configs[${location.href}] ${message}`; this.$logs = this.$logs.slice(-1000); if (!this.$logging) return; if (typeof this.$logger === 'function') this.$logger(message, ...args); else console.log(message, ...args); } _load() { return this.$_promisedLoad || (this.$_promisedLoad = this._tryLoad()); } async _tryLoad() { this._log('load'); try { this._log(`load: try load from storage on ${location.href}`); const [localValues, managedValues, lockedKeys] = await Promise.all([ (async () => { try { const localValues = await browser.storage.local.get(null); // keys must be "null" to get only stored values this._log('load: successfully loaded local storage'); const observers = [...this._observers, ...this._localLoadedObservers]; for (const [key, value] of Object.entries(localValues)) { this.$notifyToObservers(key, value, observers, 'onLocalLoaded'); } return localValues; } catch(e) { this._log('load: failed to load local storage: ', String(e)); } return {}; })(), (async () => { if (!browser.storage.managed) { this._log('load: skip managed storage'); return null; } return new Promise(async (resolve, _reject) => { const loadManagedStorage = () => { let resolved = false; return new Promise((resolve, reject) => { browser.storage.managed.get().then(managedValues => { if (resolved) return; resolved = true; this._log('load: successfully loaded managed storage'); resolve(managedValues || null); }).catch(error => { if (resolved) return; resolved = true; this._log('load: failed to load managed storage: ', String(error)); reject(error); }); // storage.managed.get() fails on options page in Thunderbird. // The problem should be fixed by Thunderbird side. setTimeout(() => { if (resolved) return; resolved = true; this._log('load: failed to load managed storage: timeout'); reject(new Error('timeout')); }, 250); }); }; for (let i = 0, maxi = 10; i < maxi; i++) { try { const result = await loadManagedStorage(); // On old versions Firefox and Thunderbird, a value with // REG_MULTI_SZ type is always delivered as a simple string, // thus we need to parse it by self. for (const [key, value] of Object.entries(result)) { const defaultValue = this._defaultValues[key]; if (typeof value != 'string') continue; const trimmed = value.trim(); if (Array.isArray(defaultValue)) { result[key] = (trimmed.startsWith('[') && trimmed.endsWith(']')) ? JSON.parse(value) : trimmed.includes('\n') ? trimmed.split('\n') : trimmed.split(','); } else if (defaultValue && typeof defaultValue == 'object' && trimmed.startsWith('{') && trimmed.endsWith('}')) { result[key] = JSON.parse(trimmed); } } resolve(result); return; } catch(error) { if (error.message != 'timeout') { console.log('managed storage is not provided'); resolve(null); return; } console.log('failed to load managed storage ', error); } await new Promise(resolve => setTimeout(resolve, 250)); } console.log('failed to load managed storage with 10 times retly'); resolve(null); }); })(), (async () => { try { const lockedKeys = await browser.runtime.sendMessage({ type: 'Configs:getLockedKeys' }); this._log('load: successfully synchronized locked state'); return lockedKeys || []; } catch(e) { this._log('load: failed to synchronize locked state: ', String(e)); } return []; })() ]); this._log(`load: loaded:`, { localValues, managedValues, lockedKeys }); lockedKeys.push(...this._lockedDefaultKeys); if (managedValues) { for (const [key, value] of Object.entries(managedValues)) { if (key.endsWith(':locked')) continue; const locked = managedValues[`${key}:locked`] !== false; this._managedValues[key] = value; if (locked) this._lockedManagedKeys.add(key); } } this._userValues = this._clone({ ...(localValues || {}) }); this._log('load: values are applied'); for (const key of new Set(lockedKeys)) { this._updateLocked(key, true); } this._log('load: locked state is applied'); this.$listeningChanges = true; if (this.sync && (this._syncKeys || this._syncKeys.length > 0)) { try { browser.storage.sync.get(this._syncKeys).then(syncedValues => { this._log('load: successfully loaded sync storage'); if (!syncedValues) return; for (const key of Object.keys(syncedValues)) { this[key] = syncedValues[key]; } }); } catch(e) { this._log('load: failed to read sync storage: ', String(e)); return null; } } this.$listeningMessages = true; if (!this.__ConfigsMigration__userValeusSameToDefaultAreCleared) { this.$cleanUp(); this.__ConfigsMigration__userValeusSameToDefaultAreCleared = true; } this.$_promisedLoad = this.$_promisedLoad.then(() => { if (this.$preReceivedChanges.length > 0) { const changes = [...this.$preReceivedChanges]; this.$preReceivedChanges = []; for (const change of changes) { this._onChanged(change, 'internal'); } } if (this.$preReceivedMessages.length > 0) { const messages = [...this.$preReceivedMessages]; this.$preReceivedMessages = []; for (const message of messages) { this._onMessage(message.message, message.sender); } } }); return this.$all; } catch(e) { this._log('load: fatal error: ', e, e.stack); throw e; } } _getValue(key) { if (this._lockedManagedKeys.has(key)) return this._managedValues[key]; if (this._lockedDefaultKeys.has(key)) return this._defaultValues[key]; if (this._lockedUserKeys.has(key)) return this._userValues[key] || this._getDefaultValue(key); if (this._userValues.hasOwnProperty(key)) return this._userValues[key]; if (this._managedValues.hasOwnProperty(key)) return this._managedValues[key]; if (this._defaultValues.hasOwnProperty(key)) return this._defaultValues[key]; throw new Error(`invalid access: unknown key ${key}`); } _setValue(key, value, force = false, { broadcast } = {}) { const newValue = this._clone(value); if (this._lockedDefaultKeys.has(key) || this._lockedManagedKeys.has(key) || this._lockedUserKeys.has(key)) { this._log(`warning: ${key} is locked and not updated`); return newValue; } const stringified = JSON.stringify(value); if (stringified == JSON.stringify(this._userValues[key]) && !force) { this._log(`skip: ${key} is not changed`); return newValue; } const oldValue = this._getValue(key); const shouldReset = stringified == JSON.stringify(this._getDefaultValue(key)); this._log(`set: ${key} = ${value}${shouldReset ? ' (reset to default)' : ''}`); if (shouldReset) delete this._userValues[key]; else this._userValues[key] = newValue; if (broadcast === false) return newValue; const update = {}; update[key] = newValue; try { const updatingValues = this._updating.get(key) || []; updatingValues.push(newValue); this._updating.set(key, updatingValues); const updated = shouldReset ? browser.storage.local.remove([key]).then(() => { this._log('local: successfully removed ', key); }) : browser.storage.local.set(update).then(() => { this._log('local: successfully saved ', update); }); updated .then(() => { setTimeout(() => { const updatingValues = this._updating.get(key); if (!updatingValues || !updatingValues.includes(newValue)) return; // failsafe: on Thunderbird updates sometimes won't be notified to the page itself. const changes = {}; changes[key] = { oldValue, newValue, }; this._onChanged(changes, 'internal'); }, 250); }); } catch(e) { this._log('save: failed', e); } try { if (this.sync && this._syncKeys.includes(key)) { if (shouldReset) browser.storage.sync.remove([key]).then(() => { this._log('sync: successfully removed', update); }); else browser.storage.sync.set(update).then(() => { this._log('sync: successfully synced', update); }); } } catch(e) { this._log('sync: failed', e); } return newValue; } $lock(key) { this._log('locking: ' + key); this._updateLocked(key, true); } $unlock(key) { this._log('unlocking: ' + key); this._updateLocked(key, false); } $isLocked(key) { return this._lockedUserKeys.has(key); } _updateLocked(key, locked, { broadcast } = {}) { if (locked) this._lockedUserKeys.add(key); else this._lockedUserKeys.delete(key); if (browser.runtime && broadcast !== false) { try { browser.runtime.sendMessage({ type: 'Configs:updateLocked', key: key, locked: this._lockedUserKeys.has(key), }).catch(_error => {}); } catch(_error) { } } } _onMessage(message, sender) { if (!message || typeof message.type != 'string') return; if (!this.$listeningMessages) { this.$preReceivedMessages.push({ message, sender }); return; } this._log(`onMessage: ${message.type}`, message, sender); switch (message.type) { case 'Configs:getLockedKeys': return Promise.resolve(Array.from(this._lockedUserKeys)); case 'Configs:updateLocked': this._updateLocked(message.key, message.locked, { broadcast: false }); break; case 'Configs:updateDefaultValue': this._setDefaultValue(message.key, message.value, { broadcast: false }); break; } } _onChanged(changes, areaName) { if (!OBSERVABLE_AREA.has(areaName)) return; if (!this.$listeningChanges) { this.$preReceivedChanges.push(changes); return; } this._log('_onChanged ', areaName, changes); const observers = [...this._observers, ...this._changedObservers]; for (const [key, change] of Object.entries(changes)) { // storage.local.onChanged is sometimes notified with delay, and it // unexpctedly reverts stored user value after it is changed multiple // times in short time range, and it may produce "ghost value" problem, like: // 1. setting to "true" (updates the stored value to "true" immediately) // 2. setting to "false" (updates the stored value to "false" immediately) // 3. "true" is notified (updates the stored value to "true" with delay) // 4. getting the value - it gots "true" instead of "false"! // To avoid such problems, we need to skip applying notified new value // if the notification is from a local change. const updatingValues = this._updating.get(key); if (updatingValues && updatingValues[0] == change.newValue) { updatingValues.shift(); } else { if ('newValue' in change) this._userValues[key] = this._clone(change.newValue); else delete this._userValues[key]; } if (!updatingValues || updatingValues.length == 0) this._updating.delete(key); else this._updating.set(key, updatingValues); const value = this._getNonDefaultValue(key); if (JSON.stringify(value) == JSON.stringify(this._getDefaultValue(key))) return; this.$notifyToObservers(key, value, observers, 'onChangeConfig'); } } $notifyToObservers(key, value, observers, observerMethod) { for (const observer of observers) { if (typeof observer === 'function') observer(key, value); else if (observer && typeof observer[observerMethod] === 'function') observer[observerMethod](key, value); } } _clone(value) { return JSON.parse(JSON.stringify(value)); } }; export default Configs;