/* * Vencord, a Discord client mod * Copyright (c) 2025 nin0 * SPDX-License-Identifier: GPL-3.0-or-later */ import { NativeSettings } from "@main/settings"; import { exec, spawn } from "child_process"; import { BrowserWindow, dialog, shell, WebContentsView } from "electron"; import { existsSync, readdirSync, readFileSync } from "fs"; import { readdir, readFile, rm } from "fs/promises"; import { basename, join } from "path"; import yaml from "yaml-js"; // @ts-ignore fuck off import pluginValidateContent from "./misc/pluginValidate.txt"; // i would use HTML but esbuild is being whiny // @ts-ignore fuck off import setGitPathContent from "./misc/setGitPath.txt"; // @ts-ignore fuck off import updateValidateContent from "./misc/updateValidate.txt"; // see above const PLUGIN_META_REGEX = /export default definePlugin\((?:\s|\/(?:\/|\*).*)*{\s*(?:\s|\/(?:\/|\*).*)*name:\s*(?:"|'|`)(.*)(?:"|'|`)(?:\s|\/(?:\/|\*).*)*,(?:\s|\/(?:\/|\*).*)*(?:\s|\/(?:\/|\*).*)*description:\s*(?:"|'|`)(.*)(?:"|'|`)(?:\s|\/(?:\/|\*).*)*/; // if edited, also edit in misc/constants.ts!!! const CLONE_LINK_REGEX = /https:\/\/(?:((?:git(?:hub|lab)\.com|git\.(?:[a-zA-Z0-9]|\.)+|codeberg\.org))\/(?!user-attachments)((?:[a-zA-Z0-9]|-)+)\/((?:[a-zA-Z0-9]|-|\.)+)(?:\.git)?|(plugins\.(nin0)\.dev)\/((?:[a-zA-Z0-9]|-|\.)+))(?:\/)?/; const vencordPath = ["desktop", "equibop"].includes(basename(__dirname)) ? join(__dirname, "../") : __dirname; export async function rmPlugin(_, name: string): Promise { // eslint-disable-next-line return new Promise(async (resolve, reject) => { const ups = await getUserplugins(); const pl = ups.find(p => p.directory! === name); if (!pl) return; const deleteReqDialog = await dialog.showMessageBox({ title: "Uninstall plugin", message: `Uninstall ${pl.name}`, type: "error", detail: `The uninstall of the userplugin ${pl.name} has been requested. Would you like to do so?\n\nIf you did not initiate this, press No.`, buttons: ["No", "Yes"] }); if (deleteReqDialog.response !== 1) return reject("User rejected"); await rm(join(vencordPath, "../src/userplugins", name), { recursive: true }); await build(); resolve("Done"); }); } export async function isUpdateAvailableForPlugin(_, name: string): Promise { return new Promise(resolve => { const pluginDir = join(vencordPath, "../src/userplugins", name); const otherProc = exec("git fetch", { cwd: pluginDir }); otherProc.once("close", () => { async function doStuff() { try { const head = (await readFile(join(pluginDir, ".git/HEAD"), "utf8")).match(/^ref: (.+)/)![1]; const remoteHead = (await readFile(join(pluginDir, ".git/refs/remotes/origin/HEAD"), "utf8")).match(/^ref: (.+)/)![1]; const localCommit = await readFile(join(pluginDir, ".git", head), "utf8"); const remoteCommit = await readFile(join(pluginDir, ".git", remoteHead), "utf8"); resolve(localCommit !== remoteCommit); } catch (e) { resolve(false); } } doStuff(); }); }); } export function initPluginInstall(_, link: string, source: string, owner: string, repo: string): Promise { // eslint-disable-next-line return new Promise(async (resolve, reject) => { const verifiedRegex = link.match(CLONE_LINK_REGEX)!; const idpl = source === "plugins.nin0.dev" ? 1 : 0; if (![4, 7].includes(verifiedRegex.length) || verifiedRegex[0] !== link || verifiedRegex[[1, 4][idpl]] !== source || verifiedRegex[[2, 5][idpl]] !== owner || verifiedRegex[[3, 6][idpl]] !== repo) return reject("Invalid link"); // Ask for clone const cloneDialog = await dialog.showMessageBox({ title: "Clone userplugin", message: `You are about to clone a userplugin from ${source}.`, type: "question", detail: `The repository name is "${repo}" and it is owned by "${owner}".\nThe repository URL is ${link}\n\n(If you did not request this intentionally, choose Cancel)`, buttons: ["Cancel", "Clone repository and continue install", "Open repository in browser"] }); switch (cloneDialog.response) { case 0: { return reject("Rejected by user"); } case 1: { await cloneRepo(link, repo); break; } case 2: { await shell.openExternal(link); return reject("silentStop"); } } // Get plugin meta const meta = await getPluginMeta(join(vencordPath, "..", "src", "userplugins", repo)); // Review plugin const win = new BrowserWindow({ maximizable: false, minimizable: false, width: 560, height: meta.usesNative || meta.usesPreSend ? 650 : 360, resizable: false, webPreferences: { devTools: true }, title: "Review userplugin", modal: true, parent: BrowserWindow.getAllWindows()[0], show: false, autoHideMenuBar: true }); const reView /* haha got it */ = new WebContentsView({ webPreferences: { devTools: true, nodeIntegration: true } }); win.contentView.addChildView(reView); win.loadURL(generateReviewPluginContent(meta)); win.on("page-title-updated", async e => { switch (win.webContents.getTitle() as "abortInstall" | "reviewCode" | "install") { case "abortInstall": { win.close(); await rm(join(vencordPath, "..", "src", "userplugins", repo), { recursive: true }); return reject("Rejected by user"); } case "install": { win.close(); try { await build(); } catch (e) { reject((e as Error).toString()); } resolve(JSON.stringify({ name: meta.name, native: meta.usesNative })); break; } } }); win.show(); }); } async function build(): Promise { return new Promise((resolve, reject) => { const proc = exec("pnpm build --dev", { cwd: join(vencordPath, ".."), shell: process.env.SHELL || process.env.ComSpec || "/bin/sh" }); proc.once("close", () => { if (proc.exitCode !== 0) { reject("Failed to build Vencord, try building from console"); } resolve("Success"); }); }); } async function getPluginMeta(path: string, extra: object = {}): Promise<{ name: string; description: string; usesPreSend: boolean; usesNative: boolean; directory?: string; remote: string; supportChannelID?: string; }> { return new Promise((resolve, reject) => { const files = readdirSync(path); let fileToRead: "index.ts" | "index.tsx" | "index.js" | "index.jsx" | undefined; files.forEach(f => { if (f === "index.ts") fileToRead = "index.ts"; if (f === "index.tsx") fileToRead = "index.tsx"; if (f === "index.js") fileToRead = "index.js"; if (f === "index.jsx") fileToRead = "index.jsx"; }); if (!fileToRead) reject("Invalid plugin"); const file = readFileSync(`${path}/${fileToRead}`, "utf8"); let remoteURL; try { const remoteC = readFileSync(join(path, ".git/config"), "utf8"); remoteURL = remoteC.match(/\[remote "origin"]\s+url = (https:\/\/(?:(?:git(?:hub|lab)\.com|git\.(?:[a-zA-Z0-9]|\.)+|codeberg\.org)\/(?!user-attachments)(?:[a-zA-Z0-9]|-)+\/(?:[a-zA-Z0-9]|-|\.)+(?:\.git)?|(plugins\.(nin0)\.dev)\/((?:[a-zA-Z0-9]|-|\.)+))(?:\/)?)\n/); } catch { remoteURL = null; } let supportChannelID; try { const meta = readFileSync(join(path, "meta.yml"), "utf8"); console.log(meta); const parsed = yaml.load(meta); if (parsed.thread && typeof parsed.thread === "string" && /^\d+$/.test(parsed.thread)) { supportChannelID = parsed.thread; } } catch { supportChannelID = null; } const rawMeta = file.match(PLUGIN_META_REGEX); resolve({ name: rawMeta![1], description: rawMeta![2], usesPreSend: file.includes("PreSendListener") || file.includes("onBeforeMessage"), usesNative: files.includes("native.ts") || files.includes("native.js"), remote: remoteURL ? remoteURL[1] : "", supportChannelID, ...extra }); }); } async function cloneRepo(link: string, repo: string): Promise { return new Promise((resolve, reject) => { const proc = spawn("git", ["clone", link], { cwd: join(vencordPath, "..", "src", "userplugins") }); proc.once("close", async () => { if (proc.exitCode !== 0) { if (!existsSync(join(vencordPath, "..", "src", "userplugins", repo))) return reject("Failed to clone"); const deleteReqDialog = await dialog.showMessageBox({ title: "Error", message: "Plugin already exists", type: "error", detail: `The plugin that you tried to clone already exists at ${join(vencordPath, "..", "src", "userplugins")}.\nWould you like to reclone it? Only do this if you want to reinstall or update the plugin.`, buttons: ["No", "Yes"] }); if (deleteReqDialog.response !== 1) return reject("User rejected"); await rm(join(vencordPath, "..", "src", "userplugins", repo), { recursive: true }); await cloneRepo(link, repo); } resolve(); }); }); } function generateReviewPluginContent(meta: { name: string; description: string; usesPreSend: boolean; usesNative: boolean; }): string { const template = pluginValidateContent.replace("%PLUGINNAME%", meta.name.replaceAll("<", "<")).replace("%PLUGINDESC%", meta.description.replaceAll("<", "<")).replace("%WARNINGHIDER%", !meta.usesNative && !meta.usesPreSend ? "[data-useless=\"warning\"] { display: none !important; }" : "").replace("%NATIVETSHIDER%", meta.usesNative ? "" : "#native-ts-warning { display: none !important; }").replace("%PRESENDHIDER%", meta.usesPreSend ? "" : "#pre-send-warning { display: none !important; }"); const buf = Buffer.from(template).toString("base64"); return `data:text/html;base64,${buf}`; } function generateUpdatePluginContent(meta: { name: string; description: string; remote: string; commit: string; }): string { const template = updateValidateContent.replace("%PLUGINNAME%", meta.name.replaceAll("<", "<")).replace("%PLUGINDESC%", meta.description.replaceAll("<", "<")).replace("%REMOTE%", meta.remote).replace("%COMMITMESSAGE%", meta.commit.replaceAll("\n", "
")); const buf = Buffer.from(template).toString("base64"); return `data:text/html;base64,${buf}`; } export async function getUserplugins() { const folderContents = await readdir(join(vencordPath, "..", "src", "userplugins"), { withFileTypes: true }); const plugins = await Promise.allSettled( folderContents .filter(item => item.isDirectory()) .map(item => ({ path: join(item.parentPath, item.name), directory: item.name })) .map(({ path, directory }) => getPluginMeta(path, { directory })) ); return plugins .filter(p => p.status === "fulfilled") .map(p => p.value); } export async function updatePlugin(_, directory: string) { return new Promise((resolve, reject) => { const pluginDir = join(vencordPath, "../src/userplugins", directory); async function doStuff() { const pluginMeta = await getPluginMeta(pluginDir); const win = new BrowserWindow({ maximizable: false, minimizable: false, width: 560, height: 600, resizable: false, webPreferences: { devTools: true }, title: "Review userplugin", modal: true, parent: BrowserWindow.getAllWindows()[0], show: false, autoHideMenuBar: true }); const reView /* haha got it */ = new WebContentsView({ webPreferences: { devTools: true, nodeIntegration: true } }); win.contentView.addChildView(reView); const commitProc = exec("git log origin/HEAD...HEAD --oneline --pretty=format:%an////////%h////////%H////////%s", { cwd: pluginDir }); let rawOutput = ""; commitProc.stdout?.on("data", d => { rawOutput += String(d); }); commitProc.once("close", () => { win.loadURL(generateUpdatePluginContent({ name: pluginMeta.name, description: pluginMeta.description, remote: pluginMeta.remote, commit: rawOutput.split("\n").map(line => line.split("////////")).map(([user, shortCommit, longCommit, message]) => `${user} (${shortCommit}) ~ ${message}`).join("\n") })); win.on("page-title-updated", async e => { if (win.webContents.getTitle().startsWith("openLink:")) { await shell.openExternal(win.webContents.getTitle().replace("openLink:", "")); } switch (win.webContents.getTitle() as "abortInstall" | "install") { case "abortInstall": { win.close(); return reject("Rejected by user"); } case "install": { win.close(); try { const otherProc = exec("git rebase origin/HEAD", { cwd: pluginDir }); let errored = ""; otherProc.stderr?.on("data", d => { if (String(d).includes("Success")) return; errored += String(d); }); otherProc.once("close", () => { if (errored) if (!errored.includes("Success")) return reject(`Failed to apply the update, chances are you have local changes that conflict with your remote. Git errors:\n\n${errored}`); build().then(() => resolve(JSON.stringify({ name: pluginMeta.name, native: pluginMeta.usesNative }))); }); } catch (e) { reject((e as Error).toString()); } break; } } }); }); win.show(); } doStuff(); }); } export async function openGitPathModal(_: any) { const gitPathSet: string | undefined = NativeSettings.store.plugins.UserpluginInstaller?.gitPath; const win = new BrowserWindow({ maximizable: false, minimizable: false, width: 560, height: 400, resizable: false, webPreferences: { devTools: true }, title: "Set Git path", modal: true, parent: BrowserWindow.getAllWindows()[0], show: false, autoHideMenuBar: true }); const reView = new WebContentsView({ webPreferences: { devTools: true, nodeIntegration: true } }); win.contentView.addChildView(reView); win.loadURL(`data:text/html;base64,${Buffer.from(setGitPathContent).toString("base64")}`); win.on("page-title-updated", async _ => { const t = win.webContents.getTitle(); if (t === "abort") win.close(); if (t.startsWith("ok")) { if (!NativeSettings.store.plugins.UserpluginInstaller) { NativeSettings.store.plugins.UserpluginInstaller = { gitPath: undefined }; } if (t === "ok-") { NativeSettings.store.plugins.UserpluginInstaller.gitPath = undefined; } else { const gitPath2 = t.split("-").toSpliced(0, 1).join("-"); NativeSettings.store.plugins.UserpluginInstaller.gitPath = gitPath2; } win.close(); } if (t.startsWith("check")) { try { const gitProc = spawn(t === "check-" ? "git" : t.split("-").toSpliced(0, 1).join("-"), ["--version"]); let rawOutput = ""; gitProc.stdout?.on("data", d => { rawOutput += String(d); }); gitProc.on("error", e => { dialog.showMessageBox({ title: "Error", message: "Git error", type: "error", detail: `${e}\n\nDouble-check the path you entered.`, buttons: ["OK"] }); }); gitProc.once("close", () => { if (gitProc.exitCode === 0) { dialog.showMessageBox({ title: "Success", message: "Git works!", type: "info", detail: `Successfully called ${rawOutput.trim()}`, buttons: ["OK"] }); } }); } catch (e) { dialog.showMessageBox({ title: "Error", message: "Git error", type: "error", detail: `${e}\n\nDouble-check the path you entered.`, buttons: ["OK"] }); } } }); win.show(); if (gitPathSet) { win.webContents.executeJavaScript(`document.querySelector("input").value = ${JSON.stringify(gitPathSet)};`); } console.log(yaml.load(` name: test items: - a - b `)); }