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 cb({ cancel: false, responseHeaders: details.responseHeaders });
161 }
162 });
163
164 this.webContents.session.webRequest.onBeforeRequest((details, cb) => {
165 /*
166 In order to get moonlight loading to be truly async, we prevent Discord
167 from loading their scripts immediately. We block the requests, keep note
168 of their URLs, and then send them off to node-preload when we get all of
169 them. node-preload then loads node side, web side, and then recreates
170 the script elements to cause them to re-fetch.
171
172 The browser extension also does this, but in a background script (see
173 packages/browser/src/background.js - we should probably get this working
174 with esbuild someday).
175 */
176 if (details.resourceType === "script" && isMainWindow) {
177 const url = new URL(details.url);
178 const hasUrl = scriptUrls.some((scriptUrl) => {
179 return (
180 details.url.includes(scriptUrl) &&
181 !url.searchParams.has("inj") &&
182 (url.host.endsWith("discord.com") || url.host.endsWith("discordapp.com"))
183 );
184 });
185 if (hasUrl) blockedScripts.add(details.url);
186
187 if (blockedScripts.size === scriptUrls.length) {
188 setTimeout(() => {
189 logger.debug("Kicking off node-preload");
190 this.webContents.send(constants.ipcNodePreloadKickoff, Array.from(blockedScripts));
191 blockedScripts.clear();
192 }, 0);
193 }
194
195 if (hasUrl) return cb({ cancel: true });
196 }
197
198 // Allow plugins to block some URLs,
199 // this is needed because multiple webRequest handlers cannot be registered at once
200 cb({ cancel: blockedUrls.some((u) => u.test(details.url)) });
201 });
202 }
203}
204
205/*
206 Fun fact: esbuild transforms that BrowserWindow class statement into this:
207
208 var variableName = class extends electronImport.BrowserWindow {
209 ...
210 }
211
212 This means that in production builds, variableName is minified, and for some
213 ungodly reason this breaks electron (because it needs to be named BrowserWindow).
214 Without it, random things fail and crash (like opening DevTools). There is no
215 esbuild option to preserve only a single name, so you get the next best thing:
216*/
217Object.defineProperty(BrowserWindow, "name", {
218 value: "BrowserWindow",
219 writable: false
220});
221
222type InjectorConfig = { disablePersist?: boolean; disableLoad?: boolean };
223export async function inject(asarPath: string, _injectorConfig?: InjectorConfig) {
224 injectorConfig = _injectorConfig;
225
226 global.moonlightNodeSandboxed = {
227 fs: createFS(),
228 // These aren't supposed to be used from host
229 addCors() {},
230 addBlocked() {}
231 };
232
233 try {
234 let config = await readConfig();
235 initLogger(config);
236 const extensions = await getExtensions();
237 const processedExtensions = await loadExtensions(extensions);
238 const moonlightDir = await getMoonlightDir();
239 const extensionsPath = await getExtensionsPath();
240
241 // Duplicated in node-preload... oops
242 function getConfig(ext: string) {
243 const val = config.extensions[ext];
244 if (val == null || typeof val === "boolean") return undefined;
245 return val.config;
246 }
247 global.moonlightHost = {
248 get config() {
249 return config;
250 },
251 extensions,
252 processedExtensions,
253 asarPath,
254 events: new EventEmitter(),
255
256 version: MOONLIGHT_VERSION,
257 branch: MOONLIGHT_BRANCH as MoonlightBranch,
258
259 getConfig,
260 getConfigPath,
261 getConfigOption(ext, name) {
262 const manifest = getManifest(extensions, ext);
263 return getConfigOption(ext, name, config, manifest?.settings);
264 },
265 setConfigOption(ext, name, value) {
266 setConfigOption(config, ext, name, value);
267 this.writeConfig(config);
268 },
269 async writeConfig(newConfig) {
270 await writeConfig(newConfig);
271 config = newConfig;
272 },
273
274 getLogger(id) {
275 return new Logger(id);
276 },
277 getMoonlightDir() {
278 return moonlightDir;
279 },
280 getExtensionDir: (ext: string) => {
281 return path.join(extensionsPath, ext);
282 }
283 };
284
285 patchElectron();
286
287 await loadProcessedExtensions(global.moonlightHost.processedExtensions);
288 } catch (error) {
289 logger.error("Failed to inject:", error);
290 }
291
292 if (injectorConfig?.disablePersist !== true) {
293 persist(asarPath);
294 }
295
296 if (injectorConfig?.disableLoad !== true) {
297 // Need to do this instead of require() or it breaks require.main
298 // @ts-expect-error Module internals
299 Module._load(asarPath, Module, true);
300 }
301}
302
303function patchElectron() {
304 const electronClone = {};
305
306 for (const property of Object.getOwnPropertyNames(electron)) {
307 if (property === "BrowserWindow") {
308 Object.defineProperty(electronClone, property, {
309 get: () => BrowserWindow,
310 enumerable: true,
311 configurable: false
312 });
313 } else {
314 Object.defineProperty(electronClone, property, Object.getOwnPropertyDescriptor(electron, property)!);
315 }
316 }
317
318 // exports is a getter only on Windows, recreate export cache instead
319 const electronPath = require.resolve("electron");
320 const cachedElectron = require.cache[electronPath]!;
321 require.cache[electronPath] = new Module(cachedElectron.id, require.main);
322 require.cache[electronPath]!.exports = electronClone;
323}