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