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