diff --git a/browser/VencordNativeStub.ts b/browser/VencordNativeStub.ts index 70fc1cf9..77c72369 100644 --- a/browser/VencordNativeStub.ts +++ b/browser/VencordNativeStub.ts @@ -26,6 +26,7 @@ import { debounce } from "../src/utils"; import { EXTENSION_BASE_URL } from "../src/utils/web-metadata"; import { getTheme, Theme } from "../src/utils/discord"; import { getThemeInfo } from "../src/main/themes"; +import { Settings } from "../src/Vencord"; // Discord deletes this so need to store in variable const { localStorage } = window; @@ -96,8 +97,15 @@ window.VencordNative = { }, settings: { - get: () => localStorage.getItem("VencordSettings") || "{}", - set: async (s: string) => localStorage.setItem("VencordSettings", s), + get: () => { + try { + return JSON.parse(localStorage.getItem("VencordSettings") || "{}"); + } catch (e) { + console.error("Failed to parse settings from localStorage: ", e); + return {}; + } + }, + set: async (s: Settings) => localStorage.setItem("VencordSettings", JSON.stringify(s)), getSettingsDir: async () => "LocalStorage" }, diff --git a/package.json b/package.json index 9d7b6347..025fa13d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "vencord", "private": "true", - "version": "1.7.2", + "version": "1.7.3", "description": "The cutest Discord client mod", "homepage": "https://github.com/Vendicated/Vencord#readme", "bugs": { diff --git a/src/VencordNative.ts b/src/VencordNative.ts index 0faa5569..42e69745 100644 --- a/src/VencordNative.ts +++ b/src/VencordNative.ts @@ -4,11 +4,12 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import { IpcEvents } from "@utils/IpcEvents"; +import { PluginIpcMappings } from "@main/ipcPlugins"; +import type { UserThemeHeader } from "@main/themes"; +import { IpcEvents } from "@shared/IpcEvents"; import { IpcRes } from "@utils/types"; +import type { Settings } from "api/Settings"; import { ipcRenderer } from "electron"; -import { PluginIpcMappings } from "main/ipcPlugins"; -import type { UserThemeHeader } from "main/themes"; function invoke(event: IpcEvents, ...args: any[]) { return ipcRenderer.invoke(event, ...args) as Promise; @@ -46,8 +47,8 @@ export default { }, settings: { - get: () => sendSync(IpcEvents.GET_SETTINGS), - set: (settings: string) => invoke(IpcEvents.SET_SETTINGS, settings), + get: () => sendSync(IpcEvents.GET_SETTINGS), + set: (settings: Settings, pathToNotify?: string) => invoke(IpcEvents.SET_SETTINGS, settings, pathToNotify), getSettingsDir: () => invoke(IpcEvents.GET_SETTINGS_DIR), }, diff --git a/src/api/Settings.ts b/src/api/Settings.ts index c1ff6915..0b797530 100644 --- a/src/api/Settings.ts +++ b/src/api/Settings.ts @@ -16,7 +16,8 @@ * along with this program. If not, see . */ -import { debounce } from "@utils/debounce"; +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/misc"; @@ -52,7 +53,6 @@ export interface Settings { | "under-page" | "window" | undefined; - macosTranslucency: boolean | undefined; disableMinSize: boolean; winNativeTitleBar: boolean; plugins: { @@ -88,8 +88,6 @@ const DefaultSettings: Settings = { frameless: false, transparent: false, winCtrlQ: false, - // Replaced by macosVibrancyStyle - macosTranslucency: undefined, macosVibrancyStyle: undefined, disableMinSize: false, winNativeTitleBar: false, @@ -110,13 +108,8 @@ const DefaultSettings: Settings = { } }; -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 settings = VencordNative.settings.get(); +mergeDefaults(settings, DefaultSettings); const saveSettingsOnFrequentAction = debounce(async () => { if (Settings.cloud.settingsSync && Settings.cloud.authenticated) { @@ -125,76 +118,52 @@ const saveSettingsOnFrequentAction = debounce(async () => { } }, 60_000); -type SubscriptionCallback = ((newValue: any, path: string) => void) & { _paths?: Array; }; -const subscriptions = new Set(); -const proxyCache = {} as Record; +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 -// 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]; + if (path === "plugins" && key in plugins) + return target[key] = { + enabled: plugins[key].required ?? plugins[key].enabledByDefault ?? false + }; - // 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?.[key]; + if (!setting) return v; - // 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; - } + if ("default" in setting) + // normal setting with a default value + return (target[key] = setting.default); - // 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._paths || subscription._paths.includes(setPath)) { - subscription(v, setPath); + if (setting.type === OptionType.SELECT) { + const def = setting.options.find(o => o.default); + if (def) + target[key] = def.value; + return def?.value; } } - // 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; } - }); -} + 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, @@ -210,7 +179,7 @@ export const PlainSettings = settings; * the updated settings to disk. * This recursively proxies objects. If you need the object non proxied, use {@link PlainSettings} */ -export const Settings = makeProxy(settings); +export const Settings = SettingsStore.store; /** * Settings hook for React components. Returns a smart settings @@ -223,45 +192,21 @@ export const Settings = makeProxy(settings); export function useSettings(paths?: UseSettings[]) { const [, forceUpdate] = React.useReducer(() => ({}), {}); - if (paths) { - (forceUpdate as SubscriptionCallback)._paths = paths; - } - React.useEffect(() => { - subscriptions.add(forceUpdate); - return () => void subscriptions.delete(forceUpdate); + 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 Settings; -} - -// Resolves a possibly nested prop in the form of "some.nested.prop" to type of T.some.nested.prop -type ResolvePropDeep = P extends "" ? T : - P extends `${infer Pre}.${infer Suf}` ? - Pre extends keyof T ? ResolvePropDeep : 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: Path, onUpdate: (newValue: Settings[Path], path: Path) => void): void; -export function addSettingsListener(path: Path, onUpdate: (newValue: Path extends "" ? any : ResolvePropDeep, path: Path extends "" ? string : Path) => void): void; -export function addSettingsListener(path: string, onUpdate: (newValue: any, path: string) => void) { - if (path) { - ((onUpdate as SubscriptionCallback)._paths ??= []).push(path); - } - - subscriptions.add(onUpdate); + return SettingsStore.store; } export function migratePluginSettings(name: string, ...oldNames: string[]) { - const { plugins } = settings; + const { plugins } = SettingsStore.plain; if (name in plugins) return; for (const oldName of oldNames) { @@ -269,7 +214,7 @@ export function migratePluginSettings(name: string, ...oldNames: string[]) { 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)); + SettingsStore.markAsChanged(); break; } } diff --git a/src/components/VencordSettings/PatchHelperTab.tsx b/src/components/VencordSettings/PatchHelperTab.tsx index 35f46ef5..064c872a 100644 --- a/src/components/VencordSettings/PatchHelperTab.tsx +++ b/src/components/VencordSettings/PatchHelperTab.tsx @@ -18,13 +18,13 @@ import { CheckedTextInput } from "@components/CheckedTextInput"; import { CodeBlock } from "@components/CodeBlock"; -import { debounce } from "@utils/debounce"; +import { debounce } from "@shared/debounce"; import { Margins } from "@utils/margins"; import { canonicalizeMatch, canonicalizeReplace } from "@utils/patches"; import { makeCodeblock } from "@utils/text"; -import { ReplaceFn } from "@utils/types"; +import { Patch, ReplaceFn } from "@utils/types"; import { search } from "@webpack"; -import { Button, Clipboard, Forms, Parser, React, Switch, TextInput } from "@webpack/common"; +import { Button, Clipboard, Forms, Parser, React, Switch, TextArea, TextInput } from "@webpack/common"; import { SettingsTab, wrapTab } from "./shared"; @@ -218,6 +218,60 @@ function ReplacementInput({ replacement, setReplacement, replacementError }) { ); } +interface FullPatchInputProps { + setFind(v: string): void; + setMatch(v: string): void; + setReplacement(v: string | ReplaceFn): void; +} + +function FullPatchInput({ setFind, setMatch, setReplacement }: FullPatchInputProps) { + const [fullPatch, setFullPatch] = React.useState(""); + const [fullPatchError, setFullPatchError] = React.useState(""); + + function update() { + if (fullPatch === "") { + setFullPatchError(""); + + setFind(""); + setMatch(""); + setReplacement(""); + return; + } + + try { + const parsed = (0, eval)(`(${fullPatch})`) as Patch; + + if (!parsed.find) throw new Error("No 'find' field"); + if (!parsed.replacement) throw new Error("No 'replacement' field"); + + if (parsed.replacement instanceof Array) { + if (parsed.replacement.length === 0) throw new Error("Invalid replacement"); + + parsed.replacement = { + match: parsed.replacement[0].match, + replace: parsed.replacement[0].replace + }; + } + + if (!parsed.replacement.match) throw new Error("No 'replacement.match' field"); + if (!parsed.replacement.replace) throw new Error("No 'replacement.replace' field"); + + setFind(parsed.find); + setMatch(parsed.replacement.match instanceof RegExp ? parsed.replacement.match.source : parsed.replacement.match); + setReplacement(parsed.replacement.replace); + setFullPatchError(""); + } catch (e) { + setFullPatchError((e as Error).message); + } + } + + return <> + Paste your full JSON patch here to fill out the fields +