fix many bugs found by Claude (#4436)

Fixes:
- Webpack Patcher: incorrect patch error handling could lead to some functional patches not being applied 
- CommandsAPI: subcommands were impossible to unregister
- useAwaiter: on error, the value was set to null even if a fallbackValue was specified, violating the type contract
- Patcher: build check boolean logic was grouped incorrectly due to missing parantheses
- CustomCommands: commands weren't unregistered on stop, leading to error when enabling, disabling & re-renabling the plugin
- Notification Log: delete was by timestamp instead of ID and non-atomic
- FixSpotifyEmbeds: settings listener was registered inside browser-window-created event handler, leading to duplicate listeners
- CustomRPC: changing activity type or timestamp didn't update the RPC
- CopyStickerLinks: message patch had non-sense code, just removed it
- XSOverlay: UDP socket wasn't closed on stop
- VoiceMessages: Web recorder never closed the mic media stream, which continues showing the OS mic indicator
- PictureInPicture: Video clone wasn't properly cleaned up, which leaked memory
- Themes (Extension version): theme object urls were never cleaned up, which leaked memory on theme change
- Themes (Extension version): Upload Theme button hitbox was only the text, not the whole button. Also added Drag & Drop from file manager support
- Dearrow: toggling for one embed flipped the global dearrow by default setting
- Extension Installer: the zip extract function was vulnerable to path traversal, although we only ever used this for React DevTools obtained via https so it has low impact
- FileWatchers: ensure always properly cleaned up
- components/Link: mutated props which is a very bad practice

All fixes are human written, only the audit was done by Claude Fable (thanks for ur tokens, Zere).
This commit is contained in:
V 2026-07-26 17:07:47 +02:00 • committed by GitHub
parent 73d8490b01
commit 2f1c6a37fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 310 additions and 143 deletions

View file

@ -40,7 +40,7 @@ export interface ProfileBadge {
/** Action to perform when you click the badge */
onClick?(event: React.MouseEvent, props: ProfileBadge & BadgeUserArgs): void;
/** Action to perform when you right click the badge */
onContextMenu?(event: React.MouseEvent, props: BadgeUserArgs & BadgeUserArgs): void;
onContextMenu?(event: React.MouseEvent, props: ProfileBadge & BadgeUserArgs): void;
/** Should the user display this badge? */
shouldShow?(userInfo: BadgeUserArgs): boolean;
/** Optional props (e.g. style) for the badge, ignored for component badges */

View file

@ -46,16 +46,14 @@ export let OptionalMessageOption: CommandOption = OptPlaceholder;
*/
export let RequiredMessageOption: CommandOption = ReqPlaceholder;
// Discord's command list has random gaps for some reason, which can cause issues while rendering the commands
// Add this offset to every added command to keep them unique
let commandIdOffset: number;
let idCounter = 99;
export const _init = function (cmds: VencordCommand[]) {
try {
BUILT_IN = cmds;
OptionalMessageOption = cmds.find(c => (c.untranslatedName || c.displayName) === "shrug")!.options![0];
RequiredMessageOption = cmds.find(c => (c.untranslatedName || c.displayName) === "me")!.options![0];
commandIdOffset = Math.abs(BUILT_IN.map(x => Number(x.id)).sort((x, y) => x - y)[0]) - BUILT_IN.length;
idCounter = Math.abs(BUILT_IN.map(x => Number(x.id)).sort((x, y) => x - y)[0]) + 1;
} catch (e) {
new Logger("CommandsAPI").error("Failed to load CommandsApi", e, " - cmds is", cmds);
}
@ -107,21 +105,23 @@ export function prepareOption<O extends CommandOption | VencordCommand>(opt: O):
return opt;
}
const isSubCommandParent = (cmd: VencordCommand) => cmd.options?.[0]?.type === ApplicationCommandOptionType.SUB_COMMAND;
const getSubCommandName = (cmd: VencordCommand, option: CommandOption) => `${cmd.name} ${option.name}`;
// Yes, Discord registers individual commands for each subcommand
// TODO: This probably doesn't support nested subcommands. If that is ever needed,
// investigate
function registerSubCommands(cmd: VencordCommand, plugin: string) {
cmd.options?.forEach(o => {
if (o.type !== ApplicationCommandOptionType.SUB_COMMAND)
throw new Error("When specifying sub-command options, all options must be sub-commands.");
const subCmd = {
...cmd,
...o,
options: o.options !== undefined ? o.options : undefined,
type: ApplicationCommandType.CHAT_INPUT,
name: `${cmd.name} ${o.name}`,
id: `${o.name}-${cmd.id}`,
displayName: `${cmd.name} ${o.name}`,
name: getSubCommandName(cmd, o),
displayName: getSubCommandName(cmd, o),
subCommandPath: [{
name: o.name,
type: o.type,
@ -129,10 +129,18 @@ function registerSubCommands(cmd: VencordCommand, plugin: string) {
}],
rootCommand: cmd
};
registerCommand(subCmd as any, plugin);
registerCommand(subCmd, plugin);
});
}
function unregisterSubCommands(cmd: VencordCommand): boolean {
const results = BUILT_IN
.filter(c => c.rootCommand === cmd)
.map(c => unregisterCommand(c.name));
return results.length > 0 && results.every(x => x);
}
export function registerCommand<C extends VencordCommand>(command: C, plugin: string) {
if (!BUILT_IN) {
console.warn(
@ -149,24 +157,30 @@ export function registerCommand<C extends VencordCommand>(command: C, plugin: st
command.isVencordCommand = true;
command.untranslatedName ??= command.name;
command.untranslatedDescription ??= command.description;
command.id ??= `-${BUILT_IN.length + commandIdOffset + 1}`;
command.id ??= `-${idCounter++}`;
command.applicationId ??= "-1"; // BUILT_IN;
command.type ??= ApplicationCommandType.CHAT_INPUT;
command.inputType ??= ApplicationCommandInputType.BUILT_IN_TEXT;
command.plugin ||= plugin;
prepareOption(command);
commands[command.name] = command;
if (command.options?.[0]?.type === ApplicationCommandOptionType.SUB_COMMAND) {
if (isSubCommandParent(command)) {
registerSubCommands(command, plugin);
return;
}
commands[command.name] = command;
BUILT_IN.push(command);
}
export function unregisterCommand(name: string) {
export function unregisterCommand(name: string, isSubCommands = false) {
const cmd = commands[name];
if (cmd && isSubCommandParent(cmd)) {
delete commands[name];
return unregisterSubCommands(cmd);
}
const idx = BUILT_IN.findIndex(c => c.name === name);
if (idx === -1)
return false;

View file

@ -9,4 +9,5 @@ export { ApplicationCommandInputType, ApplicationCommandOptionType, ApplicationC
export interface VencordCommand extends Command {
isVencordCommand?: boolean;
rootCommand?: VencordCommand;
}

View file

@ -22,7 +22,7 @@ import { openNotificationSettingsModal } from "@components/settings/tabs/vencord
import { classNameFactory } from "@utils/css";
import { useAwaiter } from "@utils/react";
import { RenderModalProps } from "@vencord/discord-types";
import { ConfirmModal, Forms, ListScrollerThin, Modal,openModal, React, Timestamp, useEffect, useReducer, useState } from "@webpack/common";
import { ConfirmModal, Forms, ListScrollerThin, Modal, openModal, React, Timestamp, useEffect, useReducer, useState } from "@webpack/common";
import { nanoid } from "nanoid";
import type { DispatchWithoutAction } from "react";
@ -74,14 +74,21 @@ export async function persistNotification(notification: NotificationData) {
signals.forEach(x => x());
}
export async function deleteNotification(timestamp: number) {
const log = await getLog();
const index = log.findIndex(x => x.timestamp === timestamp);
if (index === -1) return;
export async function deleteNotification(id: string) {
let found = false;
log.splice(index, 1);
await DataStore.set(KEY, log);
signals.forEach(x => x());
await DataStore.update(KEY, (old: PersistentNotificationData[] | undefined) => {
const log = old ?? [];
const index = log.findIndex(x => x.id === id);
if (index === -1) return log;
log.splice(index, 1);
found = true;
return log;
});
if (found)
signals.forEach(x => x());
}
export function useLogs() {
@ -113,7 +120,7 @@ function NotificationEntry({ data }: { data: PersistentNotificationData; }) {
if (removing) return;
setRemoving(true);
setTimeout(() => deleteNotification(data.timestamp), 200);
setTimeout(() => deleteNotification(data.id), 200);
}}
richBody={
<div className={cl("body-wrapper")}>

View file

@ -18,6 +18,7 @@
import { Settings, SettingsStore } from "@api/Settings";
import { createAndAppendStyle } from "@utils/css";
import { isNonNullish } from "@utils/guards";
import { ThemeStore } from "@vencord/discord-types";
import { PopoutWindowStore } from "@webpack/common";
@ -42,6 +43,9 @@ async function toggle(isEnabled: boolean) {
style.disabled = !isEnabled;
}
// for cleanup
let previousThemeBlobObjectURLs = [] as string[];
async function initThemes() {
themesStyle ??= createAndAppendStyle("vencord-themes", userStyleRootNode);
@ -66,12 +70,18 @@ async function initThemes() {
.filter(link => link !== null);
if (IS_WEB) {
for (const theme of enabledThemes) {
previousThemeBlobObjectURLs.forEach(url => URL.revokeObjectURL(url));
const objectUrls = await Promise.all(enabledThemes.map(async theme => {
const themeData = await VencordNative.themes.getThemeData(theme);
if (!themeData) continue;
if (!themeData) return null;
const blob = new Blob([themeData], { type: "text/css" });
links.push(URL.createObjectURL(blob));
}
return URL.createObjectURL(blob);
}));
previousThemeBlobObjectURLs = objectUrls.filter(isNonNullish);
links.push(...previousThemeBlobObjectURLs);
} else {
const localThemes = enabledThemes.map(theme => `vencord:///themes/${theme}?v=${Date.now()}`);
links.push(...localThemes);

View file

@ -23,6 +23,8 @@ interface Props extends React.DetailedHTMLProps<React.AnchorHTMLAttributes<HTMLA
}
export function Link(props: React.PropsWithChildren<Props>) {
props = { ...props };
if (props.disabled) {
props.style ??= {};
props.style.pointerEvents = "none";

View file

@ -17,13 +17,14 @@ export interface QuickActionProps {
text: ReactNode;
action?: () => void;
disabled?: boolean;
style?: React.CSSProperties;
}
export function QuickAction(props: QuickActionProps) {
const { Icon, action, text, disabled } = props;
const { Icon, action, text, disabled, style } = props;
return (
<button className={cl("pill")} onClick={action} disabled={disabled}>
<button className={cl("pill")} onClick={action} disabled={disabled} style={style}>
<Icon className={cl("img")} />
{text}
</button>

View file

@ -35,7 +35,7 @@ import { Margins } from "@utils/margins";
import { classes } from "@utils/misc";
import { useAwaiter, useCleanupEffect } from "@utils/react";
import { PluginTag, PluginTags } from "@utils/types";
import { Button, ConfirmModal,lodash, openModal, Parser, React, SearchableSelect, Select, TextInput, Tooltip, useMemo, useRef, useState } from "@webpack/common";
import { Button, ConfirmModal, lodash, openModal, Parser, React, SearchableSelect, Select, TextInput, Tooltip, useMemo, useRef, useState } from "@webpack/common";
import { JSX } from "react";
import Plugins, { ExcludedPlugins, PluginMeta } from "~plugins";
@ -133,10 +133,10 @@ function PluginSettings() {
<>
<p>The following plugins require a restart:</p>
<div>{changes.map((s, i) => (
<>
<React.Fragment key={s}>
{i > 0 && ", "}
{Parser.parse("`" + s.split(".")[0] + "`")}
</>
</React.Fragment>
))}</div>
</>
</ConfirmModal>

View file

@ -43,13 +43,7 @@ function onLocalThemeChange(fileName: string, value: boolean) {
}
}
async function onFileUpload(e: SyntheticEvent<HTMLInputElement>) {
e.stopPropagation();
e.preventDefault();
if (!e.currentTarget?.files?.length) return;
const { files } = e.currentTarget;
async function doUploadThemes(files: ArrayLike<File>) {
const uploads = Array.from(files, file => {
const { name } = file;
if (!name.endsWith(".css")) return;
@ -68,6 +62,47 @@ async function onFileUpload(e: SyntheticEvent<HTMLInputElement>) {
await Promise.all(uploads);
}
async function onFileUpload(e: SyntheticEvent<HTMLInputElement>) {
e.stopPropagation();
e.preventDefault();
if (!e.currentTarget?.files?.length) return;
await doUploadThemes(e.currentTarget.files);
}
function useDropFile(refreshThemes: Function) {
useEffect(() => {
const onDragOver = (e: DragEvent) => {
if (!e.dataTransfer?.items.length) return;
if (!Array.from(e.dataTransfer.items).some(item => item.kind === "file" && item.getAsFile()?.name.endsWith(".css")))
return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
};
const onDrop = async (e: DragEvent) => {
e.preventDefault();
if (!e.dataTransfer?.files.length) return;
await doUploadThemes(
Array.from(e.dataTransfer.files).filter(file => file.name.endsWith(".css"))
);
refreshThemes();
};
window.addEventListener("dragover", onDragOver);
window.addEventListener("drop", onDrop);
return () => {
window.removeEventListener("dragover", onDragOver);
window.removeEventListener("drop", onDrop);
};
}, []);
}
export function LocalThemesTab() {
const settings = useSettings(["enabledThemes"]);
@ -79,6 +114,9 @@ export function LocalThemesTab() {
refreshLocalThemes();
}, []);
// This condition is compile time so conditional hook is okay
if (IS_WEB) useDropFile(refreshLocalThemes);
async function refreshLocalThemes() {
const themes = await VencordNative.themes.getThemesList();
setUserThemes(themes);
@ -109,7 +147,7 @@ export function LocalThemesTab() {
(
<QuickAction
text={
<span style={{ position: "relative" }}>
<span>
Upload Theme
<FileInput
ref={fileInputRef}
@ -123,6 +161,7 @@ export function LocalThemesTab() {
</span>
}
Icon={PlusIcon}
style={{ position: "relative" }}
/>
) : (
<QuickAction

View file

@ -16,14 +16,16 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import "./ipcMain";
import { app, net, protocol } from "electron";
import { join } from "path";
import { pathToFileURL } from "url";
import { initCsp } from "./csp";
import { ensureSafePath } from "./ipcMain";
import { RendererSettings } from "./settings";
import { IS_VANILLA, THEMES_DIR } from "./utils/constants";
import { ensureSafePath } from "./utils/ensureSafePath";
import { installExt } from "./utils/extensions";
if (IS_VESKTOP || !IS_VANILLA) {

View file

@ -27,11 +27,12 @@ import monacoHtml from "file://monacoWin.html?minify&base64";
import { FSWatcher, mkdirSync, readFileSync, watch, writeFileSync } from "fs";
import { open, readdir, readFile } from "fs/promises";
import { release } from "os";
import { join, normalize } from "path";
import { join } from "path";
import { registerCspIpcHandlers } from "./csp/manager";
import { getThemeInfo, stripBOM, UserThemeHeader } from "./themes";
import { ALLOWED_PROTOCOLS, QUICK_CSS_PATH, SETTINGS_DIR, THEMES_DIR } from "./utils/constants";
import { ensureSafePath } from "./utils/ensureSafePath";
import { makeLinksOpenExternally } from "./utils/externalLinks";
const RENDERER_CSS_PATH = join(__dirname, IS_VESKTOP ? "vencordDesktopRenderer.css" : "renderer.css");
@ -40,13 +41,6 @@ mkdirSync(THEMES_DIR, { recursive: true });
registerCspIpcHandlers();
export function ensureSafePath(basePath: string, path: string) {
const normalizedBasePath = normalize(basePath + "/");
const newPath = join(basePath, path);
const normalizedPath = normalize(newPath);
return normalizedPath.startsWith(normalizedBasePath) ? normalizedPath : null;
}
function readCss() {
return readFile(QUICK_CSS_PATH, "utf-8").catch(() => "");
}
@ -86,7 +80,8 @@ ipcMain.handle(IpcEvents.OPEN_EXTERNAL, (_, url) => {
if (!ALLOWED_PROTOCOLS.includes(protocol))
throw "Disallowed protocol.";
shell.openExternal(url);
shell.openExternal(url)
.catch(err => console.error("[Vencord] Failed to open external link", url, err));
});
@ -112,7 +107,11 @@ ipcMain.handle(IpcEvents.GET_THEME_SYSTEM_VALUES, () => {
ipcMain.handle(IpcEvents.OPEN_THEMES_FOLDER, () => shell.openPath(THEMES_DIR));
ipcMain.handle(IpcEvents.OPEN_SETTINGS_FOLDER, () => shell.openPath(SETTINGS_DIR));
let fsWatchers = [] as FSWatcher[];
ipcMain.handle(IpcEvents.INIT_FILE_WATCHERS, ({ sender }) => {
fsWatchers.forEach(w => w.close());
let quickCssWatcher: FSWatcher | undefined;
let rendererCssWatcher: FSWatcher | undefined;
@ -133,10 +132,13 @@ ipcMain.handle(IpcEvents.INIT_FILE_WATCHERS, ({ sender }) => {
});
}
fsWatchers = [quickCssWatcher, themesWatcher, rendererCssWatcher].filter(Boolean) as FSWatcher[];
sender.once("destroyed", () => {
quickCssWatcher?.close();
themesWatcher.close();
rendererCssWatcher?.close();
fsWatchers = [];
});
});

View file

@ -0,0 +1,16 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { join, normalize } from "path";
export function ensureSafePath(basePath: string, path: string) {
const normalizedBasePath = normalize(basePath + "/");
const newPath = join(basePath, path);
const normalizedPath = normalize(newPath);
return normalizedPath === normalize(basePath) || normalizedPath.startsWith(normalizedBasePath)
? normalizedPath
: null;
}

View file

@ -24,6 +24,7 @@ import { join } from "path";
import { DATA_DIR } from "./constants";
import { crxToZip } from "./crxToZip";
import { ensureSafePath } from "./ensureSafePath";
import { fetchBuffer } from "./http";
const extensionCacheDir = join(DATA_DIR, "ExtensionCache");
@ -33,24 +34,35 @@ async function extract(data: Buffer, outDir: string) {
return new Promise<void>((resolve, reject) => {
unzip(data, (err, files) => {
if (err) return void reject(err);
Promise.all(Object.keys(files).map(async f => {
// Signature stuff
// 'Cannot load extension with file or directory name
// _metadata. Filenames starting with "_" are reserved for use by the system.';
if (f.startsWith("_metadata/")) return;
if (f.endsWith("/")) return void mkdir(join(outDir, f), { recursive: true });
if (f.includes("\0")) throw new Error(`Invalid filename: "${f}"`);
if (f.endsWith("/")) {
const dir = ensureSafePath(outDir, f);
if (!dir) throw new Error(`Path traversal detected: "${f}"`);
return void await mkdir(dir, { recursive: true });
}
const pathElements = f.split("/");
const name = pathElements.pop()!;
const directories = pathElements.join("/");
const dir = join(outDir, directories);
const directories = pathElements.slice(0, -1).join("/");
const dir = ensureSafePath(outDir, directories);
if (!dir) throw new Error(`Path traversal detected: "${f}"`);
const filePath = ensureSafePath(outDir, f);
if (!filePath) throw new Error(`Path traversal detected: "${f}"`);
if (directories) {
await mkdir(dir, { recursive: true });
}
await writeFile(join(dir, name), files[f]);
await writeFile(filePath, files[f]);
}))
.then(() => resolve())
.catch(err => {
@ -79,5 +91,9 @@ export async function installExt(id: string) {
.catch(err => console.error(`Failed to extract extension ${id}`, err));
}
session.defaultSession.loadExtension(extDir);
if (session.defaultSession.extensions) {
session.defaultSession.extensions.loadExtension(id);
} else {
session.defaultSession.loadExtension(extDir);
}
}

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu";
import { NavContextMenuPatchCallback } from "@api/ContextMenu";
import { isPluginEnabled } from "@api/PluginManager";
import ExpressionClonerPlugin from "@plugins/expressionCloner";
import { Devs } from "@utils/constants";
@ -68,9 +68,7 @@ const messageContextMenuPatch: NavContextMenuPatchCallback = (
const sticker = message.stickerItems.find(s => s.id === favoriteableId);
if (!sticker?.format_type) return;
const idx = children.findIndex(c => Array.isArray(c) && findGroupChildrenByChildId("vc-copy-sticker-url", c) != null);
children.splice(idx, 0, buildMenuItem(sticker, idx !== -1));
children.splice(-1, 0, buildMenuItem(sticker, false));
};
const expressionPickerPatch: NavContextMenuPatchCallback = (children, props: { target: HTMLElement; }) => {

View file

@ -18,7 +18,7 @@
import "./styles.css";
import { ApplicationCommandInputType, ApplicationCommandOptionType, findOption, registerCommand, sendBotMessage } from "@api/Commands";
import { ApplicationCommandInputType, ApplicationCommandOptionType, findOption, registerCommand, sendBotMessage, unregisterCommand } from "@api/Commands";
import { migratePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import { sendMessage } from "@utils/discord";
@ -96,11 +96,12 @@ export default definePlugin({
tags: ["Commands", "Customisation", "Utility"],
settings,
async start() {
const tags = getTags();
for (const tagName in tags) {
registerTagCommand(tags[tagName]);
}
start() {
getTags().forEach(registerTagCommand);
},
stop() {
getTags().forEach(tag => unregisterCommand(tag.name));
},
commands: [
@ -160,7 +161,7 @@ export default definePlugin({
}
case "list": {
const content = Object.values(getTags())
const content = getTags()
.map(tag => `\`${tag.name}\`: ${tag.message.slice(0, 72).replaceAll("\\n", " ")}${tag.message.length > 72 ? "..." : ""}`)
.join("\n");

View file

@ -28,7 +28,7 @@ export interface Tag {
}
export function getTags() {
return settings.store.tagsList;
return Object.values(settings.store.tagsList);
}
export function getTag(name: string) {

View file

@ -139,7 +139,10 @@ function SelectSetting<T>({ settingsKey, label, options, disabled }: SelectOptio
options={options}
maxVisibleItems={5}
closeOnSelect={true}
select={v => settings.store[settingsKey] = v}
select={v => {
settings.store[settingsKey] = v;
updateRPC();
}}
isSelected={v => v === settings.store[settingsKey]}
serialize={v => String(v)}
isDisabled={disabled}

View file

@ -100,7 +100,6 @@ function DearrowButton({ component }: { component: Component<Props>; }) {
className={"vc-dearrow-toggle-" + (embed.dearrow.enabled ? "on" : "off")}
onClick={() => {
const { enabled, oldThumb, oldTitle } = embed.dearrow;
settings.store.dearrowByDefault = !enabled;
embed.dearrow.enabled = !enabled;
if (oldTitle) {
embed.dearrow.oldTitle = embed.rawTitle;

View file

@ -50,14 +50,14 @@ app.on("browser-window-created", (_, win) => {
}
});
});
RendererSettings.addChangeListener("plugins.FixSpotifyEmbeds.volume", newVolume => {
try {
cleanUpAndGetSpotifyFrames().forEach(frame =>
frame.executeJavaScript(`globalThis._vcVolume = ${newVolume / 100}`)
);
} catch (e) {
console.error("FixSpotifyEmbeds: Failed to update volume", e);
}
});
});
RendererSettings.addChangeListener("plugins.FixSpotifyEmbeds.volume", newVolume => {
try {
cleanUpAndGetSpotifyFrames().forEach(frame =>
frame.executeJavaScript(`globalThis._vcVolume = ${newVolume / 100}`)
);
} catch (e) {
console.error("FixSpotifyEmbeds: Failed to update volume", e);
}
});

View file

@ -40,7 +40,7 @@ export default definePlugin({
sendBotMessage(ctx.channel.id, {
content: `
discord.gg/${invite.code} ·
Expires: <t:${new Date(invite.expires_at).getTime() / 1000}:R> ·
Expires: <t:${Math.round(new Date(invite.expires_at).getTime() / 1000)}:R> ·
Max uses: \`${invite.max_uses}\`
`.trim().replace(/\s+/g, " ")
});
@ -55,7 +55,7 @@ export default definePlugin({
const friendInviteList = invites.map(i =>
`
_discord.gg/${i.code}_ ·
Expires: <t:${new Date(i.expires_at).getTime() / 1000}:R> ·
Expires: <t:${Math.round(new Date(i.expires_at).getTime() / 1000)}:R> ·
Times used: \`${i.uses}/${i.max_uses}\`
`.trim().replace(/\s+/g, " ")
);

View file

@ -57,16 +57,44 @@ export default definePlugin({
videoClone.loop = settings.store.loop;
videoClone.style.display = "none";
videoClone.onleavepictureinpicture = () => videoClone.remove();
function launchPiP() {
videoClone.currentTime = video.currentTime;
videoClone.requestPictureInPicture();
video.pause();
videoClone.play();
let cleaned = false;
function cleanup() {
if (cleaned) return;
cleaned = true;
const { currentTime } = videoClone;
videoClone.onloadedmetadata = null;
videoClone.onleavepictureinpicture = null;
videoClone.pause();
videoClone.removeAttribute("src");
videoClone.load();
videoClone.remove();
// resume original if still in the document
if (video.isConnected) {
video.currentTime = currentTime;
video.play().catch(() => 0);
}
}
if (videoClone.readyState === 4 /* HAVE_ENOUGH_DATA */)
videoClone.onleavepictureinpicture = cleanup;
async function launchPiP() {
try {
videoClone.currentTime = video.currentTime;
video.pause();
await videoClone.play();
await videoClone.requestPictureInPicture();
} catch (err) {
console.error("Failed to enter Picture-in-Picture", err);
cleanup();
}
}
if (videoClone.readyState >= 1)
launchPiP();
else
videoClone.onloadedmetadata = launchPiP;

View file

@ -23,8 +23,9 @@ import definePlugin, { IconComponent, StartAt } from "@utils/types";
import { CloudUpload, MessageAttachment } from "@vencord/discord-types";
import { DraftStore, DraftType, UploadAttachmentStore, UserStore, useStateFromStores } from "@webpack/common";
const getDraft = (channelId: string) => DraftStore.getDraft(channelId, DraftType.ChannelMessage);
const objectURLMap = new Map<string, string[]>();
const getDraft = (channelId: string) => DraftStore.getDraft(channelId, DraftType.ChannelMessage);
const getImageBox = (url: string): Promise<{ width: number, height: number; } | null> =>
new Promise(res => {
@ -44,6 +45,8 @@ const getAttachments = async (channelId: string) =>
UploadAttachmentStore.getUploads(channelId, DraftType.ChannelMessage)
.map(async (upload: CloudUpload) => {
const { isImage, filename, spoiler, item: { file } } = upload;
// FIXME: revoke object url to fix memory leak
const url = URL.createObjectURL(file);
const attachment: MessageAttachment = {
id: generateId(),
@ -59,13 +62,13 @@ const getAttachments = async (channelId: string) =>
if (isImage) {
const box = await getImageBox(url);
if (!box) return attachment;
attachment.width = box.width;
attachment.height = box.height;
if (box) {
attachment.width = box.width;
attachment.height = box.height;
}
}
return attachment;
return { attachment, objectURL: url };
})
);
@ -99,15 +102,20 @@ const PreviewButton: ChatBarButtonFactory = ({ isAnyChat, isEmpty, type: { attac
return (
<ChatBarButton
tooltip="Preview Message"
onClick={async () =>
sendBotMessage(
onClick={async () => {
const attachments = hasAttachments ? await getAttachments(channelId) : undefined;
const message = sendBotMessage(
channelId,
{
content: getDraft(channelId),
author: UserStore.getCurrentUser(),
attachments: hasAttachments ? await getAttachments(channelId) : undefined,
attachments: attachments?.map(a => a.attachment),
}
)}
);
if (attachments)
objectURLMap.set(message.id, attachments.map(a => a.objectURL));
}}
buttonProps={{
style: {
translate: "0 2px"
@ -132,5 +140,15 @@ export default definePlugin({
chatBarButton: {
icon: PreviewIcon,
render: PreviewButton
},
flux: {
MESSAGE_DELETE({ id: messageId }) {
const objectURLs = objectURLMap.get(messageId);
if (objectURLs) {
objectURLs.forEach(url => URL.revokeObjectURL(url));
objectURLMap.delete(messageId);
}
}
}
});

View file

@ -232,7 +232,7 @@ export default function ReviewComponent({ review, refetch, profileId }: { review
<>
{Parser.parseGuildEventDescription(review.comment.substring(0, 200))}...
<br />
<a onClick={() => setShowAll(true)}>Read more</a>]
<a onClick={() => setShowAll(true)}>Read more</a>
</>
)
: Parser.parseGuildEventDescription(review.comment)}

View file

@ -40,7 +40,7 @@ const messageContextMenuPatch: NavContextMenuPatchCallback = (
const menuGroup = findGroupChildrenByChildId("delete", children);
const deleteIndex = menuGroup?.findIndex(i => i?.props?.id === "delete");
if (!deleteIndex || !menuGroup) return;
if (deleteIndex == null || !menuGroup) return;
menuGroup.splice(deleteIndex - 1, 0, (
<Menu.MenuItem

View file

@ -63,6 +63,7 @@ export const VoiceRecorderWeb: VoiceRecorder = ({ setAudioBlob, onRecordingChang
changeRecording(false);
});
recorder.stop();
recorder.stream.getTracks().forEach(track => track.stop());
}
}
}

View file

@ -305,6 +305,7 @@ export default definePlugin({
stop() {
socket.close();
Native.closeSocket();
},
settingsAboutComponent: () => (

View file

@ -6,7 +6,7 @@
import { createSocket, Socket } from "dgram";
let xsoSocket: Socket;
let xsoSocket: Socket | null = null;
export function sendToOverlay(_, data: any) {
data.messageType = data.type;
@ -14,3 +14,8 @@ export function sendToOverlay(_, data: any) {
xsoSocket ??= createSocket("udp4");
xsoSocket.send(json, 42069, "127.0.0.1");
}
export function closeSocket() {
xsoSocket?.close();
xsoSocket = null;
}

View file

@ -113,7 +113,7 @@ export function useAwaiter<T>(factory: () => Promise<T>, providedOpts?: AwaiterO
})
.catch(error => {
if (!isAlive) return;
setState({ value: null, error, pending: false });
setState({ value: opts.fallbackValue, error, pending: false });
opts.onError?.(error);
});

View file

@ -506,8 +506,8 @@ function patchFactory(moduleId: PropertyKey, originalFactory: AnyModuleFactory):
const isArrowFunction = originalFactoryCode.startsWith("(");
// 0, prefix to turn it into an expression: 0,function(){} would be invalid syntax without the 0,
let code = "0," + (!isArrowFunction ? "function" : "") + originalFactoryCode.slice(originalFactoryCode.indexOf("("));
let patchedSource = code;
let patchedCode = "0," + (!isArrowFunction ? "function" : "") + originalFactoryCode.slice(originalFactoryCode.indexOf("("));
let patchedSource = patchedCode;
let patchedFactory = originalFactory;
const patchedBy = new Set<string>();
@ -520,70 +520,65 @@ function patchFactory(moduleId: PropertyKey, originalFactory: AnyModuleFactory):
if (
shouldCheckBuildNumber &&
(patch.fromBuild != null && buildNumber < patch.fromBuild) ||
(patch.toBuild != null && buildNumber > patch.toBuild)
((patch.fromBuild != null && buildNumber < patch.fromBuild) || (patch.toBuild != null && buildNumber > patch.toBuild))
) {
patches.splice(i--, 1);
continue;
}
const moduleMatches = typeof patch.find === "string"
? code.includes(patch.find)
: (patch.find.global && (patch.find.lastIndex = 0), patch.find.test(code));
? originalFactoryCode.includes(patch.find)
: (patch.find.global && (patch.find.lastIndex = 0), patch.find.test(originalFactoryCode));
if (!moduleMatches) {
continue;
}
// Save the result from the previous patch so we can restore it in case a patch group fails.
const previousPatchedCode = patchedCode;
const previousPatchedSource = patchedSource;
const previousPatchedFactory = patchedFactory;
let shouldRestorePrevious = false;
let markedAsPatched = false;
const executePatch = traceFunctionWithResults(`patch by ${patch.plugin}`, (match: string | RegExp, replace: string) => {
if (typeof match !== "string" && match.global) {
match.lastIndex = 0;
}
return code.replace(match, replace);
return patchedCode.replace(match, replace);
});
const previousCode = code;
const previousFactory = originalFactory;
let markedAsPatched = false;
// We change all patch.replacement to array in PluginManager
for (const replacement of patch.replacement as PatchReplacement[]) {
if (
shouldCheckBuildNumber &&
(replacement.fromBuild != null && buildNumber < replacement.fromBuild) ||
(replacement.toBuild != null && buildNumber > replacement.toBuild)
((replacement.fromBuild != null && buildNumber < replacement.fromBuild) || (replacement.toBuild != null && buildNumber > replacement.toBuild))
) {
continue;
}
const lastCode = code;
const lastFactory = originalFactory;
let newPatchedCode: string = "";
try {
const [newCode, totalTime] = executePatch(replacement.match, replacement.replace as string);
const [patchResult, totalTime] = executePatch(replacement.match, replacement.replace as string);
newPatchedCode = patchResult;
if (IS_REPORTER) {
patchTimings.push([patch.plugin, moduleId, replacement.match, totalTime]);
}
if (newCode === code) {
if (newPatchedCode === patchedCode) {
if (!(patch.noWarn || replacement.noWarn)) {
logger.warn(`Patch by ${patch.plugin} had no effect (Module id is ${String(moduleId)}): ${replacement.match}`);
if (IS_DEV) {
logger.debug("Function Source:\n", code);
logger.debug("Function Source:\n", patchedCode);
}
}
if (patch.group) {
logger.warn(`Undoing patch group ${patch.find} by ${patch.plugin} because replacement ${replacement.match} had no effect`);
code = previousCode;
patchedFactory = previousFactory;
if (markedAsPatched) {
patchedBy.delete(patch.plugin);
}
shouldRestorePrevious = true;
break;
}
@ -595,14 +590,17 @@ function patchFactory(moduleId: PropertyKey, originalFactory: AnyModuleFactory):
pluginsList.push(patch.plugin);
}
code = newCode;
patchedSource = `// Webpack Module ${String(moduleId)} - Patched by ${pluginsList.join(", ")}\n${code}\n//# sourceURL=file:///WebpackModule${String(moduleId)}`;
patchedFactory = (0, eval)(patchedSource);
const newPatchedSource = `// Webpack Module ${String(moduleId)} - Patched by ${pluginsList.join(", ")}\n${newPatchedCode}\n//# sourceURL=file:///WebpackModule${String(moduleId)}`;
const newPatchedFactory = (0, eval)(newPatchedSource);
if (!patchedBy.has(patch.plugin)) {
patchedBy.add(patch.plugin);
markedAsPatched = true;
}
patchedCode = newPatchedCode;
patchedSource = newPatchedSource;
patchedFactory = newPatchedFactory;
} catch (err) {
// FIXME: Maybe fix this properly
const shouldSuppressError = patch.plugin === "ContextMenuAPI" && err instanceof SyntaxError && err.message.includes("arguments");
@ -610,23 +608,25 @@ function patchFactory(moduleId: PropertyKey, originalFactory: AnyModuleFactory):
logger.error(`Patch by ${patch.plugin} errored (Module id is ${String(moduleId)}): ${replacement.match}\n`, err);
if (IS_DEV) {
diffErroredPatch(code, lastCode, lastCode.match(replacement.match)!);
diffErroredPatch(newPatchedCode, patchedCode, patchedCode.match(replacement.match)!);
}
}
if (markedAsPatched) {
patchedBy.delete(patch.plugin);
}
if (patch.group) {
logger.warn(`Undoing patch group ${patch.find} by ${patch.plugin} because replacement ${replacement.match} errored`);
code = previousCode;
patchedFactory = previousFactory;
shouldRestorePrevious = true;
break;
}
}
}
code = lastCode;
patchedFactory = lastFactory;
if (shouldRestorePrevious) {
patchedCode = previousPatchedCode;
patchedSource = previousPatchedSource;
patchedFactory = previousPatchedFactory;
if (markedAsPatched) {
patchedBy.delete(patch.plugin);
}
}
@ -648,9 +648,12 @@ function patchFactory(moduleId: PropertyKey, originalFactory: AnyModuleFactory):
function diffErroredPatch(code: string, lastCode: string, match: RegExpMatchArray) {
const changeSize = code.length - lastCode.length;
const matchIndex = match?.index ?? 0;
const matchLength = match?.[0]?.length ?? 0;
// Use 200 surrounding characters of context
const start = Math.max(0, match.index! - 200);
const end = Math.min(lastCode.length, match.index! + match[0].length + 200);
const start = Math.max(0, matchIndex - 200);
const end = Math.min(lastCode.length, matchIndex + matchLength + 200);
// (changeSize may be negative)
const endPatched = end + changeSize;