339 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 } 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 { join, resolve } from "node:path";
15
import persist from "@moonlight-mod/core/persist";
16
import createFS from "@moonlight-mod/core/fs";
17
18
const logger = new Logger("injector");
19
20
let oldPreloadPath: string | undefined;
21
let corsAllow: string[] = [];
22
let blockedUrls: RegExp[] = [];
23
let isMoonlightDesktop = false;
24
let hasOpenAsar = false;
25
let openAsarConfigPreload: string | 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.ipcGetIsMoonlightDesktop, (e) => {
38
e.returnValue = isMoonlightDesktop;
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 = [
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
117
function removeOpenAsarEventIfPresent(eventHandler: (...args: any[]) => void) {
118
const code = eventHandler.toString();
119
if (code.indexOf("bw.webContents.on('dom-ready'") > -1) {
120
electron.app.off("browser-window-created", eventHandler);
121
}
122
}
123
124
class BrowserWindow extends ElectronBrowserWindow {
125
constructor(opts: BrowserWindowConstructorOptions) {
126
const isMainWindow = opts.webPreferences!.preload!.indexOf("discord_desktop_core") > -1;
127
128
if (isMainWindow) {
129
if (!oldPreloadPath) oldPreloadPath = opts.webPreferences!.preload;
130
opts.webPreferences!.preload = require.resolve("./node-preload.js");
131
}
132
133
// Event for modifying window options
134
moonlightHost.events.emit("window-options", opts, isMainWindow);
135
136
super(opts);
137
138
// Event for when a window is created
139
moonlightHost.events.emit("window-created", this, isMainWindow);
140
141
this.webContents.session.webRequest.onHeadersReceived((details, cb) => {
142
if (details.responseHeaders != null) {
143
// Patch CSP so things can use externally hosted assets
144
if (details.resourceType === "mainFrame") {
145
patchCsp(details.responseHeaders);
146
}
147
148
// Allow plugins to bypass CORS for specific URLs
149
if (corsAllow.some((x) => details.url.startsWith(x))) {
150
details.responseHeaders["access-control-allow-origin"] = ["*"];
151
}
152
153
cb({ cancel: false, responseHeaders: details.responseHeaders });
154
}
155
});
156
157
this.webContents.session.webRequest.onBeforeRequest((details, cb) => {
158
/*
159
In order to get moonlight loading to be truly async, we prevent Discord
160
from loading their scripts immediately. We block the requests, keep note
161
of their URLs, and then send them off to node-preload when we get all of
162
them. node-preload then loads node side, web side, and then recreates
163
the script elements to cause them to re-fetch.
164
165
The browser extension also does this, but in a background script (see
166
packages/browser/src/background.js - we should probably get this working
167
with esbuild someday).
168
*/
169
if (details.resourceType === "script" && isMainWindow) {
170
const hasUrl = scriptUrls.some((url) => details.url.includes(url) && !details.url.includes("?inj"));
171
if (hasUrl) blockedScripts.add(details.url);
172
173
if (blockedScripts.size === scriptUrls.length) {
174
setTimeout(() => {
175
logger.debug("Kicking off node-preload");
176
this.webContents.send(constants.ipcNodePreloadKickoff, Array.from(blockedScripts));
177
blockedScripts.clear();
178
}, 0);
179
}
180
181
if (hasUrl) return cb({ cancel: true });
182
}
183
184
// Allow plugins to block some URLs,
185
// this is needed because multiple webRequest handlers cannot be registered at once
186
cb({ cancel: blockedUrls.some((u) => u.test(details.url)) });
187
});
188
189
if (hasOpenAsar) {
190
// Remove DOM injections
191
// Settings can still be opened via:
192
// `DiscordNative.ipc.send("DISCORD_UPDATED_QUOTES","o")`
193
// @ts-expect-error Electron internals
194
const events = electron.app._events["browser-window-created"];
195
if (Array.isArray(events)) {
196
for (const event of events) {
197
removeOpenAsarEventIfPresent(event);
198
}
199
} else if (events != null) {
200
removeOpenAsarEventIfPresent(events);
201
}
202
203
// Config screen fails to context bridge properly
204
// Less than ideal, but better than disabling it everywhere
205
if (opts.webPreferences!.preload === openAsarConfigPreload) {
206
opts.webPreferences!.sandbox = false;
207
}
208
}
209
}
210
}
211
212
/*
213
Fun fact: esbuild transforms that BrowserWindow class statement into this:
214
215
var variableName = class extends electronImport.BrowserWindow {
216
...
217
}
218
219
This means that in production builds, variableName is minified, and for some
220
ungodly reason this breaks electron (because it needs to be named BrowserWindow).
221
Without it, random things fail and crash (like opening DevTools). There is no
222
esbuild option to preserve only a single name, so you get the next best thing:
223
*/
224
Object.defineProperty(BrowserWindow, "name", {
225
value: "BrowserWindow",
226
writable: false
227
});
228
229
export async function inject(asarPath: string) {
230
isMoonlightDesktop = asarPath === "moonlightDesktop";
231
global.moonlightNodeSandboxed = {
232
fs: createFS(),
233
// These aren't supposed to be used from host
234
addCors(url) {},
235
addBlocked(url) {}
236
};
237
238
try {
239
const config = await readConfig();
240
initLogger(config);
241
const extensions = await getExtensions();
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
250
global.moonlightHost = {
251
asarPath,
252
config,
253
events: new EventEmitter(),
254
extensions,
255
processedExtensions: {
256
extensions: [],
257
dependencyGraph: new Map()
258
},
259
260
version: MOONLIGHT_VERSION,
261
branch: MOONLIGHT_BRANCH as MoonlightBranch,
262
263
getConfig,
264
getConfigOption: <T>(ext: string, name: string) => {
265
const config = getConfig(ext);
266
if (config == null) return undefined;
267
const option = config[name];
268
if (option == null) return undefined;
269
return option as T;
270
},
271
getLogger: (id: string) => {
272
return new Logger(id);
273
}
274
};
275
276
// Check if we're running with OpenAsar
277
try {
278
require.resolve(join(asarPath, "updater", "updater.js"));
279
hasOpenAsar = true;
280
openAsarConfigPreload = resolve(asarPath, "config", "preload.js");
281
// eslint-disable-next-line no-empty
282
} catch {}
283
284
if (hasOpenAsar) {
285
// Disable command line switch injection
286
// I personally think that the command line switches should be vetted by
287
// the user and not just "trust that these are sane defaults that work
288
// always". I'm not hating on Ducko or anything, I'm just opinionated.
289
// Someone can always make a command line modifier plugin, thats the point
290
// of having host modules.
291
try {
292
const cmdSwitchesPath = require.resolve(join(asarPath, "cmdSwitches.js"));
293
require.cache[cmdSwitchesPath] = new Module(cmdSwitchesPath, require.cache[require.resolve(asarPath)]);
294
require.cache[cmdSwitchesPath]!.exports = () => {};
295
} catch (error) {
296
logger.error("Failed to disable OpenAsar's command line flags:", error);
297
}
298
}
299
300
patchElectron();
301
302
global.moonlightHost.processedExtensions = await loadExtensions(extensions);
303
await loadProcessedExtensions(global.moonlightHost.processedExtensions);
304
} catch (error) {
305
logger.error("Failed to inject:", error);
306
}
307
308
if (isMoonlightDesktop) return;
309
310
if (!hasOpenAsar && !isMoonlightDesktop) {
311
persist(asarPath);
312
}
313
314
// Need to do this instead of require() or it breaks require.main
315
// @ts-expect-error Module internals
316
Module._load(asarPath, Module, true);
317
}
318
319
function patchElectron() {
320
const electronClone = {};
321
322
for (const property of Object.getOwnPropertyNames(electron)) {
323
if (property === "BrowserWindow") {
324
Object.defineProperty(electronClone, property, {
325
get: () => BrowserWindow,
326
enumerable: true,
327
configurable: false
328
});
329
} else {
330
Object.defineProperty(electronClone, property, Object.getOwnPropertyDescriptor(electron, property)!);
331
}
332
}
333
334
// exports is a getter only on Windows, recreate export cache instead
335
const electronPath = require.resolve("electron");
336
const cachedElectron = require.cache[electronPath]!;
337
require.cache[electronPath] = new Module(cachedElectron.id, require.main);
338
require.cache[electronPath]!.exports = electronClone;
339
}
340