diff --git a/.gitignore b/.gitignore index 135673a6d..9f877c057 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ vencord_installer .DS_Store yarn.lock +bun.lock package-lock.json *.log diff --git a/scripts/build/common.mjs b/scripts/build/common.mjs index e88f1e2b9..5e71c8c5f 100644 --- a/scripts/build/common.mjs +++ b/scripts/build/common.mjs @@ -312,7 +312,7 @@ export const commonOpts = { logLevel: "info", bundle: true, watch, - minify: !watch, + minify: !watch && !IS_REPORTER, sourcemap: watch ? "inline" : "", legalComments: "linked", banner, diff --git a/scripts/generateReport.ts b/scripts/generateReport.ts index 24af628bd..0086f2477 100644 --- a/scripts/generateReport.ts +++ b/scripts/generateReport.ts @@ -215,7 +215,7 @@ page.on("console", async e => { switch (tag) { case "WebpackInterceptor:": - const patchFailMatch = message.match(/Patch by (.+?) (had no effect|errored|found no module) \(Module id is (.+?)\): (.+)/)!; + const patchFailMatch = message.match(/Patch by (.+?) (had no effect|errored|found no module|took [\d.]+?ms) \(Module id is (.+?)\): (.+)/)!; if (!patchFailMatch) break; console.error(await getText()); @@ -226,7 +226,7 @@ page.on("console", async e => { plugin, type, id, - match: regex.replace(/\(\?:\[A-Za-z_\$\]\[\\w\$\]\*\)/g, "\\i"), + match: regex, error: await maybeGetError(e.args()[3]) }); @@ -292,7 +292,7 @@ page.on("error", e => console.error("[Error]", e.message)); page.on("pageerror", e => { if (e.message.includes("Sentry successfully disabled")) return; - if (!e.message.startsWith("Object") && !e.message.includes("Cannot find module")) { + if (!e.message.startsWith("Object") && !e.message.includes("Cannot find module") && !/^.{1,2}$/.test(e.message)) { console.error("[Page Error]", e.message); report.otherErrors.push(e.message); } else { diff --git a/src/Vencord.ts b/src/Vencord.ts index c4c6d4705..63508eb06 100644 --- a/src/Vencord.ts +++ b/src/Vencord.ts @@ -23,6 +23,7 @@ export * as Util from "./utils"; export * as QuickCss from "./utils/quickCss"; export * as Updater from "./utils/updater"; export * as Webpack from "./webpack"; +export * as WebpackPatcher from "./webpack/patchWebpack"; export { PlainSettings, Settings }; import "./utils/quickCss"; diff --git a/src/api/Settings.ts b/src/api/Settings.ts index 3010327ec..08d2f8cac 100644 --- a/src/api/Settings.ts +++ b/src/api/Settings.ts @@ -32,9 +32,10 @@ export interface Settings { autoUpdate: boolean; autoUpdateNotification: boolean, useQuickCss: boolean; + eagerPatches: boolean; + enabledThemes: string[]; enableReactDevtools: boolean; themeLinks: string[]; - enabledThemes: string[]; frameless: boolean; transparent: boolean; winCtrlQ: boolean; @@ -81,6 +82,7 @@ const DefaultSettings: Settings = { autoUpdateNotification: true, useQuickCss: true, themeLinks: [], + eagerPatches: IS_REPORTER, enabledThemes: [], enableReactDevtools: false, frameless: false, diff --git a/src/debug/Tracer.ts b/src/debug/Tracer.ts index 7d80f425c..37ea4cc05 100644 --- a/src/debug/Tracer.ts +++ b/src/debug/Tracer.ts @@ -23,35 +23,61 @@ if (IS_DEV || IS_REPORTER) { var logger = new Logger("Tracer", "#FFD166"); } -const noop = function () { }; - -export const beginTrace = !(IS_DEV || IS_REPORTER) ? noop : +export const beginTrace = !(IS_DEV || IS_REPORTER) ? () => { } : function beginTrace(name: string, ...args: any[]) { - if (name in traces) + if (name in traces) { throw new Error(`Trace ${name} already exists!`); + } traces[name] = [performance.now(), args]; }; -export const finishTrace = !(IS_DEV || IS_REPORTER) ? noop : function finishTrace(name: string) { - const end = performance.now(); +export const finishTrace = !(IS_DEV || IS_REPORTER) ? () => 0 : + function finishTrace(name: string) { + const end = performance.now(); - const [start, args] = traces[name]; - delete traces[name]; + const [start, args] = traces[name]; + delete traces[name]; - logger.debug(`${name} took ${end - start}ms`, args); -}; + const totalTime = end - start; + logger.debug(`${name} took ${totalTime}ms`, args); + + return totalTime; + }; type Func = (...args: any[]) => any; type TraceNameMapper = (...args: Parameters) => string; -const noopTracer = - (name: string, f: F, mapper?: TraceNameMapper) => f; +function noopTracerWithResults(name: string, f: F, mapper?: TraceNameMapper) { + return function (this: unknown, ...args: Parameters): [ReturnType, number] { + return [f.apply(this, args), 0]; + }; +} + +function noopTracer(name: string, f: F, mapper?: TraceNameMapper) { + return f; +} + +export const traceFunctionWithResults = !(IS_DEV || IS_REPORTER) + ? noopTracerWithResults + : function traceFunctionWithResults(name: string, f: F, mapper?: TraceNameMapper): (this: unknown, ...args: Parameters) => [ReturnType, number] { + return function (this: unknown, ...args: Parameters) { + const traceName = mapper?.(...args) ?? name; + + beginTrace(traceName, ...arguments); + try { + return [f.apply(this, args), finishTrace(traceName)]; + } catch (e) { + finishTrace(traceName); + throw e; + } + }; + }; export const traceFunction = !(IS_DEV || IS_REPORTER) ? noopTracer : function traceFunction(name: string, f: F, mapper?: TraceNameMapper): F { - return function (this: any, ...args: Parameters) { + return function (this: unknown, ...args: Parameters) { const traceName = mapper?.(...args) ?? name; beginTrace(traceName, ...arguments); diff --git a/src/debug/loadLazyChunks.ts b/src/debug/loadLazyChunks.ts index c7f8047db..212078553 100644 --- a/src/debug/loadLazyChunks.ts +++ b/src/debug/loadLazyChunks.ts @@ -8,23 +8,27 @@ import { Logger } from "@utils/Logger"; import { canonicalizeMatch } from "@utils/patches"; import * as Webpack from "@webpack"; import { wreq } from "@webpack"; - -const LazyChunkLoaderLogger = new Logger("LazyChunkLoader"); +import { AnyModuleFactory, ModuleFactory } from "webpack"; export async function loadLazyChunks() { + const LazyChunkLoaderLogger = new Logger("LazyChunkLoader"); + try { LazyChunkLoaderLogger.log("Loading all chunks..."); - const validChunks = new Set(); - const invalidChunks = new Set(); - const deferredRequires = new Set(); + const validChunks = new Set(); + const invalidChunks = new Set(); + const deferredRequires = new Set(); - let chunksSearchingResolve: (value: void | PromiseLike) => void; + let chunksSearchingResolve: (value: void) => void; const chunksSearchingDone = new Promise(r => chunksSearchingResolve = r); // True if resolved, false otherwise const chunksSearchPromises = [] as Array<() => boolean>; + /* This regex loads all language packs which makes webpack finds testing extremely slow, so for now, lets use one which doesnt include those + const LazyChunkRegex = canonicalizeMatch(/(?:(?:Promise\.all\(\[)?(\i\.e\("?[^)]+?"?\)[^\]]*?)(?:\]\))?)\.then\(\i(?:\.\i)?\.bind\(\i,"?([^)]+?)"?(?:,[^)]+?)?\)\)/g); + */ const LazyChunkRegex = canonicalizeMatch(/(?:(?:Promise\.all\(\[)?(\i\.e\("?[^)]+?"?\)[^\]]*?)(?:\]\))?)\.then\(\i\.bind\(\i,"?([^)]+?)"?\)\)/g); let foundCssDebuggingLoad = false; @@ -34,12 +38,15 @@ export async function loadLazyChunks() { const hasCssDebuggingLoad = foundCssDebuggingLoad ? false : (foundCssDebuggingLoad = factoryCode.includes(".cssDebuggingEnabled&&")); const lazyChunks = factoryCode.matchAll(LazyChunkRegex); - const validChunkGroups = new Set<[chunkIds: number[], entryPoint: number]>(); + const validChunkGroups = new Set<[chunkIds: PropertyKey[], entryPoint: PropertyKey]>(); const shouldForceDefer = false; await Promise.all(Array.from(lazyChunks).map(async ([, rawChunkIds, entryPoint]) => { - const chunkIds = rawChunkIds ? Array.from(rawChunkIds.matchAll(Webpack.ChunkIdsRegex)).map(m => Number(m[1])) : []; + const chunkIds = rawChunkIds ? Array.from(rawChunkIds.matchAll(Webpack.ChunkIdsRegex)).map(m => { + const numChunkId = Number(m[1]); + return Number.isNaN(numChunkId) ? m[1] : numChunkId; + }) : []; if (chunkIds.length === 0) { return; @@ -74,7 +81,8 @@ export async function loadLazyChunks() { } if (!invalidChunkGroup) { - validChunkGroups.add([chunkIds, Number(entryPoint)]); + const numEntryPoint = Number(entryPoint); + validChunkGroups.add([chunkIds, Number.isNaN(numEntryPoint) ? entryPoint : numEntryPoint]); } })); @@ -82,7 +90,7 @@ export async function loadLazyChunks() { await Promise.all( Array.from(validChunkGroups) .map(([chunkIds]) => - Promise.all(chunkIds.map(id => wreq.e(id as any).catch(() => { }))) + Promise.all(chunkIds.map(id => wreq.e(id))) ) ); @@ -94,7 +102,7 @@ export async function loadLazyChunks() { continue; } - if (wreq.m[entryPoint]) wreq(entryPoint as any); + if (wreq.m[entryPoint]) wreq(entryPoint); } catch (err) { console.error(err); } @@ -122,41 +130,44 @@ export async function loadLazyChunks() { }, 0); } - Webpack.factoryListeners.add(factory => { + function factoryListener(factory: AnyModuleFactory | ModuleFactory) { let isResolved = false; - searchAndLoadLazyChunks(factory.toString()).then(() => isResolved = true); + searchAndLoadLazyChunks(String(factory)) + .then(() => isResolved = true) + .catch(() => isResolved = true); chunksSearchPromises.push(() => isResolved); - }); + } + Webpack.factoryListeners.add(factoryListener); for (const factoryId in wreq.m) { - let isResolved = false; - searchAndLoadLazyChunks(wreq.m[factoryId].toString()).then(() => isResolved = true); - - chunksSearchPromises.push(() => isResolved); + factoryListener(wreq.m[factoryId]); } await chunksSearchingDone; + Webpack.factoryListeners.delete(factoryListener); // Require deferred entry points for (const deferredRequire of deferredRequires) { - wreq!(deferredRequire as any); + wreq(deferredRequire); } // All chunks Discord has mapped to asset files, even if they are not used anymore - const allChunks = [] as number[]; + const allChunks = [] as PropertyKey[]; // Matches "id" or id: - for (const currentMatch of wreq!.u.toString().matchAll(/(?:"([\deE]+?)"(?![,}]))|(?:([\deE]+?):)/g)) { + for (const currentMatch of String(wreq.u).matchAll(/(?:"([\deE]+?)"(?![,}]))|(?:([\deE]+?):)/g)) { const id = currentMatch[1] ?? currentMatch[2]; if (id == null) continue; - allChunks.push(Number(id)); + const numId = Number(id); + allChunks.push(Number.isNaN(numId) ? id : numId); } if (allChunks.length === 0) throw new Error("Failed to get all chunks"); - // Chunks that are not loaded (not used) by Discord code anymore + // Chunks which our regex could not catch to load + // It will always contain WebWorker assets, and also currently contains some language packs which are loaded differently const chunksLeft = allChunks.filter(id => { return !(validChunks.has(id) || invalidChunks.has(id)); }); @@ -166,12 +177,9 @@ export async function loadLazyChunks() { .then(r => r.text()) .then(t => t.includes("importScripts(")); - // Loads and requires a chunk + // Loads the chunk. Currently this only happens with the language packs which are loaded differently if (!isWorkerAsset) { - await wreq.e(id as any); - // Technically, the id of the chunk does not match the entry point - // But, still try it because we have no way to get the actual entry point - if (wreq.m[id]) wreq(id as any); + await wreq.e(id); } })); diff --git a/src/debug/runReporter.ts b/src/debug/runReporter.ts index aef6ba3c8..e9c37970f 100644 --- a/src/debug/runReporter.ts +++ b/src/debug/runReporter.ts @@ -6,20 +6,35 @@ import { Logger } from "@utils/Logger"; import * as Webpack from "@webpack"; -import { patches } from "plugins"; +import { addPatch, patches } from "plugins"; import { loadLazyChunks } from "./loadLazyChunks"; -const ReporterLogger = new Logger("Reporter"); - async function runReporter() { + const ReporterLogger = new Logger("Reporter"); + try { ReporterLogger.log("Starting test..."); - let loadLazyChunksResolve: (value: void | PromiseLike) => void; + let loadLazyChunksResolve: (value: void) => void; const loadLazyChunksDone = new Promise(r => loadLazyChunksResolve = r); - Webpack.beforeInitListeners.add(() => loadLazyChunks().then((loadLazyChunksResolve))); + // The main patch for starting the reporter chunk loading + addPatch({ + find: '"Could not find app-mount"', + replacement: { + match: /(?<="use strict";)/, + replace: "Vencord.Webpack._initReporter();" + } + }, "Vencord Reporter"); + + // @ts-ignore + Vencord.Webpack._initReporter = function () { + // initReporter is called in the patched entry point of Discord + // setImmediate to only start searching for lazy chunks after Discord initialized the app + setTimeout(() => loadLazyChunks().then(loadLazyChunksResolve), 0); + }; + await loadLazyChunksDone; for (const patch of patches) { @@ -28,6 +43,12 @@ async function runReporter() { } } + for (const [plugin, moduleId, match, totalTime] of Vencord.WebpackPatcher.patchTimings) { + if (totalTime > 3) { + new Logger("WebpackInterceptor").warn(`Patch by ${plugin} took ${Math.round(totalTime * 100) / 100}ms (Module id is ${String(moduleId)}): ${match}`); + } + } + for (const [searchType, args] of Webpack.lazyWebpackSearchHistory) { let method = searchType; @@ -88,4 +109,6 @@ async function runReporter() { } } -runReporter(); +// Run after the Vencord object has been created. +// We need to add extra properties to it, and it is only created after all of Vencord code has ran +setTimeout(runReporter, 0); diff --git a/src/globals.d.ts b/src/globals.d.ts index e20ca4b71..4456564cc 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -64,13 +64,8 @@ declare global { export var Vesktop: any; export var VesktopNative: any; - interface Window { - webpackChunkdiscord_app: { - push(chunk: any): any; - pop(): any; - }; + interface Window extends Record { _: LoDashStatic; - [k: string]: any; } } diff --git a/src/plugins/_core/noTrack.ts b/src/plugins/_core/noTrack.ts index d552037fe..58d8d42a3 100644 --- a/src/plugins/_core/noTrack.ts +++ b/src/plugins/_core/noTrack.ts @@ -20,6 +20,7 @@ import { definePluginSettings } from "@api/Settings"; import { Devs } from "@utils/constants"; import { Logger } from "@utils/Logger"; import definePlugin, { OptionType, StartAt } from "@utils/types"; +import { WebpackRequire } from "webpack"; const settings = definePluginSettings({ disableAnalytics: { @@ -81,9 +82,9 @@ export default definePlugin({ Object.defineProperty(Function.prototype, "g", { configurable: true, - set(v: any) { + set(this: WebpackRequire, globalObj: WebpackRequire["g"]) { Object.defineProperty(this, "g", { - value: v, + value: globalObj, configurable: true, enumerable: true, writable: true @@ -92,11 +93,11 @@ export default definePlugin({ // Ensure this is most likely the Sentry WebpackInstance. // Function.g is a very generic property and is not uncommon for another WebpackInstance (or even a React component: ) to include it const { stack } = new Error(); - if (!(stack?.includes("discord.com") || stack?.includes("discordapp.com")) || !String(this).includes("exports:{}") || this.c != null) { + if (this.c != null || !stack?.includes("http") || !String(this).includes("exports:{}")) { return; } - const assetPath = stack?.match(/\/assets\/.+?\.js/)?.[0]; + const assetPath = stack.match(/http.+?(?=:\d+?:\d+?$)/m)?.[0]; if (!assetPath) { return; } @@ -106,7 +107,8 @@ export default definePlugin({ srcRequest.send(); // Final condition to see if this is the Sentry WebpackInstance - if (!srcRequest.responseText.includes("window.DiscordSentry=")) { + // This is matching window.DiscordSentry=, but without `window` to avoid issues on some proxies + if (!srcRequest.responseText.includes(".DiscordSentry=")) { return; } diff --git a/src/plugins/consoleShortcuts/index.ts b/src/plugins/consoleShortcuts/index.ts index 5d5709f4b..c0a4cb920 100644 --- a/src/plugins/consoleShortcuts/index.ts +++ b/src/plugins/consoleShortcuts/index.ts @@ -82,6 +82,8 @@ function makeShortcuts() { wp: Webpack, wpc: { getter: () => Webpack.cache }, wreq: { getter: () => Webpack.wreq }, + wpPatcher: { getter: () => Vencord.WebpackPatcher }, + wpInstances: { getter: () => Vencord.WebpackPatcher.allWebpackInstances }, wpsearch: search, wpex: extract, wpexs: (code: string) => extract(findModuleId(code)!), diff --git a/src/plugins/devCompanion.dev/index.tsx b/src/plugins/devCompanion.dev/index.tsx index a495907b2..d6a56fe69 100644 --- a/src/plugins/devCompanion.dev/index.tsx +++ b/src/plugins/devCompanion.dev/index.tsx @@ -160,7 +160,7 @@ function initWs(isManual = false) { return reply("Expected exactly one 'find' matches, found " + keys.length); const mod = candidates[keys[0]]; - let src = String(mod.original ?? mod).replaceAll("\n", ""); + let src = String(mod).replaceAll("\n", ""); if (src.startsWith("function(")) { src = "0," + src; diff --git a/src/plugins/index.ts b/src/plugins/index.ts index 306ac737d..e1899b743 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -28,7 +28,7 @@ import { addMessagePopoverButton, removeMessagePopoverButton } from "@api/Messag import { Settings, SettingsStore } from "@api/Settings"; import { disableStyle, enableStyle } from "@api/Styles"; import { Logger } from "@utils/Logger"; -import { canonicalizeFind } from "@utils/patches"; +import { canonicalizeFind, canonicalizeReplacement } from "@utils/patches"; import { Patch, Plugin, PluginDef, ReporterTestable, StartAt } from "@utils/types"; import { FluxDispatcher } from "@webpack/common"; import { FluxEvents } from "@webpack/types"; @@ -58,7 +58,7 @@ export function isPluginEnabled(p: string) { ) ?? false; } -export function addPatch(newPatch: Omit, pluginName: string) { +export function addPatch(newPatch: Omit, pluginName: string, pluginPath = `Vencord.Plugins.plugins[${JSON.stringify(pluginName)}]`) { const patch = newPatch as Patch; patch.plugin = pluginName; @@ -74,10 +74,12 @@ export function addPatch(newPatch: Omit, pluginName: string) { patch.replacement = [patch.replacement]; } - if (IS_REPORTER) { - patch.replacement.forEach(r => { - delete r.predicate; - }); + for (const replacement of patch.replacement) { + canonicalizeReplacement(replacement, pluginPath); + + if (IS_REPORTER) { + delete replacement.predicate; + } } patch.replacement = patch.replacement.filter(({ predicate }) => !predicate || predicate()); diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 5699770ea..6760d4d06 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -export const WEBPACK_CHUNK = "webpackChunkdiscord_app"; export const REACT_GLOBAL = "Vencord.Webpack.Common.React"; export const VENBOT_USER_ID = "1017176847865352332"; export const VENCORD_GUILD_ID = "1015060230222131221"; diff --git a/src/utils/misc.ts b/src/utils/misc.ts index 28c371c5b..adca15d3b 100644 --- a/src/utils/misc.ts +++ b/src/utils/misc.ts @@ -100,6 +100,11 @@ export function pluralise(amount: number, singular: string, plural = singular + return amount === 1 ? `${amount} ${singular}` : `${amount} ${plural}`; } +export function interpolateIfDefined(strings: TemplateStringsArray, ...args: any[]) { + if (args.some(arg => arg == null)) return ""; + return String.raw({ raw: strings }, ...args); +} + export function tryOrElse(func: () => T, fallback: T): T { try { const res = func(); diff --git a/src/utils/patches.ts b/src/utils/patches.ts index 097c64560..b212e6241 100644 --- a/src/utils/patches.ts +++ b/src/utils/patches.ts @@ -41,16 +41,17 @@ export function canonicalizeMatch(match: T): T { } const canonSource = partialCanon.replaceAll("\\i", String.raw`(?:[A-Za-z_$][\w$]*)`); - return new RegExp(canonSource, match.flags) as T; + const canonRegex = new RegExp(canonSource, match.flags); + canonRegex.toString = match.toString.bind(match); + + return canonRegex as T; } -export function canonicalizeReplace(replace: T, pluginName: string): T { - const self = `Vencord.Plugins.plugins[${JSON.stringify(pluginName)}]`; - +export function canonicalizeReplace(replace: T, pluginPath: string): T { if (typeof replace !== "function") - return replace.replaceAll("$self", self) as T; + return replace.replaceAll("$self", pluginPath) as T; - return ((...args) => replace(...args).replaceAll("$self", self)) as T; + return ((...args) => replace(...args).replaceAll("$self", pluginPath)) as T; } export function canonicalizeDescriptor(descriptor: TypedPropertyDescriptor, canonicalize: (value: T) => T) { @@ -65,12 +66,12 @@ export function canonicalizeDescriptor(descriptor: TypedPropertyDescriptor return descriptor; } -export function canonicalizeReplacement(replacement: Pick, plugin: string) { +export function canonicalizeReplacement(replacement: Pick, pluginPath: string) { const descriptors = Object.getOwnPropertyDescriptors(replacement); descriptors.match = canonicalizeDescriptor(descriptors.match, canonicalizeMatch); descriptors.replace = canonicalizeDescriptor( descriptors.replace, - replace => canonicalizeReplace(replace, plugin), + replace => canonicalizeReplace(replace, pluginPath), ); Object.defineProperties(replacement, descriptors); } diff --git a/src/utils/types.ts b/src/utils/types.ts index e226c5ba1..4ff30b78c 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -43,6 +43,10 @@ export interface PatchReplacement { replace: string | ReplaceFn; /** A function which returns whether this patch replacement should be applied */ predicate?(): boolean; + /** The minimum build number for this patch to be applied */ + fromBuild?: number; + /** The maximum build number for this patch to be applied */ + toBuild?: number; } export interface Patch { @@ -59,6 +63,10 @@ export interface Patch { group?: boolean; /** A function which returns whether this patch should be applied */ predicate?(): boolean; + /** The minimum build number for this patch to be applied */ + fromBuild?: number; + /** The maximum build number for this patch to be applied */ + toBuild?: number; } export interface PluginAuthor { diff --git a/src/webpack/common/menu.ts b/src/webpack/common/menu.ts index c45b69964..9896b3a20 100644 --- a/src/webpack/common/menu.ts +++ b/src/webpack/common/menu.ts @@ -25,7 +25,7 @@ export const Menu = {} as t.Menu; // Relies on .name properties added by the MenuItemDemanglerAPI waitFor(m => m.name === "MenuCheckboxItem", (_, id) => { // we have to do this manual require by ID because m is in this case the MenuCheckBoxItem instead of the entire module - const module = wreq(id as any); + const module = wreq(id); for (const e of Object.values(module)) { if (typeof e === "function" && e.name.startsWith("Menu")) { diff --git a/src/webpack/index.ts b/src/webpack/index.ts index 036c2a3fc..6f1fd25b8 100644 --- a/src/webpack/index.ts +++ b/src/webpack/index.ts @@ -18,3 +18,4 @@ export * as Common from "./common"; export * from "./webpack"; +export * from "./wreq.d"; diff --git a/src/webpack/patchWebpack.ts b/src/webpack/patchWebpack.ts index 1122d55bb..7a7107acb 100644 --- a/src/webpack/patchWebpack.ts +++ b/src/webpack/patchWebpack.ts @@ -1,353 +1,585 @@ /* - * Vencord, a modification for Discord's desktop app - * Copyright (c) 2022 Vendicated and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . -*/ + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated, Nuckyz, and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ -import { WEBPACK_CHUNK } from "@utils/constants"; +import { Settings } from "@api/Settings"; +import { makeLazy } from "@utils/lazy"; import { Logger } from "@utils/Logger"; +import { interpolateIfDefined } from "@utils/misc"; import { canonicalizeReplacement } from "@utils/patches"; import { PatchReplacement } from "@utils/types"; -import { WebpackInstance } from "discord-types/other"; -import { traceFunction } from "../debug/Tracer"; +import { traceFunctionWithResults } from "../debug/Tracer"; import { patches } from "../plugins"; -import { _initWebpack, _shouldIgnoreModule, beforeInitListeners, factoryListeners, moduleListeners, subscriptions, wreq } from "."; +import { _initWebpack, _shouldIgnoreModule, AnyModuleFactory, AnyWebpackRequire, factoryListeners, findModuleId, MaybeWrappedModuleFactory, ModuleExports, moduleListeners, waitForSubscriptions, WebpackRequire, WrappedModuleFactory, wreq } from "."; + +export const SYM_ORIGINAL_FACTORY = Symbol("WebpackPatcher.originalFactory"); +export const SYM_PATCHED_SOURCE = Symbol("WebpackPatcher.patchedSource"); +export const SYM_PATCHED_BY = Symbol("WebpackPatcher.patchedBy"); +/** A set with all the Webpack instances */ +export const allWebpackInstances = new Set(); +export const patchTimings = [] as Array<[plugin: string, moduleId: PropertyKey, match: string | RegExp, totalTime: number]>; const logger = new Logger("WebpackInterceptor", "#8caaee"); +/** Whether we tried to fallback to factory WebpackRequire, or disabled patches */ +let wreqFallbackApplied = false; +/** Whether we should be patching factories. + * + * This should be disabled if we start searching for the module to get the build number, and then resumed once it's done. + * */ +let shouldPatchFactories = true; -let webpackChunk: any[]; +export const getBuildNumber = makeLazy(() => { + try { + shouldPatchFactories = false; -// Patch the window webpack chunk setter to monkey patch the push method before any chunks are pushed -// This way we can patch the factory of everything being pushed to the modules array -Object.defineProperty(window, WEBPACK_CHUNK, { - configurable: true, + try { + if (wreq.m[128014]?.toString().includes("Trying to open a changelog for an invalid build number")) { + const hardcodedGetBuildNumber = wreq(128014).b as () => number; - get: () => webpackChunk, - set: v => { - if (v?.push) { - if (!v.push.$$vencordOriginal) { - logger.info(`Patching ${WEBPACK_CHUNK}.push`); - patchPush(v); - - // @ts-ignore - delete window[WEBPACK_CHUNK]; - window[WEBPACK_CHUNK] = v; + if (typeof hardcodedGetBuildNumber === "function" && typeof hardcodedGetBuildNumber() === "number") { + return hardcodedGetBuildNumber(); + } } + } catch { } + + const moduleId = findModuleId("Trying to open a changelog for an invalid build number"); + if (moduleId == null) { + return -1; } - webpackChunk = v; + const exports = Object.values(wreq(moduleId)); + if (exports.length !== 1 || typeof exports[0] !== "function") { + return -1; + } + + const buildNumber = exports[0](); + return typeof buildNumber === "number" ? buildNumber : -1; + } catch { + return -1; + } finally { + shouldPatchFactories = true; } }); -// wreq.m is the webpack module factory. -// normally, this is populated via webpackGlobal.push, which we patch below. -// However, Discord has their .m prepopulated. -// Thus, we use this hack to immediately access their wreq.m and patch all already existing factories -Object.defineProperty(Function.prototype, "m", { - configurable: true, +type Define = typeof Reflect.defineProperty; +const define: Define = (target, p, attributes) => { + if (Object.hasOwn(attributes, "value")) { + attributes.writable = true; + } - set(v: any) { - Object.defineProperty(this, "m", { - value: v, - configurable: true, - enumerable: true, - writable: true - }); + return Reflect.defineProperty(target, p, { + configurable: true, + enumerable: true, + ...attributes + }); +}; - // When using react devtools or other extensions, we may also catch their webpack here. - // This ensures we actually got the right one +export function getOriginalFactory(id: PropertyKey, webpackRequire = wreq as AnyWebpackRequire) { + const moduleFactory = webpackRequire.m[id]; + return (moduleFactory?.[SYM_ORIGINAL_FACTORY] ?? moduleFactory) as AnyModuleFactory | undefined; +} + +export function getFactoryPatchedSource(id: PropertyKey, webpackRequire = wreq as AnyWebpackRequire) { + return webpackRequire.m[id]?.[SYM_PATCHED_SOURCE]; +} + +export function getFactoryPatchedBy(id: PropertyKey, webpackRequire = wreq as AnyWebpackRequire) { + return webpackRequire.m[id]?.[SYM_PATCHED_BY]; +} + +// wreq.m is the Webpack object containing module factories. It is pre-populated with module factories, and is also populated via webpackGlobal.push +// We use this setter to intercept when wreq.m is defined and apply the patching in its module factories. +// We wrap wreq.m with our proxy, which is responsible for patching the module factories when they are set, or defining getters for the patched versions. + +// If this is the main Webpack, we also set up the internal references to WebpackRequire. +define(Function.prototype, "m", { + enumerable: false, + + set(this: AnyWebpackRequire, originalModules: AnyWebpackRequire["m"]) { + define(this, "m", { value: originalModules }); + + // Ensure this is one of Discord main Webpack instances. + // We may catch Discord bundled libs, React Devtools or other extensions Webpack instances here. const { stack } = new Error(); - if (!(stack?.includes("discord.com") || stack?.includes("discordapp.com")) || Array.isArray(v)) { + if (!stack?.includes("http") || stack.match(/at \d+? \(/) || !String(this).includes("exports:{}")) { return; } - const fileName = stack.match(/\/assets\/(.+?\.js)/)?.[1] ?? ""; - logger.info("Found Webpack module factory", fileName); + const fileName = stack.match(/\/assets\/(.+?\.js)/)?.[1]; + logger.info("Found Webpack module factories" + interpolateIfDefined` in ${fileName}`); - patchFactories(v); + allWebpackInstances.add(this); - // Define a setter for the bundlePath property of WebpackRequire. Only the main Webpack has this property. + // Define a setter for the ensureChunk property of WebpackRequire. Only the main Webpack (which is the only that includes chunk loading) has this property. // So if the setter is called, this means we can initialize the internal references to WebpackRequire. - Object.defineProperty(this, "p", { - configurable: true, - - set(this: WebpackInstance, bundlePath: string) { - Object.defineProperty(this, "p", { - value: bundlePath, - configurable: true, - enumerable: true, - writable: true - }); + define(this, "e", { + enumerable: false, + set(this: WebpackRequire, ensureChunk: WebpackRequire["e"]) { + define(this, "e", { value: ensureChunk }); clearTimeout(setterTimeout); - if (bundlePath !== "/assets/") return; - logger.info(`Main Webpack found in ${fileName}, initializing internal references to WebpackRequire`); + logger.info("Main WebpackInstance found" + interpolateIfDefined` in ${fileName}` + ", initializing internal references to WebpackRequire"); _initWebpack(this); - - for (const beforeInitListener of beforeInitListeners) { - beforeInitListener(this); - } } }); // setImmediate to clear this property setter if this is not the main Webpack. - // If this is the main Webpack, wreq.p will always be set before the timeout runs. - const setterTimeout = setTimeout(() => Reflect.deleteProperty(this, "p"), 0); + // If this is the main Webpack, wreq.e will always be set before the timeout runs. + const setterTimeout = setTimeout(() => Reflect.deleteProperty(this, "e"), 0); + + // Patch the pre-populated factories + for (const id in originalModules) { + if (updateExistingFactory(originalModules, id, originalModules[id], true)) { + continue; + } + + notifyFactoryListeners(originalModules[id]); + defineModulesFactoryGetter(id, Settings.eagerPatches && shouldPatchFactories ? wrapAndPatchFactory(id, originalModules[id]) : originalModules[id]); + } + + define(originalModules, Symbol.toStringTag, { + value: "ModuleFactories", + enumerable: false + }); + + // The proxy responsible for patching the module factories when they are set, or defining getters for the patched versions + const proxiedModuleFactories = new Proxy(originalModules, moduleFactoriesHandler); + /* + If Webpack ever decides to set module factories using the variable of the modules object directly, instead of wreq.m, switch the proxy to the prototype + Reflect.setPrototypeOf(originalModules, new Proxy(originalModules, moduleFactoriesHandler)); + */ + + define(this, "m", { value: proxiedModuleFactories }); } }); -function patchPush(webpackGlobal: any) { - function handlePush(chunk: any) { - try { - patchFactories(chunk[1]); - } catch (err) { - logger.error("Error in handlePush", err); +const moduleFactoriesHandler: ProxyHandler = { + /* + If Webpack ever decides to set module factories using the variable of the modules object directly instead of wreq.m, we need to switch the proxy to the prototype + and that requires defining additional traps for keeping the object working + + // Proxies on the prototype don't intercept "get" when the property is in the object itself. But in case it isn't we need to return undefined, + // to avoid Reflect.get having no effect and causing a stack overflow + get(target, p, receiver) { + return undefined; + }, + // Same thing as get + has(target, p) { + return false; + }, + */ + + // The set trap for patching or defining getters for the module factories when new module factories are loaded + set(target, p, newValue, receiver) { + if (updateExistingFactory(target, p, newValue)) { + return true; } - return handlePush.$$vencordOriginal.call(webpackGlobal, chunk); + notifyFactoryListeners(newValue); + defineModulesFactoryGetter(p, Settings.eagerPatches && shouldPatchFactories ? wrapAndPatchFactory(p, newValue) : newValue); + + return true; + } +}; + +/** + * Update a factory that exists in any Webpack instance with a new original factory. + * + * @target The module factories where this new original factory is being set + * @param id The id of the module + * @param newFactory The new original factory + * @param ignoreExistingInTarget Whether to ignore checking if the factory already exists in the moduleFactoriesTarget + * @returns Whether the original factory was updated, or false if it doesn't exist in any Webpack instance + */ +function updateExistingFactory(moduleFactoriesTarget: AnyWebpackRequire["m"], id: PropertyKey, newFactory: AnyModuleFactory, ignoreExistingInTarget: boolean = false) { + let existingFactory: TypedPropertyDescriptor | undefined; + let moduleFactoriesWithFactory: AnyWebpackRequire["m"] | undefined; + for (const wreq of allWebpackInstances) { + if (ignoreExistingInTarget && wreq.m === moduleFactoriesTarget) continue; + + if (Object.hasOwn(wreq.m, id)) { + existingFactory = Reflect.getOwnPropertyDescriptor(wreq.m, id); + moduleFactoriesWithFactory = wreq.m; + break; + } } - handlePush.$$vencordOriginal = webpackGlobal.push; - handlePush.toString = handlePush.$$vencordOriginal.toString.bind(handlePush.$$vencordOriginal); - // Webpack overwrites .push with its own push like so: `d.push = n.bind(null, d.push.bind(d));` - // it wraps the old push (`d.push.bind(d)`). this old push is in this case our handlePush. - // If we then repatched the new push, we would end up with recursive patching, which leads to our patches - // being applied multiple times. - // Thus, override bind to use the original push - handlePush.bind = (...args: unknown[]) => handlePush.$$vencordOriginal.bind(...args); + if (existingFactory != null) { + // If existingFactory exists in any Webpack instance, it's either wrapped in defineModuleFactoryGetter, or it has already been required. + // So define the descriptor of it on this current Webpack instance (if it doesn't exist already), call Reflect.set with the new original, + // and let the correct logic apply (normal set, or defineModuleFactoryGetter setter) - Object.defineProperty(webpackGlobal, "push", { - configurable: true, - - get: () => handlePush, - set(v) { - handlePush.$$vencordOriginal = v; + if (moduleFactoriesWithFactory !== moduleFactoriesTarget) { + Reflect.defineProperty(moduleFactoriesTarget, id, existingFactory); } - }); + + // Persist patched source and patched by in the new original factory, if the patched one has already been required + if (IS_DEV && existingFactory.value != null) { + newFactory[SYM_PATCHED_SOURCE] = existingFactory.value[SYM_PATCHED_SOURCE]; + newFactory[SYM_PATCHED_BY] = existingFactory.value[SYM_PATCHED_BY]; + } + + return Reflect.set(moduleFactoriesTarget, id, newFactory, moduleFactoriesTarget); + } + + return false; } -let webpackNotInitializedLogged = false; +/** + * Notify all factory listeners. + * + * @param factory The original factory to notify for + */ +function notifyFactoryListeners(factory: AnyModuleFactory) { + for (const factoryListener of factoryListeners) { + try { + factoryListener(factory); + } catch (err) { + logger.error("Error in Webpack factory listener:\n", err, factoryListener); + } + } +} -function patchFactories(factories: Record void>) { - for (const id in factories) { - let mod = factories[id]; +/** + * Define the getter for returning the patched version of the module factory. + * + * If eagerPatches is enabled, the factory argument should already be the patched version, else it will be the original + * and only be patched when accessed for the first time. + * + * @param id The id of the module + * @param factory The original or patched module factory + */ +function defineModulesFactoryGetter(id: PropertyKey, factory: MaybeWrappedModuleFactory) { + const descriptor: PropertyDescriptor = { + get() { + // SYM_ORIGINAL_FACTORY means the factory is already patched + if (!shouldPatchFactories || factory[SYM_ORIGINAL_FACTORY] != null) { + return factory; + } - const originalMod = mod; - const patchedBy = new Set(); + return (factory = wrapAndPatchFactory(id, factory)); + }, + set(newFactory: MaybeWrappedModuleFactory) { + if (IS_DEV) { + newFactory[SYM_PATCHED_SOURCE] = factory[SYM_PATCHED_SOURCE]; + newFactory[SYM_PATCHED_BY] = factory[SYM_PATCHED_BY]; + } - const factory = factories[id] = function (module: any, exports: any, require: WebpackInstance) { - if (wreq == null && IS_DEV) { - if (!webpackNotInitializedLogged) { - webpackNotInitializedLogged = true; + if (factory[SYM_ORIGINAL_FACTORY] != null) { + factory.toString = newFactory.toString.bind(newFactory); + factory[SYM_ORIGINAL_FACTORY] = newFactory; + } else { + factory = newFactory; + } + } + }; + + // Define the getter in all the module factories objects. Patches are only executed once, so make sure all module factories object + // have the patched version + for (const wreq of allWebpackInstances) { + define(wreq.m, id, descriptor); + } +} + +/** + * Wraps and patches a module factory. + * + * @param id The id of the module + * @param factory The original or patched module factory + * @returns The wrapper for the patched module factory + */ +function wrapAndPatchFactory(id: PropertyKey, originalFactory: AnyModuleFactory) { + const [patchedFactory, patchedSource, patchedBy] = patchFactory(id, originalFactory); + + const wrappedFactory: WrappedModuleFactory = function (...args) { + // Restore the original factory in all the module factories objects. We want to make sure the original factory is restored properly, no matter what is the Webpack instance + for (const wreq of allWebpackInstances) { + define(wreq.m, id, { value: wrappedFactory[SYM_ORIGINAL_FACTORY] }); + } + + // eslint-disable-next-line prefer-const + let [module, exports, require] = args; + + if (wreq == null) { + if (!wreqFallbackApplied) { + wreqFallbackApplied = true; + + // Make sure the require argument is actually the WebpackRequire function + if (typeof require === "function" && require.m != null) { + const { stack } = new Error(); + const webpackInstanceFileName = stack?.match(/\/assets\/(.+?\.js)/)?.[1]; + + logger.warn( + "WebpackRequire was not initialized, falling back to WebpackRequire passed to the first called patched module factory (" + + `id: ${String(id)}` + interpolateIfDefined`, WebpackInstance origin: ${webpackInstanceFileName}` + + ")" + ); + + _initWebpack(require as WebpackRequire); + } else if (IS_DEV) { logger.error("WebpackRequire was not initialized, running modules without patches instead."); + return wrappedFactory[SYM_ORIGINAL_FACTORY].apply(this, args); } + } else if (IS_DEV) { + return wrappedFactory[SYM_ORIGINAL_FACTORY].apply(this, args); + } + } - return void originalMod(module, exports, require); + let factoryReturn: unknown; + try { + // Call the patched factory + factoryReturn = patchedFactory.apply(this, args); + } catch (err) { + // Just re-throw Discord errors + if (patchedFactory === wrappedFactory[SYM_ORIGINAL_FACTORY]) { + throw err; } - try { - mod(module, exports, require); - } catch (err) { - // Just rethrow discord errors - if (mod === originalMod) throw err; + logger.error("Error in patched module factory:\n", err); + return wrappedFactory[SYM_ORIGINAL_FACTORY].apply(this, args); + } - logger.error("Error in patched module", err); - return void originalMod(module, exports, require); - } + exports = module.exports; + if (exports == null) { + return factoryReturn; + } - exports = module.exports; + if (typeof require === "function") { + const shouldIgnoreModule = _shouldIgnoreModule(exports); - if (!exports) return; - - if (require.c) { - const shouldIgnoreModule = _shouldIgnoreModule(exports); - - if (shouldIgnoreModule) { + if (shouldIgnoreModule) { + if (require.c != null) { Object.defineProperty(require.c, id, { value: require.c[id], enumerable: false, configurable: true, writable: true }); - - return; } - } - for (const callback of moduleListeners) { - try { - callback(exports, id); - } catch (err) { - logger.error("Error in Webpack module listener:\n", err, callback); - } - } - - for (const [filter, callback] of subscriptions) { - try { - if (exports && filter(exports)) { - subscriptions.delete(filter); - callback(exports, id); - } - - if (typeof exports !== "object") { - continue; - } - - for (const exportKey in exports) { - if (exports[exportKey] && filter(exports[exportKey])) { - subscriptions.delete(filter); - callback(exports[exportKey], id); - } - } - } catch (err) { - logger.error("Error while firing callback for Webpack subscription:\n", err, filter, callback); - } - } - } as any as { toString: () => string, original: any, (...args: any[]): void; $$vencordPatchedSource?: string; }; - - factory.toString = originalMod.toString.bind(originalMod); - factory.original = originalMod; - - for (const factoryListener of factoryListeners) { - try { - factoryListener(originalMod); - } catch (err) { - logger.error("Error in Webpack factory listener:\n", err, factoryListener); + return factoryReturn; } } - // Discords Webpack chunks for some ungodly reason contain random - // newlines. Cyn recommended this workaround and it seems to work fine, - // however this could potentially break code, so if anything goes weird, - // this is probably why. - // Additionally, `[actual newline]` is one less char than "\n", so if Discord - // ever targets newer browsers, the minifier could potentially use this trick and - // cause issues. - // - // 0, prefix is to turn it into an expression: 0,function(){} would be invalid syntax without the 0, - let code: string = "0," + mod.toString().replaceAll("\n", ""); + for (const callback of moduleListeners) { + try { + callback(exports, id); + } catch (err) { + logger.error("Error in Webpack module listener:\n", err, callback); + } + } - for (let i = 0; i < patches.length; i++) { - const patch = patches[i]; + for (const [filter, callback] of waitForSubscriptions) { + try { + if (filter(exports)) { + waitForSubscriptions.delete(filter); + callback(exports, id); + continue; + } - const moduleMatches = typeof patch.find === "string" - ? code.includes(patch.find) - : patch.find.test(code); + if (typeof exports !== "object") { + continue; + } - if (!moduleMatches) continue; + for (const exportKey in exports) { + const exportValue = exports[exportKey]; - patchedBy.add(patch.plugin); - - const executePatch = traceFunction(`patch by ${patch.plugin}`, (match: string | RegExp, replace: string) => code.replace(match, replace)); - const previousMod = mod; - const previousCode = code; - - // We change all patch.replacement to array in plugins/index - for (const replacement of patch.replacement as PatchReplacement[]) { - const lastMod = mod; - const lastCode = code; - - canonicalizeReplacement(replacement, patch.plugin); - - try { - const newCode = executePatch(replacement.match, replacement.replace as string); - if (newCode === code) { - if (!patch.noWarn) { - logger.warn(`Patch by ${patch.plugin} had no effect (Module id is ${id}): ${replacement.match}`); - if (IS_DEV) { - logger.debug("Function Source:\n", code); - } - } - - if (patch.group) { - logger.warn(`Undoing patch group ${patch.find} by ${patch.plugin} because replacement ${replacement.match} had no effect`); - mod = previousMod; - code = previousCode; - patchedBy.delete(patch.plugin); - break; - } - - continue; + if (exportValue != null && filter(exportValue)) { + waitForSubscriptions.delete(filter); + callback(exportValue, id); + break; } + } + } catch (err) { + logger.error("Error while firing callback for Webpack waitFor subscription:\n", err, filter, callback); + } + } - code = newCode; - mod = (0, eval)(`// Webpack Module ${id} - Patched by ${[...patchedBy].join(", ")}\n${newCode}\n//# sourceURL=WebpackModule${id}`); - } catch (err) { - logger.error(`Patch by ${patch.plugin} errored (Module id is ${id}): ${replacement.match}\n`, err); + return factoryReturn; + }; - if (IS_DEV) { - const changeSize = code.length - lastCode.length; - const match = lastCode.match(replacement.match)!; + wrappedFactory.toString = originalFactory.toString.bind(originalFactory); + wrappedFactory[SYM_ORIGINAL_FACTORY] = originalFactory; - // 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); - // (changeSize may be negative) - const endPatched = end + changeSize; + if (IS_DEV && patchedFactory !== originalFactory) { + wrappedFactory[SYM_PATCHED_SOURCE] = patchedSource; + wrappedFactory[SYM_PATCHED_BY] = patchedBy; + originalFactory[SYM_PATCHED_SOURCE] = patchedSource; + originalFactory[SYM_PATCHED_BY] = patchedBy; + } - const context = lastCode.slice(start, end); - const patchedContext = code.slice(start, endPatched); + // @ts-expect-error Allow GC to get into action, if possible + originalFactory = undefined; + return wrappedFactory; +} - // inline require to avoid including it in !IS_DEV builds - const diff = (require("diff") as typeof import("diff")).diffWordsWithSpace(context, patchedContext); - let fmt = "%c %s "; - const elements = [] as string[]; - for (const d of diff) { - const color = d.removed - ? "red" - : d.added - ? "lime" - : "grey"; - fmt += "%c%s"; - elements.push("color:" + color, d.value); +/** + * Patches a module factory. + * + * @param id The id of the module + * @param factory The original module factory + * @returns The patched module factory, the patched source of it, and the plugins that patched it + */ +function patchFactory(id: PropertyKey, factory: AnyModuleFactory): [patchedFactory: AnyModuleFactory, patchedSource: string, patchedBy: Set] { + // 0, prefix to turn it into an expression: 0,function(){} would be invalid syntax without the 0, + let code: string = "0," + String(factory); + let patchedSource = code; + let patchedFactory = factory; + + const patchedBy = new Set(); + + for (let i = 0; i < patches.length; i++) { + const patch = patches[i]; + + const moduleMatches = typeof patch.find === "string" + ? code.includes(patch.find) + : (patch.find.global && (patch.find.lastIndex = 0), patch.find.test(code)); + + if (!moduleMatches) { + continue; + } + + const buildNumber = getBuildNumber(); + const shouldCheckBuildId = !Settings.eagerPatches && buildNumber !== -1; + + if ( + shouldCheckBuildId && + (patch.fromBuild != null && buildNumber < patch.fromBuild) || + (patch.toBuild != null && buildNumber > patch.toBuild) + ) { + continue; + } + + 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); + }); + + const previousCode = code; + const previousFactory = factory; + let markedAsPatched = false; + + // We change all patch.replacement to array in plugins/index + for (const replacement of patch.replacement as PatchReplacement[]) { + if ( + shouldCheckBuildId && + (replacement.fromBuild != null && buildNumber < replacement.fromBuild) || + (replacement.toBuild != null && buildNumber > replacement.toBuild) + ) { + continue; + } + + // TODO: remove once Vesktop has been updated to use addPatch + if (patch.plugin === "Vesktop") { + canonicalizeReplacement(replacement, "VCDP"); + } + + const lastCode = code; + const lastFactory = factory; + + try { + const [newCode, totalTime] = executePatch(replacement.match, replacement.replace as string); + + if (IS_REPORTER) { + patchTimings.push([patch.plugin, id, replacement.match, totalTime]); + } + + if (newCode === code) { + if (!patch.noWarn) { + logger.warn(`Patch by ${patch.plugin} had no effect (Module id is ${String(id)}): ${replacement.match}`); + if (IS_DEV) { + logger.debug("Function Source:\n", code); } - - logger.errorCustomFmt(...Logger.makeTitle("white", "Before"), context); - logger.errorCustomFmt(...Logger.makeTitle("white", "After"), patchedContext); - const [titleFmt, ...titleElements] = Logger.makeTitle("white", "Diff"); - logger.errorCustomFmt(titleFmt + fmt, ...titleElements, ...elements); } - patchedBy.delete(patch.plugin); - if (patch.group) { - logger.warn(`Undoing patch group ${patch.find} by ${patch.plugin} because replacement ${replacement.match} errored`); - mod = previousMod; + 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); + } + break; } - mod = lastMod; - code = lastCode; + continue; } - } - if (!patch.all) patches.splice(i--, 1); + code = newCode; + patchedSource = `// Webpack Module ${String(id)} - Patched by ${[...patchedBy, patch.plugin].join(", ")}\n${newCode}\n//# sourceURL=WebpackModule${String(id)}`; + patchedFactory = (0, eval)(patchedSource); + + if (!patchedBy.has(patch.plugin)) { + patchedBy.add(patch.plugin); + markedAsPatched = true; + } + } catch (err) { + logger.error(`Patch by ${patch.plugin} errored (Module id is ${String(id)}): ${replacement.match}\n`, err); + + if (IS_DEV) { + diffErroredPatch(code, lastCode, lastCode.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; + break; + } + + code = lastCode; + patchedFactory = lastFactory; + } } - if (IS_DEV) { - if (mod !== originalMod) { - factory.$$vencordPatchedSource = String(mod); - } else if (wreq != null) { - const existingFactory = wreq.m[id]; - - if (existingFactory != null) { - factory.$$vencordPatchedSource = existingFactory.$$vencordPatchedSource; - } - } + if (!patch.all) { + patches.splice(i--, 1); } } + + return [patchedFactory, patchedSource, patchedBy]; +} + +function diffErroredPatch(code: string, lastCode: string, match: RegExpMatchArray) { + const changeSize = code.length - lastCode.length; + + // 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); + // (changeSize may be negative) + const endPatched = end + changeSize; + + const context = lastCode.slice(start, end); + const patchedContext = code.slice(start, endPatched); + + // Inline require to avoid including it in !IS_DEV builds + const diff = (require("diff") as typeof import("diff")).diffWordsWithSpace(context, patchedContext); + let fmt = "%c %s "; + const elements: string[] = []; + for (const d of diff) { + const color = d.removed + ? "red" + : d.added + ? "lime" + : "grey"; + fmt += "%c%s"; + elements.push("color:" + color, d.value); + } + + logger.errorCustomFmt(...Logger.makeTitle("white", "Before"), context); + logger.errorCustomFmt(...Logger.makeTitle("white", "After"), patchedContext); + const [titleFmt, ...titleElements] = Logger.makeTitle("white", "Diff"); + logger.errorCustomFmt(titleFmt + fmt, ...titleElements, ...elements); } diff --git a/src/webpack/webpack.ts b/src/webpack/webpack.ts index 7ed791080..8d5b3c688 100644 --- a/src/webpack/webpack.ts +++ b/src/webpack/webpack.ts @@ -20,9 +20,9 @@ import { makeLazy, proxyLazy } from "@utils/lazy"; import { LazyComponent } from "@utils/lazyReact"; import { Logger } from "@utils/Logger"; import { canonicalizeMatch } from "@utils/patches"; -import type { WebpackInstance } from "discord-types/other"; import { traceFunction } from "../debug/Tracer"; +import { AnyModuleFactory, ModuleExports, WebpackRequire } from "./wreq"; const logger = new Logger("Webpack"); @@ -33,8 +33,8 @@ export let _resolveReady: () => void; */ export const onceReady = new Promise(r => _resolveReady = r); -export let wreq: WebpackInstance; -export let cache: WebpackInstance["c"]; +export let wreq: WebpackRequire; +export let cache: WebpackRequire["c"]; export type FilterFn = (mod: any) => boolean; @@ -89,16 +89,27 @@ export const filters = { } }; -export type CallbackFn = (mod: any, id: string) => void; +export type CallbackFn = (module: ModuleExports, id: PropertyKey) => void; +export type FactoryListernFn = (factory: AnyModuleFactory) => void; -export const subscriptions = new Map(); +export const waitForSubscriptions = new Map(); export const moduleListeners = new Set(); -export const factoryListeners = new Set<(factory: (module: any, exports: any, require: WebpackInstance) => void) => void>(); -export const beforeInitListeners = new Set<(wreq: WebpackInstance) => void>(); +export const factoryListeners = new Set(); + +export function _initWebpack(webpackRequire: WebpackRequire) { + if (webpackRequire.c == null) { + return; + } -export function _initWebpack(webpackRequire: WebpackInstance) { wreq = webpackRequire; cache = webpackRequire.c; + + Reflect.defineProperty(webpackRequire.c, Symbol.toStringTag, { + value: "ModuleCache", + configurable: true, + writable: true, + enumerable: false + }); } // Credits to Zerebos for implementing this in BD, thus giving the idea for us to implement it too @@ -531,7 +542,7 @@ export const ChunkIdsRegex = /\("([^"]+?)"\)/g; * @param matcher A RegExp that returns the chunk ids array as the first capture group and the entry point id as the second. Defaults to a matcher that captures the first lazy chunk loading found in the module factory * @returns A promise that resolves with a boolean whether the chunks were loaded */ -export async function extractAndLoadChunks(code: CodeFilter, matcher: RegExp = DefaultExtractAndLoadChunksRegex) { +export async function extractAndLoadChunks(code: CodeFilter, matcher = DefaultExtractAndLoadChunksRegex) { const module = findModuleFactory(...code); if (!module) { const err = new Error("extractAndLoadChunks: Couldn't find module factory"); @@ -544,7 +555,7 @@ export async function extractAndLoadChunks(code: CodeFilter, matcher: RegExp = D return false; } - const match = module.toString().match(canonicalizeMatch(matcher)); + const match = String(module).match(canonicalizeMatch(matcher)); if (!match) { const err = new Error("extractAndLoadChunks: Couldn't find chunk loading in module factory code"); logger.warn(err, "Code:", code, "Matcher:", matcher); @@ -557,8 +568,9 @@ export async function extractAndLoadChunks(code: CodeFilter, matcher: RegExp = D } const [, rawChunkIds, entryPointId] = match; - if (Number.isNaN(Number(entryPointId))) { - const err = new Error("extractAndLoadChunks: Matcher didn't return a capturing group with the chunk ids array, or the entry point id returned as the second group wasn't a number"); + + if (entryPointId == null) { + const err = new Error("extractAndLoadChunks: Matcher didn't return a capturing group with the chunk ids array or the entry point id"); logger.warn(err, "Code:", code, "Matcher:", matcher); // Strict behaviour in DevBuilds to fail early and make sure the issue is found @@ -568,12 +580,19 @@ export async function extractAndLoadChunks(code: CodeFilter, matcher: RegExp = D return false; } + const numEntryPoint = Number(entryPointId); + const entryPoint = Number.isNaN(numEntryPoint) ? entryPointId : numEntryPoint; + if (rawChunkIds) { - const chunkIds = Array.from(rawChunkIds.matchAll(ChunkIdsRegex)).map((m: any) => Number(m[1])); + const chunkIds = Array.from(rawChunkIds.matchAll(ChunkIdsRegex)).map(m => { + const numChunkId = Number(m[1]); + return Number.isNaN(numChunkId) ? m[1] : numChunkId; + }); + await Promise.all(chunkIds.map(id => wreq.e(id))); } - if (wreq.m[entryPointId] == null) { + if (wreq.m[entryPoint] == null) { const err = new Error("extractAndLoadChunks: Entry point is not loaded in the module factories, perhaps one of the chunks failed to load"); logger.warn(err, "Code:", code, "Matcher:", matcher); @@ -584,7 +603,7 @@ export async function extractAndLoadChunks(code: CodeFilter, matcher: RegExp = D return false; } - wreq(Number(entryPointId)); + wreq(entryPoint); return true; } @@ -621,7 +640,7 @@ export function waitFor(filter: string | PropsFilter | FilterFn, callback: Callb if (existing) return void callback(existing, id); } - subscriptions.set(filter, callback); + waitForSubscriptions.set(filter, callback); } /** @@ -637,7 +656,7 @@ export function search(...code: CodeFilter) { const factories = wreq.m; for (const id in factories) { - const factory = factories[id].original ?? factories[id]; + const factory = factories[id]; if (stringMatches(factory.toString(), code)) results[id] = factory; diff --git a/src/webpack/wreq.d.ts b/src/webpack/wreq.d.ts new file mode 100644 index 000000000..dbc451054 --- /dev/null +++ b/src/webpack/wreq.d.ts @@ -0,0 +1,211 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated, Nuckyz and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { SYM_ORIGINAL_FACTORY, SYM_PATCHED_BY, SYM_PATCHED_SOURCE } from "./patchWebpack"; + +export type ModuleExports = any; + +export type Module = { + id: PropertyKey; + loaded: boolean; + exports: ModuleExports; +}; + +/** exports can be anything, however initially it is always an empty object */ +export type ModuleFactory = (this: ModuleExports, module: Module, exports: ModuleExports, require: WebpackRequire) => void; + +export type WebpackQueues = unique symbol | "__webpack_queues__"; +export type WebpackExports = unique symbol | "__webpack_exports__"; +export type WebpackError = unique symbol | "__webpack_error__"; + +export type AsyncModulePromise = Promise & { + [WebpackQueues]: (fnQueue: ((queue: any[]) => any)) => any; + [WebpackExports]: ModuleExports; + [WebpackError]?: any; +}; + +export type AsyncModuleBody = ( + handleAsyncDependencies: (deps: AsyncModulePromise[]) => + Promise<() => ModuleExports[]> | (() => ModuleExports[]), + asyncResult: (error?: any) => void +) => Promise; + +export type ChunkHandlers = { + /** + * Ensures the js file for this chunk is loaded, or starts to load if it's not. + * @param chunkId The chunk id + * @param promises The promises array to add the loading promise to + */ + j: (this: ChunkHandlers, chunkId: PropertyKey, promises: Promise) => void, + /** + * Ensures the css file for this chunk is loaded, or starts to load if it's not. + * @param chunkId The chunk id + * @param promises The promises array to add the loading promise to. This array will likely contain the promise of the js file too + */ + css: (this: ChunkHandlers, chunkId: PropertyKey, promises: Promise) => void, +}; + +export type ScriptLoadDone = (event: Event) => void; + +// export type OnChunksLoaded = ((this: WebpackRequire, result: any, chunkIds: PropertyKey[] | undefined | null, callback: () => any, priority: number) => any) & { +// /** Check if a chunk has been loaded */ +// j: (this: OnChunksLoaded, chunkId: PropertyKey) => boolean; +// }; + +export type WebpackRequire = ((moduleId: PropertyKey) => ModuleExports) & { + /** The module factories, where all modules that have been loaded are stored (pre-loaded or loaded by lazy chunks) */ + m: Record; + /** The module cache, where all modules which have been WebpackRequire'd are stored */ + c: Record; + // /** + // * Export star. Sets properties of "fromObject" to "toObject" as getters that return the value from "fromObject", like this: + // * @example + // * const fromObject = { a: 1 }; + // * Object.keys(fromObject).forEach(key => { + // * if (key !== "default" && !Object.hasOwn(toObject, key)) { + // * Object.defineProperty(toObject, key, { + // * get: () => fromObject[key], + // * enumerable: true + // * }); + // * } + // * }); + // * @returns fromObject + // */ + // es: (this: WebpackRequire, fromObject: AnyRecord, toObject: AnyRecord) => AnyRecord; + /** + * Creates an async module. A module that which has top level await, or requires an export from an async module. + * + * The body function must be an async function. "module.exports" will become an {@link AsyncModulePromise}. + * + * The body function will be called with a function to handle requires that import from an async module, and a function to resolve this async module. An example on how to handle async dependencies: + * @example + * const factory = (module, exports, wreq) => { + * wreq.a(module, async (handleAsyncDependencies, asyncResult) => { + * try { + * const asyncRequireA = wreq(...); + * + * const asyncDependencies = handleAsyncDependencies([asyncRequire]); + * const [requireAResult] = asyncDependencies.then != null ? (await asyncDependencies)() : asyncDependencies; + * + * // Use the required module + * console.log(requireAResult); + * + * // Mark this async module as resolved + * asyncResult(); + * } catch(error) { + * // Mark this async module as rejected with an error + * asyncResult(error); + * } + * }, false); // false because our module does not have an await after dealing with the async requires + * } + */ + a: (this: WebpackRequire, module: Module, body: AsyncModuleBody, hasAwaitAfterDependencies?: boolean) => void; + /** getDefaultExport function for compatibility with non-harmony modules */ + n: (this: WebpackRequire, exports: any) => () => ModuleExports; + /** + * Create a fake namespace object, useful for faking an __esModule with a default export. + * + * mode & 1: Value is a module id, require it + * + * mode & 2: Merge all properties of value into the namespace + * + * mode & 4: Return value when already namespace object + * + * mode & 16: Return value when it's Promise-like + * + * mode & (8|1): Behave like require + */ + t: (this: WebpackRequire, value: any, mode: number) => any; + /** + * Define getter functions for harmony exports. For every prop in "definiton" (the module exports), set a getter in "exports" for the getter function in the "definition", like this: + * @example + * const exports = {}; + * const definition = { exportName: () => someExportedValue }; + * for (const key in definition) { + * if (Object.hasOwn(definition, key) && !Object.hasOwn(exports, key)) { + * Object.defineProperty(exports, key, { + * get: definition[key], + * enumerable: true + * }); + * } + * } + * // exports is now { exportName: someExportedValue } (but each value is actually a getter) + */ + d: (this: WebpackRequire, exports: AnyRecord, definiton: AnyRecord) => void; + /** The chunk handlers, which are used to ensure the files of the chunks are loaded, or load if necessary */ + f: ChunkHandlers; + /** + * The ensure chunk function, it ensures a chunk is loaded, or loads if needed. + * Internally it uses the handlers in {@link WebpackRequire.f} to load/ensure the chunk is loaded. + */ + e: (this: WebpackRequire, chunkId: PropertyKey) => Promise; + /** Get the filename for the css part of a chunk */ + k: (this: WebpackRequire, chunkId: PropertyKey) => string; + /** Get the filename for the js part of a chunk */ + u: (this: WebpackRequire, chunkId: PropertyKey) => string; + /** The global object, will likely always be the window */ + g: typeof globalThis; + /** Harmony module decorator. Decorates a module as an ES Module, and prevents Node.js "module.exports" from being set */ + hmd: (this: WebpackRequire, module: Module) => any; + /** Shorthand for Object.prototype.hasOwnProperty */ + o: typeof Object.prototype.hasOwnProperty; + /** + * Function to load a script tag. "done" is called when the loading has finished or a timeout has occurred. + * "done" will be attached to existing scripts loading if src === url or data-webpack === `${uniqueName}:${key}`, + * so it will be called when that existing script finishes loading. + */ + l: (this: WebpackRequire, url: string, done: ScriptLoadDone, key?: string | number, chunkId?: PropertyKey) => void; + /** Defines __esModule on the exports, marking ES Modules compatibility as true */ + r: (this: WebpackRequire, exports: ModuleExports) => void; + /** Node.js module decorator. Decorates a module as a Node.js module */ + nmd: (this: WebpackRequire, module: Module) => any; + // /** + // * Register deferred code which will be executed when the passed chunks are loaded. + // * + // * If chunkIds is defined, it defers the execution of the callback and returns undefined. + // * + // * If chunkIds is undefined, and no deferred code exists or can be executed, it returns the value of the result argument. + // * + // * If chunkIds is undefined, and some deferred code can already be executed, it returns the result of the callback function of the last deferred code. + // * + // * When (priority & 1) it will wait for all other handlers with lower priority to be executed before itself is executed. + // */ + // O: OnChunksLoaded; + /** + * Instantiate a wasm instance with source using "wasmModuleHash", and importObject "importsObj", and then assign the exports of its instance to "exports". + * @returns The exports argument, but now assigned with the exports of the wasm instance + */ + v: (this: WebpackRequire, exports: ModuleExports, wasmModuleId: any, wasmModuleHash: string, importsObj?: WebAssembly.Imports) => Promise; + /** Bundle public path, where chunk files are stored. Used by other methods which load chunks to obtain the full asset url */ + p: string; + /** The runtime id of the current runtime */ + j: string; + /** Document baseURI or WebWorker location.href */ + b: string; +}; + +// Utility section for Vencord + +export type AnyWebpackRequire = ((moduleId: PropertyKey) => ModuleExports) & Partial> & { + /** The module factories, where all modules that have been loaded are stored (pre-loaded or loaded by lazy chunks) */ + m: Record; +}; + +/** exports can be anything, however initially it is always an empty object */ +export type AnyModuleFactory = ((this: ModuleExports, module: Module, exports: ModuleExports, require: AnyWebpackRequire) => void) & { + [SYM_PATCHED_SOURCE]?: string; + [SYM_PATCHED_BY]?: Set; +}; + +export type WrappedModuleFactory = AnyModuleFactory & { + [SYM_ORIGINAL_FACTORY]: AnyModuleFactory; + [SYM_PATCHED_SOURCE]?: string; + [SYM_PATCHED_BY]?: Set; +}; + +export type MaybeWrappedModuleFactory = AnyModuleFactory | WrappedModuleFactory; + +export type WrappedModuleFactories = Record;