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