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