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, writeConfig } 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 path from "node:path";
15import persist from "@moonlight-mod/core/persist";
16import createFS from "@moonlight-mod/core/fs";
17import { getConfigOption, getManifest, setConfigOption } from "@moonlight-mod/core/util/config";
18import { getConfigPath, getExtensionsPath, getMoonlightDir } from "@moonlight-mod/core/util/data";
19
20const logger = new Logger("injector");
21
22let oldPreloadPath: string | undefined;
23let corsAllow: string[] = [];
24let blockedUrls: RegExp[] = [];
25let injectorConfig: InjectorConfig | undefined;
26
27const scriptUrls = ["web.", "sentry."];
28const blockedScripts = new Set<string>();
29
30ipcMain.on(constants.ipcGetOldPreloadPath, (e) => {
31 e.returnValue = oldPreloadPath;
32});
33
34ipcMain.on(constants.ipcGetAppData, (e) => {
35 e.returnValue = app.getPath("appData");
36});
37ipcMain.on(constants.ipcGetInjectorConfig, (e) => {
38 e.returnValue = injectorConfig;
39});
40ipcMain.handle(constants.ipcMessageBox, (_, opts) => {
41 electron.dialog.showMessageBoxSync(opts);
42});
43ipcMain.handle(constants.ipcSetCorsList, (_, list) => {
44 corsAllow = list;
45});
46
47const reEscapeRegExp = /[\\^$.*+?()[\]{}|]/g;
48const reMatchPattern = /^(?<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[]>, extensionCspOverrides: Record<string, string[]>) {
80 const directives = ["script-src", "style-src", "connect-src", "img-src", "font-src", "media-src", "worker-src"];
81 const values = ["*", "blob:", "data:", "'unsafe-inline'", "'unsafe-eval'", "disclip:"];
82
83 const csp = "content-security-policy";
84 if (headers[csp] == null) return;
85
86 // This parsing is jank af lol
87 const entries = headers[csp][0]
88 .trim()
89 .split(";")
90 .map((x) => x.trim())
91 .filter((x) => x.length > 0)
92 .map((x) => x.split(" "))
93 .map((x) => [x[0], x.slice(1)]);
94 const parts = Object.fromEntries(entries);
95
96 for (const directive of directives) {
97 parts[directive] = values;
98 }
99
100 for (const [directive, urls] of Object.entries(extensionCspOverrides)) {
101 parts[directive] ??= [];
102 parts[directive].push(...urls);
103 }
104
105 const stringified = Object.entries<string[]>(parts)
106 .map(([key, value]) => {
107 return `${key} ${value.join(" ")}`;
108 })
109 .join("; ");
110 headers[csp] = [stringified];
111}
112
113class BrowserWindow extends ElectronBrowserWindow {
114 constructor(opts: BrowserWindowConstructorOptions) {
115 const isMainWindow = opts.webPreferences!.preload!.indexOf("discord_desktop_core") > -1;
116
117 if (isMainWindow) {
118 if (!oldPreloadPath) oldPreloadPath = opts.webPreferences!.preload;
119 opts.webPreferences!.preload = require.resolve("./node-preload.js");
120 }
121
122 // Event for modifying window options
123 moonlightHost.events.emit("window-options", opts, isMainWindow);
124
125 super(opts);
126
127 // Event for when a window is created
128 moonlightHost.events.emit("window-created", this, isMainWindow);
129
130 const extensionCspOverrides: Record<string, string[]> = {};
131
132 {
133 const extCsps = moonlightHost.processedExtensions.extensions.map((x) => x.manifest.csp ?? {});
134 for (const csp of extCsps) {
135 for (const [directive, urls] of Object.entries(csp)) {
136 extensionCspOverrides[directive] ??= [];
137 extensionCspOverrides[directive].push(...urls);
138 }
139 }
140 }
141
142 this.webContents.session.webRequest.onHeadersReceived((details, cb) => {
143 if (details.responseHeaders != null) {
144 // Patch CSP so things can use externally hosted assets
145 if (details.resourceType === "mainFrame") {
146 patchCsp(details.responseHeaders, extensionCspOverrides);
147 }
148
149 // Allow plugins to bypass CORS for specific URLs
150 if (corsAllow.some((x) => details.url.startsWith(x))) {
151 if (!details.responseHeaders) details.responseHeaders = {};
152
153 // Work around HTTP header case sensitivity by reusing the header name if it exists
154 // https://github.com/moonlight-mod/moonlight/issues/201
155 const fallback = "access-control-allow-origin";
156 const key = Object.keys(details.responseHeaders).find((h) => h.toLowerCase() === fallback) ?? fallback;
157 details.responseHeaders[key] = ["*"];
158 }
159
160 moonlightHost.events.emit("headers-received", details, isMainWindow);
161
162 cb({ cancel: false, responseHeaders: details.responseHeaders });
163 }
164 });
165
166 this.webContents.session.webRequest.onBeforeRequest((details, cb) => {
167 /*
168 In order to get moonlight loading to be truly async, we prevent Discord
169 from loading their scripts immediately. We block the requests, keep note
170 of their URLs, and then send them off to node-preload when we get all of
171 them. node-preload then loads node side, web side, and then recreates
172 the script elements to cause them to re-fetch.
173
174 The browser extension also does this, but in a background script (see
175 packages/browser/src/background.js - we should probably get this working
176 with esbuild someday).
177 */
178 if (details.resourceType === "script" && isMainWindow) {
179 const url = new URL(details.url);
180 const hasUrl = scriptUrls.some((scriptUrl) => {
181 return (
182 details.url.includes(scriptUrl) &&
183 !url.searchParams.has("inj") &&
184 (url.host.endsWith("discord.com") || url.host.endsWith("discordapp.com"))
185 );
186 });
187 if (hasUrl) blockedScripts.add(details.url);
188
189 if (blockedScripts.size === scriptUrls.length) {
190 setTimeout(() => {
191 logger.debug("Kicking off node-preload");
192 this.webContents.send(constants.ipcNodePreloadKickoff, Array.from(blockedScripts));
193 blockedScripts.clear();
194 }, 0);
195 }
196
197 if (hasUrl) return cb({ cancel: true });
198 }
199
200 // Allow plugins to block some URLs,
201 // this is needed because multiple webRequest handlers cannot be registered at once
202 cb({ cancel: blockedUrls.some((u) => u.test(details.url)) });
203 });
204 }
205}
206
207/*
208 Fun fact: esbuild transforms that BrowserWindow class statement into this:
209
210 var variableName = class extends electronImport.BrowserWindow {
211 ...
212 }
213
214 This means that in production builds, variableName is minified, and for some
215 ungodly reason this breaks electron (because it needs to be named BrowserWindow).
216 Without it, random things fail and crash (like opening DevTools). There is no
217 esbuild option to preserve only a single name, so you get the next best thing:
218*/
219Object.defineProperty(BrowserWindow, "name", {
220 value: "BrowserWindow",
221 writable: false
222});
223
224type InjectorConfig = { disablePersist?: boolean; disableLoad?: boolean };
225export async function inject(asarPath: string, _injectorConfig?: InjectorConfig) {
226 injectorConfig = _injectorConfig;
227
228 global.moonlightNodeSandboxed = {
229 fs: createFS(),
230 // These aren't supposed to be used from host
231 addCors() {},
232 addBlocked() {}
233 };
234
235 try {
236 let config = await readConfig();
237 initLogger(config);
238 const extensions = await getExtensions();
239 const processedExtensions = await loadExtensions(extensions);
240 const moonlightDir = await getMoonlightDir();
241 const extensionsPath = await getExtensionsPath();
242
243 // Duplicated in node-preload... oops
244 function getConfig(ext: string) {
245 const val = config.extensions[ext];
246 if (val == null || typeof val === "boolean") return undefined;
247 return val.config;
248 }
249 global.moonlightHost = {
250 get config() {
251 return config;
252 },
253 extensions,
254 processedExtensions,
255 asarPath,
256 events: new EventEmitter(),
257
258 version: MOONLIGHT_VERSION,
259 branch: MOONLIGHT_BRANCH as MoonlightBranch,
260
261 getConfig,
262 getConfigPath,
263 getConfigOption(ext, name) {
264 const manifest = getManifest(extensions, ext);
265 return getConfigOption(ext, name, config, manifest?.settings);
266 },
267 setConfigOption(ext, name, value) {
268 setConfigOption(config, ext, name, value);
269 this.writeConfig(config);
270 },
271 async writeConfig(newConfig) {
272 await writeConfig(newConfig);
273 config = newConfig;
274 },
275
276 getLogger(id) {
277 return new Logger(id);
278 },
279 getMoonlightDir() {
280 return moonlightDir;
281 },
282 getExtensionDir: (ext: string) => {
283 return path.join(extensionsPath, ext);
284 }
285 };
286
287 patchElectron();
288
289 await loadProcessedExtensions(global.moonlightHost.processedExtensions);
290 } catch (error) {
291 logger.error("Failed to inject:", error);
292 }
293
294 if (injectorConfig?.disablePersist !== true) {
295 persist(asarPath);
296 }
297
298 if (injectorConfig?.disableLoad !== true) {
299 // Need to do this instead of require() or it breaks require.main
300 // @ts-expect-error Module internals
301 Module._load(asarPath, Module, true);
302 }
303}
304
305function patchElectron() {
306 const electronClone = {};
307
308 for (const property of Object.getOwnPropertyNames(electron)) {
309 if (property === "BrowserWindow") {
310 Object.defineProperty(electronClone, property, {
311 get: () => BrowserWindow,
312 enumerable: true,
313 configurable: false
314 });
315 } else {
316 Object.defineProperty(electronClone, property, Object.getOwnPropertyDescriptor(electron, property)!);
317 }
318 }
319
320 // exports is a getter only on Windows, recreate export cache instead
321 const electronPath = require.resolve("electron");
322 const cachedElectron = require.cache[electronPath]!;
323 require.cache[electronPath] = new Module(cachedElectron.id, require.main);
324 require.cache[electronPath]!.exports = electronClone;
325}