this repo has no description
1import { Config, ExtensionEnvironment, ExtensionLoadSource, ExtensionSettingsAdvice } from "@moonlight-mod/types";
2import {
3 ExtensionState,
4 MoonbaseExtension,
5 MoonbaseNatives,
6 RepositoryManifest,
7 RestartAdvice,
8 UpdateState
9} from "../types";
10import { Store } from "@moonlight-mod/wp/discord/packages/flux";
11import Dispatcher from "@moonlight-mod/wp/discord/Dispatcher";
12import getNatives from "../native";
13import { mainRepo } from "@moonlight-mod/types/constants";
14import { checkExtensionCompat, ExtensionCompat } from "@moonlight-mod/core/extension/loader";
15import { CustomComponent } from "@moonlight-mod/types/coreExtensions/moonbase";
16import { NodeEventType } from "@moonlight-mod/types/core/event";
17import { getConfigOption, setConfigOption } from "@moonlight-mod/core/util/config";
18import diff from "microdiff";
19
20const logger = moonlight.getLogger("moonbase");
21
22let natives: MoonbaseNatives = moonlight.getNatives("moonbase");
23if (moonlightNode.isBrowser) natives = getNatives();
24
25class MoonbaseSettingsStore extends Store<any> {
26 private initialConfig: Config;
27 private savedConfig: Config;
28 private config: Config;
29 private extensionIndex: number;
30 private configComponents: Record<string, Record<string, CustomComponent>> = {};
31
32 modified: boolean;
33 submitting: boolean;
34 installing: boolean;
35
36 #updateState = UpdateState.Ready;
37 get updateState() {
38 return this.#updateState;
39 }
40 newVersion: string | null;
41 shouldShowNotice: boolean;
42
43 restartAdvice = RestartAdvice.NotNeeded;
44
45 extensions: { [id: number]: MoonbaseExtension };
46 updates: {
47 [id: number]: {
48 version: string;
49 download: string;
50 updateManifest: RepositoryManifest;
51 };
52 };
53
54 constructor() {
55 super(Dispatcher);
56
57 this.initialConfig = moonlightNode.config;
58 this.savedConfig = moonlightNode.config;
59 this.config = this.clone(this.savedConfig);
60 this.extensionIndex = 0;
61
62 this.modified = false;
63 this.submitting = false;
64 this.installing = false;
65
66 this.newVersion = null;
67 this.shouldShowNotice = false;
68
69 this.extensions = {};
70 this.updates = {};
71 for (const ext of moonlightNode.extensions) {
72 const uniqueId = this.extensionIndex++;
73 this.extensions[uniqueId] = {
74 ...ext,
75 uniqueId,
76 state: moonlight.enabledExtensions.has(ext.id) ? ExtensionState.Enabled : ExtensionState.Disabled,
77 compat: checkExtensionCompat(ext.manifest),
78 hasUpdate: false
79 };
80 }
81
82 // This is async but we're calling it without
83 this.checkUpdates();
84
85 // Update our state if another extension edited the config programatically
86 moonlightNode.events.addEventListener(NodeEventType.ConfigSaved, (config) => {
87 if (!this.submitting) {
88 this.config = this.clone(config);
89 // NOTE: This is also async but we're calling it without
90 this.processConfigChanged();
91 }
92 });
93 }
94
95 async checkUpdates() {
96 await Promise.all([this.checkExtensionUpdates(), this.checkMoonlightUpdates()]);
97 this.shouldShowNotice = this.newVersion != null || Object.keys(this.updates).length > 0;
98 this.emitChange();
99 }
100
101 private async checkExtensionUpdates() {
102 const repositories = await natives!.fetchRepositories(this.savedConfig.repositories);
103
104 // Reset update state
105 for (const id in this.extensions) {
106 const ext = this.extensions[id];
107 ext.hasUpdate = false;
108 ext.changelog = undefined;
109 }
110 this.updates = {};
111
112 for (const [repo, exts] of Object.entries(repositories)) {
113 for (const ext of exts) {
114 const uniqueId = this.extensionIndex++;
115 const extensionData = {
116 id: ext.id,
117 uniqueId,
118 manifest: ext,
119 source: { type: ExtensionLoadSource.Normal, url: repo },
120 state: ExtensionState.NotDownloaded,
121 compat: ExtensionCompat.Compatible,
122 hasUpdate: false
123 };
124
125 // Don't present incompatible updates
126 if (checkExtensionCompat(ext) !== ExtensionCompat.Compatible) continue;
127
128 const existing = this.getExisting(extensionData);
129 if (existing != null) {
130 // Make sure the download URL is properly updated
131 existing.manifest = {
132 ...existing.manifest,
133 download: ext.download
134 };
135
136 if (this.hasUpdate(extensionData)) {
137 this.updates[existing.uniqueId] = {
138 version: ext.version!,
139 download: ext.download,
140 updateManifest: ext
141 };
142 existing.hasUpdate = true;
143 existing.changelog = ext.meta?.changelog;
144 }
145 } else {
146 this.extensions[uniqueId] = extensionData;
147 }
148 }
149 }
150 }
151
152 private async checkMoonlightUpdates() {
153 this.newVersion = this.getExtensionConfigRaw("moonbase", "updateChecking", true)
154 ? await natives!.checkForMoonlightUpdate()
155 : null;
156 }
157
158 private getExisting(ext: MoonbaseExtension) {
159 return Object.values(this.extensions).find((e) => e.id === ext.id && e.source.url === ext.source.url);
160 }
161
162 private hasUpdate(ext: MoonbaseExtension) {
163 const existing = Object.values(this.extensions).find((e) => e.id === ext.id && e.source.url === ext.source.url);
164 if (existing == null) return false;
165
166 return existing.manifest.version !== ext.manifest.version && existing.state !== ExtensionState.NotDownloaded;
167 }
168
169 // Jank
170 private isModified() {
171 const orig = JSON.stringify(this.savedConfig);
172 const curr = JSON.stringify(this.config);
173 return orig !== curr;
174 }
175
176 get busy() {
177 return this.submitting || this.installing;
178 }
179
180 // Required for the settings store contract
181 showNotice() {
182 return this.modified;
183 }
184
185 getExtension(uniqueId: number) {
186 return this.extensions[uniqueId];
187 }
188
189 getExtensionUniqueId(id: string) {
190 return Object.values(this.extensions).find((ext) => ext.id === id)?.uniqueId;
191 }
192
193 getExtensionConflicting(uniqueId: number) {
194 const ext = this.getExtension(uniqueId);
195 if (ext.state !== ExtensionState.NotDownloaded) return false;
196 return Object.values(this.extensions).some(
197 (e) => e.id === ext.id && e.uniqueId !== uniqueId && e.state !== ExtensionState.NotDownloaded
198 );
199 }
200
201 getExtensionName(uniqueId: number) {
202 const ext = this.getExtension(uniqueId);
203 return ext.manifest.meta?.name ?? ext.id;
204 }
205
206 getExtensionUpdate(uniqueId: number) {
207 return this.updates[uniqueId]?.version;
208 }
209
210 getExtensionEnabled(uniqueId: number) {
211 const ext = this.getExtension(uniqueId);
212 if (ext.state === ExtensionState.NotDownloaded) return false;
213 const val = this.config.extensions[ext.id];
214 if (val == null) return false;
215 return typeof val === "boolean" ? val : val.enabled;
216 }
217
218 getExtensionConfig<T>(uniqueId: number, key: string): T | undefined {
219 const ext = this.getExtension(uniqueId);
220 const settings = ext.settingsOverride ?? ext.manifest.settings;
221 return getConfigOption(ext.id, key, this.config, settings);
222 }
223
224 getExtensionConfigRaw<T>(id: string, key: string, defaultValue: T | undefined): T | undefined {
225 const cfg = this.config.extensions[id];
226 if (cfg == null || typeof cfg === "boolean") return defaultValue;
227 return cfg.config?.[key] ?? defaultValue;
228 }
229
230 getExtensionConfigName(uniqueId: number, key: string) {
231 const ext = this.getExtension(uniqueId);
232 const settings = ext.settingsOverride ?? ext.manifest.settings;
233 return settings?.[key]?.displayName ?? key;
234 }
235
236 getExtensionConfigDescription(uniqueId: number, key: string) {
237 const ext = this.getExtension(uniqueId);
238 const settings = ext.settingsOverride ?? ext.manifest.settings;
239 return settings?.[key]?.description;
240 }
241
242 setExtensionConfig(id: string, key: string, value: any) {
243 setConfigOption(this.config, id, key, value);
244 this.modified = this.isModified();
245 this.emitChange();
246 }
247
248 setExtensionEnabled(uniqueId: number, enabled: boolean) {
249 const ext = this.getExtension(uniqueId);
250 let val = this.config.extensions[ext.id];
251
252 if (val == null) {
253 this.config.extensions[ext.id] = enabled;
254 this.modified = this.isModified();
255 this.emitChange();
256 return;
257 }
258
259 if (typeof val === "boolean") {
260 val = enabled;
261 } else {
262 val.enabled = enabled;
263 }
264
265 this.config.extensions[ext.id] = val;
266 this.modified = this.isModified();
267 this.emitChange();
268 }
269
270 dismissAllExtensionUpdates() {
271 for (const id in this.extensions) {
272 this.extensions[id].hasUpdate = false;
273 }
274 this.emitChange();
275 }
276
277 async updateAllExtensions() {
278 for (const id of Object.keys(this.updates)) {
279 try {
280 await this.installExtension(parseInt(id));
281 } catch (e) {
282 logger.error("Error bulk updating extension", id, e);
283 }
284 }
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 const existing = this.extensions[uniqueId];
304 existing.settingsOverride = update.updateManifest.settings;
305 existing.compat = checkExtensionCompat(update.updateManifest);
306 existing.manifest = update.updateManifest;
307 existing.changelog = update.updateManifest.meta?.changelog;
308 }
309
310 delete this.updates[uniqueId];
311 } catch (e) {
312 logger.error("Error installing extension:", e);
313 }
314
315 this.installing = false;
316 this.restartAdvice = this.#computeRestartAdvice();
317 this.emitChange();
318 }
319
320 private getRank(ext: MoonbaseExtension) {
321 if (ext.source.type === ExtensionLoadSource.Developer) return 3;
322 if (ext.source.type === ExtensionLoadSource.Core) return 2;
323 if (ext.source.url === mainRepo) return 1;
324 return 0;
325 }
326
327 async getDependencies(uniqueId: number) {
328 const ext = this.getExtension(uniqueId);
329
330 const missingDeps = [];
331 for (const dep of ext.manifest.dependencies ?? []) {
332 const anyInstalled = Object.values(this.extensions).some(
333 (e) => e.id === dep && e.state !== ExtensionState.NotDownloaded
334 );
335 if (!anyInstalled) missingDeps.push(dep);
336 }
337
338 if (missingDeps.length === 0) return null;
339
340 const deps: Record<string, MoonbaseExtension[]> = {};
341 for (const dep of missingDeps) {
342 const candidates = Object.values(this.extensions).filter((e) => e.id === dep);
343
344 deps[dep] = candidates.sort((a, b) => {
345 const aRank = this.getRank(a);
346 const bRank = this.getRank(b);
347 if (aRank === bRank) {
348 const repoIndex = this.savedConfig.repositories.indexOf(a.source.url!);
349 const otherRepoIndex = this.savedConfig.repositories.indexOf(b.source.url!);
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.restartAdvice = this.#computeRestartAdvice();
374 this.emitChange();
375 }
376
377 async updateMoonlight() {
378 this.#updateState = UpdateState.Working;
379 this.emitChange();
380
381 await natives
382 .updateMoonlight()
383 .then(() => (this.#updateState = UpdateState.Installed))
384 .catch((e) => {
385 logger.error(e);
386 this.#updateState = UpdateState.Failed;
387 });
388
389 this.emitChange();
390 }
391
392 getConfigOption<K extends keyof Config>(key: K): Config[K] {
393 return this.config[key];
394 }
395
396 setConfigOption<K extends keyof Config>(key: K, value: Config[K]) {
397 this.config[key] = value;
398 this.modified = this.isModified();
399 this.emitChange();
400 }
401
402 tryGetExtensionName(id: string) {
403 const uniqueId = this.getExtensionUniqueId(id);
404 return (uniqueId != null ? this.getExtensionName(uniqueId) : null) ?? id;
405 }
406
407 registerConfigComponent(ext: string, name: string, component: CustomComponent) {
408 if (!(ext in this.configComponents)) this.configComponents[ext] = {};
409 this.configComponents[ext][name] = component;
410 }
411
412 getExtensionConfigComponent(ext: string, name: string) {
413 return this.configComponents[ext]?.[name];
414 }
415
416 #computeRestartAdvice() {
417 // If moonlight update needs a restart, always hide advice.
418 if (this.#updateState === UpdateState.Installed) return RestartAdvice.NotNeeded;
419
420 const i = this.initialConfig; // Initial config, from startup
421 const n = this.config; // New config about to be saved
422
423 let returnedAdvice = RestartAdvice.NotNeeded;
424 const updateAdvice = (r: RestartAdvice) => (returnedAdvice < r ? (returnedAdvice = r) : returnedAdvice);
425
426 // Top-level keys, repositories is not needed here because Moonbase handles it.
427 if (i.patchAll !== n.patchAll) updateAdvice(RestartAdvice.ReloadNeeded);
428 if (i.loggerLevel !== n.loggerLevel) updateAdvice(RestartAdvice.ReloadNeeded);
429 if (diff(i.devSearchPaths ?? [], n.devSearchPaths ?? [], { cyclesFix: false }).length !== 0)
430 return updateAdvice(RestartAdvice.RestartNeeded);
431
432 // Extension specific logic
433 for (const id in n.extensions) {
434 // Installed extension (might not be detected yet)
435 const ext = Object.values(this.extensions).find((e) => e.id === id && e.state !== ExtensionState.NotDownloaded);
436 // Installed and detected extension
437 const detected = moonlightNode.extensions.find((e) => e.id === id);
438
439 // If it's not installed at all, we don't care
440 if (!ext) continue;
441
442 const initState = i.extensions[id];
443 const newState = n.extensions[id];
444
445 const newEnabled = typeof newState === "boolean" ? newState : newState.enabled;
446 // If it's enabled but not detected yet, restart.
447 if (newEnabled && !detected) {
448 return updateAdvice(RestartAdvice.RestartNeeded);
449 }
450
451 // Toggling extensions specifically wants to rely on the initial state,
452 // that's what was considered when loading extensions.
453 const initEnabled = initState && (typeof initState === "boolean" ? initState : initState.enabled);
454 if (initEnabled !== newEnabled || detected?.manifest.version !== ext.manifest.version) {
455 // If we have the extension locally, we confidently know if it has host/preload scripts.
456 // If not, we have to respect the environment specified in the manifest.
457 // If that is the default, we can't know what's needed.
458
459 if (detected?.scripts.hostPath || detected?.scripts.nodePath) {
460 return updateAdvice(RestartAdvice.RestartNeeded);
461 }
462
463 switch (ext.manifest.environment) {
464 case ExtensionEnvironment.Both:
465 case ExtensionEnvironment.Web:
466 updateAdvice(RestartAdvice.ReloadNeeded);
467 continue;
468 case ExtensionEnvironment.Desktop:
469 return updateAdvice(RestartAdvice.RestartNeeded);
470 default:
471 updateAdvice(RestartAdvice.ReloadNeeded);
472 continue;
473 }
474 }
475
476 const initConfig = typeof initState === "boolean" ? {} : { ...initState?.config };
477 const newConfig = typeof newState === "boolean" ? {} : { ...newState?.config };
478
479 const def = ext.manifest.settings;
480 if (!def) continue;
481
482 for (const key in def) {
483 const defaultValue = def[key].default;
484
485 initConfig[key] ??= defaultValue;
486 newConfig[key] ??= defaultValue;
487 }
488
489 const changedKeys = diff(initConfig, newConfig, { cyclesFix: false }).map((c) => c.path[0]);
490 for (const key in def) {
491 if (!changedKeys.includes(key)) continue;
492
493 const advice = def[key].advice;
494 switch (advice) {
495 case ExtensionSettingsAdvice.None:
496 updateAdvice(RestartAdvice.NotNeeded);
497 continue;
498 case ExtensionSettingsAdvice.Reload:
499 updateAdvice(RestartAdvice.ReloadNeeded);
500 continue;
501 case ExtensionSettingsAdvice.Restart:
502 updateAdvice(RestartAdvice.RestartNeeded);
503 continue;
504 default:
505 updateAdvice(RestartAdvice.ReloadSuggested);
506 }
507 }
508 }
509
510 return returnedAdvice;
511 }
512
513 async writeConfig() {
514 try {
515 this.submitting = true;
516 this.emitChange();
517
518 await moonlightNode.writeConfig(this.config);
519 await this.processConfigChanged();
520 } finally {
521 this.submitting = false;
522 this.emitChange();
523 }
524 }
525
526 private async processConfigChanged() {
527 this.savedConfig = this.clone(this.config);
528 this.restartAdvice = this.#computeRestartAdvice();
529 this.modified = false;
530
531 const modifiedRepos = diff(this.savedConfig.repositories, this.config.repositories);
532 if (modifiedRepos.length !== 0) await this.checkUpdates();
533
534 this.emitChange();
535 }
536
537 reset() {
538 this.submitting = false;
539 this.modified = false;
540 this.config = this.clone(this.savedConfig);
541 this.emitChange();
542 }
543
544 restartDiscord() {
545 if (moonlightNode.isBrowser) {
546 window.location.reload();
547 } else {
548 // @ts-expect-error TODO: DiscordNative
549 window.DiscordNative.app.relaunch();
550 }
551 }
552
553 // Required because electron likes to make it immutable sometimes.
554 // This sucks.
555 private clone<T>(obj: T): T {
556 return structuredClone(obj);
557 }
558}
559
560const settingsStore = new MoonbaseSettingsStore();
561export { settingsStore as MoonbaseSettingsStore };