this repo has no description
1import {
2 PatchReplace,
3 PatchReplaceType,
4 ExplicitExtensionDependency,
5 IdentifiedPatch,
6 IdentifiedWebpackModule,
7 WebpackJsonp,
8 WebpackJsonpEntry,
9 WebpackModuleFunc
10} from "@moonlight-mod/types";
11import Logger from "./util/logger";
12import calculateDependencies, { Dependency } from "./util/dependency";
13import WebpackRequire from "@moonlight-mod/types/discord/require";
14
15const logger = new Logger("core/patch");
16
17// Can't be Set because we need splice
18const patches: IdentifiedPatch[] = [];
19let webpackModules: Set<IdentifiedWebpackModule> = new Set();
20
21export function registerPatch(patch: IdentifiedPatch) {
22 patches.push(patch);
23}
24
25export function registerWebpackModule(wp: IdentifiedWebpackModule) {
26 webpackModules.add(wp);
27}
28
29/*
30 The patching system functions by matching a string or regex against the
31 .toString()'d copy of a Webpack module. When a patch happens, we reconstruct
32 the module with the patched source and replace it, wrapping it in the process.
33
34 We keep track of what modules we've patched (and their original sources), both
35 so we don't wrap them twice and so we can debug what extensions are patching
36 what Webpack modules.
37*/
38const moduleCache: Record<string, string> = {};
39const patched: Record<string, Array<string>> = {};
40
41function patchModules(entry: WebpackJsonpEntry[1]) {
42 for (const [id, func] of Object.entries(entry)) {
43 let moduleString = Object.prototype.hasOwnProperty.call(moduleCache, id)
44 ? moduleCache[id]
45 : func.toString().replace(/\n/g, "");
46
47 for (const patch of patches) {
48 if (patch.prerequisite != null && !patch.prerequisite()) {
49 continue;
50 }
51
52 if (patch.find instanceof RegExp && patch.find.global) {
53 // Reset state because global regexes are stateful for some reason
54 patch.find.lastIndex = 0;
55 }
56
57 // indexOf is faster than includes by 0.25% lmao
58 const match =
59 typeof patch.find === "string"
60 ? moduleString.indexOf(patch.find) !== -1
61 : patch.find.test(moduleString);
62
63 // Global regexes apply to all modules
64 const shouldRemove =
65 typeof patch.find === "string" ? true : !patch.find.global;
66
67 if (match) {
68 moonlight.unpatched.delete(patch);
69
70 // We ensured all arrays get turned into normal PatchReplace objects on register
71 const replace = patch.replace as PatchReplace;
72
73 if (
74 replace.type === undefined ||
75 replace.type === PatchReplaceType.Normal
76 ) {
77 // Add support for \i to match rspack's minified names
78 if (typeof replace.match !== "string") {
79 replace.match = new RegExp(
80 replace.match.source.replace(/\\i/g, "[A-Za-z_$][\\w$]*"),
81 replace.match.flags
82 );
83 }
84 // tsc fails to detect the overloads for this, so I'll just do this
85 // Verbose, but it works
86 let replaced;
87 if (typeof replace.replacement === "string") {
88 replaced = moduleString.replace(replace.match, replace.replacement);
89 } else {
90 replaced = moduleString.replace(replace.match, replace.replacement);
91 }
92
93 if (replaced === moduleString) {
94 logger.warn("Patch replacement failed", id, patch);
95 continue;
96 }
97
98 // Store what extensions patched what modules for easier debugging
99 patched[id] = patched[id] || [];
100 patched[id].push(`${patch.ext}#${patch.id}`);
101
102 // Webpack module arguments are minified, so we replace them with consistent names
103 // We have to wrap it so things don't break, though
104 const patchedStr = patched[id].sort().join(", ");
105
106 const wrapped =
107 `(${replaced}).apply(this, arguments)\n` +
108 `// Patched by moonlight: ${patchedStr}\n` +
109 `//# sourceURL=Webpack-Module-${id}`;
110
111 try {
112 const func = new Function(
113 "module",
114 "exports",
115 "require",
116 wrapped
117 ) as WebpackModuleFunc;
118 entry[id] = func;
119 entry[id].__moonlight = true;
120 moduleString = replaced;
121 } catch (e) {
122 logger.warn("Error constructing function for patch", e);
123 }
124 } else if (replace.type === PatchReplaceType.Module) {
125 // Directly replace the module with a new one
126 const newModule = replace.replacement(moduleString);
127 entry[id] = newModule;
128 entry[id].__moonlight = true;
129 moduleString =
130 newModule.toString().replace(/\n/g, "") +
131 `//# sourceURL=Webpack-Module-${id}`;
132 }
133
134 if (shouldRemove) {
135 patches.splice(
136 patches.findIndex((p) => p.ext === patch.ext && p.id === patch.id),
137 1
138 );
139 }
140 }
141 }
142
143 if (moonlightNode.config.patchAll === true) {
144 if (
145 (typeof id !== "string" || !id.includes("_")) &&
146 !entry[id].__moonlight
147 ) {
148 const wrapped =
149 `(${moduleString}).apply(this, arguments)\n` +
150 `//# sourceURL=Webpack-Module-${id}`;
151 entry[id] = new Function(
152 "module",
153 "exports",
154 "require",
155 wrapped
156 ) as WebpackModuleFunc;
157 entry[id].__moonlight = true;
158 }
159 }
160
161 moduleCache[id] = moduleString;
162 }
163}
164
165/*
166 Similar to patching, we also want to inject our own custom Webpack modules
167 into Discord's Webpack instance. We abuse pollution on the push function to
168 mark when we've completed it already.
169*/
170let chunkId = Number.MAX_SAFE_INTEGER;
171
172function handleModuleDependencies() {
173 const modules = Array.from(webpackModules.values());
174
175 const dependencies: Dependency<string, IdentifiedWebpackModule>[] =
176 modules.map((wp) => {
177 return {
178 id: `${wp.ext}_${wp.id}`,
179 data: wp
180 };
181 });
182
183 const [sorted, _] = calculateDependencies(
184 dependencies,
185
186 function fetchDep(id) {
187 return modules.find((x) => id === `${x.ext}_${x.id}`) ?? null;
188 },
189
190 function getDeps(item) {
191 const deps = item.data?.dependencies ?? [];
192 return (
193 deps.filter(
194 (dep) => !(dep instanceof RegExp || typeof dep === "string")
195 ) as ExplicitExtensionDependency[]
196 ).map((x) => `${x.ext}_${x.id}`);
197 }
198 );
199
200 webpackModules = new Set(sorted.map((x) => x.data));
201}
202
203const injectedWpModules: IdentifiedWebpackModule[] = [];
204function injectModules(entry: WebpackJsonpEntry[1]) {
205 const modules: Record<string, WebpackModuleFunc> = {};
206 const entrypoints: string[] = [];
207 let inject = false;
208
209 for (const [_modId, mod] of Object.entries(entry)) {
210 const modStr = mod.toString();
211 const wpModules = Array.from(webpackModules.values());
212 for (const wpModule of wpModules) {
213 const id = wpModule.ext + "_" + wpModule.id;
214 if (wpModule.dependencies) {
215 const deps = new Set(wpModule.dependencies);
216
217 // FIXME: This dependency resolution might fail if the things we want
218 // got injected earlier. If weird dependencies fail, this is likely why.
219 if (deps.size) {
220 for (const dep of deps.values()) {
221 if (typeof dep === "string") {
222 if (modStr.includes(dep)) deps.delete(dep);
223 } else if (dep instanceof RegExp) {
224 if (dep.test(modStr)) deps.delete(dep);
225 } else if (
226 injectedWpModules.find(
227 (x) => x.ext === dep.ext && x.id === dep.id
228 )
229 ) {
230 deps.delete(dep);
231 }
232 }
233
234 if (deps.size !== 0) {
235 // Update the deps that have passed
236 webpackModules.delete(wpModule);
237 wpModule.dependencies = Array.from(deps);
238 webpackModules.add(wpModule);
239 continue;
240 }
241
242 wpModule.dependencies = Array.from(deps);
243 }
244 }
245
246 webpackModules.delete(wpModule);
247 injectedWpModules.push(wpModule);
248
249 inject = true;
250
251 if (wpModule.run) modules[id] = wpModule.run;
252 if (wpModule.entrypoint) entrypoints.push(id);
253 }
254 if (!webpackModules.size) break;
255 }
256
257 if (inject) {
258 logger.debug("Injecting modules:", modules, entrypoints);
259 window.webpackChunkdiscord_app.push([
260 [--chunkId],
261 modules,
262 (require: typeof WebpackRequire) => entrypoints.map(require)
263 ]);
264 }
265}
266
267declare global {
268 interface Window {
269 webpackChunkdiscord_app: WebpackJsonp;
270 }
271}
272
273/*
274 Webpack modules are bundled into an array of arrays that hold each function.
275 Since we run code before Discord, we can create our own Webpack array and
276 hijack the .push function on it.
277
278 From there, we iterate over the object (mapping IDs to functions) and patch
279 them accordingly.
280*/
281export async function installWebpackPatcher() {
282 await handleModuleDependencies();
283
284 let realWebpackJsonp: WebpackJsonp | null = null;
285 Object.defineProperty(window, "webpackChunkdiscord_app", {
286 set: (jsonp: WebpackJsonp) => {
287 // Don't let Sentry mess with Webpack
288 const stack = new Error().stack!;
289 if (stack.includes("sentry.")) return;
290
291 realWebpackJsonp = jsonp;
292 const realPush = jsonp.push;
293 if (jsonp.push.__moonlight !== true) {
294 jsonp.push = (items) => {
295 patchModules(items[1]);
296
297 try {
298 const res = realPush.apply(realWebpackJsonp, [items]);
299 if (!realPush.__moonlight) {
300 logger.trace("Injecting Webpack modules", items[1]);
301 injectModules(items[1]);
302 }
303
304 return res;
305 } catch (err) {
306 logger.error("Failed to inject Webpack modules:", err);
307 return 0;
308 }
309 };
310
311 jsonp.push.bind = (thisArg: any, ...args: any[]) => {
312 return realPush.bind(thisArg, ...args);
313 };
314
315 jsonp.push.__moonlight = true;
316 if (!realPush.__moonlight) {
317 logger.debug("Injecting Webpack modules with empty entry");
318 // Inject an empty entry to cause iteration to happen once
319 // Kind of a dirty hack but /shrug
320 injectModules({ deez: () => {} });
321 }
322 }
323 },
324
325 get: () => {
326 const stack = new Error().stack!;
327 if (stack.includes("sentry.")) return [];
328 return realWebpackJsonp;
329 }
330 });
331
332 registerWebpackModule({
333 ext: "moonlight",
334 id: "fix_rspack_init_modules",
335 entrypoint: true,
336 run: function (module, exports, require) {
337 patchModules(require.m);
338 }
339 });
340}