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, patchId, 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 let shouldCache = true;
197 if (!swappedModule) shouldCache = patchModule(id, patchedStr.join(", "), moduleString, entry);
198 if (shouldCache) moduleCache[id] = moduleString;
199 moonlight.patched.set(id, exts);
200 }
201
202 try {
203 const parsed = moonlight.lunast.parseScript(id, moduleString);
204 if (parsed != null) {
205 for (const [parsedId, parsedScript] of Object.entries(parsed)) {
206 if (patchModule(parsedId, "lunast", parsedScript, entry)) {
207 moduleCache[parsedId] = parsedScript;
208 }
209 }
210 }
211 } catch (e) {
212 logger.error("Failed to parse script for LunAST", id, e);
213 }
214
215 if (moonlightNode.config.patchAll === true) {
216 if ((typeof id !== "string" || !id.includes("_")) && !entry[id].__moonlight) {
217 const wrapped = `(${moduleCache[id]}).apply(this, arguments)\n` + createSourceURL(id);
218 entry[id] = new Function("module", "exports", "require", wrapped) as WebpackModuleFunc;
219 entry[id].__moonlight = true;
220 }
221 }
222
223 // Dispatch module load event subscription
224 if (moduleLoadSubscriptions.has(id)) {
225 const loadCallbacks = moduleLoadSubscriptions.get(id)!;
226 for (const callback of loadCallbacks) {
227 try {
228 callback(id);
229 } catch (e) {
230 logger.error("Error in module load subscription: " + e);
231 }
232 }
233 moduleLoadSubscriptions.delete(id);
234 }
235
236 moduleCache[id] = moduleString;
237 }
238}
239
240/*
241 Similar to patching, we also want to inject our own custom Webpack modules
242 into Discord's Webpack instance. We abuse pollution on the push function to
243 mark when we've completed it already.
244*/
245let chunkId = Number.MAX_SAFE_INTEGER;
246
247function depToString(x: ExplicitExtensionDependency) {
248 return x.ext != null ? `${x.ext}_${x.id}` : x.id;
249}
250
251function handleModuleDependencies() {
252 const modules = Array.from(webpackModules.values());
253
254 const dependencies: Dependency<string, IdentifiedWebpackModule>[] = modules.map((wp) => {
255 return {
256 id: depToString(wp),
257 data: wp
258 };
259 });
260
261 const [sorted, _] = calculateDependencies(dependencies, {
262 fetchDep: (id) => {
263 return modules.find((x) => id === depToString(x)) ?? null;
264 },
265
266 getDeps: (item) => {
267 const deps = item.data?.dependencies ?? [];
268 return (
269 deps.filter(
270 (dep) => !(dep instanceof RegExp || typeof dep === "string") && dep.ext != null
271 ) as ExplicitExtensionDependency[]
272 ).map(depToString);
273 }
274 });
275
276 webpackModules = new Set(sorted.map((x) => x.data));
277}
278
279const injectedWpModules: IdentifiedWebpackModule[] = [];
280function injectModules(entry: WebpackJsonpEntry[1]) {
281 const modules: Record<string, WebpackModuleFunc> = {};
282 const entrypoints: string[] = [];
283 let inject = false;
284
285 for (const [_modId, mod] of Object.entries(entry)) {
286 const modStr = mod.toString();
287 for (const wpModule of webpackModules) {
288 const id = depToString(wpModule);
289 if (wpModule.dependencies) {
290 const deps = new Set(wpModule.dependencies);
291
292 // FIXME: This dependency resolution might fail if the things we want
293 // got injected earlier. If weird dependencies fail, this is likely why.
294 if (deps.size) {
295 for (const dep of deps) {
296 if (typeof dep === "string") {
297 if (modStr.includes(dep)) deps.delete(dep);
298 } else if (dep instanceof RegExp) {
299 if (dep.test(modStr)) deps.delete(dep);
300 } else if (
301 dep.ext != null
302 ? injectedWpModules.find((x) => x.ext === dep.ext && x.id === dep.id)
303 : injectedWpModules.find((x) => x.id === dep.id)
304 ) {
305 deps.delete(dep);
306 }
307 }
308
309 wpModule.dependencies = Array.from(deps);
310 if (deps.size !== 0) {
311 continue;
312 }
313 }
314 }
315
316 webpackModules.delete(wpModule);
317 moonlight.pendingModules.delete(wpModule);
318 injectedWpModules.push(wpModule);
319
320 inject = true;
321
322 if (wpModule.run) {
323 modules[id] = wpModule.run;
324 wpModule.run.__moonlight = true;
325 // @ts-expect-error hacks
326 wpModule.run.call = function (self, module, exports, require) {
327 try {
328 wpModule.run!.apply(self, [module, exports, require]);
329 } catch (err) {
330 logger.error(`Failed to run module "${id}":`, err);
331 }
332 };
333 if (wpModule.entrypoint) entrypoints.push(id);
334 }
335 }
336 if (!webpackModules.size) break;
337 }
338
339 for (const [name, func] of Object.entries(moonlight.moonmap.getWebpackModules("window.moonlight.moonmap"))) {
340 // @ts-expect-error probably should fix the type on this idk
341 func.__moonlight = true;
342 injectedWpModules.push({ id: name, run: func });
343 modules[name] = func;
344 inject = true;
345 }
346
347 if (webpackRequire != null) {
348 for (const id of moonlight.moonmap.getLazyModules()) {
349 webpackRequire.e(id);
350 }
351 }
352
353 if (inject) {
354 logger.debug("Injecting modules:", modules, entrypoints);
355 window.webpackChunkdiscord_app.push([
356 [--chunkId],
357 modules,
358 (require: WebpackRequireType) =>
359 entrypoints.map((id) => {
360 try {
361 if (require.m[id] == null) {
362 logger.error(`Failing to load entrypoint module "${id}" because it's not found in Webpack.`);
363 } else {
364 require(id);
365 }
366 } catch (err) {
367 logger.error(`Failed to load entrypoint module "${id}":`, err);
368 }
369 })
370 ]);
371 }
372}
373
374declare global {
375 interface Window {
376 webpackChunkdiscord_app: WebpackJsonp;
377 }
378}
379
380function moduleSourceGetter(id: string) {
381 return moduleCache[id] ?? null;
382}
383
384/*
385 Webpack modules are bundled into an array of arrays that hold each function.
386 Since we run code before Discord, we can create our own Webpack array and
387 hijack the .push function on it.
388
389 From there, we iterate over the object (mapping IDs to functions) and patch
390 them accordingly.
391*/
392export async function installWebpackPatcher() {
393 await handleModuleDependencies();
394
395 moonlight.lunast.setModuleSourceGetter(moduleSourceGetter);
396 moonlight.moonmap.setModuleSourceGetter(moduleSourceGetter);
397
398 const wpRequireFetcher: WebpackModuleFunc = (module, exports, require) => {
399 webpackRequire = require;
400 };
401 wpRequireFetcher.__moonlight = true;
402 webpackModules.add({
403 id: "moonlight",
404 entrypoint: true,
405 run: wpRequireFetcher
406 });
407
408 let realWebpackJsonp: WebpackJsonp | null = null;
409 Object.defineProperty(window, "webpackChunkdiscord_app", {
410 set: (jsonp: WebpackJsonp) => {
411 // Don't let Sentry mess with Webpack
412 const stack = new Error().stack!;
413 if (stack.includes("sentry.")) return;
414
415 realWebpackJsonp = jsonp;
416 const realPush = jsonp.push;
417 if (jsonp.push.__moonlight !== true) {
418 jsonp.push = (items) => {
419 moonlight.events.dispatchEvent(WebEventType.ChunkLoad, {
420 chunkId: items[0],
421 modules: items[1],
422 require: items[2]
423 });
424
425 patchModules(items[1]);
426
427 try {
428 const res = realPush.apply(realWebpackJsonp, [items]);
429 if (!realPush.__moonlight) {
430 logger.trace("Injecting Webpack modules", items[1]);
431 injectModules(items[1]);
432 }
433
434 return res;
435 } catch (err) {
436 logger.error("Failed to inject Webpack modules:", err);
437 return 0;
438 }
439 };
440
441 jsonp.push.bind = (thisArg: any, ...args: any[]) => {
442 return realPush.bind(thisArg, ...args);
443 };
444
445 jsonp.push.__moonlight = true;
446 if (!realPush.__moonlight) {
447 logger.debug("Injecting Webpack modules with empty entry");
448 // Inject an empty entry to cause iteration to happen once
449 // Kind of a dirty hack but /shrug
450 injectModules({ deez: () => {} });
451 }
452 }
453 },
454
455 get: () => {
456 const stack = new Error().stack!;
457 if (stack.includes("sentry.")) return [];
458 return realWebpackJsonp;
459 }
460 });
461
462 Object.defineProperty(Function.prototype, "m", {
463 configurable: true,
464 set(modules: any) {
465 const { stack } = new Error();
466 if (stack!.includes("/assets/") && !Array.isArray(modules)) {
467 moonlight.events.dispatchEvent(WebEventType.ChunkLoad, {
468 modules: modules
469 });
470 patchModules(modules);
471
472 if (!window.webpackChunkdiscord_app) window.webpackChunkdiscord_app = [];
473 injectModules(modules);
474 }
475
476 Object.defineProperty(this, "m", {
477 value: modules,
478 configurable: true,
479 enumerable: true,
480 writable: true
481 });
482 }
483 });
484}