this repo has no description
1import {
2 PatchReplace,
3 PatchReplaceType,
4 ExplicitExtensionDependency,
5 IdentifiedPatch,
6 IdentifiedWebpackModule,
7 WebpackJsonp,
8 WebpackJsonpEntry,
9 WebpackModuleFunc
10} from "@moonlight-mod/types";
11import Logger from "./util/logger";
12import calculateDependencies, { Dependency } from "./util/dependency";
13import WebpackRequire from "@moonlight-mod/types/discord/require";
14
15const logger = new Logger("core/patch");
16
17// Can't be Set because we need splice
18const patches: IdentifiedPatch[] = [];
19let webpackModules: Set<IdentifiedWebpackModule> = new Set();
20
21export function registerPatch(patch: IdentifiedPatch) {
22 patches.push(patch);
23 moonlight.unpatched.add(patch);
24}
25
26export function registerWebpackModule(wp: IdentifiedWebpackModule) {
27 webpackModules.add(wp);
28 if (wp.dependencies?.length) {
29 moonlight.pendingModules.add(wp);
30 }
31}
32
33/*
34 The patching system functions by matching a string or regex against the
35 .toString()'d copy of a Webpack module. When a patch happens, we reconstruct
36 the module with the patched source and replace it, wrapping it in the process.
37
38 We keep track of what modules we've patched (and their original sources), both
39 so we don't wrap them twice and so we can debug what extensions are patching
40 what Webpack modules.
41*/
42const moduleCache: Record<string, string> = {};
43const patched: Record<string, Array<string>> = {};
44
45function patchModules(entry: WebpackJsonpEntry[1]) {
46 for (const [id, func] of Object.entries(entry)) {
47 let moduleString = Object.prototype.hasOwnProperty.call(moduleCache, id)
48 ? moduleCache[id]
49 : func.toString().replace(/\n/g, "");
50
51 for (let i = 0; i < patches.length; i++) {
52 const patch = patches[i];
53 if (patch.prerequisite != null && !patch.prerequisite()) {
54 continue;
55 }
56
57 if (patch.find instanceof RegExp && patch.find.global) {
58 // Reset state because global regexes are stateful for some reason
59 patch.find.lastIndex = 0;
60 }
61
62 // indexOf is faster than includes by 0.25% lmao
63 const match =
64 typeof patch.find === "string"
65 ? moduleString.indexOf(patch.find) !== -1
66 : patch.find.test(moduleString);
67
68 // Global regexes apply to all modules
69 const shouldRemove =
70 typeof patch.find === "string" ? true : !patch.find.global;
71
72 if (match) {
73 moonlight.unpatched.delete(patch);
74
75 // We ensured all arrays get turned into normal PatchReplace objects on register
76 const replace = patch.replace as PatchReplace;
77
78 if (
79 replace.type === undefined ||
80 replace.type === PatchReplaceType.Normal
81 ) {
82 // Add support for \i to match rspack's minified names
83 if (typeof replace.match !== "string") {
84 replace.match = new RegExp(
85 replace.match.source.replace(/\\i/g, "[A-Za-z_$][\\w$]*"),
86 replace.match.flags
87 );
88 }
89 // tsc fails to detect the overloads for this, so I'll just do this
90 // Verbose, but it works
91 let replaced;
92 if (typeof replace.replacement === "string") {
93 replaced = moduleString.replace(replace.match, replace.replacement);
94 } else {
95 replaced = moduleString.replace(replace.match, replace.replacement);
96 }
97
98 if (replaced === moduleString) {
99 logger.warn("Patch replacement failed", id, patch);
100 continue;
101 }
102
103 // Store what extensions patched what modules for easier debugging
104 patched[id] = patched[id] || [];
105 patched[id].push(`${patch.ext}#${patch.id}`);
106
107 // Webpack module arguments are minified, so we replace them with consistent names
108 // We have to wrap it so things don't break, though
109 const patchedStr = patched[id].sort().join(", ");
110
111 const wrapped =
112 `(${replaced}).apply(this, arguments)\n` +
113 `// Patched by moonlight: ${patchedStr}\n` +
114 `//# sourceURL=Webpack-Module-${id}`;
115
116 try {
117 const func = new Function(
118 "module",
119 "exports",
120 "require",
121 wrapped
122 ) as WebpackModuleFunc;
123 entry[id] = func;
124 entry[id].__moonlight = true;
125 moduleString = replaced;
126 } catch (e) {
127 logger.warn("Error constructing function for patch", patch, e);
128 patched[id].pop();
129 }
130 } else if (replace.type === PatchReplaceType.Module) {
131 // Directly replace the module with a new one
132 const newModule = replace.replacement(moduleString);
133 entry[id] = newModule;
134 entry[id].__moonlight = true;
135 moduleString =
136 newModule.toString().replace(/\n/g, "") +
137 `//# sourceURL=Webpack-Module-${id}`;
138 }
139
140 if (shouldRemove) {
141 patches.splice(i--, 1);
142 }
143 }
144 }
145
146 if (moonlightNode.config.patchAll === true) {
147 if (
148 (typeof id !== "string" || !id.includes("_")) &&
149 !entry[id].__moonlight
150 ) {
151 const wrapped =
152 `(${moduleString}).apply(this, arguments)\n` +
153 `//# sourceURL=Webpack-Module-${id}`;
154 entry[id] = new Function(
155 "module",
156 "exports",
157 "require",
158 wrapped
159 ) as WebpackModuleFunc;
160 entry[id].__moonlight = true;
161 }
162 }
163
164 moduleCache[id] = moduleString;
165 }
166}
167
168/*
169 Similar to patching, we also want to inject our own custom Webpack modules
170 into Discord's Webpack instance. We abuse pollution on the push function to
171 mark when we've completed it already.
172*/
173let chunkId = Number.MAX_SAFE_INTEGER;
174
175function handleModuleDependencies() {
176 const modules = Array.from(webpackModules.values());
177
178 const dependencies: Dependency<string, IdentifiedWebpackModule>[] =
179 modules.map((wp) => {
180 return {
181 id: `${wp.ext}_${wp.id}`,
182 data: wp
183 };
184 });
185
186 const [sorted, _] = calculateDependencies(dependencies, {
187 fetchDep: (id) => {
188 return modules.find((x) => id === `${x.ext}_${x.id}`) ?? null;
189 },
190
191 getDeps: (item) => {
192 const deps = item.data?.dependencies ?? [];
193 return (
194 deps.filter(
195 (dep) => !(dep instanceof RegExp || typeof dep === "string")
196 ) as ExplicitExtensionDependency[]
197 ).map((x) => `${x.ext}_${x.id}`);
198 }
199 });
200
201 webpackModules = new Set(sorted.map((x) => x.data));
202}
203
204const injectedWpModules: IdentifiedWebpackModule[] = [];
205function injectModules(entry: WebpackJsonpEntry[1]) {
206 const modules: Record<string, WebpackModuleFunc> = {};
207 const entrypoints: string[] = [];
208 let inject = false;
209
210 for (const [_modId, mod] of Object.entries(entry)) {
211 const modStr = mod.toString();
212 for (const wpModule of webpackModules) {
213 const id = wpModule.ext + "_" + wpModule.id;
214 if (wpModule.dependencies) {
215 const deps = new Set(wpModule.dependencies);
216
217 // FIXME: This dependency resolution might fail if the things we want
218 // got injected earlier. If weird dependencies fail, this is likely why.
219 if (deps.size) {
220 for (const dep of deps) {
221 if (typeof dep === "string") {
222 if (modStr.includes(dep)) deps.delete(dep);
223 } else if (dep instanceof RegExp) {
224 if (dep.test(modStr)) deps.delete(dep);
225 } else if (
226 injectedWpModules.find(
227 (x) => x.ext === dep.ext && x.id === dep.id
228 )
229 ) {
230 deps.delete(dep);
231 }
232 }
233
234 if (deps.size !== 0) {
235 wpModule.dependencies = Array.from(deps);
236 continue;
237 }
238
239 wpModule.dependencies = Array.from(deps);
240 }
241 }
242
243 webpackModules.delete(wpModule);
244 moonlight.pendingModules.delete(wpModule);
245 injectedWpModules.push(wpModule);
246
247 inject = true;
248
249 if (wpModule.run) {
250 modules[id] = wpModule.run;
251 wpModule.run.__moonlight = true;
252 }
253 if (wpModule.entrypoint) entrypoints.push(id);
254 }
255 if (!webpackModules.size) break;
256 }
257
258 if (inject) {
259 logger.debug("Injecting modules:", modules, entrypoints);
260 window.webpackChunkdiscord_app.push([
261 [--chunkId],
262 modules,
263 (require: typeof WebpackRequire) => entrypoints.map(require)
264 ]);
265 }
266}
267
268declare global {
269 interface Window {
270 webpackChunkdiscord_app: WebpackJsonp;
271 }
272}
273
274/*
275 Webpack modules are bundled into an array of arrays that hold each function.
276 Since we run code before Discord, we can create our own Webpack array and
277 hijack the .push function on it.
278
279 From there, we iterate over the object (mapping IDs to functions) and patch
280 them accordingly.
281*/
282export async function installWebpackPatcher() {
283 await handleModuleDependencies();
284
285 let realWebpackJsonp: WebpackJsonp | null = null;
286 Object.defineProperty(window, "webpackChunkdiscord_app", {
287 set: (jsonp: WebpackJsonp) => {
288 // Don't let Sentry mess with Webpack
289 const stack = new Error().stack!;
290 if (stack.includes("sentry.")) return;
291
292 realWebpackJsonp = jsonp;
293 const realPush = jsonp.push;
294 if (jsonp.push.__moonlight !== true) {
295 jsonp.push = (items) => {
296 patchModules(items[1]);
297
298 try {
299 const res = realPush.apply(realWebpackJsonp, [items]);
300 if (!realPush.__moonlight) {
301 logger.trace("Injecting Webpack modules", items[1]);
302 injectModules(items[1]);
303 }
304
305 return res;
306 } catch (err) {
307 logger.error("Failed to inject Webpack modules:", err);
308 return 0;
309 }
310 };
311
312 jsonp.push.bind = (thisArg: any, ...args: any[]) => {
313 return realPush.bind(thisArg, ...args);
314 };
315
316 jsonp.push.__moonlight = true;
317 if (!realPush.__moonlight) {
318 logger.debug("Injecting Webpack modules with empty entry");
319 // Inject an empty entry to cause iteration to happen once
320 // Kind of a dirty hack but /shrug
321 injectModules({ deez: () => {} });
322 }
323 }
324 },
325
326 get: () => {
327 const stack = new Error().stack!;
328 if (stack.includes("sentry.")) return [];
329 return realWebpackJsonp;
330 }
331 });
332
333 Object.defineProperty(Function.prototype, "m", {
334 configurable: true,
335 set(modules: any) {
336 const { stack } = new Error();
337 if (stack!.includes("/assets/") && !Array.isArray(modules)) {
338 patchModules(modules);
339 if (!window.webpackChunkdiscord_app)
340 window.webpackChunkdiscord_app = [];
341 injectModules(modules);
342 }
343
344 Object.defineProperty(this, "m", {
345 value: modules,
346 configurable: true,
347 enumerable: true,
348 writable: true
349 });
350 }
351 });
352}