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