this repo has no description
1import { Config, ExtensionLoadSource } from "@moonlight-mod/types";
2import {
3 ExtensionState,
4 MoonbaseExtension,
5 MoonbaseNatives,
6 RepositoryManifest
7} from "../types";
8import { Store } from "@moonlight-mod/wp/discord/packages/flux";
9import Dispatcher from "@moonlight-mod/wp/discord/Dispatcher";
10import getNatives from "../native";
11import { mainRepo } from "@moonlight-mod/types/constants";
12import {
13 checkExtensionCompat,
14 ExtensionCompat
15} from "@moonlight-mod/core/extension/loader";
16import { CustomComponent } from "@moonlight-mod/types/coreExtensions/moonbase";
17
18const logger = moonlight.getLogger("moonbase");
19
20let natives: MoonbaseNatives = moonlight.getNatives("moonbase");
21if (moonlightNode.isBrowser) natives = getNatives();
22
23class MoonbaseSettingsStore extends Store<any> {
24 private origConfig: Config;
25 private config: Config;
26 private extensionIndex: number;
27 private configComponents: Record<string, Record<string, CustomComponent>> =
28 {};
29
30 modified: boolean;
31 submitting: boolean;
32 installing: boolean;
33
34 newVersion: string | null;
35 shouldShowNotice: boolean;
36
37 extensions: { [id: number]: MoonbaseExtension };
38 updates: {
39 [id: number]: {
40 version: string;
41 download: string;
42 updateManifest: RepositoryManifest;
43 };
44 };
45
46 constructor() {
47 super(Dispatcher);
48
49 this.origConfig = moonlightNode.config;
50 this.config = this.clone(this.origConfig);
51 this.extensionIndex = 0;
52
53 this.modified = false;
54 this.submitting = false;
55 this.installing = false;
56
57 this.newVersion = null;
58 this.shouldShowNotice = false;
59
60 this.extensions = {};
61 this.updates = {};
62 for (const ext of moonlightNode.extensions) {
63 const uniqueId = this.extensionIndex++;
64 this.extensions[uniqueId] = {
65 ...ext,
66 uniqueId,
67 state: moonlight.enabledExtensions.has(ext.id)
68 ? ExtensionState.Enabled
69 : ExtensionState.Disabled,
70 compat: checkExtensionCompat(ext.manifest),
71 hasUpdate: false
72 };
73 }
74
75 natives!
76 .fetchRepositories(this.config.repositories)
77 .then((ret) => {
78 for (const [repo, exts] of Object.entries(ret)) {
79 try {
80 for (const ext of exts) {
81 const uniqueId = this.extensionIndex++;
82 const extensionData = {
83 id: ext.id,
84 uniqueId,
85 manifest: ext,
86 source: { type: ExtensionLoadSource.Normal, url: repo },
87 state: ExtensionState.NotDownloaded,
88 compat: ExtensionCompat.Compatible,
89 hasUpdate: false
90 };
91
92 // Don't present incompatible updates
93 if (checkExtensionCompat(ext) !== ExtensionCompat.Compatible)
94 continue;
95
96 const existing = this.getExisting(extensionData);
97 if (existing != null) {
98 // Make sure the download URL is properly updated
99 for (const [id, e] of Object.entries(this.extensions)) {
100 if (e.id === ext.id && e.source.url === repo) {
101 this.extensions[parseInt(id)].manifest = {
102 ...e.manifest,
103 download: ext.download
104 };
105 break;
106 }
107 }
108
109 if (this.hasUpdate(extensionData)) {
110 this.updates[existing.uniqueId] = {
111 version: ext.version!,
112 download: ext.download,
113 updateManifest: ext
114 };
115 existing.hasUpdate = true;
116 }
117
118 continue;
119 }
120
121 this.extensions[uniqueId] = extensionData;
122 }
123 } catch (e) {
124 logger.error(`Error processing repository ${repo}`, e);
125 }
126 }
127
128 this.emitChange();
129 })
130 .then(() =>
131 this.getExtensionConfigRaw("moonbase", "updateChecking", true)
132 ? natives!.checkForMoonlightUpdate()
133 : new Promise<null>((resolve) => resolve(null))
134 )
135 .then((version) => {
136 this.newVersion = version;
137 this.emitChange();
138 })
139 .then(() => {
140 this.shouldShowNotice =
141 this.newVersion != null || Object.keys(this.updates).length > 0;
142 this.emitChange();
143 });
144 }
145
146 private getExisting(ext: MoonbaseExtension) {
147 return Object.values(this.extensions).find(
148 (e) => e.id === ext.id && e.source.url === ext.source.url
149 );
150 }
151
152 private hasUpdate(ext: MoonbaseExtension) {
153 const existing = Object.values(this.extensions).find(
154 (e) => e.id === ext.id && e.source.url === ext.source.url
155 );
156 if (existing == null) return false;
157
158 return (
159 existing.manifest.version !== ext.manifest.version &&
160 existing.state !== ExtensionState.NotDownloaded
161 );
162 }
163
164 // Jank
165 private isModified() {
166 const orig = JSON.stringify(this.origConfig);
167 const curr = JSON.stringify(this.config);
168 return orig !== curr;
169 }
170
171 get busy() {
172 return this.submitting || this.installing;
173 }
174
175 showNotice() {
176 return this.modified;
177 }
178
179 getExtension(uniqueId: number) {
180 return this.extensions[uniqueId];
181 }
182
183 getExtensionUniqueId(id: string) {
184 return Object.values(this.extensions).find((ext) => ext.id === id)
185 ?.uniqueId;
186 }
187
188 getExtensionConflicting(uniqueId: number) {
189 const ext = this.getExtension(uniqueId);
190 if (ext.state !== ExtensionState.NotDownloaded) return false;
191 return Object.values(this.extensions).some(
192 (e) =>
193 e.id === ext.id &&
194 e.uniqueId !== uniqueId &&
195 e.state !== ExtensionState.NotDownloaded
196 );
197 }
198
199 getExtensionName(uniqueId: number) {
200 const ext = this.getExtension(uniqueId);
201 return ext.manifest.meta?.name ?? ext.id;
202 }
203
204 getExtensionUpdate(uniqueId: number) {
205 return this.updates[uniqueId]?.version;
206 }
207
208 getExtensionEnabled(uniqueId: number) {
209 const ext = this.getExtension(uniqueId);
210 if (ext.state === ExtensionState.NotDownloaded) return false;
211 const val = this.config.extensions[ext.id];
212 if (val == null) return false;
213 return typeof val === "boolean" ? val : val.enabled;
214 }
215
216 getExtensionConfig<T>(uniqueId: number, key: string): T | undefined {
217 const ext = this.getExtension(uniqueId);
218 const defaultValue = ext.manifest.settings?.[key]?.default;
219 const clonedDefaultValue = this.clone(defaultValue);
220 const cfg = this.config.extensions[ext.id];
221
222 if (cfg == null || typeof cfg === "boolean") return clonedDefaultValue;
223 return cfg.config?.[key] ?? clonedDefaultValue;
224 }
225
226 getExtensionConfigRaw<T>(
227 id: string,
228 key: string,
229 defaultValue: T | undefined
230 ): T | undefined {
231 const cfg = this.config.extensions[id];
232
233 if (cfg == null || typeof cfg === "boolean") return defaultValue;
234 return cfg.config?.[key] ?? defaultValue;
235 }
236
237 getExtensionConfigName(uniqueId: number, key: string) {
238 const ext = this.getExtension(uniqueId);
239 return ext.manifest.settings?.[key]?.displayName ?? key;
240 }
241
242 getExtensionConfigDescription(uniqueId: number, key: string) {
243 const ext = this.getExtension(uniqueId);
244 return ext.manifest.settings?.[key]?.description;
245 }
246
247 setExtensionConfig(id: string, key: string, value: any) {
248 const oldConfig = this.config.extensions[id];
249 const newConfig =
250 typeof oldConfig === "boolean"
251 ? {
252 enabled: oldConfig,
253 config: { [key]: value }
254 }
255 : {
256 ...oldConfig,
257 config: { ...(oldConfig?.config ?? {}), [key]: value }
258 };
259
260 this.config.extensions[id] = newConfig;
261 this.modified = this.isModified();
262 this.emitChange();
263 }
264
265 setExtensionEnabled(uniqueId: number, enabled: boolean) {
266 const ext = this.getExtension(uniqueId);
267 let val = this.config.extensions[ext.id];
268
269 if (val == null) {
270 this.config.extensions[ext.id] = { enabled };
271 this.modified = this.isModified();
272 this.emitChange();
273 return;
274 }
275
276 if (typeof val === "boolean") {
277 val = enabled;
278 } else {
279 val.enabled = enabled;
280 }
281
282 this.config.extensions[ext.id] = val;
283 this.modified = this.isModified();
284 this.emitChange();
285 }
286
287 async installExtension(uniqueId: number) {
288 const ext = this.getExtension(uniqueId);
289 if (!("download" in ext.manifest)) {
290 throw new Error("Extension has no download URL");
291 }
292
293 this.installing = true;
294 try {
295 const update = this.updates[uniqueId];
296 const url = update?.download ?? ext.manifest.download;
297 await natives!.installExtension(ext.manifest, url, ext.source.url!);
298 if (ext.state === ExtensionState.NotDownloaded) {
299 this.extensions[uniqueId].state = ExtensionState.Disabled;
300 }
301
302 if (update != null)
303 this.extensions[uniqueId].compat = checkExtensionCompat(
304 update.updateManifest
305 );
306
307 delete this.updates[uniqueId];
308 } catch (e) {
309 logger.error("Error installing extension:", e);
310 }
311
312 this.installing = false;
313 this.emitChange();
314 }
315
316 private getRank(ext: MoonbaseExtension) {
317 if (ext.source.type === ExtensionLoadSource.Developer) return 3;
318 if (ext.source.type === ExtensionLoadSource.Core) return 2;
319 if (ext.source.url === mainRepo) return 1;
320 return 0;
321 }
322
323 async getDependencies(uniqueId: number) {
324 const ext = this.getExtension(uniqueId);
325
326 const missingDeps = [];
327 for (const dep of ext.manifest.dependencies ?? []) {
328 const anyInstalled = Object.values(this.extensions).some(
329 (e) => e.id === dep && e.state !== ExtensionState.NotDownloaded
330 );
331 if (!anyInstalled) missingDeps.push(dep);
332 }
333
334 if (missingDeps.length === 0) return null;
335
336 const deps: Record<string, MoonbaseExtension[]> = {};
337 for (const dep of missingDeps) {
338 const candidates = Object.values(this.extensions).filter(
339 (e) => e.id === dep
340 );
341
342 deps[dep] = candidates.sort((a, b) => {
343 const aRank = this.getRank(a);
344 const bRank = this.getRank(b);
345 if (aRank === bRank) {
346 const repoIndex = this.config.repositories.indexOf(a.source.url!);
347 const otherRepoIndex = this.config.repositories.indexOf(
348 b.source.url!
349 );
350 return repoIndex - otherRepoIndex;
351 } else {
352 return bRank - aRank;
353 }
354 });
355 }
356
357 return deps;
358 }
359
360 async deleteExtension(uniqueId: number) {
361 const ext = this.getExtension(uniqueId);
362 if (ext == null) return;
363
364 this.installing = true;
365 try {
366 await natives!.deleteExtension(ext.id);
367 this.extensions[uniqueId].state = ExtensionState.NotDownloaded;
368 } catch (e) {
369 logger.error("Error deleting extension:", e);
370 }
371
372 this.installing = false;
373 this.emitChange();
374 }
375
376 async updateMoonlight() {
377 await natives.updateMoonlight();
378 }
379
380 getConfigOption<K extends keyof Config>(key: K): Config[K] {
381 return this.config[key];
382 }
383
384 setConfigOption<K extends keyof Config>(key: K, value: Config[K]) {
385 this.config[key] = value;
386 this.modified = this.isModified();
387 this.emitChange();
388 }
389
390 tryGetExtensionName(id: string) {
391 const uniqueId = this.getExtensionUniqueId(id);
392 return (uniqueId != null ? this.getExtensionName(uniqueId) : null) ?? id;
393 }
394
395 registerConfigComponent(
396 ext: string,
397 name: string,
398 component: CustomComponent
399 ) {
400 if (!(ext in this.configComponents)) this.configComponents[ext] = {};
401 this.configComponents[ext][name] = component;
402 }
403
404 getExtensionConfigComponent(ext: string, name: string) {
405 return this.configComponents[ext]?.[name];
406 }
407
408 writeConfig() {
409 this.submitting = true;
410
411 moonlightNode.writeConfig(this.config);
412 this.origConfig = this.clone(this.config);
413
414 this.submitting = false;
415 this.modified = false;
416 this.emitChange();
417 }
418
419 reset() {
420 this.submitting = false;
421 this.modified = false;
422 this.config = this.clone(this.origConfig);
423 this.emitChange();
424 }
425
426 // Required because electron likes to make it immutable sometimes.
427 // This sucks.
428 private clone<T>(obj: T): T {
429 return structuredClone(obj);
430 }
431}
432
433const settingsStore = new MoonbaseSettingsStore();
434export { settingsStore as MoonbaseSettingsStore };