this repo has no description
1import {
2 ExtensionWebExports,
3 DetectedExtension,
4 ProcessedExtensions,
5 WebpackModuleFunc,
6 constants,
7 ExtensionManifest,
8 ExtensionEnvironment
9} from "@moonlight-mod/types";
10import { readConfig } from "../config";
11import Logger from "../util/logger";
12import { registerPatch, registerWebpackModule } from "../patch";
13import calculateDependencies from "../util/dependency";
14import { createEventEmitter } from "../util/event";
15import { registerStyles } from "../styles";
16import { WebEventPayloads, WebEventType } from "@moonlight-mod/types/core/event";
17
18const logger = new Logger("core/extension/loader");
19
20function 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
35async 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
46async 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
97async 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
128export enum ExtensionCompat {
129 Compatible,
130 InvalidApiLevel,
131 InvalidEnvironment
132}
133
134export 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*/
163export 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
204export 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}