feat(plugin): UserpluginInstaller (#974)

Co-authored-by: thororen1234 <78185467+thororen1234@users.noreply.github.com>
Co-authored-by: creations <creations@creations.works>
This commit is contained in:
nin0 2026-04-16 11:44:14 -04:00 • committed by GitHub
parent 8e19aecd92
commit c0459e2adb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 6732 additions and 0 deletions

View file

@ -0,0 +1,40 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 nin0
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export class VariableWithCallbacks<T> {
#value: T;
#callbacks: {
id: number;
callback: (value: T, id: number) => void;
}[] = [];
constructor(value: T) {
this.#value = value;
}
value(newValue?: T): T {
if (newValue !== undefined) {
this.#value = newValue;
this.#callbacks.forEach(c => c.callback(this.#value, c.id));
}
return this.#value;
}
registerCallback(callback: (value: T, id: number) => void): number {
const id = Date.now();
this.#callbacks.push({
id,
callback
});
return id;
}
deregisterCallback(id: number) {
const possibleFallback = this.#callbacks.find(cb => cb.id === id);
if (!possibleFallback) return;
this.#callbacks.splice(this.#callbacks.indexOf(possibleFallback), 1);
}
}

View file

@ -0,0 +1,340 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 nin0
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { pluginRequiresRestart, startPlugin, stopPlugin } from "@api/PluginManager";
import { useSettings } from "@api/Settings";
import { BaseText } from "@components/BaseText";
import { Button } from "@components/Button";
import { Card } from "@components/Card";
import { CheckedTextInput } from "@components/CheckedTextInput";
import { HeadingTertiary } from "@components/Heading";
import { DeleteIcon } from "@components/Icons";
import { Paragraph } from "@components/Paragraph";
import { AddonCard } from "@components/settings";
import {
SettingsTab as STab,
wrapTab,
} from "@components/settings/tabs/BaseTab";
import { classes, isObjectEmpty } from "@utils/misc";
import { closeAllModals } from "@utils/modal";
import { relaunch } from "@utils/native";
import { Alerts, NavigationRouter, Toasts, useEffect, useState } from "@webpack/common";
import userpluginInstaller, { Native } from "..";
import {
cl,
CLONE_LINK_REGEX,
showInstallFinishedAlert,
} from "../misc/constants";
function UserPluginsTab() {
const rs = useSettings(["plugins.*"]);
const [pluginsLoaded, loadPlugins] = useState(false);
const [plugins, setPlugins] = useState<
{
name: string;
description: string;
usesPreSend: boolean;
usesNative: boolean;
directory?: string;
remote: string;
supportChannelID?: string;
}[]
>([]);
const [url, setUrl] = useState("");
const [valid, setValid] = useState(false);
const [pluginsWithUpdates, setPluginsWithUpdates] = useState<
Record<string, string>
>({});
const [updatesLoaded, loadUpdates] = useState(false);
useEffect(() => {
const { plugins: plgobj, pluginsWithUpdates: pwug } =
userpluginInstaller;
setPlugins(plgobj.value());
loadPlugins(true);
const cid2 = plgobj.registerCallback(value => setPlugins(value));
const setPWU = value => {
const pwu = value.plugins.map(pg => ({
[pg]: plgobj.value().find(pfjh => pfjh.directory === pg)
?.name,
}));
setPluginsWithUpdates(Object.assign({}, ...pwu));
loadUpdates(value.finished);
};
const cid = pwug.registerCallback(value => {
setPWU(value);
});
setPWU(pwug.value());
return () => {
pwug.deregisterCallback(cid);
plgobj.deregisterCallback(cid2);
};
}, []);
return (
<STab
// @ts-ignore
title={`UserPlugins${pluginsLoaded ? ` (${plugins.length}, ${plugins.filter(p => Vencord.Settings.plugins[p.name].enabled).length} enabled)` : ""}`}
>
<div className={cl("update-check-container")}>
{isObjectEmpty(pluginsWithUpdates) ? (
!updatesLoaded && (
<BaseText>Checking for updates...</BaseText>
)
) : (
<Card
className={classes(cl("info-card"), "vc-warning-card")}
>
<HeadingTertiary className={cl("install-title")}>
Plugin Updates Available
</HeadingTertiary>
<Paragraph className={cl("install-desc")}>
The following plugins are out-of-date:
<ul className={cl("outdated-list")}>
{Object.values(pluginsWithUpdates)
.toSorted()
.map(pl => (
<li key={pl}>{pl}</li>
))}
</ul>
{!updatesLoaded &&
"and possibly more, as update checking is not finished. "}
You can update plugins below.
</Paragraph>
</Card>
)}
</div>
<Card className={cl("info-card")}>
<HeadingTertiary className={cl("install-title")}>
Install Plugin
</HeadingTertiary>
<Paragraph className={cl("install-desc")}>
You can install a plugin from GitHub, GitLab, Codeberg,
git.nin0.dev, or plugins.nin0.dev by pasting its clone URL
here.
</Paragraph>
<div className={cl("install-field")}>
<CheckedTextInput
onChange={t => setUrl(t)}
validate={t => {
const match = t.match(CLONE_LINK_REGEX);
if (match) {
const idpl = match.includes("plugins.nin0.dev")
? 1
: 0;
const installed = plugins
.map(p => p.directory)
.includes(match[[3, 6][idpl]]);
if (installed) {
setValid(false);
return "Plugin already installed, update below";
}
setValid(true);
return true;
} else {
setValid(false);
return "Invalid URL, read the notice above";
}
}}
value={url}
/>
</div>
<div className={cl("button-container")}>
<Button
disabled={!valid}
className={cl("install-button")}
onClick={async () => {
const gitLink = url.match(CLONE_LINK_REGEX)!;
const idpl = gitLink.includes("plugins.nin0.dev")
? 1
: 0;
try {
const { name, native } = JSON.parse(
await Native.initPluginInstall(
gitLink[0],
gitLink[[1, 4][idpl]],
gitLink[[2, 5][idpl]],
gitLink[[3, 6][idpl]],
),
);
showInstallFinishedAlert(name, native);
} catch (e: any) {
if (e.toString().includes("silentStop")) return;
Alerts.show({
title: "Install error",
body: e.toString(),
});
}
}}
>
Install
</Button>
</div>
</Card>
<div className={cl("plugins-container")}>
{pluginsLoaded ? (
<div className={cl("plugins-grid")}>
{plugins
.toSorted((a, b) =>
a.name.localeCompare(b.name, "en", {
sensitivity: "base",
}),
)
.toSorted((a, b) => {
const updatePendingNames =
Object.keys(pluginsWithUpdates);
const [aa, bb] = [
updatePendingNames.includes(a.directory!)
? 1000
: 0,
updatePendingNames.includes(b.directory!)
? 1000
: 0,
];
return bb - aa;
})
.map(plugin => {
const pl = Vencord.Plugins.plugins[
plugin.name
];
return <AddonCard
key={pl.name}
name={pl.name}
description={pl.description}
enabled={rs.plugins[pl.name].enabled}
infoButton={<button
role="switch"
onClick={async () => {
await Native.rmPlugin(
plugin.directory!,
);
Alerts.show({
title: "Done!",
body: `${plugin.name} has been uninstalled. A ${plugin.usesNative ? "restart" : "refresh"} is needed to fully remove the plugin.`,
confirmText:
plugin.usesNative
? "Restart"
: "Refresh",
cancelText: "Later",
onConfirm() {
plugin.usesNative
? relaunch()
: window.location.reload();
},
});
}}
className={cl("delete-button")}
>
<DeleteIcon />
</button>}
setEnabled={t => {
Vencord.Settings.plugins[pl.name].enabled = t;
if (pluginRequiresRestart(pl)) {
Toasts.show({
id: Toasts.genId(),
message: "Restart to apply changes!",
type: Toasts.Type.MESSAGE
});
} else {
(t ? startPlugin : stopPlugin)(pl);
}
}}
footer={(
<div className={cl("plugin-footer")}>
{Object.keys(
pluginsWithUpdates,
).includes(plugin.directory!) && (
<Button
size="small"
onClick={async () => {
try {
await Native.updatePlugin(
plugin.directory!,
);
const oldPWU =
userpluginInstaller.pluginsWithUpdates.value()
.plugins;
oldPWU.splice(
oldPWU.indexOf(
plugin.directory!,
),
1,
);
userpluginInstaller.pluginsWithUpdates.value(
{
finished: true,
plugins: oldPWU,
},
);
Alerts.show({
title: "Done!",
body: `${plugin.name} has been updated. A ${plugin.usesNative ? "restart" : "refresh"} is needed to apply the update.`,
confirmText:
plugin.usesNative
? "Restart"
: "Refresh",
cancelText: "Later",
onConfirm() {
plugin.usesNative
? relaunch()
: window.location.reload();
},
});
} catch (e: any) {
if (
e
.toString()
.includes(
"silentStop",
)
)
return;
Alerts.show({
title: "Update error",
body: e.toString(),
});
}
}}
>
Update
</Button>
)}
<Button
variant="secondary"
size="small"
disabled={plugin.remote === ""}
onClick={() =>
VencordNative.native.openExternal(
plugin.remote,
)
}
>
Source
</Button>
{plugin.supportChannelID && <Button variant="secondary" size="small" onClick={() => {
closeAllModals();
NavigationRouter.transitionTo(`/channels/1015060230222131221/${plugin.supportChannelID}`);
}}>
Support
</Button>}
</div>
)}
/>;
})}
</div>
) : (
<BaseText>Loading plugins...</BaseText>
)}
</div>
</STab>
);
}
export default wrapTab(UserPluginsTab, "UserPlugins");

