541 lines
17 kB
1
import { Config, ExtensionEnvironment, ExtensionLoadSource, ExtensionSettingsAdvice } from "@moonlight-mod/types";
2
import {
3
ExtensionState,
4
MoonbaseExtension,
5
MoonbaseNatives,
6
RepositoryManifest,
7
RestartAdvice,
8
UpdateState
9
} from "../types";
10
import { Store } from "@moonlight-mod/wp/discord/packages/flux";
11
import Dispatcher from "@moonlight-mod/wp/discord/Dispatcher";
12
import getNatives from "../native";
13
import { mainRepo } from "@moonlight-mod/types/constants";
14
import { checkExtensionCompat, ExtensionCompat } from "@moonlight-mod/core/extension/loader";
15
import { CustomComponent } from "@moonlight-mod/types/coreExtensions/moonbase";
16
import { getConfigOption, setConfigOption } from "@moonlight-mod/core/util/config";
17
import diff from "microdiff";
18
19
const logger = moonlight.getLogger("moonbase");
20
21
let natives: MoonbaseNatives = moonlight.getNatives("moonbase");
22
if (moonlightNode.isBrowser) natives = getNatives();
23
24
class 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
540
const settingsStore = new MoonbaseSettingsStore();
541
export { settingsStore as MoonbaseSettingsStore };
542