428 lines
13 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 WebpackRequire from "@moonlight-mod/types/discord/require";
15
import { EventType } from "@moonlight-mod/types/core/event";
16
import { processFind, processReplace, testFind } from "./util/patch";
17
18
const logger = new Logger("core/patch");
19
20
// Can't be Set because we need splice
21
const patches: IdentifiedPatch[] = [];
22
let webpackModules: Set<IdentifiedWebpackModule> = new Set();
23
let webpackRequire: WebpackRequireType | null = null;
24
25
const moduleLoadSubscriptions: Map<string, ((moduleId: string) => void)[]> = new Map();
26
27
export function registerPatch(patch: IdentifiedPatch) {
28
patch.find = processFind(patch.find);
29
processReplace(patch.replace);
30
31
patches.push(patch);
32
moonlight.unpatched.add(patch);
33
}
34
35
export function registerWebpackModule(wp: IdentifiedWebpackModule) {
36
webpackModules.add(wp);
37
if (wp.dependencies?.length) {
38
moonlight.pendingModules.add(wp);
39
}
40
}
41
42
export function onModuleLoad(module: string | string[], callback: (moduleId: string) => void): void {
43
let moduleIds = module;
44
45
if (typeof module === "string") {
46
moduleIds = [module];
47
}
48
49
for (const moduleId of moduleIds) {
50
if (moduleLoadSubscriptions.has(moduleId)) {
51
moduleLoadSubscriptions.get(moduleId)?.push(callback);
52
} else {
53
moduleLoadSubscriptions.set(moduleId, [callback]);
54
}
55
}
56
}
57
58
/*
59
The patching system functions by matching a string or regex against the
60
.toString()'d copy of a Webpack module. When a patch happens, we reconstruct
61
the module with the patched source and replace it, wrapping it in the process.
62
63
We keep track of what modules we've patched (and their original sources), both
64
so we don't wrap them twice and so we can debug what extensions are patching
65
what Webpack modules.
66
*/
67
const moduleCache: Record<string, string> = {};
68
const patched: Record<string, Array<string>> = {};
69
70
function patchModules(entry: WebpackJsonpEntry[1]) {
71
function patchModule(id: string, patchId: string, replaced: string) {
72
// Store what extensions patched what modules for easier debugging
73
patched[id] = patched[id] || [];
74
patched[id].push(patchId);
75
76
// Webpack module arguments are minified, so we replace them with consistent names
77
// We have to wrap it so things don't break, though
78
const patchedStr = patched[id].sort().join(", ");
79
80
const wrapped =
81
`(${replaced}).apply(this, arguments)\n` +
82
`// Patched by moonlight: ${patchedStr}\n` +
83
`//# sourceURL=Webpack-Module-${id}`;
84
85
try {
86
const func = new Function("module", "exports", "require", wrapped) as WebpackModuleFunc;
87
entry[id] = func;
88
entry[id].__moonlight = true;
89
return true;
90
} catch (e) {
91
logger.warn("Error constructing function for patch", patchId, e);
92
patched[id].pop();
93
return false;
94
}
95
}
96
97
// Populate the module cache
98
for (const [id, func] of Object.entries(entry)) {
99
if (!Object.hasOwn(moduleCache, id) && func.__moonlight !== true) {
100
moduleCache[id] = func.toString().replace(/\n/g, "");
101
moonlight.moonmap.parseScript(id, moduleCache[id]);
102
}
103
}
104
105
for (const [id, func] of Object.entries(entry)) {
106
if (func.__moonlight === true) continue;
107
let moduleString = moduleCache[id];
108
109
for (let i = 0; i < patches.length; i++) {
110
const patch = patches[i];
111
if (patch.prerequisite != null && !patch.prerequisite()) {
112
continue;
113
}
114
115
if (patch.find instanceof RegExp && patch.find.global) {
116
// Reset state because global regexes are stateful for some reason
117
patch.find.lastIndex = 0;
118
}
119
120
const match = testFind(moduleString, patch.find);
121
122
// Global regexes apply to all modules
123
const shouldRemove = typeof patch.find === "string" ? true : !patch.find.global;
124
125
if (match) {
126
// We ensured all arrays get turned into normal PatchReplace objects on register
127
const replace = patch.replace as PatchReplace;
128
129
if (replace.type === undefined || replace.type === PatchReplaceType.Normal) {
130
// tsc fails to detect the overloads for this, so I'll just do this
131
// Verbose, but it works
132
let replaced;
133
if (typeof replace.replacement === "string") {
134
replaced = moduleString.replace(replace.match, replace.replacement);
135
} else {
136
replaced = moduleString.replace(replace.match, replace.replacement);
137
}
138
139
if (replaced === moduleString) {
140
logger.warn("Patch replacement failed", id, patch);
141
continue;
142
}
143
144
if (patchModule(id, `${patch.ext}#${patch.id}`, replaced)) {
145
moduleString = replaced;
146
}
147
} else if (replace.type === PatchReplaceType.Module) {
148
// Directly replace the module with a new one
149
const newModule = replace.replacement(moduleString);
150
entry[id] = newModule;
151
entry[id].__moonlight = true;
152
moduleString = newModule.toString().replace(/\n/g, "") + `//# sourceURL=Webpack-Module-${id}`;
153
}
154
155
moonlight.unpatched.delete(patch);
156
157
if (shouldRemove) {
158
patches.splice(i--, 1);
159
}
160
}
161
}
162
163
moduleCache[id] = moduleString;
164
165
try {
166
const parsed = moonlight.lunast.parseScript(id, moduleString);
167
if (parsed != null) {
168
for (const [parsedId, parsedScript] of Object.entries(parsed)) {
169
if (patchModule(parsedId, "lunast", parsedScript)) {
170
moduleCache[parsedId] = parsedScript;
171
}
172
}
173
}
174
} catch (e) {
175
logger.error("Failed to parse script for LunAST", id, e);
176
}
177
178
if (moonlightNode.config.patchAll === true) {
179
if ((typeof id !== "string" || !id.includes("_")) && !entry[id].__moonlight) {
180
const wrapped = `(${moduleCache[id]}).apply(this, arguments)\n` + `//# sourceURL=Webpack-Module-${id}`;
181
entry[id] = new Function("module", "exports", "require", wrapped) as WebpackModuleFunc;
182
entry[id].__moonlight = true;
183
}
184
}
185
186
// Dispatch module load event subscription
187
if (moduleLoadSubscriptions.has(id)) {
188
const loadCallbacks = moduleLoadSubscriptions.get(id)!;
189
for (const callback of loadCallbacks) {
190
try {
191
callback(id);
192
} catch (e) {
193
logger.error("Error in module load subscription: " + e);
194
}
195
}
196
moduleLoadSubscriptions.delete(id);
197
}
198
199
moduleCache[id] = moduleString;
200
}
201
}
202
203
/*
204
Similar to patching, we also want to inject our own custom Webpack modules
205
into Discord's Webpack instance. We abuse pollution on the push function to
206
mark when we've completed it already.
207
*/
208
let chunkId = Number.MAX_SAFE_INTEGER;
209
210
function depToString(x: ExplicitExtensionDependency) {
211
return x.ext != null ? `${x.ext}_${x.id}` : x.id;
212
}
213
214
function handleModuleDependencies() {
215
const modules = Array.from(webpackModules.values());
216
217
const dependencies: Dependency<string, IdentifiedWebpackModule>[] = modules.map((wp) => {
218
return {
219
id: depToString(wp),
220
data: wp
221
};
222
});
223
224
const [sorted, _] = calculateDependencies(dependencies, {
225
fetchDep: (id) => {
226
return modules.find((x) => id === depToString(x)) ?? null;
227
},
228
229
getDeps: (item) => {
230
const deps = item.data?.dependencies ?? [];
231
return (
232
deps.filter(
233
(dep) => !(dep instanceof RegExp || typeof dep === "string") && dep.ext != null
234
) as ExplicitExtensionDependency[]
235
).map(depToString);
236
}
237
});
238
239
webpackModules = new Set(sorted.map((x) => x.data));
240
}
241
242
const injectedWpModules: IdentifiedWebpackModule[] = [];
243
function injectModules(entry: WebpackJsonpEntry[1]) {
244
const modules: Record<string, WebpackModuleFunc> = {};
245
const entrypoints: string[] = [];
246
let inject = false;
247
248
for (const [_modId, mod] of Object.entries(entry)) {
249
const modStr = mod.toString();
250
for (const wpModule of webpackModules) {
251
const id = depToString(wpModule);
252
if (wpModule.dependencies) {
253
const deps = new Set(wpModule.dependencies);
254
255
// FIXME: This dependency resolution might fail if the things we want
256
// got injected earlier. If weird dependencies fail, this is likely why.
257
if (deps.size) {
258
for (const dep of deps) {
259
if (typeof dep === "string") {
260
if (modStr.includes(dep)) deps.delete(dep);
261
} else if (dep instanceof RegExp) {
262
if (dep.test(modStr)) deps.delete(dep);
263
} else if (
264
dep.ext != null
265
? injectedWpModules.find((x) => x.ext === dep.ext && x.id === dep.id)
266
: injectedWpModules.find((x) => x.id === dep.id)
267
) {
268
deps.delete(dep);
269
}
270
}
271
272
if (deps.size !== 0) {
273
wpModule.dependencies = Array.from(deps);
274
continue;
275
}
276
277
wpModule.dependencies = Array.from(deps);
278
}
279
}
280
281
webpackModules.delete(wpModule);
282
moonlight.pendingModules.delete(wpModule);
283
injectedWpModules.push(wpModule);
284
285
inject = true;
286
287
if (wpModule.run) {
288
modules[id] = wpModule.run;
289
wpModule.run.__moonlight = true;
290
}
291
if (wpModule.entrypoint) entrypoints.push(id);
292
}
293
if (!webpackModules.size) break;
294
}
295
296
for (const [name, func] of Object.entries(moonlight.moonmap.getWebpackModules("window.moonlight.moonmap"))) {
297
injectedWpModules.push({ id: name, run: func });
298
modules[name] = func;
299
inject = true;
300
}
301
302
if (webpackRequire != null) {
303
for (const id of moonlight.moonmap.getLazyModules()) {
304
webpackRequire.e(id);
305
}
306
}
307
308
if (inject) {
309
logger.debug("Injecting modules:", modules, entrypoints);
310
window.webpackChunkdiscord_app.push([
311
[--chunkId],
312
modules,
313
(require: typeof WebpackRequire) => entrypoints.map(require)
314
]);
315
}
316
}
317
318
declare global {
319
interface Window {
320
webpackChunkdiscord_app: WebpackJsonp;
321
}
322
}
323
324
function moduleSourceGetter(id: string) {
325
return moduleCache[id] ?? null;
326
}
327
328
/*
329
Webpack modules are bundled into an array of arrays that hold each function.
330
Since we run code before Discord, we can create our own Webpack array and
331
hijack the .push function on it.
332
333
From there, we iterate over the object (mapping IDs to functions) and patch
334
them accordingly.
335
*/
336
export async function installWebpackPatcher() {
337
await handleModuleDependencies();
338
339
moonlight.lunast.setModuleSourceGetter(moduleSourceGetter);
340
moonlight.moonmap.setModuleSourceGetter(moduleSourceGetter);
341
342
const wpRequireFetcher: WebpackModuleFunc = (module, exports, require) => {
343
webpackRequire = require;
344
};
345
wpRequireFetcher.__moonlight = true;
346
webpackModules.add({
347
id: "moonlight",
348
entrypoint: true,
349
run: wpRequireFetcher
350
});
351
352
let realWebpackJsonp: WebpackJsonp | null = null;
353
Object.defineProperty(window, "webpackChunkdiscord_app", {
354
set: (jsonp: WebpackJsonp) => {
355
// Don't let Sentry mess with Webpack
356
const stack = new Error().stack!;
357
if (stack.includes("sentry.")) return;
358
359
realWebpackJsonp = jsonp;
360
const realPush = jsonp.push;
361
if (jsonp.push.__moonlight !== true) {
362
jsonp.push = (items) => {
363
moonlight.events.dispatchEvent(EventType.ChunkLoad, {
364
chunkId: items[0],
365
modules: items[1],
366
require: items[2]
367
});
368
369
patchModules(items[1]);
370
371
try {
372
const res = realPush.apply(realWebpackJsonp, [items]);
373
if (!realPush.__moonlight) {
374
logger.trace("Injecting Webpack modules", items[1]);
375
injectModules(items[1]);
376
}
377
378
return res;
379
} catch (err) {
380
logger.error("Failed to inject Webpack modules:", err);
381
return 0;
382
}
383
};
384
385
jsonp.push.bind = (thisArg: any, ...args: any[]) => {
386
return realPush.bind(thisArg, ...args);
387
};
388
389
jsonp.push.__moonlight = true;
390
if (!realPush.__moonlight) {
391
logger.debug("Injecting Webpack modules with empty entry");
392
// Inject an empty entry to cause iteration to happen once
393
// Kind of a dirty hack but /shrug
394
injectModules({ deez: () => {} });
395
}
396
}
397
},
398
399
get: () => {
400
const stack = new Error().stack!;
401
if (stack.includes("sentry.")) return [];
402
return realWebpackJsonp;
403
}
404
});
405
406
Object.defineProperty(Function.prototype, "m", {
407
configurable: true,
408
set(modules: any) {
409
const { stack } = new Error();
410
if (stack!.includes("/assets/") && !Array.isArray(modules)) {
411
moonlight.events.dispatchEvent(EventType.ChunkLoad, {
412
modules: modules
413
});
414
patchModules(modules);
415
416
if (!window.webpackChunkdiscord_app) window.webpackChunkdiscord_app = [];
417
injectModules(modules);
418
}
419
420
Object.defineProperty(this, "m", {
421
value: modules,
422
configurable: true,
423
enumerable: true,
424
writable: true
425
});
426
}
427
});
428
}
429