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