A lexicon-driven AppView for ATProto. happyview.dev
backfill firehose jetstream atproto appview oauth lexicon
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add support for upgrading plugins

Trezy 73a66c70 273a2ba4

+2534 -202
+1
Cargo.lock
··· 1655 1655 "regex", 1656 1656 "reqwest", 1657 1657 "rustls", 1658 + "semver", 1658 1659 "serde", 1659 1660 "serde_json", 1660 1661 "serial_test",
+1
Cargo.toml
··· 54 54 wasmtime = { version = "29", features = ["async"] } 55 55 wasmtime-wasi = "29" 56 56 regex = "1.12.3" 57 + semver = "1.0" 57 58 58 59 [[bin]] 59 60 name = "migrate-lua-sql"
+33
package-lock.json
··· 11 11 "@docusaurus/core": "^3.7.0", 12 12 "@docusaurus/preset-classic": "^3.7.0", 13 13 "@docusaurus/theme-mermaid": "^3.9.2", 14 + "@tailwindcss/typography": "^0.5.19", 14 15 "prismjs": "^1.30.0", 15 16 "react": "^19.0.0", 16 17 "react-dom": "^19.0.0" ··· 5305 5306 }, 5306 5307 "engines": { 5307 5308 "node": ">=14.16" 5309 + } 5310 + }, 5311 + "node_modules/@tailwindcss/typography": { 5312 + "version": "0.5.19", 5313 + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", 5314 + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", 5315 + "license": "MIT", 5316 + "dependencies": { 5317 + "postcss-selector-parser": "6.0.10" 5318 + }, 5319 + "peerDependencies": { 5320 + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" 5321 + } 5322 + }, 5323 + "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": { 5324 + "version": "6.0.10", 5325 + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", 5326 + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", 5327 + "license": "MIT", 5328 + "dependencies": { 5329 + "cssesc": "^3.0.0", 5330 + "util-deprecate": "^1.0.2" 5331 + }, 5332 + "engines": { 5333 + "node": ">=4" 5308 5334 } 5309 5335 }, 5310 5336 "node_modules/@trysound/sax": { ··· 18219 18245 "engines": { 18220 18246 "node": ">= 10" 18221 18247 } 18248 + }, 18249 + "node_modules/tailwindcss": { 18250 + "version": "4.2.2", 18251 + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", 18252 + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", 18253 + "license": "MIT", 18254 + "peer": true 18222 18255 }, 18223 18256 "node_modules/tapable": { 18224 18257 "version": "2.3.0",
+1
package.json
··· 11 11 "@docusaurus/core": "^3.7.0", 12 12 "@docusaurus/preset-classic": "^3.7.0", 13 13 "@docusaurus/theme-mermaid": "^3.9.2", 14 + "@tailwindcss/typography": "^0.5.19", 14 15 "prismjs": "^1.30.0", 15 16 "react": "^19.0.0", 16 17 "react-dom": "^19.0.0"
+2
src/admin/mod.rs
··· 86 86 ) 87 87 .route("/plugins", post(plugins::add).get(plugins::list)) 88 88 .route("/plugins/preview", post(plugins::preview)) 89 + .route("/plugins/official", get(plugins::list_official)) 89 90 .route("/plugins/{id}", delete(plugins::remove)) 90 91 .route("/plugins/{id}/reload", post(plugins::reload)) 92 + .route("/plugins/{id}/check-update", post(plugins::check_update)) 91 93 .route( 92 94 "/plugins/{id}/secrets", 93 95 get(plugins::get_secrets).put(plugins::update_secrets),
+285
src/admin/plugins.rs
··· 10 10 use crate::event_log::{EventLog, Severity, log_event}; 11 11 use crate::plugin::encryption::{decrypt, encrypt}; 12 12 use crate::plugin::loader; 13 + use crate::plugin::official_registry::{OfficialPlugin, ReleaseEntry}; 14 + 15 + /// If the reload request provides a new URL, use it and clear the old sha256 16 + /// (the new version has its own hash). Otherwise keep the stored values. 17 + fn resolve_reload_url( 18 + current: (String, Option<String>), 19 + body: Option<super::types::ReloadPluginBody>, 20 + ) -> (String, Option<String>) { 21 + match body { 22 + Some(b) if b.url.is_some() => (b.url.unwrap(), None), 23 + _ => current, 24 + } 25 + } 26 + 27 + struct UpdateInfo { 28 + update_available: bool, 29 + latest_version: Option<String>, 30 + pending_releases: Vec<ReleaseEntry>, 31 + } 32 + 33 + fn compute_update_info( 34 + installed_version: &str, 35 + cache_entry: Option<&OfficialPlugin>, 36 + ) -> UpdateInfo { 37 + let Some(entry) = cache_entry else { 38 + return UpdateInfo { 39 + update_available: false, 40 + latest_version: None, 41 + pending_releases: Vec::new(), 42 + }; 43 + }; 44 + 45 + let installed = match semver::Version::parse(installed_version) { 46 + Ok(v) => v, 47 + Err(_) => { 48 + return UpdateInfo { 49 + update_available: false, 50 + latest_version: Some(entry.latest_version.clone()), 51 + pending_releases: Vec::new(), 52 + }; 53 + } 54 + }; 55 + 56 + let pending: Vec<ReleaseEntry> = entry 57 + .releases 58 + .iter() 59 + .filter(|r| { 60 + semver::Version::parse(&r.version) 61 + .map(|v| v > installed) 62 + .unwrap_or(false) 63 + }) 64 + .cloned() 65 + .collect(); 66 + 67 + UpdateInfo { 68 + update_available: !pending.is_empty(), 69 + latest_version: Some(entry.latest_version.clone()), 70 + pending_releases: pending, 71 + } 72 + } 13 73 14 74 use super::auth::UserAuth; 15 75 use super::permissions::Permission; ··· 40 100 .into_iter() 41 101 .collect() 42 102 }; 103 + 104 + let official_guard = state.official_registry.read().await; 43 105 44 106 let summaries: Vec<PluginSummary> = plugins 45 107 .into_iter() ··· 82 144 let secrets_configured = 83 145 required_secrets.is_empty() || configured_plugins.contains(&p.info.id); 84 146 147 + let update_info = 148 + compute_update_info(&p.info.version, official_guard.plugins.get(&p.info.id)); 149 + 85 150 PluginSummary { 86 151 id: p.info.id.clone(), 87 152 name: p.info.name.clone(), ··· 94 159 required_secrets, 95 160 secrets_configured, 96 161 loaded_at: None, // Would need to track this in registry 162 + update_available: update_info.update_available, 163 + latest_version: update_info.latest_version, 164 + pending_releases: update_info.pending_releases, 97 165 } 98 166 }) 99 167 .collect(); ··· 195 263 required_secrets, 196 264 secrets_configured, 197 265 loaded_at: Some(now_rfc3339()), 266 + update_available: false, 267 + latest_version: None, 268 + pending_releases: Vec::new(), 198 269 }; 199 270 200 271 let plugin_id = plugin.info.id.clone(); ··· 265 336 State(state): State<AppState>, 266 337 auth: UserAuth, 267 338 Path(plugin_id): Path<String>, 339 + body: Option<Json<super::types::ReloadPluginBody>>, 268 340 ) -> Result<Json<PluginSummary>, AppError> { 269 341 auth.require(Permission::PluginsCreate).await?; 270 342 ··· 284 356 } 285 357 }; 286 358 359 + let (url, sha256) = resolve_reload_url((url, sha256), body.map(|Json(b)| b)); 360 + 287 361 // Remove old plugin 288 362 state.plugin_registry.remove(&plugin_id).await; 289 363 ··· 348 422 required_secrets, 349 423 secrets_configured, 350 424 loaded_at: Some(now_rfc3339()), 425 + update_available: false, 426 + latest_version: None, 427 + pending_releases: Vec::new(), 351 428 }; 429 + 430 + // Persist the (possibly new) URL so restarts pick it up 431 + let persist_sql = adapt_sql( 432 + "UPDATE plugins SET url = ?, sha256 = NULL WHERE id = ?", 433 + state.db_backend, 434 + ); 435 + sqlx::query(&persist_sql) 436 + .bind(&url) 437 + .bind(&plugin.info.id) 438 + .execute(&state.db) 439 + .await 440 + .map_err(|e| AppError::Internal(format!("Failed to persist plugin URL: {}", e)))?; 352 441 353 442 // Register the reloaded plugin 354 443 state.plugin_registry.register(plugin).await; ··· 544 633 545 634 Ok(StatusCode::NO_CONTENT) 546 635 } 636 + 637 + /// POST /admin/plugins/{id}/check-update — force a cache refresh for one plugin 638 + pub(super) async fn check_update( 639 + State(state): State<AppState>, 640 + auth: UserAuth, 641 + Path(plugin_id): Path<String>, 642 + ) -> Result<Json<PluginSummary>, AppError> { 643 + auth.require(Permission::PluginsCreate).await?; 644 + 645 + crate::plugin::official_registry::refresh_plugin( 646 + &state.http, 647 + &state.official_registry_config, 648 + &state.official_registry, 649 + &plugin_id, 650 + ) 651 + .await 652 + .map_err(|e| AppError::BadRequest(format!("Update check failed: {}", e)))?; 653 + 654 + // Return the refreshed PluginSummary by re-running the same join logic 655 + let current = state 656 + .plugin_registry 657 + .get(&plugin_id) 658 + .await 659 + .ok_or_else(|| AppError::NotFound(format!("Plugin '{}' not found", plugin_id)))?; 660 + 661 + let guard = state.official_registry.read().await; 662 + let update_info = compute_update_info(&current.info.version, guard.plugins.get(&plugin_id)); 663 + 664 + let required_secrets: Vec<super::types::SecretDefinition> = 665 + if let Some(manifest) = &current.manifest { 666 + manifest 667 + .required_secrets 668 + .iter() 669 + .map(|s| super::types::SecretDefinition { 670 + key: s.key.clone(), 671 + name: s.name.clone(), 672 + description: s.description.clone(), 673 + }) 674 + .collect() 675 + } else { 676 + current 677 + .info 678 + .required_secrets 679 + .iter() 680 + .map(|key| super::types::SecretDefinition { 681 + key: key.clone(), 682 + name: key.clone(), 683 + description: None, 684 + }) 685 + .collect() 686 + }; 687 + 688 + let (source, url, sha256) = match &current.source { 689 + crate::plugin::PluginSource::File { path } => { 690 + ("file".to_string(), Some(path.display().to_string()), None) 691 + } 692 + crate::plugin::PluginSource::Url { url, sha256 } => { 693 + ("url".to_string(), Some(url.clone()), sha256.clone()) 694 + } 695 + }; 696 + 697 + Ok(Json(PluginSummary { 698 + id: current.info.id.clone(), 699 + name: current.info.name.clone(), 700 + version: current.info.version.clone(), 701 + source, 702 + url, 703 + sha256, 704 + enabled: true, 705 + auth_type: current.info.auth_type.clone(), 706 + required_secrets, 707 + secrets_configured: true, 708 + loaded_at: None, 709 + update_available: update_info.update_available, 710 + latest_version: update_info.latest_version, 711 + pending_releases: update_info.pending_releases, 712 + })) 713 + } 714 + 715 + /// GET /admin/plugins/official — list plugins from the official registry cache 716 + pub(super) async fn list_official( 717 + State(state): State<AppState>, 718 + auth: UserAuth, 719 + ) -> Result<Json<super::types::OfficialPluginsListResponse>, AppError> { 720 + auth.require(Permission::PluginsRead).await?; 721 + 722 + let guard = state.official_registry.read().await; 723 + let plugins = guard 724 + .plugins 725 + .values() 726 + .map(|p| super::types::OfficialPluginSummary { 727 + id: p.id.clone(), 728 + name: p.name.clone(), 729 + description: p.description.clone(), 730 + icon_url: p.icon_url.clone(), 731 + latest_version: p.latest_version.clone(), 732 + manifest_url: p.manifest_url.clone(), 733 + }) 734 + .collect::<Vec<_>>(); 735 + 736 + Ok(Json(super::types::OfficialPluginsListResponse { 737 + plugins, 738 + last_refreshed_at: guard.last_refreshed_at.clone(), 739 + })) 740 + } 741 + 742 + #[cfg(test)] 743 + mod tests { 744 + use super::*; 745 + use crate::plugin::official_registry::{OfficialPlugin, ReleaseEntry}; 746 + 747 + fn entry(versions: &[&str]) -> OfficialPlugin { 748 + OfficialPlugin { 749 + id: "steam".into(), 750 + name: "steam".into(), 751 + description: None, 752 + icon_url: None, 753 + latest_version: versions[0].into(), 754 + manifest_url: "m".into(), 755 + wasm_url: "w".into(), 756 + releases: versions 757 + .iter() 758 + .map(|v| ReleaseEntry { 759 + version: (*v).into(), 760 + name: format!("v{v}"), 761 + published_at: "2026-04-10T00:00:00Z".into(), 762 + body: "notes".into(), 763 + }) 764 + .collect(), 765 + } 766 + } 767 + 768 + #[test] 769 + fn compute_update_info_flags_update_when_behind() { 770 + let cached = entry(&["1.2.0", "1.1.0", "1.0.0"]); 771 + let info = compute_update_info("1.0.0", Some(&cached)); 772 + assert!(info.update_available); 773 + assert_eq!(info.latest_version.as_deref(), Some("1.2.0")); 774 + assert_eq!(info.pending_releases.len(), 2); 775 + assert_eq!(info.pending_releases[0].version, "1.2.0"); 776 + assert_eq!(info.pending_releases[1].version, "1.1.0"); 777 + } 778 + 779 + #[test] 780 + fn compute_update_info_no_update_when_current() { 781 + let cached = entry(&["1.2.0"]); 782 + let info = compute_update_info("1.2.0", Some(&cached)); 783 + assert!(!info.update_available); 784 + assert_eq!(info.latest_version.as_deref(), Some("1.2.0")); 785 + assert!(info.pending_releases.is_empty()); 786 + } 787 + 788 + #[test] 789 + fn compute_update_info_no_cache_entry() { 790 + let info = compute_update_info("1.2.0", None); 791 + assert!(!info.update_available); 792 + assert!(info.latest_version.is_none()); 793 + assert!(info.pending_releases.is_empty()); 794 + } 795 + 796 + #[test] 797 + fn compute_update_info_handles_malformed_installed_version() { 798 + let cached = entry(&["1.2.0"]); 799 + let info = compute_update_info("not-semver", Some(&cached)); 800 + assert!(!info.update_available); 801 + assert_eq!(info.latest_version.as_deref(), Some("1.2.0")); 802 + } 803 + 804 + #[test] 805 + fn resolve_reload_url_uses_override_and_clears_sha() { 806 + let current = ("https://old".to_string(), Some("deadbeef".to_string())); 807 + let body = super::super::types::ReloadPluginBody { 808 + url: Some("https://new".into()), 809 + }; 810 + let (url, sha) = resolve_reload_url(current, Some(body)); 811 + assert_eq!(url, "https://new"); 812 + assert_eq!(sha, None); 813 + } 814 + 815 + #[test] 816 + fn resolve_reload_url_keeps_current_when_body_absent() { 817 + let current = ("https://old".to_string(), Some("deadbeef".to_string())); 818 + let (url, sha) = resolve_reload_url(current, None); 819 + assert_eq!(url, "https://old"); 820 + assert_eq!(sha.as_deref(), Some("deadbeef")); 821 + } 822 + 823 + #[test] 824 + fn resolve_reload_url_keeps_current_when_body_url_is_none() { 825 + let current = ("https://old".to_string(), Some("deadbeef".to_string())); 826 + let body = super::super::types::ReloadPluginBody { url: None }; 827 + let (url, sha) = resolve_reload_url(current, Some(body)); 828 + assert_eq!(url, "https://old"); 829 + assert_eq!(sha.as_deref(), Some("deadbeef")); 830 + } 831 + }
+28
src/admin/types.rs
··· 251 251 /// Whether all required secrets have been configured 252 252 pub(super) secrets_configured: bool, 253 253 pub(super) loaded_at: Option<String>, 254 + #[serde(default)] 255 + pub(super) update_available: bool, 256 + #[serde(skip_serializing_if = "Option::is_none")] 257 + pub(super) latest_version: Option<String>, 258 + #[serde(default)] 259 + pub(super) pending_releases: Vec<crate::plugin::official_registry::ReleaseEntry>, 260 + } 261 + 262 + #[derive(Serialize)] 263 + pub(super) struct OfficialPluginSummary { 264 + pub(super) id: String, 265 + pub(super) name: String, 266 + pub(super) description: Option<String>, 267 + pub(super) icon_url: Option<String>, 268 + pub(super) latest_version: String, 269 + pub(super) manifest_url: String, 270 + } 271 + 272 + #[derive(Serialize)] 273 + pub(super) struct OfficialPluginsListResponse { 274 + pub(super) plugins: Vec<OfficialPluginSummary>, 275 + pub(super) last_refreshed_at: Option<String>, 276 + } 277 + 278 + #[derive(Deserialize, Default)] 279 + pub(super) struct ReloadPluginBody { 280 + #[serde(default)] 281 + pub(super) url: Option<String>, 254 282 } 255 283 256 284 #[derive(Deserialize)]
+3
src/lib.rs
··· 25 25 use db::DatabaseBackend; 26 26 use dns::NativeDnsResolver; 27 27 use lexicon::LexiconRegistry; 28 + use plugin::official_registry::{RegistryConfig, SharedRegistry}; 28 29 use rate_limit::RateLimiter; 29 30 use std::sync::Arc; 30 31 use tokio::sync::watch; ··· 63 64 pub plugin_registry: Arc<plugin::PluginRegistry>, 64 65 pub wasm_runtime: Arc<plugin::WasmRuntime>, 65 66 pub attestation_signer: Option<Arc<plugin::attestation::AttestationSigner>>, 67 + pub official_registry: SharedRegistry, 68 + pub official_registry_config: RegistryConfig, 66 69 } 67 70 68 71 impl axum::extract::FromRef<AppState> for axum_extra::extract::cookie::Key {
+5
src/lua/atproto_api.rs
··· 364 364 crate::plugin::WasmRuntime::new().expect("wasm runtime"), 365 365 ), 366 366 attestation_signer: None, 367 + official_registry: std::sync::Arc::new(tokio::sync::RwLock::new( 368 + crate::plugin::official_registry::OfficialRegistryState::default(), 369 + )), 370 + official_registry_config: crate::plugin::official_registry::RegistryConfig::production( 371 + ), 367 372 } 368 373 } 369 374
+5
src/lua/db_api.rs
··· 715 715 crate::plugin::WasmRuntime::new().expect("wasm runtime"), 716 716 ), 717 717 attestation_signer: None, 718 + official_registry: std::sync::Arc::new(tokio::sync::RwLock::new( 719 + crate::plugin::official_registry::OfficialRegistryState::default(), 720 + )), 721 + official_registry_config: crate::plugin::official_registry::RegistryConfig::production( 722 + ), 718 723 } 719 724 } 720 725
+5
src/lua/execute.rs
··· 1096 1096 crate::plugin::WasmRuntime::new().expect("wasm runtime"), 1097 1097 ), 1098 1098 attestation_signer: None, 1099 + official_registry: std::sync::Arc::new(tokio::sync::RwLock::new( 1100 + crate::plugin::official_registry::OfficialRegistryState::default(), 1101 + )), 1102 + official_registry_config: crate::plugin::official_registry::RegistryConfig::production( 1103 + ), 1099 1104 } 1100 1105 } 1101 1106
+5
src/lua/http_api.rs
··· 181 181 crate::plugin::WasmRuntime::new().expect("wasm runtime"), 182 182 ), 183 183 attestation_signer: None, 184 + official_registry: std::sync::Arc::new(tokio::sync::RwLock::new( 185 + crate::plugin::official_registry::OfficialRegistryState::default(), 186 + )), 187 + official_registry_config: crate::plugin::official_registry::RegistryConfig::production( 188 + ), 184 189 } 185 190 } 186 191
+5
src/lua/xrpc_api.rs
··· 285 285 crate::plugin::WasmRuntime::new().expect("wasm runtime"), 286 286 ), 287 287 attestation_signer: None, 288 + official_registry: std::sync::Arc::new(tokio::sync::RwLock::new( 289 + crate::plugin::official_registry::OfficialRegistryState::default(), 290 + )), 291 + official_registry_config: crate::plugin::official_registry::RegistryConfig::production( 292 + ), 288 293 } 289 294 } 290 295
+14
src/main.rs
··· 407 407 ) 408 408 .await; 409 409 410 + let official_registry: happyview::plugin::official_registry::SharedRegistry = 411 + std::sync::Arc::new(tokio::sync::RwLock::new( 412 + happyview::plugin::official_registry::OfficialRegistryState::default(), 413 + )); 414 + let official_registry_config = 415 + happyview::plugin::official_registry::RegistryConfig::production(); 416 + happyview::plugin::official_registry::spawn_refresh_task( 417 + http.clone(), 418 + official_registry_config.clone(), 419 + official_registry.clone(), 420 + ); 421 + 410 422 let state = AppState { 411 423 config: config.clone(), 412 424 http, ··· 422 434 plugin_registry, 423 435 wasm_runtime, 424 436 attestation_signer, 437 + official_registry, 438 + official_registry_config, 425 439 }; 426 440 427 441 jetstream::spawn(state.clone(), collections_rx);
+14 -2
src/plugin/host/logging.rs
··· 103 103 fn test_log_does_not_panic() { 104 104 // With db=None, only the tracing path runs. Verifies each level does not panic. 105 105 let backend = crate::db::DatabaseBackend::Sqlite; 106 - log("test-plugin", LogLevel::Debug, "debug message", None, backend); 106 + log( 107 + "test-plugin", 108 + LogLevel::Debug, 109 + "debug message", 110 + None, 111 + backend, 112 + ); 107 113 log("test-plugin", LogLevel::Info, "info message", None, backend); 108 114 log("test-plugin", LogLevel::Warn, "warn message", None, backend); 109 - log("test-plugin", LogLevel::Error, "error message", None, backend); 115 + log( 116 + "test-plugin", 117 + LogLevel::Error, 118 + "error message", 119 + None, 120 + backend, 121 + ); 110 122 } 111 123 }
+1
src/plugin/mod.rs
··· 4 4 pub mod host; 5 5 pub mod loader; 6 6 pub mod memory; 7 + pub mod official_registry; 7 8 mod runtime; 8 9 pub mod sync; 9 10 mod types;
+443
src/plugin/official_registry.rs
··· 1 + //! Cache of plugins discovered from the official `happyview-plugins` repo. 2 + 3 + use semver::Version; 4 + use serde::{Deserialize, Serialize}; 5 + use std::collections::HashMap; 6 + use std::sync::Arc; 7 + use tokio::sync::RwLock; 8 + 9 + pub const OFFICIAL_REPO: &str = "gamesgamesgamesgamesgames/happyview-plugins"; 10 + 11 + /// A release entry for the update preview UI. 12 + #[derive(Debug, Clone, Serialize, Deserialize)] 13 + pub struct ReleaseEntry { 14 + pub version: String, 15 + pub name: String, 16 + pub published_at: String, 17 + pub body: String, 18 + } 19 + 20 + /// One plugin discovered in the official repo. 21 + #[derive(Debug, Clone, Serialize)] 22 + pub struct OfficialPlugin { 23 + pub id: String, 24 + pub name: String, 25 + pub description: Option<String>, 26 + pub icon_url: Option<String>, 27 + pub latest_version: String, 28 + pub manifest_url: String, 29 + pub wasm_url: String, 30 + pub releases: Vec<ReleaseEntry>, 31 + } 32 + 33 + /// Cache state stored on `AppState` behind an `Arc<RwLock<_>>`. 34 + #[derive(Debug, Default)] 35 + pub struct OfficialRegistryState { 36 + pub plugins: HashMap<String, OfficialPlugin>, 37 + pub last_refreshed_at: Option<String>, 38 + } 39 + 40 + pub type SharedRegistry = Arc<RwLock<OfficialRegistryState>>; 41 + 42 + /// Raw GitHub release payload (subset of fields we use). 43 + #[derive(Debug, Clone, Deserialize)] 44 + pub struct GithubRelease { 45 + pub tag_name: String, 46 + pub name: Option<String>, 47 + pub published_at: String, 48 + pub body: Option<String>, 49 + pub html_url: String, 50 + } 51 + 52 + /// Parse a monorepo tag like `steam-v1.2.0` into `("steam", "1.2.0")`. 53 + /// Returns `None` for tags we don't recognize. 54 + pub fn parse_tag(tag: &str) -> Option<(String, Version)> { 55 + let (id, version) = tag.rsplit_once("-v")?; 56 + let parsed = Version::parse(version).ok()?; 57 + Some((id.to_string(), parsed)) 58 + } 59 + 60 + /// Group releases by plugin id, filter out unparseable tags, sort each 61 + /// group newest-first. 62 + pub fn group_releases( 63 + releases: Vec<GithubRelease>, 64 + ) -> HashMap<String, Vec<(Version, GithubRelease)>> { 65 + let mut grouped: HashMap<String, Vec<(Version, GithubRelease)>> = HashMap::new(); 66 + for release in releases { 67 + let Some((id, version)) = parse_tag(&release.tag_name) else { 68 + continue; 69 + }; 70 + grouped.entry(id).or_default().push((version, release)); 71 + } 72 + for entries in grouped.values_mut() { 73 + entries.sort_by(|a, b| b.0.cmp(&a.0)); 74 + } 75 + grouped 76 + } 77 + 78 + /// Convert a grouped release entry list into serializable `ReleaseEntry`s 79 + /// for the cache / UI. The first entry is the latest. 80 + pub fn to_release_entries(entries: &[(Version, GithubRelease)]) -> Vec<ReleaseEntry> { 81 + entries 82 + .iter() 83 + .map(|(version, release)| ReleaseEntry { 84 + version: version.to_string(), 85 + name: release 86 + .name 87 + .clone() 88 + .unwrap_or_else(|| release.tag_name.clone()), 89 + published_at: release.published_at.clone(), 90 + body: release.body.clone().unwrap_or_default(), 91 + }) 92 + .collect() 93 + } 94 + 95 + use crate::plugin::loader; 96 + 97 + #[derive(Debug, Clone)] 98 + pub struct RegistryConfig { 99 + /// Base URL for the GitHub REST API, e.g. `https://api.github.com`. 100 + pub api_base: String, 101 + /// Base URL for release asset downloads, e.g. 102 + /// `https://github.com/gamesgamesgamesgamesgames/happyview-plugins/releases/download`. 103 + pub release_base: String, 104 + } 105 + 106 + impl RegistryConfig { 107 + pub fn production() -> Self { 108 + Self { 109 + api_base: "https://api.github.com".into(), 110 + release_base: format!("https://github.com/{}/releases/download", OFFICIAL_REPO), 111 + } 112 + } 113 + } 114 + 115 + #[derive(Debug, thiserror::Error)] 116 + pub enum RegistryError { 117 + #[error("GitHub API request failed: {0}")] 118 + Http(#[from] reqwest::Error), 119 + #[error("GitHub API returned status {0}")] 120 + Status(u16), 121 + } 122 + 123 + async fn fetch_releases( 124 + client: &reqwest::Client, 125 + config: &RegistryConfig, 126 + ) -> Result<Vec<GithubRelease>, RegistryError> { 127 + let url = format!( 128 + "{}/repos/{}/releases?per_page=100", 129 + config.api_base, OFFICIAL_REPO 130 + ); 131 + let response = client 132 + .get(&url) 133 + .header("User-Agent", "happyview") 134 + .header("Accept", "application/vnd.github+json") 135 + .send() 136 + .await?; 137 + if !response.status().is_success() { 138 + return Err(RegistryError::Status(response.status().as_u16())); 139 + } 140 + let releases: Vec<GithubRelease> = response.json().await?; 141 + Ok(releases) 142 + } 143 + 144 + async fn build_official_plugin( 145 + client: &reqwest::Client, 146 + config: &RegistryConfig, 147 + id: &str, 148 + entries: &[(Version, GithubRelease)], 149 + ) -> OfficialPlugin { 150 + let (latest_version, _) = entries 151 + .first() 152 + .map(|(v, _)| (v.to_string(), ())) 153 + .expect("entries non-empty"); 154 + let tag = format!("{}-v{}", id, latest_version); 155 + let manifest_url = format!("{}/{}/manifest.json", config.release_base, tag); 156 + 157 + // Try to enrich with manifest fields. On failure, fall back to id. 158 + let (name, description, icon_url, wasm_url) = 159 + match loader::fetch_manifest(client, &manifest_url).await { 160 + Ok(preview) => ( 161 + preview.manifest.name, 162 + preview.manifest.description, 163 + preview.manifest.icon_url, 164 + preview.wasm_url, 165 + ), 166 + Err(e) => { 167 + tracing::warn!( 168 + plugin = id, 169 + error = %e, 170 + "official_registry: failed to fetch manifest, using fallback metadata" 171 + ); 172 + ( 173 + id.to_string(), 174 + None, 175 + None, 176 + format!("{}/{}/{}.wasm", config.release_base, tag, id), 177 + ) 178 + } 179 + }; 180 + 181 + OfficialPlugin { 182 + id: id.to_string(), 183 + name, 184 + description, 185 + icon_url, 186 + latest_version, 187 + manifest_url, 188 + wasm_url, 189 + releases: to_release_entries(entries), 190 + } 191 + } 192 + 193 + /// Fetch all releases and rebuild the cache atomically. On error, the 194 + /// previous cache is retained and the error is returned. 195 + pub async fn refresh_full( 196 + client: &reqwest::Client, 197 + config: &RegistryConfig, 198 + state: &SharedRegistry, 199 + ) -> Result<(), RegistryError> { 200 + let releases = fetch_releases(client, config).await?; 201 + let grouped = group_releases(releases); 202 + 203 + let mut plugins = HashMap::new(); 204 + for (id, entries) in grouped { 205 + if entries.is_empty() { 206 + continue; 207 + } 208 + let plugin = build_official_plugin(client, config, &id, &entries).await; 209 + plugins.insert(id, plugin); 210 + } 211 + 212 + let mut guard = state.write().await; 213 + guard.plugins = plugins; 214 + guard.last_refreshed_at = Some(crate::db::now_rfc3339()); 215 + Ok(()) 216 + } 217 + 218 + /// Refresh just one plugin's cache entry from a fresh GitHub fetch. 219 + /// Falls back to removing the entry if the plugin has no releases. 220 + pub async fn refresh_plugin( 221 + client: &reqwest::Client, 222 + config: &RegistryConfig, 223 + state: &SharedRegistry, 224 + plugin_id: &str, 225 + ) -> Result<Option<OfficialPlugin>, RegistryError> { 226 + let releases = fetch_releases(client, config).await?; 227 + let mut grouped = group_releases(releases); 228 + 229 + let Some(entries) = grouped.remove(plugin_id) else { 230 + let mut guard = state.write().await; 231 + guard.plugins.remove(plugin_id); 232 + return Ok(None); 233 + }; 234 + 235 + let plugin = build_official_plugin(client, config, plugin_id, &entries).await; 236 + let mut guard = state.write().await; 237 + guard.plugins.insert(plugin_id.to_string(), plugin.clone()); 238 + guard.last_refreshed_at = Some(crate::db::now_rfc3339()); 239 + Ok(Some(plugin)) 240 + } 241 + 242 + /// Background task: run `refresh_full` on startup, then every 15 minutes. 243 + pub fn spawn_refresh_task(client: reqwest::Client, config: RegistryConfig, state: SharedRegistry) { 244 + tokio::spawn(async move { 245 + loop { 246 + match refresh_full(&client, &config, &state).await { 247 + Ok(()) => tracing::info!("official_registry: cache refreshed"), 248 + Err(e) => tracing::warn!(error = %e, "official_registry: refresh failed"), 249 + } 250 + tokio::time::sleep(std::time::Duration::from_secs(15 * 60)).await; 251 + } 252 + }); 253 + } 254 + 255 + #[cfg(test)] 256 + mod tests { 257 + use super::*; 258 + 259 + fn make_release(tag: &str) -> GithubRelease { 260 + GithubRelease { 261 + tag_name: tag.to_string(), 262 + name: Some(tag.to_string()), 263 + published_at: "2026-04-13T00:00:00Z".to_string(), 264 + body: Some(format!("body for {tag}")), 265 + html_url: format!("https://example.com/{tag}"), 266 + } 267 + } 268 + 269 + #[test] 270 + fn parse_tag_happy_path() { 271 + let (id, version) = parse_tag("steam-v1.2.0").unwrap(); 272 + assert_eq!(id, "steam"); 273 + assert_eq!(version, Version::parse("1.2.0").unwrap()); 274 + } 275 + 276 + #[test] 277 + fn parse_tag_prerelease() { 278 + let (id, version) = parse_tag("xbox-v2.0.0-beta.1").unwrap(); 279 + assert_eq!(id, "xbox"); 280 + assert_eq!(version, Version::parse("2.0.0-beta.1").unwrap()); 281 + } 282 + 283 + #[test] 284 + fn parse_tag_rejects_malformed() { 285 + assert!(parse_tag("not-a-tag").is_none()); 286 + assert!(parse_tag("steam-v").is_none()); 287 + assert!(parse_tag("steam-vNOT_SEMVER").is_none()); 288 + } 289 + 290 + #[test] 291 + fn group_releases_sorts_newest_first() { 292 + let releases = vec![ 293 + make_release("steam-v1.0.0"), 294 + make_release("steam-v1.2.0"), 295 + make_release("steam-v1.1.0"), 296 + make_release("xbox-v0.1.0"), 297 + make_release("garbage-tag"), 298 + ]; 299 + let grouped = group_releases(releases); 300 + assert_eq!(grouped.len(), 2); 301 + let steam = grouped.get("steam").unwrap(); 302 + assert_eq!(steam.len(), 3); 303 + assert_eq!(steam[0].0.to_string(), "1.2.0"); 304 + assert_eq!(steam[1].0.to_string(), "1.1.0"); 305 + assert_eq!(steam[2].0.to_string(), "1.0.0"); 306 + } 307 + 308 + #[test] 309 + fn to_release_entries_preserves_order() { 310 + let grouped = group_releases(vec![ 311 + make_release("steam-v1.1.0"), 312 + make_release("steam-v1.2.0"), 313 + ]); 314 + let entries = to_release_entries(grouped.get("steam").unwrap()); 315 + assert_eq!(entries[0].version, "1.2.0"); 316 + assert_eq!(entries[1].version, "1.1.0"); 317 + assert_eq!(entries[0].body, "body for steam-v1.2.0"); 318 + } 319 + 320 + use wiremock::matchers::{method, path, query_param}; 321 + use wiremock::{Mock, MockServer, ResponseTemplate}; 322 + 323 + fn gh_release_json(tag: &str, body: &str) -> serde_json::Value { 324 + serde_json::json!({ 325 + "tag_name": tag, 326 + "name": tag, 327 + "published_at": "2026-04-10T00:00:00Z", 328 + "body": body, 329 + "html_url": format!("https://example.com/{tag}"), 330 + }) 331 + } 332 + 333 + fn manifest_json(id: &str, version: &str) -> serde_json::Value { 334 + serde_json::json!({ 335 + "id": id, 336 + "name": id.to_string() + " Plugin", 337 + "version": version, 338 + "api_version": "1", 339 + "description": format!("The {id} plugin"), 340 + "icon_url": format!("https://example.com/{id}.png"), 341 + "wasm_file": format!("{id}.wasm"), 342 + "required_secrets": [], 343 + "auth_type": "oauth2", 344 + }) 345 + } 346 + 347 + #[tokio::test] 348 + async fn refresh_full_populates_cache_from_mock_github() { 349 + let server = MockServer::start().await; 350 + 351 + Mock::given(method("GET")) 352 + .and(path( 353 + "/repos/gamesgamesgamesgamesgames/happyview-plugins/releases", 354 + )) 355 + .and(query_param("per_page", "100")) 356 + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ 357 + gh_release_json("steam-v1.2.0", "- steam 1.2.0 notes"), 358 + gh_release_json("steam-v1.1.0", "- steam 1.1.0 notes"), 359 + gh_release_json("xbox-v0.1.0", "- xbox 0.1.0 notes"), 360 + gh_release_json("bogus-tag", "ignored"), 361 + ]))) 362 + .mount(&server) 363 + .await; 364 + 365 + Mock::given(method("GET")) 366 + .and(path("/download/steam-v1.2.0/manifest.json")) 367 + .respond_with(ResponseTemplate::new(200).set_body_json(manifest_json("steam", "1.2.0"))) 368 + .mount(&server) 369 + .await; 370 + 371 + Mock::given(method("GET")) 372 + .and(path("/download/xbox-v0.1.0/manifest.json")) 373 + .respond_with(ResponseTemplate::new(200).set_body_json(manifest_json("xbox", "0.1.0"))) 374 + .mount(&server) 375 + .await; 376 + 377 + let client = reqwest::Client::new(); 378 + let state: SharedRegistry = Arc::new(RwLock::new(OfficialRegistryState::default())); 379 + 380 + let config = RegistryConfig { 381 + api_base: server.uri(), 382 + release_base: format!("{}/download", server.uri()), 383 + }; 384 + 385 + refresh_full(&client, &config, &state).await.unwrap(); 386 + 387 + let guard = state.read().await; 388 + assert_eq!(guard.plugins.len(), 2); 389 + 390 + let steam = guard.plugins.get("steam").unwrap(); 391 + assert_eq!(steam.latest_version, "1.2.0"); 392 + assert_eq!(steam.releases.len(), 2); 393 + assert_eq!(steam.releases[0].version, "1.2.0"); 394 + assert_eq!(steam.releases[1].version, "1.1.0"); 395 + assert_eq!(steam.name, "steam Plugin"); 396 + assert_eq!(steam.description.as_deref(), Some("The steam plugin")); 397 + assert!(steam.manifest_url.contains("steam-v1.2.0")); 398 + assert!(steam.wasm_url.ends_with("steam.wasm")); 399 + 400 + assert!(guard.last_refreshed_at.is_some()); 401 + } 402 + 403 + #[tokio::test] 404 + async fn refresh_full_retains_previous_cache_on_error() { 405 + let server = MockServer::start().await; 406 + Mock::given(method("GET")) 407 + .and(path( 408 + "/repos/gamesgamesgamesgamesgames/happyview-plugins/releases", 409 + )) 410 + .respond_with(ResponseTemplate::new(500)) 411 + .mount(&server) 412 + .await; 413 + 414 + let state: SharedRegistry = Arc::new(RwLock::new(OfficialRegistryState { 415 + plugins: HashMap::from([( 416 + "steam".to_string(), 417 + OfficialPlugin { 418 + id: "steam".into(), 419 + name: "steam Plugin".into(), 420 + description: None, 421 + icon_url: None, 422 + latest_version: "1.0.0".into(), 423 + manifest_url: "https://example.com/m".into(), 424 + wasm_url: "https://example.com/w".into(), 425 + releases: vec![], 426 + }, 427 + )]), 428 + last_refreshed_at: Some("2026-04-12T00:00:00Z".into()), 429 + })); 430 + 431 + let config = RegistryConfig { 432 + api_base: server.uri(), 433 + release_base: format!("{}/download", server.uri()), 434 + }; 435 + 436 + let result = refresh_full(&reqwest::Client::new(), &config, &state).await; 437 + assert!(result.is_err()); 438 + 439 + let guard = state.read().await; 440 + assert_eq!(guard.plugins.len(), 1); 441 + assert!(guard.plugins.contains_key("steam")); 442 + } 443 + }
+290
tests/admin_plugins_official.rs
··· 1 + mod common; 2 + 3 + use axum::body::Body; 4 + use axum::http::{Request, StatusCode}; 5 + use http_body_util::BodyExt; 6 + use serde_json::{Value, json}; 7 + use serial_test::serial; 8 + use tower::ServiceExt; 9 + use wiremock::matchers::{method, path}; 10 + use wiremock::{Mock, MockServer, ResponseTemplate}; 11 + 12 + use common::app::TestApp; 13 + 14 + async fn json_body(resp: axum::response::Response) -> Value { 15 + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); 16 + serde_json::from_slice(&bytes).unwrap() 17 + } 18 + 19 + fn admin_get( 20 + uri: &str, 21 + cookie: (axum::http::HeaderName, axum::http::HeaderValue), 22 + ) -> Request<Body> { 23 + Request::builder() 24 + .uri(uri) 25 + .header(cookie.0, cookie.1) 26 + .body(Body::empty()) 27 + .unwrap() 28 + } 29 + 30 + fn admin_post( 31 + uri: &str, 32 + cookie: (axum::http::HeaderName, axum::http::HeaderValue), 33 + ) -> Request<Body> { 34 + Request::builder() 35 + .method("POST") 36 + .uri(uri) 37 + .header(cookie.0, cookie.1) 38 + .body(Body::empty()) 39 + .unwrap() 40 + } 41 + 42 + #[tokio::test] 43 + #[serial] 44 + #[ignore] 45 + async fn official_plugins_endpoint_returns_cached_list() { 46 + let app = TestApp::new().await; 47 + 48 + let gh = MockServer::start().await; 49 + 50 + Mock::given(method("GET")) 51 + .and(path( 52 + "/repos/gamesgamesgamesgamesgames/happyview-plugins/releases", 53 + )) 54 + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ 55 + { 56 + "tag_name": "steam-v1.2.0", 57 + "name": "steam-v1.2.0", 58 + "published_at": "2026-04-10T00:00:00Z", 59 + "body": "- logging improvements", 60 + "html_url": "https://example.com/steam-v1.2.0" 61 + } 62 + ]))) 63 + .mount(&gh) 64 + .await; 65 + 66 + Mock::given(method("GET")) 67 + .and(path("/download/steam-v1.2.0/manifest.json")) 68 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 69 + "id": "steam", 70 + "name": "Steam", 71 + "version": "1.2.0", 72 + "api_version": "1", 73 + "description": "Steam OAuth plugin", 74 + "icon_url": "https://example.com/steam.png", 75 + "wasm_file": "steam.wasm", 76 + "required_secrets": [], 77 + "auth_type": "openid" 78 + }))) 79 + .mount(&gh) 80 + .await; 81 + 82 + let config = happyview::plugin::official_registry::RegistryConfig { 83 + api_base: gh.uri(), 84 + release_base: format!("{}/download", gh.uri()), 85 + }; 86 + 87 + happyview::plugin::official_registry::refresh_full( 88 + &app.state.http, 89 + &config, 90 + &app.state.official_registry, 91 + ) 92 + .await 93 + .unwrap(); 94 + 95 + let resp = app 96 + .router 97 + .clone() 98 + .oneshot(admin_get("/admin/plugins/official", app.admin_cookie())) 99 + .await 100 + .unwrap(); 101 + 102 + assert_eq!(resp.status(), StatusCode::OK); 103 + let body = json_body(resp).await; 104 + let plugins = body["plugins"].as_array().unwrap(); 105 + assert_eq!(plugins.len(), 1); 106 + assert_eq!(plugins[0]["id"], "steam"); 107 + assert_eq!(plugins[0]["name"], "Steam"); 108 + assert_eq!(plugins[0]["latest_version"], "1.2.0"); 109 + assert!(body["last_refreshed_at"].is_string()); 110 + } 111 + 112 + #[tokio::test] 113 + #[serial] 114 + #[ignore] 115 + async fn plugins_list_populates_update_available_when_behind() { 116 + let app = TestApp::new().await; 117 + 118 + let gh = MockServer::start().await; 119 + 120 + Mock::given(method("GET")) 121 + .and(path( 122 + "/repos/gamesgamesgamesgamesgames/happyview-plugins/releases", 123 + )) 124 + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ 125 + { 126 + "tag_name": "steam-v1.2.0", 127 + "name": "steam-v1.2.0", 128 + "published_at": "2026-04-10T00:00:00Z", 129 + "body": "- logging improvements", 130 + "html_url": "https://example.com/steam-v1.2.0" 131 + }, 132 + { 133 + "tag_name": "steam-v1.1.0", 134 + "name": "steam-v1.1.0", 135 + "published_at": "2026-03-01T00:00:00Z", 136 + "body": "- initial", 137 + "html_url": "https://example.com/steam-v1.1.0" 138 + } 139 + ]))) 140 + .mount(&gh) 141 + .await; 142 + 143 + Mock::given(method("GET")) 144 + .and(path("/download/steam-v1.2.0/manifest.json")) 145 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 146 + "id": "steam", 147 + "name": "Steam", 148 + "version": "1.2.0", 149 + "api_version": "1", 150 + "wasm_file": "steam.wasm", 151 + "required_secrets": [], 152 + "auth_type": "openid" 153 + }))) 154 + .mount(&gh) 155 + .await; 156 + 157 + app.install_fake_plugin("steam", "1.1.0").await; 158 + 159 + let config = happyview::plugin::official_registry::RegistryConfig { 160 + api_base: gh.uri(), 161 + release_base: format!("{}/download", gh.uri()), 162 + }; 163 + happyview::plugin::official_registry::refresh_full( 164 + &app.state.http, 165 + &config, 166 + &app.state.official_registry, 167 + ) 168 + .await 169 + .unwrap(); 170 + 171 + let resp = app 172 + .router 173 + .clone() 174 + .oneshot(admin_get("/admin/plugins", app.admin_cookie())) 175 + .await 176 + .unwrap(); 177 + 178 + let body = json_body(resp).await; 179 + let plugins = body["plugins"].as_array().unwrap(); 180 + let steam = plugins.iter().find(|p| p["id"] == "steam").unwrap(); 181 + assert_eq!(steam["update_available"], true); 182 + assert_eq!(steam["latest_version"], "1.2.0"); 183 + let pending = steam["pending_releases"].as_array().unwrap(); 184 + assert_eq!(pending.len(), 1); 185 + assert_eq!(pending[0]["version"], "1.2.0"); 186 + } 187 + 188 + #[tokio::test] 189 + #[serial] 190 + #[ignore] 191 + async fn check_update_endpoint_refreshes_cache_on_demand() { 192 + // Start the mock server BEFORE building the app so we can wire its URL 193 + // into the registry config. 194 + let gh = MockServer::start().await; 195 + 196 + Mock::given(method("GET")) 197 + .and(path( 198 + "/repos/gamesgamesgamesgamesgames/happyview-plugins/releases", 199 + )) 200 + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ 201 + { 202 + "tag_name": "steam-v2.0.0", 203 + "name": "steam-v2.0.0", 204 + "published_at": "2026-04-12T00:00:00Z", 205 + "body": "- major rewrite", 206 + "html_url": "https://example.com/steam-v2.0.0" 207 + } 208 + ]))) 209 + .mount(&gh) 210 + .await; 211 + 212 + Mock::given(method("GET")) 213 + .and(path("/download/steam-v2.0.0/manifest.json")) 214 + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ 215 + "id": "steam", 216 + "name": "Steam", 217 + "version": "2.0.0", 218 + "api_version": "1", 219 + "description": "Steam OAuth plugin", 220 + "icon_url": null, 221 + "wasm_file": "steam.wasm", 222 + "required_secrets": [], 223 + "auth_type": "openid" 224 + }))) 225 + .mount(&gh) 226 + .await; 227 + 228 + let config = happyview::plugin::official_registry::RegistryConfig { 229 + api_base: gh.uri(), 230 + release_base: format!("{}/download", gh.uri()), 231 + }; 232 + let app = TestApp::new_with_registry_config(config).await; 233 + 234 + // Install a plugin at 1.0.0 — the cache starts empty, so /admin/plugins 235 + // should initially report no update available. 236 + app.install_fake_plugin("steam", "1.0.0").await; 237 + 238 + let resp = app 239 + .router 240 + .clone() 241 + .oneshot(admin_get("/admin/plugins", app.admin_cookie())) 242 + .await 243 + .unwrap(); 244 + let body = json_body(resp).await; 245 + let steam_before = body["plugins"] 246 + .as_array() 247 + .unwrap() 248 + .iter() 249 + .find(|p| p["id"] == "steam") 250 + .unwrap(); 251 + assert_eq!(steam_before["update_available"], false); 252 + assert!(steam_before["latest_version"].is_null()); 253 + 254 + // Force an on-demand refresh. The handler should call the mock GH API, 255 + // populate the cache, and return a summary with update fields filled in. 256 + let resp = app 257 + .router 258 + .clone() 259 + .oneshot(admin_post( 260 + "/admin/plugins/steam/check-update", 261 + app.admin_cookie(), 262 + )) 263 + .await 264 + .unwrap(); 265 + assert_eq!(resp.status(), StatusCode::OK); 266 + let body = json_body(resp).await; 267 + assert_eq!(body["id"], "steam"); 268 + assert_eq!(body["update_available"], true); 269 + assert_eq!(body["latest_version"], "2.0.0"); 270 + let pending = body["pending_releases"].as_array().unwrap(); 271 + assert_eq!(pending.len(), 1); 272 + assert_eq!(pending[0]["version"], "2.0.0"); 273 + 274 + // A follow-up /admin/plugins call should now also reflect the cache. 275 + let resp = app 276 + .router 277 + .clone() 278 + .oneshot(admin_get("/admin/plugins", app.admin_cookie())) 279 + .await 280 + .unwrap(); 281 + let body = json_body(resp).await; 282 + let steam_after = body["plugins"] 283 + .as_array() 284 + .unwrap() 285 + .iter() 286 + .find(|p| p["id"] == "steam") 287 + .unwrap(); 288 + assert_eq!(steam_after["update_available"], true); 289 + assert_eq!(steam_after["latest_version"], "2.0.0"); 290 + }
+38
tests/common/app.rs
··· 24 24 25 25 impl TestApp { 26 26 pub async fn new() -> Self { 27 + Self::new_with_registry_config( 28 + happyview::plugin::official_registry::RegistryConfig::production(), 29 + ) 30 + .await 31 + } 32 + 33 + pub async fn new_with_registry_config( 34 + registry_config: happyview::plugin::official_registry::RegistryConfig, 35 + ) -> Self { 27 36 let pool = db::test_pool().await; 28 37 let backend = db::test_backend(); 29 38 db::truncate_all(&pool).await; ··· 141 150 happyview::plugin::WasmRuntime::new().expect("wasm runtime"), 142 151 ), 143 152 attestation_signer: None, 153 + official_registry: std::sync::Arc::new(tokio::sync::RwLock::new( 154 + happyview::plugin::official_registry::OfficialRegistryState::default(), 155 + )), 156 + official_registry_config: registry_config, 144 157 }; 145 158 146 159 let router = server::router(state.clone()); ··· 157 170 /// Build a Cookie header that authenticates as the admin user. 158 171 pub fn admin_cookie(&self) -> (axum::http::HeaderName, axum::http::HeaderValue) { 159 172 crate::common::auth::admin_cookie_header(&self.admin_did, &self.state.cookie_key) 173 + } 174 + 175 + /// Install a fake plugin directly into the registry at the given version. 176 + pub async fn install_fake_plugin(&self, id: &str, version: &str) { 177 + use happyview::plugin::{LoadedPlugin, PluginInfo, PluginSource}; 178 + 179 + let plugin = LoadedPlugin { 180 + info: PluginInfo { 181 + id: id.to_string(), 182 + name: id.to_string(), 183 + version: version.to_string(), 184 + api_version: "1".to_string(), 185 + icon_url: None, 186 + required_secrets: vec![], 187 + auth_type: "openid".to_string(), 188 + config_schema: None, 189 + }, 190 + source: PluginSource::Url { 191 + url: format!("https://example.com/{id}.wasm"), 192 + sha256: None, 193 + }, 194 + wasm_bytes: vec![], 195 + manifest: None, 196 + }; 197 + self.state.plugin_registry.register(plugin).await; 160 198 } 161 199 }
+16
tests/fixtures/github_releases.json
··· 1 + [ 2 + { 3 + "tag_name": "steam-v1.2.0", 4 + "name": "steam-v1.2.0", 5 + "published_at": "2026-04-10T00:00:00Z", 6 + "body": "- Added better logging\n- Fixed token refresh", 7 + "html_url": "https://example.com/steam-v1.2.0" 8 + }, 9 + { 10 + "tag_name": "steam-v1.1.0", 11 + "name": "steam-v1.1.0", 12 + "published_at": "2026-03-01T00:00:00Z", 13 + "body": "- Initial release", 14 + "html_url": "https://example.com/steam-v1.1.0" 15 + } 16 + ]
+5
tests/lua_atproto_api.rs
··· 92 92 happyview::plugin::WasmRuntime::new().expect("wasm runtime"), 93 93 ), 94 94 attestation_signer: None, 95 + official_registry: std::sync::Arc::new(tokio::sync::RwLock::new( 96 + happyview::plugin::official_registry::OfficialRegistryState::default(), 97 + )), 98 + official_registry_config: happyview::plugin::official_registry::RegistryConfig::production( 99 + ), 95 100 } 96 101 } 97 102
+5
tests/lua_db_api.rs
··· 95 95 happyview::plugin::WasmRuntime::new().expect("wasm runtime"), 96 96 ), 97 97 attestation_signer: None, 98 + official_registry: std::sync::Arc::new(tokio::sync::RwLock::new( 99 + happyview::plugin::official_registry::OfficialRegistryState::default(), 100 + )), 101 + official_registry_config: happyview::plugin::official_registry::RegistryConfig::production( 102 + ), 98 103 } 99 104 } 100 105
+41 -6
tests/plugin_logging.rs
··· 27 27 let backend = test_backend(); 28 28 truncate_all(&pool).await; 29 29 30 - log("my-plugin", LogLevel::Debug, "dbg msg", Some(pool.clone()), backend); 31 - log("my-plugin", LogLevel::Info, "info msg", Some(pool.clone()), backend); 32 - log("my-plugin", LogLevel::Warn, "warn msg", Some(pool.clone()), backend); 33 - log("my-plugin", LogLevel::Error, "err msg", Some(pool.clone()), backend); 30 + log( 31 + "my-plugin", 32 + LogLevel::Debug, 33 + "dbg msg", 34 + Some(pool.clone()), 35 + backend, 36 + ); 37 + log( 38 + "my-plugin", 39 + LogLevel::Info, 40 + "info msg", 41 + Some(pool.clone()), 42 + backend, 43 + ); 44 + log( 45 + "my-plugin", 46 + LogLevel::Warn, 47 + "warn msg", 48 + Some(pool.clone()), 49 + backend, 50 + ); 51 + log( 52 + "my-plugin", 53 + LogLevel::Error, 54 + "err msg", 55 + Some(pool.clone()), 56 + backend, 57 + ); 34 58 35 59 flush_spawned_tasks().await; 36 60 ··· 44 68 .await 45 69 .expect("failed to query event_logs"); 46 70 47 - assert_eq!(rows.len(), 4, "expected 4 plugin.log rows, got {}", rows.len()); 71 + assert_eq!( 72 + rows.len(), 73 + 4, 74 + "expected 4 plugin.log rows, got {}", 75 + rows.len() 76 + ); 48 77 49 78 // Severity mapping: Debug->info, Info->info, Warn->warn, Error->error 50 79 let severities: Vec<&str> = rows.iter().map(|(s, _, _)| s.as_str()).collect(); ··· 79 108 truncate_all(&pool).await; 80 109 81 110 // db=None: should only emit to tracing, not persist. 82 - log("silent-plugin", LogLevel::Info, "should not persist", None, backend); 111 + log( 112 + "silent-plugin", 113 + LogLevel::Info, 114 + "should not persist", 115 + None, 116 + backend, 117 + ); 83 118 84 119 flush_spawned_tasks().await; 85 120
+501 -49
web/package-lock.json
··· 11 11 "@base-ui/react": "^1.2.0", 12 12 "@monaco-editor/react": "^4.7.0", 13 13 "@tabler/icons-react": "^3.36.1", 14 + "@tailwindcss/typography": "^0.5.19", 14 15 "@tanstack/react-table": "^8.21.3", 15 16 "class-variance-authority": "^0.7.1", 16 17 "clsx": "^2.1.1", ··· 24 25 "react": "19.2.4", 25 26 "react-day-picker": "^9.13.2", 26 27 "react-dom": "19.2.4", 28 + "react-markdown": "^10.1.0", 27 29 "recharts": "^3.7.0", 30 + "remark-gfm": "^4.0.1", 31 + "semver": "^7.7.4", 28 32 "shiki": "^3.22.0", 29 33 "sonner": "^2.0.7", 30 34 "tailwind-merge": "^3.5.0", ··· 36 40 "@types/node": "^24", 37 41 "@types/react": "^19", 38 42 "@types/react-dom": "^19", 43 + "@types/semver": "^7.7.1", 39 44 "babel-plugin-react-compiler": "1.0.0", 40 45 "eslint": "^9", 41 46 "eslint-config-next": "16.1.6", ··· 136 141 "url": "https://opencollective.com/babel" 137 142 } 138 143 }, 144 + "node_modules/@babel/core/node_modules/semver": { 145 + "version": "6.3.1", 146 + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", 147 + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", 148 + "dev": true, 149 + "license": "ISC", 150 + "bin": { 151 + "semver": "bin/semver.js" 152 + } 153 + }, 139 154 "node_modules/@babel/generator": { 140 155 "version": "7.29.1", 141 156 "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", ··· 183 198 "node": ">=6.9.0" 184 199 } 185 200 }, 201 + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { 202 + "version": "6.3.1", 203 + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", 204 + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", 205 + "dev": true, 206 + "license": "ISC", 207 + "bin": { 208 + "semver": "bin/semver.js" 209 + } 210 + }, 186 211 "node_modules/@babel/helper-create-class-features-plugin": { 187 212 "version": "7.28.6", 188 213 "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", ··· 203 228 }, 204 229 "peerDependencies": { 205 230 "@babel/core": "^7.0.0" 231 + } 232 + }, 233 + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { 234 + "version": "6.3.1", 235 + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", 236 + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", 237 + "dev": true, 238 + "license": "ISC", 239 + "bin": { 240 + "semver": "bin/semver.js" 206 241 } 207 242 }, 208 243 "node_modules/@babel/helper-globals": { ··· 4063 4098 "tailwindcss": "4.2.0" 4064 4099 } 4065 4100 }, 4101 + "node_modules/@tailwindcss/typography": { 4102 + "version": "0.5.19", 4103 + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", 4104 + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", 4105 + "license": "MIT", 4106 + "dependencies": { 4107 + "postcss-selector-parser": "6.0.10" 4108 + }, 4109 + "peerDependencies": { 4110 + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" 4111 + } 4112 + }, 4113 + "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": { 4114 + "version": "6.0.10", 4115 + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", 4116 + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", 4117 + "license": "MIT", 4118 + "dependencies": { 4119 + "cssesc": "^3.0.0", 4120 + "util-deprecate": "^1.0.2" 4121 + }, 4122 + "engines": { 4123 + "node": ">=4" 4124 + } 4125 + }, 4066 4126 "node_modules/@tanstack/react-table": { 4067 4127 "version": "8.21.3", 4068 4128 "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", ··· 4288 4348 "version": "19.2.14", 4289 4349 "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", 4290 4350 "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", 4291 - "devOptional": true, 4292 4351 "license": "MIT", 4293 4352 "dependencies": { 4294 4353 "csstype": "^3.2.2" ··· 4303 4362 "peerDependencies": { 4304 4363 "@types/react": "^19.2.0" 4305 4364 } 4365 + }, 4366 + "node_modules/@types/semver": { 4367 + "version": "7.7.1", 4368 + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", 4369 + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", 4370 + "dev": true, 4371 + "license": "MIT" 4306 4372 }, 4307 4373 "node_modules/@types/statuses": { 4308 4374 "version": "2.0.6", ··· 4559 4625 "url": "https://github.com/sponsors/isaacs" 4560 4626 } 4561 4627 }, 4562 - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { 4563 - "version": "7.7.4", 4564 - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", 4565 - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", 4566 - "dev": true, 4567 - "license": "ISC", 4568 - "bin": { 4569 - "semver": "bin/semver.js" 4570 - }, 4571 - "engines": { 4572 - "node": ">=10" 4573 - } 4574 - }, 4575 4628 "node_modules/@typescript-eslint/utils": { 4576 4629 "version": "8.56.0", 4577 4630 "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", ··· 5299 5352 "@babel/types": "^7.26.0" 5300 5353 } 5301 5354 }, 5355 + "node_modules/bail": { 5356 + "version": "2.0.2", 5357 + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", 5358 + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", 5359 + "license": "MIT", 5360 + "funding": { 5361 + "type": "github", 5362 + "url": "https://github.com/sponsors/wooorm" 5363 + } 5364 + }, 5302 5365 "node_modules/balanced-match": { 5303 5366 "version": "4.0.3", 5304 5367 "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", ··· 5905 5968 "version": "3.0.0", 5906 5969 "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", 5907 5970 "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", 5908 - "dev": true, 5909 5971 "license": "MIT", 5910 5972 "bin": { 5911 5973 "cssesc": "bin/cssesc" ··· 5918 5980 "version": "3.2.3", 5919 5981 "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", 5920 5982 "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", 5921 - "devOptional": true, 5922 5983 "license": "MIT" 5923 5984 }, 5924 5985 "node_modules/d3-array": { ··· 7002 7063 "url": "https://github.com/sponsors/ljharb" 7003 7064 } 7004 7065 }, 7066 + "node_modules/eslint-config-next/node_modules/semver": { 7067 + "version": "6.3.1", 7068 + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", 7069 + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", 7070 + "dev": true, 7071 + "license": "ISC", 7072 + "bin": { 7073 + "semver": "bin/semver.js" 7074 + } 7075 + }, 7005 7076 "node_modules/eslint-import-resolver-node": { 7006 7077 "version": "0.3.9", 7007 7078 "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", ··· 7356 7427 "express": ">= 4.11" 7357 7428 } 7358 7429 }, 7430 + "node_modules/extend": { 7431 + "version": "3.0.2", 7432 + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", 7433 + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", 7434 + "license": "MIT" 7435 + }, 7359 7436 "node_modules/fast-deep-equal": { 7360 7437 "version": "3.1.3", 7361 7438 "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", ··· 8095 8172 "node": ">=16.9.0" 8096 8173 } 8097 8174 }, 8175 + "node_modules/html-url-attributes": { 8176 + "version": "3.0.1", 8177 + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", 8178 + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", 8179 + "license": "MIT", 8180 + "funding": { 8181 + "type": "opencollective", 8182 + "url": "https://opencollective.com/unified" 8183 + } 8184 + }, 8098 8185 "node_modules/html-void-elements": { 8099 8186 "version": "3.0.0", 8100 8187 "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", ··· 8383 8470 "semver": "^7.7.1" 8384 8471 } 8385 8472 }, 8386 - "node_modules/is-bun-module/node_modules/semver": { 8387 - "version": "7.7.4", 8388 - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", 8389 - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", 8390 - "dev": true, 8391 - "license": "ISC", 8392 - "bin": { 8393 - "semver": "bin/semver.js" 8394 - }, 8395 - "engines": { 8396 - "node": ">=10" 8397 - } 8398 - }, 8399 8473 "node_modules/is-callable": { 8400 8474 "version": "1.2.7", 8401 8475 "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", ··· 8687 8761 "version": "4.1.0", 8688 8762 "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", 8689 8763 "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", 8690 - "dev": true, 8691 8764 "license": "MIT", 8692 8765 "engines": { 8693 8766 "node": ">=12" ··· 9505 9578 "@jridgewell/sourcemap-codec": "^1.5.5" 9506 9579 } 9507 9580 }, 9581 + "node_modules/markdown-table": { 9582 + "version": "3.0.4", 9583 + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", 9584 + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", 9585 + "license": "MIT", 9586 + "funding": { 9587 + "type": "github", 9588 + "url": "https://github.com/sponsors/wooorm" 9589 + } 9590 + }, 9508 9591 "node_modules/marked": { 9509 9592 "version": "14.0.0", 9510 9593 "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", ··· 9528 9611 "node": ">= 0.4" 9529 9612 } 9530 9613 }, 9614 + "node_modules/mdast-util-find-and-replace": { 9615 + "version": "3.0.2", 9616 + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", 9617 + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", 9618 + "license": "MIT", 9619 + "dependencies": { 9620 + "@types/mdast": "^4.0.0", 9621 + "escape-string-regexp": "^5.0.0", 9622 + "unist-util-is": "^6.0.0", 9623 + "unist-util-visit-parents": "^6.0.0" 9624 + }, 9625 + "funding": { 9626 + "type": "opencollective", 9627 + "url": "https://opencollective.com/unified" 9628 + } 9629 + }, 9630 + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { 9631 + "version": "5.0.0", 9632 + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", 9633 + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", 9634 + "license": "MIT", 9635 + "engines": { 9636 + "node": ">=12" 9637 + }, 9638 + "funding": { 9639 + "url": "https://github.com/sponsors/sindresorhus" 9640 + } 9641 + }, 9531 9642 "node_modules/mdast-util-from-markdown": { 9532 9643 "version": "2.0.2", 9533 9644 "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", ··· 9546 9657 "micromark-util-symbol": "^2.0.0", 9547 9658 "micromark-util-types": "^2.0.0", 9548 9659 "unist-util-stringify-position": "^4.0.0" 9660 + }, 9661 + "funding": { 9662 + "type": "opencollective", 9663 + "url": "https://opencollective.com/unified" 9664 + } 9665 + }, 9666 + "node_modules/mdast-util-gfm": { 9667 + "version": "3.1.0", 9668 + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", 9669 + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", 9670 + "license": "MIT", 9671 + "dependencies": { 9672 + "mdast-util-from-markdown": "^2.0.0", 9673 + "mdast-util-gfm-autolink-literal": "^2.0.0", 9674 + "mdast-util-gfm-footnote": "^2.0.0", 9675 + "mdast-util-gfm-strikethrough": "^2.0.0", 9676 + "mdast-util-gfm-table": "^2.0.0", 9677 + "mdast-util-gfm-task-list-item": "^2.0.0", 9678 + "mdast-util-to-markdown": "^2.0.0" 9679 + }, 9680 + "funding": { 9681 + "type": "opencollective", 9682 + "url": "https://opencollective.com/unified" 9683 + } 9684 + }, 9685 + "node_modules/mdast-util-gfm-autolink-literal": { 9686 + "version": "2.0.1", 9687 + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", 9688 + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", 9689 + "license": "MIT", 9690 + "dependencies": { 9691 + "@types/mdast": "^4.0.0", 9692 + "ccount": "^2.0.0", 9693 + "devlop": "^1.0.0", 9694 + "mdast-util-find-and-replace": "^3.0.0", 9695 + "micromark-util-character": "^2.0.0" 9696 + }, 9697 + "funding": { 9698 + "type": "opencollective", 9699 + "url": "https://opencollective.com/unified" 9700 + } 9701 + }, 9702 + "node_modules/mdast-util-gfm-footnote": { 9703 + "version": "2.1.0", 9704 + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", 9705 + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", 9706 + "license": "MIT", 9707 + "dependencies": { 9708 + "@types/mdast": "^4.0.0", 9709 + "devlop": "^1.1.0", 9710 + "mdast-util-from-markdown": "^2.0.0", 9711 + "mdast-util-to-markdown": "^2.0.0", 9712 + "micromark-util-normalize-identifier": "^2.0.0" 9713 + }, 9714 + "funding": { 9715 + "type": "opencollective", 9716 + "url": "https://opencollective.com/unified" 9717 + } 9718 + }, 9719 + "node_modules/mdast-util-gfm-strikethrough": { 9720 + "version": "2.0.0", 9721 + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", 9722 + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", 9723 + "license": "MIT", 9724 + "dependencies": { 9725 + "@types/mdast": "^4.0.0", 9726 + "mdast-util-from-markdown": "^2.0.0", 9727 + "mdast-util-to-markdown": "^2.0.0" 9728 + }, 9729 + "funding": { 9730 + "type": "opencollective", 9731 + "url": "https://opencollective.com/unified" 9732 + } 9733 + }, 9734 + "node_modules/mdast-util-gfm-table": { 9735 + "version": "2.0.0", 9736 + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", 9737 + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", 9738 + "license": "MIT", 9739 + "dependencies": { 9740 + "@types/mdast": "^4.0.0", 9741 + "devlop": "^1.0.0", 9742 + "markdown-table": "^3.0.0", 9743 + "mdast-util-from-markdown": "^2.0.0", 9744 + "mdast-util-to-markdown": "^2.0.0" 9745 + }, 9746 + "funding": { 9747 + "type": "opencollective", 9748 + "url": "https://opencollective.com/unified" 9749 + } 9750 + }, 9751 + "node_modules/mdast-util-gfm-task-list-item": { 9752 + "version": "2.0.0", 9753 + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", 9754 + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", 9755 + "license": "MIT", 9756 + "dependencies": { 9757 + "@types/mdast": "^4.0.0", 9758 + "devlop": "^1.0.0", 9759 + "mdast-util-from-markdown": "^2.0.0", 9760 + "mdast-util-to-markdown": "^2.0.0" 9549 9761 }, 9550 9762 "funding": { 9551 9763 "type": "opencollective", ··· 9790 10002 "micromark-util-types": "^2.0.0" 9791 10003 } 9792 10004 }, 10005 + "node_modules/micromark-extension-gfm": { 10006 + "version": "3.0.0", 10007 + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", 10008 + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", 10009 + "license": "MIT", 10010 + "dependencies": { 10011 + "micromark-extension-gfm-autolink-literal": "^2.0.0", 10012 + "micromark-extension-gfm-footnote": "^2.0.0", 10013 + "micromark-extension-gfm-strikethrough": "^2.0.0", 10014 + "micromark-extension-gfm-table": "^2.0.0", 10015 + "micromark-extension-gfm-tagfilter": "^2.0.0", 10016 + "micromark-extension-gfm-task-list-item": "^2.0.0", 10017 + "micromark-util-combine-extensions": "^2.0.0", 10018 + "micromark-util-types": "^2.0.0" 10019 + }, 10020 + "funding": { 10021 + "type": "opencollective", 10022 + "url": "https://opencollective.com/unified" 10023 + } 10024 + }, 10025 + "node_modules/micromark-extension-gfm-autolink-literal": { 10026 + "version": "2.1.0", 10027 + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", 10028 + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", 10029 + "license": "MIT", 10030 + "dependencies": { 10031 + "micromark-util-character": "^2.0.0", 10032 + "micromark-util-sanitize-uri": "^2.0.0", 10033 + "micromark-util-symbol": "^2.0.0", 10034 + "micromark-util-types": "^2.0.0" 10035 + }, 10036 + "funding": { 10037 + "type": "opencollective", 10038 + "url": "https://opencollective.com/unified" 10039 + } 10040 + }, 10041 + "node_modules/micromark-extension-gfm-footnote": { 10042 + "version": "2.1.0", 10043 + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", 10044 + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", 10045 + "license": "MIT", 10046 + "dependencies": { 10047 + "devlop": "^1.0.0", 10048 + "micromark-core-commonmark": "^2.0.0", 10049 + "micromark-factory-space": "^2.0.0", 10050 + "micromark-util-character": "^2.0.0", 10051 + "micromark-util-normalize-identifier": "^2.0.0", 10052 + "micromark-util-sanitize-uri": "^2.0.0", 10053 + "micromark-util-symbol": "^2.0.0", 10054 + "micromark-util-types": "^2.0.0" 10055 + }, 10056 + "funding": { 10057 + "type": "opencollective", 10058 + "url": "https://opencollective.com/unified" 10059 + } 10060 + }, 10061 + "node_modules/micromark-extension-gfm-strikethrough": { 10062 + "version": "2.1.0", 10063 + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", 10064 + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", 10065 + "license": "MIT", 10066 + "dependencies": { 10067 + "devlop": "^1.0.0", 10068 + "micromark-util-chunked": "^2.0.0", 10069 + "micromark-util-classify-character": "^2.0.0", 10070 + "micromark-util-resolve-all": "^2.0.0", 10071 + "micromark-util-symbol": "^2.0.0", 10072 + "micromark-util-types": "^2.0.0" 10073 + }, 10074 + "funding": { 10075 + "type": "opencollective", 10076 + "url": "https://opencollective.com/unified" 10077 + } 10078 + }, 10079 + "node_modules/micromark-extension-gfm-table": { 10080 + "version": "2.1.1", 10081 + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", 10082 + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", 10083 + "license": "MIT", 10084 + "dependencies": { 10085 + "devlop": "^1.0.0", 10086 + "micromark-factory-space": "^2.0.0", 10087 + "micromark-util-character": "^2.0.0", 10088 + "micromark-util-symbol": "^2.0.0", 10089 + "micromark-util-types": "^2.0.0" 10090 + }, 10091 + "funding": { 10092 + "type": "opencollective", 10093 + "url": "https://opencollective.com/unified" 10094 + } 10095 + }, 10096 + "node_modules/micromark-extension-gfm-tagfilter": { 10097 + "version": "2.0.0", 10098 + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", 10099 + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", 10100 + "license": "MIT", 10101 + "dependencies": { 10102 + "micromark-util-types": "^2.0.0" 10103 + }, 10104 + "funding": { 10105 + "type": "opencollective", 10106 + "url": "https://opencollective.com/unified" 10107 + } 10108 + }, 10109 + "node_modules/micromark-extension-gfm-task-list-item": { 10110 + "version": "2.1.0", 10111 + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", 10112 + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", 10113 + "license": "MIT", 10114 + "dependencies": { 10115 + "devlop": "^1.0.0", 10116 + "micromark-factory-space": "^2.0.0", 10117 + "micromark-util-character": "^2.0.0", 10118 + "micromark-util-symbol": "^2.0.0", 10119 + "micromark-util-types": "^2.0.0" 10120 + }, 10121 + "funding": { 10122 + "type": "opencollective", 10123 + "url": "https://opencollective.com/unified" 10124 + } 10125 + }, 9793 10126 "node_modules/micromark-factory-destination": { 9794 10127 "version": "2.0.1", 9795 10128 "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", ··· 10519 10852 }, 10520 10853 "funding": { 10521 10854 "url": "https://github.com/sponsors/ljharb" 10855 + } 10856 + }, 10857 + "node_modules/node-exports-info/node_modules/semver": { 10858 + "version": "6.3.1", 10859 + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", 10860 + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", 10861 + "dev": true, 10862 + "license": "ISC", 10863 + "bin": { 10864 + "semver": "bin/semver.js" 10522 10865 } 10523 10866 }, 10524 10867 "node_modules/node-fetch": { ··· 11451 11794 "license": "MIT", 11452 11795 "peer": true 11453 11796 }, 11797 + "node_modules/react-markdown": { 11798 + "version": "10.1.0", 11799 + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", 11800 + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", 11801 + "license": "MIT", 11802 + "dependencies": { 11803 + "@types/hast": "^3.0.0", 11804 + "@types/mdast": "^4.0.0", 11805 + "devlop": "^1.0.0", 11806 + "hast-util-to-jsx-runtime": "^2.0.0", 11807 + "html-url-attributes": "^3.0.0", 11808 + "mdast-util-to-hast": "^13.0.0", 11809 + "remark-parse": "^11.0.0", 11810 + "remark-rehype": "^11.0.0", 11811 + "unified": "^11.0.0", 11812 + "unist-util-visit": "^5.0.0", 11813 + "vfile": "^6.0.0" 11814 + }, 11815 + "funding": { 11816 + "type": "opencollective", 11817 + "url": "https://opencollective.com/unified" 11818 + }, 11819 + "peerDependencies": { 11820 + "@types/react": ">=18", 11821 + "react": ">=18" 11822 + } 11823 + }, 11454 11824 "node_modules/react-redux": { 11455 11825 "version": "9.2.0", 11456 11826 "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", ··· 11673 12043 "url": "https://github.com/sponsors/ljharb" 11674 12044 } 11675 12045 }, 12046 + "node_modules/remark-gfm": { 12047 + "version": "4.0.1", 12048 + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", 12049 + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", 12050 + "license": "MIT", 12051 + "dependencies": { 12052 + "@types/mdast": "^4.0.0", 12053 + "mdast-util-gfm": "^3.0.0", 12054 + "micromark-extension-gfm": "^3.0.0", 12055 + "remark-parse": "^11.0.0", 12056 + "remark-stringify": "^11.0.0", 12057 + "unified": "^11.0.0" 12058 + }, 12059 + "funding": { 12060 + "type": "opencollective", 12061 + "url": "https://opencollective.com/unified" 12062 + } 12063 + }, 12064 + "node_modules/remark-parse": { 12065 + "version": "11.0.0", 12066 + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", 12067 + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", 12068 + "license": "MIT", 12069 + "dependencies": { 12070 + "@types/mdast": "^4.0.0", 12071 + "mdast-util-from-markdown": "^2.0.0", 12072 + "micromark-util-types": "^2.0.0", 12073 + "unified": "^11.0.0" 12074 + }, 12075 + "funding": { 12076 + "type": "opencollective", 12077 + "url": "https://opencollective.com/unified" 12078 + } 12079 + }, 12080 + "node_modules/remark-rehype": { 12081 + "version": "11.1.2", 12082 + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", 12083 + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", 12084 + "license": "MIT", 12085 + "dependencies": { 12086 + "@types/hast": "^3.0.0", 12087 + "@types/mdast": "^4.0.0", 12088 + "mdast-util-to-hast": "^13.0.0", 12089 + "unified": "^11.0.0", 12090 + "vfile": "^6.0.0" 12091 + }, 12092 + "funding": { 12093 + "type": "opencollective", 12094 + "url": "https://opencollective.com/unified" 12095 + } 12096 + }, 12097 + "node_modules/remark-stringify": { 12098 + "version": "11.0.0", 12099 + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", 12100 + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", 12101 + "license": "MIT", 12102 + "dependencies": { 12103 + "@types/mdast": "^4.0.0", 12104 + "mdast-util-to-markdown": "^2.0.0", 12105 + "unified": "^11.0.0" 12106 + }, 12107 + "funding": { 12108 + "type": "opencollective", 12109 + "url": "https://opencollective.com/unified" 12110 + } 12111 + }, 11676 12112 "node_modules/require-directory": { 11677 12113 "version": "2.1.1", 11678 12114 "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", ··· 11909 12345 "license": "MIT" 11910 12346 }, 11911 12347 "node_modules/semver": { 11912 - "version": "6.3.1", 11913 - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", 11914 - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", 11915 - "dev": true, 12348 + "version": "7.7.4", 12349 + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", 12350 + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", 11916 12351 "license": "ISC", 11917 12352 "bin": { 11918 12353 "semver": "bin/semver.js" 12354 + }, 12355 + "engines": { 12356 + "node": ">=10" 11919 12357 } 11920 12358 }, 11921 12359 "node_modules/send": { ··· 12168 12606 "@img/sharp-win32-x64": "0.34.5" 12169 12607 } 12170 12608 }, 12171 - "node_modules/sharp/node_modules/semver": { 12172 - "version": "7.7.4", 12173 - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", 12174 - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", 12175 - "license": "ISC", 12176 - "optional": true, 12177 - "bin": { 12178 - "semver": "bin/semver.js" 12179 - }, 12180 - "engines": { 12181 - "node": ">=10" 12182 - } 12183 - }, 12184 12609 "node_modules/shebang-command": { 12185 12610 "version": "2.0.0", 12186 12611 "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", ··· 12734 13159 "version": "4.2.0", 12735 13160 "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.0.tgz", 12736 13161 "integrity": "sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q==", 12737 - "dev": true, 12738 13162 "license": "MIT" 12739 13163 }, 12740 13164 "node_modules/tapable": { ··· 12875 13299 "version": "3.0.1", 12876 13300 "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", 12877 13301 "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", 13302 + "license": "MIT", 13303 + "funding": { 13304 + "type": "github", 13305 + "url": "https://github.com/sponsors/wooorm" 13306 + } 13307 + }, 13308 + "node_modules/trough": { 13309 + "version": "2.2.0", 13310 + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", 13311 + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", 12878 13312 "license": "MIT", 12879 13313 "funding": { 12880 13314 "type": "github", ··· 13146 13580 "url": "https://github.com/sponsors/sindresorhus" 13147 13581 } 13148 13582 }, 13583 + "node_modules/unified": { 13584 + "version": "11.0.5", 13585 + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", 13586 + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", 13587 + "license": "MIT", 13588 + "dependencies": { 13589 + "@types/unist": "^3.0.0", 13590 + "bail": "^2.0.0", 13591 + "devlop": "^1.0.0", 13592 + "extend": "^3.0.0", 13593 + "is-plain-obj": "^4.0.0", 13594 + "trough": "^2.0.0", 13595 + "vfile": "^6.0.0" 13596 + }, 13597 + "funding": { 13598 + "type": "opencollective", 13599 + "url": "https://opencollective.com/unified" 13600 + } 13601 + }, 13149 13602 "node_modules/unist-util-is": { 13150 13603 "version": "6.0.1", 13151 13604 "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", ··· 13376 13829 "version": "1.0.2", 13377 13830 "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", 13378 13831 "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", 13379 - "dev": true, 13380 13832 "license": "MIT" 13381 13833 }, 13382 13834 "node_modules/validate-npm-package-name": {
+5
web/package.json
··· 12 12 "@base-ui/react": "^1.2.0", 13 13 "@monaco-editor/react": "^4.7.0", 14 14 "@tabler/icons-react": "^3.36.1", 15 + "@tailwindcss/typography": "^0.5.19", 15 16 "@tanstack/react-table": "^8.21.3", 16 17 "class-variance-authority": "^0.7.1", 17 18 "clsx": "^2.1.1", ··· 25 26 "react": "19.2.4", 26 27 "react-day-picker": "^9.13.2", 27 28 "react-dom": "19.2.4", 29 + "react-markdown": "^10.1.0", 28 30 "recharts": "^3.7.0", 31 + "remark-gfm": "^4.0.1", 32 + "semver": "^7.7.4", 29 33 "shiki": "^3.22.0", 30 34 "sonner": "^2.0.7", 31 35 "tailwind-merge": "^3.5.0", ··· 37 41 "@types/node": "^24", 38 42 "@types/react": "^19", 39 43 "@types/react-dom": "^19", 44 + "@types/semver": "^7.7.1", 40 45 "babel-plugin-react-compiler": "1.0.0", 41 46 "eslint": "^9", 42 47 "eslint-config-next": "16.1.6",
+16 -11
web/src/app/dashboard/layout.tsx
··· 6 6 import { useAuth } from "@/lib/auth-context" 7 7 import { useConfig } from "@/lib/config-context" 8 8 import { AppSidebar } from "@/components/app-sidebar" 9 + import { PluginUpdateProvider } from "@/components/plugin-update-provider" 9 10 import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar" 11 + import { Toaster } from "@/components/ui/sonner" 10 12 11 13 export default function DashboardLayout({ 12 14 children, ··· 30 32 if (!did) return null 31 33 32 34 return ( 33 - <SidebarProvider 34 - style={ 35 - { 36 - "--sidebar-width": "calc(var(--spacing) * 72)", 37 - "--header-height": "calc(var(--spacing) * 12)", 38 - } as React.CSSProperties 39 - } 40 - > 41 - <AppSidebar variant="inset" /> 42 - <SidebarInset>{children}</SidebarInset> 43 - </SidebarProvider> 35 + <PluginUpdateProvider> 36 + <SidebarProvider 37 + style={ 38 + { 39 + "--sidebar-width": "calc(var(--spacing) * 72)", 40 + "--header-height": "calc(var(--spacing) * 12)", 41 + } as React.CSSProperties 42 + } 43 + > 44 + <AppSidebar variant="inset" /> 45 + <SidebarInset>{children}</SidebarInset> 46 + </SidebarProvider> 47 + <Toaster /> 48 + </PluginUpdateProvider> 44 49 ) 45 50 }
+313 -116
web/src/app/dashboard/settings/plugins/page.tsx
··· 1 1 "use client"; 2 2 3 - import { useCallback, useEffect, useState } from "react"; 4 - import { Plus, Trash2, RefreshCw, ExternalLink, Settings, Loader2, AlertTriangle, CheckCircle2, AlertCircle } from "lucide-react"; 3 + import { useCallback, useEffect, useMemo, useState } from "react"; 4 + import { useSearchParams } from "next/navigation"; 5 + import { Plus, Trash2, RefreshCw, ExternalLink, Settings, Loader2, AlertTriangle, CheckCircle2, AlertCircle, ArrowUpCircle, Search } from "lucide-react"; 5 6 6 7 import { useCurrentUser } from "@/hooks/use-current-user"; 7 - import { getPlugins, addPlugin, removePlugin, reloadPlugin, getPluginSecrets, updatePluginSecrets, previewPlugin, type PluginPreview } from "@/lib/api"; 8 + import { useOfficialPlugins } from "@/hooks/use-official-plugins"; 9 + import { getPlugins, addPlugin, removePlugin, reloadPlugin, getPluginSecrets, updatePluginSecrets, previewPlugin, checkPluginUpdate, type PluginPreview } from "@/lib/api"; 8 10 import type { PluginSummary } from "@/types/plugins"; 11 + import { PluginUpdateDialog } from "@/components/plugin-update-dialog"; 9 12 import { SiteHeader } from "@/components/site-header"; 10 13 import { Button } from "@/components/ui/button"; 11 14 import { Input } from "@/components/ui/input"; 12 15 import { Label } from "@/components/ui/label"; 13 16 import { Badge } from "@/components/ui/badge"; 14 17 import { 18 + Command, 19 + CommandGroup, 20 + CommandItem, 21 + CommandList, 22 + } from "@/components/ui/command"; 23 + import { 24 + Popover, 25 + PopoverAnchor, 26 + PopoverContent, 27 + } from "@/components/ui/popover"; 28 + import { 15 29 Table, 16 30 TableBody, 17 31 TableCell, ··· 30 44 ResponsiveDialogTrigger, 31 45 } from "@/components/ui/responsive-dialog"; 32 46 47 + function isValidHttpUrl(value: string): boolean { 48 + try { 49 + const parsed = new URL(value); 50 + return parsed.protocol === "http:" || parsed.protocol === "https:"; 51 + } catch { 52 + return false; 53 + } 54 + } 55 + 33 56 function formatAuthType(authType: string): string { 34 57 const formats: Record<string, string> = { 35 58 oauth2: "OAuth 2.0", ··· 46 69 const [error, setError] = useState<string | null>(null); 47 70 const [reloading, setReloading] = useState<string | null>(null); 48 71 const [removing, setRemoving] = useState<string | null>(null); 72 + const [updateDialogPlugin, setUpdateDialogPlugin] = useState<PluginSummary | null>(null); 73 + const [updateDialogOpen, setUpdateDialogOpen] = useState(false); 74 + const [checkingUpdate, setCheckingUpdate] = useState<string | null>(null); 75 + 76 + const searchParams = useSearchParams(); 49 77 50 78 // Add plugin dialog state 51 79 const [addOpen, setAddOpen] = useState(false); 52 80 const [newUrl, setNewUrl] = useState(""); 53 81 const [adding, setAdding] = useState(false); 54 - const [previewing, setPreviewing] = useState(false); 55 82 const [pluginPreview, setPluginPreview] = useState<PluginPreview | null>(null); 83 + const [comboboxOpen, setComboboxOpen] = useState<boolean>(false); 84 + const [selectedManifestUrl, setSelectedManifestUrl] = useState<string | null>( 85 + null, 86 + ); 87 + 88 + const { plugins: officialPlugins, loading: officialLoading } = useOfficialPlugins(); 89 + 90 + const filteredOfficialPlugins = useMemo(() => { 91 + const q = newUrl.trim().toLowerCase(); 92 + if (!q) return officialPlugins; 93 + return officialPlugins.filter( 94 + (p) => 95 + p.id.toLowerCase().includes(q) || 96 + p.name.toLowerCase().includes(q), 97 + ); 98 + }, [officialPlugins, newUrl]); 99 + 100 + const showCombobox = officialLoading || officialPlugins.length > 0; 101 + const hasComboboxResults = 102 + officialLoading || filteredOfficialPlugins.length > 0; 56 103 57 104 // Configure secrets dialog state 58 105 const [configOpen, setConfigOpen] = useState(false); ··· 78 125 load(); 79 126 }, [load]); 80 127 81 - async function handlePreview() { 82 - if (!newUrl.trim()) return; 128 + function openUpdateDialog(plugin: PluginSummary) { 129 + setUpdateDialogPlugin(plugin); 130 + setUpdateDialogOpen(true); 131 + } 83 132 84 - setPreviewing(true); 133 + async function handleCheckUpdate(plugin: PluginSummary) { 134 + setCheckingUpdate(plugin.id); 85 135 setError(null); 86 136 try { 87 - const preview = await previewPlugin(newUrl.trim()); 88 - setPluginPreview(preview); 137 + await checkPluginUpdate(plugin.id); 138 + await load(); 89 139 } catch (e) { 90 140 setError(e instanceof Error ? e.message : String(e)); 91 141 } finally { 92 - setPreviewing(false); 142 + setCheckingUpdate(null); 93 143 } 94 144 } 95 145 146 + useEffect(() => { 147 + const updateId = searchParams.get("update"); 148 + if (!updateId || plugins.length === 0) return; 149 + const target = plugins.find((p) => p.id === updateId); 150 + if (target && target.update_available) { 151 + openUpdateDialog(target); 152 + } 153 + }, [searchParams, plugins]); 154 + 155 + const newUrlIsUrl = isValidHttpUrl(newUrl.trim()); 156 + const effectivePreviewUrl = selectedManifestUrl 157 + ? selectedManifestUrl 158 + : newUrlIsUrl 159 + ? newUrl.trim() 160 + : null; 161 + 162 + useEffect(() => { 163 + if (!addOpen) return; 164 + if (!effectivePreviewUrl) { 165 + setPluginPreview(null); 166 + return; 167 + } 168 + const controller = new AbortController(); 169 + const timer = setTimeout(async () => { 170 + try { 171 + const preview = await previewPlugin( 172 + effectivePreviewUrl, 173 + controller.signal, 174 + ); 175 + if (!controller.signal.aborted) setPluginPreview(preview); 176 + } catch { 177 + // fail silently 178 + } 179 + }, 500); 180 + return () => { 181 + clearTimeout(timer); 182 + controller.abort(); 183 + }; 184 + }, [addOpen, effectivePreviewUrl]); 185 + 96 186 async function handleAdd() { 97 187 if (!pluginPreview) return; 98 188 ··· 103 193 setAddOpen(false); 104 194 setNewUrl(""); 105 195 setPluginPreview(null); 196 + setSelectedManifestUrl(null); 106 197 load(); 107 198 } catch (e) { 108 199 setError(e instanceof Error ? e.message : String(e)); ··· 115 206 setAddOpen(false); 116 207 setNewUrl(""); 117 208 setPluginPreview(null); 209 + setSelectedManifestUrl(null); 210 + setComboboxOpen(false); 118 211 setError(null); 119 212 } 120 213 ··· 220 313 </ResponsiveDialogTrigger> 221 314 <ResponsiveDialogContent> 222 315 <ResponsiveDialogHeader> 223 - <ResponsiveDialogTitle> 224 - {pluginPreview ? `Install ${pluginPreview.name}?` : "Add Plugin"} 225 - </ResponsiveDialogTitle> 316 + <ResponsiveDialogTitle>Add Plugin</ResponsiveDialogTitle> 226 317 <ResponsiveDialogDescription> 227 - {pluginPreview 228 - ? "Review the plugin details below before installing." 229 - : "Enter a plugin URL to preview its details."} 318 + Select an official plugin or enter a plugin URL. 230 319 </ResponsiveDialogDescription> 231 320 </ResponsiveDialogHeader> 232 321 233 - {!pluginPreview ? ( 234 - // Step 1: Enter URL 235 - <div className="grid gap-4 py-4"> 236 - <div className="grid gap-2"> 237 - <Label htmlFor="url">Plugin URL</Label> 238 - <Input 239 - id="url" 240 - placeholder="https://github.com/org/repo/releases/download/v1.0.0/steam.wasm" 241 - value={newUrl} 242 - onChange={(e) => setNewUrl(e.target.value)} 243 - disabled={previewing} 244 - /> 245 - <p className="text-muted-foreground text-xs"> 246 - Link to the .wasm file or manifest.json (GitHub Releases URL) 247 - </p> 248 - </div> 249 - </div> 250 - ) : ( 251 - // Step 2: Show preview 252 - <div className="grid gap-4 py-4"> 253 - <div className="flex items-start gap-4"> 254 - {pluginPreview.icon_url && ( 255 - <img 256 - src={pluginPreview.icon_url} 257 - alt="" 258 - className="size-12 rounded" 322 + <div className="grid gap-4 py-4"> 323 + <div className="grid gap-2"> 324 + <Label htmlFor="url">Plugin</Label> 325 + {showCombobox ? ( 326 + <Popover 327 + open={ 328 + comboboxOpen && hasComboboxResults && !newUrlIsUrl 329 + } 330 + onOpenChange={setComboboxOpen} 331 + > 332 + <PopoverAnchor asChild> 333 + <Input 334 + id="url" 335 + placeholder="https://github.com/org/repo/releases/download/v1.0.0/steam.wasm" 336 + value={newUrl} 337 + onChange={(e) => { 338 + setNewUrl(e.target.value); 339 + setSelectedManifestUrl(null); 340 + setComboboxOpen(true); 341 + }} 342 + onFocus={() => setComboboxOpen(true)} 343 + autoComplete="off" 344 + /> 345 + </PopoverAnchor> 346 + <PopoverContent 347 + className="p-0 w-(--radix-popover-trigger-width)" 348 + align="start" 349 + onOpenAutoFocus={(e) => e.preventDefault()} 350 + onInteractOutside={(e) => { 351 + // Don't close when clicking the input itself 352 + const target = e.target as Node; 353 + if ( 354 + target instanceof Element && 355 + target.id === "url" 356 + ) { 357 + e.preventDefault(); 358 + } 359 + }} 360 + > 361 + <Command shouldFilter={false}> 362 + <CommandList> 363 + {officialLoading ? ( 364 + <CommandGroup> 365 + <CommandItem 366 + disabled 367 + value="__loading__" 368 + > 369 + <Loader2 className="mr-2 size-4 animate-spin" /> 370 + Loading plugins… 371 + </CommandItem> 372 + </CommandGroup> 373 + ) : ( 374 + <CommandGroup> 375 + {filteredOfficialPlugins.map((p) => ( 376 + <CommandItem 377 + key={p.id} 378 + value={`${p.id} ${p.name}`} 379 + onSelect={() => { 380 + setNewUrl(p.name); 381 + setSelectedManifestUrl( 382 + p.manifest_url, 383 + ); 384 + setComboboxOpen(false); 385 + }} 386 + > 387 + {p.icon_url ? ( 388 + // eslint-disable-next-line @next/next/no-img-element 389 + <img 390 + src={p.icon_url} 391 + alt="" 392 + className="size-6 rounded shrink-0" 393 + /> 394 + ) : ( 395 + <div className="size-6 rounded bg-muted shrink-0" /> 396 + )} 397 + <div className="flex flex-col min-w-0 flex-1"> 398 + <div className="flex items-center gap-2"> 399 + <span className="font-medium truncate"> 400 + {p.name} 401 + </span> 402 + <Badge 403 + variant="secondary" 404 + className="shrink-0" 405 + > 406 + v{p.latest_version} 407 + </Badge> 408 + </div> 409 + {p.description && ( 410 + <span className="text-muted-foreground text-xs truncate"> 411 + {p.description} 412 + </span> 413 + )} 414 + </div> 415 + </CommandItem> 416 + ))} 417 + </CommandGroup> 418 + )} 419 + </CommandList> 420 + </Command> 421 + </PopoverContent> 422 + </Popover> 423 + ) : ( 424 + <Input 425 + id="url" 426 + placeholder="https://github.com/org/repo/releases/download/v1.0.0/steam.wasm" 427 + value={newUrl} 428 + onChange={(e) => { 429 + setNewUrl(e.target.value); 430 + setSelectedManifestUrl(null); 431 + }} 259 432 /> 260 433 )} 261 - <div className="flex-1"> 262 - <h3 className="font-semibold">{pluginPreview.name}</h3> 263 - <p className="text-muted-foreground text-sm"> 264 - {pluginPreview.description || `Version ${pluginPreview.version}`} 265 - </p> 434 + <p className="text-muted-foreground text-xs"> 435 + Link to the .wasm file or manifest.json (GitHub Releases URL) 436 + </p> 437 + </div> 438 + 439 + {pluginPreview && ( 440 + <div className="grid gap-4 rounded-lg border p-4"> 441 + <div className="flex items-start gap-4"> 442 + {pluginPreview.icon_url && ( 443 + <img 444 + src={pluginPreview.icon_url} 445 + alt="" 446 + className="size-12 rounded" 447 + /> 448 + )} 449 + <div className="flex-1"> 450 + <h3 className="font-semibold">{pluginPreview.name}</h3> 451 + <p className="text-muted-foreground text-sm"> 452 + {pluginPreview.description || `Version ${pluginPreview.version}`} 453 + </p> 454 + </div> 455 + <Badge variant="secondary">{pluginPreview.version}</Badge> 266 456 </div> 267 - <Badge variant="secondary">{pluginPreview.version}</Badge> 268 - </div> 269 457 270 - <div className="grid gap-2 text-sm"> 271 - <div className="flex justify-between"> 272 - <span className="text-muted-foreground">Auth Type</span> 273 - <Badge variant="outline"> 274 - {formatAuthType(pluginPreview.auth_type)} 275 - </Badge> 276 - </div> 277 - {pluginPreview.required_secrets.length > 0 && ( 278 - <div className="grid gap-2"> 279 - <span className="text-muted-foreground">Required Configuration</span> 280 - <div className="flex flex-col gap-2"> 281 - {pluginPreview.required_secrets.map((secret) => ( 282 - <div key={secret.key} className="bg-muted rounded p-2"> 283 - <div className="flex items-center justify-between"> 284 - <span className="font-medium text-sm">{secret.name}</span> 285 - <code className="text-xs text-muted-foreground">{secret.key}</code> 458 + <div className="grid gap-2 text-sm"> 459 + <div className="flex justify-between"> 460 + <span className="text-muted-foreground">Auth Type</span> 461 + <Badge variant="outline"> 462 + {formatAuthType(pluginPreview.auth_type)} 463 + </Badge> 464 + </div> 465 + {pluginPreview.required_secrets.length > 0 && ( 466 + <div className="grid gap-2"> 467 + <span className="text-muted-foreground">Required Configuration</span> 468 + <div className="flex flex-col gap-2"> 469 + {pluginPreview.required_secrets.map((secret) => ( 470 + <div key={secret.key} className="bg-muted rounded p-2"> 471 + <div className="flex items-center justify-between"> 472 + <span className="font-medium text-sm">{secret.name}</span> 473 + <code className="text-xs text-muted-foreground">{secret.key}</code> 474 + </div> 475 + {secret.description && ( 476 + <p className="text-muted-foreground text-xs mt-1">{secret.description}</p> 477 + )} 286 478 </div> 287 - {secret.description && ( 288 - <p className="text-muted-foreground text-xs mt-1">{secret.description}</p> 289 - )} 290 - </div> 291 - ))} 479 + ))} 480 + </div> 292 481 </div> 293 - </div> 294 - )} 482 + )} 483 + </div> 295 484 </div> 296 - </div> 297 - )} 485 + )} 486 + </div> 298 487 299 488 <ResponsiveDialogFooter> 300 - {pluginPreview ? ( 301 - <> 302 - <Button 303 - variant="outline" 304 - onClick={() => setPluginPreview(null)} 305 - disabled={adding} 306 - > 307 - Back 308 - </Button> 309 - <Button onClick={handleAdd} disabled={adding}> 310 - {adding ? ( 311 - <> 312 - <Loader2 className="mr-2 size-4 animate-spin" /> 313 - Installing... 314 - </> 315 - ) : ( 316 - "Install Plugin" 317 - )} 318 - </Button> 319 - </> 320 - ) : ( 321 - <> 322 - <ResponsiveDialogClose asChild> 323 - <Button variant="outline" disabled={previewing}> 324 - Cancel 325 - </Button> 326 - </ResponsiveDialogClose> 327 - <Button 328 - onClick={handlePreview} 329 - disabled={previewing || !newUrl.trim()} 330 - > 331 - {previewing ? ( 332 - <> 333 - <Loader2 className="mr-2 size-4 animate-spin" /> 334 - Loading... 335 - </> 336 - ) : ( 337 - "Preview Plugin" 338 - )} 339 - </Button> 340 - </> 341 - )} 489 + <ResponsiveDialogClose asChild> 490 + <Button variant="outline" disabled={adding}> 491 + Cancel 492 + </Button> 493 + </ResponsiveDialogClose> 494 + <Button onClick={handleAdd} disabled={adding || !pluginPreview}> 495 + {adding ? ( 496 + <> 497 + <Loader2 className="mr-2 size-4 animate-spin" /> 498 + Installing... 499 + </> 500 + ) : ( 501 + "Install Plugin" 502 + )} 503 + </Button> 342 504 </ResponsiveDialogFooter> 343 505 </ResponsiveDialogContent> 344 506 </ResponsiveDialog> ··· 418 580 </TableCell> 419 581 <TableCell> 420 582 <div className="flex gap-1"> 583 + {canCreate && plugin.update_available && ( 584 + <Button 585 + variant="ghost" 586 + size="icon" 587 + className="size-8 text-primary" 588 + title={`Update to v${plugin.latest_version}`} 589 + onClick={() => openUpdateDialog(plugin)} 590 + > 591 + <ArrowUpCircle className="size-4" /> 592 + </Button> 593 + )} 594 + {canCreate && ( 595 + <Button 596 + variant="ghost" 597 + size="icon" 598 + className="size-8" 599 + title="Check for updates" 600 + onClick={() => handleCheckUpdate(plugin)} 601 + disabled={checkingUpdate === plugin.id} 602 + > 603 + <Search 604 + className={`size-4 ${checkingUpdate === plugin.id ? "animate-spin" : ""}`} 605 + /> 606 + </Button> 607 + )} 421 608 {canCreate && plugin.required_secrets?.length > 0 && ( 422 609 <Button 423 610 variant="ghost" ··· 510 697 </ResponsiveDialogFooter> 511 698 </ResponsiveDialogContent> 512 699 </ResponsiveDialog> 700 + 701 + <PluginUpdateDialog 702 + plugin={updateDialogPlugin} 703 + open={updateDialogOpen} 704 + onOpenChange={(open) => { 705 + setUpdateDialogOpen(open); 706 + if (!open) setUpdateDialogPlugin(null); 707 + }} 708 + onUpdated={() => load()} 709 + /> 513 710 </div> 514 711 </> 515 712 );
+2
web/src/app/globals.css
··· 2 2 @import "tw-animate-css"; 3 3 @import "shadcn/tailwind.css"; 4 4 5 + @plugin "@tailwindcss/typography"; 6 + 5 7 @custom-variant dark (&:is(.dark *)); 6 8 7 9 @theme inline {
+27 -14
web/src/components/app-sidebar.tsx
··· 16 16 IconSettings, 17 17 IconInfoCircle, 18 18 IconApps, 19 + IconArrowUpCircle, 19 20 } from "@tabler/icons-react"; 20 21 import Image from "next/image"; 21 22 import Link from "next/link"; ··· 24 25 import { useAuth } from "@/lib/auth-context"; 25 26 import { useConfig } from "@/lib/config-context"; 26 27 import { useCurrentUser } from "@/hooks/use-current-user"; 28 + import { usePluginUpdates } from "@/components/plugin-update-provider"; 27 29 import { Scroller } from "@/components/ui/scroller"; 28 30 import { 29 31 Sidebar, ··· 119 121 const { logout } = useAuth(); 120 122 const { app_name, logo_url } = useConfig(); 121 123 const { hasPermission } = useCurrentUser(); 124 + const { hasUpdates } = usePluginUpdates(); 122 125 123 126 function filterByPermission(items: NavItem[]) { 124 127 return items.filter( ··· 245 248 <SidebarGroupLabel>Integrations</SidebarGroupLabel> 246 249 <SidebarGroupContent> 247 250 <SidebarMenu> 248 - {visibleIntegrations.map((item) => ( 249 - <SidebarMenuItem key={item.title}> 250 - <SidebarMenuButton 251 - asChild 252 - tooltip={item.title} 253 - isActive={isActive(item.url)} 254 - > 255 - <Link href={item.url}> 256 - <item.icon /> 257 - <span>{item.title}</span> 258 - </Link> 259 - </SidebarMenuButton> 260 - </SidebarMenuItem> 261 - ))} 251 + {visibleIntegrations.map((item) => { 252 + const showUpdateBadge = 253 + item.title === "Plugins" && hasUpdates; 254 + return ( 255 + <SidebarMenuItem key={item.title}> 256 + <SidebarMenuButton 257 + asChild 258 + tooltip={item.title} 259 + isActive={isActive(item.url)} 260 + > 261 + <Link href={item.url}> 262 + <item.icon /> 263 + <span>{item.title}</span> 264 + {showUpdateBadge && ( 265 + <IconArrowUpCircle 266 + className="ml-auto size-4 text-primary" 267 + aria-label="Updates available" 268 + /> 269 + )} 270 + </Link> 271 + </SidebarMenuButton> 272 + </SidebarMenuItem> 273 + ); 274 + })} 262 275 </SidebarMenu> 263 276 </SidebarGroupContent> 264 277 </SidebarGroup>
+219
web/src/components/plugin-update-dialog.tsx
··· 1 + "use client" 2 + 3 + import { useEffect, useState } from "react" 4 + import { AlertTriangle, Loader2 } from "lucide-react" 5 + import ReactMarkdown from "react-markdown" 6 + import remarkGfm from "remark-gfm" 7 + import { toast } from "sonner" 8 + 9 + import { 10 + ResponsiveDialog, 11 + ResponsiveDialogContent, 12 + ResponsiveDialogDescription, 13 + ResponsiveDialogFooter, 14 + ResponsiveDialogHeader, 15 + ResponsiveDialogTitle, 16 + } from "@/components/ui/responsive-dialog" 17 + import { Badge } from "@/components/ui/badge" 18 + import { Button } from "@/components/ui/button" 19 + import { 20 + previewPlugin, 21 + reloadPlugin, 22 + type PluginPreview, 23 + type PluginSummary, 24 + type SecretDefinition, 25 + } from "@/lib/api" 26 + import { useOfficialPlugins } from "@/hooks/use-official-plugins" 27 + 28 + interface PluginUpdateDialogProps { 29 + plugin: PluginSummary | null 30 + open: boolean 31 + onOpenChange: (open: boolean) => void 32 + onUpdated: (updated: PluginSummary) => void 33 + } 34 + 35 + export function PluginUpdateDialog({ 36 + plugin, 37 + open, 38 + onOpenChange, 39 + onUpdated, 40 + }: PluginUpdateDialogProps) { 41 + const { byId } = useOfficialPlugins() 42 + const manifestUrl = plugin ? byId.get(plugin.id)?.manifest_url ?? null : null 43 + 44 + const [newManifestPreview, setNewManifestPreview] = 45 + useState<PluginPreview | null>(null) 46 + const [previewLoading, setPreviewLoading] = useState(false) 47 + const [updating, setUpdating] = useState(false) 48 + const [error, setError] = useState<string | null>(null) 49 + 50 + useEffect(() => { 51 + if (!open || !plugin) { 52 + setNewManifestPreview(null) 53 + setError(null) 54 + return 55 + } 56 + if (!manifestUrl) { 57 + setNewManifestPreview(null) 58 + return 59 + } 60 + 61 + let cancelled = false 62 + setPreviewLoading(true) 63 + previewPlugin(manifestUrl) 64 + .then((preview) => { 65 + if (!cancelled) setNewManifestPreview(preview) 66 + }) 67 + .catch(() => { 68 + if (!cancelled) setNewManifestPreview(null) 69 + }) 70 + .finally(() => { 71 + if (!cancelled) setPreviewLoading(false) 72 + }) 73 + 74 + return () => { 75 + cancelled = true 76 + } 77 + }, [open, plugin, manifestUrl]) 78 + 79 + if (!plugin) return null 80 + 81 + const installedKeys = new Set( 82 + plugin.required_secrets.map((secret) => secret.key), 83 + ) 84 + const newRequiredSecrets: SecretDefinition[] = 85 + newManifestPreview?.required_secrets.filter( 86 + (secret) => !installedKeys.has(secret.key), 87 + ) ?? [] 88 + 89 + const pendingReleases = [...(plugin.pending_releases ?? [])] 90 + const hasReleaseNotes = pendingReleases.length > 0 91 + const hasLatestVersion = Boolean(plugin.latest_version) 92 + 93 + const handleUpdate = async () => { 94 + if (!manifestUrl) { 95 + setError("No manifest URL available for this plugin.") 96 + return 97 + } 98 + setUpdating(true) 99 + setError(null) 100 + try { 101 + const updated = await reloadPlugin(plugin.id, { url: manifestUrl }) 102 + if (plugin.latest_version) { 103 + try { 104 + window.localStorage.setItem( 105 + `happyview:plugin-update-seen:${plugin.id}`, 106 + plugin.latest_version, 107 + ) 108 + } catch { 109 + // ignore storage errors 110 + } 111 + } 112 + onUpdated(updated) 113 + onOpenChange(false) 114 + toast.success( 115 + `Updated ${plugin.name} to v${plugin.latest_version ?? updated.version}`, 116 + ) 117 + } catch (err) { 118 + const message = err instanceof Error ? err.message : "Failed to update plugin" 119 + setError(message) 120 + } finally { 121 + setUpdating(false) 122 + } 123 + } 124 + 125 + const updateDisabled = 126 + updating || previewLoading || !manifestUrl 127 + 128 + return ( 129 + <ResponsiveDialog open={open} onOpenChange={onOpenChange}> 130 + <ResponsiveDialogContent> 131 + <ResponsiveDialogHeader> 132 + <ResponsiveDialogTitle>Update {plugin.name}</ResponsiveDialogTitle> 133 + <ResponsiveDialogDescription asChild> 134 + <div className="flex items-center gap-2"> 135 + <Badge variant="outline">v{plugin.version}</Badge> 136 + <span aria-hidden>→</span> 137 + <Badge> 138 + v{plugin.latest_version ?? "unknown"} 139 + </Badge> 140 + </div> 141 + </ResponsiveDialogDescription> 142 + </ResponsiveDialogHeader> 143 + 144 + <div className="flex flex-col gap-4 px-4 md:px-0"> 145 + {newRequiredSecrets.length > 0 && ( 146 + <div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4"> 147 + <div className="flex items-start gap-3"> 148 + <AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-amber-500" /> 149 + <div className="flex flex-col gap-2"> 150 + <p className="text-sm font-medium"> 151 + This update requires new configuration. After updating, 152 + you&apos;ll need to set these secrets. 153 + </p> 154 + <ul className="flex flex-col gap-1 text-sm"> 155 + {newRequiredSecrets.map((secret) => ( 156 + <li key={secret.key}> 157 + <span className="font-medium">{secret.name}</span> 158 + <span className="text-muted-foreground"> 159 + {" "} 160 + ({secret.key}) 161 + </span> 162 + </li> 163 + ))} 164 + </ul> 165 + </div> 166 + </div> 167 + </div> 168 + )} 169 + 170 + {hasReleaseNotes ? ( 171 + <div className="max-h-96 overflow-y-auto rounded-md border p-4"> 172 + <div className="flex flex-col gap-6"> 173 + {pendingReleases.map((release) => ( 174 + <section key={release.version} className="flex flex-col gap-2"> 175 + <h3 className="text-sm font-semibold"> 176 + v{release.version} ·{" "} 177 + {new Date(release.published_at).toLocaleDateString()} 178 + </h3> 179 + <div className="prose prose-sm dark:prose-invert max-w-none"> 180 + <ReactMarkdown remarkPlugins={[remarkGfm]}> 181 + {release.body} 182 + </ReactMarkdown> 183 + </div> 184 + </section> 185 + ))} 186 + </div> 187 + </div> 188 + ) : ( 189 + <p className="text-sm text-muted-foreground"> 190 + No release notes available. 191 + </p> 192 + )} 193 + 194 + {error && ( 195 + <div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"> 196 + {error} 197 + </div> 198 + )} 199 + </div> 200 + 201 + <ResponsiveDialogFooter> 202 + <Button 203 + variant="outline" 204 + onClick={() => onOpenChange(false)} 205 + disabled={updating} 206 + > 207 + Cancel 208 + </Button> 209 + <Button onClick={handleUpdate} disabled={updateDisabled}> 210 + {updating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} 211 + {hasLatestVersion 212 + ? `Update to v${plugin.latest_version}` 213 + : "Update"} 214 + </Button> 215 + </ResponsiveDialogFooter> 216 + </ResponsiveDialogContent> 217 + </ResponsiveDialog> 218 + ) 219 + }
+118
web/src/components/plugin-update-provider.tsx
··· 1 + "use client" 2 + 3 + import { 4 + createContext, 5 + useCallback, 6 + useContext, 7 + useEffect, 8 + useMemo, 9 + useState, 10 + type ReactNode, 11 + } from "react" 12 + import { useRouter } from "next/navigation" 13 + import semver from "semver" 14 + import { toast } from "sonner" 15 + import { getPlugins, type PluginSummary } from "@/lib/api" 16 + 17 + interface PluginUpdatesContextValue { 18 + plugins: PluginSummary[] 19 + hasUpdates: boolean 20 + refresh: () => Promise<void> 21 + markSeen: (id: string, version: string) => void 22 + } 23 + 24 + const PluginUpdatesContext = createContext<PluginUpdatesContextValue | null>(null) 25 + 26 + const STORAGE_PREFIX = "happyview:plugin-update-seen:" 27 + 28 + function storageKey(id: string) { 29 + return `${STORAGE_PREFIX}${id}` 30 + } 31 + 32 + function readSeen(id: string): string | null { 33 + if (typeof window === "undefined") return null 34 + try { 35 + return window.localStorage.getItem(storageKey(id)) 36 + } catch { 37 + return null 38 + } 39 + } 40 + 41 + function writeSeen(id: string, version: string) { 42 + if (typeof window === "undefined") return 43 + try { 44 + window.localStorage.setItem(storageKey(id), version) 45 + } catch { 46 + // ignore 47 + } 48 + } 49 + 50 + function isNewerThanSeen(latest: string, seen: string | null): boolean { 51 + if (!seen) return true 52 + const a = semver.coerce(latest) 53 + const b = semver.coerce(seen) 54 + if (!a || !b) return latest !== seen 55 + return semver.gt(a, b) 56 + } 57 + 58 + export function PluginUpdateProvider({ children }: { children: ReactNode }) { 59 + const router = useRouter() 60 + const [plugins, setPlugins] = useState<PluginSummary[]>([]) 61 + 62 + const refresh = useCallback(async () => { 63 + try { 64 + const res = await getPlugins() 65 + setPlugins(res.plugins) 66 + } catch { 67 + // ignore — dashboard may not be ready 68 + } 69 + }, []) 70 + 71 + const markSeen = useCallback((id: string, version: string) => { 72 + writeSeen(id, version) 73 + }, []) 74 + 75 + useEffect(() => { 76 + refresh() 77 + const id = setInterval(refresh, 60_000) 78 + return () => clearInterval(id) 79 + }, [refresh]) 80 + 81 + useEffect(() => { 82 + for (const p of plugins) { 83 + if (!p.update_available || !p.latest_version) continue 84 + const seen = readSeen(p.id) 85 + if (!isNewerThanSeen(p.latest_version, seen)) continue 86 + toast(`${p.name} v${p.latest_version} is available`, { 87 + action: { 88 + label: "Review", 89 + onClick: () => 90 + router.push(`/dashboard/settings/plugins?update=${encodeURIComponent(p.id)}`), 91 + }, 92 + }) 93 + writeSeen(p.id, p.latest_version) 94 + } 95 + }, [plugins, router]) 96 + 97 + const value = useMemo<PluginUpdatesContextValue>( 98 + () => ({ 99 + plugins, 100 + hasUpdates: plugins.some((p) => p.update_available), 101 + refresh, 102 + markSeen, 103 + }), 104 + [plugins, refresh, markSeen], 105 + ) 106 + 107 + return ( 108 + <PluginUpdatesContext.Provider value={value}>{children}</PluginUpdatesContext.Provider> 109 + ) 110 + } 111 + 112 + export function usePluginUpdates() { 113 + const ctx = useContext(PluginUpdatesContext) 114 + if (!ctx) { 115 + throw new Error("usePluginUpdates must be used within PluginUpdateProvider") 116 + } 117 + return ctx 118 + }
+34
web/src/hooks/use-official-plugins.ts
··· 1 + "use client" 2 + 3 + import { useCallback, useEffect, useMemo, useState } from "react" 4 + import { getOfficialPlugins, type OfficialPluginSummary } from "@/lib/api" 5 + 6 + export function useOfficialPlugins() { 7 + const [plugins, setPlugins] = useState<OfficialPluginSummary[]>([]) 8 + const [loading, setLoading] = useState(true) 9 + 10 + const refresh = useCallback(async () => { 11 + try { 12 + const res = await getOfficialPlugins() 13 + setPlugins(res.plugins) 14 + } catch { 15 + // swallow — registry may be empty on startup 16 + } finally { 17 + setLoading(false) 18 + } 19 + }, []) 20 + 21 + useEffect(() => { 22 + refresh() 23 + const id = setInterval(refresh, 60_000) 24 + return () => clearInterval(id) 25 + }, [refresh]) 26 + 27 + const byId = useMemo(() => { 28 + const map = new Map<string, OfficialPluginSummary>() 29 + for (const p of plugins) map.set(p.id, p) 30 + return map 31 + }, [plugins]) 32 + 33 + return { plugins, byId, loading, refresh } 34 + }
+29 -4
web/src/lib/api.ts
··· 467 467 } 468 468 469 469 // Plugins 470 - import type { PluginSummary, PluginsListResponse } from "@/types/plugins" 471 - export type { PluginSummary, PluginsListResponse } from "@/types/plugins" 470 + import type { 471 + PluginSummary, 472 + PluginsListResponse, 473 + OfficialPluginsListResponse, 474 + } from "@/types/plugins" 475 + export type { 476 + PluginSummary, 477 + PluginsListResponse, 478 + OfficialPluginSummary, 479 + OfficialPluginsListResponse, 480 + ReleaseEntry, 481 + } from "@/types/plugins" 472 482 473 483 export function getPlugins() { 474 484 return apiFetch<PluginsListResponse>("/admin/plugins") ··· 487 497 }) 488 498 } 489 499 490 - export function reloadPlugin(id: string) { 500 + export function reloadPlugin(id: string, body?: { url?: string }) { 491 501 return apiFetch<PluginSummary>( 492 502 `/admin/plugins/${encodeURIComponent(id)}/reload`, 503 + { 504 + method: "POST", 505 + body: body ? JSON.stringify(body) : undefined, 506 + }, 507 + ) 508 + } 509 + 510 + export function getOfficialPlugins() { 511 + return apiFetch<OfficialPluginsListResponse>("/admin/plugins/official") 512 + } 513 + 514 + export function checkPluginUpdate(id: string) { 515 + return apiFetch<PluginSummary>( 516 + `/admin/plugins/${encodeURIComponent(id)}/check-update`, 493 517 { method: "POST" }, 494 518 ) 495 519 } ··· 530 554 wasm_url: string 531 555 } 532 556 533 - export function previewPlugin(url: string) { 557 + export function previewPlugin(url: string, signal?: AbortSignal) { 534 558 return apiFetch<PluginPreview>("/admin/plugins/preview", { 535 559 method: "POST", 536 560 body: JSON.stringify({ url }), 561 + signal, 537 562 }) 538 563 }
+24
web/src/types/plugins.ts
··· 4 4 description: string | null; 5 5 } 6 6 7 + export interface ReleaseEntry { 8 + version: string; 9 + name: string; 10 + published_at: string; 11 + body: string; 12 + } 13 + 7 14 export interface PluginSummary { 8 15 id: string; 9 16 name: string; ··· 16 23 required_secrets: SecretDefinition[]; 17 24 secrets_configured: boolean; 18 25 loaded_at: string | null; 26 + update_available: boolean; 27 + latest_version: string | null; 28 + pending_releases: ReleaseEntry[]; 19 29 } 20 30 21 31 export interface PluginsListResponse { 22 32 plugins: PluginSummary[]; 23 33 encryption_configured: boolean; 24 34 } 35 + 36 + export interface OfficialPluginSummary { 37 + id: string; 38 + name: string; 39 + description: string | null; 40 + icon_url: string | null; 41 + latest_version: string; 42 + manifest_url: string; 43 + } 44 + 45 + export interface OfficialPluginsListResponse { 46 + plugins: OfficialPluginSummary[]; 47 + last_refreshed_at: string | null; 48 + }