diff --git a/Cargo.lock b/Cargo.lock --- a/Cargo.lock +++ b/Cargo.lock @@ -1655,6 +1655,7 @@ "regex", "reqwest", "rustls", + "semver", "serde", "serde_json", "serial_test", diff --git a/Cargo.toml b/Cargo.toml --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,7 @@ wasmtime = { version = "29", features = ["async"] } wasmtime-wasi = "29" regex = "1.12.3" +semver = "1.0" [[bin]] name = "migrate-lua-sql" diff --git a/package-lock.json b/package-lock.json --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@docusaurus/core": "^3.7.0", "@docusaurus/preset-classic": "^3.7.0", "@docusaurus/theme-mermaid": "^3.9.2", + "@tailwindcss/typography": "^0.5.19", "prismjs": "^1.30.0", "react": "^19.0.0", "react-dom": "^19.0.0" @@ -5305,6 +5306,31 @@ }, "engines": { "node": ">=14.16" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" } }, "node_modules/@trysound/sax": { @@ -18219,6 +18245,13 @@ "engines": { "node": ">= 10" } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", diff --git a/package.json b/package.json --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "@docusaurus/core": "^3.7.0", "@docusaurus/preset-classic": "^3.7.0", "@docusaurus/theme-mermaid": "^3.9.2", + "@tailwindcss/typography": "^0.5.19", "prismjs": "^1.30.0", "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/src/lib.rs b/src/lib.rs --- a/src/lib.rs +++ b/src/lib.rs @@ -25,6 +25,7 @@ use db::DatabaseBackend; use dns::NativeDnsResolver; use lexicon::LexiconRegistry; +use plugin::official_registry::{RegistryConfig, SharedRegistry}; use rate_limit::RateLimiter; use std::sync::Arc; use tokio::sync::watch; @@ -63,6 +64,8 @@ pub plugin_registry: Arc, pub wasm_runtime: Arc, pub attestation_signer: Option>, + pub official_registry: SharedRegistry, + pub official_registry_config: RegistryConfig, } impl axum::extract::FromRef for axum_extra::extract::cookie::Key { diff --git a/src/main.rs b/src/main.rs --- a/src/main.rs +++ b/src/main.rs @@ -407,6 +407,18 @@ ) .await; + let official_registry: happyview::plugin::official_registry::SharedRegistry = + std::sync::Arc::new(tokio::sync::RwLock::new( + happyview::plugin::official_registry::OfficialRegistryState::default(), + )); + let official_registry_config = + happyview::plugin::official_registry::RegistryConfig::production(); + happyview::plugin::official_registry::spawn_refresh_task( + http.clone(), + official_registry_config.clone(), + official_registry.clone(), + ); + let state = AppState { config: config.clone(), http, @@ -422,6 +434,8 @@ plugin_registry, wasm_runtime, attestation_signer, + official_registry, + official_registry_config, }; jetstream::spawn(state.clone(), collections_rx); diff --git a/tests/admin_plugins_official.rs b/tests/admin_plugins_official.rs new file mode 100644 --- /dev/null +++ b/tests/admin_plugins_official.rs @@ -0,0 +1,290 @@ +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use serial_test::serial; +use tower::ServiceExt; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use common::app::TestApp; + +async fn json_body(resp: axum::response::Response) -> Value { + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&bytes).unwrap() +} + +fn admin_get( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), +) -> Request { + Request::builder() + .uri(uri) + .header(cookie.0, cookie.1) + .body(Body::empty()) + .unwrap() +} + +fn admin_post( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), +) -> Request { + Request::builder() + .method("POST") + .uri(uri) + .header(cookie.0, cookie.1) + .body(Body::empty()) + .unwrap() +} + +#[tokio::test] +#[serial] +#[ignore] +async fn official_plugins_endpoint_returns_cached_list() { + let app = TestApp::new().await; + + let gh = MockServer::start().await; + + Mock::given(method("GET")) + .and(path( + "/repos/gamesgamesgamesgamesgames/happyview-plugins/releases", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + { + "tag_name": "steam-v1.2.0", + "name": "steam-v1.2.0", + "published_at": "2026-04-10T00:00:00Z", + "body": "- logging improvements", + "html_url": "https://example.com/steam-v1.2.0" + } + ]))) + .mount(&gh) + .await; + + Mock::given(method("GET")) + .and(path("/download/steam-v1.2.0/manifest.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "steam", + "name": "Steam", + "version": "1.2.0", + "api_version": "1", + "description": "Steam OAuth plugin", + "icon_url": "https://example.com/steam.png", + "wasm_file": "steam.wasm", + "required_secrets": [], + "auth_type": "openid" + }))) + .mount(&gh) + .await; + + let config = happyview::plugin::official_registry::RegistryConfig { + api_base: gh.uri(), + release_base: format!("{}/download", gh.uri()), + }; + + happyview::plugin::official_registry::refresh_full( + &app.state.http, + &config, + &app.state.official_registry, + ) + .await + .unwrap(); + + let resp = app + .router + .clone() + .oneshot(admin_get("/admin/plugins/official", app.admin_cookie())) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + let plugins = body["plugins"].as_array().unwrap(); + assert_eq!(plugins.len(), 1); + assert_eq!(plugins[0]["id"], "steam"); + assert_eq!(plugins[0]["name"], "Steam"); + assert_eq!(plugins[0]["latest_version"], "1.2.0"); + assert!(body["last_refreshed_at"].is_string()); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn plugins_list_populates_update_available_when_behind() { + let app = TestApp::new().await; + + let gh = MockServer::start().await; + + Mock::given(method("GET")) + .and(path( + "/repos/gamesgamesgamesgamesgames/happyview-plugins/releases", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + { + "tag_name": "steam-v1.2.0", + "name": "steam-v1.2.0", + "published_at": "2026-04-10T00:00:00Z", + "body": "- logging improvements", + "html_url": "https://example.com/steam-v1.2.0" + }, + { + "tag_name": "steam-v1.1.0", + "name": "steam-v1.1.0", + "published_at": "2026-03-01T00:00:00Z", + "body": "- initial", + "html_url": "https://example.com/steam-v1.1.0" + } + ]))) + .mount(&gh) + .await; + + Mock::given(method("GET")) + .and(path("/download/steam-v1.2.0/manifest.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "steam", + "name": "Steam", + "version": "1.2.0", + "api_version": "1", + "wasm_file": "steam.wasm", + "required_secrets": [], + "auth_type": "openid" + }))) + .mount(&gh) + .await; + + app.install_fake_plugin("steam", "1.1.0").await; + + let config = happyview::plugin::official_registry::RegistryConfig { + api_base: gh.uri(), + release_base: format!("{}/download", gh.uri()), + }; + happyview::plugin::official_registry::refresh_full( + &app.state.http, + &config, + &app.state.official_registry, + ) + .await + .unwrap(); + + let resp = app + .router + .clone() + .oneshot(admin_get("/admin/plugins", app.admin_cookie())) + .await + .unwrap(); + + let body = json_body(resp).await; + let plugins = body["plugins"].as_array().unwrap(); + let steam = plugins.iter().find(|p| p["id"] == "steam").unwrap(); + assert_eq!(steam["update_available"], true); + assert_eq!(steam["latest_version"], "1.2.0"); + let pending = steam["pending_releases"].as_array().unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0]["version"], "1.2.0"); +} + +#[tokio::test] +#[serial] +#[ignore] +async fn check_update_endpoint_refreshes_cache_on_demand() { + // Start the mock server BEFORE building the app so we can wire its URL + // into the registry config. + let gh = MockServer::start().await; + + Mock::given(method("GET")) + .and(path( + "/repos/gamesgamesgamesgamesgames/happyview-plugins/releases", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(json!([ + { + "tag_name": "steam-v2.0.0", + "name": "steam-v2.0.0", + "published_at": "2026-04-12T00:00:00Z", + "body": "- major rewrite", + "html_url": "https://example.com/steam-v2.0.0" + } + ]))) + .mount(&gh) + .await; + + Mock::given(method("GET")) + .and(path("/download/steam-v2.0.0/manifest.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "steam", + "name": "Steam", + "version": "2.0.0", + "api_version": "1", + "description": "Steam OAuth plugin", + "icon_url": null, + "wasm_file": "steam.wasm", + "required_secrets": [], + "auth_type": "openid" + }))) + .mount(&gh) + .await; + + let config = happyview::plugin::official_registry::RegistryConfig { + api_base: gh.uri(), + release_base: format!("{}/download", gh.uri()), + }; + let app = TestApp::new_with_registry_config(config).await; + + // Install a plugin at 1.0.0 — the cache starts empty, so /admin/plugins + // should initially report no update available. + app.install_fake_plugin("steam", "1.0.0").await; + + let resp = app + .router + .clone() + .oneshot(admin_get("/admin/plugins", app.admin_cookie())) + .await + .unwrap(); + let body = json_body(resp).await; + let steam_before = body["plugins"] + .as_array() + .unwrap() + .iter() + .find(|p| p["id"] == "steam") + .unwrap(); + assert_eq!(steam_before["update_available"], false); + assert!(steam_before["latest_version"].is_null()); + + // Force an on-demand refresh. The handler should call the mock GH API, + // populate the cache, and return a summary with update fields filled in. + let resp = app + .router + .clone() + .oneshot(admin_post( + "/admin/plugins/steam/check-update", + app.admin_cookie(), + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + assert_eq!(body["id"], "steam"); + assert_eq!(body["update_available"], true); + assert_eq!(body["latest_version"], "2.0.0"); + let pending = body["pending_releases"].as_array().unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0]["version"], "2.0.0"); + + // A follow-up /admin/plugins call should now also reflect the cache. + let resp = app + .router + .clone() + .oneshot(admin_get("/admin/plugins", app.admin_cookie())) + .await + .unwrap(); + let body = json_body(resp).await; + let steam_after = body["plugins"] + .as_array() + .unwrap() + .iter() + .find(|p| p["id"] == "steam") + .unwrap(); + assert_eq!(steam_after["update_available"], true); + assert_eq!(steam_after["latest_version"], "2.0.0"); +} diff --git a/tests/lua_atproto_api.rs b/tests/lua_atproto_api.rs --- a/tests/lua_atproto_api.rs +++ b/tests/lua_atproto_api.rs @@ -92,6 +92,11 @@ happyview::plugin::WasmRuntime::new().expect("wasm runtime"), ), attestation_signer: None, + official_registry: std::sync::Arc::new(tokio::sync::RwLock::new( + happyview::plugin::official_registry::OfficialRegistryState::default(), + )), + official_registry_config: happyview::plugin::official_registry::RegistryConfig::production( + ), } } diff --git a/tests/lua_db_api.rs b/tests/lua_db_api.rs --- a/tests/lua_db_api.rs +++ b/tests/lua_db_api.rs @@ -95,6 +95,11 @@ happyview::plugin::WasmRuntime::new().expect("wasm runtime"), ), attestation_signer: None, + official_registry: std::sync::Arc::new(tokio::sync::RwLock::new( + happyview::plugin::official_registry::OfficialRegistryState::default(), + )), + official_registry_config: happyview::plugin::official_registry::RegistryConfig::production( + ), } } diff --git a/tests/plugin_logging.rs b/tests/plugin_logging.rs --- a/tests/plugin_logging.rs +++ b/tests/plugin_logging.rs @@ -27,10 +27,34 @@ let backend = test_backend(); truncate_all(&pool).await; - log("my-plugin", LogLevel::Debug, "dbg msg", Some(pool.clone()), backend); - log("my-plugin", LogLevel::Info, "info msg", Some(pool.clone()), backend); - log("my-plugin", LogLevel::Warn, "warn msg", Some(pool.clone()), backend); - log("my-plugin", LogLevel::Error, "err msg", Some(pool.clone()), backend); + log( + "my-plugin", + LogLevel::Debug, + "dbg msg", + Some(pool.clone()), + backend, + ); + log( + "my-plugin", + LogLevel::Info, + "info msg", + Some(pool.clone()), + backend, + ); + log( + "my-plugin", + LogLevel::Warn, + "warn msg", + Some(pool.clone()), + backend, + ); + log( + "my-plugin", + LogLevel::Error, + "err msg", + Some(pool.clone()), + backend, + ); flush_spawned_tasks().await; @@ -44,7 +68,12 @@ .await .expect("failed to query event_logs"); - assert_eq!(rows.len(), 4, "expected 4 plugin.log rows, got {}", rows.len()); + assert_eq!( + rows.len(), + 4, + "expected 4 plugin.log rows, got {}", + rows.len() + ); // Severity mapping: Debug->info, Info->info, Warn->warn, Error->error let severities: Vec<&str> = rows.iter().map(|(s, _, _)| s.as_str()).collect(); @@ -79,7 +108,13 @@ truncate_all(&pool).await; // db=None: should only emit to tracing, not persist. - log("silent-plugin", LogLevel::Info, "should not persist", None, backend); + log( + "silent-plugin", + LogLevel::Info, + "should not persist", + None, + backend, + ); flush_spawned_tasks().await; diff --git a/web/package-lock.json b/web/package-lock.json --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,6 +11,7 @@ "@base-ui/react": "^1.2.0", "@monaco-editor/react": "^4.7.0", "@tabler/icons-react": "^3.36.1", + "@tailwindcss/typography": "^0.5.19", "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -24,7 +25,10 @@ "react": "19.2.4", "react-day-picker": "^9.13.2", "react-dom": "19.2.4", + "react-markdown": "^10.1.0", "recharts": "^3.7.0", + "remark-gfm": "^4.0.1", + "semver": "^7.7.4", "shiki": "^3.22.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", @@ -36,6 +40,7 @@ "@types/node": "^24", "@types/react": "^19", "@types/react-dom": "^19", + "@types/semver": "^7.7.1", "babel-plugin-react-compiler": "1.0.0", "eslint": "^9", "eslint-config-next": "16.1.6", @@ -136,6 +141,16 @@ "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/generator": { "version": "7.29.1", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", @@ -183,6 +198,16 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/helper-create-class-features-plugin": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", @@ -203,6 +228,16 @@ }, "peerDependencies": { "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" } }, "node_modules/@babel/helper-globals": { @@ -4063,6 +4098,31 @@ "tailwindcss": "4.2.0" } }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@tanstack/react-table": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", @@ -4288,7 +4348,6 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -4303,6 +4362,13 @@ "peerDependencies": { "@types/react": "^19.2.0" } + }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" }, "node_modules/@types/statuses": { "version": "2.0.6", @@ -4557,19 +4623,6 @@ }, "funding": { "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" } }, "node_modules/@typescript-eslint/utils": { @@ -5299,6 +5352,16 @@ "@babel/types": "^7.26.0" } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/balanced-match": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.3.tgz", @@ -5905,7 +5968,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, "license": "MIT", "bin": { "cssesc": "bin/cssesc" @@ -5918,7 +5980,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/d3-array": { @@ -7002,6 +7063,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/eslint-config-next/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/eslint-import-resolver-node": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", @@ -7355,6 +7426,12 @@ "peerDependencies": { "express": ">= 4.11" } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" }, "node_modules/fast-deep-equal": { "version": "3.1.3", @@ -8095,6 +8172,16 @@ "node": ">=16.9.0" } }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/html-void-elements": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", @@ -8381,19 +8468,6 @@ "license": "MIT", "dependencies": { "semver": "^7.7.1" - } - }, - "node_modules/is-bun-module/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" } }, "node_modules/is-callable": { @@ -8687,7 +8761,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -9505,6 +9578,16 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/marked": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", @@ -9528,6 +9611,34 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mdast-util-from-markdown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", @@ -9546,6 +9657,107 @@ "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" }, "funding": { "type": "opencollective", @@ -9788,6 +10000,127 @@ "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, "node_modules/micromark-factory-destination": { @@ -10519,6 +10852,16 @@ }, "funding": { "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" } }, "node_modules/node-fetch": { @@ -11451,6 +11794,33 @@ "license": "MIT", "peer": true }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", @@ -11671,6 +12041,72 @@ }, "funding": { "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, "node_modules/require-directory": { @@ -11909,13 +12345,15 @@ "license": "MIT" }, "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/send": { @@ -12166,19 +12604,6 @@ "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" - } - }, - "node_modules/sharp/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" } }, "node_modules/shebang-command": { @@ -12734,7 +13159,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.0.tgz", "integrity": "sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q==", - "dev": true, "license": "MIT" }, "node_modules/tapable": { @@ -12875,6 +13299,16 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", "license": "MIT", "funding": { "type": "github", @@ -13146,6 +13580,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/unist-util-is": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", @@ -13376,7 +13829,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, "license": "MIT" }, "node_modules/validate-npm-package-name": { diff --git a/web/package.json b/web/package.json --- a/web/package.json +++ b/web/package.json @@ -12,6 +12,7 @@ "@base-ui/react": "^1.2.0", "@monaco-editor/react": "^4.7.0", "@tabler/icons-react": "^3.36.1", + "@tailwindcss/typography": "^0.5.19", "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -25,7 +26,10 @@ "react": "19.2.4", "react-day-picker": "^9.13.2", "react-dom": "19.2.4", + "react-markdown": "^10.1.0", "recharts": "^3.7.0", + "remark-gfm": "^4.0.1", + "semver": "^7.7.4", "shiki": "^3.22.0", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", @@ -37,6 +41,7 @@ "@types/node": "^24", "@types/react": "^19", "@types/react-dom": "^19", + "@types/semver": "^7.7.1", "babel-plugin-react-compiler": "1.0.0", "eslint": "^9", "eslint-config-next": "16.1.6", diff --git a/src/admin/mod.rs b/src/admin/mod.rs --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -86,8 +86,10 @@ ) .route("/plugins", post(plugins::add).get(plugins::list)) .route("/plugins/preview", post(plugins::preview)) + .route("/plugins/official", get(plugins::list_official)) .route("/plugins/{id}", delete(plugins::remove)) .route("/plugins/{id}/reload", post(plugins::reload)) + .route("/plugins/{id}/check-update", post(plugins::check_update)) .route( "/plugins/{id}/secrets", get(plugins::get_secrets).put(plugins::update_secrets), diff --git a/src/admin/plugins.rs b/src/admin/plugins.rs --- a/src/admin/plugins.rs +++ b/src/admin/plugins.rs @@ -10,6 +10,66 @@ use crate::event_log::{EventLog, Severity, log_event}; use crate::plugin::encryption::{decrypt, encrypt}; use crate::plugin::loader; +use crate::plugin::official_registry::{OfficialPlugin, ReleaseEntry}; + +/// If the reload request provides a new URL, use it and clear the old sha256 +/// (the new version has its own hash). Otherwise keep the stored values. +fn resolve_reload_url( + current: (String, Option), + body: Option, +) -> (String, Option) { + match body { + Some(b) if b.url.is_some() => (b.url.unwrap(), None), + _ => current, + } +} + +struct UpdateInfo { + update_available: bool, + latest_version: Option, + pending_releases: Vec, +} + +fn compute_update_info( + installed_version: &str, + cache_entry: Option<&OfficialPlugin>, +) -> UpdateInfo { + let Some(entry) = cache_entry else { + return UpdateInfo { + update_available: false, + latest_version: None, + pending_releases: Vec::new(), + }; + }; + + let installed = match semver::Version::parse(installed_version) { + Ok(v) => v, + Err(_) => { + return UpdateInfo { + update_available: false, + latest_version: Some(entry.latest_version.clone()), + pending_releases: Vec::new(), + }; + } + }; + + let pending: Vec = entry + .releases + .iter() + .filter(|r| { + semver::Version::parse(&r.version) + .map(|v| v > installed) + .unwrap_or(false) + }) + .cloned() + .collect(); + + UpdateInfo { + update_available: !pending.is_empty(), + latest_version: Some(entry.latest_version.clone()), + pending_releases: pending, + } +} use super::auth::UserAuth; use super::permissions::Permission; @@ -40,6 +100,8 @@ .into_iter() .collect() }; + + let official_guard = state.official_registry.read().await; let summaries: Vec = plugins .into_iter() @@ -82,6 +144,9 @@ let secrets_configured = required_secrets.is_empty() || configured_plugins.contains(&p.info.id); + let update_info = + compute_update_info(&p.info.version, official_guard.plugins.get(&p.info.id)); + PluginSummary { id: p.info.id.clone(), name: p.info.name.clone(), @@ -94,6 +159,9 @@ required_secrets, secrets_configured, loaded_at: None, // Would need to track this in registry + update_available: update_info.update_available, + latest_version: update_info.latest_version, + pending_releases: update_info.pending_releases, } }) .collect(); @@ -195,6 +263,9 @@ required_secrets, secrets_configured, loaded_at: Some(now_rfc3339()), + update_available: false, + latest_version: None, + pending_releases: Vec::new(), }; let plugin_id = plugin.info.id.clone(); @@ -265,6 +336,7 @@ State(state): State, auth: UserAuth, Path(plugin_id): Path, + body: Option>, ) -> Result, AppError> { auth.require(Permission::PluginsCreate).await?; @@ -283,6 +355,8 @@ )); } }; + + let (url, sha256) = resolve_reload_url((url, sha256), body.map(|Json(b)| b)); // Remove old plugin state.plugin_registry.remove(&plugin_id).await; @@ -348,7 +422,22 @@ required_secrets, secrets_configured, loaded_at: Some(now_rfc3339()), + update_available: false, + latest_version: None, + pending_releases: Vec::new(), }; + + // Persist the (possibly new) URL so restarts pick it up + let persist_sql = adapt_sql( + "UPDATE plugins SET url = ?, sha256 = NULL WHERE id = ?", + state.db_backend, + ); + sqlx::query(&persist_sql) + .bind(&url) + .bind(&plugin.info.id) + .execute(&state.db) + .await + .map_err(|e| AppError::Internal(format!("Failed to persist plugin URL: {}", e)))?; // Register the reloaded plugin state.plugin_registry.register(plugin).await; @@ -543,4 +632,200 @@ .await; Ok(StatusCode::NO_CONTENT) +} + +/// POST /admin/plugins/{id}/check-update — force a cache refresh for one plugin +pub(super) async fn check_update( + State(state): State, + auth: UserAuth, + Path(plugin_id): Path, +) -> Result, AppError> { + auth.require(Permission::PluginsCreate).await?; + + crate::plugin::official_registry::refresh_plugin( + &state.http, + &state.official_registry_config, + &state.official_registry, + &plugin_id, + ) + .await + .map_err(|e| AppError::BadRequest(format!("Update check failed: {}", e)))?; + + // Return the refreshed PluginSummary by re-running the same join logic + let current = state + .plugin_registry + .get(&plugin_id) + .await + .ok_or_else(|| AppError::NotFound(format!("Plugin '{}' not found", plugin_id)))?; + + let guard = state.official_registry.read().await; + let update_info = compute_update_info(¤t.info.version, guard.plugins.get(&plugin_id)); + + let required_secrets: Vec = + if let Some(manifest) = ¤t.manifest { + manifest + .required_secrets + .iter() + .map(|s| super::types::SecretDefinition { + key: s.key.clone(), + name: s.name.clone(), + description: s.description.clone(), + }) + .collect() + } else { + current + .info + .required_secrets + .iter() + .map(|key| super::types::SecretDefinition { + key: key.clone(), + name: key.clone(), + description: None, + }) + .collect() + }; + + let (source, url, sha256) = match ¤t.source { + crate::plugin::PluginSource::File { path } => { + ("file".to_string(), Some(path.display().to_string()), None) + } + crate::plugin::PluginSource::Url { url, sha256 } => { + ("url".to_string(), Some(url.clone()), sha256.clone()) + } + }; + + Ok(Json(PluginSummary { + id: current.info.id.clone(), + name: current.info.name.clone(), + version: current.info.version.clone(), + source, + url, + sha256, + enabled: true, + auth_type: current.info.auth_type.clone(), + required_secrets, + secrets_configured: true, + loaded_at: None, + update_available: update_info.update_available, + latest_version: update_info.latest_version, + pending_releases: update_info.pending_releases, + })) +} + +/// GET /admin/plugins/official — list plugins from the official registry cache +pub(super) async fn list_official( + State(state): State, + auth: UserAuth, +) -> Result, AppError> { + auth.require(Permission::PluginsRead).await?; + + let guard = state.official_registry.read().await; + let plugins = guard + .plugins + .values() + .map(|p| super::types::OfficialPluginSummary { + id: p.id.clone(), + name: p.name.clone(), + description: p.description.clone(), + icon_url: p.icon_url.clone(), + latest_version: p.latest_version.clone(), + manifest_url: p.manifest_url.clone(), + }) + .collect::>(); + + Ok(Json(super::types::OfficialPluginsListResponse { + plugins, + last_refreshed_at: guard.last_refreshed_at.clone(), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::plugin::official_registry::{OfficialPlugin, ReleaseEntry}; + + fn entry(versions: &[&str]) -> OfficialPlugin { + OfficialPlugin { + id: "steam".into(), + name: "steam".into(), + description: None, + icon_url: None, + latest_version: versions[0].into(), + manifest_url: "m".into(), + wasm_url: "w".into(), + releases: versions + .iter() + .map(|v| ReleaseEntry { + version: (*v).into(), + name: format!("v{v}"), + published_at: "2026-04-10T00:00:00Z".into(), + body: "notes".into(), + }) + .collect(), + } + } + + #[test] + fn compute_update_info_flags_update_when_behind() { + let cached = entry(&["1.2.0", "1.1.0", "1.0.0"]); + let info = compute_update_info("1.0.0", Some(&cached)); + assert!(info.update_available); + assert_eq!(info.latest_version.as_deref(), Some("1.2.0")); + assert_eq!(info.pending_releases.len(), 2); + assert_eq!(info.pending_releases[0].version, "1.2.0"); + assert_eq!(info.pending_releases[1].version, "1.1.0"); + } + + #[test] + fn compute_update_info_no_update_when_current() { + let cached = entry(&["1.2.0"]); + let info = compute_update_info("1.2.0", Some(&cached)); + assert!(!info.update_available); + assert_eq!(info.latest_version.as_deref(), Some("1.2.0")); + assert!(info.pending_releases.is_empty()); + } + + #[test] + fn compute_update_info_no_cache_entry() { + let info = compute_update_info("1.2.0", None); + assert!(!info.update_available); + assert!(info.latest_version.is_none()); + assert!(info.pending_releases.is_empty()); + } + + #[test] + fn compute_update_info_handles_malformed_installed_version() { + let cached = entry(&["1.2.0"]); + let info = compute_update_info("not-semver", Some(&cached)); + assert!(!info.update_available); + assert_eq!(info.latest_version.as_deref(), Some("1.2.0")); + } + + #[test] + fn resolve_reload_url_uses_override_and_clears_sha() { + let current = ("https://old".to_string(), Some("deadbeef".to_string())); + let body = super::super::types::ReloadPluginBody { + url: Some("https://new".into()), + }; + let (url, sha) = resolve_reload_url(current, Some(body)); + assert_eq!(url, "https://new"); + assert_eq!(sha, None); + } + + #[test] + fn resolve_reload_url_keeps_current_when_body_absent() { + let current = ("https://old".to_string(), Some("deadbeef".to_string())); + let (url, sha) = resolve_reload_url(current, None); + assert_eq!(url, "https://old"); + assert_eq!(sha.as_deref(), Some("deadbeef")); + } + + #[test] + fn resolve_reload_url_keeps_current_when_body_url_is_none() { + let current = ("https://old".to_string(), Some("deadbeef".to_string())); + let body = super::super::types::ReloadPluginBody { url: None }; + let (url, sha) = resolve_reload_url(current, Some(body)); + assert_eq!(url, "https://old"); + assert_eq!(sha.as_deref(), Some("deadbeef")); + } } diff --git a/src/admin/types.rs b/src/admin/types.rs --- a/src/admin/types.rs +++ b/src/admin/types.rs @@ -251,6 +251,34 @@ /// Whether all required secrets have been configured pub(super) secrets_configured: bool, pub(super) loaded_at: Option, + #[serde(default)] + pub(super) update_available: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) latest_version: Option, + #[serde(default)] + pub(super) pending_releases: Vec, +} + +#[derive(Serialize)] +pub(super) struct OfficialPluginSummary { + pub(super) id: String, + pub(super) name: String, + pub(super) description: Option, + pub(super) icon_url: Option, + pub(super) latest_version: String, + pub(super) manifest_url: String, +} + +#[derive(Serialize)] +pub(super) struct OfficialPluginsListResponse { + pub(super) plugins: Vec, + pub(super) last_refreshed_at: Option, +} + +#[derive(Deserialize, Default)] +pub(super) struct ReloadPluginBody { + #[serde(default)] + pub(super) url: Option, } #[derive(Deserialize)] diff --git a/src/lua/atproto_api.rs b/src/lua/atproto_api.rs --- a/src/lua/atproto_api.rs +++ b/src/lua/atproto_api.rs @@ -364,6 +364,11 @@ crate::plugin::WasmRuntime::new().expect("wasm runtime"), ), attestation_signer: None, + official_registry: std::sync::Arc::new(tokio::sync::RwLock::new( + crate::plugin::official_registry::OfficialRegistryState::default(), + )), + official_registry_config: crate::plugin::official_registry::RegistryConfig::production( + ), } } diff --git a/src/lua/db_api.rs b/src/lua/db_api.rs --- a/src/lua/db_api.rs +++ b/src/lua/db_api.rs @@ -715,6 +715,11 @@ crate::plugin::WasmRuntime::new().expect("wasm runtime"), ), attestation_signer: None, + official_registry: std::sync::Arc::new(tokio::sync::RwLock::new( + crate::plugin::official_registry::OfficialRegistryState::default(), + )), + official_registry_config: crate::plugin::official_registry::RegistryConfig::production( + ), } } diff --git a/src/lua/execute.rs b/src/lua/execute.rs --- a/src/lua/execute.rs +++ b/src/lua/execute.rs @@ -1096,6 +1096,11 @@ crate::plugin::WasmRuntime::new().expect("wasm runtime"), ), attestation_signer: None, + official_registry: std::sync::Arc::new(tokio::sync::RwLock::new( + crate::plugin::official_registry::OfficialRegistryState::default(), + )), + official_registry_config: crate::plugin::official_registry::RegistryConfig::production( + ), } } diff --git a/src/lua/http_api.rs b/src/lua/http_api.rs --- a/src/lua/http_api.rs +++ b/src/lua/http_api.rs @@ -181,6 +181,11 @@ crate::plugin::WasmRuntime::new().expect("wasm runtime"), ), attestation_signer: None, + official_registry: std::sync::Arc::new(tokio::sync::RwLock::new( + crate::plugin::official_registry::OfficialRegistryState::default(), + )), + official_registry_config: crate::plugin::official_registry::RegistryConfig::production( + ), } } diff --git a/src/lua/xrpc_api.rs b/src/lua/xrpc_api.rs --- a/src/lua/xrpc_api.rs +++ b/src/lua/xrpc_api.rs @@ -285,6 +285,11 @@ crate::plugin::WasmRuntime::new().expect("wasm runtime"), ), attestation_signer: None, + official_registry: std::sync::Arc::new(tokio::sync::RwLock::new( + crate::plugin::official_registry::OfficialRegistryState::default(), + )), + official_registry_config: crate::plugin::official_registry::RegistryConfig::production( + ), } } diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -4,6 +4,7 @@ pub mod host; pub mod loader; pub mod memory; +pub mod official_registry; mod runtime; pub mod sync; mod types; diff --git a/src/plugin/official_registry.rs b/src/plugin/official_registry.rs new file mode 100644 --- /dev/null +++ b/src/plugin/official_registry.rs @@ -0,0 +1,443 @@ +//! Cache of plugins discovered from the official `happyview-plugins` repo. + +use semver::Version; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; + +pub const OFFICIAL_REPO: &str = "gamesgamesgamesgamesgames/happyview-plugins"; + +/// A release entry for the update preview UI. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReleaseEntry { + pub version: String, + pub name: String, + pub published_at: String, + pub body: String, +} + +/// One plugin discovered in the official repo. +#[derive(Debug, Clone, Serialize)] +pub struct OfficialPlugin { + pub id: String, + pub name: String, + pub description: Option, + pub icon_url: Option, + pub latest_version: String, + pub manifest_url: String, + pub wasm_url: String, + pub releases: Vec, +} + +/// Cache state stored on `AppState` behind an `Arc>`. +#[derive(Debug, Default)] +pub struct OfficialRegistryState { + pub plugins: HashMap, + pub last_refreshed_at: Option, +} + +pub type SharedRegistry = Arc>; + +/// Raw GitHub release payload (subset of fields we use). +#[derive(Debug, Clone, Deserialize)] +pub struct GithubRelease { + pub tag_name: String, + pub name: Option, + pub published_at: String, + pub body: Option, + pub html_url: String, +} + +/// Parse a monorepo tag like `steam-v1.2.0` into `("steam", "1.2.0")`. +/// Returns `None` for tags we don't recognize. +pub fn parse_tag(tag: &str) -> Option<(String, Version)> { + let (id, version) = tag.rsplit_once("-v")?; + let parsed = Version::parse(version).ok()?; + Some((id.to_string(), parsed)) +} + +/// Group releases by plugin id, filter out unparseable tags, sort each +/// group newest-first. +pub fn group_releases( + releases: Vec, +) -> HashMap> { + let mut grouped: HashMap> = HashMap::new(); + for release in releases { + let Some((id, version)) = parse_tag(&release.tag_name) else { + continue; + }; + grouped.entry(id).or_default().push((version, release)); + } + for entries in grouped.values_mut() { + entries.sort_by(|a, b| b.0.cmp(&a.0)); + } + grouped +} + +/// Convert a grouped release entry list into serializable `ReleaseEntry`s +/// for the cache / UI. The first entry is the latest. +pub fn to_release_entries(entries: &[(Version, GithubRelease)]) -> Vec { + entries + .iter() + .map(|(version, release)| ReleaseEntry { + version: version.to_string(), + name: release + .name + .clone() + .unwrap_or_else(|| release.tag_name.clone()), + published_at: release.published_at.clone(), + body: release.body.clone().unwrap_or_default(), + }) + .collect() +} + +use crate::plugin::loader; + +#[derive(Debug, Clone)] +pub struct RegistryConfig { + /// Base URL for the GitHub REST API, e.g. `https://api.github.com`. + pub api_base: String, + /// Base URL for release asset downloads, e.g. + /// `https://github.com/gamesgamesgamesgamesgames/happyview-plugins/releases/download`. + pub release_base: String, +} + +impl RegistryConfig { + pub fn production() -> Self { + Self { + api_base: "https://api.github.com".into(), + release_base: format!("https://github.com/{}/releases/download", OFFICIAL_REPO), + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum RegistryError { + #[error("GitHub API request failed: {0}")] + Http(#[from] reqwest::Error), + #[error("GitHub API returned status {0}")] + Status(u16), +} + +async fn fetch_releases( + client: &reqwest::Client, + config: &RegistryConfig, +) -> Result, RegistryError> { + let url = format!( + "{}/repos/{}/releases?per_page=100", + config.api_base, OFFICIAL_REPO + ); + let response = client + .get(&url) + .header("User-Agent", "happyview") + .header("Accept", "application/vnd.github+json") + .send() + .await?; + if !response.status().is_success() { + return Err(RegistryError::Status(response.status().as_u16())); + } + let releases: Vec = response.json().await?; + Ok(releases) +} + +async fn build_official_plugin( + client: &reqwest::Client, + config: &RegistryConfig, + id: &str, + entries: &[(Version, GithubRelease)], +) -> OfficialPlugin { + let (latest_version, _) = entries + .first() + .map(|(v, _)| (v.to_string(), ())) + .expect("entries non-empty"); + let tag = format!("{}-v{}", id, latest_version); + let manifest_url = format!("{}/{}/manifest.json", config.release_base, tag); + + // Try to enrich with manifest fields. On failure, fall back to id. + let (name, description, icon_url, wasm_url) = + match loader::fetch_manifest(client, &manifest_url).await { + Ok(preview) => ( + preview.manifest.name, + preview.manifest.description, + preview.manifest.icon_url, + preview.wasm_url, + ), + Err(e) => { + tracing::warn!( + plugin = id, + error = %e, + "official_registry: failed to fetch manifest, using fallback metadata" + ); + ( + id.to_string(), + None, + None, + format!("{}/{}/{}.wasm", config.release_base, tag, id), + ) + } + }; + + OfficialPlugin { + id: id.to_string(), + name, + description, + icon_url, + latest_version, + manifest_url, + wasm_url, + releases: to_release_entries(entries), + } +} + +/// Fetch all releases and rebuild the cache atomically. On error, the +/// previous cache is retained and the error is returned. +pub async fn refresh_full( + client: &reqwest::Client, + config: &RegistryConfig, + state: &SharedRegistry, +) -> Result<(), RegistryError> { + let releases = fetch_releases(client, config).await?; + let grouped = group_releases(releases); + + let mut plugins = HashMap::new(); + for (id, entries) in grouped { + if entries.is_empty() { + continue; + } + let plugin = build_official_plugin(client, config, &id, &entries).await; + plugins.insert(id, plugin); + } + + let mut guard = state.write().await; + guard.plugins = plugins; + guard.last_refreshed_at = Some(crate::db::now_rfc3339()); + Ok(()) +} + +/// Refresh just one plugin's cache entry from a fresh GitHub fetch. +/// Falls back to removing the entry if the plugin has no releases. +pub async fn refresh_plugin( + client: &reqwest::Client, + config: &RegistryConfig, + state: &SharedRegistry, + plugin_id: &str, +) -> Result, RegistryError> { + let releases = fetch_releases(client, config).await?; + let mut grouped = group_releases(releases); + + let Some(entries) = grouped.remove(plugin_id) else { + let mut guard = state.write().await; + guard.plugins.remove(plugin_id); + return Ok(None); + }; + + let plugin = build_official_plugin(client, config, plugin_id, &entries).await; + let mut guard = state.write().await; + guard.plugins.insert(plugin_id.to_string(), plugin.clone()); + guard.last_refreshed_at = Some(crate::db::now_rfc3339()); + Ok(Some(plugin)) +} + +/// Background task: run `refresh_full` on startup, then every 15 minutes. +pub fn spawn_refresh_task(client: reqwest::Client, config: RegistryConfig, state: SharedRegistry) { + tokio::spawn(async move { + loop { + match refresh_full(&client, &config, &state).await { + Ok(()) => tracing::info!("official_registry: cache refreshed"), + Err(e) => tracing::warn!(error = %e, "official_registry: refresh failed"), + } + tokio::time::sleep(std::time::Duration::from_secs(15 * 60)).await; + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_release(tag: &str) -> GithubRelease { + GithubRelease { + tag_name: tag.to_string(), + name: Some(tag.to_string()), + published_at: "2026-04-13T00:00:00Z".to_string(), + body: Some(format!("body for {tag}")), + html_url: format!("https://example.com/{tag}"), + } + } + + #[test] + fn parse_tag_happy_path() { + let (id, version) = parse_tag("steam-v1.2.0").unwrap(); + assert_eq!(id, "steam"); + assert_eq!(version, Version::parse("1.2.0").unwrap()); + } + + #[test] + fn parse_tag_prerelease() { + let (id, version) = parse_tag("xbox-v2.0.0-beta.1").unwrap(); + assert_eq!(id, "xbox"); + assert_eq!(version, Version::parse("2.0.0-beta.1").unwrap()); + } + + #[test] + fn parse_tag_rejects_malformed() { + assert!(parse_tag("not-a-tag").is_none()); + assert!(parse_tag("steam-v").is_none()); + assert!(parse_tag("steam-vNOT_SEMVER").is_none()); + } + + #[test] + fn group_releases_sorts_newest_first() { + let releases = vec![ + make_release("steam-v1.0.0"), + make_release("steam-v1.2.0"), + make_release("steam-v1.1.0"), + make_release("xbox-v0.1.0"), + make_release("garbage-tag"), + ]; + let grouped = group_releases(releases); + assert_eq!(grouped.len(), 2); + let steam = grouped.get("steam").unwrap(); + assert_eq!(steam.len(), 3); + assert_eq!(steam[0].0.to_string(), "1.2.0"); + assert_eq!(steam[1].0.to_string(), "1.1.0"); + assert_eq!(steam[2].0.to_string(), "1.0.0"); + } + + #[test] + fn to_release_entries_preserves_order() { + let grouped = group_releases(vec![ + make_release("steam-v1.1.0"), + make_release("steam-v1.2.0"), + ]); + let entries = to_release_entries(grouped.get("steam").unwrap()); + assert_eq!(entries[0].version, "1.2.0"); + assert_eq!(entries[1].version, "1.1.0"); + assert_eq!(entries[0].body, "body for steam-v1.2.0"); + } + + use wiremock::matchers::{method, path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn gh_release_json(tag: &str, body: &str) -> serde_json::Value { + serde_json::json!({ + "tag_name": tag, + "name": tag, + "published_at": "2026-04-10T00:00:00Z", + "body": body, + "html_url": format!("https://example.com/{tag}"), + }) + } + + fn manifest_json(id: &str, version: &str) -> serde_json::Value { + serde_json::json!({ + "id": id, + "name": id.to_string() + " Plugin", + "version": version, + "api_version": "1", + "description": format!("The {id} plugin"), + "icon_url": format!("https://example.com/{id}.png"), + "wasm_file": format!("{id}.wasm"), + "required_secrets": [], + "auth_type": "oauth2", + }) + } + + #[tokio::test] + async fn refresh_full_populates_cache_from_mock_github() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path( + "/repos/gamesgamesgamesgamesgames/happyview-plugins/releases", + )) + .and(query_param("per_page", "100")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([ + gh_release_json("steam-v1.2.0", "- steam 1.2.0 notes"), + gh_release_json("steam-v1.1.0", "- steam 1.1.0 notes"), + gh_release_json("xbox-v0.1.0", "- xbox 0.1.0 notes"), + gh_release_json("bogus-tag", "ignored"), + ]))) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/download/steam-v1.2.0/manifest.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(manifest_json("steam", "1.2.0"))) + .mount(&server) + .await; + + Mock::given(method("GET")) + .and(path("/download/xbox-v0.1.0/manifest.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(manifest_json("xbox", "0.1.0"))) + .mount(&server) + .await; + + let client = reqwest::Client::new(); + let state: SharedRegistry = Arc::new(RwLock::new(OfficialRegistryState::default())); + + let config = RegistryConfig { + api_base: server.uri(), + release_base: format!("{}/download", server.uri()), + }; + + refresh_full(&client, &config, &state).await.unwrap(); + + let guard = state.read().await; + assert_eq!(guard.plugins.len(), 2); + + let steam = guard.plugins.get("steam").unwrap(); + assert_eq!(steam.latest_version, "1.2.0"); + assert_eq!(steam.releases.len(), 2); + assert_eq!(steam.releases[0].version, "1.2.0"); + assert_eq!(steam.releases[1].version, "1.1.0"); + assert_eq!(steam.name, "steam Plugin"); + assert_eq!(steam.description.as_deref(), Some("The steam plugin")); + assert!(steam.manifest_url.contains("steam-v1.2.0")); + assert!(steam.wasm_url.ends_with("steam.wasm")); + + assert!(guard.last_refreshed_at.is_some()); + } + + #[tokio::test] + async fn refresh_full_retains_previous_cache_on_error() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path( + "/repos/gamesgamesgamesgamesgames/happyview-plugins/releases", + )) + .respond_with(ResponseTemplate::new(500)) + .mount(&server) + .await; + + let state: SharedRegistry = Arc::new(RwLock::new(OfficialRegistryState { + plugins: HashMap::from([( + "steam".to_string(), + OfficialPlugin { + id: "steam".into(), + name: "steam Plugin".into(), + description: None, + icon_url: None, + latest_version: "1.0.0".into(), + manifest_url: "https://example.com/m".into(), + wasm_url: "https://example.com/w".into(), + releases: vec![], + }, + )]), + last_refreshed_at: Some("2026-04-12T00:00:00Z".into()), + })); + + let config = RegistryConfig { + api_base: server.uri(), + release_base: format!("{}/download", server.uri()), + }; + + let result = refresh_full(&reqwest::Client::new(), &config, &state).await; + assert!(result.is_err()); + + let guard = state.read().await; + assert_eq!(guard.plugins.len(), 1); + assert!(guard.plugins.contains_key("steam")); + } +} diff --git a/tests/common/app.rs b/tests/common/app.rs --- a/tests/common/app.rs +++ b/tests/common/app.rs @@ -24,6 +24,15 @@ impl TestApp { pub async fn new() -> Self { + Self::new_with_registry_config( + happyview::plugin::official_registry::RegistryConfig::production(), + ) + .await + } + + pub async fn new_with_registry_config( + registry_config: happyview::plugin::official_registry::RegistryConfig, + ) -> Self { let pool = db::test_pool().await; let backend = db::test_backend(); db::truncate_all(&pool).await; @@ -141,6 +150,10 @@ happyview::plugin::WasmRuntime::new().expect("wasm runtime"), ), attestation_signer: None, + official_registry: std::sync::Arc::new(tokio::sync::RwLock::new( + happyview::plugin::official_registry::OfficialRegistryState::default(), + )), + official_registry_config: registry_config, }; let router = server::router(state.clone()); @@ -157,5 +170,30 @@ /// Build a Cookie header that authenticates as the admin user. pub fn admin_cookie(&self) -> (axum::http::HeaderName, axum::http::HeaderValue) { crate::common::auth::admin_cookie_header(&self.admin_did, &self.state.cookie_key) + } + + /// Install a fake plugin directly into the registry at the given version. + pub async fn install_fake_plugin(&self, id: &str, version: &str) { + use happyview::plugin::{LoadedPlugin, PluginInfo, PluginSource}; + + let plugin = LoadedPlugin { + info: PluginInfo { + id: id.to_string(), + name: id.to_string(), + version: version.to_string(), + api_version: "1".to_string(), + icon_url: None, + required_secrets: vec![], + auth_type: "openid".to_string(), + config_schema: None, + }, + source: PluginSource::Url { + url: format!("https://example.com/{id}.wasm"), + sha256: None, + }, + wasm_bytes: vec![], + manifest: None, + }; + self.state.plugin_registry.register(plugin).await; } } diff --git a/tests/fixtures/github_releases.json b/tests/fixtures/github_releases.json new file mode 100644 --- /dev/null +++ b/tests/fixtures/github_releases.json @@ -0,0 +1,16 @@ +[ + { + "tag_name": "steam-v1.2.0", + "name": "steam-v1.2.0", + "published_at": "2026-04-10T00:00:00Z", + "body": "- Added better logging\n- Fixed token refresh", + "html_url": "https://example.com/steam-v1.2.0" + }, + { + "tag_name": "steam-v1.1.0", + "name": "steam-v1.1.0", + "published_at": "2026-03-01T00:00:00Z", + "body": "- Initial release", + "html_url": "https://example.com/steam-v1.1.0" + } +] diff --git a/src/plugin/host/logging.rs b/src/plugin/host/logging.rs --- a/src/plugin/host/logging.rs +++ b/src/plugin/host/logging.rs @@ -103,9 +103,21 @@ fn test_log_does_not_panic() { // With db=None, only the tracing path runs. Verifies each level does not panic. let backend = crate::db::DatabaseBackend::Sqlite; - log("test-plugin", LogLevel::Debug, "debug message", None, backend); + log( + "test-plugin", + LogLevel::Debug, + "debug message", + None, + backend, + ); log("test-plugin", LogLevel::Info, "info message", None, backend); log("test-plugin", LogLevel::Warn, "warn message", None, backend); - log("test-plugin", LogLevel::Error, "error message", None, backend); + log( + "test-plugin", + LogLevel::Error, + "error message", + None, + backend, + ); } } diff --git a/web/src/app/globals.css b/web/src/app/globals.css --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -2,6 +2,8 @@ @import "tw-animate-css"; @import "shadcn/tailwind.css"; +@plugin "@tailwindcss/typography"; + @custom-variant dark (&:is(.dark *)); @theme inline { diff --git a/web/src/components/app-sidebar.tsx b/web/src/components/app-sidebar.tsx --- a/web/src/components/app-sidebar.tsx +++ b/web/src/components/app-sidebar.tsx @@ -16,6 +16,7 @@ IconSettings, IconInfoCircle, IconApps, + IconArrowUpCircle, } from "@tabler/icons-react"; import Image from "next/image"; import Link from "next/link"; @@ -24,6 +25,7 @@ import { useAuth } from "@/lib/auth-context"; import { useConfig } from "@/lib/config-context"; import { useCurrentUser } from "@/hooks/use-current-user"; +import { usePluginUpdates } from "@/components/plugin-update-provider"; import { Scroller } from "@/components/ui/scroller"; import { Sidebar, @@ -119,6 +121,7 @@ const { logout } = useAuth(); const { app_name, logo_url } = useConfig(); const { hasPermission } = useCurrentUser(); + const { hasUpdates } = usePluginUpdates(); function filterByPermission(items: NavItem[]) { return items.filter( @@ -245,20 +248,30 @@ Integrations - {visibleIntegrations.map((item) => ( - - - - - {item.title} - - - - ))} + {visibleIntegrations.map((item) => { + const showUpdateBadge = + item.title === "Plugins" && hasUpdates; + return ( + + + + + {item.title} + {showUpdateBadge && ( + + )} + + + + ); + })} diff --git a/web/src/components/plugin-update-dialog.tsx b/web/src/components/plugin-update-dialog.tsx new file mode 100644 --- /dev/null +++ b/web/src/components/plugin-update-dialog.tsx @@ -0,0 +1,219 @@ +"use client" + +import { useEffect, useState } from "react" +import { AlertTriangle, Loader2 } from "lucide-react" +import ReactMarkdown from "react-markdown" +import remarkGfm from "remark-gfm" +import { toast } from "sonner" + +import { + ResponsiveDialog, + ResponsiveDialogContent, + ResponsiveDialogDescription, + ResponsiveDialogFooter, + ResponsiveDialogHeader, + ResponsiveDialogTitle, +} from "@/components/ui/responsive-dialog" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + previewPlugin, + reloadPlugin, + type PluginPreview, + type PluginSummary, + type SecretDefinition, +} from "@/lib/api" +import { useOfficialPlugins } from "@/hooks/use-official-plugins" + +interface PluginUpdateDialogProps { + plugin: PluginSummary | null + open: boolean + onOpenChange: (open: boolean) => void + onUpdated: (updated: PluginSummary) => void +} + +export function PluginUpdateDialog({ + plugin, + open, + onOpenChange, + onUpdated, +}: PluginUpdateDialogProps) { + const { byId } = useOfficialPlugins() + const manifestUrl = plugin ? byId.get(plugin.id)?.manifest_url ?? null : null + + const [newManifestPreview, setNewManifestPreview] = + useState(null) + const [previewLoading, setPreviewLoading] = useState(false) + const [updating, setUpdating] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (!open || !plugin) { + setNewManifestPreview(null) + setError(null) + return + } + if (!manifestUrl) { + setNewManifestPreview(null) + return + } + + let cancelled = false + setPreviewLoading(true) + previewPlugin(manifestUrl) + .then((preview) => { + if (!cancelled) setNewManifestPreview(preview) + }) + .catch(() => { + if (!cancelled) setNewManifestPreview(null) + }) + .finally(() => { + if (!cancelled) setPreviewLoading(false) + }) + + return () => { + cancelled = true + } + }, [open, plugin, manifestUrl]) + + if (!plugin) return null + + const installedKeys = new Set( + plugin.required_secrets.map((secret) => secret.key), + ) + const newRequiredSecrets: SecretDefinition[] = + newManifestPreview?.required_secrets.filter( + (secret) => !installedKeys.has(secret.key), + ) ?? [] + + const pendingReleases = [...(plugin.pending_releases ?? [])] + const hasReleaseNotes = pendingReleases.length > 0 + const hasLatestVersion = Boolean(plugin.latest_version) + + const handleUpdate = async () => { + if (!manifestUrl) { + setError("No manifest URL available for this plugin.") + return + } + setUpdating(true) + setError(null) + try { + const updated = await reloadPlugin(plugin.id, { url: manifestUrl }) + if (plugin.latest_version) { + try { + window.localStorage.setItem( + `happyview:plugin-update-seen:${plugin.id}`, + plugin.latest_version, + ) + } catch { + // ignore storage errors + } + } + onUpdated(updated) + onOpenChange(false) + toast.success( + `Updated ${plugin.name} to v${plugin.latest_version ?? updated.version}`, + ) + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to update plugin" + setError(message) + } finally { + setUpdating(false) + } + } + + const updateDisabled = + updating || previewLoading || !manifestUrl + + return ( + + + + Update {plugin.name} + +
+ v{plugin.version} + + + v{plugin.latest_version ?? "unknown"} + +
+
+
+ +
+ {newRequiredSecrets.length > 0 && ( +
+
+ +
+

+ This update requires new configuration. After updating, + you'll need to set these secrets. +

+
    + {newRequiredSecrets.map((secret) => ( +
  • + {secret.name} + + {" "} + ({secret.key}) + +
  • + ))} +
+
+
+
+ )} + + {hasReleaseNotes ? ( +
+
+ {pendingReleases.map((release) => ( +
+

+ v{release.version} ·{" "} + {new Date(release.published_at).toLocaleDateString()} +

+
+ + {release.body} + +
+
+ ))} +
+
+ ) : ( +

+ No release notes available. +

+ )} + + {error && ( +
+ {error} +
+ )} +
+ + + + + +
+
+ ) +} diff --git a/web/src/components/plugin-update-provider.tsx b/web/src/components/plugin-update-provider.tsx new file mode 100644 --- /dev/null +++ b/web/src/components/plugin-update-provider.tsx @@ -0,0 +1,118 @@ +"use client" + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react" +import { useRouter } from "next/navigation" +import semver from "semver" +import { toast } from "sonner" +import { getPlugins, type PluginSummary } from "@/lib/api" + +interface PluginUpdatesContextValue { + plugins: PluginSummary[] + hasUpdates: boolean + refresh: () => Promise + markSeen: (id: string, version: string) => void +} + +const PluginUpdatesContext = createContext(null) + +const STORAGE_PREFIX = "happyview:plugin-update-seen:" + +function storageKey(id: string) { + return `${STORAGE_PREFIX}${id}` +} + +function readSeen(id: string): string | null { + if (typeof window === "undefined") return null + try { + return window.localStorage.getItem(storageKey(id)) + } catch { + return null + } +} + +function writeSeen(id: string, version: string) { + if (typeof window === "undefined") return + try { + window.localStorage.setItem(storageKey(id), version) + } catch { + // ignore + } +} + +function isNewerThanSeen(latest: string, seen: string | null): boolean { + if (!seen) return true + const a = semver.coerce(latest) + const b = semver.coerce(seen) + if (!a || !b) return latest !== seen + return semver.gt(a, b) +} + +export function PluginUpdateProvider({ children }: { children: ReactNode }) { + const router = useRouter() + const [plugins, setPlugins] = useState([]) + + const refresh = useCallback(async () => { + try { + const res = await getPlugins() + setPlugins(res.plugins) + } catch { + // ignore — dashboard may not be ready + } + }, []) + + const markSeen = useCallback((id: string, version: string) => { + writeSeen(id, version) + }, []) + + useEffect(() => { + refresh() + const id = setInterval(refresh, 60_000) + return () => clearInterval(id) + }, [refresh]) + + useEffect(() => { + for (const p of plugins) { + if (!p.update_available || !p.latest_version) continue + const seen = readSeen(p.id) + if (!isNewerThanSeen(p.latest_version, seen)) continue + toast(`${p.name} v${p.latest_version} is available`, { + action: { + label: "Review", + onClick: () => + router.push(`/dashboard/settings/plugins?update=${encodeURIComponent(p.id)}`), + }, + }) + writeSeen(p.id, p.latest_version) + } + }, [plugins, router]) + + const value = useMemo( + () => ({ + plugins, + hasUpdates: plugins.some((p) => p.update_available), + refresh, + markSeen, + }), + [plugins, refresh, markSeen], + ) + + return ( + {children} + ) +} + +export function usePluginUpdates() { + const ctx = useContext(PluginUpdatesContext) + if (!ctx) { + throw new Error("usePluginUpdates must be used within PluginUpdateProvider") + } + return ctx +} diff --git a/web/src/hooks/use-official-plugins.ts b/web/src/hooks/use-official-plugins.ts new file mode 100644 --- /dev/null +++ b/web/src/hooks/use-official-plugins.ts @@ -0,0 +1,34 @@ +"use client" + +import { useCallback, useEffect, useMemo, useState } from "react" +import { getOfficialPlugins, type OfficialPluginSummary } from "@/lib/api" + +export function useOfficialPlugins() { + const [plugins, setPlugins] = useState([]) + const [loading, setLoading] = useState(true) + + const refresh = useCallback(async () => { + try { + const res = await getOfficialPlugins() + setPlugins(res.plugins) + } catch { + // swallow — registry may be empty on startup + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + refresh() + const id = setInterval(refresh, 60_000) + return () => clearInterval(id) + }, [refresh]) + + const byId = useMemo(() => { + const map = new Map() + for (const p of plugins) map.set(p.id, p) + return map + }, [plugins]) + + return { plugins, byId, loading, refresh } +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -467,8 +467,18 @@ } // Plugins -import type { PluginSummary, PluginsListResponse } from "@/types/plugins" -export type { PluginSummary, PluginsListResponse } from "@/types/plugins" +import type { + PluginSummary, + PluginsListResponse, + OfficialPluginsListResponse, +} from "@/types/plugins" +export type { + PluginSummary, + PluginsListResponse, + OfficialPluginSummary, + OfficialPluginsListResponse, + ReleaseEntry, +} from "@/types/plugins" export function getPlugins() { return apiFetch("/admin/plugins") @@ -487,9 +497,23 @@ }) } -export function reloadPlugin(id: string) { +export function reloadPlugin(id: string, body?: { url?: string }) { return apiFetch( `/admin/plugins/${encodeURIComponent(id)}/reload`, + { + method: "POST", + body: body ? JSON.stringify(body) : undefined, + }, + ) +} + +export function getOfficialPlugins() { + return apiFetch("/admin/plugins/official") +} + +export function checkPluginUpdate(id: string) { + return apiFetch( + `/admin/plugins/${encodeURIComponent(id)}/check-update`, { method: "POST" }, ) } @@ -530,9 +554,10 @@ wasm_url: string } -export function previewPlugin(url: string) { +export function previewPlugin(url: string, signal?: AbortSignal) { return apiFetch("/admin/plugins/preview", { method: "POST", body: JSON.stringify({ url }), + signal, }) } diff --git a/web/src/types/plugins.ts b/web/src/types/plugins.ts --- a/web/src/types/plugins.ts +++ b/web/src/types/plugins.ts @@ -4,6 +4,13 @@ description: string | null; } +export interface ReleaseEntry { + version: string; + name: string; + published_at: string; + body: string; +} + export interface PluginSummary { id: string; name: string; @@ -16,9 +23,26 @@ required_secrets: SecretDefinition[]; secrets_configured: boolean; loaded_at: string | null; + update_available: boolean; + latest_version: string | null; + pending_releases: ReleaseEntry[]; } export interface PluginsListResponse { plugins: PluginSummary[]; encryption_configured: boolean; +} + +export interface OfficialPluginSummary { + id: string; + name: string; + description: string | null; + icon_url: string | null; + latest_version: string; + manifest_url: string; +} + +export interface OfficialPluginsListResponse { + plugins: OfficialPluginSummary[]; + last_refreshed_at: string | null; } diff --git a/web/src/app/dashboard/layout.tsx b/web/src/app/dashboard/layout.tsx --- a/web/src/app/dashboard/layout.tsx +++ b/web/src/app/dashboard/layout.tsx @@ -6,7 +6,9 @@ import { useAuth } from "@/lib/auth-context" import { useConfig } from "@/lib/config-context" import { AppSidebar } from "@/components/app-sidebar" +import { PluginUpdateProvider } from "@/components/plugin-update-provider" import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar" +import { Toaster } from "@/components/ui/sonner" export default function DashboardLayout({ children, @@ -30,16 +32,19 @@ if (!did) return null return ( - - - {children} - + + + + {children} + + + ) } diff --git a/web/src/app/dashboard/settings/plugins/page.tsx b/web/src/app/dashboard/settings/plugins/page.tsx --- a/web/src/app/dashboard/settings/plugins/page.tsx +++ b/web/src/app/dashboard/settings/plugins/page.tsx @@ -1,16 +1,30 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; -import { Plus, Trash2, RefreshCw, ExternalLink, Settings, Loader2, AlertTriangle, CheckCircle2, AlertCircle } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { Plus, Trash2, RefreshCw, ExternalLink, Settings, Loader2, AlertTriangle, CheckCircle2, AlertCircle, ArrowUpCircle, Search } from "lucide-react"; import { useCurrentUser } from "@/hooks/use-current-user"; -import { getPlugins, addPlugin, removePlugin, reloadPlugin, getPluginSecrets, updatePluginSecrets, previewPlugin, type PluginPreview } from "@/lib/api"; +import { useOfficialPlugins } from "@/hooks/use-official-plugins"; +import { getPlugins, addPlugin, removePlugin, reloadPlugin, getPluginSecrets, updatePluginSecrets, previewPlugin, checkPluginUpdate, type PluginPreview } from "@/lib/api"; import type { PluginSummary } from "@/types/plugins"; +import { PluginUpdateDialog } from "@/components/plugin-update-dialog"; import { SiteHeader } from "@/components/site-header"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; +import { + Command, + CommandGroup, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverAnchor, + PopoverContent, +} from "@/components/ui/popover"; import { Table, TableBody, @@ -30,6 +44,15 @@ ResponsiveDialogTrigger, } from "@/components/ui/responsive-dialog"; +function isValidHttpUrl(value: string): boolean { + try { + const parsed = new URL(value); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + function formatAuthType(authType: string): string { const formats: Record = { oauth2: "OAuth 2.0", @@ -46,13 +69,37 @@ const [error, setError] = useState(null); const [reloading, setReloading] = useState(null); const [removing, setRemoving] = useState(null); + const [updateDialogPlugin, setUpdateDialogPlugin] = useState(null); + const [updateDialogOpen, setUpdateDialogOpen] = useState(false); + const [checkingUpdate, setCheckingUpdate] = useState(null); + + const searchParams = useSearchParams(); // Add plugin dialog state const [addOpen, setAddOpen] = useState(false); const [newUrl, setNewUrl] = useState(""); const [adding, setAdding] = useState(false); - const [previewing, setPreviewing] = useState(false); const [pluginPreview, setPluginPreview] = useState(null); + const [comboboxOpen, setComboboxOpen] = useState(false); + const [selectedManifestUrl, setSelectedManifestUrl] = useState( + null, + ); + + const { plugins: officialPlugins, loading: officialLoading } = useOfficialPlugins(); + + const filteredOfficialPlugins = useMemo(() => { + const q = newUrl.trim().toLowerCase(); + if (!q) return officialPlugins; + return officialPlugins.filter( + (p) => + p.id.toLowerCase().includes(q) || + p.name.toLowerCase().includes(q), + ); + }, [officialPlugins, newUrl]); + + const showCombobox = officialLoading || officialPlugins.length > 0; + const hasComboboxResults = + officialLoading || filteredOfficialPlugins.length > 0; // Configure secrets dialog state const [configOpen, setConfigOpen] = useState(false); @@ -78,20 +125,63 @@ load(); }, [load]); - async function handlePreview() { - if (!newUrl.trim()) return; + function openUpdateDialog(plugin: PluginSummary) { + setUpdateDialogPlugin(plugin); + setUpdateDialogOpen(true); + } - setPreviewing(true); + async function handleCheckUpdate(plugin: PluginSummary) { + setCheckingUpdate(plugin.id); setError(null); try { - const preview = await previewPlugin(newUrl.trim()); - setPluginPreview(preview); + await checkPluginUpdate(plugin.id); + await load(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { - setPreviewing(false); + setCheckingUpdate(null); } } + + useEffect(() => { + const updateId = searchParams.get("update"); + if (!updateId || plugins.length === 0) return; + const target = plugins.find((p) => p.id === updateId); + if (target && target.update_available) { + openUpdateDialog(target); + } + }, [searchParams, plugins]); + + const newUrlIsUrl = isValidHttpUrl(newUrl.trim()); + const effectivePreviewUrl = selectedManifestUrl + ? selectedManifestUrl + : newUrlIsUrl + ? newUrl.trim() + : null; + + useEffect(() => { + if (!addOpen) return; + if (!effectivePreviewUrl) { + setPluginPreview(null); + return; + } + const controller = new AbortController(); + const timer = setTimeout(async () => { + try { + const preview = await previewPlugin( + effectivePreviewUrl, + controller.signal, + ); + if (!controller.signal.aborted) setPluginPreview(preview); + } catch { + // fail silently + } + }, 500); + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [addOpen, effectivePreviewUrl]); async function handleAdd() { if (!pluginPreview) return; @@ -103,6 +193,7 @@ setAddOpen(false); setNewUrl(""); setPluginPreview(null); + setSelectedManifestUrl(null); load(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); @@ -115,6 +206,8 @@ setAddOpen(false); setNewUrl(""); setPluginPreview(null); + setSelectedManifestUrl(null); + setComboboxOpen(false); setError(null); } @@ -220,125 +313,194 @@ - - {pluginPreview ? `Install ${pluginPreview.name}?` : "Add Plugin"} - + Add Plugin - {pluginPreview - ? "Review the plugin details below before installing." - : "Enter a plugin URL to preview its details."} + Select an official plugin or enter a plugin URL. - {!pluginPreview ? ( - // Step 1: Enter URL -
-
- - setNewUrl(e.target.value)} - disabled={previewing} - /> -

- Link to the .wasm file or manifest.json (GitHub Releases URL) -

-
-
- ) : ( - // Step 2: Show preview -
-
- {pluginPreview.icon_url && ( - +
+ + {showCombobox ? ( + + + { + setNewUrl(e.target.value); + setSelectedManifestUrl(null); + setComboboxOpen(true); + }} + onFocus={() => setComboboxOpen(true)} + autoComplete="off" + /> + + e.preventDefault()} + onInteractOutside={(e) => { + // Don't close when clicking the input itself + const target = e.target as Node; + if ( + target instanceof Element && + target.id === "url" + ) { + e.preventDefault(); + } + }} + > + + + {officialLoading ? ( + + + + Loading plugins… + + + ) : ( + + {filteredOfficialPlugins.map((p) => ( + { + setNewUrl(p.name); + setSelectedManifestUrl( + p.manifest_url, + ); + setComboboxOpen(false); + }} + > + {p.icon_url ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
+ )} +
+
+ + {p.name} + + + v{p.latest_version} + +
+ {p.description && ( + + {p.description} + + )} +
+ + ))} + + )} + + + + + ) : ( + { + setNewUrl(e.target.value); + setSelectedManifestUrl(null); + }} /> )} -
-

