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; }