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