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