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