this repo has no description
at v1.3.2 10 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[]>) { 80 const directives = [ 81 "script-src", 82 "style-src", 83 "connect-src", 84 "img-src", 85 "font-src", 86 "media-src", 87 "worker-src", 88 "prefetch-src" 89 ]; 90 const values = ["*", "blob:", "data:", "'unsafe-inline'", "'unsafe-eval'", "disclip:"]; 91 92 const csp = "content-security-policy"; 93 if (headers[csp] == null) return; 94 95 // This parsing is jank af lol 96 const entries = headers[csp][0] 97 .trim() 98 .split(";") 99 .map((x) => x.trim()) 100 .filter((x) => x.length > 0) 101 .map((x) => x.split(" ")) 102 .map((x) => [x[0], x.slice(1)]); 103 const parts = Object.fromEntries(entries); 104 105 for (const directive of directives) { 106 parts[directive] = values; 107 } 108 109 const stringified = Object.entries<string[]>(parts) 110 .map(([key, value]) => { 111 return `${key} ${value.join(" ")}`; 112 }) 113 .join("; "); 114 headers[csp] = [stringified]; 115} 116 117class BrowserWindow extends ElectronBrowserWindow { 118 constructor(opts: BrowserWindowConstructorOptions) { 119 const isMainWindow = opts.webPreferences!.preload!.indexOf("discord_desktop_core") > -1; 120 121 if (isMainWindow) { 122 if (!oldPreloadPath) oldPreloadPath = opts.webPreferences!.preload; 123 opts.webPreferences!.preload = require.resolve("./node-preload.js"); 124 } 125 126 // Event for modifying window options 127 moonlightHost.events.emit("window-options", opts, isMainWindow); 128 129 super(opts); 130 131 // Event for when a window is created 132 moonlightHost.events.emit("window-created", this, isMainWindow); 133 134 this.webContents.session.webRequest.onHeadersReceived((details, cb) => { 135 if (details.responseHeaders != null) { 136 // Patch CSP so things can use externally hosted assets 137 if (details.resourceType === "mainFrame") { 138 patchCsp(details.responseHeaders); 139 } 140 141 // Allow plugins to bypass CORS for specific URLs 142 if (corsAllow.some((x) => details.url.startsWith(x))) { 143 details.responseHeaders["access-control-allow-origin"] = ["*"]; 144 } 145 146 cb({ cancel: false, responseHeaders: details.responseHeaders }); 147 } 148 }); 149 150 this.webContents.session.webRequest.onBeforeRequest((details, cb) => { 151 /* 152 In order to get moonlight loading to be truly async, we prevent Discord 153 from loading their scripts immediately. We block the requests, keep note 154 of their URLs, and then send them off to node-preload when we get all of 155 them. node-preload then loads node side, web side, and then recreates 156 the script elements to cause them to re-fetch. 157 158 The browser extension also does this, but in a background script (see 159 packages/browser/src/background.js - we should probably get this working 160 with esbuild someday). 161 */ 162 if (details.resourceType === "script" && isMainWindow) { 163 const url = new URL(details.url); 164 const hasUrl = scriptUrls.some((scriptUrl) => { 165 return details.url.includes(scriptUrl) && !url.searchParams.has("inj") && url.host.endsWith("discord.com"); 166 }); 167 if (hasUrl) blockedScripts.add(details.url); 168 169 if (blockedScripts.size === scriptUrls.length) { 170 setTimeout(() => { 171 logger.debug("Kicking off node-preload"); 172 this.webContents.send(constants.ipcNodePreloadKickoff, Array.from(blockedScripts)); 173 blockedScripts.clear(); 174 }, 0); 175 } 176 177 if (hasUrl) return cb({ cancel: true }); 178 } 179 180 // Allow plugins to block some URLs, 181 // this is needed because multiple webRequest handlers cannot be registered at once 182 cb({ cancel: blockedUrls.some((u) => u.test(details.url)) }); 183 }); 184 } 185} 186 187/* 188 Fun fact: esbuild transforms that BrowserWindow class statement into this: 189 190 var variableName = class extends electronImport.BrowserWindow { 191 ... 192 } 193 194 This means that in production builds, variableName is minified, and for some 195 ungodly reason this breaks electron (because it needs to be named BrowserWindow). 196 Without it, random things fail and crash (like opening DevTools). There is no 197 esbuild option to preserve only a single name, so you get the next best thing: 198*/ 199Object.defineProperty(BrowserWindow, "name", { 200 value: "BrowserWindow", 201 writable: false 202}); 203 204type InjectorConfig = { disablePersist?: boolean; disableLoad?: boolean }; 205export async function inject(asarPath: string, _injectorConfig?: InjectorConfig) { 206 injectorConfig = _injectorConfig; 207 208 global.moonlightNodeSandboxed = { 209 fs: createFS(), 210 // These aren't supposed to be used from host 211 addCors() {}, 212 addBlocked() {} 213 }; 214 215 try { 216 let config = await readConfig(); 217 initLogger(config); 218 const extensions = await getExtensions(); 219 const processedExtensions = await loadExtensions(extensions); 220 const moonlightDir = await getMoonlightDir(); 221 const extensionsPath = await getExtensionsPath(); 222 223 // Duplicated in node-preload... oops 224 function getConfig(ext: string) { 225 const val = config.extensions[ext]; 226 if (val == null || typeof val === "boolean") return undefined; 227 return val.config; 228 } 229 global.moonlightHost = { 230 get config() { 231 return config; 232 }, 233 extensions, 234 processedExtensions, 235 asarPath, 236 events: new EventEmitter(), 237 238 version: MOONLIGHT_VERSION, 239 branch: MOONLIGHT_BRANCH as MoonlightBranch, 240 241 getConfig, 242 getConfigPath, 243 getConfigOption(ext, name) { 244 const manifest = getManifest(extensions, ext); 245 return getConfigOption(ext, name, config, manifest?.settings); 246 }, 247 setConfigOption(ext, name, value) { 248 setConfigOption(config, ext, name, value); 249 this.writeConfig(config); 250 }, 251 async writeConfig(newConfig) { 252 await writeConfig(newConfig); 253 config = newConfig; 254 }, 255 256 getLogger(id) { 257 return new Logger(id); 258 }, 259 getMoonlightDir() { 260 return moonlightDir; 261 }, 262 getExtensionDir: (ext: string) => { 263 return path.join(extensionsPath, ext); 264 } 265 }; 266 267 patchElectron(); 268 269 await loadProcessedExtensions(global.moonlightHost.processedExtensions); 270 } catch (error) { 271 logger.error("Failed to inject:", error); 272 } 273 274 if (injectorConfig?.disablePersist !== true) { 275 persist(asarPath); 276 } 277 278 if (injectorConfig?.disableLoad !== true) { 279 // Need to do this instead of require() or it breaks require.main 280 // @ts-expect-error Module internals 281 Module._load(asarPath, Module, true); 282 } 283} 284 285function patchElectron() { 286 const electronClone = {}; 287 288 for (const property of Object.getOwnPropertyNames(electron)) { 289 if (property === "BrowserWindow") { 290 Object.defineProperty(electronClone, property, { 291 get: () => BrowserWindow, 292 enumerable: true, 293 configurable: false 294 }); 295 } else { 296 Object.defineProperty(electronClone, property, Object.getOwnPropertyDescriptor(electron, property)!); 297 } 298 } 299 300 // exports is a getter only on Windows, recreate export cache instead 301 const electronPath = require.resolve("electron"); 302 const cachedElectron = require.cache[electronPath]!; 303 require.cache[electronPath] = new Module(cachedElectron.id, require.main); 304 require.cache[electronPath]!.exports = electronClone; 305}