rewrite settings api to use SettingsStore class (#2257)

Co-authored-by: Nuckyz <61953774+Nuckyz@users.noreply.github.com>
This commit is contained in:
V 2024-03-13 21:45:45 +01:00 committed by GitHub
parent 7190437e92
commit 9aa205b5ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 336 additions and 180 deletions

View file

@ -26,6 +26,7 @@ import { debounce } from "../src/utils";
import { EXTENSION_BASE_URL } from "../src/utils/web-metadata"; import { EXTENSION_BASE_URL } from "../src/utils/web-metadata";
import { getTheme, Theme } from "../src/utils/discord"; import { getTheme, Theme } from "../src/utils/discord";
import { getThemeInfo } from "../src/main/themes"; import { getThemeInfo } from "../src/main/themes";
import { Settings } from "../src/Vencord";
// Discord deletes this so need to store in variable // Discord deletes this so need to store in variable
const { localStorage } = window; const { localStorage } = window;
@ -96,8 +97,15 @@ window.VencordNative = {
}, },
settings: { settings: {
get: () => localStorage.getItem("VencordSettings") || "{}", get: () => {
set: async (s: string) => localStorage.setItem("VencordSettings", s), 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" getSettingsDir: async () => "LocalStorage"
}, },

View file

@ -4,11 +4,12 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
*/ */
import { PluginIpcMappings } from "@main/ipcPlugins";
import type { UserThemeHeader } from "@main/themes";
import { IpcEvents } from "@utils/IpcEvents"; import { IpcEvents } from "@utils/IpcEvents";
import { IpcRes } from "@utils/types"; import { IpcRes } from "@utils/types";
import type { Settings } from "api/Settings";
import { ipcRenderer } from "electron"; import { ipcRenderer } from "electron";
import { PluginIpcMappings } from "main/ipcPlugins";
import type { UserThemeHeader } from "main/themes";
function invoke<T = any>(event: IpcEvents, ...args: any[]) { function invoke<T = any>(event: IpcEvents, ...args: any[]) {
return ipcRenderer.invoke(event, ...args) as Promise<T>; return ipcRenderer.invoke(event, ...args) as Promise<T>;
@ -46,8 +47,8 @@ export default {
}, },
settings: { settings: {
get: () => sendSync<string>(IpcEvents.GET_SETTINGS), get: () => sendSync<Settings>(IpcEvents.GET_SETTINGS),
set: (settings: string) => invoke<void>(IpcEvents.SET_SETTINGS, settings), set: (settings: Settings, pathToNotify?: string) => invoke<void>(IpcEvents.SET_SETTINGS, settings, pathToNotify),
getSettingsDir: () => invoke<string>(IpcEvents.GET_SETTINGS_DIR), getSettingsDir: () => invoke<string>(IpcEvents.GET_SETTINGS_DIR),
}, },

View file

@ -16,6 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { SettingsStore as SettingsStoreClass } from "@shared/SettingsStore";
import { debounce } from "@utils/debounce"; import { debounce } from "@utils/debounce";
import { localStorage } from "@utils/localStorage"; import { localStorage } from "@utils/localStorage";
import { Logger } from "@utils/Logger"; import { Logger } from "@utils/Logger";
@ -52,7 +53,6 @@ export interface Settings {
| "under-page" | "under-page"
| "window" | "window"
| undefined; | undefined;
macosTranslucency: boolean | undefined;
disableMinSize: boolean; disableMinSize: boolean;
winNativeTitleBar: boolean; winNativeTitleBar: boolean;
plugins: { plugins: {
@ -88,8 +88,6 @@ const DefaultSettings: Settings = {
frameless: false, frameless: false,
transparent: false, transparent: false,
winCtrlQ: false, winCtrlQ: false,
// Replaced by macosVibrancyStyle
macosTranslucency: undefined,
macosVibrancyStyle: undefined, macosVibrancyStyle: undefined,
disableMinSize: false, disableMinSize: false,
winNativeTitleBar: false, winNativeTitleBar: false,
@ -110,13 +108,8 @@ const DefaultSettings: Settings = {
} }
}; };
try { const settings = VencordNative.settings.get();
var settings = JSON.parse(VencordNative.settings.get()) as Settings;
mergeDefaults(settings, DefaultSettings); 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 () => { const saveSettingsOnFrequentAction = debounce(async () => {
if (Settings.cloud.settingsSync && Settings.cloud.authenticated) { if (Settings.cloud.settingsSync && Settings.cloud.authenticated) {
@ -125,76 +118,52 @@ const saveSettingsOnFrequentAction = debounce(async () => {
} }
}, 60_000); }, 60_000);
type SubscriptionCallback = ((newValue: any, path: string) => void) & { _paths?: Array<string>; };
const subscriptions = new Set<SubscriptionCallback>();
const proxyCache = {} as Record<string, any>; 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 if (path === "plugins" && key in plugins)
function makeProxy(settings: any, root = settings, path = ""): Settings { return target[key] = {
return proxyCache[path] ??= new Proxy(settings, { enabled: plugins[key].required ?? plugins[key].enabledByDefault ?? false
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 // Since the property is not set, check if this is a plugin's setting and if so, try to resolve
// the default value. // the default value.
if (path.startsWith("plugins.")) { if (path.startsWith("plugins.")) {
const plugin = path.slice("plugins.".length); const plugin = path.slice("plugins.".length);
if (plugin in plugins) { if (plugin in plugins) {
const setting = plugins[plugin].options?.[p]; const setting = plugins[plugin].options?.[key];
if (!setting) return v; if (!setting) return v;
if ("default" in setting) if ("default" in setting)
// normal setting with a default value // normal setting with a default value
return (target[p] = setting.default); return (target[key] = setting.default);
if (setting.type === OptionType.SELECT) { if (setting.type === OptionType.SELECT) {
const def = setting.options.find(o => o.default); const def = setting.options.find(o => o.default);
if (def) if (def)
target[p] = def.value; target[key] = def.value;
return def?.value; return def?.value;
} }
} }
} }
return v; return v;
} }
});
// Recursively proxy Objects with the updated property path SettingsStore.addGlobalChangeListener((_, path) => {
if (typeof v === "object" && !Array.isArray(v) && v !== null) SettingsStore.plain.cloud.settingsSyncVersion = Date.now();
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);
}
}
// And don't forget to persist the settings!
PlainSettings.cloud.settingsSyncVersion = Date.now();
localStorage.Vencord_settingsDirty = true; localStorage.Vencord_settingsDirty = true;
saveSettingsOnFrequentAction(); saveSettingsOnFrequentAction();
VencordNative.settings.set(JSON.stringify(root, null, 4)); VencordNative.settings.set(SettingsStore.plain, path);
return true;
}
}); });
}
/** /**
* Same as {@link Settings} but unproxied. You should treat this as readonly, * 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. * the updated settings to disk.
* This recursively proxies objects. If you need the object non proxied, use {@link PlainSettings} * 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 * Settings hook for React components. Returns a smart settings
@ -223,45 +192,21 @@ export const Settings = makeProxy(settings);
export function useSettings(paths?: UseSettings<Settings>[]) { export function useSettings(paths?: UseSettings<Settings>[]) {
const [, forceUpdate] = React.useReducer(() => ({}), {}); const [, forceUpdate] = React.useReducer(() => ({}), {});
if (paths) {
(forceUpdate as SubscriptionCallback)._paths = paths;
}
React.useEffect(() => { React.useEffect(() => {
subscriptions.add(forceUpdate); if (paths) {
return () => void subscriptions.delete(forceUpdate); 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; return SettingsStore.store;
}
// 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) {
if (path) {
((onUpdate as SubscriptionCallback)._paths ??= []).push(path);
}
subscriptions.add(onUpdate);
} }
export function migratePluginSettings(name: string, ...oldNames: string[]) { export function migratePluginSettings(name: string, ...oldNames: string[]) {
const { plugins } = settings; const { plugins } = SettingsStore.plain;
if (name in plugins) return; if (name in plugins) return;
for (const oldName of oldNames) { 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}`); logger.info(`Migrating settings from old name ${oldName} to ${name}`);
plugins[name] = plugins[oldName]; plugins[name] = plugins[oldName];
delete plugins[oldName]; delete plugins[oldName];
VencordNative.settings.set(JSON.stringify(settings, null, 4)); SettingsStore.markAsChanged();
break; break;
} }
} }

View file

@ -22,6 +22,7 @@ import { Flex } from "@components/Flex";
import { DeleteIcon } from "@components/Icons"; import { DeleteIcon } from "@components/Icons";
import { Link } from "@components/Link"; import { Link } from "@components/Link";
import PluginModal from "@components/PluginSettings/PluginModal"; import PluginModal from "@components/PluginSettings/PluginModal";
import type { UserThemeHeader } from "@main/themes";
import { openInviteModal } from "@utils/discord"; import { openInviteModal } from "@utils/discord";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import { classes } from "@utils/misc"; import { classes } from "@utils/misc";
@ -30,7 +31,6 @@ import { showItemInFolder } from "@utils/native";
import { useAwaiter } from "@utils/react"; import { useAwaiter } from "@utils/react";
import { findByPropsLazy, findLazy } from "@webpack"; import { findByPropsLazy, findLazy } from "@webpack";
import { Button, Card, Forms, React, showToast, TabBar, TextArea, useEffect, useRef, useState } from "@webpack/common"; import { Button, Card, Forms, React, showToast, TabBar, TextArea, useEffect, useRef, useState } from "@webpack/common";
import { UserThemeHeader } from "main/themes";
import type { ComponentType, Ref, SyntheticEvent } from "react"; import type { ComponentType, Ref, SyntheticEvent } from "react";
import { AddonCard } from "./AddonCard"; import { AddonCard } from "./AddonCard";

View file

@ -50,14 +50,6 @@ function VencordSettings() {
const isMac = navigator.platform.toLowerCase().startsWith("mac"); const isMac = navigator.platform.toLowerCase().startsWith("mac");
const needsVibrancySettings = IS_DISCORD_DESKTOP && isMac; const needsVibrancySettings = IS_DISCORD_DESKTOP && isMac;
// One-time migration of the old setting to the new one if necessary.
React.useEffect(() => {
if (settings.macosTranslucency === true && !settings.macosVibrancyStyle) {
settings.macosVibrancyStyle = "sidebar";
settings.macosTranslucency = undefined;
}
}, []);
const Switches: Array<false | { const Switches: Array<false | {
key: KeysOfType<typeof settings, boolean>; key: KeysOfType<typeof settings, boolean>;
title: string; title: string;
@ -164,7 +156,7 @@ function VencordSettings() {
options={[ options={[
// Sorted from most opaque to most transparent // Sorted from most opaque to most transparent
{ {
label: "No vibrancy", default: !settings.macosTranslucency, value: undefined label: "No vibrancy", value: undefined
}, },
{ {
label: "Under Page (window tinting)", label: "Under Page (window tinting)",
@ -191,9 +183,8 @@ function VencordSettings() {
value: "header" value: "header"
}, },
{ {
label: "Sidebar (old value for transparent windows)", label: "Sidebar",
value: "sidebar", value: "sidebar"
default: settings.macosTranslucency
}, },
{ {
label: "Tooltip", label: "Tooltip",

View file

@ -19,7 +19,8 @@
import { app, protocol, session } from "electron"; import { app, protocol, session } from "electron";
import { join } from "path"; import { join } from "path";
import { ensureSafePath, getSettings } from "./ipcMain"; import { ensureSafePath } from "./ipcMain";
import { RendererSettings } from "./settings";
import { IS_VANILLA, THEMES_DIR } from "./utils/constants"; import { IS_VANILLA, THEMES_DIR } from "./utils/constants";
import { installExt } from "./utils/extensions"; import { installExt } from "./utils/extensions";
@ -55,7 +56,7 @@ if (IS_VESKTOP || !IS_VANILLA) {
}); });
try { try {
if (getSettings().enableReactDevtools) if (RendererSettings.store.enableReactDevtools)
installExt("fmkadmapgofadopljbjfkapdkoienihi") installExt("fmkadmapgofadopljbjfkapdkoienihi")
.then(() => console.info("[Vencord] Installed React Developer Tools")) .then(() => console.info("[Vencord] Installed React Developer Tools"))
.catch(err => console.error("[Vencord] Failed to install React Developer Tools", err)); .catch(err => console.error("[Vencord] Failed to install React Developer Tools", err));

View file

@ -18,22 +18,21 @@
import "./updater"; import "./updater";
import "./ipcPlugins"; import "./ipcPlugins";
import "./settings";
import { debounce } from "@utils/debounce"; import { debounce } from "@utils/debounce";
import { IpcEvents } from "@utils/IpcEvents"; import { IpcEvents } from "@utils/IpcEvents";
import { Queue } from "@utils/Queue";
import { BrowserWindow, ipcMain, shell, systemPreferences } from "electron"; import { BrowserWindow, ipcMain, shell, systemPreferences } from "electron";
import { FSWatcher, mkdirSync, readFileSync, watch } from "fs"; import { FSWatcher, mkdirSync, watch, writeFileSync } from "fs";
import { open, readdir, readFile, writeFile } from "fs/promises"; import { open, readdir, readFile } from "fs/promises";
import { join, normalize } from "path"; import { join, normalize } from "path";
import monacoHtml from "~fileContent/monacoWin.html;base64"; import monacoHtml from "~fileContent/monacoWin.html;base64";
import { getThemeInfo, stripBOM, UserThemeHeader } from "./themes"; import { getThemeInfo, stripBOM, UserThemeHeader } from "./themes";
import { ALLOWED_PROTOCOLS, QUICKCSS_PATH, SETTINGS_DIR, SETTINGS_FILE, THEMES_DIR } from "./utils/constants"; import { ALLOWED_PROTOCOLS, QUICKCSS_PATH, THEMES_DIR } from "./utils/constants";
import { makeLinksOpenExternally } from "./utils/externalLinks"; import { makeLinksOpenExternally } from "./utils/externalLinks";
mkdirSync(SETTINGS_DIR, { recursive: true });
mkdirSync(THEMES_DIR, { recursive: true }); mkdirSync(THEMES_DIR, { recursive: true });
export function ensureSafePath(basePath: string, path: string) { export function ensureSafePath(basePath: string, path: string) {
@ -71,22 +70,6 @@ function getThemeData(fileName: string) {
return readFile(safePath, "utf-8"); return readFile(safePath, "utf-8");
} }
export function readSettings() {
try {
return readFileSync(SETTINGS_FILE, "utf-8");
} catch {
return "{}";
}
}
export function getSettings(): typeof import("@api/Settings").Settings {
try {
return JSON.parse(readSettings());
} catch {
return {} as any;
}
}
ipcMain.handle(IpcEvents.OPEN_QUICKCSS, () => shell.openPath(QUICKCSS_PATH)); ipcMain.handle(IpcEvents.OPEN_QUICKCSS, () => shell.openPath(QUICKCSS_PATH));
ipcMain.handle(IpcEvents.OPEN_EXTERNAL, (_, url) => { ipcMain.handle(IpcEvents.OPEN_EXTERNAL, (_, url) => {
@ -101,12 +84,10 @@ ipcMain.handle(IpcEvents.OPEN_EXTERNAL, (_, url) => {
shell.openExternal(url); shell.openExternal(url);
}); });
const cssWriteQueue = new Queue();
const settingsWriteQueue = new Queue();
ipcMain.handle(IpcEvents.GET_QUICK_CSS, () => readCss()); ipcMain.handle(IpcEvents.GET_QUICK_CSS, () => readCss());
ipcMain.handle(IpcEvents.SET_QUICK_CSS, (_, css) => ipcMain.handle(IpcEvents.SET_QUICK_CSS, (_, css) =>
cssWriteQueue.push(() => writeFile(QUICKCSS_PATH, css)) writeFileSync(QUICKCSS_PATH, css)
); );
ipcMain.handle(IpcEvents.GET_THEMES_DIR, () => THEMES_DIR); ipcMain.handle(IpcEvents.GET_THEMES_DIR, () => THEMES_DIR);
@ -117,13 +98,6 @@ ipcMain.handle(IpcEvents.GET_THEME_SYSTEM_VALUES, () => ({
"os-accent-color": `#${systemPreferences.getAccentColor?.() || ""}` "os-accent-color": `#${systemPreferences.getAccentColor?.() || ""}`
})); }));
ipcMain.handle(IpcEvents.GET_SETTINGS_DIR, () => SETTINGS_DIR);
ipcMain.on(IpcEvents.GET_SETTINGS, e => e.returnValue = readSettings());
ipcMain.handle(IpcEvents.SET_SETTINGS, (_, s) => {
settingsWriteQueue.push(() => writeFile(SETTINGS_FILE, s));
});
export function initIpc(mainWindow: BrowserWindow) { export function initIpc(mainWindow: BrowserWindow) {
let quickCssWatcher: FSWatcher | undefined; let quickCssWatcher: FSWatcher | undefined;

View file

@ -20,7 +20,8 @@ import { onceDefined } from "@utils/onceDefined";
import electron, { app, BrowserWindowConstructorOptions, Menu } from "electron"; import electron, { app, BrowserWindowConstructorOptions, Menu } from "electron";
import { dirname, join } from "path"; import { dirname, join } from "path";
import { getSettings, initIpc } from "./ipcMain"; import { initIpc } from "./ipcMain";
import { RendererSettings } from "./settings";
import { IS_VANILLA } from "./utils/constants"; import { IS_VANILLA } from "./utils/constants";
console.log("[Vencord] Starting up..."); console.log("[Vencord] Starting up...");
@ -41,8 +42,7 @@ require.main!.filename = join(asarPath, discordPkg.main);
app.setAppPath(asarPath); app.setAppPath(asarPath);
if (!IS_VANILLA) { if (!IS_VANILLA) {
const settings = getSettings(); const settings = RendererSettings.store;
// Repatch after host updates on Windows // Repatch after host updates on Windows
if (process.platform === "win32") { if (process.platform === "win32") {
require("./patchWin32Updater"); require("./patchWin32Updater");
@ -84,13 +84,11 @@ if (!IS_VANILLA) {
options.backgroundColor = "#00000000"; options.backgroundColor = "#00000000";
} }
const needsVibrancy = process.platform === "darwin" || (settings.macosVibrancyStyle || settings.macosTranslucency); const needsVibrancy = process.platform === "darwin" && settings.macosVibrancyStyle;
if (needsVibrancy) { if (needsVibrancy) {
options.backgroundColor = "#00000000"; options.backgroundColor = "#00000000";
if (settings.macosTranslucency) { if (settings.macosVibrancyStyle) {
options.vibrancy = "sidebar";
} else if (settings.macosVibrancyStyle) {
options.vibrancy = settings.macosVibrancyStyle; options.vibrancy = settings.macosVibrancyStyle;
} }
} }

53
src/main/settings.ts Normal file
View file

@ -0,0 +1,53 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import type { Settings } from "@api/Settings";
import { SettingsStore } from "@shared/SettingsStore";
import { IpcEvents } from "@utils/IpcEvents";
import { ipcMain } from "electron";
import { mkdirSync, readFileSync, writeFileSync } from "fs";
import { NATIVE_SETTINGS_FILE, SETTINGS_DIR, SETTINGS_FILE } from "./utils/constants";
mkdirSync(SETTINGS_DIR, { recursive: true });
function readSettings<T = object>(name: string, file: string): Partial<T> {
try {
return JSON.parse(readFileSync(file, "utf-8"));
} catch (err: any) {
if (err?.code !== "ENOENT")
console.error(`Failed to read ${name} settings`, err);
return {};
}
}
export const RendererSettings = new SettingsStore(readSettings<Settings>("renderer", SETTINGS_FILE));
RendererSettings.addGlobalChangeListener(() => {
try {
writeFileSync(SETTINGS_FILE, JSON.stringify(RendererSettings.plain, null, 4));
} catch (e) {
console.error("Failed to write renderer settings", e);
}
});
ipcMain.handle(IpcEvents.GET_SETTINGS_DIR, () => SETTINGS_DIR);
ipcMain.on(IpcEvents.GET_SETTINGS, e => e.returnValue = RendererSettings.plain);
ipcMain.handle(IpcEvents.SET_SETTINGS, (_, data: Settings, pathToNotify?: string) => {
RendererSettings.setData(data, pathToNotify);
});
export const NativeSettings = new SettingsStore(readSettings("native", NATIVE_SETTINGS_FILE));
NativeSettings.addGlobalChangeListener(() => {
try {
writeFileSync(NATIVE_SETTINGS_FILE, JSON.stringify(NativeSettings.plain, null, 4));
} catch (e) {
console.error("Failed to write native settings", e);
}
});

View file

@ -28,6 +28,7 @@ export const SETTINGS_DIR = join(DATA_DIR, "settings");
export const THEMES_DIR = join(DATA_DIR, "themes"); export const THEMES_DIR = join(DATA_DIR, "themes");
export const QUICKCSS_PATH = join(SETTINGS_DIR, "quickCss.css"); export const QUICKCSS_PATH = join(SETTINGS_DIR, "quickCss.css");
export const SETTINGS_FILE = join(SETTINGS_DIR, "settings.json"); export const SETTINGS_FILE = join(SETTINGS_DIR, "settings.json");
export const NATIVE_SETTINGS_FILE = join(SETTINGS_DIR, "native-settings.json");
export const ALLOWED_PROTOCOLS = [ export const ALLOWED_PROTOCOLS = [
"https:", "https:",
"http:", "http:",

View file

@ -4,14 +4,14 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
*/ */
import { RendererSettings } from "@main/settings";
import { app } from "electron"; import { app } from "electron";
import { getSettings } from "main/ipcMain";
app.on("browser-window-created", (_, win) => { app.on("browser-window-created", (_, win) => {
win.webContents.on("frame-created", (_, { frame }) => { win.webContents.on("frame-created", (_, { frame }) => {
frame.once("dom-ready", () => { frame.once("dom-ready", () => {
if (frame.url.startsWith("https://open.spotify.com/embed/")) { if (frame.url.startsWith("https://open.spotify.com/embed/")) {
const settings = getSettings().plugins?.FixSpotifyEmbeds; const settings = RendererSettings.store.plugins?.FixSpotifyEmbeds;
if (!settings?.enabled) return; if (!settings?.enabled) return;
frame.executeJavaScript(` frame.executeJavaScript(`

View file

@ -4,14 +4,14 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
*/ */
import { RendererSettings } from "@main/settings";
import { app } from "electron"; import { app } from "electron";
import { getSettings } from "main/ipcMain";
app.on("browser-window-created", (_, win) => { app.on("browser-window-created", (_, win) => {
win.webContents.on("frame-created", (_, { frame }) => { win.webContents.on("frame-created", (_, { frame }) => {
frame.once("dom-ready", () => { frame.once("dom-ready", () => {
if (frame.url.startsWith("https://www.youtube.com/")) { if (frame.url.startsWith("https://www.youtube.com/")) {
const settings = getSettings().plugins?.FixYoutubeEmbeds; const settings = RendererSettings.store.plugins?.FixYoutubeEmbeds;
if (!settings?.enabled) return; if (!settings?.enabled) return;
frame.executeJavaScript(` frame.executeJavaScript(`

182
src/shared/SettingsStore.ts Normal file
View file

@ -0,0 +1,182 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { LiteralUnion } from "type-fest";
// 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 `${infer Pre}.${infer Suf}`
? Pre extends keyof T
? ResolvePropDeep<T[Pre], Suf>
: any
: P extends keyof T
? T[P]
: any;
interface SettingsStoreOptions {
readOnly?: boolean;
getDefaultValue?: (data: {
target: any;
key: string;
root: any;
path: string;
}) => any;
}
// merges the SettingsStoreOptions type into the class
export interface SettingsStore<T extends object> extends SettingsStoreOptions { }
/**
* The SettingsStore allows you to easily create a mutable store that
* has support for global and path-based change listeners.
*/
export class SettingsStore<T extends object> {
private pathListeners = new Map<string, Set<(newData: any) => void>>();
private globalListeners = new Set<(newData: T, path: string) => void>();
/**
* The store object. Making changes to this object will trigger the applicable change listeners
*/
public declare store: T;
/**
* The plain data. Changes to this object will not trigger any change listeners
*/
public declare plain: T;
public constructor(plain: T, options: SettingsStoreOptions = {}) {
this.plain = plain;
this.store = this.makeProxy(plain);
Object.assign(this, options);
}
private makeProxy(object: any, root: T = object, path: string = "") {
const self = this;
return new Proxy(object, {
get(target, key: string) {
let v = target[key];
if (!(key in target) && self.getDefaultValue) {
v = self.getDefaultValue({
target,
key,
root,
path
});
}
if (typeof v === "object" && v !== null && !Array.isArray(v))
return self.makeProxy(v, root, `${path}${path && "."}${key}`);
return v;
},
set(target, key: string, value) {
if (target[key] === value) return true;
Reflect.set(target, key, value);
const setPath = `${path}${path && "."}${key}`;
self.globalListeners.forEach(cb => cb(value, setPath));
self.pathListeners.get(setPath)?.forEach(cb => cb(value));
return true;
}
});
}
/**
* Set the data of the store.
* This will update this.store and this.plain (and old references to them will be stale! Avoid storing them in variables)
*
* Additionally, all global listeners (and those for pathToNotify, if specified) will be called with the new data
* @param value New data
* @param pathToNotify Optional path to notify instead of globally. Used to transfer path via ipc
*/
public setData(value: T, pathToNotify?: string) {
if (this.readOnly) throw new Error("SettingsStore is read-only");
this.plain = value;
this.store = this.makeProxy(value);
if (pathToNotify) {
let v = value;
const path = pathToNotify.split(".");
for (const p of path) {
if (!v) {
console.warn(
`Settings#setData: Path ${pathToNotify} does not exist in new data. Not dispatching update`
);
return;
}
v = v[p];
}
this.pathListeners.get(pathToNotify)?.forEach(cb => cb(v));
}
this.markAsChanged();
}
/**
* Add a global change listener, that will fire whenever any setting is changed
*
* @param data The new data. This is either the new value set on the path, or the new root object if it was changed
* @param path The path of the setting that was changed. Empty string if the root object was changed
*/
public addGlobalChangeListener(cb: (data: any, path: string) => void) {
this.globalListeners.add(cb);
}
/**
* Add a scoped change listener that will fire whenever a setting matching the specified path is changed.
*
* For example if path is `"foo.bar"`, the listener will fire on
* ```js
* Setting.store.foo.bar = "hi"
* ```
* but not on
* ```js
* Setting.store.foo.baz = "hi"
* ```
* @param path
* @param cb
*/
public addChangeListener<P extends LiteralUnion<keyof T, string>>(
path: P,
cb: (data: ResolvePropDeep<T, P>) => void
) {
const listeners = this.pathListeners.get(path as string) ?? new Set();
listeners.add(cb);
this.pathListeners.set(path as string, listeners);
}
/**
* Remove a global listener
* @see {@link addGlobalChangeListener}
*/
public removeGlobalChangeListener(cb: (data: any, path: string) => void) {
this.globalListeners.delete(cb);
}
/**
* Remove a scoped listener
* @see {@link addChangeListener}
*/
public removeChangeListener(path: LiteralUnion<keyof T, string>, cb: (data: any) => void) {
const listeners = this.pathListeners.get(path as string);
if (!listeners) return;
listeners.delete(cb);
if (!listeners.size) this.pathListeners.delete(path as string);
}
/**
* Call all global change listeners
*/
public markAsChanged() {
this.globalListeners.forEach(cb => cb(this.plain, ""));
}
}

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { addSettingsListener, Settings } from "@api/Settings"; import { Settings, SettingsStore } from "@api/Settings";
let style: HTMLStyleElement; let style: HTMLStyleElement;
@ -81,10 +81,10 @@ document.addEventListener("DOMContentLoaded", () => {
initThemes(); initThemes();
toggle(Settings.useQuickCss); toggle(Settings.useQuickCss);
addSettingsListener("useQuickCss", toggle); SettingsStore.addChangeListener("useQuickCss", toggle);
addSettingsListener("themeLinks", initThemes); SettingsStore.addChangeListener("themeLinks", initThemes);
addSettingsListener("enabledThemes", initThemes); SettingsStore.addChangeListener("enabledThemes", initThemes);
if (!IS_WEB) if (!IS_WEB)
VencordNative.quickCss.addThemeChangeListener(initThemes); VencordNative.quickCss.addThemeChangeListener(initThemes);

View file

@ -36,14 +36,14 @@ export async function importSettings(data: string) {
if ("settings" in parsed && "quickCss" in parsed) { if ("settings" in parsed && "quickCss" in parsed) {
Object.assign(PlainSettings, parsed.settings); Object.assign(PlainSettings, parsed.settings);
await VencordNative.settings.set(JSON.stringify(parsed.settings, null, 4)); await VencordNative.settings.set(parsed.settings);
await VencordNative.quickCss.set(parsed.quickCss); await VencordNative.quickCss.set(parsed.quickCss);
} else } else
throw new Error("Invalid Settings. Is this even a Vencord Settings file?"); throw new Error("Invalid Settings. Is this even a Vencord Settings file?");
} }
export async function exportSettings({ minify }: { minify?: boolean; } = {}) { export async function exportSettings({ minify }: { minify?: boolean; } = {}) {
const settings = JSON.parse(VencordNative.settings.get()); const settings = VencordNative.settings.get();
const quickCss = await VencordNative.quickCss.get(); const quickCss = await VencordNative.quickCss.get();
return JSON.stringify({ settings, quickCss }, null, minify ? undefined : 4); return JSON.stringify({ settings, quickCss }, null, minify ? undefined : 4);
} }
@ -137,7 +137,7 @@ export async function putCloudSettings(manual?: boolean) {
const { written } = await res.json(); const { written } = await res.json();
PlainSettings.cloud.settingsSyncVersion = written; PlainSettings.cloud.settingsSyncVersion = written;
VencordNative.settings.set(JSON.stringify(PlainSettings, null, 4)); VencordNative.settings.set(PlainSettings);
cloudSettingsLogger.info("Settings uploaded to cloud successfully"); cloudSettingsLogger.info("Settings uploaded to cloud successfully");
@ -222,7 +222,7 @@ export async function getCloudSettings(shouldNotify = true, force = false) {
// sync with server timestamp instead of local one // sync with server timestamp instead of local one
PlainSettings.cloud.settingsSyncVersion = written; PlainSettings.cloud.settingsSyncVersion = written;
VencordNative.settings.set(JSON.stringify(PlainSettings, null, 4)); VencordNative.settings.set(PlainSettings);
cloudSettingsLogger.info("Settings loaded from cloud successfully"); cloudSettingsLogger.info("Settings loaded from cloud successfully");
if (shouldNotify) if (shouldNotify)

View file

@ -11,7 +11,7 @@
"esnext.asynciterable", "esnext.asynciterable",
"esnext.symbol" "esnext.symbol"
], ],
"module": "commonjs", "module": "esnext",
"moduleResolution": "node", "moduleResolution": "node",
"strict": true, "strict": true,
"noImplicitAny": false, "noImplicitAny": false,
@ -20,13 +20,15 @@
"baseUrl": "./src/", "baseUrl": "./src/",
"paths": { "paths": {
"@main/*": ["./main/*"],
"@api/*": ["./api/*"], "@api/*": ["./api/*"],
"@components/*": ["./components/*"], "@components/*": ["./components/*"],
"@utils/*": ["./utils/*"], "@utils/*": ["./utils/*"],
"@shared/*": ["./shared/*"],
"@webpack/types": ["./webpack/common/types"], "@webpack/types": ["./webpack/common/types"],
"@webpack/common": ["./webpack/common"], "@webpack/common": ["./webpack/common"],
"@webpack": ["./webpack/webpack"] "@webpack": ["./webpack/webpack"]
} }
}, },
"include": ["src/**/*"] "include": ["src/**/*", "browser/**/*", "scripts/**/*"]
} }