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