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