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