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