323 lines
11 kB
1
import electron, {
2
BrowserWindowConstructorOptions,
3
BrowserWindow as ElectronBrowserWindow,
4
ipcMain,
5
app
6
} from "electron";
7
import Module from "node:module";
8
import { constants, MoonlightBranch } from "@moonlight-mod/types";
9
import { readConfig, writeConfig } from "@moonlight-mod/core/config";
10
import { getExtensions } from "@moonlight-mod/core/extension";
11
import Logger, { initLogger } from "@moonlight-mod/core/util/logger";
12
import { loadExtensions, loadProcessedExtensions } from "@moonlight-mod/core/extension/loader";
13
import EventEmitter from "node:events";
14
import path from "node:path";
15
import persist from "@moonlight-mod/core/persist";
16
import createFS from "@moonlight-mod/core/fs";
17
import { getConfigOption, getManifest, setConfigOption } from "@moonlight-mod/core/util/config";
18
import { getConfigPath, getExtensionsPath, getMoonlightDir } from "@moonlight-mod/core/util/data";
19
20
const logger = new Logger("injector");
21
22
let oldPreloadPath: string | undefined;
23
let corsAllow: string[] = [];
24
let blockedUrls: RegExp[] = [];
25
let injectorConfig: InjectorConfig | undefined;
26
27
const scriptUrls = ["web.", "sentry."];
28
const blockedScripts = new Set<string>();
29
30
ipcMain.on(constants.ipcGetOldPreloadPath, (e) => {
31
e.returnValue = oldPreloadPath;
32
});
33
34
ipcMain.on(constants.ipcGetAppData, (e) => {
35
e.returnValue = app.getPath("appData");
36
});
37
ipcMain.on(constants.ipcGetInjectorConfig, (e) => {
38
e.returnValue = injectorConfig;
39
});
40
ipcMain.handle(constants.ipcMessageBox, (_, opts) => {
41
electron.dialog.showMessageBoxSync(opts);
42
});
43
ipcMain.handle(constants.ipcSetCorsList, (_, list) => {
44
corsAllow = list;
45
});
46
47
const reEscapeRegExp = /[\\^$.*+?()[\]{}|]/g;
48
const reMatchPattern = /^(?<scheme>\*|[a-z][a-z0-9+.-]*):\/\/(?<host>.+?)\/(?<path>.+)?$/;
49
50
const escapeRegExp = (s: string) => s.replace(reEscapeRegExp, "\\$&");
51
ipcMain.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
79
function 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
113
class 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
*/
217
Object.defineProperty(BrowserWindow, "name", {
218
value: "BrowserWindow",
219
writable: false
220
});
221
222
type InjectorConfig = { disablePersist?: boolean; disableLoad?: boolean };
223
export 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
303
function 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
}
324