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