diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index fd163d2..bb18e21 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,28 @@ --- +## v1.5.0(2026-10-28) + +### ✨ 新增功能 + +- **跟随系统主题**:「设置 - 外观」新增「跟随系统」,可随操作系统的明暗主题自动切换;也可继续手动选择浅色或深色主题。 +- **自定义窗口背景**:可在「设置 - 外观」中选择或更换自己的背景图片,支持 PNG、JPG 和 WebP,单张图片不超过 15 MB。图片会保存到应用数据目录,重新打开软件后仍可使用。 +- **窗口背景遮罩**:在窗口背景上叠加随明暗主题变化的遮罩,并可在外观设置中单独调整透明度,改善背景与顶部栏操作图标之间的对比度。 + +### 🎨 优化 + +- **主题背景**:默认背景颜色改为适配明暗主题的纯色,并调整内置背景图片与明暗主题下的整体观感。 +- **折叠侧栏资源占用**:菜单栏折叠后直接显示 CPU、内存百分比和运行状态,无需悬停查看。 +- **系统资源数据复用**:仪表盘与侧栏共享 CPU、内存占用数据缓存,减少进入仪表盘后等待数据加载的时间。 + +### 📁 数据与日志 + +- **Frpc 运行日志持久化**:运行日志按日期追加保存到安装目录的 `logs/frpc-YYYY-MM-DD.log`,包含 Frpc 输出及启动、停止和退出等状态记录。 + +### 🧱 工程 + +- 应用版本升级至 **v1.5.0**。 + ## v1.4.1(2026-10-26) ### 🎨 优化 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 05334ec..3980c65 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1843,6 +1843,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + [[package]] name = "httparse" version = "1.10.1" @@ -4595,6 +4601,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", + "http-range", "jni 0.21.1", "libc", "log", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index aa08d8d..46771da 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,7 +22,7 @@ tauri-build = { version = "2", features = [] } tauri-helper = "0.2.1" [dependencies] -tauri = { version = "2.12.3", features = ["tray-icon"] } +tauri = { version = "2.12.3", features = ["protocol-asset", "tray-icon"] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src-tauri/src/commands/background.rs b/src-tauri/src/commands/background.rs new file mode 100644 index 0000000..551470e --- /dev/null +++ b/src-tauri/src/commands/background.rs @@ -0,0 +1,41 @@ +use tauri::{AppHandle, State}; +use tauri_helper::auto_collect_command; +use tauri_plugin_dialog::DialogExt; + +use crate::models::ApiResponse; +use crate::services::background::{find_custom_background, import_custom_background}; +use crate::services::storage::StoragePaths; + +/// 打开原生文件选择器并将图片复制到应用数据目录。 +#[tauri::command] +#[auto_collect_command] +pub async fn choose_custom_background( + app: AppHandle, + paths: State<'_, StoragePaths>, +) -> Result>, String> { + let picked = app + .dialog() + .file() + .add_filter("图片", &["png", "jpg", "jpeg", "webp"]) + .set_title("选择背景图片") + .blocking_pick_file(); + let Some(file) = picked else { + return Ok(ApiResponse::success(None)); + }; + let source = file.into_path().map_err(|e| e.to_string())?; + let saved = import_custom_background(&paths, &source)?; + Ok(ApiResponse::success(Some( + saved.to_string_lossy().into_owned(), + ))) +} + +/// 获取已保存的自定义背景路径,图片内容通过受限的 asset 协议读取。 +#[tauri::command] +#[auto_collect_command] +pub async fn get_custom_background( + paths: State<'_, StoragePaths>, +) -> Result>, String> { + Ok(ApiResponse::success( + find_custom_background(&paths).map(|path| path.to_string_lossy().into_owned()), + )) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2ffd9d6..bb1f93b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod background; pub mod frp; pub mod runner; pub mod server; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7f7182a..71931c7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ mod services; use tauri::{Manager, RunEvent}; use tauri_plugin_autostart::ManagerExt as _; +use crate::commands::background::{choose_custom_background, get_custom_background}; use crate::commands::frp::{ delete_frp, download_frp, get_frp_downloads, get_frp_releases, import_frp, }; diff --git a/src-tauri/src/services/background.rs b/src-tauri/src/services/background.rs new file mode 100644 index 0000000..a5c179b --- /dev/null +++ b/src-tauri/src/services/background.rs @@ -0,0 +1,137 @@ +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use crate::services::storage::StoragePaths; + +const MAX_BACKGROUND_SIZE: u64 = 15 * 1024 * 1024; + +pub fn import_custom_background(paths: &StoragePaths, source: &Path) -> Result { + let size = fs::metadata(source) + .map_err(|e| format!("读取图片信息失败:{e}"))? + .len(); + if size == 0 || size > MAX_BACKGROUND_SIZE { + return Err("图片大小必须在 1 字节到 15 MB 之间".into()); + } + + let bytes = fs::read(source).map_err(|e| format!("读取背景图片失败:{e}"))?; + if bytes.is_empty() || bytes.len() as u64 > MAX_BACKGROUND_SIZE { + return Err("图片大小必须在 1 字节到 15 MB 之间".into()); + } + let extension = detect_extension(&bytes).ok_or("仅支持 PNG、JPG 和 WebP 图片")?; + let directory = paths.data().join("backgrounds"); + fs::create_dir_all(&directory).map_err(|e| format!("创建背景图片目录失败:{e}"))?; + + let destination = directory.join(format!("custom.{extension}")); + let temporary = directory.join(format!("custom.{extension}.tmp")); + let mut file = OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&temporary) + .map_err(|e| format!("创建背景图片文件失败:{e}"))?; + if let Err(error) = file.write_all(&bytes).and_then(|()| file.sync_all()) { + drop(file); + let _ = fs::remove_file(&temporary); + return Err(format!("保存背景图片失败:{error}")); + } + drop(file); + fs::rename(&temporary, &destination).map_err(|e| { + let _ = fs::remove_file(&temporary); + format!("更新背景图片失败:{e}") + })?; + + for stale_extension in ["png", "jpg", "webp"] { + if stale_extension != extension { + let stale = directory.join(format!("custom.{stale_extension}")); + let _ = fs::remove_file(stale); + } + } + + Ok(destination) +} + +pub fn find_custom_background(paths: &StoragePaths) -> Option { + ["png", "jpg", "webp"] + .into_iter() + .map(|extension| { + paths + .data() + .join("backgrounds") + .join(format!("custom.{extension}")) + }) + .filter(|path| path.is_file()) + .max_by_key(|path| { + fs::metadata(path) + .and_then(|metadata| metadata.modified()) + .unwrap_or(std::time::UNIX_EPOCH) + }) +} + +fn detect_extension(bytes: &[u8]) -> Option<&'static str> { + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + Some("png") + } else if bytes.starts_with(b"\xff\xd8\xff") { + Some("jpg") + } else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { + Some("webp") + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn imports_supported_image_and_replaces_previous_format() { + let root = std::env::temp_dir().join(format!( + "frpcpilot-background-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&root).unwrap(); + let paths = StoragePaths { root: root.clone() }; + let source = root.join("source-image.bin"); + let png = b"\x89PNG\r\n\x1a\nimage-data"; + fs::write(&source, png).unwrap(); + + let saved = import_custom_background(&paths, &source).unwrap(); + + assert_eq!(saved, paths.data().join("backgrounds/custom.png")); + assert_eq!(fs::read(&saved).unwrap(), png); + assert_eq!(find_custom_background(&paths), Some(saved.clone())); + + let jpeg = b"\xff\xd8\xffimage-data"; + fs::write(&source, jpeg).unwrap(); + let replaced = import_custom_background(&paths, &source).unwrap(); + assert_eq!(replaced, paths.data().join("backgrounds/custom.jpg")); + assert!(!saved.exists()); + assert_eq!(fs::read(&replaced).unwrap(), jpeg); + assert_eq!(find_custom_background(&paths), Some(replaced)); + fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn rejects_non_image_files() { + let root = std::env::temp_dir().join(format!( + "frpcpilot-background-invalid-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&root).unwrap(); + let paths = StoragePaths { root: root.clone() }; + let source = root.join("not-an-image"); + fs::write(&source, b"not an image").unwrap(); + + assert!(import_custom_background(&paths, &source).is_err()); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 651166e..e6a8033 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,3 +1,4 @@ +pub mod background; pub mod config; pub mod db; pub mod runner; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 0911fa8..f2eacac 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -22,7 +22,11 @@ } ], "security": { - "csp": null + "csp": null, + "assetProtocol": { + "enable": true, + "scope": ["$APPDATA/backgrounds/**"] + } } }, "bundle": { diff --git a/src/api/background.ts b/src/api/background.ts new file mode 100644 index 0000000..0c040c4 --- /dev/null +++ b/src/api/background.ts @@ -0,0 +1,20 @@ +import { invoke } from "@tauri-apps/api/core" +import type { ApiResponse } from "@/types/api" + +async function call(command: string): Promise { + const response = await invoke>(command) + if (!response?.success) { + throw new Error(response?.msg || "操作失败") + } + return response.data as T +} + +/** 让用户选择并持久化一张自定义背景图片,取消时返回 null。 */ +export function chooseCustomBackground() { + return call("choose_custom_background") +} + +/** 获取已保存的自定义背景图片路径。 */ +export function getCustomBackground() { + return call("get_custom_background") +} diff --git a/src/config/backgrounds.ts b/src/config/backgrounds.ts index 07f9c76..2c8fe4d 100644 --- a/src/config/backgrounds.ts +++ b/src/config/backgrounds.ts @@ -9,7 +9,7 @@ import bg8 from "@/assets/images/bg-8.webp" import bg9 from "@/assets/images/bg-9.webp" /** 窗口背景图片标识 */ -export type BackgroundKey = "none" | "bg-1" | "bg-2" | "bg-3" | "bg-4" | "bg-5" | "bg-6" | "bg-7" | "bg-8" | "bg-9" +export type BackgroundKey = "none" | "custom" | "bg-1" | "bg-2" | "bg-3" | "bg-4" | "bg-5" | "bg-6" | "bg-7" | "bg-8" | "bg-9" export interface BackgroundOption { /** 唯一标识 */ @@ -38,5 +38,6 @@ export const DEFAULT_BACKGROUND: BackgroundKey = "none" /** 根据标识取图片地址 */ export function getBackgroundUrl(key: BackgroundKey): string { + if (key === "custom") return "" return backgroundOptions.find((item) => item.key === key)?.url ?? "" } diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 1c735ca..0cdab42 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -127,6 +127,9 @@ "system": "System", "background": "Background image", "backgroundTip": "Pick an image as the window background. It keeps its original aspect ratio and is never stretched.", + "customBackground": "Custom", + "chooseCustomBackground": "Choose image", + "changeCustomBackground": "Change custom background", "backgroundOverlay": "Window background overlay", "backgroundOverlayTip": "Apply a theme-aware overlay across the window to improve contrast with title bar icons.", "overlayTransparency": "Overlay transparency", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index a7ba995..fa17c28 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -127,6 +127,9 @@ "system": "跟随系统", "background": "背景图片", "backgroundTip": "选择一张图片作为窗口背景,图片保持原始比例自适应,不会变形拉伸", + "customBackground": "自定义", + "chooseCustomBackground": "选择图片", + "changeCustomBackground": "更换自定义背景", "backgroundOverlay": "窗口背景遮罩", "backgroundOverlayTip": "为整个窗口叠加随主题变化的遮罩,改善背景与顶部栏图标的对比度", "overlayTransparency": "遮罩透明度", diff --git a/src/stores/app.ts b/src/stores/app.ts index daabbf3..13bf1ac 100644 --- a/src/stores/app.ts +++ b/src/stores/app.ts @@ -1,5 +1,7 @@ import { defineStore } from "pinia" +import { convertFileSrc } from "@tauri-apps/api/core" import { preferences } from "@/utils/preferences" +import { chooseCustomBackground as chooseCustomBackgroundFile, getCustomBackground } from "@/api/background" import zhCN from "ant-design-vue/es/locale/zh_CN" import enUS from "ant-design-vue/es/locale/en_US" import { setI18nLanguage, type Language } from "@/i18n" @@ -8,6 +10,7 @@ import { getBackgroundUrl, type BackgroundKey, } from "@/config/backgrounds" +import { isTauri } from "@/utils/window" /** Frpc 运行状态 */ export type RunStatus = "running" | "error" | "stopped" @@ -46,7 +49,35 @@ export const useAppStore = defineStore("app", () => { // 窗口背景图片(持久化) const background = useStorage("frpcpilot:background", DEFAULT_BACKGROUND, preferences) - const backgroundUrl = computed(() => getBackgroundUrl(background.value)) + const customBackgroundPath = ref("") + const customBackgroundRevision = ref(0) + const customBackgroundUrl = computed(() => { + if (!customBackgroundPath.value) return "" + return `${convertFileSrc(customBackgroundPath.value)}?v=${customBackgroundRevision.value}` + }) + const backgroundUrl = computed(() => background.value === "custom" ? customBackgroundUrl.value : getBackgroundUrl(background.value)) + + async function loadCustomBackground() { + if (!isTauri()) return + try { + customBackgroundPath.value = (await getCustomBackground()) ?? "" + customBackgroundRevision.value = Date.now() + } catch (error) { + console.error("Failed to load custom background", error) + } + } + + async function chooseCustomBackground() { + if (!isTauri()) throw new Error("自定义背景图片仅在桌面应用中可用") + const path = await chooseCustomBackgroundFile() + if (!path) return false + customBackgroundPath.value = path + customBackgroundRevision.value = Date.now() + background.value = "custom" + return true + } + + void loadCustomBackground() // 把背景图同步到 (整窗背景,避免元素高度误差造成的上下留白) watchEffect(() => { @@ -118,6 +149,8 @@ export const useAppStore = defineStore("app", () => { toggleDark, background, backgroundUrl, + customBackgroundUrl, + chooseCustomBackground, cardTransparency, modalTransparency, backgroundOverlayTransparency, diff --git a/src/views/setting/Appearance.vue b/src/views/setting/Appearance.vue index 562b4b3..09572bd 100644 --- a/src/views/setting/Appearance.vue +++ b/src/views/setting/Appearance.vue @@ -47,6 +47,44 @@ + + + + +
+ {{ t("settings.appearance.customBackground") }} + + + + + + +
+
@@ -85,7 +123,8 @@