this repo has no description
at v1.3.0 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 { 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 if (wpModule.entrypoint) entrypoints.push(id); 312 } 313 } 314 if (!webpackModules.size) break; 315 } 316 317 for (const [name, func] of Object.entries(moonlight.moonmap.getWebpackModules("window.moonlight.moonmap"))) { 318 injectedWpModules.push({ id: name, run: func }); 319 modules[name] = func; 320 inject = true; 321 } 322 323 if (webpackRequire != null) { 324 for (const id of moonlight.moonmap.getLazyModules()) { 325 webpackRequire.e(id); 326 } 327 } 328 329 if (inject) { 330 logger.debug("Injecting modules:", modules, entrypoints); 331 window.webpackChunkdiscord_app.push([ 332 [--chunkId], 333 modules, 334 (require: WebpackRequireType) => 335 entrypoints.map((id) => { 336 try { 337 if (require.m[id] == null) { 338 logger.error(`Failing to load entrypoint module "${id}" because it's not found in Webpack.`); 339 } else { 340 require(id); 341 } 342 } catch (err) { 343 logger.error(`Failed to load entrypoint module "${id}":`, err); 344 } 345 }) 346 ]); 347 } 348} 349 350declare global { 351 interface Window { 352 webpackChunkdiscord_app: WebpackJsonp; 353 } 354} 355 356function moduleSourceGetter(id: string) { 357 return moduleCache[id] ?? null; 358} 359 360/* 361 Webpack modules are bundled into an array of arrays that hold each function. 362 Since we run code before Discord, we can create our own Webpack array and 363 hijack the .push function on it. 364 365 From there, we iterate over the object (mapping IDs to functions) and patch 366 them accordingly. 367*/ 368export async function installWebpackPatcher() { 369 await handleModuleDependencies(); 370 371 moonlight.lunast.setModuleSourceGetter(moduleSourceGetter); 372 moonlight.moonmap.setModuleSourceGetter(moduleSourceGetter); 373 374 const wpRequireFetcher: WebpackModuleFunc = (module, exports, require) => { 375 webpackRequire = require; 376 }; 377 wpRequireFetcher.__moonlight = true; 378 webpackModules.add({ 379 id: "moonlight", 380 entrypoint: true, 381 run: wpRequireFetcher 382 }); 383 384 let realWebpackJsonp: WebpackJsonp | null = null; 385 Object.defineProperty(window, "webpackChunkdiscord_app", { 386 set: (jsonp: WebpackJsonp) => { 387 // Don't let Sentry mess with Webpack 388 const stack = new Error().stack!; 389 if (stack.includes("sentry.")) return; 390 391 realWebpackJsonp = jsonp; 392 const realPush = jsonp.push; 393 if (jsonp.push.__moonlight !== true) { 394 jsonp.push = (items) => { 395 moonlight.events.dispatchEvent(EventType.ChunkLoad, { 396 chunkId: items[0], 397 modules: items[1], 398 require: items[2] 399 }); 400 401 patchModules(items[1]); 402 403 try { 404 const res = realPush.apply(realWebpackJsonp, [items]); 405 if (!realPush.__moonlight) { 406 logger.trace("Injecting Webpack modules", items[1]); 407 injectModules(items[1]); 408 } 409 410 return res; 411 } catch (err) { 412 logger.error("Failed to inject Webpack modules:", err); 413 return 0; 414 } 415 }; 416 417 jsonp.push.bind = (thisArg: any, ...args: any[]) => { 418 return realPush.bind(thisArg, ...args); 419 }; 420 421 jsonp.push.__moonlight = true; 422 if (!realPush.__moonlight) { 423 logger.debug("Injecting Webpack modules with empty entry"); 424 // Inject an empty entry to cause iteration to happen once 425 // Kind of a dirty hack but /shrug 426 injectModules({ deez: () => {} }); 427 } 428 } 429 }, 430 431 get: () => { 432 const stack = new Error().stack!; 433 if (stack.includes("sentry.")) return []; 434 return realWebpackJsonp; 435 } 436 }); 437 438 Object.defineProperty(Function.prototype, "m", { 439 configurable: true, 440 set(modules: any) { 441 const { stack } = new Error(); 442 if (stack!.includes("/assets/") && !Array.isArray(modules)) { 443 moonlight.events.dispatchEvent(EventType.ChunkLoad, { 444 modules: modules 445 }); 446 patchModules(modules); 447 448 if (!window.webpackChunkdiscord_app) window.webpackChunkdiscord_app = []; 449 injectModules(modules); 450 } 451 452 Object.defineProperty(this, "m", { 453 value: modules, 454 configurable: true, 455 enumerable: true, 456 writable: true 457 }); 458 } 459 }); 460}