this repo has no description
at v1.0.5 11 kB view raw
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}