this repo has no description
at develop 15 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 { 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 = Object.entries(moonlight.moonmap.modules).find((m) => m[1] === id)?.[0]; 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, patchId, 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 let shouldCache = true; 197 if (!swappedModule) shouldCache = patchModule(id, patchedStr.join(", "), moduleString, entry); 198 if (shouldCache) moduleCache[id] = moduleString; 199 moonlight.patched.set(id, exts); 200 } 201 202 try { 203 const parsed = moonlight.lunast.parseScript(id, moduleString); 204 if (parsed != null) { 205 for (const [parsedId, parsedScript] of Object.entries(parsed)) { 206 if (patchModule(parsedId, "lunast", parsedScript, entry)) { 207 moduleCache[parsedId] = parsedScript; 208 } 209 } 210 } 211 } catch (e) { 212 logger.error("Failed to parse script for LunAST", id, e); 213 } 214 215 if (moonlightNode.config.patchAll === true) { 216 if ((typeof id !== "string" || !id.includes("_")) && !entry[id].__moonlight) { 217 const wrapped = `(${moduleCache[id]}).apply(this, arguments)\n` + createSourceURL(id); 218 entry[id] = new Function("module", "exports", "require", wrapped) as WebpackModuleFunc; 219 entry[id].__moonlight = true; 220 } 221 } 222 223 // Dispatch module load event subscription 224 if (moduleLoadSubscriptions.has(id)) { 225 const loadCallbacks = moduleLoadSubscriptions.get(id)!; 226 for (const callback of loadCallbacks) { 227 try { 228 callback(id); 229 } catch (e) { 230 logger.error("Error in module load subscription: " + e); 231 } 232 } 233 moduleLoadSubscriptions.delete(id); 234 } 235 236 moduleCache[id] = moduleString; 237 } 238} 239 240/* 241 Similar to patching, we also want to inject our own custom Webpack modules 242 into Discord's Webpack instance. We abuse pollution on the push function to 243 mark when we've completed it already. 244*/ 245let chunkId = Number.MAX_SAFE_INTEGER; 246 247function depToString(x: ExplicitExtensionDependency) { 248 return x.ext != null ? `${x.ext}_${x.id}` : x.id; 249} 250 251function handleModuleDependencies() { 252 const modules = Array.from(webpackModules.values()); 253 254 const dependencies: Dependency<string, IdentifiedWebpackModule>[] = modules.map((wp) => { 255 return { 256 id: depToString(wp), 257 data: wp 258 }; 259 }); 260 261 const [sorted, _] = calculateDependencies(dependencies, { 262 fetchDep: (id) => { 263 return modules.find((x) => id === depToString(x)) ?? null; 264 }, 265 266 getDeps: (item) => { 267 const deps = item.data?.dependencies ?? []; 268 return ( 269 deps.filter( 270 (dep) => !(dep instanceof RegExp || typeof dep === "string") && dep.ext != null 271 ) as ExplicitExtensionDependency[] 272 ).map(depToString); 273 } 274 }); 275 276 webpackModules = new Set(sorted.map((x) => x.data)); 277} 278 279const injectedWpModules: IdentifiedWebpackModule[] = []; 280function injectModules(entry: WebpackJsonpEntry[1]) { 281 const modules: Record<string, WebpackModuleFunc> = {}; 282 const entrypoints: string[] = []; 283 let inject = false; 284 285 for (const [_modId, mod] of Object.entries(entry)) { 286 const modStr = mod.toString(); 287 for (const wpModule of webpackModules) { 288 const id = depToString(wpModule); 289 if (wpModule.dependencies) { 290 const deps = new Set(wpModule.dependencies); 291 292 // FIXME: This dependency resolution might fail if the things we want 293 // got injected earlier. If weird dependencies fail, this is likely why. 294 if (deps.size) { 295 for (const dep of deps) { 296 if (typeof dep === "string") { 297 if (modStr.includes(dep)) deps.delete(dep); 298 } else if (dep instanceof RegExp) { 299 if (dep.test(modStr)) deps.delete(dep); 300 } else if ( 301 dep.ext != null 302 ? injectedWpModules.find((x) => x.ext === dep.ext && x.id === dep.id) 303 : injectedWpModules.find((x) => x.id === dep.id) 304 ) { 305 deps.delete(dep); 306 } 307 } 308 309 wpModule.dependencies = Array.from(deps); 310 if (deps.size !== 0) { 311 continue; 312 } 313 } 314 } 315 316 webpackModules.delete(wpModule); 317 moonlight.pendingModules.delete(wpModule); 318 injectedWpModules.push(wpModule); 319 320 inject = true; 321 322 if (wpModule.run) { 323 modules[id] = wpModule.run; 324 wpModule.run.__moonlight = true; 325 // @ts-expect-error hacks 326 wpModule.run.call = function (self, module, exports, require) { 327 try { 328 wpModule.run!.apply(self, [module, exports, require]); 329 } catch (err) { 330 logger.error(`Failed to run module "${id}":`, err); 331 } 332 }; 333 if (wpModule.entrypoint) entrypoints.push(id); 334 } 335 } 336 if (!webpackModules.size) break; 337 } 338 339 for (const [name, func] of Object.entries(moonlight.moonmap.getWebpackModules("window.moonlight.moonmap"))) { 340 // @ts-expect-error probably should fix the type on this idk 341 func.__moonlight = true; 342 injectedWpModules.push({ id: name, run: func }); 343 modules[name] = func; 344 inject = true; 345 } 346 347 if (webpackRequire != null) { 348 for (const id of moonlight.moonmap.getLazyModules()) { 349 webpackRequire.e(id); 350 } 351 } 352 353 if (inject) { 354 logger.debug("Injecting modules:", modules, entrypoints); 355 window.webpackChunkdiscord_app.push([ 356 [--chunkId], 357 modules, 358 (require: WebpackRequireType) => 359 entrypoints.map((id) => { 360 try { 361 if (require.m[id] == null) { 362 logger.error(`Failing to load entrypoint module "${id}" because it's not found in Webpack.`); 363 } else { 364 require(id); 365 } 366 } catch (err) { 367 logger.error(`Failed to load entrypoint module "${id}":`, err); 368 } 369 }) 370 ]); 371 } 372} 373 374declare global { 375 interface Window { 376 webpackChunkdiscord_app: WebpackJsonp; 377 } 378} 379 380function moduleSourceGetter(id: string) { 381 return moduleCache[id] ?? null; 382} 383 384/* 385 Webpack modules are bundled into an array of arrays that hold each function. 386 Since we run code before Discord, we can create our own Webpack array and 387 hijack the .push function on it. 388 389 From there, we iterate over the object (mapping IDs to functions) and patch 390 them accordingly. 391*/ 392export async function installWebpackPatcher() { 393 await handleModuleDependencies(); 394 395 moonlight.lunast.setModuleSourceGetter(moduleSourceGetter); 396 moonlight.moonmap.setModuleSourceGetter(moduleSourceGetter); 397 398 const wpRequireFetcher: WebpackModuleFunc = (module, exports, require) => { 399 webpackRequire = require; 400 }; 401 wpRequireFetcher.__moonlight = true; 402 webpackModules.add({ 403 id: "moonlight", 404 entrypoint: true, 405 run: wpRequireFetcher 406 }); 407 408 let realWebpackJsonp: WebpackJsonp | null = null; 409 Object.defineProperty(window, "webpackChunkdiscord_app", { 410 set: (jsonp: WebpackJsonp) => { 411 // Don't let Sentry mess with Webpack 412 const stack = new Error().stack!; 413 if (stack.includes("sentry.")) return; 414 415 realWebpackJsonp = jsonp; 416 const realPush = jsonp.push; 417 if (jsonp.push.__moonlight !== true) { 418 jsonp.push = (items) => { 419 moonlight.events.dispatchEvent(WebEventType.ChunkLoad, { 420 chunkId: items[0], 421 modules: items[1], 422 require: items[2] 423 }); 424 425 patchModules(items[1]); 426 427 try { 428 const res = realPush.apply(realWebpackJsonp, [items]); 429 if (!realPush.__moonlight) { 430 logger.trace("Injecting Webpack modules", items[1]); 431 injectModules(items[1]); 432 } 433 434 return res; 435 } catch (err) { 436 logger.error("Failed to inject Webpack modules:", err); 437 return 0; 438 } 439 }; 440 441 jsonp.push.bind = (thisArg: any, ...args: any[]) => { 442 return realPush.bind(thisArg, ...args); 443 }; 444 445 jsonp.push.__moonlight = true; 446 if (!realPush.__moonlight) { 447 logger.debug("Injecting Webpack modules with empty entry"); 448 // Inject an empty entry to cause iteration to happen once 449 // Kind of a dirty hack but /shrug 450 injectModules({ deez: () => {} }); 451 } 452 } 453 }, 454 455 get: () => { 456 const stack = new Error().stack!; 457 if (stack.includes("sentry.")) return []; 458 return realWebpackJsonp; 459 } 460 }); 461 462 Object.defineProperty(Function.prototype, "m", { 463 configurable: true, 464 set(modules: any) { 465 const { stack } = new Error(); 466 if (stack!.includes("/assets/") && !Array.isArray(modules)) { 467 moonlight.events.dispatchEvent(WebEventType.ChunkLoad, { 468 modules: modules 469 }); 470 patchModules(modules); 471 472 if (!window.webpackChunkdiscord_app) window.webpackChunkdiscord_app = []; 473 injectModules(modules); 474 } 475 476 Object.defineProperty(this, "m", { 477 value: modules, 478 configurable: true, 479 enumerable: true, 480 writable: true 481 }); 482 } 483 }); 484}