From ec644e5434d7026a180a86f486222d47d2465e3e Mon Sep 17 00:00:00 2001 From: Yuhang Wu Date: Mon, 26 Oct 2026 14:16:18 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8C=89=E5=AE=89=E8=A3=85=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E9=9A=94=E7=A6=BB=E5=AD=98=E5=82=A8=E5=BA=94=E7=94=A8?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=EF=BC=8C=E5=AE=9E=E7=8E=B0=E5=A4=9A=E5=AE=9E?= =?UTF-8?q?=E4=BE=8B=E7=8B=AC=E7=AB=8B=E8=BF=90=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 StoragePaths 模块,将数据库、设置、frp 版本等业务数据统一保存在应用实际安装位置,避免不同实例或版本间数据混淆。新增 Preferences 模块管理安装目录内的持久化设置,重构数据库初始化逻辑,修改 Frpc 运行目录、frp 版本存储路径,新增数据目录锁机制防止多进程冲突,同时更新文档说明数据目录规则与平台差异。 --- README.md | 2 + docs/FrpcPilot开发提示词.md | 2 + docs/数据目录说明.md | 45 +++++++ src-tauri/Cargo.toml | 2 +- src-tauri/src/commands/frp.rs | 85 ++++++++++--- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/runner.rs | 17 +-- src-tauri/src/commands/storage.rs | 45 +++++++ src-tauri/src/lib.rs | 61 ++++++++-- src-tauri/src/services/db.rs | 10 +- src-tauri/src/services/mod.rs | 1 + src-tauri/src/services/runner.rs | 4 +- src-tauri/src/services/storage.rs | 153 ++++++++++++++++++++++++ src-tauri/src/services/storage_tests.rs | 74 ++++++++++++ src/api/updater.ts | 2 + src/bootstrap.ts | 44 +++++++ src/main.ts | 53 ++------ src/stores/app.ts | 14 ++- src/stores/onboarding.ts | 3 +- src/stores/update.ts | 4 +- src/stores/window.ts | 5 +- src/utils/preferences.ts | 61 ++++++++++ src/utils/window.ts | 3 + src/views/setting/General.vue | 18 +-- 24 files changed, 603 insertions(+), 106 deletions(-) create mode 100644 docs/数据目录说明.md create mode 100644 src-tauri/src/commands/storage.rs create mode 100644 src-tauri/src/services/storage.rs create mode 100644 src-tauri/src/services/storage_tests.rs create mode 100644 src/bootstrap.ts create mode 100644 src/utils/preferences.ts diff --git a/README.md b/README.md index 63ea8b2..3936bd9 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ +运行数据按安装位置分类保存,目录规则与平台差异见 [数据目录说明](docs/数据目录说明.md)。 + > 📘 **新手必读**:第一次搭建内网穿透?请阅读 **[《Frp 内网穿透搭建指南》](docs/Frp搭建内网穿透指南.md)** —— 从公网服务端搭建到用 FrpcPilot 一键开启穿透,手把手带你跑通第一个隧道。 *** diff --git a/docs/FrpcPilot开发提示词.md b/docs/FrpcPilot开发提示词.md index 6f6f90c..a0cb106 100644 --- a/docs/FrpcPilot开发提示词.md +++ b/docs/FrpcPilot开发提示词.md @@ -357,3 +357,5 @@ const toggleCollapsed = () => { - 我想到的方案是脚本,如果你有更好的方案的话,那就采用你的方案,如果没有,就按照我的要求来做,同时你需要考虑到我遗漏的地方 - 数据迁移之后,可能还需要处理数据库中保存的frp软件目录所在位置 - 迁移脚本需要考虑不同操作系统之间的差别 + - 或许可以考虑直接把之前的数据目录直接剪切到安装所在目录里面 + - 数据迁移之后需要保证功能和已配置的服务器、隧道可以正常启动 diff --git a/docs/数据目录说明.md b/docs/数据目录说明.md new file mode 100644 index 0000000..627e5fc --- /dev/null +++ b/docs/数据目录说明.md @@ -0,0 +1,45 @@ +# 数据目录说明 + +FrpcPilot 以实际安装位置保存业务数据。安装目录中没有数据库或设置文件时,直接创建空数据库并使用默认设置。旧版系统应用数据目录中的文件不会被读取、复制或删除,需要使用的服务器与隧道请在新版中重新配置。 + +## Windows 目录示例 + +安装在 `D:\software\FrpcPilot` 时: + +```text +D:\software\FrpcPilot\ +├── frpcpilot.exe +├── data\ +│ ├── frpcpilot.db # 服务器、隧道、frp 记录 +│ ├── preferences.json # 语言、外观、关闭行为等设置 +│ └── storage.lock # 防止多个进程同时使用数据目录 +├── frp\versions\ # 各版本压缩包、解压文件和导入的程序 +├── run\frpc.toml # 启动穿透时根据数据库重新生成 +├── webview\ # Windows / Linux 浏览器数据与缓存 +├── cache\ # 应用缓存 +├── logs\ # 应用日志目录 +└── tmp\ # 应用与更新器使用的临时目录 +``` + +SQLite 的 journal / WAL / SHM 文件随数据库保存在 `data` 中。frpc 的工作目录是 `run`,相对路径的运行产物留在这里;实时日志仍输出到界面,不额外保存日志文件。导入的 frpc / frps 会复制到安装目录,原文件不修改。 + +## 各平台位置 + +| 平台 | 数据根目录 | +| --- | --- | +| Windows | `frpcpilot.exe` 所在目录 | +| macOS `.app` | `.app` 同级的 `FrpcPilot-data` | +| Linux AppImage | `APPIMAGE` 指向的实际文件所在目录 | +| Linux 普通二进制 / deb | 实际可执行文件所在目录 | + +macOS 使用应用包同级目录,避免破坏签名或在更新器替换应用包时丢失数据。AppImage 的临时只读挂载目录不会用于存储业务数据。开发构建使用可执行文件旁的 `FrpcPilot-dev`,与正式版配置隔离。 + +安装目录必须允许当前用户写入。Windows `Program Files`、macOS `/Applications`、Linux deb 默认的 `/usr/bin` 等位置可能不可写,此时应用会显示错误并停止,不回退到系统默认的应用数据目录。建议使用当前用户可写的专用安装文件夹;Linux 推荐将 AppImage 放在自己的应用目录。 + +macOS WKWebView 不支持任意指定持久化目录,其系统管理的浏览器缓存仍可能写入用户 Library;应用设置保存在 `data/preferences.json`。操作系统、WebView 运行时安装、崩溃报告以及系统安装器自身生成的文件,也不能保证完全避开系统盘。 + +## 备份与安装位置变更 + +备份当前数据时,先退出软件,再备份整个安装目录;macOS 需要备份同级的 `FrpcPilot-data`。数据库和 `run/frpc.toml` 含认证信息,请按私密文件保存。 + +程序不自动修正安装位置变更前保存的 frp 绝对路径。移动安装目录后,如原路径失效,请重新下载或导入 frpc。不同操作系统的二进制不兼容,需要使用目标平台的 frpc。 diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 99161d4..aa08d8d 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", features = ["tray-icon"] } +tauri = { version = "2.12.3", features = ["tray-icon"] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src-tauri/src/commands/frp.rs b/src-tauri/src/commands/frp.rs index 260d2aa..1ac0d69 100644 --- a/src-tauri/src/commands/frp.rs +++ b/src-tauri/src/commands/frp.rs @@ -12,6 +12,7 @@ use tokio::io::AsyncWriteExt; use crate::models::{ ApiResponse, FrpDownload, FrpDownloadPayload, FrpDownloadProgress, FrpRelease, }; +use crate::services::storage::StoragePaths; /// GitHub 镜像加速前缀(国内访问 GitHub 不稳定,拉取版本与下载统一走镜像) const GITHUB_MIRROR: &str = "https://gh-proxy.com/"; @@ -133,14 +134,31 @@ async fn fetch_releases(client: &reqwest::Client) -> Result, String> Err(format!("获取 Frp 版本列表失败:{last_err}")) } -/// 应用数据目录下的 frp 版本存储根目录 +/// 安装目录下的 frp 版本存储根目录 fn versions_dir(app: &AppHandle) -> Result { - Ok(app - .path() - .app_data_dir() - .map_err(|e| e.to_string())? - .join("frp") - .join("versions")) + Ok(app.state::().versions()) +} + +fn version_directory(version: &str) -> String { + if version != "." + && version != ".." + && !version.is_empty() + && version + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')) + { + version.to_string() + } else { + // 将任意 Unicode 文件名编码为十六进制,保证导入目录名称唯一。 + format!( + "import-{}", + version + .as_bytes() + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + ) + } } /// 发送下载进度事件 @@ -153,7 +171,9 @@ fn emit_progress( message: Option<&str>, ) { let percent = if total > 0 { - ((downloaded as f64 / total as f64) * 100.0).clamp(0.0, 100.0).round() as u8 + ((downloaded as f64 / total as f64) * 100.0) + .clamp(0.0, 100.0) + .round() as u8 } else { 0 }; @@ -219,7 +239,10 @@ pub async fn get_frp_releases( }; for asset in assets { - let name = asset.get("name").and_then(Value::as_str).unwrap_or_default(); + let name = asset + .get("name") + .and_then(Value::as_str) + .unwrap_or_default(); let Some((_v, os, arch)) = parse_asset(name) else { continue; }; @@ -510,7 +533,9 @@ fn find_binary(dir: &Path, stem: &str) -> Option { stack.push(path); continue; } - let Some(name) = path.file_name().map(|s| s.to_string_lossy().to_ascii_lowercase()) + let Some(name) = path + .file_name() + .map(|s| s.to_string_lossy().to_ascii_lowercase()) else { continue; }; @@ -558,10 +583,12 @@ pub async fn delete_frp( }; if let Ok(base) = versions_dir(&app) { - let ver_dir = base.join(&row.version); + let ver_dir = base.join(version_directory(&row.version)); let _ = tokio::fs::remove_dir_all(ver_dir.join(&row.platform)).await; if let Some(archive) = &row.archive_path { - let _ = tokio::fs::remove_file(archive).await; + if Path::new(archive).starts_with(&base) { + let _ = tokio::fs::remove_file(archive).await; + } } // 版本目录已空则一并删除 if let Ok(mut entries) = tokio::fs::read_dir(&ver_dir).await { @@ -570,7 +597,6 @@ pub async fn delete_frp( } } } - match sqlx::query("DELETE FROM frp_downloads WHERE id = ?") .bind(id) .execute(&*state) @@ -581,7 +607,7 @@ pub async fn delete_frp( } } -/// 导入本地已下载的 frpc / frps(记录可执行文件路径,不复制文件) +/// 导入本地 frpc / frps,将副本保存到安装目录,原始文件保持不变。 #[tauri::command] #[auto_collect_command] pub async fn import_frp( @@ -612,11 +638,33 @@ pub async fn import_frp( .file_name() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_default(); - let size = std::fs::metadata(&path).map(|m| m.len() as i64).unwrap_or(0); + let size = std::fs::metadata(&path) + .map(|m| m.len() as i64) + .unwrap_or(0); let lower = file_name.to_ascii_lowercase(); let is_frps = lower.contains("frps"); let version = parse_version(&file_name).unwrap_or_else(|| file_name.clone()); - let full = path.to_string_lossy().to_string(); + let binary_name = if is_frps { "frps" } else { "frpc" }; + // 对用户选择的文件名进行编码后再构造存储目录。 + let import_dir = versions_dir(&app)? + .join(version_directory(&version)) + .join(&platform) + .join("import"); + std::fs::create_dir_all(&import_dir).map_err(|e| e.to_string())?; + let destination = import_dir.join(if cfg!(windows) { + format!("{binary_name}.exe") + } else { + binary_name.to_string() + }); + let same_file = match (std::fs::canonicalize(&destination), std::fs::canonicalize(&path)) { + (Ok(destination), Ok(source)) => destination == source, + _ => false, + }; + if !same_file { + std::fs::copy(&path, &destination).map_err(|e| format!("导入 Frp 失败:{e}"))?; + } + make_executable(&destination); + let full = destination.to_string_lossy().to_string(); let (frpc_path, frps_path) = if is_frps { (None, Some(full)) } else { @@ -683,7 +731,10 @@ mod tests { #[test] fn parse_version_extracts_semver() { - assert_eq!(parse_version("frpc_0.71.0_windows_amd64.exe"), Some("v0.71.0".into())); + assert_eq!( + parse_version("frpc_0.71.0_windows_amd64.exe"), + Some("v0.71.0".into()) + ); assert_eq!(parse_version("frps-v0.60.0"), Some("v0.60.0".into())); assert_eq!(parse_version("frpc.exe"), None); assert_eq!(parse_version("frpc_0.71.exe"), None); diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 5f002c0..2ffd9d6 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod frp; pub mod runner; pub mod server; +pub mod storage; pub mod system; pub mod tunnel; diff --git a/src-tauri/src/commands/runner.rs b/src-tauri/src/commands/runner.rs index 7f9b7f0..c56b81c 100644 --- a/src-tauri/src/commands/runner.rs +++ b/src-tauri/src/commands/runner.rs @@ -12,6 +12,7 @@ use tauri_helper::auto_collect_command; use crate::models::{ApiResponse, FrpDownload, FrpcStatus, Server, Tunnel}; use crate::services::config::generate_frpc_config; use crate::services::runner::FrpcRunner; +use crate::services::storage::StoragePaths; // 统一查询字段,使用 concat! 保证结果为 'static str(sqlx 0.9 要求) macro_rules! server_fields { @@ -32,14 +33,9 @@ macro_rules! download_fields { }; } -/// 本次运行生成的 frpc.toml 路径(应用数据目录/frp/run/frpc.toml) +/// 本次运行生成的 frpc.toml 路径(安装目录/run/frpc.toml) fn config_file_path(app: &AppHandle) -> Result { - let dir = app - .path() - .app_data_dir() - .map_err(|e| e.to_string())? - .join("frp") - .join("run"); + let dir = app.state::().run(); std::fs::create_dir_all(&dir).map_err(|e| format!("创建配置目录失败:{e}"))?; Ok(dir.join("frpc.toml")) } @@ -77,7 +73,12 @@ pub async fn start_frpc( .await { Ok(Some(row)) => row, - Ok(None) => return Ok(ApiResponse::error(404, "所选 Frpc 版本不存在,请刷新后重试")), + Ok(None) => { + return Ok(ApiResponse::error( + 404, + "所选 Frpc 版本不存在,请刷新后重试", + )); + } Err(e) => return Ok(ApiResponse::error(500, &e.to_string())), }; diff --git a/src-tauri/src/commands/storage.rs b/src-tauri/src/commands/storage.rs new file mode 100644 index 0000000..b30a72d --- /dev/null +++ b/src-tauri/src/commands/storage.rs @@ -0,0 +1,45 @@ +use std::collections::BTreeMap; +use std::sync::Mutex; + +use tauri::State; +use tauri_helper::auto_collect_command; + +use crate::services::storage::{StoragePaths, write_json}; + +#[derive(Default)] +pub struct PreferencesLock(pub Mutex<()>); + +#[tauri::command] +#[auto_collect_command] +pub fn get_preferences( + paths: State<'_, StoragePaths>, + lock: State<'_, PreferencesLock>, +) -> Result>, String> { + let _guard = lock.0.lock().map_err(|e| e.to_string())?; + let path = paths.data().join("preferences.json"); + match std::fs::read(path) { + Ok(bytes) => serde_json::from_slice(&bytes) + .map(Some) + .map_err(|e| e.to_string()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.to_string()), + } +} + +#[tauri::command] +#[auto_collect_command] +pub fn set_preferences( + paths: State<'_, StoragePaths>, + lock: State<'_, PreferencesLock>, + preferences: BTreeMap, +) -> Result<(), String> { + if preferences + .iter() + .any(|(key, value)| !key.starts_with("frpcpilot:") || key.len() + value.len() > 65536) + || preferences.len() > 100 + { + return Err("设置数据无效或过大".into()); + } + let _guard = lock.0.lock().map_err(|e| e.to_string())?; + write_json(&paths.data().join("preferences.json"), &preferences) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 678c801..7f7182a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,25 +13,44 @@ use crate::commands::server::{ create_server, delete_server, get_server_list, set_server_enabled, set_server_pinned, update_server, }; +use crate::commands::storage::{PreferencesLock, get_preferences, set_preferences}; use crate::commands::system::{get_system_details, get_system_info, is_autostart_supported}; use crate::commands::tunnel::{ create_tunnel, delete_tunnel, get_tunnel_list, set_tunnel_enabled, set_tunnel_pinned, update_tunnel, }; -use crate::services::db::init_pool; use crate::services::runner::FrpcRunner; +use crate::services::storage::{self, StoragePaths}; use crate::services::system::SystemStats; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + let paths = StoragePaths::discover().expect("无法确定软件安装目录"); + // 此入口在启动 Tauri、Tokio 或任何工作线程之前调用。 + // 将更新、解压及子进程继承的临时文件目录统一指向安装目录。 + if std::fs::create_dir_all(paths.root.join("tmp")).is_ok() { + unsafe { + std::env::set_var("TEMP", paths.root.join("tmp")); + std::env::set_var("TMP", paths.root.join("tmp")); + std::env::set_var("TMPDIR", paths.root.join("tmp")); + } + } + let mut context = tauri::generate_context!(); + context.config_mut().app.app_directories_override = Some(paths.overrides()); + // 在打开 WebView 前初始化数据目录,同时保留各平台的窗口配置。 + for window in &mut context.config_mut().app.windows { + window.create = false; + } let mut builder = tauri::Builder::default() + .manage(paths) + .manage(PreferencesLock::default()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) // 对话框插件 .plugin(tauri_plugin_updater::Builder::new().build()) // 更新插件 .plugin(tauri_plugin_process::init()) // 进程插件(更新后重启) .plugin(tauri_plugin_prevent_default::Builder::new().build()); - // Development executables require Vite; debug builds also open a Windows console. + // 开发版依赖 Vite,调试构建在 Windows 上还会打开控制台窗口。 if is_autostart_supported() { builder = builder.plugin(tauri_plugin_autostart::init( tauri_plugin_autostart::MacosLauncher::LaunchAgent, @@ -41,8 +60,26 @@ pub fn run() { let app = builder .setup(|app| { - // The plugin resolves the running executable dynamically. Refresh enabled - // entries after reinstalling or moving the app, preserving disabled state. + use tauri_plugin_dialog::{DialogExt, MessageDialogKind}; + + let paths = app.state::(); + let initialize = || -> Result<_, Box> { + let (pool, lock) = tauri::async_runtime::block_on(storage::initialize(&paths)) + .map_err(std::io::Error::other)?; + app.manage(pool); + app.manage(lock); + Ok(()) + }; + if let Err(error) = initialize() { + app.dialog().message(format!( + "无法初始化安装目录中的数据:{}\n\n{error}\n\n请确认安装目录可写、磁盘空间充足,并退出使用同一数据目录的其他实例。", + paths.root.display() + )).title("FrpcPilot 数据初始化失败").kind(MessageDialogKind::Error).blocking_show(); + return Err(error); + } + + // 插件会动态解析当前可执行文件的位置;重新安装或移动应用后刷新 + // 已启用的自启动项,原本禁用的自启动项仍保持禁用。 if is_autostart_supported() { let autostart = app.autolaunch(); let result = autostart.is_enabled().and_then(|enabled| { @@ -57,13 +94,6 @@ pub fn run() { } } - let pool = tauri::async_runtime::block_on(async { - init_pool(app.handle()) - .await - .expect("sqlite数据库连接池初始化失败!") - }); - app.manage(pool); - // 系统资源占用(CPU / 内存)后台采集 let stats = SystemStats::default(); stats.start(); @@ -72,12 +102,19 @@ pub fn run() { // Frpc 进程管理器(一键启动 / 停止与日志实时回显) app.manage(FrpcRunner::default()); + for config in &app.config().app.windows { + let window = tauri::WebviewWindowBuilder::from_config(app.handle(), config)?; + #[cfg(not(target_os = "macos"))] + let window = window.data_directory(paths.webview()); + window.build()?; + } + Ok(()) }) .plugin(tauri_plugin_store::Builder::default().build()) // .invoke_handler(tauri::generate_handler![greet]) .invoke_handler(tauri_helper::tauri_collect_commands!()) - .build(tauri::generate_context!()) + .build(context) .expect("error while building tauri application"); app.run(|app_handle, event| { diff --git a/src-tauri/src/services/db.rs b/src-tauri/src/services/db.rs index 84e4773..489f778 100644 --- a/src-tauri/src/services/db.rs +++ b/src-tauri/src/services/db.rs @@ -1,14 +1,10 @@ use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions}; -use tauri::{AppHandle, Manager}; +use std::path::Path; -pub async fn init_pool(app: &AppHandle) -> Result { - // 数据库文件路径 - let app_dir = app.path().app_data_dir().expect("无法获取APP数据目录"); - std::fs::create_dir_all(&app_dir).unwrap(); - let db_path = app_dir.join("frpcpilot.db"); +pub async fn init_pool(db_path: &Path) -> Result { // 初始化数据库连接池 let options = SqliteConnectOptions::new() - .filename(&db_path) + .filename(db_path) .create_if_missing(true); let pool = SqlitePoolOptions::new() .max_connections(5) diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 0290153..651166e 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,4 +1,5 @@ pub mod config; pub mod db; pub mod runner; +pub mod storage; pub mod system; diff --git a/src-tauri/src/services/runner.rs b/src-tauri/src/services/runner.rs index 5bf0311..0626336 100644 --- a/src-tauri/src/services/runner.rs +++ b/src-tauri/src/services/runner.rs @@ -97,8 +97,8 @@ impl FrpcRunner { .stdout(Stdio::piped()) .stderr(Stdio::piped()); - // 工作目录设为 frpc 所在目录,保证其相对路径配置可正常解析 - if let Some(dir) = exe.parent() { + // 相对路径的运行产物统一保存在生成配置所在的目录。 + if let Some(dir) = Path::new(config_path).parent() { command.current_dir(dir); } diff --git a/src-tauri/src/services/storage.rs b/src-tauri/src/services/storage.rs new file mode 100644 index 0000000..52abeea --- /dev/null +++ b/src-tauri/src/services/storage.rs @@ -0,0 +1,153 @@ +//! 按安装位置分类存储应用数据。 +//! 此模块在创建任何 WebView 或打开应用数据库连接池之前执行。 + +use std::fs::{self, File, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use sqlx::SqlitePool; +use tauri::utils::config::{AppDirectoriesOverride, AppDirectoryOverrides}; + +use super::db::init_pool; + +#[derive(Clone, Debug)] +pub struct StoragePaths { + pub root: PathBuf, +} + +impl StoragePaths { + pub fn discover() -> Result { + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + // APPIMAGE 指向实际的安装文件,避免使用只读的 /tmp/.mount_* 挂载目录。 + let appimage = if cfg!(target_os = "linux") && !cfg!(debug_assertions) { + std::env::var_os("APPIMAGE").map(PathBuf::from) + } else { + None + }; + let mut root = installation_root(&exe, appimage.as_deref(), cfg!(target_os = "macos"))?; + // 开发和测试使用独立的数据目录,避免影响正式版配置。 + if cfg!(debug_assertions) { + root = root.join("FrpcPilot-dev"); + } + Ok(Self { root }) + } + + pub fn data(&self) -> PathBuf { + self.root.join("data") + } + pub fn db(&self) -> PathBuf { + self.data().join("frpcpilot.db") + } + pub fn versions(&self) -> PathBuf { + self.root.join("frp").join("versions") + } + pub fn run(&self) -> PathBuf { + self.root.join("run") + } + pub fn webview(&self) -> PathBuf { + self.root.join("webview") + } + pub fn overrides(&self) -> AppDirectoriesOverride { + AppDirectoriesOverride::Directories(AppDirectoryOverrides { + config: Some(self.data()), + data: Some(self.data()), + local_data: Some(self.webview()), + cache: Some(self.root.join("cache")), + log: Some(self.root.join("logs")), + }) + } +} + +fn installation_root(exe: &Path, appimage: Option<&Path>, macos: bool) -> Result { + if macos { + if let Some(bundle) = exe + .ancestors() + .find(|p| p.extension().is_some_and(|e| e == "app")) + { + // 数据保存在应用包同级目录,避免 macOS 更新器替换签名应用包时丢失数据。 + return Ok(bundle.with_file_name("FrpcPilot-data")); + } + } + let location = appimage.unwrap_or(exe); + if !location.is_absolute() { + return Err(format!("安装位置必须为绝对路径:{}", location.display())); + } + location + .parent() + .map(Path::to_path_buf) + .ok_or_else(|| "无法确定安装目录".into()) +} + +/// 在应用整个生命周期内持有文件锁,进程崩溃后操作系统也会自动释放锁。 +pub struct StorageLock(File); + +impl StorageLock { + fn acquire(path: &Path) -> Result { + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path) + .map_err(|e| { + format!( + "无法写入 {}:{e}。请将软件安装到当前用户可写的目录。", + path.display() + ) + })?; + file.try_lock().map_err(|e| { + format!( + "数据目录正在被另一个 FrpcPilot 使用:{}({e})。请先退出其他实例。", + path.display() + ) + })?; + Ok(Self(file)) + } +} + +impl Drop for StorageLock { + fn drop(&mut self) { + let _ = self.0.unlock(); + } +} + +pub async fn initialize(paths: &StoragePaths) -> Result<(SqlitePool, StorageLock), String> { + fs::create_dir_all(paths.data()).map_err(|e| { + format!( + "无法创建数据目录 {}:{e}。请使用可写的安装目录。", + paths.data().display() + ) + })?; + let lock = StorageLock::acquire(&paths.data().join("storage.lock"))?; + for dir in [ + paths.versions(), + paths.run(), + paths.webview(), + paths.root.join("cache"), + paths.root.join("logs"), + paths.root.join("tmp"), + ] { + fs::create_dir_all(&dir).map_err(|e| format!("无法创建 {}:{e}", dir.display()))?; + } + let pool = init_pool(&paths.db()) + .await + .map_err(|e| format!("初始化数据库失败:{e}"))?; + Ok((pool, lock)) +} + +pub fn write_json(path: &Path, value: &impl Serialize) -> Result<(), String> { + let bytes = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?; + let temp = path.with_extension("json.tmp"); + let mut file = File::create(&temp).map_err(|e| e.to_string())?; + file.write_all(&bytes) + .and_then(|_| file.sync_all()) + .map_err(|e| e.to_string())?; + drop(file); + // std::fs::rename 在 Windows 和 Unix 上均以原子方式替换已有文件。 + fs::rename(&temp, path).map_err(|e| e.to_string()) +} + +#[cfg(test)] +#[path = "storage_tests.rs"] +mod tests; diff --git a/src-tauri/src/services/storage_tests.rs b/src-tauri/src/services/storage_tests.rs new file mode 100644 index 0000000..1ee86ee --- /dev/null +++ b/src-tauri/src/services/storage_tests.rs @@ -0,0 +1,74 @@ +use super::*; +use std::sync::atomic::{AtomicU64, Ordering}; + +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("target") + .join("storage-tests") + .join(format!( + "{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::SeqCst) + )); + fs::create_dir_all(&root).unwrap(); + Self(root) + } + fn paths(&self, name: &str) -> StoragePaths { + StoragePaths { + root: self.0.join(name), + } + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[test] +fn install_locations_cover_bundles_and_appimage() { + let f = Fixture::new(); + assert_eq!( + installation_root(&f.0.join("pilot/frpcpilot"), None, false).unwrap(), + f.0.join("pilot") + ); + assert_eq!( + installation_root( + &f.0.join(".mount_Pilot/usr/bin/frpcpilot"), + Some(&f.0.join("apps/FrpcPilot.AppImage")), + false + ) + .unwrap(), + f.0.join("apps") + ); + assert_eq!( + installation_root( + &f.0.join("Applications/FrpcPilot.app/Contents/MacOS/frpcpilot"), + None, + true + ) + .unwrap(), + f.0.join("Applications/FrpcPilot-data") + ); + assert!(installation_root(Path::new("relative/frpcpilot"), None, false).is_err()); +} + +#[test] +fn fresh_install_and_exclusive_storage_lock() { + tauri::async_runtime::block_on(async { + let f = Fixture::new(); + let paths = f.paths("new"); + let (pool, lock) = initialize(&paths).await.unwrap(); + assert!(paths.db().is_file()); + assert!(paths.run().is_dir()); + assert!(paths.versions().is_dir()); + assert!(initialize(&paths).await.is_err()); + pool.close().await; + drop(lock); + let (pool, _lock) = initialize(&paths).await.unwrap(); + pool.close().await; + }); +} diff --git a/src/api/updater.ts b/src/api/updater.ts index a1fba4c..1dfe1f3 100644 --- a/src/api/updater.ts +++ b/src/api/updater.ts @@ -1,5 +1,6 @@ import type { Update } from "@tauri-apps/plugin-updater" import { isTauri } from "@/utils/window" +import { flushPreferences } from "@/utils/preferences" /** 项目 GitHub 仓库地址(与 tauri.conf.json 中 updater 端点保持一致) */ export const PROJECT_GITHUB_URL = "https://github.com/SparksFly91/FrpcPilot" @@ -18,6 +19,7 @@ export async function checkForAppUpdate(): Promise { /** 重启应用(macOS / Linux 安装更新后需要重启才能运行新版本) */ export async function relaunchApp() { if (!isTauri()) return + await flushPreferences() const { relaunch } = await import("@tauri-apps/plugin-process") await relaunch() } diff --git a/src/bootstrap.ts b/src/bootstrap.ts new file mode 100644 index 0000000..cc58555 --- /dev/null +++ b/src/bootstrap.ts @@ -0,0 +1,44 @@ +import { createApp } from "vue" +import { createPinia } from "pinia" +import App from "./App.vue" +import "@/assets/main.css" +// 引入 Ant Design Vue 组件库 +import Antd from "ant-design-vue" +import "ant-design-vue/dist/reset.css" +// 引入路由 +import router from "@/router/router" +// 引入国际化 +import i18n from "@/i18n" +import { detectPlatform } from "@/composables/usePlatform" +import { nextTick } from "vue" +import { useOnboardingStore } from "@/stores/onboarding" + +/* + * macOS 的无边框窗口系统不会自动裁圆角,需要配合 transparent 窗口, + * 由 #app 自身裁出 8px 圆角(见 assets/main.css)。这里在挂载前打上平台标记, + * 避免首帧出现方角闪烁。 + */ +if (detectPlatform() === "macos") { + document.documentElement.classList.add("is-macos") +} + +const app = createApp(App) +app.use(createPinia()) +app.use(Antd) +app.use(i18n) +app.use(router) +// 路由懒加载完成后再挂载,避免启动画面移除后出现空白内容区。 +async function mountApp() { + await router.isReady() + app.mount("#app") + await nextTick() + // 让浏览器先绘制完整布局,再揭开启动画面并开始首次引导。 + requestAnimationFrame(() => { + requestAnimationFrame(() => { + document.getElementById("startup-loader")?.remove() + useOnboardingStore().ready = true + }) + }) +} + +void mountApp() diff --git a/src/main.ts b/src/main.ts index cc58555..8d51d0f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,44 +1,15 @@ -import { createApp } from "vue" -import { createPinia } from "pinia" -import App from "./App.vue" -import "@/assets/main.css" -// 引入 Ant Design Vue 组件库 -import Antd from "ant-design-vue" -import "ant-design-vue/dist/reset.css" -// 引入路由 -import router from "@/router/router" -// 引入国际化 -import i18n from "@/i18n" -import { detectPlatform } from "@/composables/usePlatform" -import { nextTick } from "vue" -import { useOnboardingStore } from "@/stores/onboarding" +import { initializePreferences } from "@/utils/preferences" -/* - * macOS 的无边框窗口系统不会自动裁圆角,需要配合 transparent 窗口, - * 由 #app 自身裁出 8px 圆角(见 assets/main.css)。这里在挂载前打上平台标记, - * 避免首帧出现方角闪烁。 - */ -if (detectPlatform() === "macos") { - document.documentElement.classList.add("is-macos") +// 状态仓库创建时会读取初始设置,因此先加载安装目录内保存的设置。 +async function start() { + try { + await initializePreferences() + await import("./bootstrap") + } catch (error) { + console.error("Failed to initialize application", error) + const loader = document.getElementById("startup-loader") + if (loader) loader.textContent = `无法读取应用设置 / Cannot load settings: ${String(error)}` + } } -const app = createApp(App) -app.use(createPinia()) -app.use(Antd) -app.use(i18n) -app.use(router) -// 路由懒加载完成后再挂载,避免启动画面移除后出现空白内容区。 -async function mountApp() { - await router.isReady() - app.mount("#app") - await nextTick() - // 让浏览器先绘制完整布局,再揭开启动画面并开始首次引导。 - requestAnimationFrame(() => { - requestAnimationFrame(() => { - document.getElementById("startup-loader")?.remove() - useOnboardingStore().ready = true - }) - }) -} - -void mountApp() +void start() diff --git a/src/stores/app.ts b/src/stores/app.ts index a32be32..2003b54 100644 --- a/src/stores/app.ts +++ b/src/stores/app.ts @@ -1,4 +1,5 @@ import { defineStore } from "pinia" +import { preferences } from "@/utils/preferences" 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" @@ -15,13 +16,14 @@ export type RunStatus = "running" | "error" | "stopped" * 全局应用配置:语言、主题、窗口背景、Frpc 运行状态 */ export const useAppStore = defineStore("app", () => { - // 语言(持久化到 localStorage) - const language = useStorage("frpcpilot:language", "zh-CN") + // 安装目录内的持久化设置 + const language = useStorage("frpcpilot:language", "zh-CN", preferences) - const sidebarCollapsed = useStorage("frpcpilot:sidebar-collapsed", false) + const sidebarCollapsed = useStorage("frpcpilot:sidebar-collapsed", false, preferences) // 明暗主题:useDark 会在 上切换 .dark 类名 const isDark = useDark({ + storage: preferences, storageKey: "frpcpilot:theme", selector: "html", attribute: "class", @@ -31,7 +33,7 @@ export const useAppStore = defineStore("app", () => { const toggleDark = useToggle(isDark) // 窗口背景图片(持久化) - const background = useStorage("frpcpilot:background", DEFAULT_BACKGROUND) + const background = useStorage("frpcpilot:background", DEFAULT_BACKGROUND, preferences) const backgroundUrl = computed(() => getBackgroundUrl(background.value)) // 把背景图同步到 (整窗背景,避免元素高度误差造成的上下留白) @@ -48,8 +50,8 @@ export const useAppStore = defineStore("app", () => { }) // 磨砂玻璃透明度(0 - 100,数值越大越透明,持久化) - const cardTransparency = useStorage("frpcpilot:card-transparency", 55) - const modalTransparency = useStorage("frpcpilot:modal-transparency", 38) + const cardTransparency = useStorage("frpcpilot:card-transparency", 55, preferences) + const modalTransparency = useStorage("frpcpilot:modal-transparency", 38, preferences) /** 透明度百分比 → 背景 alpha(越大越透明,alpha 越小;并做范围兜底) */ function alphaFromTransparency(value: number) { diff --git a/src/stores/onboarding.ts b/src/stores/onboarding.ts index 74f950a..7c81e38 100644 --- a/src/stores/onboarding.ts +++ b/src/stores/onboarding.ts @@ -1,7 +1,8 @@ import { defineStore } from "pinia" +import { preferences } from "@/utils/preferences" export const useOnboardingStore = defineStore("onboarding", () => { - const dismissed = useStorage("frpcpilot:onboarding-dismissed", false) + const dismissed = useStorage("frpcpilot:onboarding-dismissed", false, preferences) const ready = ref(false) const active = ref(false) diff --git a/src/stores/update.ts b/src/stores/update.ts index 911be62..98100e3 100644 --- a/src/stores/update.ts +++ b/src/stores/update.ts @@ -1,4 +1,5 @@ import { defineStore } from "pinia" +import { preferences, flushPreferences } from "@/utils/preferences" import { message } from "ant-design-vue" import type { Update } from "@tauri-apps/plugin-updater" import { checkForAppUpdate, relaunchApp } from "@/api/updater" @@ -20,7 +21,7 @@ export interface UpdateInfo { */ export const useUpdateStore = defineStore("update", () => { /** 用户选择「忽略该版本」后持久化的版本号,静默检查时不再提示 */ - const ignoredVersion = useStorage("frpcpilot:ignored-update", "") + const ignoredVersion = useStorage("frpcpilot:ignored-update", "", preferences) const checking = ref(false) const installing = ref(false) @@ -137,6 +138,7 @@ export const useUpdateStore = defineStore("update", () => { installing.value = true resetProgress() try { + await flushPreferences() await pending.downloadAndInstall((event) => { switch (event.event) { case "Started": diff --git a/src/stores/window.ts b/src/stores/window.ts index 4a58f4b..935de18 100644 --- a/src/stores/window.ts +++ b/src/stores/window.ts @@ -1,4 +1,5 @@ import { defineStore } from "pinia" +import { preferences } from "@/utils/preferences" import { exitApp, hideWindow, isTauri, revealWindow } from "@/utils/window" import { useFrpcStore } from "@/stores/frpc" @@ -24,9 +25,9 @@ export const useWindowStore = defineStore("window", () => { /** 关闭确认对话框是否打开 */ const closeConfirmOpen = ref(false) /** 记住的关闭方式(弹窗默认选中项;「保持以上选择」时直接生效,持久化) */ - const closeChoice = useStorage("frpcpilot:close-choice", "tray") + const closeChoice = useStorage("frpcpilot:close-choice", "tray", preferences) /** 关闭行为:每次询问 / 保持以上选择(持久化,默认每次询问) */ - const closeBehavior = useStorage("frpcpilot:close-behavior", "ask") + const closeBehavior = useStorage("frpcpilot:close-behavior", "ask", preferences) /** 是否正在退出(停止穿透 + 退出软件) */ const quitting = ref(false) diff --git a/src/utils/preferences.ts b/src/utils/preferences.ts new file mode 100644 index 0000000..7563fe3 --- /dev/null +++ b/src/utils/preferences.ts @@ -0,0 +1,61 @@ +import { invoke } from "@tauri-apps/api/core" + +const desktop = "__TAURI_INTERNALS__" in window +let values: Record = {} +let pending: Promise = Promise.resolve() +let saveError: unknown + +/** 在加载 Vue 状态仓库前读取安装目录内的设置。 */ +export async function initializePreferences() { + if (!desktop) return + const saved = await invoke | null>("get_preferences") + if (saved !== null) { + values = saved + } else { + values = {} + await invoke("set_preferences", { preferences: values }) + } +} + +function persist() { + const snapshot = { ...values } + pending = pending.then(async () => { + try { + await invoke("set_preferences", { preferences: snapshot }) + saveError = undefined + } catch (error) { + saveError = error + console.error("Failed to save preferences", error) + const { message } = await import("ant-design-vue") + message.error(`保存设置失败 / Failed to save settings: ${String(error)}`) + } + }) +} + +/** VueUse 存储适配器,将应用偏好设置保存在 data/preferences.json 中。 */ +export const preferences: Storage = { + get length() { return desktop ? Object.keys(values).length : localStorage.length }, + key(index) { return desktop ? Object.keys(values)[index] ?? null : localStorage.key(index) }, + getItem(key) { return desktop ? values[key] ?? null : localStorage.getItem(key) }, + setItem(key, value) { + if (!desktop) return localStorage.setItem(key, value) + if (values[key] === value) return + values[key] = value + persist() + }, + removeItem(key) { + if (!desktop) return localStorage.removeItem(key) + delete values[key] + persist() + }, + clear() { + if (!desktop) return localStorage.clear() + values = {} + persist() + }, +} + +export async function flushPreferences() { + await pending + if (saveError) throw saveError +} diff --git a/src/utils/window.ts b/src/utils/window.ts index 6bf5bba..fef85cb 100644 --- a/src/utils/window.ts +++ b/src/utils/window.ts @@ -3,6 +3,8 @@ * 在纯浏览器环境(非 Tauri)下调用会被安全忽略,方便 `pnpm dev` 调试。 */ +import { flushPreferences } from "@/utils/preferences" + export function isTauri() { return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window } @@ -49,6 +51,7 @@ export async function revealWindow() { /** 退出整个应用(退出前由调用方负责停止穿透等收尾工作) */ export async function exitApp() { if (!isTauri()) return + await flushPreferences() const { exit } = await import("@tauri-apps/plugin-process") await exit(0) } diff --git a/src/views/setting/General.vue b/src/views/setting/General.vue index 9289d99..8e186bb 100644 --- a/src/views/setting/General.vue +++ b/src/views/setting/General.vue @@ -15,14 +15,16 @@ -
-
{{ t("settings.general.language") }}
-

{{ t("settings.general.languageTip") }}

- +
+
{{ t("settings.general.language") }}
+

{{ t("settings.general.languageTip") }}

+
+
@@ -91,7 +93,7 @@ const choiceOptions = computed(() => [ ]) /** 语言选项与顶部栏共用 languageOptions,保证两端一致 */ -const languageRadios = languageOptions.map((item) => ({ label: item.label, value: item.key })) +const languageSelectOptions = languageOptions.map((item) => ({ label: item.label, value: item.key })) /** 无法访问系统启动项时(非桌面环境)给出说明,避免开关无故置灰 */ const autostartTip = computed(() =>