View file

@ -0,0 +1,55 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 nin0
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Button } from "@components/Button";
import { Alerts, ChannelStore, useEffect, useState } from "@webpack/common";
import userpluginInstaller, { Native, OpenSettingsModule, settings } from "..";
import { CLONE_LINK_REGEX, showInstallFinishedAlert, WHITELISTED_SHARE_CHANNELS } from "../misc/constants";
export default function UserpluginInstallButton({ props }: any) {
const [plugins, setPlugins] = useState<{
directory?: string;
}[]>([]);
useEffect(() => {
const { plugins } = userpluginInstaller;
setPlugins(plugins.value());
const cid = plugins.registerCallback(value => setPlugins(plugins.value()));
return () => plugins.deregisterCallback(cid);
});
const { message } = props;
if (![...WHITELISTED_SHARE_CHANNELS, ...(settings.store.allowlistedChannels || "").split(",")].includes(ChannelStore.getChannel(message.channel_id).parent_id) && !WHITELISTED_SHARE_CHANNELS.includes(message.channel_id))
return;
const gitLink = (props.message.content as string).match(CLONE_LINK_REGEX);
if (!gitLink) return;
const idpl = gitLink.includes("plugins.nin0.dev") ? 1 : 0;
const installed = plugins.map(p => p.directory).includes(gitLink[[3, 6][idpl]]);
return <>
<div style={{ display: "flex" }}>
<Button style={{
marginTop: "5px"
}}
variant={installed ? "secondary" : "primary"}
onClick={async () => {
if (installed) return void OpenSettingsModule.openUserSettings("vencord_userplugins_panel");
try {
const { name, native } = JSON.parse(await Native.initPluginInstall(gitLink[0], gitLink[[1, 4][idpl]], gitLink[[2, 5][idpl]], gitLink[[3, 6][idpl]]));
showInstallFinishedAlert(name, native);
}
catch (e: any) {
if (e.toString().includes("silentStop")) return;
Alerts.show({
title: "Install error",
body: e.toString()
});
}
}}>
{installed ? "Manage plugins" : "Install plugin"}
</Button>
</div>
</>;
}

