Make Settings & Settings Page

This commit is contained in:
Vendicated 2022-08-31 04:07:16 +02:00
parent cb288e204d
commit 98cb301df5
No known key found for this signature in database
GPG Key ID: EC781ADFB93EFFA3
18 changed files with 330 additions and 43 deletions

View File

@ -117,7 +117,7 @@ await Promise.all([
],
sourcemap: "inline",
watch,
minify: true
minify: false,
})
]).then(res => {
const took = performance.now() - begin;

View File

@ -1,5 +1,5 @@
export * as Plugins from "./plugins";
export * as Webpack from "./utils/webpack";
export * as Webpack from "./webpack";
export * as Api from "./api";
export * as Components from "./components";

View File

@ -1,12 +1,33 @@
import { IPC_QUICK_CSS_UPDATE, IPC_GET_QUICK_CSS } from './utils/ipcEvents';
import { ipcRenderer } from 'electron';
import IPC_EVENTS from './utils/IpcEvents';
import { IpcRenderer, ipcRenderer } from 'electron';
export default {
handleQuickCssUpdate(cb: (s: string) => void) {
ipcRenderer.on(IPC_QUICK_CSS_UPDATE, (_, css) => {
cb(css);
});
getVersions: () => process.versions,
ipc: {
send(event: string, ...args: any[]) {
if (event in IPC_EVENTS) ipcRenderer.send(event, ...args);
else throw new Error(`Event ${event} not allowed.`);
},
sendSync(event: string, ...args: any[]) {
if (event in IPC_EVENTS) return ipcRenderer.sendSync(event, ...args);
else throw new Error(`Event ${event} not allowed.`);
},
on(event: string, listener: Parameters<IpcRenderer["on"]>[1]) {
if (event in IPC_EVENTS) ipcRenderer.on(event, listener);
else throw new Error(`Event ${event} not allowed.`);
},
invoke(event: string, ...args: any[]) {
if (event in IPC_EVENTS) return ipcRenderer.invoke(event, ...args);
else throw new Error(`Event ${event} not allowed.`);
}
},
getQuickCss: () => ipcRenderer.invoke(IPC_GET_QUICK_CSS) as Promise<string>,
getVersions: () => process.versions
require(mod: string) {
const settings = ipcRenderer.sendSync(IPC_EVENTS.GET_SETTINGS);
try {
if (!JSON.parse(settings).unsafeRequire) throw "no";
} catch {
throw new Error("Unsafe require is not allowed. Enable it in settings and try again.");
}
return require(mod);
}
};

81
src/api/settings.ts Normal file
View File

@ -0,0 +1,81 @@
import plugins from "plugins";
import IpcEvents from "../utils/IpcEvents";
import { React } from "../webpack";
import { mergeDefaults } from '../utils/misc';
interface Settings {
unsafeRequire: boolean;
plugins: {
[plugin: string]: {
enabled: boolean;
[setting: string]: any;
};
};
}
const DefaultSettings: Settings = {
unsafeRequire: false,
plugins: {}
};
for (const plugin of plugins) {
DefaultSettings.plugins[plugin.name] = {
enabled: plugin.required ?? false
};
}
try {
var settings = JSON.parse(VencordNative.ipc.sendSync(IpcEvents.GET_SETTINGS)) as Settings;
for (const key in DefaultSettings) {
settings[key] ??= DefaultSettings[key];
}
mergeDefaults(settings, DefaultSettings);
} catch (err) {
console.error("Corrupt settings file. ", err);
var settings = mergeDefaults({} as Settings, DefaultSettings);
}
const subscriptions = new Set<() => void>();
function makeProxy(settings: Settings, root = settings): Settings {
return new Proxy(settings, {
get(target, p) {
const v = target[p];
if (typeof v === "object" && !Array.isArray(v)) return makeProxy(v, root);
return v;
},
set(target, p, v) {
if (target[p] === v) return true;
target[p] = v;
for (const subscription of subscriptions) {
subscription();
}
VencordNative.ipc.invoke(IpcEvents.SET_SETTINGS, JSON.stringify(root));
return true;
}
});
}
/**
* A smart settings object. Altering props automagically saves
* the updated settings to disk.
*/
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
* @returns Settings
*/
export function useSettings() {
const [, forceUpdate] = React.useReducer(x => ({}), {});
React.useEffect(() => {
subscriptions.add(forceUpdate);
return () => void subscriptions.delete(forceUpdate);
}, []);
return Settings;
}

View File

@ -1,4 +1,84 @@
import { lazy, LazyComponent, useAwaiter } from "../utils/misc";
import { findByDisplayName, Forms } from '../webpack';
import Plugins from 'plugins';
import { useSettings } from "../api/settings";
import { findByProps } from '../webpack/index';
import IpcEvents from "../utils/IpcEvents";
// Lazy spam because this is ran before React is a thing. Todo: Fix that and clean this up lmao
const SwitchItem = LazyComponent<React.PropsWithChildren<{
value: boolean;
onChange: (v: boolean) => void;
note?: string;
tooltipNote?: string;
disabled?: boolean;
}>>(() => findByDisplayName("SwitchItem").default);
const getButton = lazy(() => findByProps("ButtonLooks", "default"));
const Button = LazyComponent(() => getButton().default);
const getFlex = lazy(() => findByDisplayName("Flex"));
const Flex = LazyComponent(() => getFlex().default);
const FlexChild = LazyComponent(() => getFlex().default.Child);
const getMargins = lazy(() => findByProps("marginTop8", "marginBottom8"));
export default function Settings(props) {
console.log(props);
return (<p>Hi</p>);
const settingsDir = useAwaiter(() => VencordNative.ipc.invoke(IpcEvents.GET_SETTINGS_DIR), "Loading...");
const settings = useSettings();
return (
<Forms.FormSection tag="h1" title="Vencord">
<Forms.FormText>SettingsDir: {settingsDir}</Forms.FormText>
<Flex className={getMargins().marginTop8 + " " + getMargins().marginBottom8}>
<FlexChild>
<Button
onClick={() => VencordNative.ipc.invoke(IpcEvents.OPEN_PATH, settingsDir)}
size={getButton().ButtonSizes.SMALL}
disabled={settingsDir === "Loading..."}
>
Launch Directory
</Button>
</FlexChild>
<FlexChild>
<Button
onClick={() => VencordNative.ipc.invoke(IpcEvents.OPEN_PATH, settingsDir + "/quickCss.css")}
size={getButton().ButtonSizes.SMALL}
disabled={settingsDir === "Loading..."}
>
Open QuickCSS File
</Button>
</FlexChild>
</Flex>
<Forms.FormTitle tag="h5">Settings</Forms.FormTitle>
<SwitchItem
value={settings.unsafeRequire}
onChange={v => settings.unsafeRequire = v}
note="Enables VencordNative.require. Useful for testing, very bad for security. Leave this off unless you need it."
>
Enable Ensafe Require
</SwitchItem>
<Forms.FormDivider />
<Forms.FormTitle tag="h5">Plugins</Forms.FormTitle>
{Plugins.map(p => (
<SwitchItem
disabled={p.required === true}
key={p.name}
value={settings.plugins[p.name].enabled}
onChange={v => {
settings.plugins[p.name].enabled = v;
if (v) {
p.dependencies?.forEach(d => {
settings.plugins[d].enabled = true;
});
}
}}
note={p.description}
tooltipNote={p.required ? "This plugin is required. Thus you cannot disable it." : undefined}
>
{p.name}
</SwitchItem>
))
}
</Forms.FormSection >
);
}

View File

@ -1,25 +1,39 @@
import { app, BrowserWindow, ipcMain } from "electron";
import { fstat, watch } from "fs";
import { open, readFile } from "fs/promises";
import { app, BrowserWindow, ipcMain, shell } from "electron";
import { readFileSync, watch } from "fs";
import { open, readFile, writeFile } from "fs/promises";
import { join } from 'path';
import { IPC_GET_SETTINGS_DIR, IPC_GET_QUICK_CSS, IPC_QUICK_CSS_UPDATE } from './utils/ipcEvents';
import IpcEvents from './utils/IpcEvents';
const DATA_DIR = join(app.getPath("userData"), "..", "Vencord");
const SETTINGS_DIR = join(DATA_DIR, "settings");
const QUICKCSS_PATH = join(SETTINGS_DIR, "quickCss.css");
const SETTINGS_FILE = join(SETTINGS_DIR, "settings.json");
function readCss() {
return readFile(QUICKCSS_PATH, "utf-8").catch(() => "");
}
ipcMain.handle(IPC_GET_SETTINGS_DIR, () => SETTINGS_DIR);
ipcMain.handle(IPC_GET_QUICK_CSS, () => readCss());
function readSettings() {
try {
return readFileSync(SETTINGS_FILE, "utf-8");
} catch {
return "{}";
}
}
ipcMain.handle(IpcEvents.GET_SETTINGS_DIR, () => SETTINGS_DIR);
ipcMain.handle(IpcEvents.GET_QUICK_CSS, () => readCss());
// .on because we need Settings synchronously (ipcRenderer.sendSync)
ipcMain.on(IpcEvents.GET_SETTINGS, (e) => e.returnValue = readSettings());
ipcMain.handle(IpcEvents.SET_SETTINGS, (_, s) => void writeFile(SETTINGS_FILE, s));
ipcMain.handle(IpcEvents.OPEN_PATH, (_, path) => shell.openPath(path));
ipcMain.handle(IpcEvents.OPEN_EXTERNAL, (_, url) => shell.openExternal(url));
export function initIpc(mainWindow: BrowserWindow) {
open(QUICKCSS_PATH, "a+").then(fd => {
fd.close();
watch(QUICKCSS_PATH, async () => {
mainWindow.webContents.postMessage(IPC_QUICK_CSS_UPDATE, await readCss());
mainWindow.webContents.postMessage(IpcEvents.QUICK_CSS_UPDATE, await readCss());
});
});
}

View File

@ -1,4 +1,5 @@
import Plugins from "plugins";
import { Settings } from "../api/settings";
import Logger from "../utils/logger";
import { Patch } from "../utils/types";
@ -7,7 +8,7 @@ const logger = new Logger("PluginManager", "#a6d189");
export const plugins = Plugins;
export const patches = [] as Patch[];
for (const plugin of Plugins) if (plugin.patches) {
for (const plugin of Plugins) if (plugin.patches && Settings.plugins[plugin.name].enabled) {
for (const patch of plugin.patches) {
patch.plugin = plugin.name;
if (!Array.isArray(patch.replacement)) patch.replacement = [patch.replacement];
@ -16,7 +17,7 @@ for (const plugin of Plugins) if (plugin.patches) {
}
export function startAll() {
for (const plugin of plugins) if (plugin.start) {
for (const plugin of plugins) if (plugin.start && Settings.plugins[plugin.name].enabled) {
try {
logger.info("Starting plugin", plugin.name);
plugin.start();

View File

@ -1,11 +1,12 @@
import { MessageClicks } from "../api";
import definePlugin from "../utils/types";
import { find, findByProps } from "../utils/webpack";
import { find, findByProps } from "../webpack";
export default definePlugin({
name: "MessageQuickActions",
description: "Quick Delete, Quick edit",
author: "Vendicated",
dependencies: ["MessageClicksApi"],
start() {
const { deleteMessage, startEditMessage } = findByProps("deleteMessage");
const { can } = findByProps("can", "initialize");

View File

@ -1,5 +1,5 @@
import definePlugin from "../utils/types";
import { findByProps } from "../utils/webpack";
import { findByProps } from "../webpack";
const DO_NOTHING = () => void 0;
@ -7,6 +7,7 @@ export default definePlugin({
name: "NoTrack",
description: "Disable Discord's tracking and crash reporting",
author: "Vendicated",
required: true,
start() {
findByProps("getSuperPropertiesBase64", "track").track = DO_NOTHING;
findByProps("submitLiveCrashReport").submitLiveCrashReport = DO_NOTHING;

View File

@ -5,6 +5,7 @@ export default definePlugin({
name: "Settings",
description: "Adds Settings UI and debug info",
author: "Vendicated",
required: true,
patches: [{
find: "default.versionHash",
replacement: [
@ -28,7 +29,7 @@ export default definePlugin({
}, {
find: "Messages.ACTIVITY_SETTINGS",
replacement: {
match: /\{section:(.{1,2})\.SectionTypes\.HEADER,label:(.{1,2})\.default\.Messages\.ACTIVITY_SETTINGS\}/,
match: /\{section:(.{1,2})\.SectionTypes\.HEADER,\s*label:(.{1,2})\.default\.Messages\.ACTIVITY_SETTINGS\}/,
replace: (m, mod) =>
`{section:${mod}.SectionTypes.HEADER,label:"Vencord"},` +
`{section:"Vencord",label:"Vencord",element:Vencord.Components.Settings},` +

22
src/utils/IpcEvents.ts Normal file
View File

@ -0,0 +1,22 @@
type Enum<T extends Record<string, string>> = {
[k in keyof T]: T[k];
} & { [v in keyof T as T[v]]: v; };
function strEnum<T extends Record<string, string>>(obj: T): T {
const o = {} as T;
for (const key in obj) {
o[key] = obj[key] as any;
o[obj[key]] = key as any;
};
return o;
}
export default strEnum({
QUICK_CSS_UPDATE: "VencordQuickCssUpdate",
GET_QUICK_CSS: "VencordGetQuickCss",
GET_SETTINGS_DIR: "VencordGetSettingsDir",
GET_SETTINGS: "VencordGetSettings",
SET_SETTINGS: "VencordSetSettings",
OPEN_EXTERNAL: "VencordOpenExternal",
OPEN_PATH: "VencordOpenPath",
} as const);

View File

@ -1,3 +0,0 @@
export const IPC_QUICK_CSS_UPDATE = "VencordQuickCssUpdate";
export const IPC_GET_QUICK_CSS = "VencordGetQuickCss";
export const IPC_GET_SETTINGS_DIR = "VencordGetSettingsDir";

61
src/utils/misc.tsx Normal file
View File

@ -0,0 +1,61 @@
import { React } from "../webpack";
/**
* Makes a lazy function. On first call, the value is computed.
* On subsequent calls, the same computed value will be returned
* @param factory Factory function
*/
export function lazy<T>(factory: () => T): () => T {
let cache: T;
return () => {
return cache ?? (cache = factory());
};
}
/**
* Await a promise
* @param factory Factory
* @param fallbackValue The fallback value that will be used until the promise resolved
* @returns A state that will either be null or the result of the promise
*/
export function useAwaiter<T>(factory: () => Promise<T>, fallbackValue: T | null = null): T | null {
const [res, setRes] = React.useState<T | null>(fallbackValue);
React.useEffect(() => {
factory().then(setRes);
}, []);
return res;
}
/**
* A lazy component. The factory method is called on first render. For example useful
* for const Component = LazyComponent(() => findByDisplayName("...").default)
* @param factory Function returning a Component
* @returns Result of factory function
*/
export function LazyComponent<T = any>(factory: () => React.ComponentType<T>) {
return (props: T) => {
const Component = React.useMemo(factory, []);
return <Component {...props} />;
};
}
/**
* Recursively merges defaults into an object and returns the same object
* @param obj Object
* @param defaults Defaults
* @returns obj
*/
export function mergeDefaults<T>(obj: T, defaults: T): T {
for (const key in defaults) {
const v = defaults[key];
if (typeof v === "object" && !Array.isArray(v)) {
obj[key] ??= {} as any;
mergeDefaults(obj[key], v);
} else {
obj[key] ??= v;
}
}
return obj;
}

View File

@ -1,6 +1,6 @@
import { WEBPACK_CHUNK } from './constants';
import Logger from "./logger";
import { _initWebpack } from "./webpack";
import { _initWebpack } from "../webpack";
let webpackChunk: any[];
@ -83,9 +83,13 @@ function patchPush() {
const lastCode = code;
try {
const newCode = code.replace(replacement.match, replacement.replace);
const newMod = (0, eval)(`// Webpack Module ${id} - Patched by ${[...patchedBy].join(", ")}\n${newCode}\n//# sourceURL=WebpackModule${id}`);
code = newCode;
mod = newMod;
if (newCode === code) {
logger.warn(`Patch by ${patch.plugin} had no effect: ${replacement.match}`);
} else {
const newMod = (0, eval)(`// Webpack Module ${id} - Patched by ${[...patchedBy].join(", ")}\n${newCode}\n//# sourceURL=WebpackModule${id}`);
code = newCode;
mod = newMod;
}
} catch (err) {
logger.error("Failed to apply patch of", patch.plugin, err);
code = lastCode;

View File

@ -1,6 +1,8 @@
import IpcEvents from "./IpcEvents";
document.addEventListener("DOMContentLoaded", async () => {
const style = document.createElement("style");
document.head.appendChild(style);
VencordNative.handleQuickCssUpdate((css: string) => style.innerText = css);
style.innerText = await VencordNative.getQuickCss();
VencordNative.ipc.on(IpcEvents.QUICK_CSS_UPDATE, (_, css: string) => style.innerText = css);
style.innerText = await VencordNative.ipc.invoke(IpcEvents.GET_QUICK_CSS);
});

View File

@ -20,6 +20,8 @@ export interface Plugin {
author: string;
start?(): void;
patches?: Patch[];
dependencies?: string[],
required?: boolean;
}
// @ts-ignore lole

View File

@ -1,5 +1,4 @@
import { startAll } from "../plugins";
import Logger from "./logger";
let webpackCache: typeof window.webpackChunkdiscord_app;
@ -9,11 +8,10 @@ export const listeners = new Set<CallbackFn>();
type FilterFn = (mod: any) => boolean;
type CallbackFn = (mod: any) => void;
export let Common: {
React: typeof import("react"),
FluxDispatcher: any;
UserStore: any;
} = {} as any;
export let React: typeof import("react");
export let FluxDispatcher: any;
export let Forms: any;
export let UserStore: any;
export function _initWebpack(instance: typeof window.webpackChunkdiscord_app) {
if (webpackCache !== void 0) throw "no.";
@ -24,9 +22,9 @@ export function _initWebpack(instance: typeof window.webpackChunkdiscord_app) {
// Abandon Hope All Ye Who Enter Here
let started = false;
waitFor("getCurrentUser", x => Common.UserStore = x);
waitFor("getCurrentUser", x => UserStore = x);
waitFor(["dispatch", "subscribe"], x => {
Common.FluxDispatcher = x;
FluxDispatcher = x;
const cb = () => {
console.info("Connection open");
x.unsubscribe("CONNECTION_OPEN", cb);
@ -34,7 +32,8 @@ export function _initWebpack(instance: typeof window.webpackChunkdiscord_app) {
};
x.subscribe("CONNECTION_OPEN", cb);
});
waitFor("useState", x => Common.React = x);
waitFor("useState", x => (React = x));
waitFor("FormSection", x => Forms = x);
}
export function find(filter: FilterFn, getDefault = true) {

View File

@ -9,7 +9,7 @@
"noImplicitAny": false,
"target": "ESNEXT",
// https://esbuild.github.io/api/#jsx-factory
"jsxFactory": "Vencord.Webpack.Common.React.createElement",
"jsxFactory": "Vencord.Webpack.React.createElement",
"jsx": "react"
},
"include": ["src/**/*"]