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