diff --git a/src-tauri/src/backgrounds/marketplace.rs b/src-tauri/src/backgrounds/marketplace.rs new file mode 100644 index 0000000000..1662d2de48 --- /dev/null +++ b/src-tauri/src/backgrounds/marketplace.rs @@ -0,0 +1,591 @@ +//! 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`, and a redirect policy that re-applies +//! the host allowlist to every hop — validating only the URL we dial would +//! leave the allowlist enforceable in one place and bypassable in the next. + +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; +/// Deadline for the small JSON/thumbnail fetches, which are tens of KiB. +const SMALL_FETCH_TIMEOUT: Duration = Duration::from_secs(30); +/// Deadline for a full image. Deliberately not 30 s: at the 16 MiB ceiling that +/// would demand ~4.4 Mbit/s sustained, and this market exists precisely because +/// some users cannot reach the CDN well. Stalls are caught by the client's +/// read timeout, so this only bounds a slow-but-progressing transfer. +const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(300); +/// Redirect hops allowed, each re-checked against the host allowlist. +const MAX_REDIRECT_HOPS: usize = 5; + +static MARKET_HTTP_CLIENT: LazyLock> = LazyLock::new(|| { + reqwest::Client::builder() + .connect_timeout(Duration::from_secs(8)) + // Per-read, not per-request: a stalled transfer still fails fast while a + // slow one is allowed to finish (see `DOWNLOAD_TIMEOUT`). + .read_timeout(Duration::from_secs(30)) + .redirect(wallhaven_redirect_policy()) + .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") +} + +/// Whether a *redirect target* is still inside the allowlist. +/// +/// Validating the URL we hand to `reqwest` only covers the first hop. Under the +/// default redirect policy the client would then follow a `Location` anywhere — +/// and since `fetch_asset` hands the response body back to the caller, a +/// redirect off wallhaven would turn this proxy into a general-purpose reader of +/// whatever the *host* can reach, which in server mode may be a private network. +/// So every hop is re-checked, the way `forge` and `remote_proxy` re-check +/// theirs. +pub(crate) fn is_allowed_wallhaven_hop(url: &reqwest::Url) -> bool { + url.scheme() == "https" && url.host_str().is_some_and(is_allowed_wallhaven_host) +} + +fn wallhaven_redirect_policy() -> reqwest::redirect::Policy { + reqwest::redirect::Policy::custom(|attempt| { + if !is_allowed_wallhaven_hop(attempt.url()) { + let refused = format!("refused a redirect off wallhaven.cc to {}", attempt.url()); + return attempt.error(refused); + } + if attempt.previous().len() > MAX_REDIRECT_HOPS { + return attempt.error(format!("more than {MAX_REDIRECT_HOPS} redirects")); + } + attempt.follow() + }) +} + +/// 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`). +/// +/// `width`/`height`/`file_size_bytes` are carried rather than dropped because +/// `download` refuses anything past `backgrounds::MAX_BG_BYTES` / +/// `MAX_BG_PIXELS`, and roughly one wallhaven listing in fifteen is past one of +/// them. Without these three numbers the frontend cannot tell a wallpaper it can +/// apply from one it can only fail on. `0` means the listing omitted the field. +#[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 width: u32, + pub height: u32, + pub file_size_bytes: u64, + 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) + .timeout(SMALL_FETCH_TIMEOUT) + .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)] + file_size: 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; + } + // 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, + width: item.dimension_x.unwrap_or(0), + height: item.dimension_y.unwrap_or(0), + file_size_bytes: item.file_size.unwrap_or(0), + category: item.category.unwrap_or_default(), + }); + } + let meta = payload.meta.unwrap_or_default(); + Ok(MarketSearchPage { + // Both floored at 1: the frontend pages relative to `page`, so a `0` + // from upstream would put its cursor somewhere that cannot be asked for. + page: meta.current_page.unwrap_or(1).max(1), + last_page: meta.last_page.unwrap_or(1).max(1), + items, + }) +} + +// ─── 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, + SMALL_FETCH_TIMEOUT, + "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, + DOWNLOAD_TIMEOUT, + "wallpaper download", + ) + .await?; + // Same gate as a manual pick: byte sniff, 16 MiB and 40 Mpx caps. Off the + // runtime like every other command in `commands::background` — decoding a + // header and fsyncing up to 16 MiB is not work to do on an executor thread. + tokio::task::spawn_blocking(move || { + validate_background(&bytes)?; + write_background_atomic(&bytes) + }) + .await + .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))? +} + +// ─── Shared fetch helper ───────────────────────────────────────────────── + +async fn fetch_image_capped( + url: &reqwest::Url, + cap: u64, + timeout: Duration, + what: &str, +) -> Result<(String, Vec), AppCommandError> { + let resp = client()? + .get(url.clone()) + .timeout(timeout) + .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, + "file_size": 4455320, + "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, + "file_size": 91234, + "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.width, item.height), (1920, 1080)); + assert_eq!(item.file_size_bytes, 4_455_320); + assert_eq!(item.category, "general"); + assert_eq!(page.page, 2); + assert_eq!(page.last_page, 10); + } + + /// A listing that omits the size fields must still be usable; `0` is the + /// "unknown" the frontend reads as "let the backend decide". + #[test] + fn search_payload_defaults_missing_size_fields_to_zero() { + let page = parse_search_payload( + r#"{"data":[{"id":"x1","path":"https://w.wallhaven.cc/full/x/x1.jpg", + "thumbs":{"small":"https://th.wallhaven.cc/small/x/x1.jpg"}}]}"#, + ) + .expect("parse"); + let item = &page.items[0]; + assert_eq!((item.width, item.height, item.file_size_bytes), (0, 0, 0)); + } + + #[test] + fn redirect_hops_are_held_to_the_same_allowlist_as_the_first_url() { + let allowed = |raw: &str| is_allowed_wallhaven_hop(&reqwest::Url::parse(raw).unwrap()); + assert!(allowed("https://w.wallhaven.cc/full/ab/x.jpg")); + assert!(allowed("https://wallhaven.cc/w/abc")); + // The shapes an open redirect would aim at: another host, a scheme + // downgrade, and the metadata/loopback endpoints a host-side proxy + // must never be steered onto. + assert!(!allowed("https://evil.example/x.jpg")); + assert!(!allowed("https://wallhaven.cc.evil/x.jpg")); + assert!(!allowed("http://wallhaven.cc/w/abc")); + assert!(!allowed("http://169.254.169.254/latest/meta-data/")); + assert!(!allowed("https://127.0.0.1/admin")); + } + + #[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..353941cf2f 100644 --- a/src-tauri/src/backgrounds/mod.rs +++ b/src-tauri/src/backgrounds/mod.rs @@ -13,7 +13,9 @@ use std::fs; use std::io::Write; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, SystemTime}; use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; use image::{ImageFormat, ImageReader}; @@ -22,6 +24,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; @@ -35,6 +39,15 @@ const MAX_BG_PIXELS: u64 = 40_000_000; /// Canonical on-disk filename. Extension-agnostic — the mime type is sniffed /// from the magic bytes on read, so a single path round-trips PNG/JPEG/WebP/GIF. const BACKGROUND_FILENAME: &str = "background.img"; +/// How long a staging file must sit untouched before a later write treats it as +/// crash debris and deletes it. Comfortably longer than any real write, so a +/// concurrent writer's in-flight staging file is never swept out from under it. +const STALE_TMP_AGE: Duration = Duration::from_secs(600); + +/// Disambiguates staging files between concurrent writers. Paired with the pid +/// it also separates two processes (desktop app + standalone server) pointed at +/// one `CODEG_DATA_DIR`. +static TMP_SEQ: AtomicU64 = AtomicU64::new(0); fn background_path() -> PathBuf { codeg_backgrounds_root().join(BACKGROUND_FILENAME) @@ -56,11 +69,18 @@ fn background_path() -> PathBuf { /// So the two guards bound different things, and neither bounds animation: /// `MAX_BG_PIXELS` caps the canvas (which every frame is confined to), and /// `MAX_BG_BYTES` caps the *input*, not the work a webview spends decoding a -/// long one. Frame count and per-frame LZW payloads go unchecked. That is -/// acceptable here because the file is explicit, local and user-picked behind -/// an authenticated command — not an adversarial upload — and because APNG and -/// animated WebP already reached the webview by this same route before GIF was -/// allowed. Treat it as a sanity bound, not a DoS boundary. +/// long one. Frame count and per-frame LZW payloads go unchecked. Treat this as +/// a sanity bound, not a DoS boundary. +/// +/// That framing was originally justified by every caller handing over a file the +/// user had picked from their own disk. `marketplace::download` widened it: the +/// bytes now come from a third-party upload site. The caps above are what still +/// applies to that path — the *decode* cost of a pathological animation inside +/// them does not, and the residual exposure is the user's own webview. It is +/// accepted knowingly, not overlooked: APNG and animated WebP already reached +/// the webview by this same route before GIF was allowed, and bounding frame +/// counts would mean re-encoding, which would break animated backgrounds +/// outright. pub fn validate_background(bytes: &[u8]) -> Result<(), AppCommandError> { if bytes.len() < MIN_BG_BYTES { return Err(AppCommandError::invalid_input( @@ -109,17 +129,77 @@ 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()?; + write_background_atomic_in(&root, bytes) +} + +/// Stage into a sibling file, then rename over the background. +/// +/// The staging name is **per-writer** (`background.img...tmp`) rather +/// than one shared `background.img.tmp`. Two writers do overlap in practice — +/// the wallpaper market's grid lets a second wallpaper be picked while the first +/// is still downloading — and a shared staging path made them fight over one +/// inode: the second `File::create` truncates what the first is still writing, +/// and whichever `rename` lands second fails with `ENOENT` because the other +/// already moved the file away. That surfaced as "download failed" on a download +/// that had in fact succeeded. With a private staging file each writer is a +/// clean last-writer-wins: `rename` is atomic, so a reader sees one whole image +/// either way, and neither writer can truncate the other's bytes. +/// +/// Takes `root` explicitly so tests can exercise it against a temp dir instead +/// of the process-global `CODEG_HOME`. +fn write_background_atomic_in(root: &Path, bytes: &[u8]) -> Result<(), AppCommandError> { + sweep_stale_staging_files(root); let final_path = root.join(BACKGROUND_FILENAME); - let tmp_path = root.join(format!("{BACKGROUND_FILENAME}.tmp")); - { + let tmp_path = root.join(format!( + "{BACKGROUND_FILENAME}.{}.{}.tmp", + std::process::id(), + TMP_SEQ.fetch_add(1, Ordering::Relaxed) + )); + let staged = (|| -> Result<(), AppCommandError> { let mut f = fs::File::create(&tmp_path).map_err(AppCommandError::io)?; f.write_all(bytes).map_err(AppCommandError::io)?; f.sync_all().map_err(AppCommandError::io)?; + drop(f); + fs::rename(&tmp_path, &final_path).map_err(AppCommandError::io) + })(); + if staged.is_err() { + // A private staging file is ours alone to remove, so a failed write + // never leaves debris behind for the sweep to find later. + let _ = fs::remove_file(&tmp_path); + } + staged +} + +/// Delete staging files left by a crash (or by a pre-`.` build, whose +/// name was the fixed `background.img.tmp`). Only long-untouched entries are +/// removed, so a staging file another writer is filling right now is safe. +/// Best-effort: a failure here must never fail the write that follows. +fn sweep_stale_staging_files(root: &Path) { + let prefix = format!("{BACKGROUND_FILENAME}."); + let Ok(entries) = fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + if !name.starts_with(&prefix) || !name.ends_with(".tmp") { + continue; + } + let stale = entry + .metadata() + .and_then(|m| m.modified()) + .map(|modified| { + SystemTime::now() + .duration_since(modified) + .is_ok_and(|age| age >= STALE_TMP_AGE) + }) + .unwrap_or(false); + if stale { + let _ = fs::remove_file(entry.path()); + } } - fs::rename(&tmp_path, &final_path).map_err(AppCommandError::io)?; - Ok(()) } fn decode_base64_payload(b64: &str) -> Result, AppCommandError> { @@ -188,10 +268,11 @@ pub fn clear_background() -> Result<(), AppCommandError> { mod tests { use super::*; - // Filesystem-touching paths depend on the global `CODEG_HOME`/`CODEG_DATA_DIR` - // env (shared, races under parallel tests), so — like `pets::tests` — we - // exercise the pure validation surface here and cover disk I/O via manual - // smoke tests. + // The env-resolved paths (`background_path`, `ensure_backgrounds_root`) + // depend on the global `CODEG_HOME`/`CODEG_DATA_DIR` (shared, races under + // parallel tests), so — like `pets::tests` — those are covered by manual + // smoke tests. `write_background_atomic_in` takes its root explicitly, so + // the staging/rename behaviour is testable against a temp dir. fn encode_png(w: u32, h: u32) -> Vec { let mut img = image::RgbaImage::new(w, h); @@ -282,4 +363,80 @@ mod tests { assert_eq!(sniff_mime(b"GIF89a\x00\x00\x00\x00abcd"), "image/gif"); assert_eq!(sniff_mime(b"GIF87a\x00\x00\x00\x00abcd"), "image/gif"); } + + /// Two writers racing on one background — what the market grid produces + /// when a second wallpaper is clicked mid-download. Both must report + /// success, and the file left behind must be exactly one of the two inputs, + /// never a splice of both. + #[test] + fn concurrent_writes_both_succeed_and_leave_one_whole_image() { + let dir = tempfile::tempdir().expect("tempdir"); + let a = vec![0xAAu8; 2 * 1024 * 1024]; + let b = vec![0xBBu8; 2 * 1024 * 1024]; + for _ in 0..8 { + std::thread::scope(|scope| { + let root = dir.path(); + let h1 = scope.spawn(|| write_background_atomic_in(root, &a)); + let h2 = scope.spawn(|| write_background_atomic_in(root, &b)); + h1.join().unwrap().expect("first writer"); + h2.join().unwrap().expect("second writer"); + }); + let out = fs::read(dir.path().join(BACKGROUND_FILENAME)).expect("background exists"); + assert!( + out == a || out == b, + "expected one whole image, got {} bytes mixing both", + out.len() + ); + } + } + + #[test] + fn a_failed_write_leaves_no_staging_file() { + let dir = tempfile::tempdir().expect("tempdir"); + // A directory where the final file goes makes `rename` fail after the + // staging file is fully written — the one window that used to leak. + fs::create_dir(dir.path().join(BACKGROUND_FILENAME)).expect("blocker"); + assert!(write_background_atomic_in(dir.path(), &encode_png(32, 32)).is_err()); + let leftovers: Vec<_> = fs::read_dir(dir.path()) + .expect("read_dir") + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|name| name.ends_with(".tmp")) + .collect(); + assert!(leftovers.is_empty(), "leaked staging files: {leftovers:?}"); + } + + #[test] + fn sweep_removes_crash_debris_but_spares_a_fresh_staging_file() { + let dir = tempfile::tempdir().expect("tempdir"); + // Named exactly like the pre-`.` builds' shared staging file. + let stale = dir.path().join(format!("{BACKGROUND_FILENAME}.tmp")); + let fresh = dir.path().join(format!("{BACKGROUND_FILENAME}.999.0.tmp")); + let unrelated = dir.path().join("notes.txt"); + for path in [&stale, &fresh, &unrelated] { + fs::write(path, b"x").expect("seed"); + } + let old = SystemTime::now() - STALE_TMP_AGE - Duration::from_secs(60); + // Opened for writing rather than with `File::open`: Windows backs + // `set_modified` with `SetFileTime`, which needs `FILE_WRITE_ATTRIBUTES` + // on the handle, and only a write-mode open asks for it. A read-only + // handle backdates happily on Unix — `futimens` gates an explicit + // timestamp on the file's ownership, not the descriptor's access mode — + // and fails with "Access is denied" on Windows. + fs::File::options() + .write(true) + .open(&stale) + .expect("open for backdating") + .set_modified(old) + .expect("backdate"); + + sweep_stale_staging_files(dir.path()); + + assert!(!stale.exists(), "crash debris should be swept"); + assert!( + fresh.exists(), + "a concurrent writer's staging file must survive" + ); + assert!(unrelated.exists(), "unrelated files must be untouched"); + } } 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/forge/deliver.rs b/src-tauri/src/forge/deliver.rs index b5e4a585a4..cab6c98dd7 100644 --- a/src-tauri/src/forge/deliver.rs +++ b/src-tauri/src/forge/deliver.rs @@ -31,6 +31,82 @@ use super::auth::ResolvedAuth; use super::{ gitea, github, gitlab, urlencode_query, web_origin, ForgeError, ForgeItemKind, ForgeProvider, }; +use crate::app_error::AppCommandError; + +/// Flatten a classified git failure into one line, git's own words included. +/// +/// `AppCommandError`'s `Display` is `{message}` alone, and +/// `classify_remote_git_error` puts everything specific — the whole of git's +/// stderr — in `detail`. A plain `to_string()` here therefore hands the caller +/// nothing but "git push failed", and the delivery path has no second channel +/// to the failure: that one string is what a human reads. Losing the detail +/// there once cost a reader a long detour, because the caller filled the +/// silence with a guess about repository permissions when git had actually +/// said the branch was out of date. +/// +/// The detail is bounded. It is arbitrary output from another program, and +/// this string does not just get read once — it is persisted on the task row +/// and rendered in the UI, so a server-side hook that answers a push with a +/// screenful of banner would otherwise all end up in the database. Git leads +/// with the part that identifies the failure and follows with hints, so a +/// prefix is the right thing to keep. +const MAX_GIT_DETAIL_CHARS: usize = 800; + +fn git_failure_message(err: AppCommandError) -> String { + match err + .detail + .as_deref() + .map(str::trim) + .filter(|d| !d.is_empty()) + { + Some(detail) => format!("{}: {}", err.message, truncate_chars(&redact_userinfo(detail))), + None => err.message, + } +} + +/// Blank out `scheme://user:secret@host` in text about to be shown and stored. +/// +/// The account's token never travels in a URL — it reaches git through +/// `GIT_ASKPASS` — so this is not about that. It is about the URL git echoes +/// back in its errors: `web_origin` returns a self-hosted `server_url` verbatim, +/// so a user who typed credentials into their own forge address would have them +/// come back out here, in a string that is persisted and rendered. Cheap to +/// scrub, and this is a failure path where nothing is worth that risk. +fn redact_userinfo(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(scheme_end) = rest.find("://") { + let authority_start = scheme_end + "://".len(); + // The authority runs to the first character that can only follow it. + // Quotes count: git wraps the URL in them ('https://…/repo.git/'). + let authority_end = rest[authority_start..] + .find(|c: char| matches!(c, '/' | '?' | '#' | '\'' | '"') || c.is_whitespace()) + .map(|i| authority_start + i) + .unwrap_or(rest.len()); + let authority = &rest[authority_start..authority_end]; + match authority.rfind('@') { + Some(at) => { + out.push_str(&rest[..authority_start]); + out.push_str("***@"); + out.push_str(&authority[at + 1..]); + } + None => out.push_str(&rest[..authority_end]), + } + rest = &rest[authority_end..]; + } + out.push_str(rest); + out +} + +/// Cut on a character boundary, never a byte one — git happily reports branch +/// names and hook banners in any encoding, and slicing those mid-codepoint +/// would panic on the failure path. +fn truncate_chars(text: &str) -> String { + match text.char_indices().nth(MAX_GIT_DETAIL_CHARS) { + Some((cut, _)) => format!("{}…", &text[..cut]), + None => text.to_string(), + } +} /// A pull request as far as delivery cares. Deliberately not the full API /// object: everything here is either an adoption criterion or shown to the user. @@ -553,9 +629,9 @@ async fn push_work_branch( .await .map_err(|e| format!("could not run git push: {e}"))?; if !output.status.success() { - return Err( - crate::commands::folders::classify_remote_git_error("push", &output.stderr).to_string(), - ); + return Err(git_failure_message( + crate::commands::folders::classify_remote_git_error("push", &output.stderr), + )); } Ok(()) } @@ -585,8 +661,9 @@ async fn fetch_into_ref( .await .map_err(|e| format!("could not run git fetch: {e}"))?; if !out.status.success() { - return Err(crate::commands::folders::classify_remote_git_error("fetch", &out.stderr) - .to_string()); + return Err(git_failure_message( + crate::commands::folders::classify_remote_git_error("fetch", &out.stderr), + )); } crate::work_task::git::rev_parse(repo_path, local_ref) .await @@ -815,6 +892,82 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; + /// The delivery error string is the only thing a human sees when a push + /// fails, and `classify_remote_git_error` files everything specific under + /// `detail`. `Display` prints `message` alone, so flattening has to reach + /// for the detail explicitly or the reader is left with "git push failed". + #[test] + fn a_flattened_git_failure_keeps_what_git_said() { + let stderr = b"! [rejected] task/57 -> feat/x (fetch first)\n\ + error: failed to push some refs"; + let flattened = git_failure_message(crate::commands::folders::classify_remote_git_error( + "push", stderr, + )); + assert!(flattened.starts_with("git push failed"), "{flattened}"); + assert!(flattened.contains("(fetch first)"), "{flattened}"); + } + + #[test] + fn a_flattened_failure_without_detail_stays_one_clause() { + let bare = AppCommandError::network("git push: network error"); + assert_eq!(git_failure_message(bare), "git push: network error"); + let blank = AppCommandError::network("git push: network error").with_detail(" "); + assert_eq!(git_failure_message(blank), "git push: network error"); + } + + /// Git echoes the remote URL in its errors, and `web_origin` hands back a + /// self-hosted `server_url` verbatim — so credentials a user typed into + /// their own forge address would otherwise come back out in a string that + /// is persisted and rendered. + #[test] + fn a_flattened_failure_blanks_credentials_git_echoed_back() { + let echoed = "fatal: unable to access \ + 'https://me:s3cret@git.example.com/team/app.git/': The requested URL returned \ + error: 403"; + let flattened = git_failure_message( + AppCommandError::network("git push failed").with_detail(echoed), + ); + assert!(!flattened.contains("s3cret"), "{flattened}"); + assert!(flattened.contains("https://***@git.example.com/team/app.git/"), "{flattened}"); + // The rest of git's sentence has to survive the scrub intact. + assert!(flattened.contains("returned error: 403"), "{flattened}"); + } + + #[test] + fn a_flattened_failure_leaves_a_credential_free_url_alone() { + let plain = "! [rejected] a -> b (fetch first)\nerror: failed to push some refs to \ + 'https://github.com/owner/repo.git'"; + let flattened = + git_failure_message(AppCommandError::network("git push failed").with_detail(plain)); + assert!( + flattened.contains("'https://github.com/owner/repo.git'"), + "{flattened}" + ); + assert!(!flattened.contains("***"), "{flattened}"); + } + + /// This string is persisted and rendered, and the detail is another + /// program's output — a hook that answers a push with a banner must not + /// land in the database whole. Multi-byte input is the case that would + /// panic if the cut were taken on bytes. + #[test] + fn a_flattened_failure_bounds_a_runaway_detail() { + let banner = "の".repeat(MAX_GIT_DETAIL_CHARS * 2); + let flattened = + git_failure_message(AppCommandError::network("git push failed").with_detail(&banner)); + assert!(flattened.starts_with("git push failed: の"), "{flattened}"); + assert!(flattened.ends_with('…'), "expected an elision marker"); + assert_eq!( + flattened.chars().count(), + "git push failed: ".chars().count() + MAX_GIT_DETAIL_CHARS + 1 + ); + // Exactly at the limit is kept whole, with no marker implying loss. + let exact = "x".repeat(MAX_GIT_DETAIL_CHARS); + let kept = + git_failure_message(AppCommandError::network("git push failed").with_detail(&exact)); + assert!(kept.ends_with('x'), "{kept}"); + } + fn pr(number: i64, head_sha: &str, head_ref: &str, base: &str, repo: &str) -> ForgePr { ForgePr { number, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 31874b9e10..07433b89ab 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1352,6 +1352,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 8b76f4e53e..a998666174 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -1574,6 +1574,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)) diff --git a/src-tauri/src/work_task/engine.rs b/src-tauri/src/work_task/engine.rs index a360791b38..c8674facca 100644 --- a/src-tauri/src/work_task/engine.rs +++ b/src-tauri/src/work_task/engine.rs @@ -4276,19 +4276,30 @@ impl TaskEngine { .push_branch(&ctx, wt_path, &push_repo, work_branch, remote_branch) .await .map_err(|e| { - if crate::forge::same_repo(&push_repo, &meta.owner_repo) { - format!("could not push back to '{remote_branch}': {e}") + let own_repo = crate::forge::same_repo(&push_repo, &meta.owner_repo); + let where_ = if own_repo { + format!("could not push back to '{remote_branch}'") } else { - // The push went to the FORK. The by-far most common - // refusal there is permission: forges only let this - // account push when the author allowed maintainer - // edits on the pull request. - format!( - "could not push back to '{remote_branch}' on {push_repo}: {e} — \ - pushing to a fork needs its author to allow edits from \ - maintainers on the {}", + format!("could not push back to '{remote_branch}' on {push_repo}") + }; + // A hint is only added when git actually said something we + // recognise. The fork case used to carry the permission + // hint unconditionally, which reads as a finding rather + // than the guess it was: a push refused for being out of + // date came back advising the reader to go change a + // setting on the pull request that was already correct. + match classify_push_refusal(&e) { + PushRefusal::Permission if !own_repo => format!( + "{where_}: {e} — pushing to a fork needs its author to allow edits \ + from maintainers on the {}", meta.provider.change_noun() - ) + ), + PushRefusal::BranchMoved => format!( + "{where_}: {e} — that branch has commits this task does not have, so \ + the push is not a fast-forward. Bring them into the task's branch, \ + then deliver again" + ), + _ => format!("{where_}: {e}"), } })?; @@ -5889,6 +5900,67 @@ fn is_queued_merge_superseded(error: &str) -> bool { error.contains("changed or withdrawn") } +/// What a refused push-back most likely means, read off git's own words. +/// +/// Deliberately narrow. Whatever this returns is printed to a human as advice, +/// so the only two shapes recognised are the ones git states plainly, and +/// everything else is `Unknown` — which prints no advice at all. An unhelpful +/// message costs a reader a moment; a confident wrong one sends them to change +/// a setting that was never the problem. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PushRefusal { + /// The remote refused the account: no write access, or a fork whose author + /// did not allow maintainer edits. + Permission, + /// The branch has commits the task's branch does not, so a fast-forward + /// push cannot apply. Routine on a long-lived pull request: the author + /// pushed, or merged the base branch in, after this task was triggered. + BranchMoved, + Unknown, +} + +/// Match on git's stderr, which reaches here inside the delivery error string. +/// Permission is tested first: a forge that refuses the push outright can also +/// mention "rejected", and being told the wrong one of these two is precisely +/// the failure this classification exists to stop. +fn classify_push_refusal(error: &str) -> PushRefusal { + let lower = error.to_lowercase(); + // The status codes are matched in the phrasing git and curl actually + // print, not as bare digits: "403" on its own also matches a branch called + // `fix/403-page` or a repository named after an issue number, and the whole + // point of this function is to stop it handing out confident wrong advice. + let permission = [ + "permission to", + "denied to", + "error: 403", + "error: 401", + "http 403", + "http 401", + "status code 403", + "status code 401", + "authentication failed", + "write access", + "read-only", + "protected branch", + "pre-receive hook declined", + ]; + if permission.iter().any(|needle| lower.contains(needle)) { + return PushRefusal::Permission; + } + let moved = [ + "non-fast-forward", + "fetch first", + "updates were rejected", + "[rejected]", + "behind its remote", + "stale info", + ]; + if moved.iter().any(|needle| lower.contains(needle)) { + return PushRefusal::BranchMoved; + } + PushRefusal::Unknown +} + /// The repository a pull-request task's push-back lands in: the HEAD /// repository recorded at trigger time — the fork, when the pull request comes /// from one. A row that recorded none falls back to the source repository: @@ -6879,6 +6951,73 @@ mod tests { #[cfg(not(windows))] const ABS_PREFIX: &str = ""; + /// The stderr shapes below are what git and the forges actually print. A + /// push-back refused for being out of date used to come back advising the + /// reader to turn on maintainer edits — a setting that, in the report that + /// prompted this, was already on. So the two must never be confused. + #[test] + fn a_stale_push_back_is_not_read_as_a_permission_problem() { + let out_of_date = "git push failed: ! [rejected] task/57 -> feat/x \ + (fetch first)\nerror: failed to push some refs to \ + 'https://github.com/owner/repo.git'\nhint: Updates were rejected because the \ + remote contains work that you do not have locally."; + assert_eq!(classify_push_refusal(out_of_date), PushRefusal::BranchMoved); + assert_eq!( + classify_push_refusal("git push failed: ! [rejected] a -> b (non-fast-forward)"), + PushRefusal::BranchMoved + ); + } + + #[test] + fn a_refused_fork_push_is_read_as_a_permission_problem() { + let denied = "git push: authentication failed. Configure a GitHub account in \ + Settings → Version Control.: remote: Permission to author/repo.git denied to \ + maintainer.\nfatal: unable to access \ + 'https://github.com/author/repo.git/': The requested URL returned error: 403"; + assert_eq!(classify_push_refusal(denied), PushRefusal::Permission); + assert_eq!( + classify_push_refusal( + "remote: GitLab: You are not allowed to push code to \ + protected branches on this project." + ), + PushRefusal::Permission + ); + // A forge that refuses outright can also say "rejected"; permission + // has to win, or the advice sends the reader to rebase for nothing. + assert_eq!( + classify_push_refusal( + "! [remote rejected] a -> b (pre-receive hook declined)\nerror: failed to push" + ), + PushRefusal::Permission + ); + } + + /// Anything unrecognised must stay `Unknown`, because `Unknown` is what + /// prints no advice — the whole point of the split. + #[test] + fn an_unrecognised_push_failure_gets_no_advice() { + assert_eq!( + classify_push_refusal("git push failed: fatal: the remote end hung up unexpectedly"), + PushRefusal::Unknown + ); + assert_eq!(classify_push_refusal(""), PushRefusal::Unknown); + } + + /// A status code has to be matched in the phrasing that carries it. Branch + /// and repository names are part of every push error, and plenty of them + /// are named after an issue number. + #[test] + fn a_number_in_a_branch_name_is_not_a_status_code() { + let stale_on_an_issue_branch = "git push failed: ! [rejected] fix/403-page -> \ + fix/401-redirect (fetch first)\nerror: failed to push some refs to \ + 'https://github.com/owner/repo-403.git'"; + assert_eq!( + classify_push_refusal(stale_on_an_issue_branch), + PushRefusal::BranchMoved, + "a 403 in a ref name must not be read as a permission refusal" + ); + } + #[test] fn worktree_names_carry_ids() { assert_eq!(basename("/home/me/repo"), "repo"); diff --git a/src/components/appearance-provider.tsx b/src/components/appearance-provider.tsx index 3daf9a9d0f..6ca6f137ad 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(() => { @@ -956,6 +1004,12 @@ export function AppearanceProvider({ if (e.key === STORAGE_KEY_WORKSPACE_BG_IMAGE_VERSION) { void reloadWorkspaceBackgroundImage() } + // 来源页跟着图一起变(另一窗口换成市场图 / 本地图 / 移除)。不同步的话本窗口 + // 的市场面板会继续把一张已被换掉的壁纸标成「使用中」—— 那不是标记丢了, + // 而是标记在说谎。removeItem 时 newValue 为 null。 + if (e.key === STORAGE_KEY_WORKSPACE_BG_SOURCE_URL) { + setWorkspaceBgSourceUrlState(e.newValue ?? null) + } // 自定义样式跨窗口同步。主题与 CSS 都要顺手重置防抖基线,否则本窗口会把 // 刚收到的别人的值当成本地编辑再写回去,两个窗口互相回声。 if (e.key === STORAGE_KEY_CUSTOM_THEME) { @@ -1033,7 +1087,9 @@ export function AppearanceProvider({ setWorkspaceBgFillMode, workspaceBgImageUrl, setWorkspaceBackgroundImage, + downloadMarketWorkspaceBackground, removeWorkspaceBackground, + workspaceBgSourceUrl, isDarkMode, customTheme, setCustomThemeToken, 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..a5c8a27b4b --- /dev/null +++ b/src/components/settings/workspace-background-market-dialog.test.tsx @@ -0,0 +1,286 @@ +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() + +// Only the transport call is stubbed; the pure helpers (blocker/formatting) are +// the thing under test on the "unavailable" path, so they run for real. +vi.mock("@/lib/workspace-background-market", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("@/lib/workspace-background-market") + >()), + searchWorkspaceBgMarket: (input: unknown) => searchMock(input), +})) + +// The real hook mints blob URLs, which jsdom does not implement. 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, + }), +})) + +// Echoes params back, because several strings (the card's accessible name, the +// "too large" hint) carry their meaning entirely in the interpolated values. +vi.mock("next-intl", () => ({ + useTranslations: + (ns: string) => (key: string, params?: Record) => + params + ? `${ns}.${key}(${Object.entries(params) + .map(([k, v]) => `${k}=${v}`) + .join(",")})` + : `${ns}.${key}`, +})) + +import { WorkspaceBackgroundMarketDialog } from "./workspace-background-market-dialog" + +const NS = "AppearanceSettings.workspaceBackground.market" + +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", + width: 1920, + height: 1080, + fileSizeBytes: 4_455_320, + category: "general", +} + +/** Past the backend's 16 MiB ceiling — clicking it could only ever fail. */ +const OVERSIZED: MarketWallpaper = { + ...ITEM, + id: "huge9", + sourceUrl: "https://wallhaven.cc/w/huge9", + fileSizeBytes: 20 * 1024 * 1024, +} + +function renderDialog( + props: Partial<{ appliedSourceUrl: string | null }> = {} +) { + const onApply = vi.fn().mockResolvedValue(undefined) + render( + {}} + appliedSourceUrl={props.appliedSourceUrl ?? null} + onApply={onApply} + /> + ) + return { onApply } +} + +beforeEach(() => { + searchMock.mockReset() +}) + +describe("WorkspaceBackgroundMarketDialog", () => { + it("renders listing items once loaded", async () => { + searchMock.mockResolvedValue({ items: [ITEM], page: 1, lastPage: 3 }) + renderDialog() + 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 } = renderDialog({ + appliedSourceUrl: "https://wallhaven.cc/w/abc123", + }) + await waitFor(() => + expect(screen.getByText("1920×1080")).toBeInTheDocument() + ) + expect(screen.getByText(`${NS}.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, with the backend's reason", async () => { + searchMock.mockRejectedValue(new Error("wallhaven returned HTTP 429")) + renderDialog() + await waitFor(() => + expect(screen.getByText(`${NS}.error`)).toBeInTheDocument() + ) + expect(screen.getByText("wallhaven returned HTTP 429")).toBeInTheDocument() + + searchMock.mockResolvedValue({ items: [ITEM], page: 1, lastPage: 1 }) + // The toolbar's refresh button and this one no longer share a name. + await userEvent.click(screen.getByRole("button", { name: `${NS}.retry` })) + await waitFor(() => + expect(screen.getByText("1920×1080")).toBeInTheDocument() + ) + }) + + it("refuses a wallpaper the backend's size caps would reject, saying why", async () => { + searchMock.mockResolvedValue({ + items: [OVERSIZED], + page: 1, + lastPage: 1, + }) + const { onApply } = renderDialog() + await waitFor(() => + expect(screen.getByText(`${NS}.unavailable`)).toBeInTheDocument() + ) + const card = screen.getByRole("button", { name: /huge9/ }) + expect(card).toBeDisabled() + // The reason names the actual size and the ceiling, not just "failed" — + // on screen (a disabled button's tooltip is unreliable) and to AT. + expect(screen.getByText("20.0 MB")).toBeInTheDocument() + expect(card).toHaveAccessibleName(/20\.0 MB/) + expect(card).toHaveAccessibleName(/16\.0 MB/) + await userEvent.click(card) + expect(onApply).not.toHaveBeenCalled() + }) + + it("locks the whole grid while a download is in flight", async () => { + const second = { + ...ITEM, + id: "def456", + sourceUrl: "https://wallhaven.cc/w/def456", + } + searchMock.mockResolvedValue({ + items: [ITEM, second], + page: 1, + lastPage: 1, + }) + // Never settles: two concurrent downloads would race over one background + // file, so the second card must be unreachable while the first is running. + render( + {}} + appliedSourceUrl={null} + onApply={() => new Promise(() => {})} + /> + ) + await waitFor(() => + expect(screen.getByRole("button", { name: /abc123/ })).toBeEnabled() + ) + await userEvent.click(screen.getByRole("button", { name: /abc123/ })) + await waitFor(() => + expect(screen.getByRole("button", { name: /def456/ })).toBeDisabled() + ) + expect(screen.getByRole("button", { name: /abc123/ })).toBeDisabled() + }) + + it("labels and pages from the page wallhaven reported", async () => { + searchMock.mockImplementation(({ page }: { page: number }) => + Promise.resolve({ items: [ITEM], page, lastPage: 5 }) + ) + renderDialog() + await waitFor(() => + expect( + screen.getByText(`${NS}.pageInfo(page=1,lastPage=5)`) + ).toBeInTheDocument() + ) + await userEvent.click( + screen.getByRole("button", { name: `${NS}.nextPage` }) + ) + await waitFor(() => + expect( + screen.getByText(`${NS}.pageInfo(page=2,lastPage=5)`) + ).toBeInTheDocument() + ) + expect(searchMock).toHaveBeenLastCalledWith({ + query: "", + category: "all", + page: 2, + }) + }) + + it("keeps a page turned right after opening, once the search debounce lands", async () => { + // The debounce timer is queued at mount too. Unconditionally resetting the + // page when it fires snapped anyone who paged inside that 300 ms window + // back to page 1, just as their next page finished loading. + searchMock.mockImplementation(({ page }: { page: number }) => + Promise.resolve({ items: [ITEM], page, lastPage: 5 }) + ) + renderDialog() + await waitFor(() => + expect( + screen.getByText(`${NS}.pageInfo(page=1,lastPage=5)`) + ).toBeInTheDocument() + ) + await userEvent.click( + screen.getByRole("button", { name: `${NS}.nextPage` }) + ) + await waitFor(() => + expect( + screen.getByText(`${NS}.pageInfo(page=2,lastPage=5)`) + ).toBeInTheDocument() + ) + // Outlast the debounce, then confirm nothing yanked the page back. + await new Promise((resolve) => setTimeout(resolve, 500)) + expect( + screen.getByText(`${NS}.pageInfo(page=2,lastPage=5)`) + ).toBeInTheDocument() + expect(searchMock).toHaveBeenLastCalledWith({ + query: "", + category: "all", + page: 2, + }) + }) + + it("settles on the served page instead of sticking when wallhaven clamps", async () => { + // Claims five pages but never serves past two — the shape that used to let + // next/prev compute forever from a page number nothing would ever return. + searchMock.mockImplementation(({ page }: { page: number }) => + Promise.resolve({ items: [ITEM], page: Math.min(page, 2), lastPage: 5 }) + ) + renderDialog() + const next = () => + userEvent.click(screen.getByRole("button", { name: `${NS}.nextPage` })) + await waitFor(() => + expect( + screen.getByText(`${NS}.pageInfo(page=1,lastPage=5)`) + ).toBeInTheDocument() + ) + await next() + await waitFor(() => + expect( + screen.getByText(`${NS}.pageInfo(page=2,lastPage=5)`) + ).toBeInTheDocument() + ) + await next() + // It really does ask for 3 … + await waitFor(() => + expect(searchMock).toHaveBeenCalledWith({ + query: "", + category: "all", + page: 3, + }) + ) + // … and lands back on the page it was actually given, without looping. + await waitFor(() => + expect( + screen.getByText(`${NS}.pageInfo(page=2,lastPage=5)`) + ).toBeInTheDocument() + ) + const settled = searchMock.mock.calls.length + expect(settled).toBeLessThanOrEqual(5) + + // And "next" still works after the bounce: the cursor was reconciled onto + // the served page, so a second click cannot resolve to the page already + // loaded and silently issue nothing. (The request that lands *last* is the + // reconcile back to 2 — what matters is that 3 was asked for again.) + const asksForThree = () => + searchMock.mock.calls.filter((call) => call[0]?.page === 3).length + expect(asksForThree()).toBe(1) + await next() + await waitFor(() => expect(asksForThree()).toBe(2)) + expect(searchMock.mock.calls.length).toBeGreaterThan(settled) + }) +}) 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..c0e5e16441 --- /dev/null +++ b/src/components/settings/workspace-background-market-dialog.tsx @@ -0,0 +1,409 @@ +"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, + DialogDescription, + 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 { toErrorMessage } from "@/lib/app-error" +import { + MARKET_CATEGORIES, + formatMarketBytes, + formatMarketPixels, + formatMarketResolution, + marketWallpaperBlocker, + searchWorkspaceBgMarket, + type MarketCategory, + type MarketWallpaper, +} from "@/lib/workspace-background-market" +import { + MAX_WORKSPACE_BG_BYTES, + MAX_WORKSPACE_BG_PIXELS, +} from "@/lib/workspace-background" +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, + busy, + onApply, +}: { + wallpaper: MarketWallpaper + applied: boolean + /** 这张图正在下载(本卡片转圈)。 */ + downloading: boolean + /** 任意一张图正在下载 —— 全网格禁用,见 onCardApply 的单飞注释。 */ + busy: boolean + onApply: (wallpaper: MarketWallpaper) => void +}) { + const t = useTranslations("AppearanceSettings.workspaceBackground.market") + const thumb = useProxiedBackgroundThumb(wallpaper.thumbUrl) + const resolution = formatMarketResolution(wallpaper) + // 超出后端 16 MiB / 40 Mpx 上限的图点了必失败,且重试永远不会成功 —— + // 与其发一条「请重试」的假建议,不如在卡片上就说清楚。 + const blocker = marketWallpaperBlocker(wallpaper) + const blockerHint = + blocker === "tooManyBytes" + ? t("tooLarge", { + actual: formatMarketBytes(wallpaper.fileSizeBytes), + limit: formatMarketBytes(MAX_WORKSPACE_BG_BYTES), + }) + : blocker === "tooManyPixels" + ? t("tooLarge", { + actual: formatMarketPixels(wallpaper.width * wallpaper.height), + limit: formatMarketPixels(MAX_WORKSPACE_BG_PIXELS), + }) + : null + const sizeChip = formatMarketBytes(wallpaper.fileSizeBytes) + + // aria-label 覆盖按钮内容,所以徽标文字得手动并进来,否则「使用中 / 不可用」 + // 对读屏用户就消失了。 + const label = [ + t("cardLabel", { id: wallpaper.id }), + resolution, + applied ? t("applied") : null, + blockerHint, + ] + .filter(Boolean) + .join(" · ") + + 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") + // `page` 是请求游标;`shownPage` 是上游确认的那一页(wallhaven 会把越界页夹住), + // 翻页从 shownPage 起算,所以夹住之后按钮不会卡在一个不存在的页码上。 + const [page, setPage] = useState(1) + const [shownPage, setShownPage] = useState(1) + const [items, setItems] = useState([]) + const [lastPage, setLastPage] = useState(1) + const [loading, setLoading] = useState(false) + // 失败详情(headline 在渲染期翻译,避免把 t 引进 load 的依赖)。 + const [error, setError] = useState(null) + const [downloadingId, setDownloadingId] = useState(null) + // 单飞闸的真相在 ref 而不是 state:禁用其余卡片要等一次重渲染,同一帧内落到 + // 两张卡上的点击都会看到旧的 null,而 ref 是同步的。 + const downloadingRef = useRef(null) + // 代次守卫:慢请求晚归不覆盖新请求的结果(与宠物市场同款问题)。 + const requestSeq = useRef(0) + + // 搜索框 debounce;输入变化回到第 1 页。 + // + // 只在 trim 后的词真的变了才动 —— 挂载时也会排一个定时器,300ms 后落地。若它 + // 无条件 setPage(1),刚打开面板就翻页的人会在下一页刚出来时被弹回第 1 页; + // 同理,输入后又删回原样也不该丢掉当前页。 + const committedQuery = useRef("") + useEffect(() => { + const handle = setTimeout(() => { + const next = searchInput.trim() + if (next === committedQuery.current) return + committedQuery.current = next + setQuery(next) + 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(null) + setShownPage(p) + try { + const result = await searchWorkspaceBgMarket({ + query: q, + category: c, + page: p, + }) + if (seq !== requestSeq.current) return + setItems(result.items) + setLastPage(result.lastPage) + setShownPage(result.page) + // 把游标拉到上游真正给的那一页,翻页才总是从一页真实存在的页起算。 + // 只往回拉,不追着往前跑:夹页(要第 9 页、给第 5 页)是唯一现实的分歧, + // 而「给的比要的大」若每次都发生,追下去就是一个由远端应答驱动、没有上限 + // 的请求循环。往回拉最多再对齐一次就收敛。 + if (result.page < p) setPage(result.page) + } catch (err) { + if (seq !== requestSeq.current) return + setItems([]) + setLastPage(1) + setError(toErrorMessage(err)) + } 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) => { + if (downloadingRef.current !== null) return + downloadingRef.current = wallpaper.id + setDownloadingId(wallpaper.id) + try { + await onApply(wallpaper.fullUrl, wallpaper.sourceUrl) + toast.success(t("appliedToast")) + } catch (err) { + toast.error(t("downloadFailed"), { description: toErrorMessage(err) }) + } finally { + downloadingRef.current = null + setDownloadingId(null) + } + } + + return ( + + + + + + {t("title")} + + + {t("description")} · {t("credit")} + + + + {/* 搜索 + 分类 */} +
+ setSearchInput(e.target.value)} + placeholder={t("searchPlaceholder")} + aria-label={t("searchPlaceholder")} + className="h-8 w-56" + /> +
+ {MARKET_CATEGORIES.map((c) => ( + + ))} +
+ +
+ + {/* 网格 / 三态 */} + + {error !== null ? ( +
+

{t("error")}

+ {error && ( +

+ {error} +

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

+ {t("empty")} +

+ ) : ( +
+ {items.map((w) => ( + void onCardApply(item)} + /> + ))} +
+ )} +
+ + {/* 分页 */} +
+ + {t("pageInfo", { page: shownPage, 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 && (