diff --git a/src/api/Badges.ts b/src/api/Badges.ts index 1a6d085b6..6f0260c0b 100644 --- a/src/api/Badges.ts +++ b/src/api/Badges.ts @@ -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 */ diff --git a/src/api/Commands/index.ts b/src/api/Commands/index.ts index 231b3daf1..6b5a5ac06 100644 --- a/src/api/Commands/index.ts +++ b/src/api/Commands/index.ts @@ -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(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(command: C, plugin: string) { if (!BUILT_IN) { console.warn( @@ -149,24 +157,30 @@ export function registerCommand(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; diff --git a/src/api/Commands/types.ts b/src/api/Commands/types.ts index c5ecb35ec..dd238a296 100644 --- a/src/api/Commands/types.ts +++ b/src/api/Commands/types.ts @@ -9,4 +9,5 @@ export { ApplicationCommandInputType, ApplicationCommandOptionType, ApplicationC export interface VencordCommand extends Command { isVencordCommand?: boolean; + rootCommand?: VencordCommand; } diff --git a/src/api/Notifications/notificationLog.tsx b/src/api/Notifications/notificationLog.tsx index 7cfd55a80..ee36ecf1e 100644 --- a/src/api/Notifications/notificationLog.tsx +++ b/src/api/Notifications/notificationLog.tsx @@ -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={
diff --git a/src/api/Themes.ts b/src/api/Themes.ts index 0a761e567..2aadd78e8 100644 --- a/src/api/Themes.ts +++ b/src/api/Themes.ts @@ -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); diff --git a/src/components/Link.tsx b/src/components/Link.tsx index 2eb7ab00f..418f48ddd 100644 --- a/src/components/Link.tsx +++ b/src/components/Link.tsx @@ -23,6 +23,8 @@ interface Props extends React.DetailedHTMLProps) { + props = { ...props }; + if (props.disabled) { props.style ??= {}; props.style.pointerEvents = "none"; diff --git a/src/components/settings/QuickAction.tsx b/src/components/settings/QuickAction.tsx index 49b893a5a..ad2612d10 100644 --- a/src/components/settings/QuickAction.tsx +++ b/src/components/settings/QuickAction.tsx @@ -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 ( - diff --git a/src/components/settings/tabs/plugins/index.tsx b/src/components/settings/tabs/plugins/index.tsx index 99a327bd2..69d9a7c08 100644 --- a/src/components/settings/tabs/plugins/index.tsx +++ b/src/components/settings/tabs/plugins/index.tsx @@ -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() { <>

The following plugins require a restart:

{changes.map((s, i) => ( - <> + {i > 0 && ", "} {Parser.parse("`" + s.split(".")[0] + "`")} - + ))}
diff --git a/src/components/settings/tabs/themes/LocalThemesTab.tsx b/src/components/settings/tabs/themes/LocalThemesTab.tsx index c2601e3fb..0ed8c1f78 100644 --- a/src/components/settings/tabs/themes/LocalThemesTab.tsx +++ b/src/components/settings/tabs/themes/LocalThemesTab.tsx @@ -43,13 +43,7 @@ function onLocalThemeChange(fileName: string, value: boolean) { } } -async function onFileUpload(e: SyntheticEvent) { - e.stopPropagation(); - e.preventDefault(); - - if (!e.currentTarget?.files?.length) return; - const { files } = e.currentTarget; - +async function doUploadThemes(files: ArrayLike) { const uploads = Array.from(files, file => { const { name } = file; if (!name.endsWith(".css")) return; @@ -68,6 +62,47 @@ async function onFileUpload(e: SyntheticEvent) { await Promise.all(uploads); } +async function onFileUpload(e: SyntheticEvent) { + 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() { ( + Upload Theme } Icon={PlusIcon} + style={{ position: "relative" }} /> ) : ( . */ +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) { diff --git a/src/main/ipcMain.ts b/src/main/ipcMain.ts index 9a1f6e530..652fb09a5 100644 --- a/src/main/ipcMain.ts +++ b/src/main/ipcMain.ts @@ -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 = []; }); }); diff --git a/src/main/utils/ensureSafePath.ts b/src/main/utils/ensureSafePath.ts new file mode 100644 index 000000000..e6db7e6d7 --- /dev/null +++ b/src/main/utils/ensureSafePath.ts @@ -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; +} diff --git a/src/main/utils/extensions.ts b/src/main/utils/extensions.ts index 1d7559fb9..5f4ccb653 100644 --- a/src/main/utils/extensions.ts +++ b/src/main/utils/extensions.ts @@ -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((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); + } } diff --git a/src/plugins/copyStickerLinks/index.tsx b/src/plugins/copyStickerLinks/index.tsx index bc8fe8e90..530f19509 100644 --- a/src/plugins/copyStickerLinks/index.tsx +++ b/src/plugins/copyStickerLinks/index.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -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; }) => { diff --git a/src/plugins/customCommands/index.ts b/src/plugins/customCommands/index.ts index fe22de6e4..e5f4503f8 100644 --- a/src/plugins/customCommands/index.ts +++ b/src/plugins/customCommands/index.ts @@ -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"); diff --git a/src/plugins/customCommands/settings.ts b/src/plugins/customCommands/settings.ts index 418f06fa2..ad3ecbb0d 100644 --- a/src/plugins/customCommands/settings.ts +++ b/src/plugins/customCommands/settings.ts @@ -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) { diff --git a/src/plugins/customRPC/RpcSettings.tsx b/src/plugins/customRPC/RpcSettings.tsx index bdca847d2..f642e09be 100644 --- a/src/plugins/customRPC/RpcSettings.tsx +++ b/src/plugins/customRPC/RpcSettings.tsx @@ -139,7 +139,10 @@ function SelectSetting({ 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} diff --git a/src/plugins/dearrow/index.tsx b/src/plugins/dearrow/index.tsx index f91c91d45..164f91cbd 100644 --- a/src/plugins/dearrow/index.tsx +++ b/src/plugins/dearrow/index.tsx @@ -100,7 +100,6 @@ function DearrowButton({ component }: { component: Component; }) { 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; diff --git a/src/plugins/fixSpotifyEmbeds.desktop/native.ts b/src/plugins/fixSpotifyEmbeds.desktop/native.ts index b730883c1..203fb18d3 100644 --- a/src/plugins/fixSpotifyEmbeds.desktop/native.ts +++ b/src/plugins/fixSpotifyEmbeds.desktop/native.ts @@ -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); + } }); diff --git a/src/plugins/friendInvites/index.ts b/src/plugins/friendInvites/index.ts index ec677e297..eee2d4127 100644 --- a/src/plugins/friendInvites/index.ts +++ b/src/plugins/friendInvites/index.ts @@ -40,7 +40,7 @@ export default definePlugin({ sendBotMessage(ctx.channel.id, { content: ` discord.gg/${invite.code} · - Expires: · + Expires: · 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: · + Expires: · Times used: \`${i.uses}/${i.max_uses}\` `.trim().replace(/\s+/g, " ") ); diff --git a/src/plugins/pictureInPicture/index.tsx b/src/plugins/pictureInPicture/index.tsx index cba14d74f..ae097a090 100644 --- a/src/plugins/pictureInPicture/index.tsx +++ b/src/plugins/pictureInPicture/index.tsx @@ -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; diff --git a/src/plugins/previewMessage/index.tsx b/src/plugins/previewMessage/index.tsx index 58f43af6e..4d2aa89c3 100644 --- a/src/plugins/previewMessage/index.tsx +++ b/src/plugins/previewMessage/index.tsx @@ -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(); +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 ( - 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); + } + } } }); diff --git a/src/plugins/reviewDB/components/ReviewComponent.tsx b/src/plugins/reviewDB/components/ReviewComponent.tsx index 295ce1a52..d2eec254e 100644 --- a/src/plugins/reviewDB/components/ReviewComponent.tsx +++ b/src/plugins/reviewDB/components/ReviewComponent.tsx @@ -232,7 +232,7 @@ export default function ReviewComponent({ review, refetch, profileId }: { review <> {Parser.parseGuildEventDescription(review.comment.substring(0, 200))}...
- setShowAll(true)}>Read more] + setShowAll(true)}>Read more ) : Parser.parseGuildEventDescription(review.comment)} diff --git a/src/plugins/unsuppressEmbeds/index.tsx b/src/plugins/unsuppressEmbeds/index.tsx index 4c25ffea0..79ddaac70 100644 --- a/src/plugins/unsuppressEmbeds/index.tsx +++ b/src/plugins/unsuppressEmbeds/index.tsx @@ -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, ( track.stop()); } } } diff --git a/src/plugins/xsOverlay/index.tsx b/src/plugins/xsOverlay/index.tsx index 7146a80ce..51550c9c3 100644 --- a/src/plugins/xsOverlay/index.tsx +++ b/src/plugins/xsOverlay/index.tsx @@ -305,6 +305,7 @@ export default definePlugin({ stop() { socket.close(); + Native.closeSocket(); }, settingsAboutComponent: () => ( diff --git a/src/plugins/xsOverlay/native.ts b/src/plugins/xsOverlay/native.ts index 75ddfeea5..afb82f468 100644 --- a/src/plugins/xsOverlay/native.ts +++ b/src/plugins/xsOverlay/native.ts @@ -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; +} diff --git a/src/utils/react.tsx b/src/utils/react.tsx index f283c2a8d..53eec023f 100644 --- a/src/utils/react.tsx +++ b/src/utils/react.tsx @@ -113,7 +113,7 @@ export function useAwaiter(factory: () => Promise, providedOpts?: AwaiterO }) .catch(error => { if (!isAlive) return; - setState({ value: null, error, pending: false }); + setState({ value: opts.fallbackValue, error, pending: false }); opts.onError?.(error); }); diff --git a/src/webpack/patchWebpack.ts b/src/webpack/patchWebpack.ts index a25199e1b..b6a2c4c48 100644 --- a/src/webpack/patchWebpack.ts +++ b/src/webpack/patchWebpack.ts @@ -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(); @@ -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;