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