this repo has no description
at v1.3.7 11 kB view raw
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 cb({ cancel: false, responseHeaders: details.responseHeaders }); 161 } 162 }); 163 164 this.webContents.session.webRequest.onBeforeRequest((details, cb) => { 165 /* 166 In order to get moonlight loading to be truly async, we prevent Discord 167 from loading their scripts immediately. We block the requests, keep note 168 of their URLs, and then send them off to node-preload when we get all of 169 them. node-preload then loads node side, web side, and then recreates 170 the script elements to cause them to re-fetch. 171 172 The browser extension also does this, but in a background script (see 173 packages/browser/src/background.js - we should probably get this working 174 with esbuild someday). 175 */ 176 if (details.resourceType === "script" && isMainWindow) { 177 const url = new URL(details.url); 178 const hasUrl = scriptUrls.some((scriptUrl) => { 179 return ( 180 details.url.includes(scriptUrl) && 181 !url.searchParams.has("inj") && 182 (url.host.endsWith("discord.com") || url.host.endsWith("discordapp.com")) 183 ); 184 }); 185 if (hasUrl) blockedScripts.add(details.url); 186 187 if (blockedScripts.size === scriptUrls.length) { 188 setTimeout(() => { 189 logger.debug("Kicking off node-preload"); 190 this.webContents.send(constants.ipcNodePreloadKickoff, Array.from(blockedScripts)); 191 blockedScripts.clear(); 192 }, 0); 193 } 194 195 if (hasUrl) return cb({ cancel: true }); 196 } 197 198 // Allow plugins to block some URLs, 199 // this is needed because multiple webRequest handlers cannot be registered at once 200 cb({ cancel: blockedUrls.some((u) => u.test(details.url)) }); 201 }); 202 } 203} 204 205/* 206 Fun fact: esbuild transforms that BrowserWindow class statement into this: 207 208 var variableName = class extends electronImport.BrowserWindow { 209 ... 210 } 211 212 This means that in production builds, variableName is minified, and for some 213 ungodly reason this breaks electron (because it needs to be named BrowserWindow). 214 Without it, random things fail and crash (like opening DevTools). There is no 215 esbuild option to preserve only a single name, so you get the next best thing: 216*/ 217Object.defineProperty(BrowserWindow, "name", { 218 value: "BrowserWindow", 219 writable: false 220}); 221 222type InjectorConfig = { disablePersist?: boolean; disableLoad?: boolean }; 223export async function inject(asarPath: string, _injectorConfig?: InjectorConfig) { 224 injectorConfig = _injectorConfig; 225 226 global.moonlightNodeSandboxed = { 227 fs: createFS(), 228 // These aren't supposed to be used from host 229 addCors() {}, 230 addBlocked() {} 231 }; 232 233 try { 234 let config = await readConfig(); 235 initLogger(config); 236 const extensions = await getExtensions(); 237 const processedExtensions = await loadExtensions(extensions); 238 const moonlightDir = await getMoonlightDir(); 239 const extensionsPath = await getExtensionsPath(); 240 241 // Duplicated in node-preload... oops 242 function getConfig(ext: string) { 243 const val = config.extensions[ext]; 244 if (val == null || typeof val === "boolean") return undefined; 245 return val.config; 246 } 247 global.moonlightHost = { 248 get config() { 249 return config; 250 }, 251 extensions, 252 processedExtensions, 253 asarPath, 254 events: new EventEmitter(), 255 256 version: MOONLIGHT_VERSION, 257 branch: MOONLIGHT_BRANCH as MoonlightBranch, 258 259 getConfig, 260 getConfigPath, 261 getConfigOption(ext, name) { 262 const manifest = getManifest(extensions, ext); 263 return getConfigOption(ext, name, config, manifest?.settings); 264 }, 265 setConfigOption(ext, name, value) { 266 setConfigOption(config, ext, name, value); 267 this.writeConfig(config); 268 }, 269 async writeConfig(newConfig) { 270 await writeConfig(newConfig); 271 config = newConfig; 272 }, 273 274 getLogger(id) { 275 return new Logger(id); 276 }, 277 getMoonlightDir() { 278 return moonlightDir; 279 }, 280 getExtensionDir: (ext: string) => { 281 return path.join(extensionsPath, ext); 282 } 283 }; 284 285 patchElectron(); 286 287 await loadProcessedExtensions(global.moonlightHost.processedExtensions); 288 } catch (error) { 289 logger.error("Failed to inject:", error); 290 } 291 292 if (injectorConfig?.disablePersist !== true) { 293 persist(asarPath); 294 } 295 296 if (injectorConfig?.disableLoad !== true) { 297 // Need to do this instead of require() or it breaks require.main 298 // @ts-expect-error Module internals 299 Module._load(asarPath, Module, true); 300 } 301} 302 303function patchElectron() { 304 const electronClone = {}; 305 306 for (const property of Object.getOwnPropertyNames(electron)) { 307 if (property === "BrowserWindow") { 308 Object.defineProperty(electronClone, property, { 309 get: () => BrowserWindow, 310 enumerable: true, 311 configurable: false 312 }); 313 } else { 314 Object.defineProperty(electronClone, property, Object.getOwnPropertyDescriptor(electron, property)!); 315 } 316 } 317 318 // exports is a getter only on Windows, recreate export cache instead 319 const electronPath = require.resolve("electron"); 320 const cachedElectron = require.cache[electronPath]!; 321 require.cache[electronPath] = new Module(cachedElectron.id, require.main); 322 require.cache[electronPath]!.exports = electronClone; 323}