258 lines
7.6 kB
1
import {
2
ExtensionWebExports,
3
DetectedExtension,
4
ProcessedExtensions,
5
WebpackModuleFunc,
6
constants,
7
ExtensionManifest,
8
ExtensionEnvironment
9
} from "@moonlight-mod/types";
10
import { readConfig } from "../config";
11
import Logger from "../util/logger";
12
import { registerPatch, registerWebpackModule } from "../patch";
13
import calculateDependencies from "../util/dependency";
14
import { createEventEmitter } from "../util/event";
15
import { registerStyles } from "../styles";
16
import { WebEventPayloads, WebEventType } from "@moonlight-mod/types/core/event";
17
18
const logger = new Logger("core/extension/loader");
19
20
function evalIIFE(id: string, source: string): ExtensionWebExports {
21
const fn = new Function("require", "module", "exports", source);
22
23
const module = { id, exports: {} };
24
fn.apply(window, [
25
() => {
26
logger.warn("Attempted to require() from web");
27
},
28
module,
29
module.exports
30
]);
31
32
return module.exports;
33
}
34
35
async function evalEsm(source: string): Promise<ExtensionWebExports> {
36
// Data URLs (`data:`) don't seem to work under the CSP, but object URLs do
37
const url = URL.createObjectURL(new Blob([source], { type: "text/javascript" }));
38
39
const module = await import(url);
40
41
URL.revokeObjectURL(url);
42
43
return module;
44
}
45
46
async function loadExtWeb(ext: DetectedExtension) {
47
if (ext.scripts.web != null) {
48
const source = ext.scripts.web + `\n//# sourceURL=${ext.id}/web.js`;
49
50
let exports: ExtensionWebExports;
51
52
try {
53
exports = evalIIFE(ext.id, source);
54
} catch {
55
logger.trace(`Failed to load IIFE for extension ${ext.id}, trying ESM loading`);
56
exports = await evalEsm(source);
57
}
58
59
if (exports.patches != null) {
60
let idx = 0;
61
for (const patch of exports.patches) {
62
if (Array.isArray(patch.replace)) {
63
registerPatch({ ...patch, ext: ext.id, id: idx });
64
} else {
65
registerPatch({ ...patch, replace: [patch.replace], ext: ext.id, id: idx });
66
}
67
idx++;
68
}
69
}
70
71
if (exports.webpackModules != null) {
72
for (const [name, wp] of Object.entries(exports.webpackModules)) {
73
if (wp.run == null && ext.scripts.webpackModules?.[name] != null) {
74
const source = ext.scripts.webpackModules[name]! + `\n//# sourceURL=${ext.id}/webpackModules/${name}.js`;
75
const func = new Function("module", "exports", "require", source) as WebpackModuleFunc;
76
registerWebpackModule({
77
...wp,
78
ext: ext.id,
79
id: name,
80
run: func
81
});
82
} else {
83
registerWebpackModule({ ...wp, ext: ext.id, id: name });
84
}
85
}
86
}
87
88
if (exports.styles != null) {
89
registerStyles(exports.styles.map((style, i) => `/* ${ext.id}#${i} */ ${style}`));
90
}
91
if (ext.scripts.style != null) {
92
registerStyles([`/* ${ext.id}#style.css */ ${ext.scripts.style}`]);
93
}
94
}
95
}
96
97
async function loadExt(ext: DetectedExtension) {
98
webTarget: {
99
try {
100
await loadExtWeb(ext);
101
} catch (e) {
102
logger.error(`Failed to load extension "${ext.id}"`, e);
103
}
104
}
105
106
nodePreload: {
107
if (ext.scripts.nodePath != null) {
108
try {
109
const module = require(ext.scripts.nodePath);
110
moonlightNode.nativesCache[ext.id] = module;
111
} catch (e) {
112
logger.error(`Failed to load extension "${ext.id}"`, e);
113
}
114
}
115
}
116
117
injector: {
118
if (ext.scripts.hostPath != null) {
119
try {
120
require(ext.scripts.hostPath);
121
} catch (e) {
122
logger.error(`Failed to load extension "${ext.id}"`, e);
123
}
124
}
125
}
126
}
127
128
export enum ExtensionCompat {
129
Compatible,
130
InvalidApiLevel,
131
InvalidEnvironment
132
}
133
134
export function checkExtensionCompat(manifest: ExtensionManifest): ExtensionCompat {
135
let environment;
136
webTarget: {
137
environment = ExtensionEnvironment.Web;
138
}
139
nodeTarget: {
140
environment = ExtensionEnvironment.Desktop;
141
}
142
143
if (manifest.apiLevel !== constants.apiLevel) return ExtensionCompat.InvalidApiLevel;
144
if ((manifest.environment ?? "both") !== "both" && manifest.environment !== environment)
145
return ExtensionCompat.InvalidEnvironment;
146
return ExtensionCompat.Compatible;
147
}
148
149
/*
150
This function resolves extensions and loads them, split into a few stages:
151
152
- Duplicate detection (removing multiple extensions with the same internal ID)
153
- Dependency resolution (creating a dependency graph & detecting circular dependencies)
154
- Failed dependency pruning
155
- Implicit dependency resolution (enabling extensions that are dependencies of other extensions)
156
- Loading all extensions
157
158
Instead of constructing an order from the dependency graph and loading
159
extensions synchronously, we load them in parallel asynchronously. Loading
160
extensions fires an event on completion, which allows us to await the loading
161
of another extension, resolving dependencies & load order effectively.
162
*/
163
export async function loadExtensions(exts: DetectedExtension[]): Promise<ProcessedExtensions> {
164
exts = exts.filter((ext) => checkExtensionCompat(ext.manifest) === ExtensionCompat.Compatible);
165
166
const config = await readConfig();
167
const items = exts
168
.map((ext) => {
169
return {
170
id: ext.id,
171
data: ext
172
};
173
})
174
.sort((a, b) => a.id.localeCompare(b.id));
175
176
const [sorted, dependencyGraph] = calculateDependencies(items, {
177
fetchDep: (id) => {
178
return exts.find((x) => x.id === id) ?? null;
179
},
180
181
getDeps: (item) => {
182
return item.data.manifest.dependencies ?? [];
183
},
184
185
getIncompatible: (item) => {
186
return item.data.manifest.incompatible ?? [];
187
},
188
189
getEnabled: (item) => {
190
const entry = config.extensions[item.id];
191
if (entry == null) return false;
192
if (entry === true) return true;
193
if (typeof entry === "object" && entry.enabled === true) return true;
194
return false;
195
}
196
});
197
198
return {
199
extensions: sorted.map((x) => x.data),
200
dependencyGraph
201
};
202
}
203
204
export async function loadProcessedExtensions({ extensions, dependencyGraph }: ProcessedExtensions) {
205
const eventEmitter = createEventEmitter<WebEventType, WebEventPayloads>();
206
const finished: Set<string> = new Set();
207
208
logger.trace(
209
"Load stage - extension list:",
210
extensions.map((x) => x.id)
211
);
212
213
async function loadExtWithDependencies(ext: DetectedExtension) {
214
const deps = Array.from(dependencyGraph.get(ext.id)!);
215
216
// Wait for the dependencies to finish
217
const waitPromises = deps.map(
218
(dep: string) =>
219
new Promise<void>((r) => {
220
function cb(eventDep: string) {
221
if (eventDep === dep) {
222
done();
223
}
224
}
225
226
function done() {
227
eventEmitter.removeEventListener(WebEventType.ExtensionLoad, cb);
228
r();
229
}
230
231
eventEmitter.addEventListener(WebEventType.ExtensionLoad, cb);
232
if (finished.has(dep)) done();
233
})
234
);
235
236
if (waitPromises.length > 0) {
237
logger.debug(`Waiting on ${waitPromises.length} dependencies for "${ext.id}"`);
238
await Promise.all(waitPromises);
239
}
240
241
logger.debug(`Loading "${ext.id}"`);
242
await loadExt(ext);
243
244
finished.add(ext.id);
245
eventEmitter.dispatchEvent(WebEventType.ExtensionLoad, ext.id);
246
logger.debug(`Loaded "${ext.id}"`);
247
}
248
249
webTarget: {
250
for (const ext of extensions) {
251
moonlight.enabledExtensions.add(ext.id);
252
}
253
}
254
255
logger.debug("Loading all extensions");
256
await Promise.all(extensions.map(loadExtWithDependencies));
257
logger.info(`Loaded ${extensions.length} extensions`);
258
}
259