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