404 lines
12 kB
1
import { Config, ExtensionLoadSource } from "@moonlight-mod/types";
2
import { ExtensionState, MoonbaseExtension, MoonbaseNatives, RepositoryManifest } 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
11
const logger = moonlight.getLogger("moonbase");
12
13
let natives: MoonbaseNatives = moonlight.getNatives("moonbase");
14
if (moonlightNode.isBrowser) natives = getNatives();
15
16
class MoonbaseSettingsStore extends Store<any> {
17
private origConfig: Config;
18
private config: Config;
19
private extensionIndex: number;
20
private configComponents: Record<string, Record<string, CustomComponent>> = {};
21
22
modified: boolean;
23
submitting: boolean;
24
installing: boolean;
25
26
newVersion: string | null;
27
shouldShowNotice: boolean;
28
29
#showOnlyUpdateable = false;
30
set showOnlyUpdateable(v: boolean) {
31
this.#showOnlyUpdateable = v;
32
this.emitChange();
33
}
34
get showOnlyUpdateable() {
35
return this.#showOnlyUpdateable;
36
}
37
38
extensions: { [id: number]: MoonbaseExtension };
39
updates: {
40
[id: number]: {
41
version: string;
42
download: string;
43
updateManifest: RepositoryManifest;
44
};
45
};
46
47
constructor() {
48
super(Dispatcher);
49
50
this.origConfig = moonlightNode.config;
51
this.config = this.clone(this.origConfig);
52
this.extensionIndex = 0;
53
54
this.modified = false;
55
this.submitting = false;
56
this.installing = false;
57
58
this.newVersion = null;
59
this.shouldShowNotice = false;
60
61
this.extensions = {};
62
this.updates = {};
63
for (const ext of moonlightNode.extensions) {
64
const uniqueId = this.extensionIndex++;
65
this.extensions[uniqueId] = {
66
...ext,
67
uniqueId,
68
state: moonlight.enabledExtensions.has(ext.id) ? ExtensionState.Enabled : ExtensionState.Disabled,
69
compat: checkExtensionCompat(ext.manifest),
70
hasUpdate: false
71
};
72
}
73
74
natives!
75
.fetchRepositories(this.config.repositories)
76
.then((ret) => {
77
for (const [repo, exts] of Object.entries(ret)) {
78
try {
79
for (const ext of exts) {
80
const uniqueId = this.extensionIndex++;
81
const extensionData = {
82
id: ext.id,
83
uniqueId,
84
manifest: ext,
85
source: { type: ExtensionLoadSource.Normal, url: repo },
86
state: ExtensionState.NotDownloaded,
87
compat: ExtensionCompat.Compatible,
88
hasUpdate: false
89
};
90
91
// Don't present incompatible updates
92
if (checkExtensionCompat(ext) !== ExtensionCompat.Compatible) continue;
93
94
const existing = this.getExisting(extensionData);
95
if (existing != null) {
96
// Make sure the download URL is properly updated
97
for (const [id, e] of Object.entries(this.extensions)) {
98
if (e.id === ext.id && e.source.url === repo) {
99
this.extensions[parseInt(id)].manifest = {
100
...e.manifest,
101
download: ext.download
102
};
103
break;
104
}
105
}
106
107
if (this.hasUpdate(extensionData)) {
108
this.updates[existing.uniqueId] = {
109
version: ext.version!,
110
download: ext.download,
111
updateManifest: ext
112
};
113
existing.hasUpdate = true;
114
existing.changelog = ext.meta?.changelog;
115
}
116
117
continue;
118
}
119
120
this.extensions[uniqueId] = extensionData;
121
}
122
} catch (e) {
123
logger.error(`Error processing repository ${repo}`, e);
124
}
125
}
126
127
this.emitChange();
128
})
129
.then(() =>
130
this.getExtensionConfigRaw("moonbase", "updateChecking", true)
131
? natives!.checkForMoonlightUpdate()
132
: new Promise<null>((resolve) => resolve(null))
133
)
134
.then((version) => {
135
this.newVersion = version;
136
this.emitChange();
137
})
138
.then(() => {
139
this.shouldShowNotice = this.newVersion != null || Object.keys(this.updates).length > 0;
140
this.emitChange();
141
});
142
}
143
144
private getExisting(ext: MoonbaseExtension) {
145
return Object.values(this.extensions).find((e) => e.id === ext.id && e.source.url === ext.source.url);
146
}
147
148
private hasUpdate(ext: MoonbaseExtension) {
149
const existing = Object.values(this.extensions).find((e) => e.id === ext.id && e.source.url === ext.source.url);
150
if (existing == null) return false;
151
152
return existing.manifest.version !== ext.manifest.version && existing.state !== ExtensionState.NotDownloaded;
153
}
154
155
// Jank
156
private isModified() {
157
const orig = JSON.stringify(this.origConfig);
158
const curr = JSON.stringify(this.config);
159
return orig !== curr;
160
}
161
162
get busy() {
163
return this.submitting || this.installing;
164
}
165
166
// Required for the settings store contract
167
showNotice() {
168
return this.modified;
169
}
170
171
getExtension(uniqueId: number) {
172
return this.extensions[uniqueId];
173
}
174
175
getExtensionUniqueId(id: string) {
176
return Object.values(this.extensions).find((ext) => ext.id === id)?.uniqueId;
177
}
178
179
getExtensionConflicting(uniqueId: number) {
180
const ext = this.getExtension(uniqueId);
181
if (ext.state !== ExtensionState.NotDownloaded) return false;
182
return Object.values(this.extensions).some(
183
(e) => e.id === ext.id && e.uniqueId !== uniqueId && e.state !== ExtensionState.NotDownloaded
184
);
185
}
186
187
getExtensionName(uniqueId: number) {
188
const ext = this.getExtension(uniqueId);
189
return ext.manifest.meta?.name ?? ext.id;
190
}
191
192
getExtensionUpdate(uniqueId: number) {
193
return this.updates[uniqueId]?.version;
194
}
195
196
getExtensionEnabled(uniqueId: number) {
197
const ext = this.getExtension(uniqueId);
198
if (ext.state === ExtensionState.NotDownloaded) return false;
199
const val = this.config.extensions[ext.id];
200
if (val == null) return false;
201
return typeof val === "boolean" ? val : val.enabled;
202
}
203
204
getExtensionConfig<T>(uniqueId: number, key: string): T | undefined {
205
const ext = this.getExtension(uniqueId);
206
const settings = ext.settingsOverride ?? ext.manifest.settings;
207
return getConfigOption(ext.id, key, this.config, settings);
208
}
209
210
getExtensionConfigRaw<T>(id: string, key: string, defaultValue: T | undefined): T | undefined {
211
const cfg = this.config.extensions[id];
212
if (cfg == null || typeof cfg === "boolean") return defaultValue;
213
return cfg.config?.[key] ?? defaultValue;
214
}
215
216
getExtensionConfigName(uniqueId: number, key: string) {
217
const ext = this.getExtension(uniqueId);
218
const settings = ext.settingsOverride ?? ext.manifest.settings;
219
return settings?.[key]?.displayName ?? key;
220
}
221
222
getExtensionConfigDescription(uniqueId: number, key: string) {
223
const ext = this.getExtension(uniqueId);
224
const settings = ext.settingsOverride ?? ext.manifest.settings;
225
return settings?.[key]?.description;
226
}
227
228
setExtensionConfig(id: string, key: string, value: any) {
229
setConfigOption(this.config, id, key, value);
230
this.modified = this.isModified();
231
this.emitChange();
232
}
233
234
setExtensionEnabled(uniqueId: number, enabled: boolean) {
235
const ext = this.getExtension(uniqueId);
236
let val = this.config.extensions[ext.id];
237
238
if (val == null) {
239
this.config.extensions[ext.id] = { enabled };
240
this.modified = this.isModified();
241
this.emitChange();
242
return;
243
}
244
245
if (typeof val === "boolean") {
246
val = enabled;
247
} else {
248
val.enabled = enabled;
249
}
250
251
this.config.extensions[ext.id] = val;
252
this.modified = this.isModified();
253
this.emitChange();
254
}
255
256
async installExtension(uniqueId: number) {
257
const ext = this.getExtension(uniqueId);
258
if (!("download" in ext.manifest)) {
259
throw new Error("Extension has no download URL");
260
}
261
262
this.installing = true;
263
try {
264
const update = this.updates[uniqueId];
265
const url = update?.download ?? ext.manifest.download;
266
await natives!.installExtension(ext.manifest, url, ext.source.url!);
267
if (ext.state === ExtensionState.NotDownloaded) {
268
this.extensions[uniqueId].state = ExtensionState.Disabled;
269
}
270
271
if (update != null) {
272
this.extensions[uniqueId].settingsOverride = update.updateManifest.settings;
273
this.extensions[uniqueId].compat = checkExtensionCompat(update.updateManifest);
274
}
275
276
delete this.updates[uniqueId];
277
} catch (e) {
278
logger.error("Error installing extension:", e);
279
}
280
281
this.installing = false;
282
this.emitChange();
283
}
284
285
private getRank(ext: MoonbaseExtension) {
286
if (ext.source.type === ExtensionLoadSource.Developer) return 3;
287
if (ext.source.type === ExtensionLoadSource.Core) return 2;
288
if (ext.source.url === mainRepo) return 1;
289
return 0;
290
}
291
292
async getDependencies(uniqueId: number) {
293
const ext = this.getExtension(uniqueId);
294
295
const missingDeps = [];
296
for (const dep of ext.manifest.dependencies ?? []) {
297
const anyInstalled = Object.values(this.extensions).some(
298
(e) => e.id === dep && e.state !== ExtensionState.NotDownloaded
299
);
300
if (!anyInstalled) missingDeps.push(dep);
301
}
302
303
if (missingDeps.length === 0) return null;
304
305
const deps: Record<string, MoonbaseExtension[]> = {};
306
for (const dep of missingDeps) {
307
const candidates = Object.values(this.extensions).filter((e) => e.id === dep);
308
309
deps[dep] = candidates.sort((a, b) => {
310
const aRank = this.getRank(a);
311
const bRank = this.getRank(b);
312
if (aRank === bRank) {
313
const repoIndex = this.config.repositories.indexOf(a.source.url!);
314
const otherRepoIndex = this.config.repositories.indexOf(b.source.url!);
315
return repoIndex - otherRepoIndex;
316
} else {
317
return bRank - aRank;
318
}
319
});
320
}
321
322
return deps;
323
}
324
325
async deleteExtension(uniqueId: number) {
326
const ext = this.getExtension(uniqueId);
327
if (ext == null) return;
328
329
this.installing = true;
330
try {
331
await natives!.deleteExtension(ext.id);
332
this.extensions[uniqueId].state = ExtensionState.NotDownloaded;
333
} catch (e) {
334
logger.error("Error deleting extension:", e);
335
}
336
337
this.installing = false;
338
this.emitChange();
339
}
340
341
async updateMoonlight() {
342
await natives.updateMoonlight();
343
}
344
345
getConfigOption<K extends keyof Config>(key: K): Config[K] {
346
return this.config[key];
347
}
348
349
setConfigOption<K extends keyof Config>(key: K, value: Config[K]) {
350
this.config[key] = value;
351
this.modified = this.isModified();
352
this.emitChange();
353
}
354
355
tryGetExtensionName(id: string) {
356
const uniqueId = this.getExtensionUniqueId(id);
357
return (uniqueId != null ? this.getExtensionName(uniqueId) : null) ?? id;
358
}
359
360
registerConfigComponent(ext: string, name: string, component: CustomComponent) {
361
if (!(ext in this.configComponents)) this.configComponents[ext] = {};
362
this.configComponents[ext][name] = component;
363
}
364
365
getExtensionConfigComponent(ext: string, name: string) {
366
return this.configComponents[ext]?.[name];
367
}
368
369
writeConfig() {
370
this.submitting = true;
371
372
moonlightNode.writeConfig(this.config);
373
this.origConfig = this.clone(this.config);
374
375
this.submitting = false;
376
this.modified = false;
377
this.emitChange();
378
}
379
380
reset() {
381
this.submitting = false;
382
this.modified = false;
383
this.config = this.clone(this.origConfig);
384
this.emitChange();
385
}
386
387
restartDiscord() {
388
if (moonlightNode.isBrowser) {
389
window.location.reload();
390
} else {
391
// @ts-expect-error TODO: DiscordNative
392
window.DiscordNative.app.relaunch();
393
}
394
}
395
396
// Required because electron likes to make it immutable sometimes.
397
// This sucks.
398
private clone<T>(obj: T): T {
399
return structuredClone(obj);
400
}
401
}
402
403
const settingsStore = new MoonbaseSettingsStore();
404
export { settingsStore as MoonbaseSettingsStore };
405