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