View file

@ -0,0 +1,138 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 nin0
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import "./misc/style.css";
import { showNotification } from "@api/Notifications";
import { definePluginSettings } from "@api/Settings";
import { Button } from "@components/Button";
import plSettings from "@plugins/_core/settings";
import { Devs } from "@utils/constants";
import { relaunch } from "@utils/native";
import definePlugin, { OptionType, PluginNative } from "@utils/types";
import { findByPropsLazy, findComponentByCodeLazy } from "@webpack";
import { Alerts } from "@webpack/common";
import SettingsTab from "./components/SettingsTab";
import UserpluginInstallButton from "./components/UserpluginInstallButton";
import { VariableWithCallbacks } from "./VariableWithCallbacks";
// @ts-ignore
export const Native = VencordNative.pluginHelpers.UserpluginInstaller as PluginNative<typeof import("./native")>;
export const OpenSettingsModule = findByPropsLazy("openUserSettings");
const AppsIcon = findComponentByCodeLazy("2.95H20a2 2 0");
export const settings = definePluginSettings({
allowlistedChannels: {
type: OptionType.STRING,
description: "Comma separated list of channels where the Install Plugin button should be displayed. It is always displayed in the Vencord Userplugin channels"
},
notifyIfUpdate: {
type: OptionType.BOOLEAN,
description: "Show a Vencord notification if UserPlugins need to be updated",
default: true
},
neverNotifyForPlugins: {
type: OptionType.STRING,
description: "Never show update notifications for these plugins (you can still update them from the UserPlugins tab)",
default: ""
},
setGitPath: {
type: OptionType.COMPONENT,
component: () => <Button onClick={() => {
Native.openGitPathModal();
}} variant="secondary">
Set Git path
</Button>
}
});
export default definePlugin({
name: "UserpluginInstaller",
description: "Install userplugins with a simple button click",
async checkPluginUpdates() {
for (const p of this.plugins.value()) {
if (await Native.isUpdateAvailableForPlugin(p.directory!)) {
const t = this.pluginsWithUpdates.value().plugins;
t.push(p.directory!);
this.pluginsWithUpdates.value({
finished: false,
plugins: t
});
}
}
const t = this.pluginsWithUpdates.value().plugins;
this.pluginsWithUpdates.value({
finished: true,
plugins: t
});
},
section: {
key: "vencord_userplugins",
title: "UserPlugins",
panelTitle: "UserPlugins",
Component: SettingsTab,
Icon: AppsIcon
},
async start() {
if (!VencordNative.pluginHelpers.UserpluginInstaller) return void Alerts.show({
title: "UserpluginInstaller not fully loaded",
body: "This usually happens if you didn't fully restart your client after installing the plugin, which prevents the native helpers from loading. Restart your client to fix the issue.",
confirmText: "Restart now",
onConfirm() {
relaunch();
},
cancelText: "Later"
});
plSettings.customEntries.push(this.section);
this.pluginsWithUpdates.registerCallback((value, id) => {
if (value.plugins.length === 0) return;
if (settings.store.neverNotifyForPlugins.split(",").map(t => t.trim().toLowerCase()).includes(value.plugins[value.plugins.length - 1].toLowerCase()))
return;
this.pluginsWithUpdates.deregisterCallback(id);
if (settings.store.notifyIfUpdate)
showNotification({
title: "Some UserPlugins are out of date!",
body: "Click to open the UserPlugin Updater",
noPersist: true,
permanent: true,
onClick() {
OpenSettingsModule.openUserSettings("vencord_userplugins_panel");
},
});
});
const pls = await Native.getUserplugins();
// @ts-ignore :trolley:
this.plugins.value(pls);
await this.checkPluginUpdates();
},
stop() {
// @ts-ignore
plSettings.customEntries.splice(plSettings.customEntries.indexOf(this.section), 1);
},
plugins: new VariableWithCallbacks<{
name: string;
description: string;
usesPreSend: boolean;
usesNative: boolean;
directory: string;
remote: string;
}[]>([]),
pluginsWithUpdates: new VariableWithCallbacks<{
finished: boolean;
plugins: string[];
}>({
finished: false,
plugins: []
}),
settings,
authors: [Devs.nin0dev],
renderMessageAccessory: props => {
return <UserpluginInstallButton props={props} />;
}
});

