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