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