View file

@ -0,0 +1,28 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 nin0
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { classNameFactory } from "@api/Styles";
import { relaunch } from "@utils/native";
import { Alerts } from "@webpack/common";
// if edited, also edit in native.ts!!!
export 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]|-|\.)+))(?:\/)?/;
export const WHITELISTED_SHARE_CHANNELS = ["1256395889354997771", "1032200195582197831", "1301947896601509900", "1322935137591365683"];
export const cl = classNameFactory("vc-userplugininstaller-");
export function showInstallFinishedAlert(pluginToEnable: string, native: boolean) {
Alerts.show({
title: "Done!",
body: `${pluginToEnable} has been successfully installed.${native ? " However, as it makes use of native functions, a client restart is required." : ""} What now?`,
confirmText: `Enable & ${native ? "restart" : "refresh"}`,
cancelText: native ? "Restart" : "Refresh",
onConfirm() {
!Vencord.Plugins.plugins[pluginToEnable] ? Vencord.Settings.plugins[pluginToEnable] = { enabled: true } : Vencord.Settings.plugins[pluginToEnable].enabled = true;
native ? relaunch() : window.location.reload();
},
onCancel: () => native ? relaunch() : window.location.reload()
});
}

View file

@ -0,0 +1,158 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Review userplugin</title>
<script>
document.addEventListener("DOMContentLoaded", () => {
document
.querySelector("#abort")
.addEventListener("click", () => {
document.title = "abortInstall";
});
document
.querySelector("#install")
.addEventListener("click", () => {
if (
!document
.querySelector("style")
.innerHTML.includes(
"#native-ts-warning { display: none !important; }"
) &&
!document.querySelector("input[type='checkbox']")
.checked
)
return alert(
"Make sure to acknowledge all warnings before installing."
);
document.title = "install";
});
document.querySelector("style").innerHTML +=
document.querySelector("addAtRuntimeStyle").innerHTML;
});
</script>
<style>
addAtRuntimeStyle {
display: none !important;
}
* {
font-family: sans-serif;
color: #cdd6f4;
}
body {
background-color: #1e1e2e;
padding: 10px;
display: flex;
flex-direction: column;
}
.card {
padding: 10px 12px;
border: 1px solid #9399b2;
border-radius: 10px;
margin-bottom: 10px; /* Added margin for better spacing */
}
.warn-card {
border-color: #f38ba8;
background-color: #313244;
}
.danger-card {
border-color: #f38ba8;
background-color: #313244;
h3 {
color: #cdd6f4;
}
}
.card * {
margin: 0;
}
.card h3 {
margin-bottom: 5px;
}
input[type="checkbox"] {
margin-right: 5px;
}
#validate {
color: #f38ba8;
}
#btn-row {
display: flex;
flex: 1;
}
#btn-row * {
flex: 1;
margin: 5px;
padding: 10px 5px;
border: 1px solid #cdd6f4;
border-radius: 5px;
}
#abort {
background-color: #f38ba8;
color: #11111b;
}
#install {
color: #a6e3a1;
border-color: #a6e3a1;
background-color: #00000000;
}
#abort:hover {
background-color: #ec9393;
font-weight: bold;
}
#install:hover {
background-color: #425040;
font-weight: bold;
}
</style>
<addAtRuntimeStyle>
%WARNINGHIDER% %NATIVETSHIDER% %PRESENDHIDER%
</addAtRuntimeStyle>
</head>
<body>
<h2>Plugin info</h2>
<div class="card" id="plugin-info-card">
<h3>%PLUGINNAME%</h3>
<p>%PLUGINDESC%</p>
</div>
<h2 data-useless="warning">Warnings</h2>
<div
data-useless="warning"
class="card danger-card"
id="native-ts-warning"
>
<h3>Uses a native.ts file</h3>
<p>
Use of this file allows the plugin to escape the browser sandbox
and potentially do anything to your system and data.
<b
>ONLY INSTALL THIS PLUGIN AFTER REVIEWING THE CODE AND BEING
100% SURE THAT NOTHING BAD CAN BE DONE!</b
>
</p>
<br />
<input type="checkbox" /> Acknowledge warning (required to allow
install)
</div>
<div
data-useless="warning"
class="card warn-card"
id="pre-send-warning"
>
<h3>Has pre-send listeners</h3>
<p>
This allows the plugin to edit your messages before they are
sent.
</p>
</div>
<p id="validate">
Reminder: installing a userplugin can be a destructive action. Make
sure that you know and trust the developer before installing any.
</p>
<div id="btn-row">
<button id="abort">Cancel installation</button>
<button id="install">Install plugin</button>
</div>
</body>
</html>

