this repo has no description
1import electron, { 2 BrowserWindowConstructorOptions, 3 BrowserWindow as ElectronBrowserWindow, 4 ipcMain, 5 app 6} from "electron"; 7import Module from "node:module"; 8import { constants, MoonlightBranch } from "@moonlight-mod/types"; 9import { readConfig, writeConfig } from "@moonlight-mod/core/config"; 10import { getExtensions } from "@moonlight-mod/core/extension"; 11import Logger, { initLogger } from "@moonlight-mod/core/util/logger"; 12import { loadExtensions, loadProcessedExtensions } from "@moonlight-mod/core/extension/loader"; 13import EventEmitter from "node:events"; 14import path from "node:path"; 15import persist from "@moonlight-mod/core/persist"; 16import createFS from "@moonlight-mod/core/fs"; 17import { getConfigOption, getManifest, setConfigOption } from "@moonlight-mod/core/util/config"; 18import { getConfigPath, getExtensionsPath, getMoonlightDir } from "@moonlight-mod/core/util/data"; 19 20const logger = new Logger("injector"); 21 22let oldPreloadPath: string | undefined; 23let corsAllow: string[] = []; 24let blockedUrls: RegExp[] = []; 25let injectorConfig: InjectorConfig | undefined; 26 27const scriptUrls = ["web.", "sentry."]; 28const blockedScripts = new Set<string>(); 29 30ipcMain.on(constants.ipcGetOldPreloadPath, (e) => { 31 e.returnValue = oldPreloadPath; 32}); 33 34ipcMain.on(constants.ipcGetAppData, (e) => { 35 e.returnValue = app.getPath("appData"); 36}); 37ipcMain.on(constants.ipcGetInjectorConfig, (e) => { 38 e.returnValue = injectorConfig; 39}); 40ipcMain.handle(constants.ipcMessageBox, (_, opts) => { 41 electron.dialog.showMessageBoxSync(opts); 42}); 43ipcMain.handle(constants.ipcSetCorsList, (_, list) => { 44 corsAllow = list; 45}); 46 47const reEscapeRegExp = /[\\^$.*+?()[\]{}|]/g; 48const reMatchPattern = /^(?<scheme>\*|[a-z][a-z0-9+.-]*):\/\/(?<host>.+?)\/(?<path>.+)?$/; 49 50const escapeRegExp = (s: string) => s.replace(reEscapeRegExp, "\\$&"); 51ipcMain.handle(constants.ipcSetBlockedList, (_, list: string[]) => { 52 // We compile the patterns into a RegExp based on a janky match pattern-like syntax 53 const compiled = list 54 .map((pattern) => { 55 const match = pattern.match(reMatchPattern); 56 if (!match?.groups) return; 57 58 let regex = ""; 59 if (match.groups.scheme === "*") regex += ".+?"; 60 else regex += escapeRegExp(match.groups.scheme); 61 regex += ":\\/\\/"; 62 63 const parts = match.groups.host.split("."); 64 if (parts[0] === "*") { 65 parts.shift(); 66 regex += "(?:.+?\\.)?"; 67 } 68 regex += escapeRegExp(parts.join(".")); 69 70 regex += "\\/" + escapeRegExp(match.groups.path).replace("\\*", ".*?"); 71 72 return new RegExp("^" + regex + "$"); 73 }) 74 .filter(Boolean) as RegExp[]; 75 76 blockedUrls = compiled; 77}); 78 79function patchCsp(headers: Record<string, string[]>, extensionCspOverrides: Record<string, string[]>) { 80 const directives = ["script-src", "style-src", "connect-src", "img-src", "font-src", "media-src", "worker-src"]; 81 const values = ["*", "blob:", "data:", "'unsafe-inline'", "'unsafe-eval'", "disclip:"]; 82 83 const csp = "content-security-policy"; 84 if (headers[csp] == null) return; 85 86 // This parsing is jank af lol 87 const entries = headers[csp][0] 88 .trim() 89 .split(";") 90 .map((x) => x.trim()) 91 .filter((x) => x.length > 0) 92 .map((x) => x.split(" ")) 93 .map((x) => [x[0], x.slice(1)]); 94 const parts = Object.fromEntries(entries); 95 96 for (const directive of directives) { 97 parts[directive] = values; 98 } 99 100 for (const [directive, urls] of Object.entries(extensionCspOverrides)) { 101 parts[directive] ??= []; 102 parts[directive].push(...urls); 103 } 104 105 const stringified = Object.entries<string[]>(parts) 106 .map(([key, value]) => { 107 return `${key} ${value.join(" ")}`; 108 }) 109 .join("; "); 110 headers[csp] = [stringified]; 111} 112 113class BrowserWindow extends ElectronBrowserWindow { 114 constructor(opts: BrowserWindowConstructorOptions) { 115 const isMainWindow = opts.webPreferences!.preload!.indexOf("discord_desktop_core") > -1; 116 117 if (isMainWindow) { 118 if (!oldPreloadPath) oldPreloadPath = opts.webPreferences!.preload; 119 opts.webPreferences!.preload = require.resolve("./node-preload.js"); 120 } 121 122 // Event for modifying window options 123 moonlightHost.events.emit("window-options", opts, isMainWindow); 124 125 super(opts); 126 127 // Event for when a window is created 128 moonlightHost.events.emit("window-created", this, isMainWindow); 129 130 const extensionCspOverrides: Record<string, string[]> = {}; 131 132 { 133 const extCsps = moonlightHost.processedExtensions.extensions.map((x) => x.manifest.csp ?? {}); 134 for (const csp of extCsps) { 135 for (const [directive, urls] of Object.entries(csp)) { 136 extensionCspOverrides[directive] ??= []; 137 extensionCspOverrides[directive].push(...urls); 138 } 139 } 140 } 141 142 this.webContents.session.webRequest.onHeadersReceived((details, cb) => { 143 if (details.responseHeaders != null) { 144 // Patch CSP so things can use externally hosted assets 145 if (details.resourceType === "mainFrame") { 146 patchCsp(details.responseHeaders, extensionCspOverrides); 147 } 148 149 // Allow plugins to bypass CORS for specific URLs 150 if (corsAllow.some((x) => details.url.startsWith(x))) { 151 if (!details.responseHeaders) details.responseHeaders = {}; 152 153 // Work around HTTP header case sensitivity by reusing the header name if it exists 154 // https://github.com/moonlight-mod/moonlight/issues/201 155 const fallback = "access-control-allow-origin"; 156 const key = Object.keys(details.responseHeaders).find((h) => h.toLowerCase() === fallback) ?? fallback; 157 details.responseHeaders[key] = ["*"]; 158 } 159 160 moonlightHost.events.emit("headers-received", details, isMainWindow); 161 162 cb({ cancel: false, responseHeaders: details.responseHeaders }); 163 } 164 }); 165 166 this.webContents.session.webRequest.onBeforeRequest((details, cb) => { 167 /* 168 In order to get moonlight loading to be truly async, we prevent Discord 169 from loading their scripts immediately. We block the requests, keep note 170 of their URLs, and then send them off to node-preload when we get all of 171 them. node-preload then loads node side, web side, and then recreates 172 the script elements to cause them to re-fetch. 173 174 The browser extension also does this, but in a background script (see 175 packages/browser/src/background.js - we should probably get this working 176 with esbuild someday). 177 */ 178 if (details.resourceType === "script" && isMainWindow) { 179 const url = new URL(details.url); 180 const hasUrl = scriptUrls.some((scriptUrl) => { 181 return ( 182 details.url.includes(scriptUrl) && 183 !url.searchParams.has("inj") && 184 (url.host.endsWith("discord.com") || url.host.endsWith("discordapp.com")) 185 ); 186 }); 187 if (hasUrl) blockedScripts.add(details.url); 188 189 if (blockedScripts.size === scriptUrls.length) { 190 setTimeout(() => { 191 logger.debug("Kicking off node-preload"); 192 this.webContents.send(constants.ipcNodePreloadKickoff, Array.from(blockedScripts)); 193 blockedScripts.clear(); 194 }, 0); 195 } 196 197 if (hasUrl) return cb({ cancel: true }); 198 } 199 200 // Allow plugins to block some URLs, 201 // this is needed because multiple webRequest handlers cannot be registered at once 202 cb({ cancel: blockedUrls.some((u) => u.test(details.url)) }); 203 }); 204 } 205} 206 207/* 208 Fun fact: esbuild transforms that BrowserWindow class statement into this: 209 210 var variableName = class extends electronImport.BrowserWindow { 211 ... 212 } 213 214 This means that in production builds, variableName is minified, and for some 215 ungodly reason this breaks electron (because it needs to be named BrowserWindow). 216 Without it, random things fail and crash (like opening DevTools). There is no 217 esbuild option to preserve only a single name, so you get the next best thing: 218*/ 219Object.defineProperty(BrowserWindow, "name", { 220 value: "BrowserWindow", 221 writable: false 222}); 223 224type InjectorConfig = { disablePersist?: boolean; disableLoad?: boolean }; 225export async function inject(asarPath: string, _injectorConfig?: InjectorConfig) { 226 injectorConfig = _injectorConfig; 227 228 global.moonlightNodeSandboxed = { 229 fs: createFS(), 230 // These aren't supposed to be used from host 231 addCors() {}, 232 addBlocked() {} 233 }; 234 235 try { 236 let config = await readConfig(); 237 initLogger(config); 238 const extensions = await getExtensions(); 239 const processedExtensions = await loadExtensions(extensions); 240 const moonlightDir = await getMoonlightDir(); 241 const extensionsPath = await getExtensionsPath(); 242 243 // Duplicated in node-preload... oops 244 function getConfig(ext: string) { 245 const val = config.extensions[ext]; 246 if (val == null || typeof val === "boolean") return undefined; 247 return val.config; 248 } 249 global.moonlightHost = { 250 get config() { 251 return config; 252 }, 253 extensions, 254 processedExtensions, 255 asarPath, 256 events: new EventEmitter(), 257 258 version: MOONLIGHT_VERSION, 259 branch: MOONLIGHT_BRANCH as MoonlightBranch, 260 261 getConfig, 262 getConfigPath, 263 getConfigOption(ext, name) { 264 const manifest = getManifest(extensions, ext); 265 return getConfigOption(ext, name, config, manifest?.settings); 266 }, 267 setConfigOption(ext, name, value) { 268 setConfigOption(config, ext, name, value); 269 this.writeConfig(config); 270 }, 271 async writeConfig(newConfig) { 272 await writeConfig(newConfig); 273 config = newConfig; 274 }, 275 276 getLogger(id) { 277 return new Logger(id); 278 }, 279 getMoonlightDir() { 280 return moonlightDir; 281 }, 282 getExtensionDir: (ext: string) => { 283 return path.join(extensionsPath, ext); 284 } 285 }; 286 287 patchElectron(); 288 289 await loadProcessedExtensions(global.moonlightHost.processedExtensions); 290 } catch (error) { 291 logger.error("Failed to inject:", error); 292 } 293 294 if (injectorConfig?.disablePersist !== true) { 295 persist(asarPath); 296 } 297 298 if (injectorConfig?.disableLoad !== true) { 299 // Need to do this instead of require() or it breaks require.main 300 // @ts-expect-error Module internals 301 Module._load(asarPath, Module, true); 302 } 303} 304 305function patchElectron() { 306 const electronClone = {}; 307 308 for (const property of Object.getOwnPropertyNames(electron)) { 309 if (property === "BrowserWindow") { 310 Object.defineProperty(electronClone, property, { 311 get: () => BrowserWindow, 312 enumerable: true, 313 configurable: false 314 }); 315 } else { 316 Object.defineProperty(electronClone, property, Object.getOwnPropertyDescriptor(electron, property)!); 317 } 318 } 319 320 // exports is a getter only on Windows, recreate export cache instead 321 const electronPath = require.resolve("electron"); 322 const cachedElectron = require.cache[electronPath]!; 323 require.cache[electronPath] = new Module(cachedElectron.id, require.main); 324 require.cache[electronPath]!.exports = electronClone; 325}