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