View file

@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Git Path</title>
<script>
document.addEventListener("DOMContentLoaded", () => {
document.title
document.querySelector("#abort").addEventListener("click", () => {document.title = "abort";})
document.querySelector("#check").addEventListener("click", () => {document.title = `check-${document.querySelector("input").value.trim()}`; setTimeout(() => {document.title = "git";}, 1000)})
document.querySelector("#install").addEventListener("click", () => {document.title = `ok-${document.querySelector("input").value.trim()}`;})
});
</script>
<style>
* {
font-family: sans-serif;
color: #cdd6f4;
}
body {
background-color: #1e1e2e;
padding: 10px;
display: flex;
flex-direction: column;
}
#btn-row {
display: flex;
flex: 1;
}
#btn-row * {
flex: 1;
margin: 5px;
padding: 10px 5px;
border: 1px solid #cdd6f4;
border-radius: 5px;
}
#abort {
border-color: #f38ba8;
color: #f38ba8;
background-color: #00000000;
}
#install {
color: #a6e3a1;
border-color: #a6e3a1;
background-color: #00000000;
}
#check {
color: #a1afe3;
border-color: #a1afe3;
background-color: #00000000;
}
#abort:hover {
background-color: #504040;
font-weight: bold;
}
#check:hover {
background-color: #404550;
font-weight: bold;
}
#install:hover {
background-color: #425040;
font-weight: bold;
}
input {
background-color: #313244;
color: #cdd6f4;
padding: 5px 10px;
margin-bottom: 15px;
border: 1px solid #6c7086;
border-radius: 5px;
&:focus {
outline: none;
border: 2px solid #cdd6f4;
padding: 4px 9px;
}
&::placeholder {
color: #6c7086;
}
}
</style>
</head>
<body>
<h2>Git path</h2>
<p>
If you're having issues downloading userplugins because Git isn't properly detected, you can manually set a Git
path
that'll be used instead of
simply looking in PATH.
<br />
If you do not specify anything, Git will be called from the PATH variable. Do not set this setting unless you
have issues.
<br />
<br />
On Linux, Flatpak still isn't supported.
</p>
<input type="text" id="git-path" placeholder="Full, absolute path to Git" />
<div id="btn-row">
<button id="abort">Cancel</button>
<button id="check">Check</button>
<button id="install">Set</button>
</div>
</body>
</html>

