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