rewrite settings api to use SettingsStore class (#2257)
Co-authored-by: Nuckyz <61953774+Nuckyz@users.noreply.github.com>main
parent
7190437e92
commit
9aa205b5ec
@ -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);
|
||||||
|
}
|
||||||
|
});
|
@ -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, ""));
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in new issue