Devcompanion: only include in dev builds

This commit is contained in:
sadan4 2025-01-09 12:36:23 -05:00 committed by GitHub
parent 93ec8dbe7a
commit d26cf96343
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 2 additions and 2 deletions

View file

@ -0,0 +1,70 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import { Logger } from "@utils/Logger";
import definePlugin, { OptionType, ReporterTestable } from "@utils/types";
import { initWs, socket, stopWs } from "./initWs";
console.log("imported");
export const PORT = 8485;
const NAV_ID = "dev-companion-reconnect";
export const logger = new Logger("DevCompanion");
export const settings = definePluginSettings({
notifyOnAutoConnect: {
description: "Whether to notify when Dev Companion has automatically connected.",
type: OptionType.BOOLEAN,
default: true
},
usePatchedModule: {
description: "On extract requests, reply with the current patched module (if it is patched) instead of the original.",
default: true,
type: OptionType.BOOLEAN,
},
reloadAfterToggle: {
description: "Reload after a disable/enable plugin command is recived.",
default: true,
type: OptionType.BOOLEAN
}
});
export default definePlugin({
name: "DevCompanion",
description: "Dev Companion Plugin",
authors: [Devs.Ven, Devs.sadan, Devs.Samwich],
reporterTestable: ReporterTestable.None,
settings,
toolboxActions: {
"Reconnect"() {
socket?.close(1000, "Reconnecting");
initWs(true);
}
},
start() {
// if we're running the reporter, we need to initws in the reporter file to avoid a race condition
if (!IS_COMPANION_TEST)
initWs();
},
stop: stopWs,
});

View file