{pluginPreview.name}

-

- {pluginPreview.description || `Version ${pluginPreview.version}`} -

-
- {pluginPreview.version} -
- -
-
- Auth Type - - {formatAuthType(pluginPreview.auth_type)} - -
- {pluginPreview.required_secrets.length > 0 && ( -
- Required Configuration -
- {pluginPreview.required_secrets.map((secret) => ( -
-
- {secret.name} - {secret.key} -
- {secret.description && ( -

{secret.description}

- )} -
- ))} -
-
- )} -
+

+ Link to the .wasm file or manifest.json (GitHub Releases URL) +

- )} + + {pluginPreview && ( +
+
+ {pluginPreview.icon_url && ( + + )} +
+

{pluginPreview.name}

+

+ {pluginPreview.description || `Version ${pluginPreview.version}`} +

+
+ {pluginPreview.version} +
+ +
+
+ Auth Type + + {formatAuthType(pluginPreview.auth_type)} + +
+ {pluginPreview.required_secrets.length > 0 && ( +
+ Required Configuration +
+ {pluginPreview.required_secrets.map((secret) => ( +
+
+ {secret.name} + {secret.key} +
+ {secret.description && ( +

{secret.description}

+ )} +
+ ))} +
+
+ )} +
+
+ )} +
- {pluginPreview ? ( - <> - - - - ) : ( - <> - - - - - - )} + + + + @@ -418,6 +580,31 @@
+ {canCreate && plugin.update_available && ( + + )} + {canCreate && ( + + )} {canCreate && plugin.required_secrets?.length > 0 && (
);