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