View file

@ -0,0 +1,78 @@
.vc-userplugininstaller-info-card {
padding: 15px 20px;
margin-bottom: 20px;
}
.vc-userplugininstaller-install-desc {
margin-top: 5px;
}
.vc-userplugininstaller-install-field,
.vc-userplugininstaller-button-container {
margin-top: 10px;
}
.vc-userplugininstaller-install-button {
margin-left: auto;
}
.vc-userplugininstaller-update-check-container {
margin-top: 10px;
}
.vc-userplugininstaller-plugins-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
align-items: start;
}
.vc-userplugininstaller-info-button {
height: 24px;
width: 24px;
padding: 0;
background: transparent;
color: var(--icon-secondary);
transition: background-color 0.2s ease;
&:hover {
color: var(--interactive-hover);
}
}
.vc-userplugininstaller-settings-button:hover {
color: var(--interactive-hover);
}
.vc-userplugininstaller-plugin-footer {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 5px;
div {
flex-basis: 100%;
margin-top: 5px;
font-size: 0.9rem;
}
}
.vc-userplugininstaller-outdated-list {
padding-left: 30px;
margin: 3px 0;
list-style-type: disc;
}
.vc-userplugininstaller-delete-button {
height: 24px;
width: 24px;
padding: 0;
background: transparent;
margin-right: 8px;
color: var(--icon-muted);
transition: background-color .2s ease;
&:hover {
color: var(--red-new-46)
}
}

