Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
479 changes: 479 additions & 0 deletions src-tauri/src/backgrounds/marketplace.rs

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion src-tauri/src/backgrounds/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -109,7 +111,7 @@ fn ensure_backgrounds_root() -> Result<PathBuf, AppCommandError> {
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"));
Expand Down
83 changes: 81 additions & 2 deletions src-tauri/src/commands/background.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) ──────────────────────────────────────────────
Expand Down Expand Up @@ -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<MarketSearchPage, AppCommandError> {
background_marketplace::search(params).await
}

pub async fn background_market_asset_core(url: String) -> Result<BackgroundAsset, AppCommandError> {
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<String>,
pub category: Option<String>,
pub page: Option<u32>,
}

#[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<String>,
category: Option<String>,
page: Option<u32>,
) -> Result<MarketSearchPage, AppCommandError> {
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<BackgroundAsset, AppCommandError> {
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
}
3 changes: 3 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
36 changes: 34 additions & 2 deletions src-tauri/src/web/handlers/background.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
//! Axum handlers mirroring `commands::background`. All three are stateless
//! (disk-only), so none take `Extension<Arc<AppState>>`.
//! Axum handlers mirroring `commands::background`. All of them are stateless
//! (disk-only or proxied fetch), so none take `Extension<Arc<AppState>>`.

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<Json<Option<BackgroundAsset>>, AppCommandError> {
Expand All @@ -23,3 +27,31 @@ pub async fn background_set(
pub async fn background_clear() -> Result<Json<()>, AppCommandError> {
background_commands::background_clear_core().await.map(Json)
}

pub async fn background_market_search(
Json(params): Json<BackgroundMarketSearchParams>,
) -> Result<Json<MarketSearchPage>, 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<BackgroundMarketAssetParams>,
) -> Result<Json<BackgroundAsset>, AppCommandError> {
background_commands::background_market_asset_core(params.url)
.await
.map(Json)
}

pub async fn background_market_download(
Json(params): Json<BackgroundMarketDownloadParams>,
) -> Result<Json<()>, AppCommandError> {
background_commands::background_market_download_core(params.url, params.source_url)
.await
.map(Json)
}
12 changes: 12 additions & 0 deletions src-tauri/src/web/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1550,6 +1550,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))
Expand Down
52 changes: 51 additions & 1 deletion src/components/appearance-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -156,6 +158,13 @@ type AppearanceContextValue = {
setWorkspaceBackgroundImage: (imageBase64: string) => Promise<void>
/** 移除背景图片(删盘 + revoke blob URL)。 */
removeWorkspaceBackground: () => Promise<void>
/** 从壁纸市场下载并应用背景。写盘在后端,成功后与本地选图共用同一套失效 + 重读盘。 */
downloadMarketWorkspaceBackground: (
url: string,
sourceUrl: string
) => Promise<void>
/** 当前背景的市场来源页(https://wallhaven.cc/w/<id>);本地图 / 未设置为 null。 */
workspaceBgSourceUrl: string | null
/** 当前解析出的明暗模式(读 <html> 的 dark 类,非 next-themes 的 resolvedTheme)。 */
isDarkMode: boolean
/** 主题 token 覆盖(明暗两套,键名不带 `--`,= shadcn cssVars 形状)。 */
Expand Down Expand Up @@ -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 首次运行写的是同一份值,幂等)。
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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
Expand All @@ -680,7 +728,7 @@ export function AppearanceProvider({
revokeBackgroundObjectUrl(prev)
return null
})
}, [])
}, [clearWorkspaceBgSourceUrl])

// Sync traffic-light position and appearance mode on mount
useEffect(() => {
Expand Down Expand Up @@ -1033,7 +1081,9 @@ export function AppearanceProvider({
setWorkspaceBgFillMode,
workspaceBgImageUrl,
setWorkspaceBackgroundImage,
downloadMarketWorkspaceBackground,
removeWorkspaceBackground,
workspaceBgSourceUrl,
isDarkMode,
customTheme,
setCustomThemeToken,
Expand Down
Loading