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