View file

@ -0,0 +1,129 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Review userplugin</title>
<script>
document.addEventListener("DOMContentLoaded", () => {
document
.querySelector("#abort")
.addEventListener("click", () => {
document.title = "abortInstall";
});
document
.querySelector("#install")
.addEventListener("click", () => {
document.title = "install";
});
for (const aEl of document.querySelectorAll("a")) {
aEl.addEventListener("click", (e) => {
e.preventDefault();
document.title = `openLink:${aEl.href}`;
});
}
});
</script>
<style>
addAtRuntimeStyle {
display: none !important;
}
* {
font-family: sans-serif;
color: #cdd6f4;
}
body {
background-color: #1e1e2e;
padding: 10px;
display: flex;
flex-direction: column;
}
.card {
padding: 10px 12px;
border: 1px solid #9399b2;
border-radius: 10px;
margin-bottom: 10px; /* Added margin for better spacing */
}
.warn-card {
border-color: #f38ba8;
background-color: #313244;
}
.danger-card {
border-color: #f38ba8;
background-color: #313244;
h3 {
color: #cdd6f4;
}
}
.card * {
margin: 0;
}
.card h3 {
margin-bottom: 5px;
}
input[type="checkbox"] {
margin-right: 5px;
}
#validate {
color: #f38ba8;
}
#btn-row {
display: flex;
flex: 1;
}
#btn-row * {
flex: 1;
margin: 5px;
padding: 10px 5px;
border: 1px solid #cdd6f4;
border-radius: 5px;
}
#abort {
background-color: #f38ba8;
color: #11111b;
}
#install {
color: #a6e3a1;
border-color: #a6e3a1;
background-color: #00000000;
}
#abort:hover {
background-color: #ec9393;
font-weight: bold;
}
#install:hover {
background-color: #425040;
font-weight: bold;
}
a {
color: #89b4fa;
}
</style>
</head>
<body>
<h2>Plugin info</h2>
<div class="card" id="plugin-info-card">
<h3>%PLUGINNAME%</h3>
<p>%PLUGINDESC%</p>
</div>
<h2>Update info</h2>
<div class="card" id="update-info-card">
<p>%COMMITMESSAGE%</p>
<br />
<p>
You may want to review the
<a href="%REMOTE%">source code</a> that will be applied.
</p>
</div>
<p id="validate">
Reminder: installing a userplugin can be a destructive action. Make
sure that you know and trust the developer before installing any.
</p>
<div id="btn-row">
<button id="abort">Cancel update</button>
<button id="install">Apply update</button>
</div>
</body>
</html>

View file

@ -0,0 +1,473 @@
/*
* 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";
// @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
import YAML from "./yaml.js";
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<string> {
// 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<boolean> {
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<string> {
// 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<any> {
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<void> {
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("<", "&lt;")).replace("%PLUGINDESC%", meta.description.replaceAll("<", "&lt;")).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("<", "&lt;")).replace("%PLUGINDESC%", meta.description.replaceAll("<", "&lt;")).replace("%REMOTE%", meta.remote).replace("%COMMITMESSAGE%", meta.commit.replaceAll("\n", "<br />"));
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} (<a href="${pluginMeta.remote.replace("plugins.nin0.dev", "git.nin0.dev/userplugins")}/commit/${longCommit}" style="font-family: monospace;">${shortCommit}</a>) ~ ${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
`));
}

File diff suppressed because it is too large Load diff