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