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