this repo has no description
at v1.0.10 11 kB view raw
1import { 2 PatchReplace, 3 PatchReplaceType, 4 ExplicitExtensionDependency, 5 IdentifiedPatch, 6 IdentifiedWebpackModule, 7 WebpackJsonp, 8 WebpackJsonpEntry, 9 WebpackModuleFunc 10} from "@moonlight-mod/types"; 11import Logger from "./util/logger"; 12import calculateDependencies, { Dependency } from "./util/dependency"; 13import WebpackRequire from "@moonlight-mod/types/discord/require"; 14 15const logger = new Logger("core/patch"); 16 17// Can't be Set because we need splice 18const patches: IdentifiedPatch[] = []; 19let webpackModules: Set<IdentifiedWebpackModule> = new Set(); 20 21export function registerPatch(patch: IdentifiedPatch) { 22 patches.push(patch); 23 moonlight.unpatched.add(patch); 24} 25 26export function registerWebpackModule(wp: IdentifiedWebpackModule) { 27 webpackModules.add(wp); 28 if (wp.dependencies?.length) { 29 moonlight.pendingModules.add(wp); 30 } 31} 32 33/* 34 The patching system functions by matching a string or regex against the 35 .toString()'d copy of a Webpack module. When a patch happens, we reconstruct 36 the module with the patched source and replace it, wrapping it in the process. 37 38 We keep track of what modules we've patched (and their original sources), both 39 so we don't wrap them twice and so we can debug what extensions are patching 40 what Webpack modules. 41*/ 42const moduleCache: Record<string, string> = {}; 43const patched: Record<string, Array<string>> = {}; 44 45function patchModules(entry: WebpackJsonpEntry[1]) { 46 for (const [id, func] of Object.entries(entry)) { 47 let moduleString = Object.prototype.hasOwnProperty.call(moduleCache, id) 48 ? moduleCache[id] 49 : func.toString().replace(/\n/g, ""); 50 51 for (let i = 0; i < patches.length; i++) { 52 const patch = patches[i]; 53 if (patch.prerequisite != null && !patch.prerequisite()) { 54 continue; 55 } 56 57 if (patch.find instanceof RegExp && patch.find.global) { 58 // Reset state because global regexes are stateful for some reason 59 patch.find.lastIndex = 0; 60 } 61 62 // indexOf is faster than includes by 0.25% lmao 63 const match = 64 typeof patch.find === "string" 65 ? moduleString.indexOf(patch.find) !== -1 66 : patch.find.test(moduleString); 67 68 // Global regexes apply to all modules 69 const shouldRemove = 70 typeof patch.find === "string" ? true : !patch.find.global; 71 72 if (match) { 73 moonlight.unpatched.delete(patch); 74 75 // We ensured all arrays get turned into normal PatchReplace objects on register 76 const replace = patch.replace as PatchReplace; 77 78 if ( 79 replace.type === undefined || 80 replace.type === PatchReplaceType.Normal 81 ) { 82 // Add support for \i to match rspack's minified names 83 if (typeof replace.match !== "string") { 84 replace.match = new RegExp( 85 replace.match.source.replace(/\\i/g, "[A-Za-z_$][\\w$]*"), 86 replace.match.flags 87 ); 88 } 89 // tsc fails to detect the overloads for this, so I'll just do this 90 // Verbose, but it works 91 let replaced; 92 if (typeof replace.replacement === "string") { 93 replaced = moduleString.replace(replace.match, replace.replacement); 94 } else { 95 replaced = moduleString.replace(replace.match, replace.replacement); 96 } 97 98 if (replaced === moduleString) { 99 logger.warn("Patch replacement failed", id, patch); 100 continue; 101 } 102 103 // Store what extensions patched what modules for easier debugging 104 patched[id] = patched[id] || []; 105 patched[id].push(`${patch.ext}#${patch.id}`); 106 107 // Webpack module arguments are minified, so we replace them with consistent names 108 // We have to wrap it so things don't break, though 109 const patchedStr = patched[id].sort().join(", "); 110 111 const wrapped = 112 `(${replaced}).apply(this, arguments)\n` + 113 `// Patched by moonlight: ${patchedStr}\n` + 114 `//# sourceURL=Webpack-Module-${id}`; 115 116 try { 117 const func = new Function( 118 "module", 119 "exports", 120 "require", 121 wrapped 122 ) as WebpackModuleFunc; 123 entry[id] = func; 124 entry[id].__moonlight = true; 125 moduleString = replaced; 126 } catch (e) { 127 logger.warn("Error constructing function for patch", patch, e); 128 patched[id].pop(); 129 } 130 } else if (replace.type === PatchReplaceType.Module) { 131 // Directly replace the module with a new one 132 const newModule = replace.replacement(moduleString); 133 entry[id] = newModule; 134 entry[id].__moonlight = true; 135 moduleString = 136 newModule.toString().replace(/\n/g, "") + 137 `//# sourceURL=Webpack-Module-${id}`; 138 } 139 140 if (shouldRemove) { 141 patches.splice(i--, 1); 142 } 143 } 144 } 145 146 if (moonlightNode.config.patchAll === true) { 147 if ( 148 (typeof id !== "string" || !id.includes("_")) && 149 !entry[id].__moonlight 150 ) { 151 const wrapped = 152 `(${moduleString}).apply(this, arguments)\n` + 153 `//# sourceURL=Webpack-Module-${id}`; 154 entry[id] = new Function( 155 "module", 156 "exports", 157 "require", 158 wrapped 159 ) as WebpackModuleFunc; 160 entry[id].__moonlight = true; 161 } 162 } 163 164 moduleCache[id] = moduleString; 165 } 166} 167 168/* 169 Similar to patching, we also want to inject our own custom Webpack modules 170 into Discord's Webpack instance. We abuse pollution on the push function to 171 mark when we've completed it already. 172*/ 173let chunkId = Number.MAX_SAFE_INTEGER; 174 175function handleModuleDependencies() { 176 const modules = Array.from(webpackModules.values()); 177 178 const dependencies: Dependency<string, IdentifiedWebpackModule>[] = 179 modules.map((wp) => { 180 return { 181 id: `${wp.ext}_${wp.id}`, 182 data: wp 183 }; 184 }); 185 186 const [sorted, _] = calculateDependencies(dependencies, { 187 fetchDep: (id) => { 188 return modules.find((x) => id === `${x.ext}_${x.id}`) ?? null; 189 }, 190 191 getDeps: (item) => { 192 const deps = item.data?.dependencies ?? []; 193 return ( 194 deps.filter( 195 (dep) => !(dep instanceof RegExp || typeof dep === "string") 196 ) as ExplicitExtensionDependency[] 197 ).map((x) => `${x.ext}_${x.id}`); 198 } 199 }); 200 201 webpackModules = new Set(sorted.map((x) => x.data)); 202} 203 204const injectedWpModules: IdentifiedWebpackModule[] = []; 205function injectModules(entry: WebpackJsonpEntry[1]) { 206 const modules: Record<string, WebpackModuleFunc> = {}; 207 const entrypoints: string[] = []; 208 let inject = false; 209 210 for (const [_modId, mod] of Object.entries(entry)) { 211 const modStr = mod.toString(); 212 for (const wpModule of webpackModules) { 213 const id = wpModule.ext + "_" + wpModule.id; 214 if (wpModule.dependencies) { 215 const deps = new Set(wpModule.dependencies); 216 217 // FIXME: This dependency resolution might fail if the things we want 218 // got injected earlier. If weird dependencies fail, this is likely why. 219 if (deps.size) { 220 for (const dep of deps) { 221 if (typeof dep === "string") { 222 if (modStr.includes(dep)) deps.delete(dep); 223 } else if (dep instanceof RegExp) { 224 if (dep.test(modStr)) deps.delete(dep); 225 } else if ( 226 injectedWpModules.find( 227 (x) => x.ext === dep.ext && x.id === dep.id 228 ) 229 ) { 230 deps.delete(dep); 231 } 232 } 233 234 if (deps.size !== 0) { 235 wpModule.dependencies = Array.from(deps); 236 continue; 237 } 238 239 wpModule.dependencies = Array.from(deps); 240 } 241 } 242 243 webpackModules.delete(wpModule); 244 moonlight.pendingModules.delete(wpModule); 245 injectedWpModules.push(wpModule); 246 247 inject = true; 248 249 if (wpModule.run) { 250 modules[id] = wpModule.run; 251 wpModule.run.__moonlight = true; 252 } 253 if (wpModule.entrypoint) entrypoints.push(id); 254 } 255 if (!webpackModules.size) break; 256 } 257 258 if (inject) { 259 logger.debug("Injecting modules:", modules, entrypoints); 260 window.webpackChunkdiscord_app.push([ 261 [--chunkId], 262 modules, 263 (require: typeof WebpackRequire) => entrypoints.map(require) 264 ]); 265 } 266} 267 268declare global { 269 interface Window { 270 webpackChunkdiscord_app: WebpackJsonp; 271 } 272} 273 274/* 275 Webpack modules are bundled into an array of arrays that hold each function. 276 Since we run code before Discord, we can create our own Webpack array and 277 hijack the .push function on it. 278 279 From there, we iterate over the object (mapping IDs to functions) and patch 280 them accordingly. 281*/ 282export async function installWebpackPatcher() { 283 await handleModuleDependencies(); 284 285 let realWebpackJsonp: WebpackJsonp | null = null; 286 Object.defineProperty(window, "webpackChunkdiscord_app", { 287 set: (jsonp: WebpackJsonp) => { 288 // Don't let Sentry mess with Webpack 289 const stack = new Error().stack!; 290 if (stack.includes("sentry.")) return; 291 292 realWebpackJsonp = jsonp; 293 const realPush = jsonp.push; 294 if (jsonp.push.__moonlight !== true) { 295 jsonp.push = (items) => { 296 patchModules(items[1]); 297 298 try { 299 const res = realPush.apply(realWebpackJsonp, [items]); 300 if (!realPush.__moonlight) { 301 logger.trace("Injecting Webpack modules", items[1]); 302 injectModules(items[1]); 303 } 304 305 return res; 306 } catch (err) { 307 logger.error("Failed to inject Webpack modules:", err); 308 return 0; 309 } 310 }; 311 312 jsonp.push.bind = (thisArg: any, ...args: any[]) => { 313 return realPush.bind(thisArg, ...args); 314 }; 315 316 jsonp.push.__moonlight = true; 317 if (!realPush.__moonlight) { 318 logger.debug("Injecting Webpack modules with empty entry"); 319 // Inject an empty entry to cause iteration to happen once 320 // Kind of a dirty hack but /shrug 321 injectModules({ deez: () => {} }); 322 } 323 } 324 }, 325 326 get: () => { 327 const stack = new Error().stack!; 328 if (stack.includes("sentry.")) return []; 329 return realWebpackJsonp; 330 } 331 }); 332 333 Object.defineProperty(Function.prototype, "m", { 334 configurable: true, 335 set(modules: any) { 336 const { stack } = new Error(); 337 if (stack!.includes("/assets/") && !Array.isArray(modules)) { 338 patchModules(modules); 339 if (!window.webpackChunkdiscord_app) 340 window.webpackChunkdiscord_app = []; 341 injectModules(modules); 342 } 343 344 Object.defineProperty(this, "m", { 345 value: modules, 346 configurable: true, 347 enumerable: true, 348 writable: true 349 }); 350 } 351 }); 352}