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