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