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
18let 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 = moduleCache.hasOwnProperty(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 // tsc fails to detect the overloads for this, so I'll just do this
78 // Verbose, but it works
79 let replaced;
80 if (typeof replace.replacement === "string") {
81 replaced = moduleString.replace(replace.match, replace.replacement);
82 } else {
83 replaced = moduleString.replace(replace.match, replace.replacement);
84 }
85
86 if (replaced === moduleString) {
87 logger.warn("Patch replacement failed", id, patch);
88 continue;
89 }
90
91 // Store what extensions patched what modules for easier debugging
92 patched[id] = patched[id] || [];
93 patched[id].push(`${patch.ext}#${patch.id}`);
94
95 // Webpack module arguments are minified, so we replace them with consistent names
96 // We have to wrap it so things don't break, though
97 const patchedStr = patched[id].sort().join(", ");
98
99 const wrapped =
100 `(${replaced}).apply(this, arguments)\n` +
101 `// Patched by moonlight: ${patchedStr}\n` +
102 `//# sourceURL=Webpack-Module-${id}`;
103
104 try {
105 const func = new Function(
106 "module",
107 "exports",
108 "require",
109 wrapped
110 ) as WebpackModuleFunc;
111 entry[id] = func;
112 entry[id].__moonlight = true;
113 moduleString = replaced;
114 } catch (e) {
115 logger.warn("Error constructing function for patch", e);
116 }
117 } else if (replace.type == PatchReplaceType.Module) {
118 // Directly replace the module with a new one
119 const newModule = replace.replacement(moduleString);
120 entry[id] = newModule;
121 entry[id].__moonlight = true;
122 moduleString =
123 newModule.toString().replace(/\n/g, "") +
124 `//# sourceURL=Webpack-Module-${id}`;
125 }
126
127 if (shouldRemove) {
128 patches.splice(
129 patches.findIndex((p) => p.ext === patch.ext && p.id === patch.id),
130 1
131 );
132 }
133 }
134 }
135
136 if (moonlightNode.config.patchAll === true) {
137 if (
138 (typeof id !== "string" || !id.includes("_")) &&
139 !entry[id].__moonlight
140 ) {
141 const wrapped =
142 `(${moduleString}).apply(this, arguments)\n` +
143 `//# sourceURL=Webpack-Module-${id}`;
144 entry[id] = new Function(
145 "module",
146 "exports",
147 "require",
148 wrapped
149 ) as WebpackModuleFunc;
150 entry[id].__moonlight = true;
151 }
152 }
153
154 moduleCache[id] = moduleString;
155 }
156}
157
158/*
159 Similar to patching, we also want to inject our own custom Webpack modules
160 into Discord's Webpack instance. We abuse pollution on the push function to
161 mark when we've completed it already.
162*/
163let chunkId = Number.MAX_SAFE_INTEGER;
164
165function handleModuleDependencies() {
166 const modules = Array.from(webpackModules.values());
167
168 const dependencies: Dependency<string, IdentifiedWebpackModule>[] =
169 modules.map((wp) => {
170 return {
171 id: `${wp.ext}_${wp.id}`,
172 data: wp
173 };
174 });
175
176 const [sorted, _] = calculateDependencies(
177 dependencies,
178
179 function fetchDep(id) {
180 return modules.find((x) => id === `${x.ext}_${x.id}`) ?? null;
181 },
182
183 function getDeps(item) {
184 const deps = item.data?.dependencies ?? [];
185 return (
186 deps.filter(
187 (dep) => !(dep instanceof RegExp || typeof dep === "string")
188 ) as ExplicitExtensionDependency[]
189 ).map((x) => `${x.ext}_${x.id}`);
190 }
191 );
192
193 webpackModules = new Set(sorted.map((x) => x.data));
194}
195
196const injectedWpModules: IdentifiedWebpackModule[] = [];
197function injectModules(entry: WebpackJsonpEntry[1]) {
198 const modules: Record<string, WebpackModuleFunc> = {};
199 const entrypoints: string[] = [];
200 let inject = false;
201
202 for (const [modId, mod] of Object.entries(entry)) {
203 const modStr = mod.toString();
204 const wpModules = Array.from(webpackModules.values());
205 for (const wpModule of wpModules) {
206 const id = wpModule.ext + "_" + wpModule.id;
207 if (wpModule.dependencies) {
208 const deps = new Set(wpModule.dependencies);
209
210 // FIXME: This dependency resolution might fail if the things we want
211 // got injected earlier. If weird dependencies fail, this is likely why.
212 if (deps.size) {
213 for (const dep of deps.values()) {
214 if (typeof dep === "string") {
215 if (modStr.includes(dep)) deps.delete(dep);
216 } else if (dep instanceof RegExp) {
217 if (dep.test(modStr)) deps.delete(dep);
218 } else if (
219 injectedWpModules.find(
220 (x) => x.ext === dep.ext && x.id === dep.id
221 )
222 ) {
223 deps.delete(dep);
224 }
225 }
226
227 if (deps.size !== 0) {
228 // Update the deps that have passed
229 webpackModules.delete(wpModule);
230 wpModule.dependencies = Array.from(deps);
231 webpackModules.add(wpModule);
232 continue;
233 }
234
235 wpModule.dependencies = Array.from(deps);
236 }
237 }
238
239 webpackModules.delete(wpModule);
240 injectedWpModules.push(wpModule);
241
242 inject = true;
243
244 modules[id] = wpModule.run;
245 if (wpModule.entrypoint) entrypoints.push(id);
246 }
247 if (!webpackModules.size) break;
248 }
249
250 if (inject) {
251 logger.debug("Injecting modules:", modules, entrypoints);
252 window.webpackChunkdiscord_app.push([
253 [--chunkId],
254 modules,
255 (require: typeof WebpackRequire) => entrypoints.map(require)
256 ]);
257 }
258}
259
260declare global {
261 interface Window {
262 webpackChunkdiscord_app: WebpackJsonp;
263 }
264}
265
266/*
267 Webpack modules are bundled into an array of arrays that hold each function.
268 Since we run code before Discord, we can create our own Webpack array and
269 hijack the .push function on it.
270
271 From there, we iterate over the object (mapping IDs to functions) and patch
272 them accordingly.
273*/
274export async function installWebpackPatcher() {
275 await handleModuleDependencies();
276
277 let realWebpackJsonp: WebpackJsonp | null = null;
278 Object.defineProperty(window, "webpackChunkdiscord_app", {
279 set: (jsonp: WebpackJsonp) => {
280 // Don't let Sentry mess with Webpack
281 const stack = new Error().stack!;
282 if (stack.includes("sentry.")) return;
283
284 realWebpackJsonp = jsonp;
285 const realPush = jsonp.push;
286 if (jsonp.push.__moonlight !== true) {
287 jsonp.push = (items) => {
288 patchModules(items[1]);
289
290 try {
291 const res = realPush.apply(realWebpackJsonp, [items]);
292 if (!realPush.__moonlight) {
293 logger.trace("Injecting Webpack modules", items[1]);
294 injectModules(items[1]);
295 }
296
297 return res;
298 } catch (err) {
299 logger.error("Failed to inject Webpack modules:", err);
300 return 0;
301 }
302 };
303
304 jsonp.push.bind = (thisArg: any, ...args: any[]) => {
305 return realPush.bind(thisArg, ...args);
306 };
307
308 jsonp.push.__moonlight = true;
309 if (!realPush.__moonlight) {
310 logger.debug("Injecting Webpack modules with empty entry");
311 // Inject an empty entry to cause iteration to happen once
312 // Kind of a dirty hack but /shrug
313 injectModules({ deez: () => {} });
314 }
315 }
316 },
317
318 get: () => {
319 const stack = new Error().stack!;
320 if (stack.includes("sentry.")) return [];
321 return realWebpackJsonp;
322 }
323 });
324
325 registerWebpackModule({
326 ext: "moonlight",
327 id: "fix_rspack_init_modules",
328 entrypoint: true,
329 run: function (module, exports, require) {
330 patchModules(require.m);
331 }
332 });
333}