Spaces:
Runtime error
Runtime error
| /** | |
| * Copyright (C) 2021 Thomas Weber | |
| * | |
| * This program is free software: you can redistribute it and/or modify | |
| * it under the terms of the GNU General Public License version 3 as | |
| * published by the Free Software Foundation. | |
| * | |
| * 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 <https://www.gnu.org/licenses/>. | |
| */ | |
| import addons from './generated/addon-manifests'; | |
| import upstreamMeta from './generated/upstream-meta.json'; | |
| import EventTargetShim from './event-target'; | |
| const SETTINGS_KEY = 'tw:addons'; | |
| const VERSION = 4; | |
| const migrateSettings = settings => { | |
| const oldVersion = settings._; | |
| if (oldVersion === VERSION || !oldVersion) { | |
| return settings; | |
| } | |
| // Migrate 1 -> 2 | |
| // tw-project-info is now block-count | |
| // tw-interface-customization split into tw-remove-backpack and tw-remove-feedback | |
| if (oldVersion < 2) { | |
| const projectInfo = settings['tw-project-info']; | |
| if (projectInfo && projectInfo.enabled) { | |
| settings['block-count'] = { | |
| enabled: true | |
| }; | |
| } | |
| const interfaceCustomization = settings['tw-interface-customization']; | |
| if (interfaceCustomization && interfaceCustomization.enabled) { | |
| if (interfaceCustomization.removeBackpack) { | |
| settings['tw-remove-backpack'] = { | |
| enabled: true | |
| }; | |
| } | |
| if (interfaceCustomization.removeFeedback) { | |
| settings['tw-remove-feedback'] = { | |
| enabled: true | |
| }; | |
| } | |
| } | |
| } | |
| // Migrate 2 -> 3 | |
| // The default value of hide-flyout's toggle setting changed from "hover" to "cathover" | |
| // We want to keep the old default value for existing users. | |
| if (oldVersion < 3) { | |
| const hideFlyout = settings['hide-flyout']; | |
| if (hideFlyout && hideFlyout.enabled && typeof hideFlyout.toggled === 'undefined') { | |
| hideFlyout.toggle = 'hover'; | |
| } | |
| } | |
| // Migrate 3 -> 4 | |
| // editor-devtools was broken up into find-bar and middle-click-popup. | |
| // If someone disabled editor-devtools, we want to keep these disabled. | |
| if (oldVersion < 4) { | |
| const editorDevtools = settings['editor-devtools']; | |
| if (editorDevtools && editorDevtools.enabled === false) { | |
| settings['find-bar'] = { | |
| enabled: false | |
| }; | |
| settings['middle-click-popup'] = { | |
| enabled: false | |
| }; | |
| } | |
| } | |
| return settings; | |
| }; | |
| /** | |
| * @template T | |
| * @param {T|T[]} v A value | |
| * @returns {T[]} The value if it is a list, otherwise a 1 item list | |
| */ | |
| const asArray = v => { | |
| if (Array.isArray(v)) { | |
| return v; | |
| } | |
| return [v]; | |
| }; | |
| class SettingsStore extends EventTargetShim { | |
| constructor () { | |
| super(); | |
| this.store = this.createEmptyStore(); | |
| this.remote = false; | |
| } | |
| /** | |
| * @private | |
| */ | |
| createEmptyStore () { | |
| const result = {}; | |
| for (const addonId of Object.keys(addons)) { | |
| result[addonId] = {}; | |
| } | |
| return result; | |
| } | |
| readLocalStorage () { | |
| const base = this.store; | |
| try { | |
| const local = localStorage.getItem(SETTINGS_KEY); | |
| if (local) { | |
| let result = JSON.parse(local); | |
| if (result && typeof result === 'object') { | |
| result = migrateSettings(result); | |
| for (const key of Object.keys(result)) { | |
| if (base.hasOwnProperty(key)) { | |
| const value = result[key]; | |
| if (value && typeof value === 'object') { | |
| base[key] = value; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } catch (e) { | |
| // ignore | |
| } | |
| this.store = base; | |
| } | |
| /** | |
| * @private | |
| */ | |
| saveToLocalStorage () { | |
| if (this.remote) { | |
| return; | |
| } | |
| try { | |
| const result = { | |
| _: VERSION | |
| }; | |
| for (const addonId of Object.keys(addons)) { | |
| const data = this.getAddonStorage(addonId); | |
| if (Object.keys(data).length > 0) { | |
| result[addonId] = data; | |
| } | |
| } | |
| localStorage.setItem(SETTINGS_KEY, JSON.stringify(result)); | |
| } catch (e) { | |
| // ignore | |
| } | |
| } | |
| /** | |
| * @private | |
| */ | |
| getAddonStorage (addonId) { | |
| if (this.store[addonId]) { | |
| return this.store[addonId]; | |
| } | |
| throw new Error(`Unknown addon store: ${addonId}`); | |
| } | |
| /** | |
| * @private | |
| */ | |
| getAddonManifest (addonId) { | |
| if (addons[addonId]) { | |
| return addons[addonId]; | |
| } | |
| throw new Error(`Unknown addon: ${addonId}`); | |
| } | |
| /** | |
| * @private | |
| */ | |
| getAddonSettingObject (manifest, settingId) { | |
| if (!manifest.settings) { | |
| return null; | |
| } | |
| for (const setting of manifest.settings) { | |
| if (setting.id === settingId) { | |
| return setting; | |
| } | |
| } | |
| return null; | |
| } | |
| getAddonEnabled (addonId) { | |
| const manifest = this.getAddonManifest(addonId); | |
| if (manifest.unsupported) { | |
| return false; | |
| } | |
| const storage = this.getAddonStorage(addonId); | |
| if (storage.hasOwnProperty('enabled')) { | |
| return storage.enabled; | |
| } | |
| return !!manifest.enabledByDefault; | |
| } | |
| getAddonSetting (addonId, settingId) { | |
| const storage = this.getAddonStorage(addonId); | |
| const manifest = this.getAddonManifest(addonId); | |
| const settingObject = this.getAddonSettingObject(manifest, settingId); | |
| if (!settingObject) { | |
| throw new Error(`Unknown setting: ${settingId}`); | |
| } | |
| if (storage.hasOwnProperty(settingId)) { | |
| return storage[settingId]; | |
| } | |
| return settingObject.default; | |
| } | |
| /** | |
| * @private | |
| */ | |
| getDefaultSettings (addonId) { | |
| const manifest = this.getAddonManifest(addonId); | |
| const result = {}; | |
| for (const {id, default: value} of manifest.settings) { | |
| result[id] = value; | |
| } | |
| return result; | |
| } | |
| setAddonEnabled (addonId, enabled) { | |
| const storage = this.getAddonStorage(addonId); | |
| const manifest = this.getAddonManifest(addonId); | |
| const oldValue = this.getAddonEnabled(addonId); | |
| if (enabled === null) { | |
| enabled = !!manifest.enabledByDefault; | |
| delete storage.enabled; | |
| } else if (typeof enabled === 'boolean') { | |
| storage.enabled = enabled; | |
| } else { | |
| throw new Error('Enabled value is invalid.'); | |
| } | |
| this.saveToLocalStorage(); | |
| if (enabled !== oldValue) { | |
| // Dynamic enable is always supported. | |
| // Dynamic disable requires addon support. | |
| const supportsDynamic = enabled ? true : !!manifest.dynamicDisable; | |
| this.dispatchEvent(new CustomEvent('setting-changed', { | |
| detail: { | |
| addonId, | |
| settingId: 'enabled', | |
| reloadRequired: !supportsDynamic, | |
| value: enabled | |
| } | |
| })); | |
| } | |
| } | |
| setAddonSetting (addonId, settingId, value) { | |
| const storage = this.getAddonStorage(addonId); | |
| const manifest = this.getAddonManifest(addonId); | |
| const settingObject = this.getAddonSettingObject(manifest, settingId); | |
| const oldValue = this.getAddonSetting(addonId, settingId); | |
| if (value === null) { | |
| value = settingObject.default; | |
| delete storage[settingId]; | |
| } else { | |
| if (settingObject.type === 'boolean') { | |
| if (typeof value !== 'boolean') { | |
| throw new Error('Setting value is invalid.'); | |
| } | |
| } else if (settingObject.type === 'integer') { | |
| if (typeof value !== 'number') { | |
| throw new Error('Setting value is invalid.'); | |
| } | |
| } else if (settingObject.type === 'color') { | |
| if (typeof value !== 'string') { | |
| throw new Error('Color value is not a string.'); | |
| } | |
| // Remove alpha channel from colors like #012345ff | |
| // We don't support transparency yet, but settings imported from Scratch Addons | |
| // might contain transparency. | |
| if (value.length === 9) { | |
| value = value.substring(0, 7); | |
| } | |
| if (!/^#[0-9a-f]{6}$/i.test(value)) { | |
| throw new Error('Color value is invalid format.'); | |
| } | |
| } else if (settingObject.type === 'select') { | |
| if (!settingObject.potentialValues.some(potentialValue => potentialValue.id === value)) { | |
| throw new Error('Setting value is invalid.'); | |
| } | |
| } else { | |
| throw new Error('Setting object is of unknown type'); | |
| } | |
| storage[settingId] = value; | |
| } | |
| this.saveToLocalStorage(); | |
| if (value !== oldValue) { | |
| this.dispatchEvent(new CustomEvent('setting-changed', { | |
| detail: { | |
| addonId, | |
| settingId, | |
| reloadRequired: !settingObject.dynamic, | |
| value | |
| } | |
| })); | |
| } | |
| } | |
| applyAddonPreset (addonId, presetId) { | |
| const manifest = this.getAddonManifest(addonId); | |
| for (const {id, values} of manifest.presets) { | |
| if (id !== presetId) { | |
| continue; | |
| } | |
| const settings = { | |
| ...this.getDefaultSettings(addonId), | |
| ...values | |
| }; | |
| for (const key of Object.keys(settings)) { | |
| this.setAddonSetting(addonId, key, settings[key]); | |
| } | |
| return; | |
| } | |
| throw new Error(`Unknown preset: ${presetId}`); | |
| } | |
| resetAllAddons () { | |
| for (const addon of Object.keys(addons)) { | |
| this.resetAddon(addon, true); | |
| } | |
| // In case resetAddon missed some properties, do a hard reset on storage. | |
| this.store = this.createEmptyStore(); | |
| this.saveToLocalStorage(); | |
| } | |
| resetAddon (addonId, resetEverything) { | |
| const storage = this.getAddonStorage(addonId); | |
| for (const setting of Object.keys(storage)) { | |
| if (setting === 'enabled') { | |
| if (resetEverything) { | |
| this.setAddonEnabled(addonId, null); | |
| } | |
| continue; | |
| } | |
| try { | |
| this.setAddonSetting(addonId, setting, null); | |
| } catch (e) { | |
| // ignore | |
| } | |
| } | |
| } | |
| parseUrlParameter (parameter) { | |
| this.remote = true; | |
| const enabled = parameter.split(','); | |
| for (const id of Object.keys(addons)) { | |
| this.setAddonEnabled(id, enabled.includes(id)); | |
| } | |
| } | |
| export ({theme}) { | |
| const result = { | |
| core: { | |
| // Upstream property. We don't use this. | |
| lightTheme: theme === 'light', | |
| // Doesn't matter what we set this to | |
| version: `v1.0.0-tw-${upstreamMeta.commit}` | |
| }, | |
| addons: {} | |
| }; | |
| for (const [addonId, manifest] of Object.entries(addons)) { | |
| const enabled = this.getAddonEnabled(addonId); | |
| const settings = {}; | |
| if (manifest.settings) { | |
| for (const {id} of manifest.settings) { | |
| settings[id] = this.getAddonSetting(addonId, id); | |
| } | |
| } | |
| result.addons[addonId] = { | |
| enabled, | |
| settings | |
| }; | |
| } | |
| return result; | |
| } | |
| import (data) { | |
| for (const [addonId, value] of Object.entries(data.addons)) { | |
| if (!addons.hasOwnProperty(addonId)) { | |
| continue; | |
| } | |
| const {enabled, settings} = value; | |
| if (typeof enabled === 'boolean') { | |
| this.setAddonEnabled(addonId, enabled); | |
| } | |
| for (const [settingId, settingValue] of Object.entries(settings)) { | |
| try { | |
| this.setAddonSetting(addonId, settingId, settingValue); | |
| } catch (e) { | |
| // ignore | |
| } | |
| } | |
| } | |
| } | |
| setStoreWithVersionCheck ({version, store}) { | |
| if (version !== upstreamMeta.commit) { | |
| return; | |
| } | |
| this.setStore(store); | |
| } | |
| setStore (newStore) { | |
| const oldStore = this.store; | |
| for (const addonId of Object.keys(oldStore)) { | |
| const oldSettings = oldStore[addonId]; | |
| const newSettings = newStore[addonId]; | |
| if (!newSettings || typeof newSettings !== 'object') { | |
| continue; | |
| } | |
| if (JSON.stringify(oldSettings) !== JSON.stringify(newSettings)) { | |
| const manifest = this.getAddonManifest(addonId); | |
| // Dynamic enable is always supported. | |
| const dynamicEnable = !oldSettings.enabled && newSettings.enabled; | |
| // Dynamic disable requires addon support. | |
| const dynamicDisable = !!manifest.dynamicDisable && oldSettings.enabled && !newSettings.enabled; | |
| // Clone to avoid pass-by-reference issues | |
| this.store[addonId] = JSON.parse(JSON.stringify(newSettings)); | |
| this.dispatchEvent(new CustomEvent('addon-changed', { | |
| detail: { | |
| addonId, | |
| dynamicEnable, | |
| dynamicDisable | |
| } | |
| })); | |
| } | |
| } | |
| } | |
| /** | |
| * Evaluate an `if` value from addon.json. | |
| * @param {string} addonId The ID of the addon. | |
| * @param {unknown} condition Condition from addon.json | |
| * @returns {boolean} True if the condition is met. | |
| */ | |
| evaluateCondition (addonId, condition) { | |
| if (!condition) { | |
| // No condition. Default to true. | |
| return true; | |
| } | |
| if (condition.addonEnabled) { | |
| // addonEnabled is an OR | |
| const addonsToCheck = asArray(condition.addonEnabled); | |
| if (addonsToCheck.every(id => !this.getAddonEnabled(id))) { | |
| return false; | |
| } | |
| } | |
| if (condition.settings) { | |
| // settings is an AND | |
| for (const [settingName, expectedValue] of Object.entries(condition.settings)) { | |
| if (this.getAddonSetting(addonId, settingName) !== expectedValue) { | |
| return false; | |
| } | |
| } | |
| } | |
| return true; | |
| } | |
| } | |
| export default SettingsStore; | |