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, 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}