From 2a77941dff67fa0825b729af91be9432286c03f6 Mon Sep 17 00:00:00 2001
From: Nuckyz <61953774+Nuckyz@users.noreply.github.com>
Date: Sun, 19 May 2024 22:49:58 -0300
Subject: [PATCH] Proxy modules object for patching
---
scripts/generateReport.ts | 6 +
src/utils/constants.ts | 1 -
src/utils/lazy.ts | 10 +-
src/utils/misc.tsx | 3 +
src/webpack/patchWebpack.ts | 441 +++++++++++++++++-------------------
5 files changed, 215 insertions(+), 246 deletions(-)
diff --git a/scripts/generateReport.ts b/scripts/generateReport.ts
index 164e409df..6462d91a0 100644
--- a/scripts/generateReport.ts
+++ b/scripts/generateReport.ts
@@ -465,6 +465,12 @@ async function runtime(token: string) {
}
}));
+ // Call the getter for all the values in the modules object
+ // So modules that were not required get patched by our proxy
+ for (const id in wreq!.m) {
+ wreq!.m[id];
+ }
+
console.log("[PUP_DEBUG]", "Finished loading all chunks!");
for (const patch of Vencord.Plugins.patches) {
diff --git a/src/utils/constants.ts b/src/utils/constants.ts
index 44d13b54c..01125c1af 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 SUPPORT_CHANNEL_ID = "1026515880080842772";
diff --git a/src/utils/lazy.ts b/src/utils/lazy.ts
index a61785df9..b05855fa0 100644
--- a/src/utils/lazy.ts
+++ b/src/utils/lazy.ts
@@ -16,6 +16,8 @@
* along with this program. If not, see .
*/
+import { UNCONFIGURABLE_PROPERTIES } from "./misc";
+
export function makeLazy(factory: () => T, attempts = 5): () => T {
let tries = 0;
let cache: T;
@@ -29,10 +31,6 @@ export function makeLazy(factory: () => T, attempts = 5): () => T {
};
}
-// Proxies demand that these properties be unmodified, so proxyLazy
-// will always return the function default for them.
-const unconfigurable = ["arguments", "caller", "prototype"];
-
const handler: ProxyHandler = {};
const kGET = Symbol.for("vencord.lazy.get");
@@ -59,14 +57,14 @@ for (const method of [
handler.ownKeys = target => {
const v = target[kGET]();
const keys = Reflect.ownKeys(v);
- for (const key of unconfigurable) {
+ for (const key of UNCONFIGURABLE_PROPERTIES) {
if (!keys.includes(key)) keys.push(key);
}
return keys;
};
handler.getOwnPropertyDescriptor = (target, p) => {
- if (typeof p === "string" && unconfigurable.includes(p))
+ if (typeof p === "string" && UNCONFIGURABLE_PROPERTIES.includes(p))
return Reflect.getOwnPropertyDescriptor(target, p);
const descriptor = Reflect.getOwnPropertyDescriptor(target[kGET](), p);
diff --git a/src/utils/misc.tsx b/src/utils/misc.tsx
index fb08c93f6..5bf2c2398 100644
--- a/src/utils/misc.tsx
+++ b/src/utils/misc.tsx
@@ -99,3 +99,6 @@ export const isPluginDev = (id: string) => Object.hasOwn(DevsById, id);
export function pluralise(amount: number, singular: string, plural = singular + "s") {
return amount === 1 ? `${amount} ${singular}` : `${amount} ${plural}`;
}
+
+/** Unconfigurable properties for proxies */
+export const UNCONFIGURABLE_PROPERTIES = ["arguments", "caller", "prototype"];
diff --git a/src/webpack/patchWebpack.ts b/src/webpack/patchWebpack.ts
index c7e424671..9eaddb3d3 100644
--- a/src/webpack/patchWebpack.ts
+++ b/src/webpack/patchWebpack.ts
@@ -1,23 +1,11 @@
/*
- * 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 and contributors
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
-import { WEBPACK_CHUNK } from "@utils/constants";
import { Logger } from "@utils/Logger";
+import { UNCONFIGURABLE_PROPERTIES } from "@utils/misc";
import { canonicalizeMatch, canonicalizeReplacement } from "@utils/patches";
import { PatchReplacement } from "@utils/types";
import { WebpackInstance } from "discord-types/other";
@@ -29,29 +17,30 @@ import { _initWebpack, beforeInitListeners, factoryListeners, moduleListeners, s
const logger = new Logger("WebpackInterceptor", "#8caaee");
const initCallbackRegex = canonicalizeMatch(/{return \i\(".+?"\)}/);
-let webpackChunk: any[];
+const modulesProxyhandler: ProxyHandler = {
+ ...Object.fromEntries(Object.getOwnPropertyNames(Reflect).map(propName =>
+ [propName, (target: any, ...args: any[]) => Reflect[propName](target, ...args)]
+ )),
+ get: (target, p: string) => {
+ const mod = Reflect.get(target, p);
-// 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,
+ // If the property is not a module id, return the value of it without trying to patch
+ if (mod == null || mod.$$vencordOriginal != null || Number.isNaN(Number(p))) return mod;
- get: () => webpackChunk,
- set: v => {
- if (v?.push) {
- if (!v.push.$$vencordOriginal) {
- logger.info(`Patching ${WEBPACK_CHUNK}.push`);
- patchPush(v);
+ const patchedMod = patchFactory(p, mod);
+ Reflect.set(target, p, patchedMod);
- // @ts-ignore
- delete window[WEBPACK_CHUNK];
- window[WEBPACK_CHUNK] = v;
- }
+ return patchedMod;
+ },
+ set: (target, p, newValue) => Reflect.set(target, p, newValue),
+ ownKeys: target => {
+ const keys = Reflect.ownKeys(target);
+ for (const key of UNCONFIGURABLE_PROPERTIES) {
+ if (!keys.includes(key)) keys.push(key);
}
-
- webpackChunk = v;
+ return keys;
}
-});
+};
// wreq.O is the webpack onChunksLoaded function
// Discord uses it to await for all the chunks to be loaded before initializing the app
@@ -108,251 +97,225 @@ Object.defineProperty(Function.prototype, "O", {
}
});
-// 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
-//
-// Update: Discord now has TWO webpack instances. Their normal one and sentry
-// Sentry does not push chunks to the global at all, so this same patch now also handles their sentry modules
+// wreq.m is the webpack object containing module factories.
+// This is pre-populated with modules, and is also populated via webpackGlobal.push
+// We replace its prototype with our proxy, which is responsible for returning patched module factories containing our patches
Object.defineProperty(Function.prototype, "m", {
configurable: true,
- set(v: any) {
+ set(originalModules: any) {
// When using react devtools or other extensions, we may also catch their webpack here.
// This ensures we actually got the right one
const { stack } = new Error();
if (stack?.includes("discord.com") || stack?.includes("discordapp.com")) {
logger.info("Found Webpack module factory", stack.match(/\/assets\/(.+?\.js)/)?.[1] ?? "");
- patchFactories(v);
+
+ // The new object which will contain the factories
+ const modules = Object.assign({}, originalModules);
+
+ // Clear the original object so pre-populated factories are patched
+ for (const propName in originalModules) {
+ delete originalModules[propName];
+ }
+
+ Object.setPrototypeOf(originalModules, new Proxy(modules, modulesProxyhandler));
}
Object.defineProperty(this, "m", {
- value: v,
+ value: originalModules,
configurable: true
});
}
});
-function patchPush(webpackGlobal: any) {
- function handlePush(chunk: any) {
- try {
- patchFactories(chunk[1]);
- } catch (err) {
- logger.error("Error in handlePush", err);
- }
-
- return handlePush.$$vencordOriginal.call(webpackGlobal, chunk);
- }
-
- 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);
-
- Object.defineProperty(webpackGlobal, "push", {
- configurable: true,
-
- get: () => handlePush,
- set(v) {
- handlePush.$$vencordOriginal = v;
- }
- });
-}
-
let webpackNotInitializedLogged = false;
-function patchFactories(factories: Record void>) {
- for (const id in factories) {
- let mod = factories[id];
-
- const originalMod = mod;
- const patchedBy = new Set();
-
- const factory = factories[id] = function (module: any, exports: any, require: WebpackInstance) {
- if (wreq == null && IS_DEV) {
- if (!webpackNotInitializedLogged) {
- webpackNotInitializedLogged = true;
- logger.error("WebpackRequire was not initialized, running modules without patches instead.");
- }
-
- return void originalMod(module, exports, require);
- }
-
- try {
- mod(module, exports, require);
- } catch (err) {
- // Just rethrow discord errors
- if (mod === originalMod) throw err;
-
- logger.error("Error in patched module", err);
- return void originalMod(module, exports, require);
- }
-
- exports = module.exports;
-
- 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 (exports === window && require.c) {
- 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 (filter(exports)) {
- subscriptions.delete(filter);
- callback(exports, id);
- } else if (exports.default && filter(exports.default)) {
- subscriptions.delete(filter);
- callback(exports.default, 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; };
-
- 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);
- }
+function patchFactory(id: string, mod: (module: any, exports: any, require: WebpackInstance) => void) {
+ for (const factoryListener of factoryListeners) {
+ try {
+ factoryListener(mod);
+ } catch (err) {
+ logger.error("Error in Webpack factory listener:\n", err, factoryListener);
}
+ }
- // 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", "");
+ const originalMod = mod;
+ const patchedBy = new Set();
- for (let i = 0; i < patches.length; i++) {
- const patch = patches[i];
- if (patch.predicate && !patch.predicate()) continue;
+ // 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", "");
- const moduleMatches = typeof patch.find === "string"
- ? code.includes(patch.find)
- : patch.find.test(code);
+ for (let i = 0; i < patches.length; i++) {
+ const patch = patches[i];
+ if (patch.predicate && !patch.predicate()) continue;
- if (!moduleMatches) continue;
+ const moduleMatches = typeof patch.find === "string"
+ ? code.includes(patch.find)
+ : patch.find.test(code);
- patchedBy.add(patch.plugin);
+ if (!moduleMatches) continue;
- const executePatch = traceFunction(`patch by ${patch.plugin}`, (match: string | RegExp, replace: string) => code.replace(match, replace));
- const previousMod = mod;
- const previousCode = code;
+ patchedBy.add(patch.plugin);
- // We change all patch.replacement to array in plugins/index
- for (const replacement of patch.replacement as PatchReplacement[]) {
- if (replacement.predicate && !replacement.predicate()) continue;
+ const executePatch = traceFunction(`patch by ${patch.plugin}`, (match: string | RegExp, replace: string) => code.replace(match, replace));
+ const previousMod = mod;
+ const previousCode = code;
- const lastMod = mod;
- const lastCode = code;
+ // We change all patch.replacement to array in plugins/index
+ for (const replacement of patch.replacement as PatchReplacement[]) {
+ if (replacement.predicate && !replacement.predicate()) continue;
- canonicalizeReplacement(replacement, patch.plugin);
+ const lastMod = mod;
+ const lastCode = code;
- 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);
- }
+ 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;
}
- 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);
-
- if (IS_DEV) {
- const changeSize = code.length - lastCode.length;
- const match = lastCode.match(replacement.match)!;
-
- // 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 = [] 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);
- }
-
- 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`);
+ 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;
}
- mod = lastMod;
- code = lastCode;
+ continue;
}
+
+ 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);
+
+ if (IS_DEV) {
+ const changeSize = code.length - lastCode.length;
+ const match = lastCode.match(replacement.match)!;
+
+ // 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 = [] 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);
+ }
+
+ 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;
+ code = previousCode;
+ break;
+ }
+
+ mod = lastMod;
+ code = lastCode;
+ }
+ }
+
+ if (!patch.all) patches.splice(i--, 1);
+ }
+
+ function patchedFactory(module: any, exports: any, require: WebpackInstance) {
+ if (wreq == null && IS_DEV) {
+ if (!webpackNotInitializedLogged) {
+ webpackNotInitializedLogged = true;
+ logger.error("WebpackRequire was not initialized, running modules without patches instead.");
}
- if (!patch.all) patches.splice(i--, 1);
+ return void originalMod(module, exports, require);
+ }
+
+ try {
+ mod(module, exports, require);
+ } catch (err) {
+ // Just rethrow Discord errors
+ if (mod === originalMod) throw err;
+
+ logger.error("Error in patched module", err);
+ return void originalMod(module, exports, require);
+ }
+
+ // Webpack sometimes sets the value of module.exports directly, so assign exports to it to make sure we properly handle it
+ exports = module.exports;
+ if (exports == null) return;
+
+ // There are (at the time of writing) 11 modules exporting the window
+ // Make these non enumerable to improve webpack search performance
+ if (exports === window && require.c) {
+ Object.defineProperty(require.c, id, {
+ value: require.c[id],
+ configurable: true,
+ writable: true,
+ enumerable: false
+ });
+ 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 (filter(exports)) {
+ subscriptions.delete(filter);
+ callback(exports, id);
+ } else if (exports.default && filter(exports.default)) {
+ subscriptions.delete(filter);
+ callback(exports.default, id);
+ }
+ } catch (err) {
+ logger.error("Error while firing callback for Webpack subscription:\n", err, filter, callback);
+ }
}
}
+
+ patchedFactory.toString = originalMod.toString.bind(originalMod);
+ // @ts-ignore
+ patchedFactory.$$vencordOriginal = originalMod;
+
+ return patchedFactory;
}