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