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