haicord/src/webpack/patchWebpack.ts

431 lines
18 KiB
TypeScript
Raw Normal View History

2022-10-22 01:17:06 +02:00
/*
* Vencord, a Discord client mod
2024-05-26 20:38:57 -03:00
* Copyright (c) 2024 Vendicated, Nuckyz, and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
2022-10-22 01:17:06 +02:00
2024-05-23 06:04:21 -03:00
import { Settings } from "@api/Settings";
2023-05-06 01:36:00 +02:00
import { Logger } from "@utils/Logger";
import { interpolateIfDefined } from "@utils/misc";
2024-05-24 05:14:27 -03:00
import { canonicalizeReplacement } from "@utils/patches";
import { PatchReplacement } from "@utils/types";
import { traceFunction } from "../debug/Tracer";
import { patches } from "../plugins";
import { _initWebpack, AnyModuleFactory, AnyWebpackRequire, factoryListeners, moduleListeners, PatchedModuleFactories, PatchedModuleFactory, waitForSubscriptions, WebpackRequire, wreq } from ".";
const logger = new Logger("WebpackInterceptor", "#8caaee");
2022-08-29 02:25:27 +02:00
2024-05-28 17:11:17 -03:00
/** A set with all the Webpack instances */
export const allWebpackInstances = new Set<AnyWebpackRequire>();
2024-05-26 20:22:03 -03:00
/** Whether we tried to fallback to factory WebpackRequire, or disabled patches */
let wreqFallbackApplied = false;
2024-05-28 17:11:17 -03:00
type Define = typeof Reflect.defineProperty;
const define: Define = (target, p, attributes) => {
if (Object.hasOwn(attributes, "value")) {
attributes.writable = true;
}
return Reflect.defineProperty(target, p, {
configurable: true,
enumerable: true,
...attributes
});
};
2024-06-01 01:40:33 -03:00
// wreq.O is the Webpack onChunksLoaded function.
// It is pretty likely that all important Discord Webpack instances will have this property set,
// because Discord bundled code is chunked.
// As of the time of writing, only the main and sentry Webpack instances have this property, and they are the only ones we care about.
2024-06-01 05:03:47 -03:00
// We use this setter to intercept when wreq.O is defined, so we can patch the modules factories (wreq.m).
2024-05-26 20:22:03 -03:00
// wreq.m is pre-populated with module factories, and is also populated via webpackGlobal.push
2024-06-01 05:03:47 -03:00
// The sentry module also has their own Webpack with a pre-populated wreq.m, so this also patches those.
2024-06-01 01:40:33 -03:00
// 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, "O", {
2024-05-28 17:11:17 -03:00
enumerable: false,
2024-05-26 20:22:03 -03:00
2024-06-02 18:46:03 -03:00
set(this: AnyWebpackRequire, onChunksLoaded: AnyWebpackRequire["O"]) {
2024-06-01 01:40:33 -03:00
define(this, "O", { value: onChunksLoaded });
2024-05-26 20:22:03 -03:00
const { stack } = new Error();
2024-06-01 01:44:18 -03:00
if (this.m == null || !(stack?.includes("discord.com") || stack?.includes("discordapp.com"))) {
2024-05-28 17:11:17 -03:00
return;
}
2024-05-23 06:04:21 -03:00
2024-05-28 17:11:17 -03:00
const fileName = stack?.match(/\/assets\/(.+?\.js)/)?.[1];
logger.info("Found Webpack module factories" + interpolateIfDefined` in ${fileName}`);
2024-05-26 20:22:03 -03:00
2024-05-28 17:11:17 -03:00
allWebpackInstances.add(this);
2024-05-26 20:22:03 -03:00
2024-05-28 17:11:17 -03:00
// Define a setter for the bundlePath property of WebpackRequire. Only the main Webpack has this property.
// So if the setter is called, this means we can initialize the internal references to WebpackRequire.
define(this, "p", {
enumerable: false,
set(this: WebpackRequire, bundlePath: WebpackRequire["p"]) {
2024-05-31 06:16:01 -03:00
define(this, "p", { value: bundlePath });
2024-05-31 06:19:57 -03:00
clearTimeout(setterTimeout);
2024-05-31 06:16:01 -03:00
2024-05-28 17:11:17 -03:00
logger.info("Main Webpack found" + interpolateIfDefined` in ${fileName}` + ", initializing internal references to WebpackRequire");
_initWebpack(this);
}
});
// setImmediate to clear this property setter if this is not the main Webpack.
2024-06-01 05:03:47 -03:00
// If this is the main Webpack, wreq.p will always be set before the timeout runs.
2024-05-28 17:11:17 -03:00
const setterTimeout = setTimeout(() => Reflect.deleteProperty(this, "p"), 0);
2024-06-01 01:40:33 -03:00
// Patch the pre-populated factories
for (const id in this.m) {
2024-06-01 19:52:17 -03:00
if (updateExistingFactory(this.m, id, this.m[id], true)) {
continue;
}
notifyFactoryListeners(this.m[id]);
2024-06-01 01:40:33 -03:00
defineModulesFactoryGetter(id, Settings.eagerPatches ? patchFactory(id, this.m[id]) : this.m[id]);
}
define(this.m, Symbol.toStringTag, {
2024-05-28 17:11:17 -03:00
value: "ModuleFactories",
enumerable: false
2024-05-26 20:22:03 -03:00
});
2024-05-28 17:11:17 -03:00
// The proxy responsible for patching the module factories when they are set, or definining getters for the patched versions
2024-06-01 01:40:33 -03:00
const proxiedModuleFactories = new Proxy(this.m, moduleFactoriesHandler);
2024-05-28 17:11:17 -03:00
/*
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
2024-06-02 18:46:03 -03:00
define(this, "m", { value: Reflect.setPrototypeOf(this.m, new Proxy(this.m, moduleFactoriesHandler)) });
2024-05-28 17:11:17 -03:00
*/
2024-05-28 17:14:35 -03:00
define(this, "m", { value: proxiedModuleFactories });
2024-05-26 20:22:03 -03:00
}
});
2024-06-12 17:33:26 -03:00
const moduleFactoriesHandler: ProxyHandler<PatchedModuleFactories> = {
/*
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 });
}
if (updateExistingFactory(target, p, newValue)) {
return true;
}
notifyFactoryListeners(newValue);
defineModulesFactoryGetter(p, Settings.eagerPatches ? patchFactory(p, newValue) : newValue);
return true;
}
};
2024-05-26 20:22:03 -03:00
/**
* Define the getter for returning the patched version of the module factory.
2024-05-26 20:22:03 -03:00
*
* 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.
2024-05-26 20:22:03 -03:00
*
* @param id The id of the module
* @param factory The original or patched module factory
*/
2024-05-26 00:27:13 -03:00
function defineModulesFactoryGetter(id: PropertyKey, factory: PatchedModuleFactory) {
2024-05-26 20:22:03 -03:00
// 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
2024-05-28 17:11:17 -03:00
for (const wreq of allWebpackInstances) {
define(wreq.m, id, {
2024-05-26 00:27:13 -03:00
get() {
// $$vencordOriginal means the factory is already patched
if (factory.$$vencordOriginal != null) {
return factory;
}
2024-05-23 06:04:21 -03:00
2024-05-26 00:27:13 -03:00
return (factory = patchFactory(id, factory));
},
2024-06-02 18:46:03 -03:00
set(v: AnyModuleFactory) {
2024-05-26 00:27:13 -03:00
if (factory.$$vencordOriginal != null) {
factory.$$vencordOriginal = v;
} else {
factory = v;
}
}
2024-05-26 00:27:13 -03:00
});
}
}
/**
* 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
2024-06-01 19:52:17 -03:00
* @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
*/
2024-06-02 18:46:03 -03:00
function updateExistingFactory(moduleFactoriesTarget: AnyWebpackRequire["m"], id: PropertyKey, newFactory: AnyModuleFactory, ignoreExistingInTarget: boolean = false) {
let existingFactory: TypedPropertyDescriptor<PatchedModuleFactory> | undefined;
for (const wreq of allWebpackInstances) {
2024-06-01 19:52:17 -03:00
if (ignoreExistingInTarget && wreq.m === moduleFactoriesTarget) continue;
if (Reflect.getOwnPropertyDescriptor(wreq.m, id) != null) {
existingFactory = Reflect.getOwnPropertyDescriptor(wreq.m, id);
break;
}
}
if (existingFactory != null) {
// If existingFactory exists in any Webpack instance, its either wrapped in defineModuleFactoryGetter, or it has already been required.
// So define the descriptor of it on this current Webpack instance, call Reflect.set with the new original,
// and let the correct logic apply (normal set, or defineModuleFactoryGetter setter)
2024-06-01 19:52:17 -03:00
Reflect.defineProperty(moduleFactoriesTarget, id, existingFactory);
return Reflect.set(moduleFactoriesTarget, id, newFactory, moduleFactoriesTarget);
}
return false;
}
/**
2024-06-12 17:31:05 -03:00
* 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);
}
}
}
2024-05-26 20:22:03 -03:00
/**
* Patches a module factory.
*
* The factory argument will become the patched version of the factory.
* @param id The id of the module
* @param factory The original or patched module factory
* @returns The wrapper for the patched module factory
*/
2024-06-02 18:46:03 -03:00
function patchFactory(id: PropertyKey, factory: AnyModuleFactory) {
2024-05-26 00:27:13 -03:00
const originalFactory = factory;
2024-05-22 06:08:28 -03:00
const patchedBy = new Set<string>();
// 0, prefix to turn it into an expression: 0,function(){} would be invalid syntax without the 0,
let code: string = "0," + String(factory);
2024-05-19 22:49:58 -03:00
for (let i = 0; i < patches.length; i++) {
const patch = patches[i];
if (patch.predicate && !patch.predicate()) continue;
2024-05-19 22:49:58 -03:00
const moduleMatches = typeof patch.find === "string"
2024-05-31 17:34:32 -03:00
? code.includes(patch.find)
: (patch.find.global && (patch.find.lastIndex = 0), patch.find.test(code));
2024-05-19 22:49:58 -03:00
if (!moduleMatches) continue;
2024-05-19 22:49:58 -03:00
patchedBy.add(patch.plugin);
2024-05-19 22:49:58 -03:00
const executePatch = traceFunction(`patch by ${patch.plugin}`, (match: string | RegExp, replace: string) => code.replace(match, replace));
const previousCode = code;
2024-05-26 00:27:13 -03:00
const previousFactory = factory;
2024-05-19 22:49:58 -03:00
// We change all patch.replacement to array in plugins/index
for (const replacement of patch.replacement as PatchReplacement[]) {
if (replacement.predicate && !replacement.predicate()) continue;
2024-05-19 22:49:58 -03:00
const lastCode = code;
2024-05-26 00:27:13 -03:00
const lastFactory = factory;
2024-05-19 22:49:58 -03:00
canonicalizeReplacement(replacement, patch.plugin);
2024-05-19 22:49:58 -03:00
try {
const newCode = executePatch(replacement.match, replacement.replace as string);
if (newCode === code) {
if (!patch.noWarn) {
2024-05-23 03:36:53 -03:00
logger.warn(`Patch by ${patch.plugin} had no effect (Module id is ${String(id)}): ${replacement.match}`);
2024-05-19 22:49:58 -03:00
if (IS_DEV) {
logger.debug("Function Source:\n", code);
}
}
2024-05-19 22:49:58 -03:00
if (patch.group) {
logger.warn(`Undoing patch group ${patch.find} by ${patch.plugin} because replacement ${replacement.match} had no effect`);
code = previousCode;
2024-05-26 00:27:13 -03:00
factory = previousFactory;
2024-05-19 22:49:58 -03:00
patchedBy.delete(patch.plugin);
break;
}
2024-05-19 22:49:58 -03:00
continue;
}
2024-05-19 22:49:58 -03:00
code = newCode;
2024-05-23 06:04:21 -03:00
factory = (0, eval)(`// Webpack Module ${String(id)} - Patched by ${[...patchedBy].join(", ")}\n${newCode}\n//# sourceURL=WebpackModule${String(id)}`);
2024-05-19 22:49:58 -03:00
} catch (err) {
2024-05-23 03:36:53 -03:00
logger.error(`Patch by ${patch.plugin} errored (Module id is ${String(id)}): ${replacement.match}\n`, err);
2024-05-19 22:49:58 -03:00
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);
}
2024-05-19 22:49:58 -03:00
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);
}
2024-05-19 22:49:58 -03:00
patchedBy.delete(patch.plugin);
2024-05-19 22:49:58 -03:00
if (patch.group) {
logger.warn(`Undoing patch group ${patch.find} by ${patch.plugin} because replacement ${replacement.match} errored`);
code = previousCode;
2024-05-26 00:27:13 -03:00
factory = previousFactory;
2024-05-19 22:49:58 -03:00
break;
}
code = lastCode;
2024-05-26 00:27:13 -03:00
factory = lastFactory;
}
}
2024-05-19 22:49:58 -03:00
if (!patch.all) patches.splice(i--, 1);
}
2024-05-28 03:57:56 -03:00
// The patched factory wrapper, define it in an object to preserve the name after minification
const patchedFactory: PatchedModuleFactory = {
2024-06-02 18:46:03 -03:00
PatchedFactory(...args: Parameters<AnyModuleFactory>) {
2024-05-28 03:57:56 -03:00
// Restore the original factory in all the module factories objects,
// because we want to make sure the original factory is restored properly, no matter what is the Webpack instance
2024-05-28 17:11:17 -03:00
for (const wreq of allWebpackInstances) {
define(wreq.m, id, { value: patchedFactory.$$vencordOriginal });
2024-05-28 03:57:56 -03:00
}
2024-05-23 06:04:21 -03:00
2024-05-28 03:57:56 -03:00
// 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}` +
")"
);
2024-06-02 18:46:03 -03:00
_initWebpack(require as WebpackRequire);
2024-05-28 03:57:56 -03:00
} else if (IS_DEV) {
logger.error("WebpackRequire was not initialized, running modules without patches instead.");
}
}
2024-05-28 03:57:56 -03:00
if (IS_DEV) {
return originalFactory.apply(this, args);
}
}
2024-05-28 03:57:56 -03:00
let factoryReturn: unknown;
2024-05-19 22:49:58 -03:00
try {
2024-05-28 03:57:56 -03:00
// Call the patched factory
factoryReturn = factory.apply(this, args);
2024-05-19 22:49:58 -03:00
} catch (err) {
2024-05-28 03:57:56 -03:00
// Just re-throw Discord errors
if (factory === originalFactory) throw err;
2024-05-28 03:57:56 -03:00
logger.error("Error in patched module factory", err);
return originalFactory.apply(this, args);
2024-05-19 22:49:58 -03:00
}
2024-05-28 03:57:56 -03:00
// 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 factoryReturn;
// There are (at the time of writing) 11 modules exporting the window
// Make these non enumerable to improve webpack search performance
if ((exports === window || exports?.default === window) && typeof require === "function" && require.c != null) {
2024-05-28 17:11:17 -03:00
define(require.c, id, {
2024-05-28 03:57:56 -03:00
value: require.c[id],
enumerable: false
});
return factoryReturn;
}
2024-05-28 03:57:56 -03:00
for (const callback of moduleListeners) {
try {
2024-05-19 22:49:58 -03:00
callback(exports, id);
2024-05-28 03:57:56 -03:00
} catch (err) {
logger.error("Error in Webpack module listener:\n", err, callback);
}
2024-05-19 22:49:58 -03:00
}
for (const [filter, callback] of waitForSubscriptions) {
2024-05-28 03:57:56 -03:00
try {
if (filter(exports)) {
waitForSubscriptions.delete(filter);
callback(exports);
2024-05-28 03:57:56 -03:00
} else if (exports.default && filter(exports.default)) {
waitForSubscriptions.delete(filter);
callback(exports.default);
2024-05-28 03:57:56 -03:00
}
} catch (err) {
logger.error("Error while firing callback for Webpack waitFor subscription:\n", err, filter, callback);
}
}
2024-05-26 00:27:13 -03:00
2024-05-28 03:57:56 -03:00
return factoryReturn;
}
}.PatchedFactory;
2024-05-19 22:49:58 -03:00
2024-05-23 06:04:21 -03:00
patchedFactory.toString = originalFactory.toString.bind(originalFactory);
patchedFactory.$$vencordOriginal = originalFactory;
2024-05-19 22:49:58 -03:00
return patchedFactory;
}