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