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