473 lines
15 kB
1
import {
2
PatchReplace,
3
PatchReplaceType,
4
ExplicitExtensionDependency,
5
IdentifiedPatch,
6
IdentifiedWebpackModule,
7
WebpackJsonp,
8
WebpackJsonpEntry,
9
WebpackModuleFunc,
10
WebpackRequireType
11
} from "@moonlight-mod/types";
12
import Logger from "./util/logger";
13
import calculateDependencies, { Dependency } from "./util/dependency";
14
import { WebEventType } from "@moonlight-mod/types/core/event";
15
import { processFind, processReplace, testFind } from "./util/patch";
16
17
const logger = new Logger("core/patch");
18
19
// Can't be Set because we need splice
20
const patches: IdentifiedPatch[] = [];
21
let webpackModules: Set<IdentifiedWebpackModule> = new Set();
22
let webpackRequire: WebpackRequireType | null = null;
23
24
const moduleLoadSubscriptions: Map<string, ((moduleId: string) => void)[]> = new Map();
25
26
export 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
34
export function registerWebpackModule(wp: IdentifiedWebpackModule) {
35
webpackModules.add(wp);
36
if (wp.dependencies?.length) {
37
moonlight.pendingModules.add(wp);
38
}
39
}
40
41
export 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
*/
66
const moduleCache: Record<string, string> = {};
67
const patched: Record<string, Array<string>> = {};
68
69
function 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
*/
236
let chunkId = Number.MAX_SAFE_INTEGER;
237
238
function depToString(x: ExplicitExtensionDependency) {
239
return x.ext != null ? `${x.ext}_${x.id}` : x.id;
240
}
241
242
function 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
270
const injectedWpModules: IdentifiedWebpackModule[] = [];
271
function 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
363
declare global {
364
interface Window {
365
webpackChunkdiscord_app: WebpackJsonp;
366
}
367
}
368
369
function 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
*/
381
export 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
}
474