From 119b628f331e9282342df213f941851f98630ebb Mon Sep 17 00:00:00 2001 From: V Date: Thu, 9 Nov 2023 02:32:34 +0100 Subject: [PATCH] feat: simple plugin natives (#1965) --- scripts/build/build.mjs | 64 ++++++++++++++- scripts/build/common.mjs | 12 ++- src/VencordNative.ts | 20 +++-- src/main/ipcPlugins.ts | 77 ++++--------------- src/modules.d.ts | 5 ++ .../fixSpotifyEmbeds.desktop/native.ts | 27 +++++++ src/plugins/openInApp/index.ts | 6 +- src/plugins/openInApp/native.ts | 31 ++++++++ src/plugins/voiceMessages/DesktopRecorder.tsx | 5 +- src/plugins/voiceMessages/native.ts | 24 ++++++ src/utils/IpcEvents.ts | 2 + src/utils/types.ts | 7 ++ 12 files changed, 200 insertions(+), 80 deletions(-) create mode 100644 src/plugins/fixSpotifyEmbeds.desktop/native.ts create mode 100644 src/plugins/openInApp/native.ts create mode 100644 src/plugins/voiceMessages/native.ts diff --git a/scripts/build/build.mjs b/scripts/build/build.mjs index f606f1b..89cca7e 100755 --- a/scripts/build/build.mjs +++ b/scripts/build/build.mjs @@ -18,8 +18,10 @@ */ import esbuild from "esbuild"; +import { readdir } from "fs/promises"; +import { join } from "path"; -import { BUILD_TIMESTAMP, commonOpts, globPlugins, isStandalone, updaterDisabled, VERSION, watch } from "./common.mjs"; +import { BUILD_TIMESTAMP, commonOpts, existsAsync, globPlugins, isStandalone, updaterDisabled, VERSION, watch } from "./common.mjs"; const defines = { IS_STANDALONE: isStandalone, @@ -43,13 +45,59 @@ const nodeCommonOpts = { format: "cjs", platform: "node", target: ["esnext"], - external: ["electron", "original-fs", ...commonOpts.external], + external: ["electron", "original-fs", "~pluginNatives", ...commonOpts.external], define: defines, }; const sourceMapFooter = s => watch ? "" : `//# sourceMappingURL=vencord://${s}.js.map`; const sourcemap = watch ? "inline" : "external"; +/** + * @type {import("esbuild").Plugin} + */ +const globNativesPlugin = { + name: "glob-natives-plugin", + setup: build => { + const filter = /^~pluginNatives$/; + build.onResolve({ filter }, args => { + return { + namespace: "import-natives", + path: args.path + }; + }); + + build.onLoad({ filter, namespace: "import-natives" }, async () => { + const pluginDirs = ["plugins", "userplugins"]; + let code = ""; + let natives = "\n"; + let i = 0; + for (const dir of pluginDirs) { + const dirPath = join("src", dir); + if (!await existsAsync(dirPath)) continue; + const plugins = await readdir(dirPath); + for (const p of plugins) { + if (!await existsAsync(join(dirPath, p, "native.ts"))) continue; + + const nameParts = p.split("."); + const namePartsWithoutTarget = nameParts.length === 1 ? nameParts : nameParts.slice(0, -1); + // pluginName.thing.desktop -> PluginName.thing + const cleanPluginName = p[0].toUpperCase() + namePartsWithoutTarget.join(".").slice(1); + + const mod = `p${i}`; + code += `import * as ${mod} from "./${dir}/${p}/native";\n`; + natives += `${JSON.stringify(cleanPluginName)}:${mod},\n`; + i++; + } + } + code += `export default {${natives}};`; + return { + contents: code, + resolveDir: "./src" + }; + }); + } +}; + await Promise.all([ // Discord Desktop main & renderer & preload esbuild.build({ @@ -62,7 +110,11 @@ await Promise.all([ ...defines, IS_DISCORD_DESKTOP: true, IS_VESKTOP: false - } + }, + plugins: [ + ...nodeCommonOpts.plugins, + globNativesPlugin + ] }), esbuild.build({ ...commonOpts, @@ -107,7 +159,11 @@ await Promise.all([ ...defines, IS_DISCORD_DESKTOP: false, IS_VESKTOP: true - } + }, + plugins: [ + ...nodeCommonOpts.plugins, + globNativesPlugin + ] }), esbuild.build({ ...commonOpts, diff --git a/scripts/build/common.mjs b/scripts/build/common.mjs index 8efe2be..5488b1b 100644 --- a/scripts/build/common.mjs +++ b/scripts/build/common.mjs @@ -20,8 +20,8 @@ import "../suppressExperimentalWarnings.js"; import "../checkNodeVersion.js"; import { exec, execSync } from "child_process"; -import { existsSync, readFileSync } from "fs"; -import { readdir, readFile } from "fs/promises"; +import { constants as FsConstants, readFileSync } from "fs"; +import { access, readdir, readFile } from "fs/promises"; import { join, relative } from "path"; import { promisify } from "util"; @@ -47,6 +47,12 @@ export const banner = { const isWeb = process.argv.slice(0, 2).some(f => f.endsWith("buildWeb.mjs")); +export function existsAsync(path) { + return access(path, FsConstants.F_OK) + .then(() => true) + .catch(() => false); +} + // https://github.com/evanw/esbuild/issues/619#issuecomment-751995294 /** * @type {import("esbuild").Plugin} @@ -79,7 +85,7 @@ export const globPlugins = kind => ({ let plugins = "\n"; let i = 0; for (const dir of pluginDirs) { - if (!existsSync(`./src/${dir}`)) continue; + if (!await existsAsync(`./src/${dir}`)) continue; const files = await readdir(`./src/${dir}`); for (const file of files) { if (file.startsWith("_") || file.startsWith(".")) continue; diff --git a/src/VencordNative.ts b/src/VencordNative.ts index dd97b5d..0faa556 100644 --- a/src/VencordNative.ts +++ b/src/VencordNative.ts @@ -7,6 +7,7 @@ import { IpcEvents } from "@utils/IpcEvents"; import { IpcRes } from "@utils/types"; import { ipcRenderer } from "electron"; +import { PluginIpcMappings } from "main/ipcPlugins"; import type { UserThemeHeader } from "main/themes"; function invoke(event: IpcEvents, ...args: any[]) { @@ -17,6 +18,16 @@ export function sendSync(event: IpcEvents, ...args: any[]) { return ipcRenderer.sendSync(event, ...args) as T; } +const PluginHelpers = {} as Record Promise>>; +const pluginIpcMap = sendSync(IpcEvents.GET_PLUGIN_IPC_METHOD_MAP); + +for (const [plugin, methods] of Object.entries(pluginIpcMap)) { + const map = PluginHelpers[plugin] = {}; + for (const [methodName, method] of Object.entries(methods)) { + map[methodName] = (...args: any[]) => invoke(method as IpcEvents, ...args); + } +} + export default { themes: { uploadTheme: (fileName: string, fileData: string) => invoke(IpcEvents.UPLOAD_THEME, fileName, fileData), @@ -61,12 +72,5 @@ export default { openExternal: (url: string) => invoke(IpcEvents.OPEN_EXTERNAL, url) }, - pluginHelpers: { - OpenInApp: { - resolveRedirect: (url: string) => invoke(IpcEvents.OPEN_IN_APP__RESOLVE_REDIRECT, url), - }, - VoiceMessages: { - readRecording: (path: string) => invoke(IpcEvents.VOICE_MESSAGES_READ_RECORDING, path), - } - } + pluginHelpers: PluginHelpers }; diff --git a/src/main/ipcPlugins.ts b/src/main/ipcPlugins.ts index 3034fb4..5d679fc 100644 --- a/src/main/ipcPlugins.ts +++ b/src/main/ipcPlugins.ts @@ -17,73 +17,26 @@ */ import { IpcEvents } from "@utils/IpcEvents"; -import { app, ipcMain } from "electron"; -import { readFile } from "fs/promises"; -import { request } from "https"; -import { basename, normalize } from "path"; +import { ipcMain } from "electron"; -import { getSettings } from "./ipcMain"; +import PluginNatives from "~pluginNatives"; -// FixSpotifyEmbeds -app.on("browser-window-created", (_, win) => { - win.webContents.on("frame-created", (_, { frame }) => { - frame.once("dom-ready", () => { - if (frame.url.startsWith("https://open.spotify.com/embed/")) { - const settings = getSettings().plugins?.FixSpotifyEmbeds; - if (!settings?.enabled) return; +const PluginIpcMappings = {} as Record>; +export type PluginIpcMappings = typeof PluginIpcMappings; - frame.executeJavaScript(` - const original = Audio.prototype.play; - Audio.prototype.play = function() { - this.volume = ${(settings.volume / 100) || 0.1}; - return original.apply(this, arguments); - } - `); - } - }); - }); -}); +for (const [plugin, methods] of Object.entries(PluginNatives)) { + const entries = Object.entries(methods); + if (!entries.length) continue; -// #region OpenInApp -// These links don't support CORS, so this has to be native -const validRedirectUrls = /^https:\/\/(spotify\.link|s\.team)\/.+$/; + const mappings = PluginIpcMappings[plugin] = {}; -function getRedirect(url: string) { - return new Promise((resolve, reject) => { - const req = request(new URL(url), { method: "HEAD" }, res => { - resolve( - res.headers.location - ? getRedirect(res.headers.location) - : url - ); - }); - req.on("error", reject); - req.end(); - }); + for (const [methodName, method] of entries) { + const key = `VencordPluginNative_${plugin}_${methodName}`; + ipcMain.handle(key, method); + mappings[methodName] = key; + } } -ipcMain.handle(IpcEvents.OPEN_IN_APP__RESOLVE_REDIRECT, async (_, url: string) => { - if (!validRedirectUrls.test(url)) return url; - - return getRedirect(url); +ipcMain.on(IpcEvents.GET_PLUGIN_IPC_METHOD_MAP, e => { + e.returnValue = PluginIpcMappings; }); -// #endregion - - -// #region VoiceMessages -ipcMain.handle(IpcEvents.VOICE_MESSAGES_READ_RECORDING, async (_, filePath: string) => { - filePath = normalize(filePath); - const filename = basename(filePath); - const discordBaseDirWithTrailingSlash = normalize(app.getPath("userData") + "/"); - console.log(filename, discordBaseDirWithTrailingSlash, filePath); - if (filename !== "recording.ogg" || !filePath.startsWith(discordBaseDirWithTrailingSlash)) return null; - - try { - const buf = await readFile(filePath); - return new Uint8Array(buf.buffer); - } catch { - return null; - } -}); - -// #endregion diff --git a/src/modules.d.ts b/src/modules.d.ts index d75a84f..24f3466 100644 --- a/src/modules.d.ts +++ b/src/modules.d.ts @@ -24,6 +24,11 @@ declare module "~plugins" { export default plugins; } +declare module "~pluginNatives" { + const pluginNatives: Record unknown>>; + export default pluginNatives; +} + declare module "~git-hash" { const hash: string; export default hash; diff --git a/src/plugins/fixSpotifyEmbeds.desktop/native.ts b/src/plugins/fixSpotifyEmbeds.desktop/native.ts new file mode 100644 index 0000000..f779c40 --- /dev/null +++ b/src/plugins/fixSpotifyEmbeds.desktop/native.ts @@ -0,0 +1,27 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { app } from "electron"; +import { getSettings } from "main/ipcMain"; + +app.on("browser-window-created", (_, win) => { + win.webContents.on("frame-created", (_, { frame }) => { + frame.once("dom-ready", () => { + if (frame.url.startsWith("https://open.spotify.com/embed/")) { + const settings = getSettings().plugins?.FixSpotifyEmbeds; + if (!settings?.enabled) return; + + frame.executeJavaScript(` + const original = Audio.prototype.play; + Audio.prototype.play = function() { + this.volume = ${(settings.volume / 100) || 0.1}; + return original.apply(this, arguments); + } + `); + } + }); + }); +}); diff --git a/src/plugins/openInApp/index.ts b/src/plugins/openInApp/index.ts index 5a2641e..0835c06 100644 --- a/src/plugins/openInApp/index.ts +++ b/src/plugins/openInApp/index.ts @@ -18,7 +18,7 @@ import { definePluginSettings } from "@api/Settings"; import { Devs } from "@utils/constants"; -import definePlugin, { OptionType } from "@utils/types"; +import definePlugin, { OptionType, PluginNative } from "@utils/types"; import { showToast, Toasts } from "@webpack/common"; import type { MouseEvent } from "react"; @@ -45,6 +45,8 @@ const settings = definePluginSettings({ } }); +const Native = VencordNative.pluginHelpers.OpenInApp as PluginNative; + export default definePlugin({ name: "OpenInApp", description: "Open Spotify, Steam and Epic Games URLs in their respective apps instead of your browser", @@ -84,7 +86,7 @@ export default definePlugin({ if (!IS_WEB && ShortUrlMatcher.test(url)) { event?.preventDefault(); // CORS jumpscare - url = await VencordNative.pluginHelpers.OpenInApp.resolveRedirect(url); + url = await Native.resolveRedirect(url); } spotify: { diff --git a/src/plugins/openInApp/native.ts b/src/plugins/openInApp/native.ts new file mode 100644 index 0000000..2563742 --- /dev/null +++ b/src/plugins/openInApp/native.ts @@ -0,0 +1,31 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { IpcMainInvokeEvent } from "electron"; +import { request } from "https"; + +// These links don't support CORS, so this has to be native +const validRedirectUrls = /^https:\/\/(spotify\.link|s\.team)\/.+$/; + +function getRedirect(url: string) { + return new Promise((resolve, reject) => { + const req = request(new URL(url), { method: "HEAD" }, res => { + resolve( + res.headers.location + ? getRedirect(res.headers.location) + : url + ); + }); + req.on("error", reject); + req.end(); + }); +} + +export async function resolveRedirect(_: IpcMainInvokeEvent, url: string) { + if (!validRedirectUrls.test(url)) return url; + + return getRedirect(url); +} diff --git a/src/plugins/voiceMessages/DesktopRecorder.tsx b/src/plugins/voiceMessages/DesktopRecorder.tsx index 36f6a60..a69739a 100644 --- a/src/plugins/voiceMessages/DesktopRecorder.tsx +++ b/src/plugins/voiceMessages/DesktopRecorder.tsx @@ -16,11 +16,14 @@ * along with this program. If not, see . */ +import { PluginNative } from "@utils/types"; import { Button, showToast, Toasts, useState } from "@webpack/common"; import type { VoiceRecorder } from "."; import { settings } from "./settings"; +const Native = VencordNative.pluginHelpers.VoiceMessages as PluginNative; + export const VoiceRecorderDesktop: VoiceRecorder = ({ setAudioBlob, onRecordingChange }) => { const [recording, setRecording] = useState(false); @@ -49,7 +52,7 @@ export const VoiceRecorderDesktop: VoiceRecorder = ({ setAudioBlob, onRecordingC } else { discordVoice.stopLocalAudioRecording(async (filePath: string) => { if (filePath) { - const buf = await VencordNative.pluginHelpers.VoiceMessages.readRecording(filePath); + const buf = await Native.readRecording(filePath); if (buf) setAudioBlob(new Blob([buf], { type: "audio/ogg; codecs=opus" })); else diff --git a/src/plugins/voiceMessages/native.ts b/src/plugins/voiceMessages/native.ts new file mode 100644 index 0000000..bbc19c8 --- /dev/null +++ b/src/plugins/voiceMessages/native.ts @@ -0,0 +1,24 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { app } from "electron"; +import { readFile } from "fs/promises"; +import { basename, normalize } from "path"; + +export async function readRecording(_, filePath: string) { + filePath = normalize(filePath); + const filename = basename(filePath); + const discordBaseDirWithTrailingSlash = normalize(app.getPath("userData") + "/"); + console.log(filename, discordBaseDirWithTrailingSlash, filePath); + if (filename !== "recording.ogg" || !filePath.startsWith(discordBaseDirWithTrailingSlash)) return null; + + try { + const buf = await readFile(filePath); + return new Uint8Array(buf.buffer); + } catch { + return null; + } +} diff --git a/src/utils/IpcEvents.ts b/src/utils/IpcEvents.ts index 16bcfa6..2027df9 100644 --- a/src/utils/IpcEvents.ts +++ b/src/utils/IpcEvents.ts @@ -38,6 +38,8 @@ export const enum IpcEvents { BUILD = "VencordBuild", OPEN_MONACO_EDITOR = "VencordOpenMonacoEditor", + GET_PLUGIN_IPC_METHOD_MAP = "VencordGetPluginIpcMethodMap", + OPEN_IN_APP__RESOLVE_REDIRECT = "VencordOIAResolveRedirect", VOICE_MESSAGES_READ_RECORDING = "VencordVMReadRecording", } diff --git a/src/utils/types.ts b/src/utils/types.ts index ff2c79a..b32b127 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -307,3 +307,10 @@ export type PluginOptionBoolean = PluginSettingBooleanDef & PluginSettingCommon export type PluginOptionSelect = PluginSettingSelectDef & PluginSettingCommon & IsDisabled & IsValid; export type PluginOptionSlider = PluginSettingSliderDef & PluginSettingCommon & IsDisabled & IsValid; export type PluginOptionComponent = PluginSettingComponentDef & PluginSettingCommon; + +export type PluginNative any>> = { + [key in keyof PluginExports]: + PluginExports[key] extends (event: Electron.IpcMainInvokeEvent, ...args: infer Args) => infer Return + ? (...args: Args) => Return extends Promise ? Return : Promise + : never; +};