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