diff --git a/src/plugins/devCompanion.dev.tsx b/src/plugins/devCompanion.dev.tsx new file mode 100644 index 00000000..eaf13b7d --- /dev/null +++ b/src/plugins/devCompanion.dev.tsx @@ -0,0 +1,250 @@ +/* + * Vencord, a modification for Discord's desktop app + * Copyright (c) 2023 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 . +*/ + +import { addContextMenuPatch } from "@api/ContextMenu"; +import { showNotification } from "@api/Notifications"; +import { Devs } from "@utils/constants"; +import Logger from "@utils/Logger"; +import { canonicalizeMatch, canonicalizeReplace } from "@utils/patches"; +import definePlugin from "@utils/types"; +import { filters, findAll, search } from "@webpack"; +import { Menu } from "@webpack/common"; + +const PORT = 8485; +const NAV_ID = "dev-companion-reconnect"; + +const logger = new Logger("DevCompanion"); + +let socket: WebSocket | undefined; + +type Node = StringNode | RegexNode | FunctionNode; + +interface StringNode { + type: "string"; + value: string; +} + +interface RegexNode { + type: "regex"; + value: { + pattern: string; + flags: string; + }; +} + +interface FunctionNode { + type: "function"; + value: string; +} + +interface PatchData { + find: string; + replacement: { + match: StringNode | RegexNode; + replace: StringNode | FunctionNode; + }[]; +} + +interface FindData { + type: string; + args: Array; +} + +function parseNode(node: Node) { + switch (node.type) { + case "string": + return node.value; + case "regex": + return new RegExp(node.value.pattern, node.value.flags); + case "function": + // We LOVE remote code execution + // Safety: This comes from localhost only, which actually means we have less permissions than the source, + // since we're running in the browser sandbox, whereas the sender has host access + return (0, eval)(node.value); + default: + throw new Error("Unknown Node Type " + (node as any).type); + } +} + +function initWs(isManual = false) { + let wasConnected = isManual; + let hasErrored = false; + const ws = socket = new WebSocket(`ws://localhost:${PORT}`); + + ws.addEventListener("open", () => { + wasConnected = true; + + logger.info("Connected to WebSocket"); + + showNotification({ + title: "Dev Companion Connected", + body: "Connected to WebSocket" + }); + }); + + ws.addEventListener("error", e => { + if (!wasConnected) return; + + hasErrored = true; + + logger.error("Dev Companion Error:", e); + + showNotification({ + title: "Dev Companion Error", + body: (e as ErrorEvent).message || "No Error Message", + color: "var(--status-danger, red)" + }); + }); + + ws.addEventListener("close", e => { + if (!wasConnected && !hasErrored) return; + + logger.info("Dev Companion Disconnected:", e.code, e.reason); + + showNotification({ + title: "Dev Companion Disconnected", + body: e.reason || "No Reason provided", + color: "var(--status-danger, red)" + }); + }); + + ws.addEventListener("message", e => { + try { + var { nonce, type, data } = JSON.parse(e.data); + } catch (err) { + logger.error("Invalid JSON:", err, "\n" + e.data); + return; + } + + function reply(error?: string) { + const data = { nonce, ok: !error } as Record; + if (error) data.error = error; + + ws.send(JSON.stringify(data)); + } + + logger.info("Received Message:", type, "\n", data); + + switch (type) { + case "testPatch": { + const { find, replacement } = data as PatchData; + + const candidates = search(find); + const keys = Object.keys(candidates); + if (keys.length !== 1) + return reply("Expected exactly one 'find' matches, found " + keys.length); + + let src = String(candidates[keys[0]]); + + let i = 0; + + for (const { match, replace } of replacement) { + i++; + + try { + const matcher = canonicalizeMatch(parseNode(match)); + const replacement = canonicalizeReplace(parseNode(replace), "PlaceHolderPluginName"); + + const newSource = src.replace(matcher, replacement as string); + + if (src === newSource) throw "Had no effect"; + Function(newSource); + + src = newSource; + } catch (err) { + return reply(`Replacement ${i} failed: ${err}`); + } + } + + reply(); + break; + } + case "testFind": { + const { type, args } = data as FindData; + try { + var parsedArgs = args.map(parseNode); + } catch (err) { + return reply("Failed to parse args: " + err); + } + + try { + let results: any[]; + switch (type.replace("find", "").replace("Lazy", "")) { + case "": + results = findAll(parsedArgs[0]); + break; + case "ByProps": + results = findAll(filters.byProps(...parsedArgs)); + break; + case "Store": + results = findAll(filters.byStoreName(parsedArgs[0])); + break; + case "ByCode": + results = findAll(filters.byCode(...parsedArgs)); + break; + case "ModuleId": + results = Object.keys(search(parsedArgs[0])); + break; + default: + return reply("Unknown Find Type " + type); + } + + if (results.length === 0) throw "No results"; + if (results.length > 1) throw "Found more than one result! Make this filter more specific"; + } catch (err) { + return reply("Failed to find: " + err); + } + + reply(); + break; + } + default: + reply("Unknown Type " + type); + break; + } + }); +} + +export default definePlugin({ + name: "DevCompanion", + description: "Dev Companion Plugin", + authors: [Devs.Ven], + + start() { + initWs(); + addContextMenuPatch("user-settings-cog", kids => { + if (kids.some(k => k?.props?.id === NAV_ID)) return; + + kids.unshift( + { + socket?.close(1000, "Reconnecting"); + initWs(true); + }} + /> + ); + }); + }, + + stop() { + socket?.close(1000, "Plugin Stopped"); + socket = void 0; + } +});