feat: 新增自定义窗口背景功能

This commit is contained in:
Yuhang Wu
2026-10-28 11:17:28 +08:00
parent 135e465b16
commit 74f7b7d30f
15 changed files with 345 additions and 5 deletions
+7
View File
@@ -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",
+1 -1
View File
@@ -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"
+41
View File
@@ -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<ApiResponse<Option<String>>, 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<ApiResponse<Option<String>>, String> {
Ok(ApiResponse::success(
find_custom_background(&paths).map(|path| path.to_string_lossy().into_owned()),
))
}
+1
View File
@@ -1,3 +1,4 @@
pub mod background;
pub mod frp;
pub mod runner;
pub mod server;
+1
View File
@@ -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,
};
+137
View File
@@ -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<PathBuf, String> {
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<PathBuf> {
["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();
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod background;
pub mod config;
pub mod db;
pub mod runner;
+5 -1
View File
@@ -22,7 +22,11 @@
}
],
"security": {
"csp": null
"csp": null,
"assetProtocol": {
"enable": true,
"scope": ["$APPDATA/backgrounds/**"]
}
}
},
"bundle": {