From 18a699350944876b115f6acccc9f5d10d58bd6ef Mon Sep 17 00:00:00 2001 From: user-A100 <3535108954@qq.com> Date: Tue, 1 Sep 2026 18:20:53 +0800 Subject: [PATCH 1/5] feat(backgrounds): add wallhaven marketplace core (search/asset/download) --- src-tauri/src/backgrounds/marketplace.rs | 479 +++++++++++++++++++++++ src-tauri/src/backgrounds/mod.rs | 4 +- 2 files changed, 482 insertions(+), 1 deletion(-) create mode 100644 src-tauri/src/backgrounds/marketplace.rs diff --git a/src-tauri/src/backgrounds/marketplace.rs b/src-tauri/src/backgrounds/marketplace.rs new file mode 100644 index 0000000000..a9100ce56d --- /dev/null +++ b/src-tauri/src/backgrounds/marketplace.rs @@ -0,0 +1,479 @@ +//! Workspace-background marketplace backed by [wallhaven.cc](https://wallhaven.cc/). +//! +//! Three operations, all proxied host-side so the webview never talks to the +//! CDN directly (it is unreachable from some networks — same reason +//! `crate::pets::marketplace` proxies): +//! - `search(...)` — public `GET /api/v1/search` with `purity=100` (SFW) +//! hard-coded; the app structurally cannot request NSFW results. +//! - `fetch_asset(...)` — one allowlisted thumbnail, returned as a +//! `BackgroundAsset` for the frontend to mint a blob URL from. +//! - `download(...)` — full image through the *same* validation and atomic +//! write as a manual background pick, so a market download and a local +//! file share one security path (byte sniff, 16 MiB / 40 Mpx caps). +//! +//! All traffic uses a process-wide `reqwest::Client` with a stable +//! user-agent, mirroring `crate::pets::marketplace`. + +use std::sync::LazyLock; +use std::time::Duration; + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use serde::{Deserialize, Serialize}; + +use crate::app_error::AppCommandError; +use crate::backgrounds::{validate_background, write_background_atomic}; +use crate::models::background::BackgroundAsset; + +const WALLHAVEN_SEARCH_URL: &str = "https://wallhaven.cc/api/v1/search"; +const WALLHAVEN_USER_AGENT: &str = "codeg-wallpaper-market/1.0"; +/// SFW-only, permanently. Appended verbatim — never taken from params. +const WALLHAVEN_PURITY: &str = "100"; +/// Search JSON cap. Real pages are ~50 KiB; 4 MiB matches the pet listing cap. +const MAX_SEARCH_JSON_BYTES: u64 = 4 * 1024 * 1024; +/// Thumbnail cap. Real `th.wallhaven.cc/small` files are tens of KiB. +const MAX_ASSET_BYTES: u64 = 4 * 1024 * 1024; +/// Full-image cap. Deliberately equals `backgrounds::MAX_BG_BYTES` so the +/// transport cap and the byte-level validator agree on one ceiling. +const MAX_DOWNLOAD_BYTES: u64 = 16 * 1024 * 1024; +/// Longest accepted search query; wallhaven itself truncates far earlier. +const MAX_QUERY_CHARS: usize = 128; + +static MARKET_HTTP_CLIENT: LazyLock> = LazyLock::new(|| { + reqwest::Client::builder() + .connect_timeout(Duration::from_secs(8)) + .timeout(Duration::from_secs(30)) + .user_agent(WALLHAVEN_USER_AGENT) + .build() + .map_err(|e| format!("failed to initialize wallpaper market HTTP client: {e}")) +}); + +fn client() -> Result<&'static reqwest::Client, AppCommandError> { + MARKET_HTTP_CLIENT + .as_ref() + .map_err(|err| AppCommandError::network(err.clone())) +} + +// ─── URL allowlist ─────────────────────────────────────────────────────── + +pub(crate) fn is_allowed_wallhaven_host(host: &str) -> bool { + host == "wallhaven.cc" || host.ends_with(".wallhaven.cc") +} + +/// Accept only `https` URLs on wallhaven.cc or a subdomain, with no embedded +/// userinfo. Everything the market fetches funnels through this check. +pub(crate) fn parse_wallhaven_https_url(raw: &str) -> Result { + let url = reqwest::Url::parse(raw).map_err(|_| { + AppCommandError::invalid_input("Marketplace URL must be a valid https wallhaven.cc URL.") + })?; + if url.scheme() != "https" { + return Err(AppCommandError::invalid_input( + "Marketplace URL must use https.", + )); + } + // A URL carrying userinfo is not a shape wallhaven ever produces; refuse + // it rather than wonder what it was impersonating. + if !url.username().is_empty() || url.password().is_some() { + return Err(AppCommandError::invalid_input( + "Marketplace URL must not embed credentials.", + )); + } + let host = url.host_str().ok_or_else(|| { + AppCommandError::invalid_input("Marketplace URL must name a host.") + })?; + if !is_allowed_wallhaven_host(host) { + return Err(AppCommandError::invalid_input( + "Marketplace URL host must be wallhaven.cc or a subdomain.", + )); + } + Ok(url) +} + +/// Canonical page URL for an id — derived, never trusted from the listing, +/// because `download` requires exactly this shape for `source_url`. +pub(crate) fn wallhaven_source_url(id: &str) -> String { + format!("https://wallhaven.cc/w/{}", id.trim()) +} + +// ─── Wire types ────────────────────────────────────────────────────────── + +/// Query parameters for `search`. `category` accepts exactly +/// all/general/anime/people; anything else is an error (a typo silently +/// becoming "all" would look like broken filtering). +#[derive(Debug, Default, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MarketSearchParams { + #[serde(default)] + pub query: Option, + #[serde(default)] + pub category: Option, + #[serde(default)] + pub page: Option, +} + +/// One listing entry re-serialized as a stable contract (a subset of the +/// upstream record, like `pets::marketplace::MarketplacePetSummary`). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MarketWallpaperSummary { + pub id: String, + pub thumb_url: String, + pub full_url: String, + pub source_url: String, + pub resolution: String, + pub category: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MarketSearchPage { + pub items: Vec, + pub page: u32, + pub last_page: u32, +} + +/// wallhaven category bitmask: general=100 / anime=010 / people=001. +pub(crate) fn wallhaven_categories(category: Option<&str>) -> Result<&'static str, AppCommandError> { + match category { + None | Some("all") => Ok("111"), + Some("general") => Ok("100"), + Some("anime") => Ok("010"), + Some("people") => Ok("001"), + Some(other) => Err(AppCommandError::invalid_input(format!( + "Unknown wallpaper market category: {other}" + ))), + } +} + +// ─── Search ────────────────────────────────────────────────────────────── + +pub async fn search(params: MarketSearchParams) -> Result { + let query = params + .query + .as_deref() + .map(str::trim) + .filter(|q| !q.is_empty()); + if let Some(q) = query { + if q.chars().count() > MAX_QUERY_CHARS { + return Err(AppCommandError::invalid_input(format!( + "Search query exceeds {MAX_QUERY_CHARS} characters." + ))); + } + } + let page = params.page.unwrap_or(1).max(1); + let categories = wallhaven_categories(params.category.as_deref())?; + + let mut url = reqwest::Url::parse(WALLHAVEN_SEARCH_URL) + .map_err(|e| AppCommandError::network(format!("invalid search URL: {e}")))?; + { + let mut pairs = url.query_pairs_mut(); + pairs.append_pair("categories", categories); + pairs.append_pair("purity", WALLHAVEN_PURITY); + pairs.append_pair("page", &page.to_string()); + match query { + Some(q) => { + pairs.append_pair("q", q); + pairs.append_pair("sorting", "relevance"); + } + // Browse mode: the last month's top list is a sensible default grid. + None => { + pairs.append_pair("sorting", "toplist"); + pairs.append_pair("topRange", "1M"); + } + } + } + + let resp = client()? + .get(url) + .send() + .await + .map_err(|e| AppCommandError::network(format!("wallhaven search failed: {e}")))?; + if !resp.status().is_success() { + return Err(AppCommandError::network(format!( + "wallhaven search returned HTTP {}", + resp.status() + ))); + } + let body = read_capped(resp, MAX_SEARCH_JSON_BYTES, "wallhaven search payload").await?; + let text = String::from_utf8_lossy(&body).into_owned(); + parse_search_payload(&text) +} + +/// Pure parser so the listing contract is unit-testable without network. +pub(crate) fn parse_search_payload(body: &str) -> Result { + #[derive(Deserialize)] + struct ApiThumbs { + #[serde(default)] + small: Option, + } + #[derive(Deserialize)] + struct ApiItem { + id: String, + #[serde(default)] + path: Option, + #[serde(default)] + thumbs: Option, + #[serde(default)] + dimension_x: Option, + #[serde(default)] + dimension_y: Option, + #[serde(default)] + category: Option, + } + #[derive(Default, Deserialize)] + struct ApiMeta { + #[serde(default)] + current_page: Option, + #[serde(default)] + last_page: Option, + } + #[derive(Deserialize)] + struct ApiPayload { + #[serde(default)] + data: Vec, + #[serde(default)] + meta: Option, + } + + let payload: ApiPayload = serde_json::from_str(body) + .map_err(|e| AppCommandError::network(format!("wallhaven returned malformed JSON: {e}")))?; + + let mut items = Vec::with_capacity(payload.data.len()); + for item in payload.data { + let (Some(full_url), Some(thumb_url)) = (item.path, item.thumbs.and_then(|t| t.small)) + else { + continue; + }; + // A listing entry pointing off wallhaven is dropped, not trusted — + // the frontend will only ever hand us URLs we vouched for here. + if parse_wallhaven_https_url(&full_url).is_err() + || parse_wallhaven_https_url(&thumb_url).is_err() + { + continue; + } + let resolution = match (item.dimension_x, item.dimension_y) { + (Some(w), Some(h)) => format!("{w}×{h}"), + _ => String::new(), + }; + // Derived from the id, not copied from the listing. Computed before + // `item.id` is moved into the summary below. + let source_url = wallhaven_source_url(&item.id); + items.push(MarketWallpaperSummary { + id: item.id, + thumb_url, + full_url, + source_url, + resolution, + category: item.category.unwrap_or_default(), + }); + } + let meta = payload.meta.unwrap_or_default(); + Ok(MarketSearchPage { + items, + page: meta.current_page.unwrap_or(1), + last_page: meta.last_page.unwrap_or(1).max(1), + }) +} + +// ─── Asset proxy (thumbnails) ──────────────────────────────────────────── + +pub async fn fetch_asset(url: &str) -> Result { + let url = parse_wallhaven_https_url(url)?; + let (mime, bytes) = fetch_image_capped(&url, MAX_ASSET_BYTES, "wallhaven thumbnail").await?; + Ok(BackgroundAsset { + mime, + data_base64: BASE64.encode(&bytes), + }) +} + +// ─── Download & apply ──────────────────────────────────────────────────── + +pub async fn download(url: &str, source_url: &str) -> Result<(), AppCommandError> { + let full_url = parse_wallhaven_https_url(url)?; + // `source_url` is metadata we display/compare; require it to be the real + // page URL shape so a download can't be attributed to a bogus source. + let source = parse_wallhaven_https_url(source_url)?; + if source.host_str() != Some("wallhaven.cc") || !source.path().starts_with("/w/") { + return Err(AppCommandError::invalid_input( + "sourceUrl must be a https://wallhaven.cc/w/ page URL.", + )); + } + + let (_mime, bytes) = + fetch_image_capped(&full_url, MAX_DOWNLOAD_BYTES, "wallpaper download").await?; + // Same gate as a manual pick: byte sniff, 16 MiB and 40 Mpx caps. + validate_background(&bytes)?; + write_background_atomic(&bytes) +} + +// ─── Shared fetch helper ───────────────────────────────────────────────── + +async fn fetch_image_capped( + url: &reqwest::Url, + cap: u64, + what: &str, +) -> Result<(String, Vec), AppCommandError> { + let resp = client()? + .get(url.clone()) + .send() + .await + .map_err(|e| AppCommandError::network(format!("{what} failed: {e}")))?; + if !resp.status().is_success() { + return Err(AppCommandError::network(format!( + "{what} returned HTTP {}", + resp.status() + ))); + } + let content_type = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(|v| { + v.split(';') + .next() + .unwrap_or("") + .trim() + .to_ascii_lowercase() + }); + // wallhaven serves jpeg/png/webp for both thumbs and full images. The + // byte-level sniff in `validate_background` remains the final authority + // for downloads; this is the early, cheap rejection. + if !matches!( + content_type.as_deref(), + Some("image/jpeg") | Some("image/png") | Some("image/webp") + ) { + return Err(AppCommandError::network(format!( + "{what} returned unsupported content-type {content_type:?}" + ))); + } + let bytes = read_capped(resp, cap, what).await?; + Ok((content_type.expect("checked above"), bytes)) +} + +/// Read a response body with a hard ceiling: reject on declared +/// Content-Length and again on accumulated bytes, so a lying header or a +/// chunked stream cannot balloon memory. +async fn read_capped( + mut resp: reqwest::Response, + cap: u64, + what: &str, +) -> Result, AppCommandError> { + let cap_mib = cap / (1024 * 1024); + if let Some(len) = resp.content_length() { + if len > cap { + return Err(AppCommandError::network(format!( + "{what} exceeds {cap_mib} MiB cap." + ))); + } + } + let mut buf: Vec = Vec::new(); + while let Some(chunk) = resp + .chunk() + .await + .map_err(|e| AppCommandError::network(format!("{what} failed mid-transfer: {e}")))? + { + if buf.len() as u64 + chunk.len() as u64 > cap { + return Err(AppCommandError::network(format!( + "{what} exceeds {cap_mib} MiB cap." + ))); + } + buf.extend_from_slice(&chunk); + } + Ok(buf) +} + +#[cfg(test)] +mod tests { + use super::*; + + const FIXTURE: &str = r#"{ + "data": [ + { + "id": "abc123", + "url": "https://wallhaven.cc/w/abc123", + "path": "https://w.wallhaven.cc/full/ab/wallhaven-abc123.jpg", + "thumbs": { "small": "https://th.wallhaven.cc/small/ab/abc123.jpg" }, + "dimension_x": 1920, + "dimension_y": 1080, + "category": "general" + }, + { + "id": "bad9", + "url": "https://wallhaven.cc/w/bad9", + "path": "https://evil.example/full/wallhaven-bad9.jpg", + "thumbs": { "small": "https://th.wallhaven.cc/small/ba/bad9.jpg" }, + "dimension_x": 800, + "dimension_y": 600, + "category": "anime" + } + ], + "meta": { "current_page": 2, "last_page": 10, "per_page": 24, "total": 240 } + }"#; + + #[test] + fn categories_maps_known_filters() { + assert_eq!(wallhaven_categories(Some("all")).unwrap(), "111"); + assert_eq!(wallhaven_categories(Some("general")).unwrap(), "100"); + assert_eq!(wallhaven_categories(Some("anime")).unwrap(), "010"); + assert_eq!(wallhaven_categories(Some("people")).unwrap(), "001"); + assert_eq!(wallhaven_categories(None).unwrap(), "111"); + } + + #[test] + fn categories_rejects_unknown_value() { + assert!(wallhaven_categories(Some("nsfw")).is_err()); + } + + #[test] + fn host_allowlist_accepts_wallhaven_and_subdomains_only() { + assert!(is_allowed_wallhaven_host("wallhaven.cc")); + assert!(is_allowed_wallhaven_host("th.wallhaven.cc")); + assert!(is_allowed_wallhaven_host("w.wallhaven.cc")); + assert!(!is_allowed_wallhaven_host("wallhaven.cc.evil")); + assert!(!is_allowed_wallhaven_host("evil.cc")); + } + + #[test] + fn url_parser_enforces_https_wallhaven_no_userinfo() { + assert!(parse_wallhaven_https_url("https://w.wallhaven.cc/full/ab/x.jpg").is_ok()); + assert!(parse_wallhaven_https_url("https://wallhaven.cc/w/abc").is_ok()); + assert!(parse_wallhaven_https_url("http://wallhaven.cc/w/abc").is_err()); + assert!(parse_wallhaven_https_url("https://example.com/a.jpg").is_err()); + assert!(parse_wallhaven_https_url("file:///etc/passwd").is_err()); + assert!(parse_wallhaven_https_url("https://user:pw@wallhaven.cc/w/abc").is_err()); + assert!(parse_wallhaven_https_url("not a url").is_err()); + } + + #[test] + fn source_url_is_derived_from_id() { + assert_eq!(wallhaven_source_url(" abc123 "), "https://wallhaven.cc/w/abc123"); + } + + #[test] + fn search_payload_parses_and_drops_non_wallhaven_entries() { + let page = parse_search_payload(FIXTURE).expect("parse"); + // 第二项的 path 指向 evil.example —— 整条丢弃,不信任。 + assert_eq!(page.items.len(), 1); + let item = &page.items[0]; + assert_eq!(item.id, "abc123"); + assert_eq!(item.thumb_url, "https://th.wallhaven.cc/small/ab/abc123.jpg"); + assert_eq!(item.full_url, "https://w.wallhaven.cc/full/ab/wallhaven-abc123.jpg"); + assert_eq!(item.source_url, "https://wallhaven.cc/w/abc123"); + assert_eq!(item.resolution, "1920×1080"); + assert_eq!(item.category, "general"); + assert_eq!(page.page, 2); + assert_eq!(page.last_page, 10); + } + + #[test] + fn search_payload_tolerates_missing_meta() { + let page = parse_search_payload(r#"{"data":[]}"#).expect("parse"); + assert!(page.items.is_empty()); + assert_eq!(page.page, 1); + assert_eq!(page.last_page, 1); + } + + #[test] + fn search_payload_rejects_garbage() { + assert!(parse_search_payload("not json").is_err()); + } +} diff --git a/src-tauri/src/backgrounds/mod.rs b/src-tauri/src/backgrounds/mod.rs index 4798541b96..c2364aeaeb 100644 --- a/src-tauri/src/backgrounds/mod.rs +++ b/src-tauri/src/backgrounds/mod.rs @@ -22,6 +22,8 @@ use crate::app_error::AppCommandError; use crate::models::background::BackgroundAsset; use crate::paths::codeg_backgrounds_root; +pub mod marketplace; + /// Smallest plausible image payload; rejecting tiny inputs early avoids /// decoding random files. const MIN_BG_BYTES: usize = 64; @@ -109,7 +111,7 @@ fn ensure_backgrounds_root() -> Result { Ok(root) } -fn write_background_atomic(bytes: &[u8]) -> Result<(), AppCommandError> { +pub(crate) fn write_background_atomic(bytes: &[u8]) -> Result<(), AppCommandError> { let root = ensure_backgrounds_root()?; let final_path = root.join(BACKGROUND_FILENAME); let tmp_path = root.join(format!("{BACKGROUND_FILENAME}.tmp")); From 7a25417fd83afea7f79e7248fab58c72d5b30c7e Mon Sep 17 00:00:00 2001 From: user-A100 <3535108954@qq.com> Date: Tue, 1 Sep 2026 18:33:16 +0800 Subject: [PATCH 2/5] feat(backgrounds): expose wallpaper market commands (tauri + web) --- src-tauri/src/commands/background.rs | 83 +++++++++++++++++++++++- src-tauri/src/lib.rs | 3 + src-tauri/src/web/handlers/background.rs | 36 +++++++++- src-tauri/src/web/router.rs | 12 ++++ 4 files changed, 130 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/commands/background.rs b/src-tauri/src/commands/background.rs index 07bfc6655d..a8a153213d 100644 --- a/src-tauri/src/commands/background.rs +++ b/src-tauri/src/commands/background.rs @@ -2,11 +2,16 @@ //! //! All filesystem operations live in `crate::backgrounds`; this module owns the //! thin double-mode wrappers that offload the blocking I/O and surface it as -//! `AppCommandError`. All three commands are **stateless** (disk-only, no DB / -//! `AppState`), like `pet_read_spritesheet` / `pet_add` / `pet_replace_sprite`. +//! `AppCommandError`. The disk-backed commands are **stateless** (no DB / +//! `AppState`), like `pet_read_spritesheet` / `pet_add` / `pet_replace_sprite`; +//! the `background_market_*` trio additionally proxies wallhaven.cc through +//! `crate::backgrounds::marketplace`. use crate::app_error::AppCommandError; use crate::backgrounds; +use crate::backgrounds::marketplace::{ + self as background_marketplace, MarketSearchPage, MarketSearchParams, +}; use crate::models::background::BackgroundAsset; // ─── core ops (filesystem) ────────────────────────────────────────────── @@ -56,3 +61,77 @@ pub async fn background_set(image_base64: String) -> Result<(), AppCommandError> pub async fn background_clear() -> Result<(), AppCommandError> { background_clear_core().await } + +// ─── marketplace (wallhaven) ──────────────────────────────────────────── + +pub async fn background_market_search_core( + params: MarketSearchParams, +) -> Result { + background_marketplace::search(params).await +} + +pub async fn background_market_asset_core(url: String) -> Result { + background_marketplace::fetch_asset(&url).await +} + +pub async fn background_market_download_core( + url: String, + source_url: String, +) -> Result<(), AppCommandError> { + background_marketplace::download(&url, &source_url).await +} + +// ─── web-handler param structs ────────────────────────────────────────── + +/// Web-mode JSON bodies for the `background_market_*` commands. The Tauri +/// commands take flat scalars (auto snake_case-translated on the way in); the +/// Axum handlers need named structs to deserialize the same camelCase payload. +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BackgroundMarketSearchParams { + pub query: Option, + pub category: Option, + pub page: Option, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BackgroundMarketAssetParams { + pub url: String, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BackgroundMarketDownloadParams { + pub url: String, + pub source_url: String, +} + +// ─── tauri command wrappers ───────────────────────────────────────────── + +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn background_market_search( + query: Option, + category: Option, + page: Option, +) -> Result { + background_market_search_core(MarketSearchParams { + query, + category, + page, + }) + .await +} + +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn background_market_asset(url: String) -> Result { + background_market_asset_core(url).await +} + +#[cfg_attr(feature = "tauri-runtime", tauri::command)] +pub async fn background_market_download( + url: String, + source_url: String, +) -> Result<(), AppCommandError> { + background_market_download_core(url, source_url).await +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3059a6e4ef..12577ff4b0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1205,6 +1205,9 @@ mod tauri_app { background_commands::background_read, background_commands::background_set, background_commands::background_clear, + background_commands::background_market_search, + background_commands::background_market_asset, + background_commands::background_market_download, app_update_commands::app_update_state, app_update_commands::perform_app_update, app_update_commands::restart_app, diff --git a/src-tauri/src/web/handlers/background.rs b/src-tauri/src/web/handlers/background.rs index b51c188732..1bdf9d7999 100644 --- a/src-tauri/src/web/handlers/background.rs +++ b/src-tauri/src/web/handlers/background.rs @@ -1,11 +1,15 @@ -//! Axum handlers mirroring `commands::background`. All three are stateless -//! (disk-only), so none take `Extension>`. +//! Axum handlers mirroring `commands::background`. All of them are stateless +//! (disk-only or proxied fetch), so none take `Extension>`. use axum::Json; use crate::app_error::AppCommandError; +use crate::backgrounds::marketplace::{MarketSearchPage, MarketSearchParams}; use crate::commands::background as background_commands; use crate::commands::background::BackgroundSetParams; +use crate::commands::background::{ + BackgroundMarketAssetParams, BackgroundMarketDownloadParams, BackgroundMarketSearchParams, +}; use crate::models::background::BackgroundAsset; pub async fn background_read() -> Result>, AppCommandError> { @@ -23,3 +27,31 @@ pub async fn background_set( pub async fn background_clear() -> Result, AppCommandError> { background_commands::background_clear_core().await.map(Json) } + +pub async fn background_market_search( + Json(params): Json, +) -> Result, AppCommandError> { + background_commands::background_market_search_core(MarketSearchParams { + query: params.query, + category: params.category, + page: params.page, + }) + .await + .map(Json) +} + +pub async fn background_market_asset( + Json(params): Json, +) -> Result, AppCommandError> { + background_commands::background_market_asset_core(params.url) + .await + .map(Json) +} + +pub async fn background_market_download( + Json(params): Json, +) -> Result, AppCommandError> { + background_commands::background_market_download_core(params.url, params.source_url) + .await + .map(Json) +} diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index edc1bd40a8..1fbc304fd0 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -1546,6 +1546,18 @@ pub fn build_router( "/background_clear", post(handlers::background::background_clear), ) + .route( + "/background_market_search", + post(handlers::background::background_market_search), + ) + .route( + "/background_market_asset", + post(handlers::background::background_market_asset), + ) + .route( + "/background_market_download", + post(handlers::background::background_market_download), + ) // ─── Pet ─── .route("/pet_list", post(handlers::pet::pet_list)) .route("/pet_get", post(handlers::pet::pet_get)) From 2de7a5051f7e33f05bb53b57f2a8b20fde088400 Mon Sep 17 00:00:00 2001 From: user-A100 <3535108954@qq.com> Date: Tue, 1 Sep 2026 18:43:05 +0800 Subject: [PATCH 3/5] feat(web): add wallpaper market transport bindings and proxied thumb hook --- .../use-proxied-background-thumb.test.ts | 83 +++++++++++++++++ src/hooks/use-proxied-background-thumb.ts | 92 +++++++++++++++++++ src/lib/workspace-background-market.test.ts | 53 +++++++++++ src/lib/workspace-background-market.ts | 60 ++++++++++++ 4 files changed, 288 insertions(+) create mode 100644 src/hooks/use-proxied-background-thumb.test.ts create mode 100644 src/hooks/use-proxied-background-thumb.ts create mode 100644 src/lib/workspace-background-market.test.ts create mode 100644 src/lib/workspace-background-market.ts diff --git a/src/hooks/use-proxied-background-thumb.test.ts b/src/hooks/use-proxied-background-thumb.test.ts new file mode 100644 index 0000000000..f7ed1b6d80 --- /dev/null +++ b/src/hooks/use-proxied-background-thumb.test.ts @@ -0,0 +1,83 @@ +import { renderHook, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import type { BackgroundAsset } from "@/lib/workspace-background" + +const fetchMock = vi.fn() + +vi.mock("@/lib/workspace-background-market", () => ({ + fetchWorkspaceBgMarketAsset: (url: string) => fetchMock(url), +})) + +// Track blob create/revoke without the real URL object API — jsdom does not +// implement `URL.createObjectURL` (same reason the pet-market hook test mocks +// its sprite-url helpers). Each create returns a unique url so we can assert +// the hook's blob is the exact one handed to the consumer. +let blobSeq = 0 +const created: string[] = [] +const revoked: string[] = [] +vi.mock("@/lib/workspace-background", () => ({ + createBackgroundObjectUrl: vi.fn((asset: { dataBase64: string }) => { + const url = `blob:${asset.dataBase64}#${blobSeq++}` + created.push(url) + return url + }), + revokeBackgroundObjectUrl: vi.fn((url: string | null | undefined) => { + if (url) revoked.push(url) + }), +})) + +import { + __resetBackgroundThumbCacheForTests, + useProxiedBackgroundThumb, +} from "./use-proxied-background-thumb" + +const JPEG: BackgroundAsset = { mime: "image/jpeg", dataBase64: "eHg=" } + +beforeEach(() => { + blobSeq = 0 + created.length = 0 + revoked.length = 0 + fetchMock.mockReset() + __resetBackgroundThumbCacheForTests() +}) + +describe("useProxiedBackgroundThumb", () => { + it("resolves a blob src after the proxied fetch settles", async () => { + fetchMock.mockResolvedValue(JPEG) + const { result, unmount } = renderHook(() => + useProxiedBackgroundThumb("https://th.wallhaven.cc/small/ab/x.jpg") + ) + expect(result.current.loading).toBe(true) + await waitFor(() => expect(result.current.src).toBeTruthy()) + expect(result.current.src).toMatch(/^blob:/) + expect(created).toContain(result.current.src) + expect(result.current.failed).toBe(false) + + unmount() + expect(revoked).toEqual([result.current.src]) + }) + + it("reports failure when the fetch rejects", async () => { + fetchMock.mockRejectedValue(new Error("network")) + const { result } = renderHook(() => + useProxiedBackgroundThumb("https://th.wallhaven.cc/small/ab/y.jpg") + ) + await waitFor(() => expect(result.current.failed).toBe(true)) + expect(result.current.src).toBeNull() + expect(created).toHaveLength(0) // no orphan blob on failure + }) + + it("serves a second consumer from the shared asset cache", async () => { + fetchMock.mockResolvedValue(JPEG) + const first = renderHook(() => + useProxiedBackgroundThumb("https://th.wallhaven.cc/small/ab/z.jpg") + ) + await waitFor(() => expect(first.result.current.src).toBeTruthy()) + const second = renderHook(() => + useProxiedBackgroundThumb("https://th.wallhaven.cc/small/ab/z.jpg") + ) + await waitFor(() => expect(second.result.current.src).toBeTruthy()) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/hooks/use-proxied-background-thumb.ts b/src/hooks/use-proxied-background-thumb.ts new file mode 100644 index 0000000000..ac3a750f3f --- /dev/null +++ b/src/hooks/use-proxied-background-thumb.ts @@ -0,0 +1,92 @@ +import { useEffect, useState } from "react" + +import { fetchWorkspaceBgMarketAsset } from "@/lib/workspace-background-market" +import { + createBackgroundObjectUrl, + revokeBackgroundObjectUrl, + type BackgroundAsset, +} from "@/lib/workspace-background" + +// Cache fetched *asset data* per URL (thumbs are immutable content-addressed +// paths) — NOT the blob URL. Paging back / reopening the dialog resolves +// without another wallhaven fetch. Each consumer mints its own blob URL and +// revokes it on unmount, so a shared entry can never be revoked out from +// under a still-mounted consumer. +const assetCache = new Map>() + +function loadAsset(url: string): Promise { + const existing = assetCache.get(url) + if (existing) return existing + + const promise = fetchWorkspaceBgMarketAsset(url) + assetCache.set(url, promise) + // Don't cache a rejection — a transient network blip stays retryable. The + // eviction is identity-guarded so a superseded request's late failure + // can't evict a newer entry. + promise.catch(() => { + if (assetCache.get(url) === promise) assetCache.delete(url) + }) + return promise +} + +export interface ProxiedThumb { + /** Blob URL for the proxied thumbnail, or `null` while loading / on failure. */ + src: string | null + loading: boolean + failed: boolean +} + +interface Outcome { + src: string | null + failed: boolean +} + +/** + * Resolve a wallhaven thumbnail URL to a locally-served blob URL by proxying + * the bytes through the backend (`background_market_asset`) — the webview + * can't reach th.wallhaven.cc directly on some networks, so market cards + * render wherever the listing loads. Keyed by URL; state is only written + * from async callbacks, never synchronously in the effect body. + */ +export function useProxiedBackgroundThumb(url: string): ProxiedThumb { + const [state, setState] = useState<{ url: string | null; outcome: Outcome }>( + () => ({ url: null, outcome: { src: null, failed: false } }) + ) + + useEffect(() => { + if (!url) return + + let cancelled = false + let objectUrl: string | null = null + loadAsset(url) + .then((asset) => { + if (cancelled) return + objectUrl = createBackgroundObjectUrl(asset) + setState({ url, outcome: { src: objectUrl, failed: false } }) + }) + .catch(() => { + if (cancelled) return + setState({ url, outcome: { src: null, failed: true } }) + }) + + return () => { + cancelled = true + if (objectUrl) revokeBackgroundObjectUrl(objectUrl) + } + }, [url]) + + if (state.url === url) { + return { + src: state.outcome.src, + loading: false, + failed: state.outcome.failed, + } + } + // `url` changed and the effect hasn't resolved the new one yet. + return { src: null, loading: true, failed: false } +} + +/** Test-only: drop cached asset data so module state doesn't leak across tests. */ +export function __resetBackgroundThumbCacheForTests(): void { + assetCache.clear() +} diff --git a/src/lib/workspace-background-market.test.ts b/src/lib/workspace-background-market.test.ts new file mode 100644 index 0000000000..56f43b8977 --- /dev/null +++ b/src/lib/workspace-background-market.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const callMock = vi.fn() + +vi.mock("@/lib/transport", () => ({ + getTransport: () => ({ call: callMock }), +})) + +import { + downloadWorkspaceBgMarket, + fetchWorkspaceBgMarketAsset, + searchWorkspaceBgMarket, +} from "./workspace-background-market" + +beforeEach(() => { + callMock.mockReset() +}) + +describe("workspace-background-market transport bindings", () => { + it("passes camelCase params to background_market_search", async () => { + callMock.mockResolvedValue({ items: [], page: 2, lastPage: 5 }) + await searchWorkspaceBgMarket({ + query: "mountain", + category: "anime", + page: 2, + }) + expect(callMock).toHaveBeenCalledWith("background_market_search", { + query: "mountain", + category: "anime", + page: 2, + }) + }) + + it("proxies asset fetch through background_market_asset", async () => { + callMock.mockResolvedValue({ mime: "image/jpeg", dataBase64: "eHg=" }) + await fetchWorkspaceBgMarketAsset("https://th.wallhaven.cc/small/ab/x.jpg") + expect(callMock).toHaveBeenCalledWith("background_market_asset", { + url: "https://th.wallhaven.cc/small/ab/x.jpg", + }) + }) + + it("sends url + sourceUrl to background_market_download", async () => { + callMock.mockResolvedValue(undefined) + await downloadWorkspaceBgMarket( + "https://w.wallhaven.cc/full/ab/x.jpg", + "https://wallhaven.cc/w/x" + ) + expect(callMock).toHaveBeenCalledWith("background_market_download", { + url: "https://w.wallhaven.cc/full/ab/x.jpg", + sourceUrl: "https://wallhaven.cc/w/x", + }) + }) +}) diff --git a/src/lib/workspace-background-market.ts b/src/lib/workspace-background-market.ts new file mode 100644 index 0000000000..fcd4cf3f31 --- /dev/null +++ b/src/lib/workspace-background-market.ts @@ -0,0 +1,60 @@ +// Transport-aware bindings for the wallpaper market (wallhaven.cc) commands. +// Same dual-mode pattern as src/lib/workspace-background.ts: everything goes +// through getTransport().call(...) so one code path serves Tauri (invoke) and +// standalone-server (fetch) modes. + +import { getTransport } from "@/lib/transport" +import type { BackgroundAsset } from "@/lib/workspace-background" + +// ─── Types ─── + +/** Category filter mirrored from the Rust `wallhaven_categories` allowlist. */ +export const MARKET_CATEGORIES = ["all", "general", "anime", "people"] as const +export type MarketCategory = (typeof MARKET_CATEGORIES)[number] + +/** camelCase mirror of the Rust `MarketWallpaperSummary`. */ +export type MarketWallpaper = { + id: string + thumbUrl: string + fullUrl: string + sourceUrl: string + resolution: string + category: string +} + +/** camelCase mirror of the Rust `MarketSearchPage`. */ +export type MarketSearchResult = { + items: MarketWallpaper[] + page: number + lastPage: number +} + +// ─── Transport bindings ─── + +export async function searchWorkspaceBgMarket(input: { + query: string + category: MarketCategory + page: number +}): Promise { + return getTransport().call("background_market_search", { + query: input.query, + category: input.category, + page: input.page, + }) +} + +export async function fetchWorkspaceBgMarketAsset( + url: string +): Promise { + return getTransport().call("background_market_asset", { url }) +} + +export async function downloadWorkspaceBgMarket( + url: string, + sourceUrl: string +): Promise { + return getTransport().call("background_market_download", { + url, + sourceUrl, + }) +} From dfd941f7106912fa36da1c518fae3a8358b075ed Mon Sep 17 00:00:00 2001 From: user-A100 <3535108954@qq.com> Date: Tue, 1 Sep 2026 19:04:33 +0800 Subject: [PATCH 4/5] feat(appearance): apply market wallpapers via provider with source marker --- src/components/appearance-provider.tsx | 52 +++++++++++++++++++++++++- src/hooks/use-appearance.ts | 4 ++ src/lib/appearance-script.ts | 4 ++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/components/appearance-provider.tsx b/src/components/appearance-provider.tsx index 3daf9a9d0f..3439ccae58 100644 --- a/src/components/appearance-provider.tsx +++ b/src/components/appearance-provider.tsx @@ -50,6 +50,7 @@ import { STORAGE_KEY_WORKSPACE_BG_FILL, STORAGE_KEY_WORKSPACE_BG_PANEL_OPACITY, STORAGE_KEY_WORKSPACE_BG_IMAGE_VERSION, + STORAGE_KEY_WORKSPACE_BG_SOURCE_URL, STORAGE_KEY_CUSTOM_THEME, STORAGE_KEY_CUSTOM_THEME_ENABLED, STORAGE_KEY_CUSTOM_CSS, @@ -88,6 +89,7 @@ import { clearWorkspaceBackground, type WorkspaceBgFillMode, } from "@/lib/workspace-background" +import { downloadWorkspaceBgMarket } from "@/lib/workspace-background-market" function syncTrafficLightPosition(zoom: number) { if (typeof window === "undefined" || !("__TAURI_INTERNALS__" in window)) @@ -156,6 +158,13 @@ type AppearanceContextValue = { setWorkspaceBackgroundImage: (imageBase64: string) => Promise /** 移除背景图片(删盘 + revoke blob URL)。 */ removeWorkspaceBackground: () => Promise + /** 从壁纸市场下载并应用背景。写盘在后端,成功后与本地选图共用同一套失效 + 重读盘。 */ + downloadMarketWorkspaceBackground: ( + url: string, + sourceUrl: string + ) => Promise + /** 当前背景的市场来源页(https://wallhaven.cc/w/);本地图 / 未设置为 null。 */ + workspaceBgSourceUrl: string | null /** 当前解析出的明暗模式(读 的 dark 类,非 next-themes 的 resolvedTheme)。 */ isDarkMode: boolean /** 主题 token 覆盖(明暗两套,键名不带 `--`,= shadcn cssVars 形状)。 */ @@ -434,6 +443,15 @@ export function AppearanceProvider({ const [workspaceBgImageUrl, setWorkspaceBgImageUrlState] = useState< string | null >(null) + const [workspaceBgSourceUrl, setWorkspaceBgSourceUrlState] = useState< + string | null + >(() => { + try { + return localStorage.getItem(STORAGE_KEY_WORKSPACE_BG_SOURCE_URL) ?? null + } catch { + return null + } + }) // 自定义样式。初值同样从 localStorage 读 —— 视觉已由 inline 脚本就位,这里只是 // 回填状态,不会造成闪烁(下方 apply effect 首次运行写的是同一份值,幂等)。 @@ -642,6 +660,16 @@ export function AppearanceProvider({ // 切换的竞态)。写入窗口收不到自己的 storage 事件,本地一致性全靠这个守卫。 const reloadGenRef = useRef(0) + // 本地选图 / 移除背景时,市场「使用中」标记随之失效。 + const clearWorkspaceBgSourceUrl = useCallback(() => { + try { + localStorage.removeItem(STORAGE_KEY_WORKSPACE_BG_SOURCE_URL) + } catch { + // localStorage unavailable + } + setWorkspaceBgSourceUrlState(null) + }, []) + // 从磁盘重新读取背景图并刷新 blob URL(revoke 旧、建新或置 null)。写/换/删图 // 与跨窗口版本戳变更都复用它,确保 URL 生命周期与磁盘状态一致。 const reloadWorkspaceBackgroundImage = useCallback(async () => { @@ -662,16 +690,36 @@ export function AppearanceProvider({ const setWorkspaceBackgroundImage = useCallback( async (imageBase64: string) => { await setWorkspaceBackground(imageBase64) + // 本地图覆盖市场图 → 「使用中」来源标记失效。 + clearWorkspaceBgSourceUrl() // 写盘持久化后立即广播版本戳(不等本地 readback):避免设置窗口在读回大图 // 期间被关闭,导致 workspace 窗口收不到失效信号、停留在旧图。随后再刷新本地预览。 persist(STORAGE_KEY_WORKSPACE_BG_IMAGE_VERSION, String(Date.now())) await reloadWorkspaceBackgroundImage() }, + [clearWorkspaceBgSourceUrl, reloadWorkspaceBackgroundImage] + ) + + // 壁纸市场下载:字节直接由后端落盘(不走前端 base64 往返),成功后与本地选图 + // 共用同一套失效广播 + 重读盘,保证所有窗口一致换图。 + const downloadMarketWorkspaceBackground = useCallback( + async (url: string, sourceUrl: string) => { + await downloadWorkspaceBgMarket(url, sourceUrl) + try { + localStorage.setItem(STORAGE_KEY_WORKSPACE_BG_SOURCE_URL, sourceUrl) + } catch { + // localStorage unavailable + } + setWorkspaceBgSourceUrlState(sourceUrl) + persist(STORAGE_KEY_WORKSPACE_BG_IMAGE_VERSION, String(Date.now())) + await reloadWorkspaceBackgroundImage() + }, [reloadWorkspaceBackgroundImage] ) const removeWorkspaceBackground = useCallback(async () => { await clearWorkspaceBackground() + clearWorkspaceBgSourceUrl() // 使任何在途 reload 失效(否则先前发起的旧读可能在清空后完成、恢复已删的图), // 立即广播失效戳,再置空本地预览。 reloadGenRef.current += 1 @@ -680,7 +728,7 @@ export function AppearanceProvider({ revokeBackgroundObjectUrl(prev) return null }) - }, []) + }, [clearWorkspaceBgSourceUrl]) // Sync traffic-light position and appearance mode on mount useEffect(() => { @@ -1033,7 +1081,9 @@ export function AppearanceProvider({ setWorkspaceBgFillMode, workspaceBgImageUrl, setWorkspaceBackgroundImage, + downloadMarketWorkspaceBackground, removeWorkspaceBackground, + workspaceBgSourceUrl, isDarkMode, customTheme, setCustomThemeToken, diff --git a/src/hooks/use-appearance.ts b/src/hooks/use-appearance.ts index 6a93f457a3..6468b07e02 100644 --- a/src/hooks/use-appearance.ts +++ b/src/hooks/use-appearance.ts @@ -158,7 +158,9 @@ export function useWorkspaceBackground() { setWorkspaceBgFillMode, workspaceBgImageUrl, setWorkspaceBackgroundImage, + downloadMarketWorkspaceBackground, removeWorkspaceBackground, + workspaceBgSourceUrl, } = useAppearance() return { workspaceBgEnabled, @@ -173,6 +175,8 @@ export function useWorkspaceBackground() { setWorkspaceBgFillMode, workspaceBgImageUrl, setWorkspaceBackgroundImage, + downloadMarketWorkspaceBackground, removeWorkspaceBackground, + workspaceBgSourceUrl, } } diff --git a/src/lib/appearance-script.ts b/src/lib/appearance-script.ts index 0ad7d1866b..7ea02f4e70 100644 --- a/src/lib/appearance-script.ts +++ b/src/lib/appearance-script.ts @@ -48,6 +48,10 @@ export const STORAGE_KEY_WORKSPACE_BG_PANEL_OPACITY = // 写/换/删图后 bump,让 workspace 窗口经 storage 事件重新读盘。不需预水合。 export const STORAGE_KEY_WORKSPACE_BG_IMAGE_VERSION = "codeg-workspace-bg-image-version" +// 壁纸市场:当前背景的来源页(https://wallhaven.cc/w/),仅用于市场卡片的 +// 「使用中」标记。本地选图 / 移除背景时清除;不参与渲染,丢了也只是标记失灵。 +export const STORAGE_KEY_WORKSPACE_BG_SOURCE_URL = + "codeg-workspace-bg-source-url" // 自定义样式(外观设置页)。全部需要预水合 —— 少一帧就会看到「基底预设 → 用户配色」 // 的跳变,比没有这个功能更糟。 From bfb5e54242e48ff174fad6bbeabb6f81b12b5bd9 Mon Sep 17 00:00:00 2001 From: user-A100 <3535108954@qq.com> Date: Tue, 1 Sep 2026 19:30:11 +0800 Subject: [PATCH 5/5] feat(settings): add wallpaper market dialog with 10-locale i18n --- ...orkspace-background-market-dialog.test.tsx | 115 +++++++ .../workspace-background-market-dialog.tsx | 308 ++++++++++++++++++ .../settings/workspace-background-section.tsx | 23 +- src/i18n/messages/ar.json | 27 +- src/i18n/messages/de.json | 27 +- src/i18n/messages/en.json | 27 +- src/i18n/messages/es.json | 27 +- src/i18n/messages/fr.json | 27 +- src/i18n/messages/ja.json | 27 +- src/i18n/messages/ko.json | 27 +- src/i18n/messages/pt.json | 27 +- src/i18n/messages/zh-CN.json | 27 +- src/i18n/messages/zh-TW.json | 27 +- 13 files changed, 705 insertions(+), 11 deletions(-) create mode 100644 src/components/settings/workspace-background-market-dialog.test.tsx create mode 100644 src/components/settings/workspace-background-market-dialog.tsx diff --git a/src/components/settings/workspace-background-market-dialog.test.tsx b/src/components/settings/workspace-background-market-dialog.test.tsx new file mode 100644 index 0000000000..fbfb2ec93e --- /dev/null +++ b/src/components/settings/workspace-background-market-dialog.test.tsx @@ -0,0 +1,115 @@ +import { render, screen, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import type { MarketWallpaper } from "@/lib/workspace-background-market" + +const searchMock = vi.fn() + +vi.mock("@/lib/workspace-background-market", () => ({ + MARKET_CATEGORIES: ["all", "general", "anime", "people"] as const, + searchWorkspaceBgMarket: (input: unknown) => searchMock(input), +})) + +// The real hook pulls `fetchWorkspaceBgMarketAsset` out of the module mocked +// above (which deliberately only exports the search surface) and mints blob +// URLs — neither of which exists under jsdom. Pin it to a resolved thumb so +// the card renders its branch deterministically. +vi.mock("@/hooks/use-proxied-background-thumb", () => ({ + useProxiedBackgroundThumb: () => ({ + src: "blob:x", + loading: false, + failed: false, + }), +})) + +vi.mock("next-intl", () => ({ + useTranslations: (ns: string) => (key: string) => `${ns}.${key}`, +})) + +import { WorkspaceBackgroundMarketDialog } from "./workspace-background-market-dialog" + +const ITEM: MarketWallpaper = { + id: "abc123", + thumbUrl: "https://th.wallhaven.cc/small/ab/abc123.jpg", + fullUrl: "https://w.wallhaven.cc/full/ab/wallhaven-abc123.jpg", + sourceUrl: "https://wallhaven.cc/w/abc123", + resolution: "1920×1080", + category: "general", +} + +beforeEach(() => { + searchMock.mockReset() +}) + +describe("WorkspaceBackgroundMarketDialog", () => { + it("renders listing items once loaded", async () => { + searchMock.mockResolvedValue({ items: [ITEM], page: 1, lastPage: 3 }) + render( + {}} + appliedSourceUrl={null} + onApply={vi.fn()} + /> + ) + await waitFor(() => + expect(screen.getByText("1920×1080")).toBeInTheDocument() + ) + expect(searchMock).toHaveBeenCalledWith({ + query: "", + category: "all", + page: 1, + }) + }) + + it("marks the applied wallpaper and applies on click", async () => { + searchMock.mockResolvedValue({ items: [ITEM], page: 1, lastPage: 1 }) + const onApply = vi.fn().mockResolvedValue(undefined) + render( + {}} + appliedSourceUrl="https://wallhaven.cc/w/abc123" + onApply={onApply} + /> + ) + await waitFor(() => + expect(screen.getByText("1920×1080")).toBeInTheDocument() + ) + expect( + screen.getByText("AppearanceSettings.workspaceBackground.market.applied") + ).toBeInTheDocument() + await userEvent.click(screen.getByRole("button", { name: /abc123/ })) + await waitFor(() => + expect(onApply).toHaveBeenCalledWith(ITEM.fullUrl, ITEM.sourceUrl) + ) + }) + + it("shows a retryable error state on failure", async () => { + searchMock.mockRejectedValue(new Error("network")) + render( + {}} + appliedSourceUrl={null} + onApply={vi.fn()} + /> + ) + await waitFor(() => + expect( + screen.getByText("AppearanceSettings.workspaceBackground.market.error") + ).toBeInTheDocument() + ) + searchMock.mockResolvedValue({ items: [ITEM], page: 1, lastPage: 1 }) + // The toolbar's icon-only refresh button and the error-state retry button + // share one accessible name; the error-state one is the last rendered. + const retryButtons = screen.getAllByRole("button", { + name: "AppearanceSettings.workspaceBackground.market.retry", + }) + await userEvent.click(retryButtons[retryButtons.length - 1]) + await waitFor(() => + expect(screen.getByText("1920×1080")).toBeInTheDocument() + ) + }) +}) diff --git a/src/components/settings/workspace-background-market-dialog.tsx b/src/components/settings/workspace-background-market-dialog.tsx new file mode 100644 index 0000000000..8cfd0d1c2a --- /dev/null +++ b/src/components/settings/workspace-background-market-dialog.tsx @@ -0,0 +1,308 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" +import { useTranslations } from "next-intl" +import { + ChevronLeft, + ChevronRight, + Download, + ImageOff, + Loader2, + RefreshCw, + Store, +} from "lucide-react" +import { toast } from "sonner" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { ScrollArea } from "@/components/ui/scroll-area" +import { useProxiedBackgroundThumb } from "@/hooks/use-proxied-background-thumb" +import { + MARKET_CATEGORIES, + searchWorkspaceBgMarket, + type MarketCategory, + type MarketWallpaper, +} from "@/lib/workspace-background-market" +import { cn } from "@/lib/utils" + +const SEARCH_DEBOUNCE_MS = 300 + +interface WorkspaceBackgroundMarketDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + /** 当前背景的市场来源页(本地图/未设置为 null),用于「使用中」标记。 */ + appliedSourceUrl: string | null + /** 下载并应用(provider 的 downloadMarketWorkspaceBackground)。 */ + onApply: (url: string, sourceUrl: string) => Promise +} + +function MarketCard({ + wallpaper, + applied, + downloading, + onApply, +}: { + wallpaper: MarketWallpaper + applied: boolean + downloading: boolean + onApply: (wallpaper: MarketWallpaper) => void +}) { + const t = useTranslations("AppearanceSettings.workspaceBackground.market") + const thumb = useProxiedBackgroundThumb(wallpaper.thumbUrl) + + return ( + + ) +} + +export function WorkspaceBackgroundMarketDialog({ + open, + onOpenChange, + appliedSourceUrl, + onApply, +}: WorkspaceBackgroundMarketDialogProps) { + const t = useTranslations("AppearanceSettings.workspaceBackground.market") + const [searchInput, setSearchInput] = useState("") + const [query, setQuery] = useState("") + const [category, setCategory] = useState("all") + const [page, setPage] = useState(1) + const [items, setItems] = useState([]) + const [lastPage, setLastPage] = useState(1) + const [loading, setLoading] = useState(false) + // 失败标记(文案在渲染期翻译,避免把 t 引进 load 的依赖)。 + const [error, setError] = useState(false) + const [downloadingId, setDownloadingId] = useState(null) + // 代次守卫:慢请求晚归不覆盖新请求的结果(与宠物市场同款问题)。 + const requestSeq = useRef(0) + + // 搜索框 debounce;输入变化回到第 1 页。 + useEffect(() => { + const handle = setTimeout(() => { + setQuery(searchInput.trim()) + setPage(1) + }, SEARCH_DEBOUNCE_MS) + return () => clearTimeout(handle) + }, [searchInput]) + + const load = useCallback(async (q: string, c: MarketCategory, p: number) => { + const seq = ++requestSeq.current + setLoading(true) + setError(false) + try { + const result = await searchWorkspaceBgMarket({ + query: q, + category: c, + page: p, + }) + if (seq !== requestSeq.current) return + setItems(result.items) + setLastPage(result.lastPage) + } catch { + if (seq !== requestSeq.current) return + setItems([]) + setLastPage(1) + setError(true) + } finally { + if (seq === requestSeq.current) setLoading(false) + } + }, []) + + useEffect(() => { + if (!open) return + void load(query, category, page) + }, [open, query, category, page, load]) + + const onCardApply = async (wallpaper: MarketWallpaper) => { + setError(false) + setDownloadingId(wallpaper.id) + try { + await onApply(wallpaper.fullUrl, wallpaper.sourceUrl) + toast.success(t("appliedToast")) + } catch { + toast.error(t("downloadFailed")) + } finally { + setDownloadingId(null) + } + } + + return ( + + + + + + {t("title")} + +

