460 lines
14 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 = 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
*/
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
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
350
declare global {
351
interface Window {
352
webpackChunkdiscord_app: WebpackJsonp;
353
}
354
}
355
356
function 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
*/
368
export 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
}
461