Vencord/src/api/Settings.ts
Claire f21db5cb01
add Native settings implementation (#2346)
Co-authored-by: vee <vendicated@riseup.net>
2024-05-12 02:08:17 +02:00

256 lines
8.2 KiB
TypeScript

/*
* 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 "@shared/debounce";
import { SettingsStore as SettingsStoreClass } from "@shared/SettingsStore";
import { localStorage } from "@utils/localStorage";
import { Logger } from "@utils/Logger";
import { mergeDefaults } from "@utils/mergeDefaults";
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 {
autoUpdate: boolean;
autoUpdateNotification: boolean,
useQuickCss: boolean;
enableReactDevtools: boolean;
themeLinks: string[];
enabledThemes: string[];
frameless: boolean;
transparent: boolean;
winCtrlQ: boolean;
macosVibrancyStyle:
| "content"
| "fullscreen-ui"
| "header"
| "hud"
| "menu"
| "popover"
| "selection"
| "sidebar"
| "titlebar"
| "tooltip"
| "under-page"
| "window"
| undefined;
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 = {
autoUpdate: true,
autoUpdateNotification: true,
useQuickCss: true,
themeLinks: [],
enabledThemes: [],
enableReactDevtools: false,
frameless: false,
transparent: false,
winCtrlQ: false,
macosVibrancyStyle: undefined,
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
}
};
const settings = VencordNative.settings.get();
mergeDefaults(settings, DefaultSettings);
const saveSettingsOnFrequentAction = debounce(async () => {
if (Settings.cloud.settingsSync && Settings.cloud.authenticated) {
await putCloudSettings();
delete localStorage.Vencord_settingsDirty;
}
}, 60_000);
export const SettingsStore = new SettingsStoreClass(settings, {
readOnly: true,
getDefaultValue({
target,
key,
path
}) {
const v = target[key];
if (!plugins) return v; // plugins not initialised yet. this means this path was reached by being called on the top level
if (path === "plugins" && key in plugins)
return target[key] = {
enabled: plugins[key].required ?? plugins[key].enabledByDefault ?? false
};
// 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?.[key];
if (!setting) return v;
if ("default" in setting)
// normal setting with a default value
return (target[key] = setting.default);
if (setting.type === OptionType.SELECT) {
const def = setting.options.find(o => o.default);
if (def)
target[key] = def.value;
return def?.value;
}
}
}
return v;
}
});
SettingsStore.addGlobalChangeListener((_, path) => {
SettingsStore.plain.cloud.settingsSyncVersion = Date.now();
localStorage.Vencord_settingsDirty = true;
saveSettingsOnFrequentAction();
VencordNative.settings.set(SettingsStore.plain, path);
});
/**
* 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 = SettingsStore.store;
/**
* 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(() => ({}), {});
React.useEffect(() => {
if (paths) {
paths.forEach(p => SettingsStore.addChangeListener(p, forceUpdate));
return () => paths.forEach(p => SettingsStore.removeChangeListener(p, forceUpdate));
} else {
SettingsStore.addGlobalChangeListener(forceUpdate);
return () => SettingsStore.removeGlobalChangeListener(forceUpdate);
}
}, []);
return SettingsStore.store;
}
export function migratePluginSettings(name: string, ...oldNames: string[]) {
const { plugins } = SettingsStore.plain;
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];
SettingsStore.markAsChanged();
break;
}
}
}
export function definePluginSettings<
Def extends SettingsDefinition,
Checks extends SettingsChecks<Def>,
PrivateSettings extends object = {}
>(def: Def, checks?: Checks) {
const definedSettings: DefinedSettings<Def, Checks, PrivateSettings> = {
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 ?? {} as any,
pluginName: "",
withPrivateSettings<T extends object>() {
return this as DefinedSettings<Def, Checks, T>;
}
};
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;
};