@ -0,0 +1,455 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { popNotice, showNotice } from "@api/Notices";
import ErrorBoundary from "@components/ErrorBoundary";
import { canonicalizeMatch, canonicalizeReplace } from "@utils/patches";
import { filters, findAll, search, wreq } from "@webpack";
import { React, Toasts, useState } from "@webpack/common";
import { loadLazyChunks } from "debug/loadLazyChunks";
import { reporterData } from "debug/reporterData";
import { Settings } from "Vencord";
import { logger, PORT, settings } from ".";
import { Recieve, Send } from "./types";
import { extractModule, extractOrThrow, findModuleId, mkRegexFind, parseNode, toggleEnabled, } from "./util";
export function stopWs() {
socket?.close(1000, "Plugin Stopped");
socket = void 0;
}
export let socket: WebSocket | undefined;
export function initWs(isManual = false) {
let wasConnected = isManual;
let hasErrored = false;
const ws = socket = new WebSocket(`ws://localhost:${PORT}`);
function replyData(data: Send.OutgoingMessage) {
ws.send(JSON.stringify(data));
}
ws.addEventListener("open", () => {
wasConnected = true;
logger.info("Connected to WebSocket");
// send module cache to vscode
replyData({
type: "moduleList",
data: {
modules: Object.keys(wreq.m)
},
ok: true
});
if (IS_COMPANION_TEST) {
const toSend = JSON.stringify(reporterData, (_k, v) => {
if (v instanceof RegExp)
return String(v);
return v;
});
socket?.send(JSON.stringify({
type: "report",
data: JSON.parse(toSend),
ok: true
}));
}
try {
if (settings.store.notifyOnAutoConnect || isManual) {
Toasts.show({
message: "Connected to WebSocket",
id: Toasts.genId(),
type: Toasts.Type.SUCCESS,
options: {
position: Toasts.Position.TOP
}
});
}
}
catch (e) {
console.error(e);
}
});
ws.addEventListener("error", e => {
if (!wasConnected) return;
hasErrored = true;
logger.error("Dev Companion Error:", e);
Toasts.show({
message: "Dev Companion Error",
id: Toasts.genId(),
type: Toasts.Type.FAILURE,
options: {
position: Toasts.Position.TOP
}
});
});
ws.addEventListener("close", e => {
if (!wasConnected || hasErrored) return;
logger.info("Dev Companion Disconnected:", e.code, e.reason);
Toasts.show({
message: "Dev Companion Disconnected",
id: Toasts.genId(),
type: Toasts.Type.FAILURE,
options: {
position: Toasts.Position.TOP
}
});
});
ws.addEventListener("message", e => {
try {
var d = JSON.parse(e.data) as Recieve.FullIncomingMessage;
} catch (err) {
logger.error("Invalid JSON:", err, "\n" + e.data);
return;
}
/**
* @param error the error to reply with. if there is no error, the reply is a sucess
*/
function reply(error?: string) {
const data = { nonce: d.nonce, ok: !error } as Record<string, unknown>;
if (error) data.error = error;
ws.send(JSON.stringify(data));
}
function replyData(data: Send.OutgoingMessage) {
const toSend: Send.FullOutgoingMessage = {
...data,
nonce: d.nonce
};
// data.nonce = d.nonce;
ws.send(JSON.stringify(toSend));
}
logger.info("Received Message:", d.type, "\n", d.data);
switch (d.type) {
case "disable": {
const m = d.data;
const settings = Settings.plugins[m.pluginName];
if (m.enabled !== settings.enabled)
toggleEnabled(m.pluginName, reply);
break;
}
case "rawId": {
const m = d.data;
replyData({
type: "rawId",
ok: true,
data: extractModule(m.id),
});
break;
}
case "diff": {
try {
const m = d.data;
switch (m.extractType) {
case "id": {
if (typeof m.idOrSearch !== "number")
throw new Error("Id is not a number, got :" + typeof m.idOrSearch);
replyData({
type: "diff",
ok: true,
data: {
patched: extractOrThrow(m.idOrSearch),
source: extractModule(m.idOrSearch, false),
moduleNumber: m.idOrSearch
},
});
break;
}
case "search": {
let moduleId: number;
if (m.findType === "string")
moduleId = +findModuleId([m.idOrSearch.toString()]);
else
moduleId = +findModuleId(mkRegexFind(m.idOrSearch));
const p = extractOrThrow(moduleId);
const p2 = extractModule(moduleId, false);
replyData({
type: "diff",
ok: true,
data: {
patched: p,
source: p2,
moduleNumber: moduleId
},
});
break;
}
}
} catch (error) {
reply(String(error));
}
break;
}
case "reload": {
reply();
window.location.reload();
break;
}
case "extract": {
try {
const m = d.data;
switch (m.extractType) {
case "id": {
if (typeof m.idOrSearch !== "number")
throw new Error("Id is not a number, got :" + typeof m.idOrSearch);
else
replyData({
type: "extract",
ok: true,
data: {
module: extractModule(m.idOrSearch),
moduleNumber: m.idOrSearch,
},
});
break;
}
case "search": {
let moduleId;
if (m.findType === "string")
moduleId = +findModuleId([m.idOrSearch.toString()]);
else
moduleId = +findModuleId(mkRegexFind(m.idOrSearch));
replyData({
type: "extract",
ok: true,
data: {
module: extractModule(moduleId),
moduleNumber: moduleId
},
});
break;
}
case "find": {
try {
var parsedArgs = m.findArgs.map(parseNode);
} catch (err) {
return reply("Failed to parse args: " + err);
}
try {
let results: any[];
switch (m.findType.replace("find", "").replace("Lazy", "")) {
case "":
case "Component":
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;
case "ComponentByCode":
results = findAll(filters.componentByCode(...parsedArgs));
break;
default:
return reply("Unknown Find Type " + m.findType);
}
const uniqueResultsCount = new Set(results).size;
if (uniqueResultsCount === 0) throw "No results";
if (uniqueResultsCount > 1) throw "Found more than one result! Make this filter more specific";
// best name ever
const foundFind: string = [...results][0].toString();
replyData({
type: "extract",
ok: true,
data: {
module: foundFind,
find: true,
moduleNumber: +findModuleId([foundFind])
},
});
} catch (err) {
return reply("Failed to find: " + err);
}
break;
}
default:
reply(`Unknown Extract type. Got: ${d.data.extractType}`);
break;
}
} catch (error) {
reply(String(error));
}
break;
}
case "testPatch": {
const m = d.data;
let candidates;
if (d.data.findType === "string")
candidates = search(m.find.toString());
else
candidates = search(...mkRegexFind(m.find));
// const candidates = search(find);
const keys = Object.keys(candidates);
if (keys.length !== 1)
return reply("Expected exactly one 'find' matches, found " + keys.length);
const mod = candidates[keys[0]];
let src = String(mod.original ?? mod).replaceAll("\n", "");
if (src.startsWith("function(")) {
src = "0," + src;
}
let i = 0;
for (const { match, replace } of m.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 m = d.data;
try {
var parsedArgs = m.args.map(parseNode);
} catch (err) {
return reply("Failed to parse args: " + err);
}
try {
let results: any[];
switch (m.type.replace("find", "").replace("Lazy", "")) {
case "":
case "Component":
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;
case "ComponentByCode":
results = findAll(filters.componentByCode(...parsedArgs));
break;
default:
return reply("Unknown Find Type " + m.type);
}
const uniqueResultsCount = new Set(results).size;
if (uniqueResultsCount === 0) throw "No results";
if (uniqueResultsCount > 1) throw "Found more than one result! Make this filter more specific";
} catch (err) {
return reply("Failed to find: " + err);
}
reply();
break;
}
case "allModules": {
const { promise, resolve, reject } = Promise.withResolvers<void>();
// wrap in try/catch to prevent crashing if notice api is not loaded
try {
let closed = false;
const close = () => {
if (closed) return;
closed = true;
popNotice();
};
// @ts-expect-error it accepts react components
showNotice(<AllModulesNoti done={promise} close={close} />, "OK", () => {
closed = true;
popNotice();
});
} catch (e) {
console.error(e);
}
loadLazyChunks()
.then(() => {
resolve();
replyData({
type: "moduleList",
data: {
modules: Object.keys(wreq.m)
},
ok: true
});
})
.catch(e => {
console.error(e);
replyData({
type: "moduleList",
ok: false,
error: String(e),
data: null
});
reject(e);
});
break;
}
default:
reply("Unknown Type " + (d as any).type);
break;
}
});
}
interface AllModulesNotiProps {
done: Promise<unknown>;
close: () => void;
}
const AllModulesNoti = ErrorBoundary.wrap(function ({ done, close }: AllModulesNotiProps) {
const [state, setState] = useState<0 | 1 | -1>(0);
done.then(setState.bind(null, 1)).catch(setState.bind(null, -1));
console.log("test");
if (state === 1) setTimeout(close, 5000);
return (<>
{state === 0 && "Loading lazy modules, restarting could lead to errors"}
{state === 1 && "Loaded all lazy modules"}
{state === -1 && "Failed to load lazy modules, check console for errors"}
</>);
}, { noop: true });

View file

@ -0,0 +1,8 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export * as Recieve from "./recieve";
export * as Send from "./send";

View file

@ -0,0 +1,140 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
// should be the same types as ./server/types/send.ts in the companion
export type SearchData =
| {
extractType: "id";
idOrSearch: number;
}
| (
| {
extractType: "search";
/**
* stringified regex
*/
idOrSearch: string;
findType: "regex";
}
| {
extractType: "search";
idOrSearch: string;
findType: "string";
}
);
export type FindOrSearchData =
| SearchData
| ({
extractType: "find";
} & _PrefixKeys<_CapitalizeKeys<FindData>, "find">);
export type AnyFindType =
`find${"Component" | "ByProps" | "Store" | "ByCode" | "ModuleId" | "ComponentByCode" | ""}${"Lazy" | ""}`;
export type StringNode = {
type: "string";
value: string;
};
export type RegexNode = {
type: "regex";
value: {
pattern: string;
flags: string;
};
};
export type FunctionNode = {
type: "function";
value: string;
};
export type FindNode = StringNode | RegexNode | FunctionNode;
export type FindData = {
type: AnyFindType;
args: FindNode[];
};
export type IncomingMessage = DisablePlugin | RawId | DiffPatch | Reload | ExtractModule | TestPatch | TestFind | AllModules;
export type FullIncomingMessage = IncomingMessage & { nonce: number; };
export type DisablePlugin = {
type: "disable";
data: {
enabled: boolean;
pluginName: string;
};
};
export type RawId = {
type: "rawId";
data: {
id: number;
};
};
export type DiffPatch = {
type: "diff";
data: SearchData;
};
export type Reload = {
type: "reload";
data: null;
};
export type ExtractModule = {
type: "extract";
// FIXME: update client code so you can just pass FindData here
data: FindOrSearchData;
};
export type TestPatch = {
type: "testPatch";
data: (
| {
findType: "string";
find: string;
}
| {
findType: "regex";
/**
* stringified regex
*/
find: string;
}
) & {
replacement: {
match: StringNode | RegexNode;
replace: StringNode | RegexNode;
}[];
};
};
export type TestFind = {
type: "testFind";
data: FindData;
};
export type AllModules = {
type: "allModules";
data: null;
};
type _PrefixKeys<
T extends Record<string, any>,
P extends string,
> = string extends P
? never
: {
[K in keyof T as K extends string ? `${P}${K}` : never]: T[K];
};
type _CapitalizeKeys<T extends Record<string, any>> = {
[K in keyof T as K extends string ? Capitalize<K> : never]: T[K];
};

View file

@ -0,0 +1,63 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
// should be the same types as src/server/types/recieve.ts in the companion
import { ReporterData as IReporterData } from "debug/reporterData";
export type ReporterData = IReporterData;
export type OutgoingMessage = (Report | DiffModule | ExtractModule | ModuleList | RawId) & Base;
export type FullOutgoingMessage = OutgoingMessage & Nonce;
export type Base = {
ok: true;
} | {
ok: false;
data?: any;
error: string;
};
export type Nonce = {
nonce: number;
};
export type ModuleResult = {
moduleNumber: number;
};
// #region valid payloads
export type Report = {
type: "report";
data: ReporterData;
};
export type DiffModule = {
type: "diff";
data: {
source: string;
patched: string;
} & ModuleResult;
};
export type ExtractModule = {
type: "extract";
data: {
module: string;
/**
* if the module is incomplete. ie: from a find
*/
find?: boolean;
} & ModuleResult;
};
export type ModuleList = {
type: "moduleList";
data: {
modules: string[];
};
};
export type RawId = {
type: "rawId";
data: string;
};
// #endregion

View file

@ -0,0 +1,162 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { showNotice } from "@api/Notices";
import { Settings } from "@api/Settings";
import { canonicalizeMatch } from "@utils/patches";
import { CodeFilter, stringMatches, wreq } from "@webpack";
import { Toasts } from "@webpack/common";
import { settings as companionSettings } from ".";
import { Recieve } from "./types";
/**
* extracts the patched module, if there is no patched module, throws an error
* @param id module id
*/
export function extractOrThrow(id: number): string {
const module = wreq.m[id];
if (!module?.$$vencordPatchedSource)
throw new Error("No patched module found for module id " + id);
return module.$$vencordPatchedSource;
}
/**
* attempts to extract the module, throws if not found
*
*
* if patched is true and no patched module is found fallsback to the non-patched module
* @param id module id
* @param patched return the patched module
*/
export function extractModule(id: number, patched = companionSettings.store.usePatchedModule): string {
const module = wreq.m[id];
if (!module)
throw new Error("No module found for module id:" + id);
return patched ? module.$$vencordPatchedSource ?? module.original.toString() : module.original.toString();
}
export function parseNode(node: Recieve.FindNode): any {
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);
}
}
// we need to have our own because the one in webpack returns the first with no handling of more than one module
export function findModuleId(find: CodeFilter) {
const matches: string[] = [];
for (const id in wreq.m) {
if (stringMatches(wreq.m[id].toString(), find)) matches.push(id);
}
if (matches.length === 0) {
throw new Error("No Matches Found");
}
if (matches.length !== 1) {
throw new Error(`This filter matches ${matches.length} modules. Make it more specific!`);
}
return matches[0];
}
export function mkRegexFind(idOrSearch: string): RegExp[] {
const regex = idOrSearch.substring(1, idOrSearch.lastIndexOf("/"));
const flags = idOrSearch.substring(idOrSearch.lastIndexOf("/") + 1);
return [canonicalizeMatch(RegExp(regex, flags))];
}
// the next two functions are copied from components/pluginSettings
function showErrorToast(message: string) {
Toasts.show({
message,
type: Toasts.Type.FAILURE,
id: Toasts.genId(),
options: {
position: Toasts.Position.BOTTOM
}
});
}
export function toggleEnabled(name: string, beforeReload: (error?: string) => void) {
let restartNeeded = false;
function onRestartNeeded() {
restartNeeded = true;
}
function beforeReturn() {
if (restartNeeded) {
if (companionSettings.store.reloadAfterToggle) {
beforeReload();
window.location.reload();
}
Toasts.show({
id: Toasts.genId(),
message: "Reload Needed",
type: Toasts.Type.MESSAGE,
options: {
duration: 5000,
position: Toasts.Position.TOP
}
});
}
}
const plugin = Vencord.Plugins.plugins[name];
const settings = Settings.plugins[plugin.name];
const isEnabled = () => settings.enabled ?? false;
const wasEnabled = isEnabled();
// If we're enabling a plugin, make sure all deps are enabled recursively.
if (!wasEnabled) {
const { restartNeeded, failures } = Vencord.Plugins.startDependenciesRecursive(plugin);
if (failures.length) {
console.error(`Failed to start dependencies for ${plugin.name}: ${failures.join(", ")}`);
showNotice("Failed to start dependencies: " + failures.join(", "), "Close", () => null);
beforeReturn();
return;
} else if (restartNeeded) {
// If any dependencies have patches, don't start the plugin yet.
settings.enabled = true;
onRestartNeeded();
beforeReturn();
return;
}
}
// if the plugin has patches, dont use stopPlugin/startPlugin. Wait for restart to apply changes.
if (plugin.patches?.length) {
settings.enabled = !wasEnabled;
onRestartNeeded();
beforeReturn();
return;
}
// If the plugin is enabled, but hasn't been started, then we can just toggle it off.
if (wasEnabled && !plugin.started) {
settings.enabled = !wasEnabled;
beforeReturn();
return;
}
const result = wasEnabled ? Vencord.Plugins.stopPlugin(plugin) : Vencord.Plugins.startPlugin(plugin);
if (!result) {
settings.enabled = false;
const msg = `Error while ${wasEnabled ? "stopping" : "starting"} plugin ${plugin.name}`;
console.error(msg);
showErrorToast(msg);
beforeReturn();
return;
}
settings.enabled = !wasEnabled;
beforeReturn();
}