this repo has no description
at v1.2.5 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 } 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 { join, resolve } from "node:path"; 15import persist from "@moonlight-mod/core/persist"; 16import createFS from "@moonlight-mod/core/fs"; 17 18const logger = new Logger("injector"); 19 20let oldPreloadPath: string | undefined; 21let corsAllow: string[] = []; 22let blockedUrls: RegExp[] = []; 23let isMoonlightDesktop = false; 24let hasOpenAsar = false; 25let openAsarConfigPreload: string | undefined; 26 27ipcMain.on(constants.ipcGetOldPreloadPath, (e) => { 28 e.returnValue = oldPreloadPath; 29}); 30ipcMain.on(constants.ipcGetAppData, (e) => { 31 e.returnValue = app.getPath("appData"); 32}); 33ipcMain.on(constants.ipcGetIsMoonlightDesktop, (e) => { 34 e.returnValue = isMoonlightDesktop; 35}); 36ipcMain.handle(constants.ipcMessageBox, (_, opts) => { 37 electron.dialog.showMessageBoxSync(opts); 38}); 39ipcMain.handle(constants.ipcSetCorsList, (_, list) => { 40 corsAllow = list; 41}); 42 43const reEscapeRegExp = /[\\^$.*+?()[\]{}|]/g; 44const reMatchPattern = /^(?<scheme>\*|[a-z][a-z0-9+.-]*):\/\/(?<host>.+?)\/(?<path>.+)?$/; 45 46const escapeRegExp = (s: string) => s.replace(reEscapeRegExp, "\\$&"); 47ipcMain.handle(constants.ipcSetBlockedList, (_, list: string[]) => { 48 // We compile the patterns into a RegExp based on a janky match pattern-like syntax 49 const compiled = list 50 .map((pattern) => { 51 const match = pattern.match(reMatchPattern); 52 if (!match?.groups) return; 53 54 let regex = ""; 55 if (match.groups.scheme === "*") regex += ".+?"; 56 else regex += escapeRegExp(match.groups.scheme); 57 regex += ":\\/\\/"; 58 59 const parts = match.groups.host.split("."); 60 if (parts[0] === "*") { 61 parts.shift(); 62 regex += "(?:.+?\\.)?"; 63 } 64 regex += escapeRegExp(parts.join(".")); 65 66 regex += "\\/" + escapeRegExp(match.groups.path).replace("\\*", ".*?"); 67 68 return new RegExp("^" + regex + "$"); 69 }) 70 .filter(Boolean) as RegExp[]; 71 72 blockedUrls = compiled; 73}); 74 75function patchCsp(headers: Record<string, string[]>) { 76 const directives = ["style-src", "connect-src", "img-src", "font-src", "media-src", "worker-src", "prefetch-src"]; 77 const values = ["*", "blob:", "data:", "'unsafe-inline'", "disclip:"]; 78 79 const csp = "content-security-policy"; 80 if (headers[csp] == null) return; 81 82 // This parsing is jank af lol 83 const entries = headers[csp][0] 84 .trim() 85 .split(";") 86 .map((x) => x.trim()) 87 .filter((x) => x.length > 0) 88 .map((x) => x.split(" ")) 89 .map((x) => [x[0], x.slice(1)]); 90 const parts = Object.fromEntries(entries); 91 92 for (const directive of directives) { 93 parts[directive] = values; 94 } 95 96 const stringified = Object.entries<string[]>(parts) 97 .map(([key, value]) => { 98 return `${key} ${value.join(" ")}`; 99 }) 100 .join("; "); 101 headers[csp] = [stringified]; 102} 103 104function removeOpenAsarEventIfPresent(eventHandler: (...args: any[]) => void) { 105 const code = eventHandler.toString(); 106 if (code.indexOf("bw.webContents.on('dom-ready'") > -1) { 107 electron.app.off("browser-window-created", eventHandler); 108 } 109} 110 111class BrowserWindow extends ElectronBrowserWindow { 112 constructor(opts: BrowserWindowConstructorOptions) { 113 oldPreloadPath = opts.webPreferences!.preload; 114 115 const isMainWindow = opts.webPreferences!.preload!.indexOf("discord_desktop_core") > -1; 116 117 if (isMainWindow) opts.webPreferences!.preload = require.resolve("./node-preload.js"); 118 119 // Event for modifying window options 120 moonlightHost.events.emit("window-options", opts, isMainWindow); 121 122 super(opts); 123 124 // Event for when a window is created 125 moonlightHost.events.emit("window-created", this, isMainWindow); 126 127 this.webContents.session.webRequest.onHeadersReceived((details, cb) => { 128 if (details.responseHeaders != null) { 129 // Patch CSP so things can use externally hosted assets 130 if (details.resourceType === "mainFrame") { 131 patchCsp(details.responseHeaders); 132 } 133 134 // Allow plugins to bypass CORS for specific URLs 135 if (corsAllow.some((x) => details.url.startsWith(x))) { 136 details.responseHeaders["access-control-allow-origin"] = ["*"]; 137 } 138 139 cb({ cancel: false, responseHeaders: details.responseHeaders }); 140 } 141 }); 142 143 // Allow plugins to block some URLs, 144 // this is needed because multiple webRequest handlers cannot be registered at once 145 this.webContents.session.webRequest.onBeforeRequest((details, cb) => { 146 cb({ cancel: blockedUrls.some((u) => u.test(details.url)) }); 147 }); 148 149 if (hasOpenAsar) { 150 // Remove DOM injections 151 // Settings can still be opened via: 152 // `DiscordNative.ipc.send("DISCORD_UPDATED_QUOTES","o")` 153 // @ts-expect-error Electron internals 154 const events = electron.app._events["browser-window-created"]; 155 if (Array.isArray(events)) { 156 for (const event of events) { 157 removeOpenAsarEventIfPresent(event); 158 } 159 } else if (events != null) { 160 removeOpenAsarEventIfPresent(events); 161 } 162 163 // Config screen fails to context bridge properly 164 // Less than ideal, but better than disabling it everywhere 165 if (opts.webPreferences!.preload === openAsarConfigPreload) { 166 opts.webPreferences!.sandbox = false; 167 } 168 } 169 } 170} 171 172/* 173 Fun fact: esbuild transforms that BrowserWindow class statement into this: 174 175 var variableName = class extends electronImport.BrowserWindow { 176 ... 177 } 178 179 This means that in production builds, variableName is minified, and for some 180 ungodly reason this breaks electron (because it needs to be named BrowserWindow). 181 Without it, random things fail and crash (like opening DevTools). There is no 182 esbuild option to preserve only a single name, so you get the next best thing: 183*/ 184Object.defineProperty(BrowserWindow, "name", { 185 value: "BrowserWindow", 186 writable: false 187}); 188 189export async function inject(asarPath: string) { 190 isMoonlightDesktop = asarPath === "moonlightDesktop"; 191 global.moonlightNodeSandboxed = { 192 fs: createFS(), 193 // These aren't supposed to be used from host 194 addCors(url) {}, 195 addBlocked(url) {} 196 }; 197 198 try { 199 const config = await readConfig(); 200 initLogger(config); 201 const extensions = await getExtensions(); 202 203 // Duplicated in node-preload... oops 204 function getConfig(ext: string) { 205 const val = config.extensions[ext]; 206 if (val == null || typeof val === "boolean") return undefined; 207 return val.config; 208 } 209 210 global.moonlightHost = { 211 asarPath, 212 config, 213 events: new EventEmitter(), 214 extensions, 215 processedExtensions: { 216 extensions: [], 217 dependencyGraph: new Map() 218 }, 219 220 version: MOONLIGHT_VERSION, 221 branch: MOONLIGHT_BRANCH as MoonlightBranch, 222 223 getConfig, 224 getConfigOption: <T>(ext: string, name: string) => { 225 const config = getConfig(ext); 226 if (config == null) return undefined; 227 const option = config[name]; 228 if (option == null) return undefined; 229 return option as T; 230 }, 231 getLogger: (id: string) => { 232 return new Logger(id); 233 } 234 }; 235 236 // Check if we're running with OpenAsar 237 try { 238 require.resolve(join(asarPath, "updater", "updater.js")); 239 hasOpenAsar = true; 240 openAsarConfigPreload = resolve(asarPath, "config", "preload.js"); 241 // eslint-disable-next-line no-empty 242 } catch {} 243 244 if (hasOpenAsar) { 245 // Disable command line switch injection 246 // I personally think that the command line switches should be vetted by 247 // the user and not just "trust that these are sane defaults that work 248 // always". I'm not hating on Ducko or anything, I'm just opinionated. 249 // Someone can always make a command line modifier plugin, thats the point 250 // of having host modules. 251 try { 252 const cmdSwitchesPath = require.resolve(join(asarPath, "cmdSwitches.js")); 253 require.cache[cmdSwitchesPath] = new Module(cmdSwitchesPath, require.cache[require.resolve(asarPath)]); 254 require.cache[cmdSwitchesPath]!.exports = () => {}; 255 } catch (error) { 256 logger.error("Failed to disable OpenAsar's command line flags:", error); 257 } 258 } 259 260 patchElectron(); 261 262 global.moonlightHost.processedExtensions = await loadExtensions(extensions); 263 await loadProcessedExtensions(global.moonlightHost.processedExtensions); 264 } catch (error) { 265 logger.error("Failed to inject:", error); 266 } 267 268 if (isMoonlightDesktop) return; 269 270 if (!hasOpenAsar && !isMoonlightDesktop) { 271 persist(asarPath); 272 } 273 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 279function patchElectron() { 280 const electronClone = {}; 281 282 for (const property of Object.getOwnPropertyNames(electron)) { 283 if (property === "BrowserWindow") { 284 Object.defineProperty(electronClone, property, { 285 get: () => BrowserWindow, 286 enumerable: true, 287 configurable: false 288 }); 289 } else { 290 Object.defineProperty(electronClone, property, Object.getOwnPropertyDescriptor(electron, property)!); 291 } 292 } 293 294 // exports is a getter only on Windows, recreate export cache instead 295 const electronPath = require.resolve("electron"); 296 const cachedElectron = require.cache[electronPath]!; 297 require.cache[electronPath] = new Module(cachedElectron.id, require.main); 298 require.cache[electronPath]!.exports = electronClone; 299}