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
+2
View File
@@ -15,6 +15,8 @@
</div>
运行数据按安装位置分类保存,目录规则与平台差异见 [数据目录说明](docs/数据目录说明.md)。
> 📘 **新手必读**:第一次搭建内网穿透?请阅读 **[《Frp 内网穿透搭建指南》](docs/Frp搭建内网穿透指南.md)** —— 从公网服务端搭建到用 FrpcPilot 一键开启穿透,手把手带你跑通第一个隧道。
***
+2
View File
@@ -357,3 +357,5 @@ const toggleCollapsed = () => {
- 我想到的方案是脚本,如果你有更好的方案的话,那就采用你的方案,如果没有,就按照我的要求来做,同时你需要考虑到我遗漏的地方
- 数据迁移之后,可能还需要处理数据库中保存的frp软件目录所在位置
- 迁移脚本需要考虑不同操作系统之间的差别
- 或许可以考虑直接把之前的数据目录直接剪切到安装所在目录里面
- 数据迁移之后需要保证功能和已配置的服务器、隧道可以正常启动
+45
View File
@@ -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。
+1 -1
View File
@@ -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"
+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)
}
+49 -12
View File
@@ -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::<StoragePaths>();
let initialize = || -> Result<_, Box<dyn std::error::Error>> {
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| {
+3 -7
View File
@@ -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<SqlitePool, sqlx::Error> {
// 数据库文件路径
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<SqlitePool, sqlx::Error> {
// 初始化数据库连接池
let options = SqliteConnectOptions::new()
.filename(&db_path)
.filename(db_path)
.create_if_missing(true);
let pool = SqlitePoolOptions::new()
.max_connections(5)
+1
View File
@@ -1,4 +1,5 @@
pub mod config;
pub mod db;
pub mod runner;
pub mod storage;
pub mod system;
+2 -2
View File
@@ -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);
}
+153
View File
@@ -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<Self, String> {
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<PathBuf, String> {
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<Self, String> {
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;
+74
View File
@@ -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;
});
}
+2
View File
@@ -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<Update | null> {
/** 重启应用(macOS / Linux 安装更新后需要重启才能运行新版本) */
export async function relaunchApp() {
if (!isTauri()) return
await flushPreferences()
const { relaunch } = await import("@tauri-apps/plugin-process")
await relaunch()
}
+44
View File
@@ -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()
+12 -41
View File
@@ -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()
+8 -6
View File
@@ -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<Language>("frpcpilot:language", "zh-CN")
// 安装目录内的持久化设置
const language = useStorage<Language>("frpcpilot:language", "zh-CN", preferences)
const sidebarCollapsed = useStorage("frpcpilot:sidebar-collapsed", false)
const sidebarCollapsed = useStorage("frpcpilot:sidebar-collapsed", false, preferences)
// 明暗主题:useDark 会在 <html> 上切换 .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<BackgroundKey>("frpcpilot:background", DEFAULT_BACKGROUND)
const background = useStorage<BackgroundKey>("frpcpilot:background", DEFAULT_BACKGROUND, preferences)
const backgroundUrl = computed(() => getBackgroundUrl(background.value))
// 把背景图同步到 <html>(整窗背景,避免元素高度误差造成的上下留白)
@@ -48,8 +50,8 @@ export const useAppStore = defineStore("app", () => {
})
// 磨砂玻璃透明度(0 - 100,数值越大越透明,持久化)
const cardTransparency = useStorage<number>("frpcpilot:card-transparency", 55)
const modalTransparency = useStorage<number>("frpcpilot:modal-transparency", 38)
const cardTransparency = useStorage<number>("frpcpilot:card-transparency", 55, preferences)
const modalTransparency = useStorage<number>("frpcpilot:modal-transparency", 38, preferences)
/** 透明度百分比 → 背景 alpha(越大越透明,alpha 越小;并做范围兜底) */
function alphaFromTransparency(value: number) {
+2 -1
View File
@@ -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)
+3 -1
View File
@@ -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<string>("frpcpilot:ignored-update", "")
const ignoredVersion = useStorage<string>("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":
+3 -2
View File
@@ -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<CloseChoice>("frpcpilot:close-choice", "tray")
const closeChoice = useStorage<CloseChoice>("frpcpilot:close-choice", "tray", preferences)
/** 关闭行为:每次询问 / 保持以上选择(持久化,默认每次询问) */
const closeBehavior = useStorage<CloseBehavior>("frpcpilot:close-behavior", "ask")
const closeBehavior = useStorage<CloseBehavior>("frpcpilot:close-behavior", "ask", preferences)
/** 是否正在退出(停止穿透 + 退出软件) */
const quitting = ref(false)
+61
View File
@@ -0,0 +1,61 @@
import { invoke } from "@tauri-apps/api/core"
const desktop = "__TAURI_INTERNALS__" in window
let values: Record<string, string> = {}
let pending: Promise<void> = Promise.resolve()
let saveError: unknown
/** 在加载 Vue 状态仓库前读取安装目录内的设置。 */
export async function initializePreferences() {
if (!desktop) return
const saved = await invoke<Record<string, string> | 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
}
+3
View File
@@ -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)
}
+10 -8
View File
@@ -15,14 +15,16 @@
<a-divider class="general-divider" />
<!-- 语言:与顶部栏语言切换共用同一状态 -->
<section>
<div class="mb-1 text-sm font-medium">{{ t("settings.general.language") }}</div>
<p class="mb-2 text-xs opacity-60">{{ t("settings.general.languageTip") }}</p>
<a-radio-group
<section class="flex items-center justify-between gap-4">
<div class="min-w-0">
<div class="text-sm font-medium">{{ t("settings.general.language") }}</div>
<p class="mt-1 text-xs opacity-60">{{ t("settings.general.languageTip") }}</p>
</div>
<a-select
v-model:value="appStore.language"
:options="languageRadios"
option-type="button"
button-style="solid"
:options="languageSelectOptions"
:aria-label="t('settings.general.language')"
class="w-36 shrink-0"
/>
</section>
@@ -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(() =>