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