From 346462e9c3afbe597707b16ea80f65054fa01afb Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 11 Sep 2026 19:59:17 +0800 Subject: [PATCH 1/3] fix(todos): prevent calendar cells overlapping after resize --- src/web-ui/src/app/scenes/todos/TodosScene.scss | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/web-ui/src/app/scenes/todos/TodosScene.scss b/src/web-ui/src/app/scenes/todos/TodosScene.scss index e295035177..e3076ce6ce 100644 --- a/src/web-ui/src/app/scenes/todos/TodosScene.scss +++ b/src/web-ui/src/app/scenes/todos/TodosScene.scss @@ -426,6 +426,7 @@ $todos-compact-breakpoint: 720px; display: flex; flex: 1 1 auto; flex-direction: column; + container-type: inline-size; gap: var(--openbitfun-space-3); min-width: 0; min-height: 0; @@ -464,7 +465,10 @@ $todos-compact-breakpoint: 720px; &__calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); - grid-template-rows: repeat(6, auto); + // WebKit can retain auto row heights after aspect-ratio cells resize. + // Size the tracks from the current calendar width, including column gaps, + // and let cells stretch into them so rows and cells always resize together. + grid-template-rows: repeat(6, calc((100cqi - 6 * var(--openbitfun-space-2)) / 7 / 1.06)); gap: var(--openbitfun-space-2); flex: 0 0 auto; min-height: 0; @@ -477,7 +481,6 @@ $todos-compact-breakpoint: 720px; gap: var(--openbitfun-space-1); min-width: 0; min-height: 0; - aspect-ratio: 1.06 / 1; padding: var(--openbitfun-space-2); border: 1px solid transparent; border-radius: var(--openbitfun-radius-base); @@ -1054,7 +1057,7 @@ $todos-compact-breakpoint: 720px; } &__calendar-grid { - grid-template-rows: repeat(6, auto); + grid-template-rows: repeat(6, calc((100cqi - 6 * var(--openbitfun-space-1)) / 7 / 1.06)); min-height: 0; gap: var(--openbitfun-space-1); } From 90b882f570a9dec544bf8baa7a9edde3bb1cf149 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 11 Sep 2026 20:34:36 +0800 Subject: [PATCH 2/3] fix(marketplace): persist public images and smooth catalog updates --- docs/interactive-capabilities/README.md | 4 +- .../technical/tauri-command-map.json | 20 +- src/apps/desktop/src/api/market_image_api.rs | 41 +++ src/apps/desktop/src/api/mod.rs | 1 + src/apps/desktop/src/lib.rs | 1 + .../generated/remote-surface-registry.json | 14 +- .../src/remote_surface/table.rs | 1 + .../services/services-integrations/AGENTS.md | 7 + .../src/appearance_market/client.rs | 9 +- .../services/services-integrations/src/lib.rs | 3 + .../services-integrations/src/market_image.rs | 331 ++++++++++++++++++ .../src/miniapp_market/client.rs | 9 +- .../interactive-capabilities/catalog.json | 6 + .../GalleryLayout/GalleryLayout.appearance.ts | 2 +- .../components/GalleryLayout/MarketImage.scss | 14 + .../GalleryLayout/MarketImage.test.tsx | 51 +++ .../components/GalleryLayout/MarketImage.tsx | 99 ++++++ .../GalleryLayout/MarketList.test.tsx | 84 +++++ .../components/GalleryLayout/MarketList.tsx | 77 ++++ .../MiniAppCard.presentation.test.ts | 4 +- .../miniapps/components/MiniAppLibraryRow.tsx | 21 +- .../miniapps/views/MiniAppLibraryView.tsx | 82 +++-- .../views/miniAppLibraryItems.test.ts | 13 +- .../miniapps/views/miniAppLibraryItems.ts | 5 +- .../api/generated/remoteSurface.ts | 3 +- .../api/service-api/AppearanceMarketAPI.ts | 20 +- .../service-api/MarketCatalogCache.test.ts | 101 ++++++ .../api/service-api/MarketCatalogCache.ts | 79 +++++ .../api/service-api/MarketImageCache.test.ts | 57 +++ .../api/service-api/MarketImageCache.ts | 75 ++++ .../api/service-api/MiniAppMarketAPI.ts | 29 +- .../AppearanceMarketDialog.test.tsx | 40 +++ .../components/AppearanceMarketDialog.tsx | 62 ++-- .../components/AppearanceSettingsPage.scss | 7 - 34 files changed, 1279 insertions(+), 93 deletions(-) create mode 100644 src/apps/desktop/src/api/market_image_api.rs create mode 100644 src/crates/services/services-integrations/src/market_image.rs create mode 100644 src/web-ui/src/app/components/GalleryLayout/MarketImage.scss create mode 100644 src/web-ui/src/app/components/GalleryLayout/MarketImage.test.tsx create mode 100644 src/web-ui/src/app/components/GalleryLayout/MarketImage.tsx create mode 100644 src/web-ui/src/app/components/GalleryLayout/MarketList.test.tsx create mode 100644 src/web-ui/src/app/components/GalleryLayout/MarketList.tsx create mode 100644 src/web-ui/src/infrastructure/api/service-api/MarketCatalogCache.test.ts create mode 100644 src/web-ui/src/infrastructure/api/service-api/MarketCatalogCache.ts create mode 100644 src/web-ui/src/infrastructure/api/service-api/MarketImageCache.test.ts create mode 100644 src/web-ui/src/infrastructure/api/service-api/MarketImageCache.ts diff --git a/docs/interactive-capabilities/README.md b/docs/interactive-capabilities/README.md index 41201e92aa..697bb000d5 100644 --- a/docs/interactive-capabilities/README.md +++ b/docs/interactive-capabilities/README.md @@ -27,9 +27,9 @@ OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, a - Generated per-item interaction audit: `docs/interactive-capabilities/technical/product-control-open-audit.json` - Generated low-level audit map: `docs/interactive-capabilities/technical/tauri-command-map.json` -说明书、网站、搜索和 Agent 只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **643** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 +说明书、网站、搜索和 Agent 只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **644** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 -Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **643** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. +Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **644** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. ## 控制边界 / Control boundary diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index a797664bdc..d4b8cd309f 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -2,12 +2,12 @@ "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", "catalogDigest": "896281a3cd5cac2b50ec607988e04624224444911ddb2d58ba59a5d7d06493c5", - "commandCount": 643, + "commandCount": 644, "coverage": { - "commandCount": 643, + "commandCount": 644, "documentedCommandCount": 607, - "implementationCommandCount": 36, - "implementationDigest": "c9bbc0f2ca3695d8c18cff285386edeba7bcf3b11120adb57509a80b09ee3297" + "implementationCommandCount": 37, + "implementationDigest": "3c5e64b6ff16d55d95c2a6b4f77046be009a554b395b4a52bf8ed7c162ab1093" }, "commands": [ { @@ -5240,6 +5240,18 @@ "signature": "fn mark_openbitfun_control_surface_unready()", "remoteWorkspacePolicy": "WorkspaceAgnostic" }, + { + "id": "market_image_load", + "moduleId": "market_image", + "capabilityId": null, + "capabilityIds": [], + "documentedItemIds": [], + "visibility": "internal", + "rustPath": "api::market_image_api::market_image_load", + "sourceFile": "src/apps/desktop/src/api/market_image_api.rs", + "signature": "fn market_image_load( app: tauri::AppHandle, request: MarketImageRequest, ) -> Result", + "remoteWorkspacePolicy": "WorkspaceAgnostic" + }, { "id": "miniapp_agent_cancel", "moduleId": "miniapp_agent", diff --git a/src/apps/desktop/src/api/market_image_api.rs b/src/apps/desktop/src/api/market_image_api.rs new file mode 100644 index 0000000000..ea3c80d9e8 --- /dev/null +++ b/src/apps/desktop/src/api/market_image_api.rs @@ -0,0 +1,41 @@ +//! Controller-owned public image cache shared by the two marketplace surfaces. + +use openbitfun_services_integrations::{ + appearance_market::AppearanceMarketClient, market_image::MarketImageCache, + miniapp_market::MarketClient, +}; +use serde::Deserialize; +use tauri::Manager; +use tokio::sync::OnceCell; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MarketImageRequest { + pub source: String, +} + +#[tauri::command] +pub async fn market_image_load( + app: tauri::AppHandle, + request: MarketImageRequest, +) -> Result { + static CACHE: OnceCell = OnceCell::const_new(); + let cache = CACHE + .get_or_try_init(|| async { + let root = app + .path() + .app_cache_dir() + .map_err(|error| error.to_string())? + .join("market-images-v1"); + MarketImageCache::new( + root, + &MarketClient::configured_base_url(), + &AppearanceMarketClient::configured_base_url(), + ) + }) + .await?; + cache + .load(&request.source) + .await + .map(tauri::ipc::Response::new) +} diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index a8aeb0272a..49e6c4c170 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -32,6 +32,7 @@ pub mod git_api; pub mod html_preview_api; pub mod i18n_api; pub mod insights_api; +pub mod market_image_api; pub mod mcp_api; pub mod miniapp_agent_api; pub mod miniapp_api; diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index f83fb1f480..fc294e88b7 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1939,6 +1939,7 @@ pub async fn run() { api::miniapp_api::miniapp_get_customization_metadata, api::miniapp_api::miniapp_decline_builtin_update, api::miniapp_market_api::miniapp_market_browse, + api::market_image_api::market_image_load, api::miniapp_market_api::miniapp_market_get_listing, api::miniapp_market_api::miniapp_market_capture_window, api::miniapp_market_api::miniapp_market_set_rating, diff --git a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json index c69940ecca..598a8cf734 100644 --- a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json +++ b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "digest": "fnv1a64:57f26e05119c5457", + "digest": "fnv1a64:f94e0d96f655c86c", "retiredCommandPrefixes": [ { "prefix": "lsp_", @@ -4114,6 +4114,18 @@ "reason": "the controller keeps this command; peer hosts refuse it before dispatch" } }, + { + "id": "market_image_load", + "surface": "tauri_command", + "remoteWorkspace": "WorkspaceAgnostic", + "peer": { + "kind": "controller_local" + }, + "cliPeer": { + "kind": "unsupported", + "reason": "the controller keeps this command; peer hosts refuse it before dispatch" + } + }, { "id": "miniapp_agent_cancel", "surface": "tauri_command", diff --git a/src/crates/contracts/product-domains/src/remote_surface/table.rs b/src/crates/contracts/product-domains/src/remote_surface/table.rs index 571114d43c..53f0d4813f 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/table.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/table.rs @@ -428,6 +428,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("mark_announcement_seen", Agnostic, ControllerLocal, REFUSED), op("mark_openbitfun_control_surface_ready", Agnostic, ControllerLocal, REFUSED), op("mark_openbitfun_control_surface_unready", Agnostic, ControllerLocal, REFUSED), + op("market_image_load", Agnostic, ControllerLocal, REFUSED), op("miniapp_agent_cancel", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("miniapp_agent_cancel_stale_runs", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("miniapp_agent_ensure_session", LocalOnly, Proxied, CLI_NOT_IMPLEMENTED), diff --git a/src/crates/services/services-integrations/AGENTS.md b/src/crates/services/services-integrations/AGENTS.md index d061e37024..4eef21e403 100644 --- a/src/crates/services/services-integrations/AGENTS.md +++ b/src/crates/services/services-integrations/AGENTS.md @@ -135,6 +135,13 @@ pnpm run check:core-boundaries Other family-specific targets remain in `Cargo.toml`; add a guide command only for a recurring workflow, not to mirror every test target. +For persistent public marketplace image caching (restart/offline reuse, URL +boundaries, and bounded disk storage), run: + +```bash +cargo test --locked -p openbitfun-services-integrations --no-default-features --features miniapp-market --lib market_image::tests +``` + On Windows with an initialized WSL distribution, set `OPENBITFUN_TEST_WSL_DISTRO` and run `cargo test -p openbitfun-services-integrations --no-default-features --features remote-ssh-concrete --lib wsl_windows_workspace_transport -- --ignored` diff --git a/src/crates/services/services-integrations/src/appearance_market/client.rs b/src/crates/services/services-integrations/src/appearance_market/client.rs index 81bbaf8386..2d6843c147 100644 --- a/src/crates/services/services-integrations/src/appearance_market/client.rs +++ b/src/crates/services/services-integrations/src/appearance_market/client.rs @@ -36,11 +36,14 @@ pub struct AppearanceMarketClient { } impl AppearanceMarketClient { + pub fn configured_base_url() -> String { + std::env::var("OPENBITFUN_APPEARANCE_MARKET_API_URL") + .unwrap_or_else(|_| DEFAULT_APPEARANCE_MARKET_API_URL.to_string()) + } + pub async fn from_environment() -> Result { - let base_url = std::env::var("OPENBITFUN_APPEARANCE_MARKET_API_URL") - .unwrap_or_else(|_| DEFAULT_APPEARANCE_MARKET_API_URL.to_string()); let identity = AccountIdentityClient::from_environment().await?; - Self::with_identity(base_url, identity) + Self::with_identity(Self::configured_base_url(), identity) } pub async fn new( diff --git a/src/crates/services/services-integrations/src/lib.rs b/src/crates/services/services-integrations/src/lib.rs index ad34ceac5d..339e1bc314 100644 --- a/src/crates/services/services-integrations/src/lib.rs +++ b/src/crates/services/services-integrations/src/lib.rs @@ -70,6 +70,9 @@ pub mod miniapp; #[cfg(feature = "miniapp-market")] pub mod miniapp_market; +#[cfg(feature = "miniapp-market")] +pub mod market_image; + #[cfg(feature = "plugin-source")] pub mod plugin_source; diff --git a/src/crates/services/services-integrations/src/market_image.rs b/src/crates/services/services-integrations/src/market_image.rs new file mode 100644 index 0000000000..3b54f3d8ab --- /dev/null +++ b/src/crates/services/services-integrations/src/market_image.rs @@ -0,0 +1,331 @@ +//! Persistent cache for public, content-addressed marketplace WebP images. +//! Hosts supply their cache directory; no workspace or account data is cached. + +use reqwest::{header, Url}; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; +use tokio::sync::{Mutex, Semaphore}; + +const MAX_IMAGE_BYTES: usize = 20 * 1024 * 1024; +const CACHE_BUDGET_BYTES: u64 = 256 * 1024 * 1024; + +pub struct MarketImageCache { + root: PathBuf, + prefixes: Vec, + client: reqwest::Client, + downloads: Semaphore, + writer: Mutex<()>, +} + +impl MarketImageCache { + pub fn new(root: PathBuf, miniapp_api: &str, appearance_api: &str) -> Result { + let mut prefixes = Vec::new(); + for (base, path) in [ + (miniapp_api, "screenshots/"), + (appearance_api, "artifacts/previews/"), + ] { + let url = Url::parse(base).map_err(|error| error.to_string())?; + let loopback = matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]")); + if (url.scheme() != "https" && !(url.scheme() == "http" && loopback)) + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return Err("Marketplace image origins must use HTTPS or loopback HTTP.".into()); + } + prefixes.push(format!("{}/{path}", url.as_str().trim_end_matches('/'))); + } + let client = crate::reqwest_client_builder() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(20)) + .build() + .map_err(|error| error.to_string())?; + Ok(Self { + root, + prefixes, + client, + downloads: Semaphore::new(4), + writer: Mutex::new(()), + }) + } + + fn image_url(&self, source: &str) -> Result { + let url = Url::parse(source).map_err(|error| error.to_string())?; + let mut path = url.clone(); + path.set_query(None); + if url.fragment().is_some() + || !self.prefixes.iter().any(|prefix| { + path.as_str().strip_prefix(prefix).is_some_and(|hash| { + hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) + }) + { + return Err( + "Only public images from the configured marketplaces can be cached.".into(), + ); + } + let query: Vec<_> = url.query_pairs().collect(); + if query.len() > 1 + || query.iter().any(|(key, value)| { + key != "variant" || !matches!(value.as_ref(), "compact-v1" | "large-v1") + }) + { + return Err("Unsupported marketplace image variant.".into()); + } + Ok(url) + } + + pub async fn load(&self, source: &str) -> Result, String> { + let url = self.image_url(source)?; + // Include the origin and variant: separate deployments and resolutions + // must never alias, even when they reference the same source hash. + let key = hex::encode(Sha256::digest(url.as_str().as_bytes())); + let path = self.root.join(format!("{key}.webp")); + if let Some(bytes) = read_cached(&path).await { + return Ok(bytes); + } + + let _permit = self + .downloads + .acquire() + .await + .map_err(|error| error.to_string())?; + if let Some(bytes) = read_cached(&path).await { + return Ok(bytes); + } + let mut response = self + .client + .get(url) + .send() + .await + .map_err(|error| error.to_string())? + .error_for_status() + .map_err(|error| error.to_string())?; + if response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| value.split(';').next().unwrap_or("").trim()) + != Some("image/webp") + || response + .content_length() + .is_some_and(|size| size > MAX_IMAGE_BYTES as u64) + { + return Err("Marketplace image response has an unsupported type or size.".into()); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|error| error.to_string())? { + if bytes.len() + chunk.len() > MAX_IMAGE_BYTES { + return Err("Marketplace image exceeds the cache size limit.".into()); + } + bytes.extend_from_slice(&chunk); + } + if !is_webp(&bytes) { + return Err("Marketplace returned an invalid WebP image.".into()); + } + + // An unavailable/full cache must not hide a successfully fetched image. + let _writer = self.writer.lock().await; + if let Err(error) = self.store(&path, &bytes).await { + log::warn!("Failed to persist marketplace image cache: {error}"); + } + Ok(bytes) + } + + async fn store(&self, path: &Path, bytes: &[u8]) -> std::io::Result<()> { + tokio::fs::create_dir_all(&self.root).await?; + let temporary = self.root.join(format!("{}.part", uuid::Uuid::new_v4())); + if let Err(error) = async { + tokio::fs::write(&temporary, bytes).await?; + tokio::fs::rename(&temporary, path).await + } + .await + { + let _ = tokio::fs::remove_file(&temporary).await; + return Err(error); + } + prune_cache(&self.root, path, CACHE_BUDGET_BYTES).await + } +} + +fn is_webp(bytes: &[u8]) -> bool { + bytes.len() >= 12 + && bytes.starts_with(b"RIFF") + && &bytes[8..12] == b"WEBP" + && u32::from_le_bytes(bytes[4..8].try_into().unwrap()) as usize + 8 == bytes.len() +} + +async fn read_cached(path: &Path) -> Option> { + let metadata = tokio::fs::metadata(path).await.ok()?; + if !metadata.is_file() || metadata.len() > MAX_IMAGE_BYTES as u64 { + return None; + } + let bytes = tokio::fs::read(path).await.ok()?; + is_webp(&bytes).then_some(bytes) +} + +async fn prune_cache(root: &Path, keep: &Path, budget: u64) -> std::io::Result<()> { + let mut directory = tokio::fs::read_dir(root).await?; + let mut entries = Vec::new(); + let mut total = 0; + while let Some(entry) = directory.next_entry().await? { + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("webp") { + continue; + } + let metadata = entry.metadata().await?; + if metadata.is_file() { + total += metadata.len(); + entries.push(( + metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH), + path, + metadata.len(), + )); + } + } + entries.sort_by_key(|(modified, _, _)| *modified); + for (_, path, size) in entries { + if total <= budget { + break; + } + if path != keep { + tokio::fs::remove_file(path).await?; + total -= size; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + fn webp() -> Vec { + let mut bytes = std::io::Cursor::new(Vec::new()); + image::DynamicImage::new_rgb8(2, 2) + .write_to(&mut bytes, image::ImageFormat::WebP) + .unwrap(); + bytes.into_inner() + } + + async fn serve_once(body: Vec, headers: &str) -> (String, tokio::task::JoinHandle) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let headers = headers.to_string(); + let task = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = vec![0; 4096]; + let count = stream.read(&mut request).await.unwrap(); + let head = format!( + "HTTP/1.1 200 OK\r\n{headers}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream.write_all(head.as_bytes()).await.unwrap(); + stream.write_all(&body).await.unwrap(); + String::from_utf8_lossy(&request[..count]).into_owned() + }); + (base, task) + } + + #[tokio::test] + async fn cached_images_survive_a_new_client_with_the_server_offline() { + let storage = tempfile::tempdir().unwrap(); + let bytes = webp(); + let (base, server) = serve_once(bytes.clone(), "Content-Type: image/webp").await; + let source = format!("{base}/screenshots/{}?variant=compact-v1", "a".repeat(64)); + let cache = MarketImageCache::new(storage.path().into(), &base, &base).unwrap(); + assert_eq!(cache.load(&source).await.unwrap(), bytes); + assert!(server.await.unwrap().contains("variant=compact-v1")); + drop(cache); + let restarted = MarketImageCache::new(storage.path().into(), &base, &base).unwrap(); + assert_eq!(restarted.load(&source).await.unwrap(), bytes); + // A different resolution cannot accidentally consume the compact entry. + assert!(restarted + .load(&source.replace("compact-v1", "large-v1")) + .await + .is_err()); + } + + #[tokio::test] + async fn appearance_previews_use_the_same_cache_without_authentication() { + let storage = tempfile::tempdir().unwrap(); + let bytes = webp(); + let (base, server) = serve_once(bytes.clone(), "Content-Type: image/webp").await; + let source = format!( + "{base}/artifacts/previews/{}?variant=large-v1", + "b".repeat(64) + ); + let cache = MarketImageCache::new(storage.path().into(), &base, &base).unwrap(); + assert_eq!(cache.load(&source).await.unwrap(), bytes); + let request = server.await.unwrap().to_lowercase(); + assert!(!request.contains("authorization:")); + assert!(!request.contains("cookie:")); + assert_eq!(cache.load(&source).await.unwrap(), bytes); + } + + #[test] + fn rejects_arbitrary_origins_paths_and_personalized_urls() { + let cache = MarketImageCache::new( + PathBuf::new(), + "https://market.test/miniapp/api/v1", + "https://market.test/skin/api/v1", + ) + .unwrap(); + let path = format!("/miniapp/api/v1/screenshots/{}", "a".repeat(64)); + assert!(cache + .image_url(&format!("https://market.test{path}")) + .is_ok()); + for source in [ + format!("https://market.test.evil{path}"), + format!("http://market.test{path}"), + format!("https://market.test{path}?token=secret"), + format!("https://market.test{path}?variant=other"), + format!("https://market.test{path}?variant=compact-v1&variant=large-v1"), + format!("https://market.test{path}#fragment"), + "https://market.test/miniapp/api/v1/screenshots/../listings".into(), + "file:///etc/passwd".into(), + ] { + assert!(cache.image_url(&source).is_err(), "accepted {source}"); + } + } + + #[tokio::test] + async fn rejects_invalid_downloads_and_recovers_from_incomplete_cache_files() { + let storage = tempfile::tempdir().unwrap(); + let bytes = webp(); + let (base, server) = serve_once(bytes.clone(), "Content-Type: image/webp").await; + let source = format!("{base}/screenshots/{}", "c".repeat(64)); + let key = hex::encode(Sha256::digest(source.as_bytes())); + tokio::fs::write(storage.path().join(format!("{key}.webp")), &bytes[..12]) + .await + .unwrap(); + let cache = MarketImageCache::new(storage.path().into(), &base, &base).unwrap(); + assert_eq!(cache.load(&source).await.unwrap(), bytes); + server.await.unwrap(); + + let (base, server) = serve_once(b"error page".to_vec(), "Content-Type: image/webp").await; + let cache = MarketImageCache::new(storage.path().into(), &base, &base).unwrap(); + assert!(cache + .load(&format!("{base}/screenshots/{}", "d".repeat(64))) + .await + .is_err()); + server.await.unwrap(); + } + + #[tokio::test] + async fn pruning_keeps_the_new_image_and_stays_within_budget() { + let storage = tempfile::tempdir().unwrap(); + let keep = storage.path().join("new.webp"); + tokio::fs::write(storage.path().join("old.webp"), vec![0; 20]) + .await + .unwrap(); + tokio::fs::write(&keep, vec![0; 20]).await.unwrap(); + prune_cache(storage.path(), &keep, 20).await.unwrap(); + assert!(!storage.path().join("old.webp").exists()); + assert!(keep.exists()); + } +} diff --git a/src/crates/services/services-integrations/src/miniapp_market/client.rs b/src/crates/services/services-integrations/src/miniapp_market/client.rs index ac86698252..479370caea 100644 --- a/src/crates/services/services-integrations/src/miniapp_market/client.rs +++ b/src/crates/services/services-integrations/src/miniapp_market/client.rs @@ -61,10 +61,13 @@ pub struct MarketClient { } impl MarketClient { + pub fn configured_base_url() -> String { + std::env::var("OPENBITFUN_MINIAPP_MARKET_API_URL") + .unwrap_or_else(|_| DEFAULT_MARKET_API_URL.to_string()) + } + pub async fn from_environment() -> Result { - let base_url = std::env::var("OPENBITFUN_MINIAPP_MARKET_API_URL") - .unwrap_or_else(|_| DEFAULT_MARKET_API_URL.to_string()); - Self::new(base_url).await + Self::new(Self::configured_base_url()).await } pub async fn new(base_url: impl Into) -> Result { diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index 141f9c166f..8419880dfa 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -182,6 +182,9 @@ } ], "implementationOnlyCommands": { + "publicMarketImageCache": [ + "market_image_load" + ], "legacyUpdateCompatibility": [ "install_update" ], @@ -9372,6 +9375,9 @@ "miniapp_market": { "capabilityId": "feature.miniapps" }, + "market_image": { + "capabilityId": null + }, "acp_client": { "capabilityId": "setting.tools.acp" }, diff --git a/src/web-ui/src/app/components/GalleryLayout/GalleryLayout.appearance.ts b/src/web-ui/src/app/components/GalleryLayout/GalleryLayout.appearance.ts index 7871753d92..577fdbcc19 100644 --- a/src/web-ui/src/app/components/GalleryLayout/GalleryLayout.appearance.ts +++ b/src/web-ui/src/app/components/GalleryLayout/GalleryLayout.appearance.ts @@ -1,2 +1,2 @@ import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; -export const galleryLayoutAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'gallery-layout', parts: [{ id: 'root' }, { id: 'body' }, { id: 'content' }] }; +export const galleryLayoutAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'gallery-layout', parts: [{ id: 'root' }, { id: 'body' }, { id: 'content' }, { id: 'marketImage' }] }; diff --git a/src/web-ui/src/app/components/GalleryLayout/MarketImage.scss b/src/web-ui/src/app/components/GalleryLayout/MarketImage.scss new file mode 100644 index 0000000000..e14af797b7 --- /dev/null +++ b/src/web-ui/src/app/components/GalleryLayout/MarketImage.scss @@ -0,0 +1,14 @@ +.market-image { + opacity: 0; + transition: opacity var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + + &[data-loaded] { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .market-image { + transition: none; + } +} diff --git a/src/web-ui/src/app/components/GalleryLayout/MarketImage.test.tsx b/src/web-ui/src/app/components/GalleryLayout/MarketImage.test.tsx new file mode 100644 index 0000000000..ab86f3d07f --- /dev/null +++ b/src/web-ui/src/app/components/GalleryLayout/MarketImage.test.tsx @@ -0,0 +1,51 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { MarketImage } from './MarketImage'; + +const cache = vi.hoisted(() => ({ acquireMarketImage: vi.fn(), canCacheMarketImage: () => true })); +vi.mock('@/infrastructure/api/service-api/MarketImageCache', () => cache); +let container: HTMLDivElement; +let root: ReturnType; +beforeEach(() => { + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + cache.acquireMarketImage.mockReset(); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); +}); +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); +}); + +it('ignores an old download after the server catalog replaces the image hash', async () => { + let finishOld!: (url: string) => void; + const releaseOld = vi.fn(); + const releaseNew = vi.fn(); + cache.acquireMarketImage.mockReturnValueOnce({ + url: new Promise(resolve => { finishOld = resolve; }), release: releaseOld, + }).mockReturnValueOnce({ url: Promise.resolve('blob:new'), release: releaseNew }); + await act(async () => root.render()); + await act(async () => root.render()); + expect(container.querySelector('img')?.src).toBe('blob:new'); + expect(releaseOld).toHaveBeenCalledOnce(); + await act(async () => finishOld('blob:old')); + expect(container.querySelector('img')?.src).toBe('blob:new'); + await act(async () => root.render(null)); + expect(releaseNew).toHaveBeenCalledOnce(); +}); + +it('keeps a cache hit mounted and revealed when only catalog metadata changes', async () => { + cache.acquireMarketImage.mockReturnValue({ + readyUrl: 'blob:cached', url: Promise.resolve('blob:cached'), release: vi.fn(), + }); + await act(async () => root.render()); + const image = container.querySelector('img')!; + await act(async () => image.dispatchEvent(new Event('load'))); + await act(async () => root.render()); + expect(container.querySelector('img')).toBe(image); + expect(image.dataset.loaded).toBe('true'); + expect(cache.acquireMarketImage).toHaveBeenCalledOnce(); +}); diff --git a/src/web-ui/src/app/components/GalleryLayout/MarketImage.tsx b/src/web-ui/src/app/components/GalleryLayout/MarketImage.tsx new file mode 100644 index 0000000000..eecc00b002 --- /dev/null +++ b/src/web-ui/src/app/components/GalleryLayout/MarketImage.tsx @@ -0,0 +1,99 @@ +import { useEffect, useLayoutEffect, useRef, useState, type ImgHTMLAttributes } from 'react'; +import { + marketImageSrcSet, marketImageUrl, retryOriginalMarketImage, type MarketImageVariant, +} from '@/infrastructure/api/service-api/MarketImage'; +import { acquireMarketImage, canCacheMarketImage } from '@/infrastructure/api/service-api/MarketImageCache'; +import './MarketImage.scss'; + +interface MarketImageProps extends Omit, 'src' | 'srcSet'> { + source: string; + variant?: MarketImageVariant; + responsive?: boolean; +} + +/** Fixed media geometry stays with the card; decode only reveals its pixels. */ +export function MarketImage(props: MarketImageProps) { + return ; +} + +function MarketImageContent({ + source, variant = 'compact-v1', responsive = false, onError, onLoad, className, + loading = 'lazy', decoding = 'async', ...props +}: MarketImageProps) { + const nativeCache = canCacheMarketImage(source); + const [resolvedVariant, setResolvedVariant] = useState( + nativeCache && responsive ? undefined : variant, + ); + const directUrl = marketImageUrl(source, resolvedVariant ?? variant); + const [src, setSrc] = useState(nativeCache ? undefined : directUrl); + const [loaded, setLoaded] = useState(false); + const ref = useRef(null); + + useLayoutEffect(() => { + if (!nativeCache || !responsive || !ref.current) return; + const image = ref.current; + const chooseVariant = () => { + const pixels = image.getBoundingClientRect().width * (window.devicePixelRatio || 1); + const next = pixels > 640 ? 'large-v1' : 'compact-v1'; + // Once a larger image is loaded, shrinking a window needs no new bytes. + setResolvedVariant(current => current === 'large-v1' ? current : next); + }; + chooseVariant(); + if (typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(chooseVariant); + observer.observe(image); + return () => observer.disconnect(); + }, [nativeCache, responsive]); + + useEffect(() => { + if (!nativeCache || !resolvedVariant) return; + let cancelled = false; + let lease: ReturnType | undefined; + const load = () => { + if (lease || cancelled) return; + lease = acquireMarketImage(source, resolvedVariant); + if (lease.readyUrl) setSrc(lease.readyUrl); + void lease.url.then(url => { + if (!cancelled) setSrc(url); + }).catch(() => { + if (!cancelled) setSrc(directUrl); + }); + }; + const image = ref.current; + let observer: IntersectionObserver | undefined; + if (image && loading === 'lazy' && typeof IntersectionObserver !== 'undefined') { + observer = new IntersectionObserver(entries => { + if (entries.some(entry => entry.isIntersecting)) { + observer?.disconnect(); + load(); + } + }, { rootMargin: '240px' }); + observer.observe(image); + } else load(); + return () => { + cancelled = true; + observer?.disconnect(); + lease?.release(); + }; + }, [directUrl, loading, nativeCache, source, resolvedVariant]); + + return { + setLoaded(true); + onLoad?.(event); + }} + onError={event => { + if (!retryOriginalMarketImage(event.currentTarget, source)) onError?.(event); + }} + />; +} diff --git a/src/web-ui/src/app/components/GalleryLayout/MarketList.test.tsx b/src/web-ui/src/app/components/GalleryLayout/MarketList.test.tsx new file mode 100644 index 0000000000..23e192d941 --- /dev/null +++ b/src/web-ui/src/app/components/GalleryLayout/MarketList.test.tsx @@ -0,0 +1,84 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { MarketList } from './MarketList'; + +describe('marketplace reorder motion', () => { + let container: HTMLDivElement; + let root: ReturnType; + let animations: Array<{ element: HTMLElement; frames: Keyframe[]; cancel: ReturnType }>; + let offsets: Map; + let reduced: boolean; + + beforeEach(() => { + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + reduced = false; offsets = new Map(); animations = []; + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: reduced }))); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + vi.spyOn(HTMLElement.prototype, 'getClientRects').mockReturnValue([{}] as unknown as DOMRectList); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function () { + const element = this as HTMLElement; + const index = element.dataset.marketKey ? [...element.parentElement!.children].indexOf(element) : 0; + const top = index * 100 + (offsets.get(element.dataset.marketKey ?? '') ?? 0); + return { x: 0, y: top, left: 0, top, width: 200, height: 80, bottom: top + 80, right: 200, toJSON: () => ({}) }; + }); + vi.stubGlobal('Animation', class {}); + Object.defineProperty(HTMLElement.prototype, 'animate', { configurable: true, value: vi.fn(function (frames: Keyframe[]) { + const element = this as HTMLElement; + const cancel = vi.fn(() => offsets.delete(element.dataset.marketKey!)); + animations.push({ element, frames, cancel }); + return { cancel, onfinish: null }; + }) }); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + delete (HTMLElement.prototype as any).animate; + vi.restoreAllMocks(); vi.unstubAllGlobals(); + }); + + async function render(keys: string[], animate = true) { + await act(async () => root.render( + + {keys.map(key => )} + , + )); + } + + it('keeps keyed nodes mounted and animates their displacement, not their dimensions', async () => { + await render(['a', 'b', 'c']); + const first = container.querySelector('[data-market-key="a"]'); + await render(['c', 'b', 'a']); + expect(container.querySelector('[data-market-key="a"]')).toBe(first); + expect(animations).toHaveLength(2); + expect(animations.find(item => item.element === first)?.frames) + .toEqual([{ transform: 'translate(0px, -200px)' }, { transform: 'translate(0, 0)' }]); + await render(['c', 'b', 'a']); + expect(animations).toHaveLength(2); + }); + + it('retargets an interrupted sort from the current on-screen position', async () => { + await render(['a', 'b']); + await render(['b', 'a']); + offsets.set('a', -40); // a is midway back from its old position. + offsets.set('b', 40); + await render(['a', 'b']); + expect(animations[0].cancel).toHaveBeenCalledOnce(); + expect(animations.filter(item => item.element.dataset.marketKey === 'a').at(-1)?.frames[0]) + .toEqual({ transform: 'translate(0px, 60px)' }); + }); + + it('leaves unchanged catalogs, reduced motion and keyboard-driven sorts instant', async () => { + await render(['a', 'b']); + await render(['a', 'b']); + reduced = true; + await render(['b', 'a']); + reduced = false; + await render(['a', 'b'], false); + expect(animations).toHaveLength(0); + }); +}); diff --git a/src/web-ui/src/app/components/GalleryLayout/MarketList.tsx b/src/web-ui/src/app/components/GalleryLayout/MarketList.tsx new file mode 100644 index 0000000000..1d71f2b720 --- /dev/null +++ b/src/web-ui/src/app/components/GalleryLayout/MarketList.tsx @@ -0,0 +1,77 @@ +import { Component, createRef, type HTMLAttributes, type ReactNode } from 'react'; +import { isReducedMotionPreferred } from '@/shared/utils/motionPreference'; + +interface MarketListProps extends HTMLAttributes { + children: ReactNode; + /** Stable item identities, in display order. Metadata refreshes need no motion. */ + revision: string; + animate?: boolean; +} +interface Position { left: number; top: number } +type Snapshot = Map | null; + +/** FLIP uses a pre-commit snapshot, so interrupted sorts start where cards are. */ +export class MarketList extends Component, Snapshot> { + private root = createRef(); + private animations = new Map(); + + getSnapshotBeforeUpdate(previous: MarketListProps): Snapshot { + const root = this.root.current; + if (!root || previous.revision === this.props.revision || !this.shouldAnimate()) return null; + const origin = root.getBoundingClientRect(); + return new Map([...root.querySelectorAll(':scope > [data-market-key]')].map(item => { + const rect = item.getBoundingClientRect(); + return [item.dataset.marketKey!, { left: rect.left - origin.left, top: rect.top - origin.top }]; + })); + } + + componentDidUpdate(_previous: MarketListProps, _state: Record, snapshot: Snapshot) { + // Snapshot includes the current visual transform. Cancel before measuring + // the new layout, then retarget from that visual position without snapping. + if (_previous.revision === this.props.revision) return; + this.cancelAnimations(); + const root = this.root.current; + if (!snapshot || !root || !this.shouldAnimate() || !root.getClientRects().length) return; + const origin = root.getBoundingClientRect(); + const style = getComputedStyle(root); + const durationToken = style.getPropertyValue('--openbitfun-motion-duration-normal').trim(); + const duration = durationToken.endsWith('ms') ? parseFloat(durationToken) : parseFloat(durationToken) * 1000; + const easing = style.getPropertyValue('--openbitfun-motion-easing-standard').trim() || 'ease-out'; + for (const item of root.querySelectorAll(':scope > [data-market-key]')) { + if (typeof item.animate !== 'function') continue; + const rect = item.getBoundingClientRect(); + if (rect.bottom <= 0 || rect.top >= window.innerHeight) continue; + const previous = snapshot.get(item.dataset.marketKey!); + const x = previous ? previous.left - (rect.left - origin.left) : 0; + const y = previous ? previous.top - (rect.top - origin.top) : 0; + if (previous && Math.abs(x) < 0.5 && Math.abs(y) < 0.5) continue; + const frames = previous + ? [{ transform: `translate(${x}px, ${y}px)` }, { transform: 'translate(0, 0)' }] + : [{ opacity: 0 }, { opacity: 1 }]; + const animation = item.animate(frames, { + duration: Number.isFinite(duration) ? Math.min(duration, 260) : 220, + easing, + }); + this.animations.set(item, animation); + animation.onfinish = () => { + if (this.animations.get(item) === animation) this.animations.delete(item); + }; + } + } + + componentWillUnmount() { this.cancelAnimations(); } + + private shouldAnimate() { + return this.props.animate !== false && !isReducedMotionPreferred(); + } + + private cancelAnimations() { + for (const animation of this.animations.values()) animation.cancel(); + this.animations.clear(); + } + + render() { + const { children, revision: _revision, animate: _animate, ...props } = this.props; + return
{children}
; + } +} diff --git a/src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.presentation.test.ts b/src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.presentation.test.ts index c2f92c3e97..10d7ac02a1 100644 --- a/src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.presentation.test.ts +++ b/src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.presentation.test.ts @@ -169,7 +169,7 @@ describe('Mini App card presentation', () => { const projection = readRelative('../views/miniAppLibraryItems.ts'); expect(projection).toMatch( - /key: `local:\$\{app\.id\}`,[\s\S]*?downloadCount: 0,[\s\S]*?ratingAverage: 3,/, + /action: 'open',[\s\S]*?downloadCount: 0,[\s\S]*?ratingAverage: 3,/, ); expect(source).toMatch( /downloadCount=\{item\.listing\s*\? formatNumber\(item\.downloadCount\)\s*: undefined\}/, @@ -185,7 +185,7 @@ describe('Mini App card presentation', () => { const stylesheet = readRelative('../views/MiniAppLibraryView.scss'); expect(library).toContain('getMiniAppShowcaseAsset(item.app.id)'); - expect(row).toContain('marketImageSrcSet(showcaseUrl)'); + expect(row).toMatch(/ = ({ + itemKey, action, actionDisabled = false, actionLabel, @@ -77,6 +75,7 @@ const MiniAppLibraryRow: React.FC = ({
= ({ data-openbitfun-part="showcase" > {!showcaseUnavailable && showcaseUrl ? ( - {showcaseAlt} { - if (!retryOriginalMarketImage(event.currentTarget, showcaseUrl)) { - setShowcaseUnavailable(true); - } - }} + onError={() => setShowcaseUnavailable(true)} /> ) : ( = ({ tabs }) => { +const MiniAppLibraryView: React.FC = (props) => { + const epoch = useSyncExternalStore(onSurfaceActivated, () => getActiveSurfaceScope().epoch); + return ; +}; + +const MiniAppLibraryContent: React.FC = ({ tabs }) => { + const [surfaceScope] = useState(getActiveSurfaceScope); const apps = useMiniAppStore((state) => state.apps); const loading = useMiniAppStore((state) => state.loading); const customizingAppIds = useMiniAppStore((state) => state.customizingAppIds); @@ -121,8 +126,9 @@ const MiniAppLibraryView: React.FC = ({ tabs }) => { const [query, setQuery] = useState(''); const [category, setCategory] = useState('all'); const [sort, setSort] = useState('downloads'); - const [marketItems, setMarketItems] = useState([]); - const [nextCursor, setNextCursor] = useState(); + const [cachedPage] = useState(() => miniAppMarketAPI.getCachedPage({ query: '', category: 'all', sort: 'downloads', limit: 30 })); + const [marketItems, setMarketItems] = useState(cachedPage?.items ?? []); + const [nextCursor, setNextCursor] = useState(cachedPage?.nextCursor); const [catalogLoading, setCatalogLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [catalogError, setCatalogError] = useState(); @@ -146,6 +152,7 @@ const MiniAppLibraryView: React.FC = ({ tabs }) => { const importTriggerRef = useRef(null); const importMenuRef = useRef(null); const catalogRequestRef = useRef(0); + const animateList = useRef(true); const detailRequestRef = useRef(0); const importMenuLayout = useAnchoredPopoverPosition({ @@ -207,9 +214,13 @@ const MiniAppLibraryView: React.FC = ({ tabs }) => { }, [activateScene, openScene, openTabIds]); const fetchCatalog = useCallback(async (cursor?: string, append = false) => { + if (!surfaceScope.isCurrent()) return; const requestId = ++catalogRequestRef.current; if (append) setLoadingMore(true); - else setCatalogLoading(true); + else { + setCatalogLoading(true); + setLoadingMore(false); + } setCatalogError(undefined); try { @@ -226,10 +237,7 @@ const MiniAppLibraryView: React.FC = ({ tabs }) => { } catch (loadError) { if (requestId !== catalogRequestRef.current) return; log.error('Failed to load MiniApp marketplace catalog', loadError); - if (!append) { - setMarketItems([]); - setNextCursor(undefined); - } + if (!append) setNextCursor(undefined); setCatalogError(String(loadError)); } finally { if (requestId === catalogRequestRef.current) { @@ -237,16 +245,21 @@ const MiniAppLibraryView: React.FC = ({ tabs }) => { setLoadingMore(false); } } - }, [category, query, sort]); + }, [category, query, sort, surfaceScope]); useEffect(() => { const timeout = window.setTimeout(() => { void fetchCatalog(undefined, false); }, 250); - return () => window.clearTimeout(timeout); + return () => { + window.clearTimeout(timeout); + // Invalidate before the debounce of a new filter, not after it fires. + catalogRequestRef.current += 1; + }; }, [fetchCatalog]); const refetchMiniAppLibrary = useCallback(async () => { + if (!surfaceScope.isCurrent()) return; setLoading(true); try { const [refreshed, running, origins] = await Promise.all([ @@ -254,16 +267,18 @@ const MiniAppLibraryView: React.FC = ({ tabs }) => { miniAppAPI.workerListRunning(), loadInstalledMarketOrigins(), ]); + if (!surfaceScope.isCurrent()) return; setApps(refreshed); setRunningWorkerIds(running); setMarketOrigins(origins); await fetchCatalog(undefined, false); } catch (error) { + if (!surfaceScope.isCurrent()) return; log.error('Failed to refresh MiniApp library', error); } finally { - setLoading(false); + if (surfaceScope.isCurrent()) setLoading(false); } - }, [fetchCatalog, setApps, setLoading, setMarketOrigins, setRunningWorkerIds]); + }, [fetchCatalog, setApps, setLoading, setMarketOrigins, setRunningWorkerIds, surfaceScope]); useGallerySceneAutoRefresh({ sceneId: 'miniapps', @@ -641,7 +656,11 @@ const MiniAppLibraryView: React.FC = ({ tabs }) => { return ( <> -
+ item.key))} + animate={animateList.current} + aria-busy={catalogLoading || loadingMore || undefined} + > {libraryItems.map((item) => { const source = item.listing ?? item.app; if (!source) return null; @@ -686,6 +705,7 @@ const MiniAppLibraryView: React.FC = ({ tabs }) => { return ( = ({ tabs }) => { /> ); })} -
+ {catalogLoading && marketItems.length === 0 ? ( = ({ tabs }) => { } - onValueChange={setQuery} + onValueChange={value => { + animateList.current = getInteractionMotion() === 'pointer'; + setQuery(value); + }} placeholder={t('searchPlaceholder')} aria-label={t('searchPlaceholder')} size="sm" @@ -850,7 +873,10 @@ const MiniAppLibraryView: React.FC = ({ tabs }) => { className="miniapp-gallery__categories" options={CATEGORIES.map((value) => ({ label: categoryLabel(value, t), value }))} value={category} - onValueChange={(value) => setCategory(value as MiniAppCategory)} + onValueChange={(value) => { + animateList.current = getInteractionMotion() === 'pointer'; + setCategory(value as MiniAppCategory); + }} aria-label={t('market.catalog')} size="sm" /> @@ -860,7 +886,10 @@ const MiniAppLibraryView: React.FC = ({ tabs }) => { size="sm" options={sortOptions} value={sort} - onValueChange={(value) => setSort(value as MarketSort)} + onValueChange={(value) => { + animateList.current = getInteractionMotion() === 'pointer'; + setSort(value as MarketSort); + }} aria-label={t('market.sortLabel')} /> @@ -982,17 +1011,16 @@ const MiniAppLibraryView: React.FC = ({ tabs }) => { {detail.screenshotUrls.length ? (
{detail.screenshotUrls.map((url, index) => ( - {t('market.library.showcaseAlt', retryOriginalMarketImage(event.currentTarget, url)} /> ))}
diff --git a/src/web-ui/src/app/scenes/miniapps/views/miniAppLibraryItems.test.ts b/src/web-ui/src/app/scenes/miniapps/views/miniAppLibraryItems.test.ts index 7081d80a81..190c8d0027 100644 --- a/src/web-ui/src/app/scenes/miniapps/views/miniAppLibraryItems.test.ts +++ b/src/web-ui/src/app/scenes/miniapps/views/miniAppLibraryItems.test.ts @@ -57,6 +57,17 @@ function origin(listingId: string, releaseNumber: number): InstalledMarketOrigin } describe('buildMiniAppLibraryItems', () => { + it('preserves install identity when metadata arrives without colliding with another copy', () => { + const apps = [app('first'), app('second')]; + const origins = { first: origin('shared', 1), second: origin('shared', 1) }; + const before = buildMiniAppLibraryItems([], apps, origins); + const after = buildMiniAppLibraryItems([listing('shared', 2)], apps, origins); + for (const items of [before, after]) { + expect(items.find(item => item.app?.id === 'first')?.key).toBe('market:shared'); + expect(items.find(item => item.app?.id === 'second')?.key).toBe('local:second'); + expect(new Set(items.map(item => item.key)).size).toBe(items.length); + } + }); it('joins installed marketplace apps and projects App Store actions', () => { const installed = app('local-market-id'); const result = buildMiniAppLibraryItems( @@ -154,7 +165,7 @@ describe('buildMiniAppLibraryItems', () => { expect(result.map((item) => item.key)).toEqual([ 'market:duplicate', - 'local:off-page-install', + 'market:off-page-listing', ]); expect(result[1]).toMatchObject({ action: 'open', diff --git a/src/web-ui/src/app/scenes/miniapps/views/miniAppLibraryItems.ts b/src/web-ui/src/app/scenes/miniapps/views/miniAppLibraryItems.ts index 6319a430f2..bf832a4b89 100644 --- a/src/web-ui/src/app/scenes/miniapps/views/miniAppLibraryItems.ts +++ b/src/web-ui/src/app/scenes/miniapps/views/miniAppLibraryItems.ts @@ -85,7 +85,10 @@ export function buildMiniAppLibraryItems( if (consumedAppIds.has(app.id)) continue; projected.push({ item: { - key: `local:${app.id}`, + // Keep a marketplace install's DOM identity when metadata arrives. + key: origins[app.id] && installedByListingId.get(origins[app.id].listingId)?.app.id === app.id + ? `market:${origins[app.id].listingId}` + : `local:${app.id}`, action: 'open', app, downloadCount: 0, diff --git a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts index 313b39daf4..7e2d6270a2 100644 --- a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts +++ b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts @@ -1,6 +1,6 @@ // Generated by scripts/generate-interactive-capabilities.mjs; do not edit. // Source: openbitfun_product_domains::remote_surface (Product Operation Registry). -export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:57f26e05119c5457" as const; +export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:f94e0d96f655c86c" as const; /** * Registered Tauri commands the Peer Device controller keeps on the controller @@ -94,6 +94,7 @@ export const PEER_CONTROLLER_LOCAL_COMMANDS: ReadonlySet = new Set([ "mark_announcement_seen", "mark_openbitfun_control_surface_ready", "mark_openbitfun_control_surface_unready", + "market_image_load", "minimize_to_tray", "never_show_announcement", "peer_control_attach", diff --git a/src/web-ui/src/infrastructure/api/service-api/AppearanceMarketAPI.ts b/src/web-ui/src/infrastructure/api/service-api/AppearanceMarketAPI.ts index 2397e49c88..2d8937ef56 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AppearanceMarketAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AppearanceMarketAPI.ts @@ -1,5 +1,6 @@ import { createTauriCommandError } from '../errors/TauriCommandError'; import { api } from './ApiClient'; +import { isMarketSummary, isStringArray, readMarketCatalog, writeMarketCatalog } from './MarketCatalogCache'; export type AppearanceMarketSort = 'newest' | 'downloads'; export type AppearanceMarketMode = 'light' | 'dark'; @@ -136,11 +137,28 @@ function isolatedArrayBuffer(value: ArrayBuffer | Uint8Array): ArrayBuffer { } export class AppearanceMarketAPI { + private catalogKey(request: AppearanceMarketBrowseRequest): string { + // Appearance browsing belongs to the controller even in Peer Device Mode. + return JSON.stringify(['local', 'appearance-market', request.query?.trim() ?? '', + request.mode ?? 'all', request.sort ?? 'newest', request.limit ?? 20]); + } + + getCachedPage(request: AppearanceMarketBrowseRequest): AppearanceMarketCursorPage | undefined { + if (request.cursor) return undefined; + return readMarketCatalog(this.catalogKey(request), (value): value is AppearanceMarketListingSummary => ( + isMarketSummary(value) + && ['packageId', 'packageVersion', 'previewUrl'].every(key => typeof value[key] === 'string') + && (value.mode === 'dark' || value.mode === 'light') && isStringArray(value.requiredCapabilities) + )); + } + async browse( request: AppearanceMarketBrowseRequest, ): Promise> { try { - return await api.invoke('appearance_market_browse', { request }); + const page = await api.invoke>('appearance_market_browse', { request }); + if (!request.cursor) writeMarketCatalog(this.catalogKey(request), page); + return page; } catch (error) { throw createTauriCommandError('appearance_market_browse', error, request); } diff --git a/src/web-ui/src/infrastructure/api/service-api/MarketCatalogCache.test.ts b/src/web-ui/src/infrastructure/api/service-api/MarketCatalogCache.test.ts new file mode 100644 index 0000000000..56cf0ec431 --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/MarketCatalogCache.test.ts @@ -0,0 +1,101 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { MiniAppMarketAPI, type MarketListingSummary } from './MiniAppMarketAPI'; +import { AppearanceMarketAPI, type AppearanceMarketListingSummary } from './AppearanceMarketAPI'; +import { activateSurface, resetDeviceSurfaceForTest } from '@/infrastructure/peer-device/deviceSurface'; +import { readMarketCatalog, writeMarketCatalog } from './MarketCatalogCache'; + +const mocks = vi.hoisted(() => ({ invoke: vi.fn() })); +vi.mock('./ApiClient', () => ({ api: mocks })); + +const listing: MarketListingSummary = { + listingId: 'one', slug: 'one', name: 'One', description: '', icon: 'box', + category: 'utilities', tags: [], permissions: {}, + owner: { githubId: 1, login: 'owner', avatarUrl: '' }, + latestRelease: 1, minOpenBitFunVersion: '1.0.0', publishedAt: 1, + screenshotUrls: [`https://market.test/screenshots/${'a'.repeat(64)}`], + ratingAverage: 4, ratingCount: 2, downloadCount: 12, favoriteCount: 3, + isFavorited: true, myRating: 5, +}; +const skin: AppearanceMarketListingSummary = { + ...listing, packageId: 'skin.one', packageVersion: '1.0.0', mode: 'dark', + requiredCapabilities: [], previewUrl: `https://market.test/artifacts/previews/${'b'.repeat(64)}`, +}; + +describe('public marketplace catalog snapshots', () => { + afterEach(() => vi.unstubAllGlobals()); + beforeEach(() => { + const values = new Map(); + vi.stubGlobal('localStorage', { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { values.set(key, value); }, + }); + mocks.invoke.mockReset(); + resetDeviceSurfaceForTest(); + }); + + it('restores first-page ordering in a new API instance without retaining personal state', async () => { + const request = { sort: 'downloads' as const, limit: 30 }; + const page = { items: [listing, { ...listing, listingId: 'two' }], nextCursor: 'next' }; + mocks.invoke.mockResolvedValue(page); + await new MiniAppMarketAPI().browse(request); + const cached = new MiniAppMarketAPI().getCachedPage(request); + expect(cached?.items.map(item => item.listingId)).toEqual(['one', 'two']); + expect(cached?.nextCursor).toBe('next'); + expect(cached?.items[0]).not.toHaveProperty('isFavorited'); + expect(cached?.items[0]).not.toHaveProperty('myRating'); + expect(mocks.invoke).toHaveBeenCalledOnce(); + }); + + it('updates the cached image address when the server publishes a new content hash', async () => { + const api = new MiniAppMarketAPI(); + mocks.invoke.mockResolvedValueOnce({ items: [listing] }); + await api.browse({}); + const replacement = { ...listing, screenshotUrls: [listing.screenshotUrls[0].replace('a'.repeat(64), 'c'.repeat(64))] }; + mocks.invoke.mockResolvedValueOnce({ items: [replacement] }); + await api.browse({}); + expect(new MiniAppMarketAPI().getCachedPage({})?.items[0].screenshotUrls) + .toEqual(replacement.screenshotUrls); + }); + + it('isolates MiniApp snapshots by device, filter and sort; Appearance remains controller-owned', async () => { + mocks.invoke.mockResolvedValue({ items: [listing] }); + const miniapps = new MiniAppMarketAPI(); + await miniapps.browse({ sort: 'downloads' }); + expect(miniapps.getCachedPage({ sort: 'rating' })).toBeUndefined(); + expect(miniapps.getCachedPage({ sort: 'downloads', category: 'data' })).toBeUndefined(); + mocks.invoke.mockResolvedValue({ items: [skin] }); + const appearances = new AppearanceMarketAPI(); + await appearances.browse({}); + activateSurface('peer-device'); + expect(miniapps.getCachedPage({ sort: 'downloads' })).toBeUndefined(); + expect(appearances.getCachedPage({})?.items[0].listingId).toBe('one'); + }); + + it('keeps the last successful page on a network failure and never replaces it with pagination', async () => { + const api = new MiniAppMarketAPI(); + mocks.invoke.mockResolvedValueOnce({ items: [listing], nextCursor: 'next' }); + await api.browse({}); + mocks.invoke.mockResolvedValueOnce({ items: [{ ...listing, listingId: 'two' }] }); + await api.browse({ cursor: 'next' }); + expect(api.getCachedPage({})?.items.map(item => item.listingId)).toEqual(['one']); + mocks.invoke.mockRejectedValueOnce(new Error('offline')); + await expect(api.browse({})).rejects.toThrow(); + expect(api.getCachedPage({})?.items[0].listingId).toBe('one'); + }); + + it('tolerates absent, malformed, future and expired records without deleting them', () => { + const key = 'openbitfun:market-catalog:v1'; + const valid = (value: unknown): value is number => typeof value === 'number'; + expect(readMarketCatalog('test', valid)).toBeUndefined(); + for (const raw of ['broken', JSON.stringify({ version: 9, pages: [] }), JSON.stringify({ + version: 1, pages: [{ key: 'test', savedAt: 1, page: { items: [1] } }], + })]) { + localStorage.setItem(key, raw); + expect(readMarketCatalog('test', valid)).toBeUndefined(); + expect(localStorage.getItem(key)).toBe(raw); + } + writeMarketCatalog('test', { items: [1], nextCursor: 'next' }); + expect(readMarketCatalog('test', valid)).toEqual({ items: [1], nextCursor: 'next' }); + }); +}); diff --git a/src/web-ui/src/infrastructure/api/service-api/MarketCatalogCache.ts b/src/web-ui/src/infrastructure/api/service-api/MarketCatalogCache.ts new file mode 100644 index 0000000000..de7313ff07 --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/MarketCatalogCache.ts @@ -0,0 +1,79 @@ +/** Public browse snapshots only. Installed state and account details stay live. */ +import { createLogger } from '@/shared/utils/logger'; + +const STORAGE_KEY = 'openbitfun:market-catalog:v1'; +const MAX_PAGES = 12; +const MAX_BYTES = 1024 * 1024; +const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; +const log = createLogger('MarketCatalogCache'); + +interface CatalogPage { + items: T[]; + nextCursor?: string; +} + +interface Snapshot { + key: string; + savedAt: number; + page: CatalogPage; +} + +function readSnapshots(): Snapshot[] { + try { + const raw = globalThis.localStorage?.getItem(STORAGE_KEY); + if (!raw || raw.length > MAX_BYTES) return []; + const stored = JSON.parse(raw); + if (stored.version !== 1 || !Array.isArray(stored.pages)) return []; + return stored.pages.filter((entry: Snapshot) => ( + entry && typeof entry.key === 'string' && Number.isFinite(entry.savedAt) + && Date.now() - entry.savedAt <= MAX_AGE_MS + && entry.page && Array.isArray(entry.page.items) + && (entry.page.nextCursor === undefined || typeof entry.page.nextCursor === 'string') + )); + } catch { + // Unavailable storage, older versions and malformed cache entries all use + // the existing network path. Never reset any application/user storage. + return []; + } +} + +export function readMarketCatalog( + key: string, + isItem: (value: unknown) => value is T, +): CatalogPage | undefined { + const snapshot = readSnapshots().find(entry => entry.key === key); + if (!snapshot || !snapshot.page.items.every(isItem)) return undefined; + return snapshot.page as CatalogPage; +} + +export function writeMarketCatalog(key: string, page: CatalogPage): void { + try { + const pages = [ + { key, savedAt: Date.now(), page }, + ...readSnapshots().filter(entry => entry.key !== key), + ].slice(0, MAX_PAGES); + let value = JSON.stringify({ version: 1, pages }); + while (value.length > MAX_BYTES && pages.length > 1) { + pages.pop(); + value = JSON.stringify({ version: 1, pages }); + } + if (value.length <= MAX_BYTES) globalThis.localStorage?.setItem(STORAGE_KEY, value); + } catch (error) { + log.warn('Failed to persist public marketplace catalog', { error }); + } +} + +/** Guards the fields used during a cached card's first render. */ +export function isMarketSummary(value: unknown): value is Record { + if (!value || typeof value !== 'object') return false; + const item = value as Record; + const owner = item.owner as Record | undefined; + return ['listingId', 'slug', 'name', 'description', 'minOpenBitFunVersion'] + .every(key => typeof item[key] === 'string') + && ['latestRelease', 'downloadCount', 'publishedAt'].every(key => Number.isFinite(item[key])) + && Boolean(owner && typeof owner.login === 'string' && typeof owner.githubId === 'number'); +} + +export function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every(item => typeof item === 'string'); +} diff --git a/src/web-ui/src/infrastructure/api/service-api/MarketImageCache.test.ts b/src/web-ui/src/infrastructure/api/service-api/MarketImageCache.test.ts new file mode 100644 index 0000000000..098fbb4f3b --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/MarketImageCache.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ invoke: vi.fn() })); +vi.mock('./ApiClient', () => ({ api: mocks })); +vi.mock('@/infrastructure/runtime', () => ({ isTauriRuntime: () => true })); + +describe('market image leases', () => { + afterEach(() => vi.unstubAllGlobals()); + beforeEach(() => { + vi.resetModules(); + mocks.invoke.mockReset(); + vi.stubGlobal('URL', class extends URL { + static createObjectURL = vi.fn(() => 'blob:cached-market-image'); + static revokeObjectURL = vi.fn(); + }); + }); + + it('deduplicates concurrent thumbnails and reuses decoded-source URLs on reopen', async () => { + const { acquireMarketImage } = await import('./MarketImageCache'); + mocks.invoke.mockResolvedValue(new Uint8Array([1, 2, 3]).buffer); + const source = `https://market.test/screenshots/${'a'.repeat(64)}`; + const first = acquireMarketImage(source, 'compact-v1'); + const second = acquireMarketImage(source, 'compact-v1'); + expect(await first.url).toBe(await second.url); + first.release(); second.release(); + const reopened = acquireMarketImage(source, 'compact-v1'); + expect(reopened.readyUrl).toBe('blob:cached-market-image'); + expect(mocks.invoke).toHaveBeenCalledOnce(); + reopened.release(); + }); + + it('fetches changed content and resolutions independently, then caches both', async () => { + const { acquireMarketImage } = await import('./MarketImageCache'); + mocks.invoke.mockResolvedValue(new Uint8Array([1, 2, 3])); + const source = `https://market.test/screenshots/${'a'.repeat(64)}`; + for (const [url, variant] of [[source, 'compact-v1'], [source, 'large-v1'], + [source.replace('a'.repeat(64), 'b'.repeat(64)), 'compact-v1']] as const) { + const image = acquireMarketImage(url, variant); + await image.url; image.release(); + const reused = acquireMarketImage(url, variant); + await reused.url; reused.release(); + } + expect(mocks.invoke).toHaveBeenCalledTimes(3); + }); + + it('does not turn a failed request into a permanent cached failure', async () => { + const { acquireMarketImage } = await import('./MarketImageCache'); + const source = `https://market.test/screenshots/${'a'.repeat(64)}`; + mocks.invoke.mockRejectedValueOnce(new Error('offline')); + const first = acquireMarketImage(source, 'compact-v1'); + await expect(first.url).rejects.toThrow('offline'); first.release(); + mocks.invoke.mockResolvedValueOnce(new ArrayBuffer(4)); + const retry = acquireMarketImage(source, 'compact-v1'); + await expect(retry.url).resolves.toBe('blob:cached-market-image'); retry.release(); + expect(mocks.invoke).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/web-ui/src/infrastructure/api/service-api/MarketImageCache.ts b/src/web-ui/src/infrastructure/api/service-api/MarketImageCache.ts new file mode 100644 index 0000000000..7ff7546362 --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/MarketImageCache.ts @@ -0,0 +1,75 @@ +import { isTauriRuntime } from '@/infrastructure/runtime'; +import { createLogger } from '@/shared/utils/logger'; +import { api } from './ApiClient'; +import { marketImageUrl, type MarketImageVariant } from './MarketImage'; + +const log = createLogger('MarketImageCache'); +const MAX_IDLE_IMAGES = 48; +const MAX_IDLE_BYTES = 24 * 1024 * 1024; +interface Entry { + promise: Promise; + url?: string; + size: number; + users: number; +} +const images = new Map(); + +export function canCacheMarketImage(source: string): boolean { + return isTauriRuntime() && /^https?:\/\/[^?#]+\/(?:screenshots|artifacts\/previews)\/[a-f\d]{64}$/i.test(source); +} + +/** A local blob lease; the native adapter owns download validation and disk IO. */ +export function acquireMarketImage(source: string, variant: MarketImageVariant): { + readyUrl?: string; + url: Promise; + release: () => void; +} { + const key = marketImageUrl(source, variant); + let entry = images.get(key); + if (!entry) { + const created: Entry = { users: 0, size: 0, promise: Promise.resolve('') }; + created.promise = api.invoke('market_image_load', { + request: { source: key }, + }).then(bytes => { + const buffer = bytes instanceof ArrayBuffer ? bytes.slice(0) : new Uint8Array(bytes).buffer; + created.size = buffer.byteLength; + created.url = URL.createObjectURL(new Blob([buffer], { type: 'image/webp' })); + return created.url; + }).catch(error => { + images.delete(key); + // Older local hosts may lack this optional acceleration. The existing + // public image URL remains usable; never substitute a workspace file. + log.warn('Marketplace image cache unavailable; using public image URL', { error }); + throw error; + }).finally(() => pruneImages()); + images.set(key, created); + entry = created; + } + images.delete(key); + images.set(key, entry); + entry.users += 1; + let released = false; + return { + readyUrl: entry.url, + url: entry.promise, + release: () => { + if (released) return; + released = true; + entry.users -= 1; + pruneImages(); + }, + }; +} + +function pruneImages(): void { + const idle = [...images.entries()].filter(([, entry]) => entry.users === 0 && entry.url); + let bytes = idle.reduce((total, [, entry]) => total + entry.size, 0); + let count = idle.length; + for (const [key, entry] of idle) { + if (count <= MAX_IDLE_IMAGES && bytes <= MAX_IDLE_BYTES) break; + URL.revokeObjectURL(entry.url!); + images.delete(key); + count -= 1; + bytes -= entry.size; + } +} diff --git a/src/web-ui/src/infrastructure/api/service-api/MiniAppMarketAPI.ts b/src/web-ui/src/infrastructure/api/service-api/MiniAppMarketAPI.ts index 6287f55563..9e53c93640 100644 --- a/src/web-ui/src/infrastructure/api/service-api/MiniAppMarketAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/MiniAppMarketAPI.ts @@ -1,6 +1,8 @@ import { accountIdentityAPI } from './AccountIdentityAPI'; import { api } from './ApiClient'; import { createTauriCommandError } from '../errors/TauriCommandError'; +import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; +import { isMarketSummary, isStringArray, readMarketCatalog, writeMarketCatalog } from './MarketCatalogCache'; import type { MiniApp, MiniAppI18n, @@ -169,9 +171,34 @@ export interface MarketUploadProgress { } export class MiniAppMarketAPI { + getCachedPage(request: MarketBrowseRequest): CursorPage | undefined { + if (request.cursor) return undefined; + return readMarketCatalog(this.catalogKey(request), (value): value is MarketListingSummary => ( + isMarketSummary(value) + && typeof value.category === 'string' && typeof value.icon === 'string' + && isStringArray(value.tags) && isStringArray(value.screenshotUrls) + && Boolean(value.permissions && typeof value.permissions === 'object') + && ['ratingAverage', 'ratingCount', 'favoriteCount'].every(key => Number.isFinite(value[key])) + )); + } + + private catalogKey(request: MarketBrowseRequest): string { + return getActiveSurfaceScope().key('miniapp-market', JSON.stringify([ + request.query?.trim() ?? '', request.category ?? 'all', request.sort ?? 'newest', request.limit ?? 20, + ])); + } + async browse(request: MarketBrowseRequest): Promise> { + const key = this.catalogKey(request); try { - return await api.invoke('miniapp_market_browse', { request }); + const page = await api.invoke>('miniapp_market_browse', { request }); + if (!request.cursor) { + writeMarketCatalog(key, { + ...page, + items: page.items.map(({ isFavorited: _favorite, myRating: _rating, ...item }) => item), + }); + } + return page; } catch (error) { throw createTauriCommandError('miniapp_market_browse', error); } diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx index 50cb6791b5..716cb0e8c0 100644 --- a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AppearanceMarketDialog } from './AppearanceMarketDialog'; const mocks = vi.hoisted(() => ({ + getCachedPage: vi.fn(), browse: vi.fn(), getListing: vi.fn(), downloadRelease: vi.fn(), @@ -117,6 +118,7 @@ vi.mock('@/infrastructure/i18n/hooks/useI18n', () => ({ vi.mock('@/infrastructure/api/service-api/AppearanceMarketAPI', () => ({ appearanceMarketAPI: { + getCachedPage: mocks.getCachedPage, browse: mocks.browse, getListing: mocks.getListing, downloadRelease: mocks.downloadRelease, @@ -187,6 +189,7 @@ describe('AppearanceMarketDialog', () => { let root: ReturnType; beforeEach(() => { + mocks.getCachedPage.mockReset(); mocks.browse.mockReset().mockResolvedValue({ items: [summary] }); mocks.getListing.mockReset().mockResolvedValue({ ...summary, @@ -313,6 +316,43 @@ describe('AppearanceMarketDialog', () => { expect(container.querySelector('.appearance-market__card--skeleton')).toBeNull(); }); + it('shows cached cards immediately and preserves the same image through revalidation', async () => { + mocks.getCachedPage.mockReturnValue({ items: [summary] }); + let resolveBrowse!: (page: unknown) => void; + mocks.browse.mockImplementation(() => new Promise(resolve => { resolveBrowse = resolve; })); + await act(async () => root.render( undefined} />)); + const image = container.querySelector('.appearance-market__preview img'); + expect(image).not.toBeNull(); + expect(container.querySelector('.appearance-market__card--skeleton')).toBeNull(); + expect(container.querySelector('.appearance-market__results--dimmed')).toBeNull(); + await act(async () => resolveBrowse({ items: [summary] })); + expect(container.querySelector('.appearance-market__preview img')).toBe(image); + }); + + it('keeps cached cards available when revalidation fails', async () => { + mocks.getCachedPage.mockReturnValue({ items: [summary] }); + mocks.browse.mockRejectedValue(new Error('offline')); + await act(async () => root.render( undefined} />)); + expect(container.textContent).toContain('Tokyo Night'); + expect(container.querySelector('[role="alert"]')?.textContent).toContain('offline'); + }); + + it('ignores a stale browse response after the sort changes', async () => { + const responses: Array<(page: unknown) => void> = []; + mocks.browse.mockImplementation(() => new Promise(resolve => { responses.push(resolve); })); + await act(async () => root.render( undefined} />)); + await act(async () => { + const select = container.querySelector('[aria-label="package.market.sortLabel"]')!; + select.value = 'downloads'; + select.dispatchEvent(new Event('change', { bubbles: true })); + }); + expect(responses).toHaveLength(2); + await act(async () => responses[1]({ items: [{ ...summary, name: 'Newest result' }] })); + await act(async () => responses[0]({ items: [summary] })); + expect(container.textContent).toContain('Newest result'); + expect(container.textContent).not.toContain('Tokyo Night'); + }); + it('keeps an empty result set on the empty state once loading settles', async () => { mocks.browse.mockResolvedValue({ items: [] }); diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx index 854eded37f..3f03fbcb64 100644 --- a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx +++ b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx @@ -25,11 +25,9 @@ import { type AppearanceMarketRelease, type AppearanceMarketSort, } from '@/infrastructure/api/service-api/AppearanceMarketAPI'; -import { - marketImageSrcSet, - marketImageUrl, - retryOriginalMarketImage, -} from '@/infrastructure/api/service-api/MarketImage'; +import { MarketImage } from '@/app/components/GalleryLayout/MarketImage'; +import { MarketList } from '@/app/components/GalleryLayout/MarketList'; +import { getInteractionMotion } from '@/shared/utils/motionPreference'; import { getAppearancePackageValidationError, useAppearance, @@ -117,17 +115,19 @@ export function AppearanceMarketDialog({ isOpen, onClose }: AppearanceMarketDial const [submittedQuery, setSubmittedQuery] = useState(''); const [mode, setMode] = useState('all'); const [sort, setSort] = useState('newest'); - const [items, setItems] = useState([]); - const [nextCursor, setNextCursor] = useState(); + const [cachedPage] = useState(() => appearanceMarketAPI.getCachedPage({ query: '', mode: 'all', sort: 'newest', limit: 20 })); + const [items, setItems] = useState(cachedPage?.items ?? []); + const [nextCursor, setNextCursor] = useState(cachedPage?.nextCursor); const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(false); const [appending, setAppending] = useState(false); - const [loadedOnce, setLoadedOnce] = useState(false); + const [loadedOnce, setLoadedOnce] = useState(Boolean(cachedPage)); const [detailLoading, setDetailLoading] = useState(false); const [installing, setInstalling] = useState(false); const [error, setError] = useState(null); const [view, setView] = useState<'browse' | AppearanceMarketWorkflow>('browse'); const browseSequence = useRef(0); + const animateList = useRef(true); const browseRequest = useMemo(() => ({ query: submittedQuery, @@ -148,8 +148,8 @@ export function AppearanceMarketDialog({ isOpen, onClose }: AppearanceMarketDial setNextCursor(page.nextCursor); } catch (loadError) { if (sequence !== browseSequence.current) return; + if (!append) setNextCursor(undefined); setError(errorMessage(loadError)); - if (!append) setItems([]); } finally { if (sequence === browseSequence.current) { setLoading(false); @@ -163,6 +163,7 @@ export function AppearanceMarketDialog({ isOpen, onClose }: AppearanceMarketDial if (!isOpen || view !== 'browse') return; setDetail(null); void loadPage(); + return () => { browseSequence.current += 1; }; }, [isOpen, loadPage, view]); useEffect(() => { @@ -308,13 +309,14 @@ export function AppearanceMarketDialog({ isOpen, onClose }: AppearanceMarketDial > {detail.previewUrl ? ( - {detail.name} retryOriginalMarketImage(event.currentTarget, detail.previewUrl)} /> ) :