498 lines
16 kB
1
import { Config, ExtensionEnvironment, ExtensionLoadSource, ExtensionSettingsAdvice } from "@moonlight-mod/types";
2
import { ExtensionState, MoonbaseExtension, MoonbaseNatives, RepositoryManifest, RestartAdvice } from "../types";
3
import { Store } from "@moonlight-mod/wp/discord/packages/flux";
4
import Dispatcher from "@moonlight-mod/wp/discord/Dispatcher";
5
import getNatives from "../native";
6
import { mainRepo } from "@moonlight-mod/types/constants";
7
import { checkExtensionCompat, ExtensionCompat } from "@moonlight-mod/core/extension/loader";
8
import { CustomComponent } from "@moonlight-mod/types/coreExtensions/moonbase";
9
import { getConfigOption, setConfigOption } from "@moonlight-mod/core/util/config";
10
import diff from "microdiff";
11
12
const logger = moonlight.getLogger("moonbase");
13
14
let natives: MoonbaseNatives = moonlight.getNatives("moonbase");
15
if (moonlightNode.isBrowser) natives = getNatives();
16
17
class 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.changelog = update.updateManifest.meta?.changelog;
278
}
279
280
delete this.updates[uniqueId];
281
} catch (e) {
282
logger.error("Error installing extension:", e);
283
}
284
285
this.installing = false;
286
this.restartAdvice = this.#computeRestartAdvice();
287
this.emitChange();
288
}
289
290
private getRank(ext: MoonbaseExtension) {
291
if (ext.source.type === ExtensionLoadSource.Developer) return 3;
292
if (ext.source.type === ExtensionLoadSource.Core) return 2;
293
if (ext.source.url === mainRepo) return 1;
294
return 0;
295
}
296
297
async getDependencies(uniqueId: number) {
298
const ext = this.getExtension(uniqueId);
299
300
const missingDeps = [];
301
for (const dep of ext.manifest.dependencies ?? []) {
302
const anyInstalled = Object.values(this.extensions).some(
303
(e) => e.id === dep && e.state !== ExtensionState.NotDownloaded
304
);
305
if (!anyInstalled) missingDeps.push(dep);
306
}
307
308
if (missingDeps.length === 0) return null;
309
310
const deps: Record<string, MoonbaseExtension[]> = {};
311
for (const dep of missingDeps) {
312
const candidates = Object.values(this.extensions).filter((e) => e.id === dep);
313
314
deps[dep] = candidates.sort((a, b) => {
315
const aRank = this.getRank(a);
316
const bRank = this.getRank(b);
317
if (aRank === bRank) {
318
const repoIndex = this.savedConfig.repositories.indexOf(a.source.url!);
319
const otherRepoIndex = this.savedConfig.repositories.indexOf(b.source.url!);
320
return repoIndex - otherRepoIndex;
321
} else {
322
return bRank - aRank;
323
}
324
});
325
}
326
327
return deps;
328
}
329
330
async deleteExtension(uniqueId: number) {
331
const ext = this.getExtension(uniqueId);
332
if (ext == null) return;
333
334
this.installing = true;
335
try {
336
await natives!.deleteExtension(ext.id);
337
this.extensions[uniqueId].state = ExtensionState.NotDownloaded;
338
} catch (e) {
339
logger.error("Error deleting extension:", e);
340
}
341
342
this.installing = false;
343
this.restartAdvice = this.#computeRestartAdvice();
344
this.emitChange();
345
}
346
347
async updateMoonlight() {
348
await natives.updateMoonlight();
349
}
350
351
getConfigOption<K extends keyof Config>(key: K): Config[K] {
352
return this.config[key];
353
}
354
355
setConfigOption<K extends keyof Config>(key: K, value: Config[K]) {
356
this.config[key] = value;
357
this.modified = this.isModified();
358
this.emitChange();
359
}
360
361
tryGetExtensionName(id: string) {
362
const uniqueId = this.getExtensionUniqueId(id);
363
return (uniqueId != null ? this.getExtensionName(uniqueId) : null) ?? id;
364
}
365
366
registerConfigComponent(ext: string, name: string, component: CustomComponent) {
367
if (!(ext in this.configComponents)) this.configComponents[ext] = {};
368
this.configComponents[ext][name] = component;
369
}
370
371
getExtensionConfigComponent(ext: string, name: string) {
372
return this.configComponents[ext]?.[name];
373
}
374
375
#computeRestartAdvice() {
376
const i = this.initialConfig; // Initial config, from startup
377
const n = this.config; // New config about to be saved
378
379
let returnedAdvice = RestartAdvice.NotNeeded;
380
const updateAdvice = (r: RestartAdvice) => (returnedAdvice < r ? (returnedAdvice = r) : returnedAdvice);
381
382
// Top-level keys, repositories is not needed here because Moonbase handles it.
383
if (i.patchAll !== n.patchAll) updateAdvice(RestartAdvice.ReloadNeeded);
384
if (i.loggerLevel !== n.loggerLevel) updateAdvice(RestartAdvice.ReloadNeeded);
385
if (diff(i.devSearchPaths ?? [], n.devSearchPaths ?? [], { cyclesFix: false }).length !== 0)
386
return updateAdvice(RestartAdvice.RestartNeeded);
387
388
// Extension specific logic
389
for (const id in n.extensions) {
390
// Installed extension (might not be detected yet)
391
const ext = Object.values(this.extensions).find((e) => e.id === id && e.state !== ExtensionState.NotDownloaded);
392
// Installed and detected extension
393
const detected = moonlightNode.extensions.find((e) => e.id === id);
394
395
// If it's not installed at all, we don't care
396
if (!ext) continue;
397
398
const initState = i.extensions[id];
399
const newState = n.extensions[id];
400
401
const newEnabled = typeof newState === "boolean" ? newState : newState.enabled;
402
// If it's enabled but not detected yet, restart.
403
if (newEnabled && !detected) {
404
return updateAdvice(RestartAdvice.RestartNeeded);
405
}
406
407
// Toggling extensions specifically wants to rely on the initial state,
408
// that's what was considered when loading extensions.
409
const initEnabled = initState && (typeof initState === "boolean" ? initState : initState.enabled);
410
if (initEnabled !== newEnabled || detected?.manifest.version !== ext.manifest.version) {
411
// If we have the extension locally, we confidently know if it has host/preload scripts.
412
// If not, we have to respect the environment specified in the manifest.
413
// If that is the default, we can't know what's needed.
414
415
if (detected?.scripts.hostPath || detected?.scripts.nodePath) {
416
return updateAdvice(RestartAdvice.RestartNeeded);
417
}
418
419
switch (ext.manifest.environment) {
420
case ExtensionEnvironment.Both:
421
case ExtensionEnvironment.Web:
422
updateAdvice(RestartAdvice.ReloadNeeded);
423
continue;
424
case ExtensionEnvironment.Desktop:
425
return updateAdvice(RestartAdvice.RestartNeeded);
426
default:
427
updateAdvice(RestartAdvice.ReloadNeeded);
428
continue;
429
}
430
}
431
432
const initConfig = typeof initState === "boolean" ? {} : initState?.config ?? {};
433
const newConfig = typeof newState === "boolean" ? {} : newState?.config ?? {};
434
435
const def = ext.manifest.settings;
436
if (!def) continue;
437
438
const changedKeys = diff(initConfig, newConfig, { cyclesFix: false }).map((c) => c.path[0]);
439
for (const key in def) {
440
if (!changedKeys.includes(key)) continue;
441
442
const advice = def[key].advice;
443
switch (advice) {
444
case ExtensionSettingsAdvice.None:
445
updateAdvice(RestartAdvice.NotNeeded);
446
continue;
447
case ExtensionSettingsAdvice.Reload:
448
updateAdvice(RestartAdvice.ReloadNeeded);
449
continue;
450
case ExtensionSettingsAdvice.Restart:
451
updateAdvice(RestartAdvice.RestartNeeded);
452
continue;
453
default:
454
updateAdvice(RestartAdvice.ReloadSuggested);
455
}
456
}
457
}
458
459
return returnedAdvice;
460
}
461
462
writeConfig() {
463
this.submitting = true;
464
this.restartAdvice = this.#computeRestartAdvice();
465
466
moonlightNode.writeConfig(this.config);
467
this.savedConfig = this.clone(this.config);
468
469
this.submitting = false;
470
this.modified = false;
471
this.emitChange();
472
}
473
474
reset() {
475
this.submitting = false;
476
this.modified = false;
477
this.config = this.clone(this.savedConfig);
478
this.emitChange();
479
}
480
481
restartDiscord() {
482
if (moonlightNode.isBrowser) {
483
window.location.reload();
484
} else {
485
// @ts-expect-error TODO: DiscordNative
486
window.DiscordNative.app.relaunch();
487
}
488
}
489
490
// Required because electron likes to make it immutable sometimes.
491
// This sucks.
492
private clone<T>(obj: T): T {
493
return structuredClone(obj);
494
}
495
}
496
497
const settingsStore = new MoonbaseSettingsStore();
498
export { settingsStore as MoonbaseSettingsStore };
499