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