Spaces:
Running
Running
/** | |
* 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; | |