this repo has no description
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}