diff options
Diffstat (limited to 'src/api/Settings.ts')
-rw-r--r-- | src/api/Settings.ts | 283 |
1 files changed, 283 insertions, 0 deletions
diff --git a/src/api/Settings.ts b/src/api/Settings.ts new file mode 100644 index 0000000..e481e48 --- /dev/null +++ b/src/api/Settings.ts @@ -0,0 +1,283 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2022 Vendicated and contributors + * + * 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 <https://www.gnu.org/licenses/>. +*/ + +import { debounce } from "@utils/debounce"; +import { localStorage } from "@utils/localStorage"; +import { Logger } from "@utils/Logger"; +import { mergeDefaults } from "@utils/misc"; +import { putCloudSettings } from "@utils/settingsSync"; +import { DefinedSettings, OptionType, SettingsChecks, SettingsDefinition } from "@utils/types"; +import { React } from "@webpack/common"; + +import plugins from "~plugins"; + +const logger = new Logger("Settings"); +export interface Settings { + notifyAboutUpdates: boolean; + autoUpdate: boolean; + autoUpdateNotification: boolean, + useQuickCss: boolean; + enableReactDevtools: boolean; + themeLinks: string[]; + frameless: boolean; + transparent: boolean; + winCtrlQ: boolean; + macosTranslucency: boolean; + disableMinSize: boolean; + winNativeTitleBar: boolean; + plugins: { + [plugin: string]: { + enabled: boolean; + [setting: string]: any; + }; + }; + + notifications: { + timeout: number; + position: "top-right" | "bottom-right"; + useNative: "always" | "never" | "not-focused"; + logLimit: number; + }; + + cloud: { + authenticated: boolean; + url: string; + settingsSync: boolean; + settingsSyncVersion: number; + }; +} + +const DefaultSettings: Settings = { + notifyAboutUpdates: true, + autoUpdate: false, + autoUpdateNotification: true, + useQuickCss: true, + themeLinks: [], + enableReactDevtools: false, + frameless: false, + transparent: false, + winCtrlQ: false, + macosTranslucency: false, + disableMinSize: false, + winNativeTitleBar: false, + plugins: {}, + + notifications: { + timeout: 5000, + position: "bottom-right", + useNative: "not-focused", + logLimit: 50 + }, + + cloud: { + authenticated: false, + url: "https://api.vencord.dev/", + settingsSync: false, + settingsSyncVersion: 0 + } +}; + +try { + var settings = JSON.parse(VencordNative.settings.get()) as Settings; + mergeDefaults(settings, DefaultSettings); +} catch (err) { + var settings = mergeDefaults({} as Settings, DefaultSettings); + logger.error("An error occurred while loading the settings. Corrupt settings file?\n", err); +} + +const saveSettingsOnFrequentAction = debounce(async () => { + if (Settings.cloud.settingsSync && Settings.cloud.authenticated) { + await putCloudSettings(); + delete localStorage.Vencord_settingsDirty; + } +}, 60_000); + +type SubscriptionCallback = ((newValue: any, path: string) => void) & { _path?: string; }; +const subscriptions = new Set<SubscriptionCallback>(); + +const proxyCache = {} as Record<string, any>; + +// Wraps the passed settings object in a Proxy to nicely handle change listeners and default values +function makeProxy(settings: any, root = settings, path = ""): Settings { + return proxyCache[path] ??= new Proxy(settings, { + get(target, p: string) { + const v = target[p]; + + // using "in" is important in the following cases to properly handle falsy or nullish values + if (!(p in target)) { + // Return empty for plugins with no settings + if (path === "plugins" && p in plugins) + return target[p] = makeProxy({ + enabled: plugins[p].required ?? plugins[p].enabledByDefault ?? false + }, root, `plugins.${p}`); + + // Since the property is not set, check if this is a plugin's setting and if so, try to resolve + // the default value. + if (path.startsWith("plugins.")) { + const plugin = path.slice("plugins.".length); + if (plugin in plugins) { + const setting = plugins[plugin].options?.[p]; + if (!setting) return v; + if ("default" in setting) + // normal setting with a default value + return (target[p] = setting.default); + if (setting.type === OptionType.SELECT) { + const def = setting.options.find(o => o.default); + if (def) + target[p] = def.value; + return def?.value; + } + } + } + return v; + } + + // Recursively proxy Objects with the updated property path + if (typeof v === "object" && !Array.isArray(v) && v !== null) + return makeProxy(v, root, `${path}${path && "."}${p}`); + + // primitive or similar, no need to proxy further + return v; + }, + + set(target, p: string, v) { + // avoid unnecessary updates to React Components and other listeners + if (target[p] === v) return true; + + target[p] = v; + // Call any listeners that are listening to a setting of this path + const setPath = `${path}${path && "."}${p}`; + delete proxyCache[setPath]; + for (const subscription of subscriptions) { + if (!subscription._path || subscription._path === setPath) { + subscription(v, setPath); + } + } + // And don't forget to persist the settings! + PlainSettings.cloud.settingsSyncVersion = Date.now(); + localStorage.Vencord_settingsDirty = true; + saveSettingsOnFrequentAction(); + VencordNative.settings.set(JSON.stringify(root, null, 4)); + return true; + } + }); +} + +/** + * Same as {@link Settings} but unproxied. You should treat this as readonly, + * as modifying properties on this will not save to disk or call settings + * listeners. + * WARNING: default values specified in plugin.options will not be ensured here. In other words, + * settings for which you specified a default value may be uninitialised. If you need proper + * handling for default values, use {@link Settings} + */ +export const PlainSettings = settings; +/** + * A smart settings object. Altering props automagically saves + * the updated settings to disk. + * This recursively proxies objects. If you need the object non proxied, use {@link PlainSettings} + */ +export const Settings = makeProxy(settings); + +/** + * Settings hook for React components. Returns a smart settings + * object that automagically triggers a rerender if any properties + * are altered + * @param paths An optional list of paths to whitelist for rerenders + * @returns Settings + */ +// TODO: Representing paths as essentially "string[].join('.')" wont allow dots in paths, change to "paths?: string[][]" later +export function useSettings(paths?: UseSettings<Settings>[]) { + const [, forceUpdate] = React.useReducer(() => ({}), {}); + + const onUpdate: SubscriptionCallback = paths + ? (value, path) => paths.includes(path as UseSettings<Settings>) && forceUpdate() + : forceUpdate; + + React.useEffect(() => { + subscriptions.add(onUpdate); + return () => void subscriptions.delete(onUpdate); + }, []); + + return Settings; +} + +// Resolves a possibly nested prop in the form of "some.nested.prop" to type of T.some.nested.prop +type ResolvePropDeep<T, P> = P extends "" ? T : + P extends `${infer Pre}.${infer Suf}` ? + Pre extends keyof T ? ResolvePropDeep<T[Pre], Suf> : never : P extends keyof T ? T[P] : never; + +/** + * Add a settings listener that will be invoked whenever the desired setting is updated + * @param path Path to the setting that you want to watch, for example "plugins.Unindent.enabled" will fire your callback + * whenever Unindent is toggled. Pass an empty string to get notified for all changes + * @param onUpdate Callback function whenever a setting matching path is updated. It gets passed the new value and the path + * to the updated setting. This path will be the same as your path argument, unless it was an empty string. + * + * @example addSettingsListener("", (newValue, path) => console.log(`${path} is now ${newValue}`)) + * addSettingsListener("plugins.Unindent.enabled", v => console.log("Unindent is now", v ? "enabled" : "disabled")) + */ +export function addSettingsListener<Path extends keyof Settings>(path: Path, onUpdate: (newValue: Settings[Path], path: Path) => void): void; +export function addSettingsListener<Path extends string>(path: Path, onUpdate: (newValue: Path extends "" ? any : ResolvePropDeep<Settings, Path>, path: Path extends "" ? string : Path) => void): void; +export function addSettingsListener(path: string, onUpdate: (newValue: any, path: string) => void) { + (onUpdate as SubscriptionCallback)._path = path; + subscriptions.add(onUpdate); +} + +export function migratePluginSettings(name: string, ...oldNames: string[]) { + const { plugins } = settings; + if (name in plugins) return; + + for (const oldName of oldNames) { + if (oldName in plugins) { + logger.info(`Migrating settings from old name ${oldName} to ${name}`); + plugins[name] = plugins[oldName]; + delete plugins[oldName]; + VencordNative.settings.set(JSON.stringify(settings, null, 4)); + break; + } + } +} + +export function definePluginSettings<D extends SettingsDefinition, C extends SettingsChecks<D>>(def: D, checks?: C) { + const definedSettings: DefinedSettings<D> = { + get store() { + if (!definedSettings.pluginName) throw new Error("Cannot access settings before plugin is initialized"); + return Settings.plugins[definedSettings.pluginName] as any; + }, + use: settings => useSettings( + settings?.map(name => `plugins.${definedSettings.pluginName}.${name}`) as UseSettings<Settings>[] + ).plugins[definedSettings.pluginName] as any, + def, + checks: checks ?? {}, + pluginName: "", + }; + return definedSettings; +} + +type UseSettings<T extends object> = ResolveUseSettings<T>[keyof T]; + +type ResolveUseSettings<T extends object> = { + [Key in keyof T]: + Key extends string + ? T[Key] extends Record<string, unknown> + // @ts-ignore "Type instantiation is excessively deep and possibly infinite" + ? UseSettings<T[Key]> extends string ? `${Key}.${UseSettings<T[Key]>}` : never + : Key + : never; +}; |