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