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";
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}