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