From 35a6a027fa7278fa5ed33de723001b3b85e5d3d6 Mon Sep 17 00:00:00 2001 From: Nuckyz <61953774+Nuckyz@users.noreply.github.com> Date: Thu, 23 Jan 2025 00:23:45 -0300 Subject: [PATCH] Rewrite webpack patcher --- scripts/build/common.mjs | 2 +- scripts/generateReport.ts | 19 +- src/Vencord.ts | 1 + src/api/Settings.ts | 4 +- src/debug/Tracer.ts | 52 +- src/debug/loadLazyChunks.ts | 44 +- src/debug/runReporter.ts | 30 +- src/plugins/_core/noTrack.ts | 12 +- .../accountPanelServerProfile/index.tsx | 2 +- src/plugins/index.ts | 12 +- src/utils/misc.ts | 5 + src/utils/patches.ts | 5 +- src/utils/types.ts | 8 + src/webpack/index.ts | 1 + src/webpack/patchWebpack.ts | 728 +++++++++++------- src/webpack/webpack.ts | 25 +- src/webpack/wreq.d.ts | 205 +++++ 17 files changed, 809 insertions(+), 346 deletions(-) create mode 100644 src/webpack/wreq.d.ts 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..874ba174c 100644 --- a/scripts/generateReport.ts +++ b/scripts/generateReport.ts @@ -26,7 +26,7 @@ import { readFileSync } from "fs"; import pup, { JSHandle } from "puppeteer-core"; -for (const variable of ["DISCORD_TOKEN", "CHROMIUM_BIN"]) { +for (const variable of ["CHROMIUM_BIN"]) { if (!process.env[variable]) { console.error(`Missing environment variable ${variable}`); process.exit(1); @@ -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 { @@ -300,20 +300,9 @@ page.on("pageerror", e => { } }); -async function reporterRuntime(token: string) { - Vencord.Webpack.waitFor( - "loginToken", - m => { - console.log("[PUP_DEBUG]", "Logging in with token..."); - m.loginToken(token); - } - ); -} - await page.evaluateOnNewDocument(` if (location.host.endsWith("discord.com")) { ${readFileSync("./dist/browser.js", "utf-8")}; - (${reporterRuntime.toString()})(${JSON.stringify(process.env.DISCORD_TOKEN)}); } `); 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 c99d030d0..ef9feb96e 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..8a5329e8c 100644 --- a/src/debug/loadLazyChunks.ts +++ b/src/debug/loadLazyChunks.ts @@ -8,10 +8,11 @@ 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..."); @@ -25,7 +26,7 @@ export async function loadLazyChunks() { // True if resolved, false otherwise const chunksSearchPromises = [] as Array<() => boolean>; - const LazyChunkRegex = canonicalizeMatch(/(?:(?:Promise\.all\(\[)?(\i\.e\("?[^)]+?"?\)[^\]]*?)(?:\]\))?)\.then\(\i\.bind\(\i,"?([^)]+?)"?\)\)/g); + const LazyChunkRegex = canonicalizeMatch(/(?:(?:Promise\.all\(\[)?(\i\.e\("?[^)]+?"?\)[^\]]*?)(?:\]\))?)\.then\(\i(?:\.\i)?\.bind\(\i,"?([^)]+?)"?(?:,[^)]+?)?\)\)/g); let foundCssDebuggingLoad = false; @@ -82,7 +83,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 +95,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,32 +123,33 @@ export async function loadLazyChunks() { }, 0); } - Webpack.factoryListeners.add(factory => { + function factoryListener(factory: AnyModuleFactory | ModuleFactory) { let isResolved = false; - searchAndLoadLazyChunks(factory.toString()).then(() => isResolved = true); - - chunksSearchPromises.push(() => isResolved); - }); - - for (const factoryId in wreq.m) { - let isResolved = false; - searchAndLoadLazyChunks(wreq.m[factoryId].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) { + 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[]; // 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; @@ -156,7 +158,8 @@ export async function loadLazyChunks() { 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 +169,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 ddd5e5f18..cfae827ac 100644 --- a/src/debug/runReporter.ts +++ b/src/debug/runReporter.ts @@ -6,7 +6,7 @@ import { Logger } from "@utils/Logger"; import * as Webpack from "@webpack"; -import { patches } from "plugins"; +import { addPatch, patches } from "plugins"; import { loadLazyChunks } from "./loadLazyChunks"; @@ -16,10 +16,25 @@ async function runReporter() { 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 ${totalTime}ms (Module id is ${String(moduleId)}): ${match}`); + } + } + for (const [searchType, args] of Webpack.lazyWebpackSearchHistory) { let method = searchType; @@ -81,4 +102,5 @@ async function runReporter() { } } -runReporter(); +// Run after the Vencord object has been created, because we need to add extra properties to it +setTimeout(runReporter, 0); 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/accountPanelServerProfile/index.tsx b/src/plugins/accountPanelServerProfile/index.tsx index a2fed5d79..807f51757 100644 --- a/src/plugins/accountPanelServerProfile/index.tsx +++ b/src/plugins/accountPanelServerProfile/index.tsx @@ -74,7 +74,7 @@ export default definePlugin({ replacement: [ { match: /(?<=\.AVATAR_SIZE\);)/, - replace: "$self.useAccountPanelRef();" + replace: "$self.useAccountPanelRef();", }, { match: /(\.AVATAR,children:.+?renderPopout:(\i)=>){(.+?)}(?=,position)(?<=currentUser:(\i).+?)/, diff --git a/src/plugins/index.ts b/src/plugins/index.ts index 545169b1f..3fe7bb1ac 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -27,7 +27,7 @@ import { addMessageClickListener, addMessagePreEditListener, addMessagePreSendLi import { addMessagePopoverButton, removeMessagePopoverButton } from "@api/MessagePopover"; import { Settings, SettingsStore } from "@api/Settings"; 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"; @@ -73,10 +73,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, pluginName); + + if (IS_REPORTER) { + delete replacement.predicate; + } } patch.replacement = patch.replacement.filter(({ predicate }) => !predicate || predicate()); diff --git a/src/utils/misc.ts b/src/utils/misc.ts index 28c371c5b..9001dfad2 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 strings.reduce((acc, str, i) => `${acc}${str}${args[i] ?? ""}`, ""); +} + 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..f544309dd 100644 --- a/src/utils/patches.ts +++ b/src/utils/patches.ts @@ -41,7 +41,10 @@ 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 { diff --git a/src/utils/types.ts b/src/utils/types.ts index 54de59e34..c0a7273a5 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/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 fb640cea8..f9434f5db 100644 --- a/src/webpack/patchWebpack.ts +++ b/src/webpack/patchWebpack.ts @@ -1,370 +1,558 @@ /* - * 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 { canonicalizeReplacement } from "@utils/patches"; +import { interpolateIfDefined } from "@utils/misc"; 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, beforeInitListeners, factoryListeners, moduleListeners, subscriptions, wreq } from "."; +import { _initWebpack, AnyModuleFactory, AnyWebpackRequire, factoryListeners, findModuleId, ModuleExports, moduleListeners, subscriptions, WebpackRequire, WrappedModuleFactory, wreq } from "."; const logger = new Logger("WebpackInterceptor", "#8caaee"); -let webpackChunk: any[]; +/** 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]>; -// 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, +/** 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; - get: () => webpackChunk, - set: v => { - if (v?.push) { - if (!v.push.$$vencordOriginal) { - logger.info(`Patching ${WEBPACK_CHUNK}.push`); - patchPush(v); +const getBuildNumber = makeLazy(() => { + try { + shouldPatchFactories = false; - // @ts-ignore - delete window[WEBPACK_CHUNK]; - window[WEBPACK_CHUNK] = v; - } + const hardcodedModuleAttempt = wreq(128014)?.b; + if (typeof hardcodedModuleAttempt === "function" && typeof hardcodedModuleAttempt() === "number") { + return hardcodedModuleAttempt() as number; } - webpackChunk = v; + const moduleId = findModuleId("Trying to open a changelog for an invalid build number"); + if (moduleId == null) { + return -1; + } + + 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 +// 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 definining 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 definining getters for the patched versions + const proxiedModuleFactories = new Proxy(originalModules, moduleFactoriesHandler); + /* + If Discord 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 Discord 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 dont 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 the property is not a number, we are not dealing with a module factory + if (Number.isNaN(Number(p))) { + return define(target, p, { value: newValue }); } - return handlePush.$$vencordOriginal.call(webpackGlobal, chunk); + if (updateExistingFactory(target, p, newValue)) { + return true; + } + + 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 (Reflect.getOwnPropertyDescriptor(wreq.m, id) != null) { + 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 $$vencordPatchedSource in the new original factory, if the patched one has already been required + if (IS_DEV && existingFactory.value != null) { + newFactory.$$vencordPatchedSource = existingFactory.value.$$vencordPatchedSource; + } + + 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: WrappedModuleFactory) { + const descriptor: PropertyDescriptor = { + get() { + // $$vencordOriginal means the factory is already patched + if (!shouldPatchFactories || factory.$$vencordOriginal != null) { + return factory; + } - const originalMod = mod; - const patchedBy = new Set(); + return (factory = wrapAndPatchFactory(id, factory)); + }, + set(newFactory: AnyModuleFactory) { + if (IS_DEV && factory.$$vencordPatchedSource != null) { + newFactory.$$vencordPatchedSource = factory.$$vencordPatchedSource; + } - const factory = factories[id] = function (module: any, exports: any, require: WebpackInstance) { - if (wreq == null && IS_DEV) { - if (!webpackNotInitializedLogged) { - webpackNotInitializedLogged = true; + if (factory.$$vencordOriginal != null) { + factory.toString = newFactory.toString.bind(newFactory); + factory.$$vencordOriginal = 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 = 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.$$vencordOriginal }); + } + + // 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.$$vencordOriginal!.apply(this, args); } + } else if (IS_DEV) { + return wrappedFactory.$$vencordOriginal!.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 === originalFactory) { + 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.$$vencordOriginal!.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; + // There are (at the time of writing) 11 modules exporting the window + // Make these non enumerable to improve webpack search performance + if (typeof require === "function") { + let foundWindow = false; - if (!exports) return; - - // There are (at the time of writing) 11 modules exporting the window - // Make these non enumerable to improve webpack search performance - if (require.c) { - let foundWindow = false; - - if (exports === window) { + if (exports === window) { + foundWindow = true; + } else if (typeof exports === "object") { + if (exports.default === window) { foundWindow = true; - } else if (typeof exports === "object") { - if (exports?.default === window) { - foundWindow = true; - } else { - for (const nested in exports) if (nested.length <= 3) { - if (exports[nested] === window) { - foundWindow = true; - } + } else { + for (const exportKey in exports) if (exportKey.length <= 3) { + if (exports[exportKey] === window) { + foundWindow = true; } } } + } - if (foundWindow) { + if (foundWindow) { + 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); - } else if (typeof exports === "object") { - if (exports.default && filter(exports.default)) { - subscriptions.delete(filter); - callback(exports.default, id); - } else { - for (const nested in exports) if (nested.length <= 3) { - if (exports[nested] && filter(exports[nested])) { - subscriptions.delete(filter); - callback(exports[nested], 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 subscriptions) { + try { - const moduleMatches = typeof patch.find === "string" - ? code.includes(patch.find) - : patch.find.test(code); + if (filter(exports)) { + subscriptions.delete(filter); + callback(exports, id); + continue; + } - if (!moduleMatches) continue; + if (typeof exports !== "object") { + continue; + } - patchedBy.add(patch.plugin); + if (exports.default != null && filter(exports.default)) { + subscriptions.delete(filter); + callback(exports.default, id); + continue; + } - const executePatch = traceFunction(`patch by ${patch.plugin}`, (match: string | RegExp, replace: string) => code.replace(match, replace)); - const previousMod = mod; - const previousCode = code; + for (const exportKey in exports) if (exportKey.length <= 3) { + const exportValue = exports[exportKey]; - // 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)) { + subscriptions.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.$$vencordOriginal = 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) { + const patchedSource = String(patchedFactory); - const context = lastCode.slice(start, end); - const patchedContext = code.slice(start, endPatched); + wrappedFactory.$$vencordPatchedSource = patchedSource; + originalFactory.$$vencordPatchedSource = patchedSource; + } - // 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); + return wrappedFactory; +} + +/** + * Patches a module factory. + * + * @param id The id of the module + * @param factory The original module factory + * @returns The patched module factory + */ +function patchFactory(id: PropertyKey, factory: AnyModuleFactory) { + // 0, prefix to turn it into an expression: 0,function(){} would be invalid syntax without the 0, + let code: string = "0," + String(factory); + 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; + + if ( + !Settings.eagerPatches && + (patch.fromBuild != null && getBuildNumber() !== -1 && getBuildNumber() < patch.fromBuild) || + (patch.toBuild != null && getBuildNumber() !== -1 && getBuildNumber() > patch.toBuild) + ) { + continue; + } + + patchedBy.add(patch.plugin); + + const executePatch = traceFunctionWithResults(`patch by ${patch.plugin}`, (match: string | RegExp, replace: string) => { + if (match instanceof RegExp && match.global) { + match.lastIndex = 0; + } + + return code.replace(match, replace); + }); + const previousCode = code; + const previousFactory = factory; + + // We change all patch.replacement to array in plugins/index + for (const replacement of patch.replacement as PatchReplacement[]) { + if ( + !Settings.eagerPatches && + (replacement.fromBuild != null && getBuildNumber() !== -1 && getBuildNumber() < replacement.fromBuild) || + (replacement.toBuild != null && getBuildNumber() !== -1 && getBuildNumber() > replacement.toBuild) + ) { + continue; + } + + 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; + patchedBy.delete(patch.plugin); break; } - mod = lastMod; - code = lastCode; + continue; } - } - if (!patch.all) patches.splice(i--, 1); - } + code = newCode; + patchedFactory = (0, eval)(`// Webpack Module ${String(id)} - Patched by ${[...patchedBy].join(", ")}\n${newCode}\n//# sourceURL=WebpackModule${String(id)}`); + } catch (err) { + logger.error(`Patch by ${patch.plugin} errored (Module id is ${String(id)}): ${replacement.match}\n`, err); - if (IS_DEV) { - if (mod !== originalMod) { - factory.$$vencordPatchedSource = String(mod); - } else if (wreq != null) { - const existingFactory = wreq.m[id]; + if (IS_DEV) { + const changeSize = code.length - lastCode.length; + const match = lastCode.match(replacement.match)!; - if (existingFactory != null) { - factory.$$vencordPatchedSource = existingFactory.$$vencordPatchedSource; + // 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); } + + 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 (!patch.all) patches.splice(i--, 1); } + + return patchedFactory; } diff --git a/src/webpack/webpack.ts b/src/webpack/webpack.ts index 19519d647..cedb35118 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; @@ -80,16 +80,25 @@ 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 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: WebpackInstance) { +export function _initWebpack(webpackRequire: WebpackRequire) { wreq = webpackRequire; + + if (webpackRequire.c == null) return; cache = webpackRequire.c; + + Reflect.defineProperty(webpackRequire.c, Symbol.toStringTag, { + value: "ModuleCache", + configurable: true, + writable: true, + enumerable: false + }); } let devToolsOpen = false; @@ -611,7 +620,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..63bd27942 --- /dev/null +++ b/src/webpack/wreq.d.ts @@ -0,0 +1,205 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated, Nuckyz and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +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; +export type WebpackExports = unique symbol; +export type WebpackError = unique symbol; + +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 exports something that is a Promise, 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) & { + $$vencordPatchedSource?: string; +}; + +export type WrappedModuleFactory = AnyModuleFactory & { + $$vencordOriginal?: AnyModuleFactory; + $$vencordPatchedSource?: string; +}; + +export type WrappedModuleFactories = Record;