feat: 按安装目录隔离存储应用数据,实现多实例独立运行

新增 StoragePaths 模块,将数据库、设置、frp 版本等业务数据统一保存在应用实际安装位置,避免不同实例或版本间数据混淆。新增 Preferences 模块管理安装目录内的持久化设置,重构数据库初始化逻辑,修改 Frpc 运行目录、frp 版本存储路径,新增数据目录锁机制防止多进程冲突,同时更新文档说明数据目录规则与平台差异。
This commit is contained in:
Yuhang Wu
2026-10-26 14:16:18 +08:00
parent 93b15ccb16
commit ec644e5434
24 changed files with 603 additions and 106 deletions
+68 -17
View File
@@ -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<Vec<Value>, String>
Err(format!("获取 Frp 版本列表失败:{last_err}"))
}
/// 应用数据目录下的 frp 版本存储根目录
/// 安装目录下的 frp 版本存储根目录
fn versions_dir(app: &AppHandle) -> Result<PathBuf, String> {
Ok(app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("frp")
.join("versions"))
Ok(app.state::<StoragePaths>().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::<String>()
)
}
}
/// 发送下载进度事件
@@ -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<PathBuf> {
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);
+1
View File
@@ -1,5 +1,6 @@
pub mod frp;
pub mod runner;
pub mod server;
pub mod storage;
pub mod system;
pub mod tunnel;
+9 -8
View File
@@ -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<PathBuf, String> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("frp")
.join("run");
let dir = app.state::<StoragePaths>().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())),
};
+45
View File
@@ -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<Option<BTreeMap<String, String>>, 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<String, String>,
) -> 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)
}