+ {t("description")} · {t("credit")} +

+
+ + {/* 搜索 + 分类 */} +
+ setSearchInput(e.target.value)} + placeholder={t("searchPlaceholder")} + className="h-8 w-56" + /> +
+ {MARKET_CATEGORIES.map((c) => ( + + ))} +
+ +
+ + {/* 网格 / 三态 */} + + {error ? ( +
+

{t("error")}

+ +
+ ) : loading && items.length === 0 ? ( +
+ +
+ ) : items.length === 0 ? ( +

+ {t("empty")} +

+ ) : ( +
+ {items.map((w) => ( + void onCardApply(item)} + /> + ))} +
+ )} +
+ + {/* 分页 */} +
+ + {t("pageInfo", { page, lastPage })} + +
+ + +
+
+
+
+ ) +} diff --git a/src/components/settings/workspace-background-section.tsx b/src/components/settings/workspace-background-section.tsx index f244f7fd9c..a48ce0654f 100644 --- a/src/components/settings/workspace-background-section.tsx +++ b/src/components/settings/workspace-background-section.tsx @@ -1,7 +1,7 @@ "use client" import { useRef, useState } from "react" -import { Image as ImageIcon } from "lucide-react" +import { Image as ImageIcon, Store } from "lucide-react" import { useTranslations } from "next-intl" import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" @@ -14,6 +14,7 @@ import { SelectValue, } from "@/components/ui/select" import { useWorkspaceBackground } from "@/hooks/use-appearance" +import { WorkspaceBackgroundMarketDialog } from "./workspace-background-market-dialog" import { MAX_WORKSPACE_BG_BYTES, WORKSPACE_BG_ACCEPT, @@ -43,11 +44,14 @@ export function WorkspaceBackgroundSection() { workspaceBgImageUrl, setWorkspaceBackgroundImage, removeWorkspaceBackground, + downloadMarketWorkspaceBackground, + workspaceBgSourceUrl, } = useWorkspaceBackground() const fileInputRef = useRef(null) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) + const [marketOpen, setMarketOpen] = useState(false) const onChooseFile = async (file: File) => { setError(null) @@ -159,6 +163,16 @@ export function WorkspaceBackgroundSection() { ? t("workspaceBackground.replaceImage") : t("workspaceBackground.chooseImage")} + {workspaceBgImageUrl && (