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