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 if (wpModule.entrypoint) entrypoints.push(id);
312 }
313 }
314 if (!webpackModules.size) break;
315 }
316
317 for (const [name, func] of Object.entries(moonlight.moonmap.getWebpackModules("window.moonlight.moonmap"))) {
318 injectedWpModules.push({ id: name, run: func });
319 modules[name] = func;
320 inject = true;
321 }
322
323 if (webpackRequire != null) {
324 for (const id of moonlight.moonmap.getLazyModules()) {
325 webpackRequire.e(id);
326 }
327 }
328
329 if (inject) {
330 logger.debug("Injecting modules:", modules, entrypoints);
331 window.webpackChunkdiscord_app.push([
332 [--chunkId],
333 modules,
334 (require: WebpackRequireType) =>
335 entrypoints.map((id) => {
336 try {
337 if (require.m[id] == null) {
338 logger.error(`Failing to load entrypoint module "${id}" because it's not found in Webpack.`);
339 } else {
340 require(id);
341 }
342 } catch (err) {
343 logger.error(`Failed to load entrypoint module "${id}":`, err);
344 }
345 })
346 ]);
347 }
348}
349
350declare global {
351 interface Window {
352 webpackChunkdiscord_app: WebpackJsonp;
353 }
354}
355
356function moduleSourceGetter(id: string) {
357 return moduleCache[id] ?? null;
358}
359
360/*
361 Webpack modules are bundled into an array of arrays that hold each function.
362 Since we run code before Discord, we can create our own Webpack array and
363 hijack the .push function on it.
364
365 From there, we iterate over the object (mapping IDs to functions) and patch
366 them accordingly.
367*/
368export async function installWebpackPatcher() {
369 await handleModuleDependencies();
370
371 moonlight.lunast.setModuleSourceGetter(moduleSourceGetter);
372 moonlight.moonmap.setModuleSourceGetter(moduleSourceGetter);
373
374 const wpRequireFetcher: WebpackModuleFunc = (module, exports, require) => {
375 webpackRequire = require;
376 };
377 wpRequireFetcher.__moonlight = true;
378 webpackModules.add({
379 id: "moonlight",
380 entrypoint: true,
381 run: wpRequireFetcher
382 });
383
384 let realWebpackJsonp: WebpackJsonp | null = null;
385 Object.defineProperty(window, "webpackChunkdiscord_app", {
386 set: (jsonp: WebpackJsonp) => {
387 // Don't let Sentry mess with Webpack
388 const stack = new Error().stack!;
389 if (stack.includes("sentry.")) return;
390
391 realWebpackJsonp = jsonp;
392 const realPush = jsonp.push;
393 if (jsonp.push.__moonlight !== true) {
394 jsonp.push = (items) => {
395 moonlight.events.dispatchEvent(EventType.ChunkLoad, {
396 chunkId: items[0],
397 modules: items[1],
398 require: items[2]
399 });
400
401 patchModules(items[1]);
402
403 try {
404 const res = realPush.apply(realWebpackJsonp, [items]);
405 if (!realPush.__moonlight) {
406 logger.trace("Injecting Webpack modules", items[1]);
407 injectModules(items[1]);
408 }
409
410 return res;
411 } catch (err) {
412 logger.error("Failed to inject Webpack modules:", err);
413 return 0;
414 }
415 };
416
417 jsonp.push.bind = (thisArg: any, ...args: any[]) => {
418 return realPush.bind(thisArg, ...args);
419 };
420
421 jsonp.push.__moonlight = true;
422 if (!realPush.__moonlight) {
423 logger.debug("Injecting Webpack modules with empty entry");
424 // Inject an empty entry to cause iteration to happen once
425 // Kind of a dirty hack but /shrug
426 injectModules({ deez: () => {} });
427 }
428 }
429 },
430
431 get: () => {
432 const stack = new Error().stack!;
433 if (stack.includes("sentry.")) return [];
434 return realWebpackJsonp;
435 }
436 });
437
438 Object.defineProperty(Function.prototype, "m", {
439 configurable: true,
440 set(modules: any) {
441 const { stack } = new Error();
442 if (stack!.includes("/assets/") && !Array.isArray(modules)) {
443 moonlight.events.dispatchEvent(EventType.ChunkLoad, {
444 modules: modules
445 });
446 patchModules(modules);
447
448 if (!window.webpackChunkdiscord_app) window.webpackChunkdiscord_app = [];
449 injectModules(modules);
450 }
451
452 Object.defineProperty(this, "m", {
453 value: modules,
454 configurable: true,
455 enumerable: true,
456 writable: true
457 });
458 }
459 });
460}