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