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