Compare commits

...
3 Commits
Author SHA1 Message Date
Yuhang Wu ec644e5434 feat: 按安装目录隔离存储应用数据,实现多实例独立运行
新增 StoragePaths 模块,将数据库、设置、frp 版本等业务数据统一保存在应用实际安装位置,避免不同实例或版本间数据混淆。新增 Preferences 模块管理安装目录内的持久化设置,重构数据库初始化逻辑,修改 Frpc 运行目录、frp 版本存储路径,新增数据目录锁机制防止多进程冲突,同时更新文档说明数据目录规则与平台差异。
2026-10-26 14:16:18 +08:00
Yuhang Wu 93b15ccb16 feat: 添加新手引导功能和侧边栏折叠功能 2026-10-26 11:29:43 +08:00
Yuhang Wu fafc87f4c6 feat: 调整Frp版本筛选逻辑为平台下拉选择
将原有的两个架构开关替换为平台下拉筛选器,统一Frp相关文本表述,版本号升级至1.4.0
2026-10-26 09:56:55 +08:00
38 changed files with 1166 additions and 137 deletions
+2
View File
@@ -15,6 +15,8 @@
</div> </div>
运行数据按安装位置分类保存,目录规则与平台差异见 [数据目录说明](docs/数据目录说明.md)。
> 📘 **新手必读**:第一次搭建内网穿透?请阅读 **[《Frp 内网穿透搭建指南》](docs/Frp搭建内网穿透指南.md)** —— 从公网服务端搭建到用 FrpcPilot 一键开启穿透,手把手带你跑通第一个隧道。 > 📘 **新手必读**:第一次搭建内网穿透?请阅读 **[《Frp 内网穿透搭建指南》](docs/Frp搭建内网穿透指南.md)** —— 从公网服务端搭建到用 FrpcPilot 一键开启穿透,手把手带你跑通第一个隧道。
*** ***
+170
View File
@@ -71,6 +71,150 @@ Layout布局content区域的磨砂玻璃卡片圆角样式我要改动一下:
- 设置页面里面也有一些没有随主题改变颜色 - 设置页面里面也有一些没有随主题改变颜色
- 整体全部检查并且修复一下 - 整体全部检查并且修复一下
#### 2.7 侧边栏折叠和漫游式引导
- 侧边导航栏在设置下面增加一个折叠/展开的图标,点击用于控制侧边导航栏的折叠和展开
- 侧边导航栏折叠之后,需要注意折叠之后的图标居中显示,并且鼠标放上去之后的hover效果显示的时候,图标也要在hover效果里面居中
- 官方参考代码如下:
```html
<template>
<div style="width: 256px">
<a-button type="primary" style="margin-bottom: 16px" @click="toggleCollapsed">
<MenuUnfoldOutlined v-if="state.collapsed" />
<MenuFoldOutlined v-else />
</a-button>
<a-menu
v-model:openKeys="state.openKeys"
v-model:selectedKeys="state.selectedKeys"
mode="inline"
theme="dark"
:inline-collapsed="state.collapsed"
:items="items"
></a-menu>
</div>
</template>
<script lang="ts" setup>
import { reactive, watch, h } from 'vue';
import {
MenuFoldOutlined,
MenuUnfoldOutlined,
PieChartOutlined,
MailOutlined,
DesktopOutlined,
InboxOutlined,
AppstoreOutlined,
} from '@ant-design/icons-vue';
const state = reactive({
collapsed: false,
selectedKeys: ['1'],
openKeys: ['sub1'],
preOpenKeys: ['sub1'],
});
const items = reactive([
{
key: '1',
icon: () => h(PieChartOutlined),
label: 'Option 1',
title: 'Option 1',
},
{
key: '2',
icon: () => h(DesktopOutlined),
label: 'Option 2',
title: 'Option 2',
},
{
key: '3',
icon: () => h(InboxOutlined),
label: 'Option 3',
title: 'Option 3',
},
{
key: 'sub1',
icon: () => h(MailOutlined),
label: 'Navigation One',
title: 'Navigation One',
children: [
{
key: '5',
label: 'Option 5',
title: 'Option 5',
},
{
key: '6',
label: 'Option 6',
title: 'Option 6',
},
{
key: '7',
label: 'Option 7',
title: 'Option 7',
},
{
key: '8',
label: 'Option 8',
title: 'Option 8',
},
],
},
{
key: 'sub2',
icon: () => h(AppstoreOutlined),
label: 'Navigation Two',
title: 'Navigation Two',
children: [
{
key: '9',
label: 'Option 9',
title: 'Option 9',
},
{
key: '10',
label: 'Option 10',
title: 'Option 10',
},
{
key: 'sub3',
label: 'Submenu',
title: 'Submenu',
children: [
{
key: '11',
label: 'Option 11',
title: 'Option 11',
},
{
key: '12',
label: 'Option 12',
title: 'Option 12',
},
],
},
],
},
]);
watch(
() => state.openKeys,
(_val, oldVal) => {
state.preOpenKeys = oldVal;
},
);
const toggleCollapsed = () => {
state.collapsed = !state.collapsed;
state.openKeys = state.collapsed ? [] : state.preOpenKeys;
};
</script>
```
- 第一次进入应用时,使用Antdv的漫游式引导组件做一个引导操作
- 漫游式引导的目的是帮助用户快速上手FrpcPilot的功能使用流程
- 配置服务器、创建隧道、下载Frp、仪表盘一键启动这是主要的四步流程,你可以按照这个来做引导
- 打开应用时,当主界面未显示完整(还是白屏的时候),做一个启动加载动画,显示“FrpcPilot正在启动中”
- 加载动画可以是一圈正在转圈的圆点,圆点大小要有变化,颜色在加载的过程中也可以有变化,最好是做成svg动画的形式,或者你有更好的方案也可以采用
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
### 3. 功能开发 ### 3. 功能开发
#### 3.1 开发服务器配置功能 #### 3.1 开发服务器配置功能
@@ -189,3 +333,29 @@ Layout布局content区域的磨砂玻璃卡片圆角样式我要改动一下:
- 其他与项目名称有关的也修改一下 - 其他与项目名称有关的也修改一下
先扫描一下有哪些需要修改的,列一个清单出来,经过我的确认之后再开始修改 先扫描一下有哪些需要修改的,列一个清单出来,经过我的确认之后再开始修改
#### 4.2 Frpc下载功能修改
保持Frpc下载主要功能不变,现在需要对检索结果进行过滤方面的修改,具体如下:
- 去掉当前的两个开关选项“所有架构frpc”和“所有架构frps”
- 换成一个下拉列表,列表值为frp的所有不同架构分类,命名方式为:系统类型+下划线+架构,例如:windows_amd64、darwin_amd64、linux_amd64、linux_arm等等,默认值为当前运行环境操作系统类型和架构,最顶上有一个“全部”选项用来显示所有
- 如果是选中“全部”,排列顺序为按照frp版本,从高到低显示同一个版本不同系统和架构的frp,再显示低版本的
- 修改过程中保持主要的下载功能不变,包括加速方案
- 左侧菜单栏改成Frp下载
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
#### 4.3 程序运行数据目录
当前软件运行的数据在我这台电脑上都在:C:\Users\OrBit-0297\AppData\Roaming\FrpcPilot这个目录里面,我想要修改为数据保存在软件安装所在目录,例如我这台电脑安装在:D:\software\FrpcPilot,按照以下要求修改数据保存的目录:
- 数据库文件、下载的frp全部保存在软件安装所在目录里面,并做好分类,例如:db数据库文件放在data目录里面,下载的frp放在frp目录里面,运行frpc生成的toml文件放在run目录里面
- 程序运行时生成的其他文件等也放在软件安装目录下面
- 我这样做的目的是假如安装位置不在C盘,就尽量减少C盘的占用,如果本身就安装在C盘的某个位置,上面说的那些文件保存路径也是以软件安装目录为准,避免放在默认目录里面
- 顺便准备一份迁移脚本,在下一个版本升级的时候便于老版本用户无感迁移之前的数据,注意新安装用户不需要使用这个迁移脚本
- 我想到的方案是脚本,如果你有更好的方案的话,那就采用你的方案,如果没有,就按照我的要求来做,同时你需要考虑到我遗漏的地方
- 数据迁移之后,可能还需要处理数据库中保存的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。
+74 -1
View File
@@ -1,13 +1,86 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FrpcPilot</title> <title>FrpcPilot</title>
<!-- 首屏样式内联,脚本和样式资源尚未加载时也能显示启动状态。 -->
<style>
#startup-loader {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
background: radial-gradient(ellipse at 50% 40%, #e0e7ff, #f8fafc 70%);
color: #334155;
font: 500 15px/1.6 'Segoe UI', 'Microsoft YaHei', sans-serif;
}
#startup-loader[data-theme="dark"] {
background: radial-gradient(ellipse at 50% 40%, #1e1b4b, #0f172a 70%);
color: #e2e8f0;
}
#startup-loader svg { width: 76px; height: 76px; overflow: visible; }
#startup-loader .startup-ring {
transform-origin: 40px 40px;
animation: startup-orbit 3.6s linear infinite;
}
#startup-loader circle {
fill: #6366f1;
transform-box: fill-box;
transform-origin: center;
animation: startup-dot 1.6s ease-in-out infinite;
animation-delay: var(--delay);
}
@keyframes startup-orbit { to { transform: rotate(360deg); } }
@keyframes startup-dot {
0%, 100% { transform: scale(.55); opacity: .35; fill: #6366f1; }
50% { transform: scale(1.1); opacity: 1; fill: #06b6d4; }
}
@media (prefers-color-scheme: dark) {
#startup-loader:not([data-theme="light"]) {
background: radial-gradient(ellipse at 50% 40%, #1e1b4b, #0f172a 70%);
color: #e2e8f0;
}
}
@media (prefers-reduced-motion: reduce) {
#startup-loader .startup-ring, #startup-loader circle { animation: none; }
}
</style>
</head> </head>
<body> <body>
<div id="startup-loader" role="status" aria-live="polite" aria-busy="true" data-tauri-drag-region>
<svg viewBox="0 0 80 80" aria-hidden="true">
<g class="startup-ring">
<circle cx="40" cy="12" r="5" style="--delay: -1.4s" />
<circle cx="60" cy="20" r="5" style="--delay: -1.2s" />
<circle cx="68" cy="40" r="5" style="--delay: -1s" />
<circle cx="60" cy="60" r="5" style="--delay: -.8s" />
<circle cx="40" cy="68" r="5" style="--delay: -.6s" />
<circle cx="20" cy="60" r="5" style="--delay: -.4s" />
<circle cx="12" cy="40" r="5" style="--delay: -.2s" />
<circle cx="20" cy="20" r="5" style="--delay: 0s" />
</g>
</svg>
<span id="startup-label">FrpcPilot正在启动中</span>
</div>
<script>
try {
const savedTheme = localStorage.getItem('frpcpilot:theme');
if (savedTheme === 'dark' || savedTheme === 'light') {
document.getElementById('startup-loader').dataset.theme = savedTheme;
}
if (localStorage.getItem('frpcpilot:language') === 'en-US') {
document.getElementById('startup-label').textContent = 'FrpcPilot is starting';
document.documentElement.lang = 'en';
}
} catch { /* 存储不可用时仍显示默认启动画面。 */ }
</script>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frpcpilot", "name": "frpcpilot",
"private": true, "private": true,
"version": "1.3.2", "version": "1.4.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -22,7 +22,7 @@ tauri-build = { version = "2", features = [] }
tauri-helper = "0.2.1" tauri-helper = "0.2.1"
[dependencies] [dependencies]
tauri = { version = "2", features = ["tray-icon"] } tauri = { version = "2.12.3", features = ["tray-icon"] }
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
+70 -19
View File
@@ -12,6 +12,7 @@ use tokio::io::AsyncWriteExt;
use crate::models::{ use crate::models::{
ApiResponse, FrpDownload, FrpDownloadPayload, FrpDownloadProgress, FrpRelease, ApiResponse, FrpDownload, FrpDownloadPayload, FrpDownloadProgress, FrpRelease,
}; };
use crate::services::storage::StoragePaths;
/// GitHub 镜像加速前缀(国内访问 GitHub 不稳定,拉取版本与下载统一走镜像) /// GitHub 镜像加速前缀(国内访问 GitHub 不稳定,拉取版本与下载统一走镜像)
const GITHUB_MIRROR: &str = "https://gh-proxy.com/"; 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}")) Err(format!("获取 Frp 版本列表失败:{last_err}"))
} }
/// 应用数据目录下的 frp 版本存储根目录 /// 安装目录下的 frp 版本存储根目录
fn versions_dir(app: &AppHandle) -> Result<PathBuf, String> { fn versions_dir(app: &AppHandle) -> Result<PathBuf, String> {
Ok(app Ok(app.state::<StoragePaths>().versions())
.path() }
.app_data_dir()
.map_err(|e| e.to_string())? fn version_directory(version: &str) -> String {
.join("frp") if version != "."
.join("versions")) && 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>, message: Option<&str>,
) { ) {
let percent = if total > 0 { 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 { } else {
0 0
}; };
@@ -174,8 +194,8 @@ fn emit_progress(
/// 获取 Frp 版本列表(返回所有架构的发行包,并合并本机已下载 / 已导入记录) /// 获取 Frp 版本列表(返回所有架构的发行包,并合并本机已下载 / 已导入记录)
/// ///
/// 前端根据「展示所有架构的 Frpc / Frps」开关在本地过滤,避免频繁请求 /// 前端根据平台下拉列表在本地过滤,避免频繁请求 GitHub API 触发限流;
/// GitHub API 触发限流;默认仅展示当前运行平台由前端完成。 /// 默认仅展示当前运行平台由前端完成。
#[tauri::command] #[tauri::command]
#[auto_collect_command] #[auto_collect_command]
pub async fn get_frp_releases( pub async fn get_frp_releases(
@@ -219,7 +239,10 @@ pub async fn get_frp_releases(
}; };
for asset in assets { 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 { let Some((_v, os, arch)) = parse_asset(name) else {
continue; continue;
}; };
@@ -510,7 +533,9 @@ fn find_binary(dir: &Path, stem: &str) -> Option<PathBuf> {
stack.push(path); stack.push(path);
continue; 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 { else {
continue; continue;
}; };
@@ -558,10 +583,12 @@ pub async fn delete_frp(
}; };
if let Ok(base) = versions_dir(&app) { 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; let _ = tokio::fs::remove_dir_all(ver_dir.join(&row.platform)).await;
if let Some(archive) = &row.archive_path { 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 { 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 = ?") match sqlx::query("DELETE FROM frp_downloads WHERE id = ?")
.bind(id) .bind(id)
.execute(&*state) .execute(&*state)
@@ -581,7 +607,7 @@ pub async fn delete_frp(
} }
} }
/// 导入本地已下载的 frpc / frps(记录可执行文件路径,不复制文件) /// 导入本地 frpc / frps,将副本保存到安装目录,原始文件保持不变。
#[tauri::command] #[tauri::command]
#[auto_collect_command] #[auto_collect_command]
pub async fn import_frp( pub async fn import_frp(
@@ -612,11 +638,33 @@ pub async fn import_frp(
.file_name() .file_name()
.map(|s| s.to_string_lossy().to_string()) .map(|s| s.to_string_lossy().to_string())
.unwrap_or_default(); .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 lower = file_name.to_ascii_lowercase();
let is_frps = lower.contains("frps"); let is_frps = lower.contains("frps");
let version = parse_version(&file_name).unwrap_or_else(|| file_name.clone()); 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 { let (frpc_path, frps_path) = if is_frps {
(None, Some(full)) (None, Some(full))
} else { } else {
@@ -683,7 +731,10 @@ mod tests {
#[test] #[test]
fn parse_version_extracts_semver() { 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("frps-v0.60.0"), Some("v0.60.0".into()));
assert_eq!(parse_version("frpc.exe"), None); assert_eq!(parse_version("frpc.exe"), None);
assert_eq!(parse_version("frpc_0.71.exe"), None); assert_eq!(parse_version("frpc_0.71.exe"), None);
+1
View File
@@ -1,5 +1,6 @@
pub mod frp; pub mod frp;
pub mod runner; pub mod runner;
pub mod server; pub mod server;
pub mod storage;
pub mod system; pub mod system;
pub mod tunnel; 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::models::{ApiResponse, FrpDownload, FrpcStatus, Server, Tunnel};
use crate::services::config::generate_frpc_config; use crate::services::config::generate_frpc_config;
use crate::services::runner::FrpcRunner; use crate::services::runner::FrpcRunner;
use crate::services::storage::StoragePaths;
// 统一查询字段,使用 concat! 保证结果为 'static str(sqlx 0.9 要求) // 统一查询字段,使用 concat! 保证结果为 'static str(sqlx 0.9 要求)
macro_rules! server_fields { 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> { fn config_file_path(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app let dir = app.state::<StoragePaths>().run();
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("frp")
.join("run");
std::fs::create_dir_all(&dir).map_err(|e| format!("创建配置目录失败:{e}"))?; std::fs::create_dir_all(&dir).map_err(|e| format!("创建配置目录失败:{e}"))?;
Ok(dir.join("frpc.toml")) Ok(dir.join("frpc.toml"))
} }
@@ -77,7 +73,12 @@ pub async fn start_frpc(
.await .await
{ {
Ok(Some(row)) => row, 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())), 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, create_server, delete_server, get_server_list, set_server_enabled, set_server_pinned,
update_server, 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::system::{get_system_details, get_system_info, is_autostart_supported};
use crate::commands::tunnel::{ use crate::commands::tunnel::{
create_tunnel, delete_tunnel, get_tunnel_list, set_tunnel_enabled, set_tunnel_pinned, create_tunnel, delete_tunnel, get_tunnel_list, set_tunnel_enabled, set_tunnel_pinned,
update_tunnel, update_tunnel,
}; };
use crate::services::db::init_pool;
use crate::services::runner::FrpcRunner; use crate::services::runner::FrpcRunner;
use crate::services::storage::{self, StoragePaths};
use crate::services::system::SystemStats; use crate::services::system::SystemStats;
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { 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() let mut builder = tauri::Builder::default()
.manage(paths)
.manage(PreferencesLock::default())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init()) // 对话框插件 .plugin(tauri_plugin_dialog::init()) // 对话框插件
.plugin(tauri_plugin_updater::Builder::new().build()) // 更新插件 .plugin(tauri_plugin_updater::Builder::new().build()) // 更新插件
.plugin(tauri_plugin_process::init()) // 进程插件(更新后重启) .plugin(tauri_plugin_process::init()) // 进程插件(更新后重启)
.plugin(tauri_plugin_prevent_default::Builder::new().build()); .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() { if is_autostart_supported() {
builder = builder.plugin(tauri_plugin_autostart::init( builder = builder.plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent, tauri_plugin_autostart::MacosLauncher::LaunchAgent,
@@ -41,8 +60,26 @@ pub fn run() {
let app = builder let app = builder
.setup(|app| { .setup(|app| {
// The plugin resolves the running executable dynamically. Refresh enabled use tauri_plugin_dialog::{DialogExt, MessageDialogKind};
// entries after reinstalling or moving the app, preserving disabled state.
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() { if is_autostart_supported() {
let autostart = app.autolaunch(); let autostart = app.autolaunch();
let result = autostart.is_enabled().and_then(|enabled| { 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 / 内存)后台采集 // 系统资源占用(CPU / 内存)后台采集
let stats = SystemStats::default(); let stats = SystemStats::default();
stats.start(); stats.start();
@@ -72,12 +102,19 @@ pub fn run() {
// Frpc 进程管理器(一键启动 / 停止与日志实时回显) // Frpc 进程管理器(一键启动 / 停止与日志实时回显)
app.manage(FrpcRunner::default()); 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(()) Ok(())
}) })
.plugin(tauri_plugin_store::Builder::default().build()) .plugin(tauri_plugin_store::Builder::default().build())
// .invoke_handler(tauri::generate_handler![greet]) // .invoke_handler(tauri::generate_handler![greet])
.invoke_handler(tauri_helper::tauri_collect_commands!()) .invoke_handler(tauri_helper::tauri_collect_commands!())
.build(tauri::generate_context!()) .build(context)
.expect("error while building tauri application"); .expect("error while building tauri application");
app.run(|app_handle, event| { app.run(|app_handle, event| {
+3 -7
View File
@@ -1,14 +1,10 @@
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions}; 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> { pub async fn init_pool(db_path: &Path) -> 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");
// 初始化数据库连接池 // 初始化数据库连接池
let options = SqliteConnectOptions::new() let options = SqliteConnectOptions::new()
.filename(&db_path) .filename(db_path)
.create_if_missing(true); .create_if_missing(true);
let pool = SqlitePoolOptions::new() let pool = SqlitePoolOptions::new()
.max_connections(5) .max_connections(5)
+1
View File
@@ -1,4 +1,5 @@
pub mod config; pub mod config;
pub mod db; pub mod db;
pub mod runner; pub mod runner;
pub mod storage;
pub mod system; pub mod system;
+2 -2
View File
@@ -97,8 +97,8 @@ impl FrpcRunner {
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(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); 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;
});
}
+1 -1
View File
@@ -22,7 +22,7 @@ async function call<T>(command: string, args?: Record<string, unknown>): Promise
} }
/** /**
* 获取 Frp 版本列表(返回所有架构,前端按开关过滤) * 获取 Frp 版本列表(返回所有架构,前端按平台筛选)
*/ */
export function getFrpReleases() { export function getFrpReleases() {
return call<FrpRelease[]>("get_frp_releases") return call<FrpRelease[]>("get_frp_releases")
+2
View File
@@ -1,5 +1,6 @@
import type { Update } from "@tauri-apps/plugin-updater" import type { Update } from "@tauri-apps/plugin-updater"
import { isTauri } from "@/utils/window" import { isTauri } from "@/utils/window"
import { flushPreferences } from "@/utils/preferences"
/** 项目 GitHub 仓库地址(与 tauri.conf.json 中 updater 端点保持一致) */ /** 项目 GitHub 仓库地址(与 tauri.conf.json 中 updater 端点保持一致) */
export const PROJECT_GITHUB_URL = "https://github.com/SparksFly91/FrpcPilot" export const PROJECT_GITHUB_URL = "https://github.com/SparksFly91/FrpcPilot"
@@ -18,6 +19,7 @@ export async function checkForAppUpdate(): Promise<Update | null> {
/** 重启应用(macOS / Linux 安装更新后需要重启才能运行新版本) */ /** 重启应用(macOS / Linux 安装更新后需要重启才能运行新版本) */
export async function relaunchApp() { export async function relaunchApp() {
if (!isTauri()) return if (!isTauri()) return
await flushPreferences()
const { relaunch } = await import("@tauri-apps/plugin-process") const { relaunch } = await import("@tauri-apps/plugin-process")
await relaunch() 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()
+71
View File
@@ -0,0 +1,71 @@
<template>
<a-tour
:open="onboarding.active && visible"
:current="current"
:steps="steps"
:gap="{ offset: 8, radius: 10 }"
:mask="{ color: 'rgba(15, 23, 42, 0.55)' }"
@change="changeStep"
@close="onboarding.close"
/>
</template>
<script setup lang="ts">
import type { TourProps } from "ant-design-vue"
import { useI18n } from "vue-i18n"
import { useOnboardingStore } from "@/stores/onboarding"
const { t } = useI18n()
const router = useRouter()
const onboarding = useOnboardingStore()
const current = ref(0)
const visible = ref(false)
const navigating = ref(false)
const target = shallowRef<HTMLElement>()
const destinations = [
{ path: "/servers", target: "server-create", key: "servers" },
{ path: "/tunnels", target: "tunnel-create", key: "tunnels" },
{ path: "/download", target: "frp-download", key: "download" },
{ path: "/dashboard", target: "dashboard-start", key: "dashboard" },
] as const
const steps = computed<TourProps["steps"]>(() => destinations.map((step, index) => ({
title: t(`onboarding.${step.key}Title`),
description: t(`onboarding.${step.key}Description`),
target: index === current.value ? target.value : undefined,
placement: "bottom",
})))
async function changeStep(index: number) {
const destination = destinations[index]
if (!destination || navigating.value) return
navigating.value = true
visible.value = false
try {
await router.push(destination.path)
await nextTick()
current.value = index
target.value = document.querySelector<HTMLElement>(`[data-tour="${destination.target}"]`) ?? undefined
visible.value = onboarding.active
} catch (error) {
onboarding.close()
console.error("Unable to open onboarding page", error)
} finally {
navigating.value = false
}
}
watch(() => onboarding.active, (active) => {
if (active) void changeStep(0)
else visible.value = false
})
watch(() => onboarding.ready, (ready) => {
if (ready && !onboarding.dismissed) onboarding.start()
}, { immediate: true })
// Antdv 提供关闭按钮;同时允许键盘用户通过 Escape 跳过引导。
onKeyStroke("Escape", () => {
if (onboarding.active) onboarding.close()
})
</script>
+1 -2
View File
@@ -11,7 +11,7 @@
<div class="mt-1 flex flex-wrap items-center gap-2"> <div class="mt-1 flex flex-wrap items-center gap-2">
<a-tag color="blue" class="!me-0">{{ release.platform }}</a-tag> <a-tag color="blue" class="!me-0">{{ release.platform }}</a-tag>
<a-tag class="!me-0">frpc</a-tag> <a-tag class="!me-0">frpc</a-tag>
<a-tag v-if="showFrps" color="cyan" class="!me-0">frps</a-tag> <a-tag color="cyan" class="!me-0">frps</a-tag>
</div> </div>
</div> </div>
</div> </div>
@@ -93,7 +93,6 @@ import type { FrpDownloadProgress, FrpRelease } from "@/types/frp"
const props = defineProps<{ const props = defineProps<{
release: FrpRelease release: FrpRelease
progress?: FrpDownloadProgress | null progress?: FrpDownloadProgress | null
showFrps?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
+24 -6
View File
@@ -25,9 +25,27 @@
"dashboard": "Dashboard", "dashboard": "Dashboard",
"servers": "Servers", "servers": "Servers",
"tunnels": "Tunnels", "tunnels": "Tunnels",
"download": "Download frpc", "download": "Frp Download",
"setting": "Settings" "setting": "Settings"
}, },
"sidebar": {
"navigation": "Main navigation",
"collapse": "Collapse navigation",
"expand": "Expand navigation"
},
"onboarding": {
"title": "Getting started",
"replay": "View guide",
"replayDescription": "Learn the complete setup and startup workflow in four steps",
"serversTitle": "1 / 4 · Configure a server",
"serversDescription": "Choose Add Server, enter your deployed frps address, port and authentication token, then enable the server. Only one server needs to be enabled at a time.",
"tunnelsTitle": "2 / 4 · Create a tunnel",
"tunnelsDescription": "Choose Add Tunnel, select a proxy type such as TCP or HTTP, and enter your local service IP and port, plus a remote port or domain. Save and enable the tunnel. Multiple tunnels can run together.",
"downloadTitle": "3 / 4 · Download Frp",
"downloadDescription": "Filter by your system and architecture, then download Frp from a version card below. You can also import an existing local frpc. Downloaded or imported versions appear in the dashboard selector.",
"dashboardTitle": "4 / 4 · Start from the dashboard",
"dashboardDescription": "After setup, select a downloaded Frpc version and click Start to connect. Check the live logs and running status below to verify the connection. You are ready to configure your connection. Replay this guide anytime in Settings → General."
},
"tray": { "tray": {
"show": "Show window", "show": "Show window",
"start": "Start tunnel", "start": "Start tunnel",
@@ -62,7 +80,7 @@
"arch": "Architecture", "arch": "Architecture",
"ip": "IP address", "ip": "IP address",
"frpcVersion": "Frpc version", "frpcVersion": "Frpc version",
"frpcVersionEmpty": "No downloaded version yet. Download one from the Frpc download page first.", "frpcVersionEmpty": "No downloaded version yet. Download one from the Frp download page first.",
"start": "One-click start", "start": "One-click start",
"stop": "Stop", "stop": "Stop",
"logs": "Live logs", "logs": "Live logs",
@@ -236,8 +254,8 @@
"frp": { "frp": {
"refresh": "Refresh", "refresh": "Refresh",
"import": "Import local", "import": "Import local",
"showAllArchFrpc": "All-arch Frpc", "platformFilter": "Platform",
"showAllArchFrps": "All-arch Frps", "allPlatforms": "All",
"empty": "No Frp release data. Click Refresh to retry.", "empty": "No Frp release data. Click Refresh to retry.",
"downloaded": "Downloaded", "downloaded": "Downloaded",
"local": "Local", "local": "Local",
@@ -272,8 +290,8 @@
"intro": "FrpcPilot is a visual client for frp built with Tauri 2 + Vue 3 + TypeScript. Manage frpc versions, servers and tunnels through a friendly GUI — and say goodbye to hand-writing frpc.toml.", "intro": "FrpcPilot is a visual client for frp built with Tauri 2 + Vue 3 + TypeScript. Manage frpc versions, servers and tunnels through a friendly GUI — and say goodbye to hand-writing frpc.toml.",
"flowTitle": "How to use", "flowTitle": "How to use",
"flow": { "flow": {
"step1Title": "Download frpc", "step1Title": "Download Frp",
"step1Desc": "Open \"Download frpc\", grab the build for your platform and architecture, or import a local one.", "step1Desc": "Open \"Frp Download\", grab the build for your platform and architecture, or import a local one.",
"step2Title": "Configure a server", "step2Title": "Configure a server",
"step2Desc": "Add the frps address, port and auth token under \"Servers\", then enable one of them.", "step2Desc": "Add the frps address, port and auth token under \"Servers\", then enable one of them.",
"step3Title": "Configure tunnels", "step3Title": "Configure tunnels",
+24 -6
View File
@@ -25,9 +25,27 @@
"dashboard": "仪表盘", "dashboard": "仪表盘",
"servers": "服务器", "servers": "服务器",
"tunnels": "隧道列表", "tunnels": "隧道列表",
"download": "Frpc 下载", "download": "Frp下载",
"setting": "设置" "setting": "设置"
}, },
"sidebar": {
"navigation": "主导航",
"collapse": "折叠导航",
"expand": "展开导航"
},
"onboarding": {
"title": "新手引导",
"replay": "重新查看",
"replayDescription": "用四个步骤了解从配置到启动的完整流程",
"serversTitle": "1 / 4 · 配置服务器",
"serversDescription": "从「新增服务器」开始,填写已部署的 frps 地址、端口和鉴权 Token,并启用这台服务器。同一时间只需启用一台。",
"tunnelsTitle": "2 / 4 · 创建隧道",
"tunnelsDescription": "点击「新增隧道」,选择 TCP、HTTP 等代理类型,填写本地服务的 IP 和端口,以及远程端口或域名,保存并启用隧道。可以同时启用多个隧道。",
"downloadTitle": "3 / 4 · 下载 Frp",
"downloadDescription": "按当前系统和架构筛选版本,在下方版本卡片中下载 Frp;也可以通过「导入本地」使用已有的 frpc。下载或导入后,即可在仪表盘中选择该版本。",
"dashboardTitle": "4 / 4 · 仪表盘一键启动",
"dashboardDescription": "完成前面的配置后,选择已下载的 Frpc 版本,点击「一键启动」开始穿透。下方实时日志和运行状态可帮助确认连接情况。现在可以开始配置了,也可随时在「设置 → 常规」重新查看引导。"
},
"tray": { "tray": {
"show": "显示窗口", "show": "显示窗口",
"start": "启动穿透", "start": "启动穿透",
@@ -62,7 +80,7 @@
"arch": "系统架构", "arch": "系统架构",
"ip": "IP 地址", "ip": "IP 地址",
"frpcVersion": "Frpc 版本", "frpcVersion": "Frpc 版本",
"frpcVersionEmpty": "暂无已下载版本,请先前往 Frpc 下载页面下载", "frpcVersionEmpty": "暂无已下载版本,请先前往 Frp下载页面下载",
"start": "一键启动", "start": "一键启动",
"stop": "停止", "stop": "停止",
"logs": "实时日志", "logs": "实时日志",
@@ -236,8 +254,8 @@
"frp": { "frp": {
"refresh": "刷新", "refresh": "刷新",
"import": "导入本地", "import": "导入本地",
"showAllArchFrpc": "所有架构 Frpc", "platformFilter": "架构",
"showAllArchFrps": "所有架构 Frps", "allPlatforms": "全部",
"empty": "暂无 Frp 版本数据,点击刷新重试", "empty": "暂无 Frp 版本数据,点击刷新重试",
"downloaded": "已下载", "downloaded": "已下载",
"local": "本地", "local": "本地",
@@ -272,8 +290,8 @@
"intro": "FrpcPilot 是一个基于 Tauri 2 + Vue 3 + TypeScript 打造的 frp 可视化客户端,帮你用图形界面管理 frpc 版本、服务器与隧道,从此告别手写 frpc.toml。", "intro": "FrpcPilot 是一个基于 Tauri 2 + Vue 3 + TypeScript 打造的 frp 可视化客户端,帮你用图形界面管理 frpc 版本、服务器与隧道,从此告别手写 frpc.toml。",
"flowTitle": "使用流程", "flowTitle": "使用流程",
"flow": { "flow": {
"step1Title": "下载 frpc", "step1Title": "下载 Frp",
"step1Desc": "进入「Frpc 下载」,按平台与架构下载,或导入本地已有的 frpc。", "step1Desc": "进入「Frp下载」,按平台与架构下载,或导入本地已有的 frpc。",
"step2Title": "配置服务器", "step2Title": "配置服务器",
"step2Desc": "在「服务器」中填写 frps 地址、端口与鉴权 Token,并启用其中一台。", "step2Desc": "在「服务器」中填写 frps 地址、端口与鉴权 Token,并启用其中一台。",
"step3Title": "配置隧道", "step3Title": "配置隧道",
+12 -3
View File
@@ -23,6 +23,7 @@
<!-- 关闭确认对话框(最小化到系统托盘 / 直接退出) --> <!-- 关闭确认对话框(最小化到系统托盘 / 直接退出) -->
<CloseConfirmModal /> <CloseConfirmModal />
<OnboardingTour />
</div> </div>
</template> </template>
@@ -32,9 +33,12 @@ import Aside from "./aside/Aside.vue"
import UpdateModal from "@/components/UpdateModal.vue" import UpdateModal from "@/components/UpdateModal.vue"
import CloseConfirmModal from "@/components/CloseConfirmModal.vue" import CloseConfirmModal from "@/components/CloseConfirmModal.vue"
import { useUpdateStore } from "@/stores/update" import { useUpdateStore } from "@/stores/update"
import { useOnboardingStore } from "@/stores/onboarding"
import OnboardingTour from "@/components/OnboardingTour.vue"
const router = useRouter() const router = useRouter()
const updateStore = useUpdateStore() const updateStore = useUpdateStore()
const onboarding = useOnboardingStore()
/** /**
* 需要 keep-alive 缓存的页面组件名列表(由路由 meta.isCache 决定)。 * 需要 keep-alive 缓存的页面组件名列表(由路由 meta.isCache 决定)。
@@ -55,7 +59,12 @@ function goToSetting() {
} }
// 应用启动后静默检查一次更新(已忽略的版本不会打扰用户) // 应用启动后静默检查一次更新(已忽略的版本不会打扰用户)
onMounted(() => { const stopUpdateWatch = watch(
updateStore.checkForUpdate(true) () => onboarding.ready && onboarding.dismissed && !onboarding.active,
}) (canCheck) => {
if (!canCheck) return
void updateStore.checkForUpdate(true)
stopUpdateWatch()
},
)
</script> </script>
+88 -3
View File
@@ -1,14 +1,19 @@
<template> <template>
<!-- 侧边栏:磨砂玻璃悬浮面板 --> <!-- 侧边栏:磨砂玻璃悬浮面板 -->
<aside class="glass-panel flex w-56 shrink-0 flex-col overflow-hidden"> <aside
id="app-sidebar"
class="app-sidebar glass-panel flex shrink-0 flex-col overflow-hidden"
:class="{ 'is-collapsed': appStore.sidebarCollapsed }"
:aria-label="t('sidebar.navigation')"
>
<!-- 菜单区 --> <!-- 菜单区 -->
<div class="flex-1 overflow-y-auto p-2"> <div class="flex-1 overflow-y-auto p-2">
<a-menu class="aside-menu" mode="inline" :selected-keys="selectedKeys" :items="menuItems" @click="onMenuClick" /> <a-menu class="aside-menu" mode="inline" :inline-collapsed="appStore.sidebarCollapsed" :selected-keys="selectedKeys" :items="menuItems" @click="onMenuClick" />
</div> </div>
<!-- 底部系统信息区(与菜单之间有间隔) --> <!-- 底部系统信息区(与菜单之间有间隔) -->
<div class="system-panel mt-2 border-t px-2 pb-2 pt-3"> <div class="system-panel mt-2 border-t px-2 pb-2 pt-3">
<div class="px-1"> <div v-if="!appStore.sidebarCollapsed" class="px-1">
<!-- CPU --> <!-- CPU -->
<div class="mb-2"> <div class="mb-2">
<div class="mb-1 flex items-center justify-between text-xs"> <div class="mb-1 flex items-center justify-between text-xs">
@@ -35,15 +40,38 @@
</div> </div>
</div> </div>
<a-tooltip v-else placement="right" :title="`${t('system.cpu')}: ${cpu}% · ${t('system.memory')}: ${memory}% · ${statusText}`">
<div class="flex h-10 items-center justify-center" tabindex="0" :aria-label="`${t('system.cpu')}: ${cpu}% · ${t('system.memory')}: ${memory}% · ${statusText}`">
<span class="h-2 w-2 rounded-full" :class="statusDotClass" />
</div>
</a-tooltip>
<!-- 设置入口(位于运行状态下方) --> <!-- 设置入口(位于运行状态下方) -->
<div class="system-divider mt-3 border-t pt-2"> <div class="system-divider mt-3 border-t pt-2">
<a-menu <a-menu
class="aside-menu" class="aside-menu"
mode="inline" mode="inline"
:inline-collapsed="appStore.sidebarCollapsed"
:selected-keys="settingSelectedKeys" :selected-keys="settingSelectedKeys"
:items="settingItems" :items="settingItems"
@click="onMenuClick" @click="onMenuClick"
/> />
<a-tooltip placement="right" :title="collapseLabel">
<a-button
class="sidebar-toggle"
type="text"
:aria-label="collapseLabel"
:aria-expanded="!appStore.sidebarCollapsed"
aria-controls="app-sidebar"
@click="appStore.sidebarCollapsed = !appStore.sidebarCollapsed"
>
<template #icon>
<MenuUnfoldOutlined v-if="appStore.sidebarCollapsed" />
<MenuFoldOutlined v-else />
</template>
<span v-if="!appStore.sidebarCollapsed">{{ collapseLabel }}</span>
</a-button>
</a-tooltip>
</div> </div>
</div> </div>
</aside> </aside>
@@ -56,6 +84,8 @@ import {
CloudServerOutlined, CloudServerOutlined,
DashboardOutlined, DashboardOutlined,
DownloadOutlined, DownloadOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
SettingOutlined, SettingOutlined,
} from "@ant-design/icons-vue" } from "@ant-design/icons-vue"
import { useI18n } from "vue-i18n" import { useI18n } from "vue-i18n"
@@ -67,6 +97,7 @@ const { t } = useI18n()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const appStore = useAppStore() const appStore = useAppStore()
const collapseLabel = computed(() => t(appStore.sidebarCollapsed ? 'sidebar.expand' : 'sidebar.collapse'))
const { cpu, memory } = useSystemStats() const { cpu, memory } = useSystemStats()
@@ -121,6 +152,60 @@ const statusText = computed(() => {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.app-sidebar {
width: 224px;
transition: width 200ms ease;
&.is-collapsed {
width: 72px;
}
}
.sidebar-toggle {
width: 100%;
height: 44px;
border-radius: 10px;
}
.sidebar-toggle:focus-visible {
outline: 2px solid #6366f1;
outline-offset: -2px;
}
.is-collapsed .aside-menu {
width: 100%;
border-inline-end: none;
:deep(.ant-menu-item) {
display: flex;
align-items: center;
justify-content: center;
padding-inline: 0 !important;
height: 44px;
line-height: 44px;
}
:deep(.ant-menu-item-icon) {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 16px;
margin: 0;
font-size: 16px;
line-height: 1;
}
:deep(.ant-menu-title-content) {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.app-sidebar {
transition: none;
}
}
.system-panel { .system-panel {
border-color: rgba(15, 23, 42, 0.08); border-color: rgba(15, 23, 42, 0.08);
} }
+12 -25
View File
@@ -1,28 +1,15 @@
import { createApp } from "vue" import { initializePreferences } from "@/utils/preferences"
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"
/* // 状态仓库创建时会读取初始设置,因此先加载安装目录内保存的设置。
* macOS 的无边框窗口系统不会自动裁圆角,需要配合 transparent 窗口, async function start() {
* 由 #app 自身裁出 8px 圆角(见 assets/main.css)。这里在挂载前打上平台标记, try {
* 避免首帧出现方角闪烁。 await initializePreferences()
*/ await import("./bootstrap")
if (detectPlatform() === "macos") { } catch (error) {
document.documentElement.classList.add("is-macos") 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) void start()
app.use(createPinia())
app.use(Antd)
app.use(i18n)
app.use(router)
app.mount("#app")
+10 -5
View File
@@ -1,4 +1,5 @@
import { defineStore } from "pinia" import { defineStore } from "pinia"
import { preferences } from "@/utils/preferences"
import zhCN from "ant-design-vue/es/locale/zh_CN" import zhCN from "ant-design-vue/es/locale/zh_CN"
import enUS from "ant-design-vue/es/locale/en_US" import enUS from "ant-design-vue/es/locale/en_US"
import { setI18nLanguage, type Language } from "@/i18n" import { setI18nLanguage, type Language } from "@/i18n"
@@ -15,11 +16,14 @@ export type RunStatus = "running" | "error" | "stopped"
* 全局应用配置:语言、主题、窗口背景、Frpc 运行状态 * 全局应用配置:语言、主题、窗口背景、Frpc 运行状态
*/ */
export const useAppStore = defineStore("app", () => { 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, preferences)
// 明暗主题:useDark 会在 <html> 上切换 .dark 类名 // 明暗主题:useDark 会在 <html> 上切换 .dark 类名
const isDark = useDark({ const isDark = useDark({
storage: preferences,
storageKey: "frpcpilot:theme", storageKey: "frpcpilot:theme",
selector: "html", selector: "html",
attribute: "class", attribute: "class",
@@ -29,7 +33,7 @@ export const useAppStore = defineStore("app", () => {
const toggleDark = useToggle(isDark) 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)) const backgroundUrl = computed(() => getBackgroundUrl(background.value))
// 把背景图同步到 <html>(整窗背景,避免元素高度误差造成的上下留白) // 把背景图同步到 <html>(整窗背景,避免元素高度误差造成的上下留白)
@@ -46,8 +50,8 @@ export const useAppStore = defineStore("app", () => {
}) })
// 磨砂玻璃透明度(0 - 100,数值越大越透明,持久化) // 磨砂玻璃透明度(0 - 100,数值越大越透明,持久化)
const cardTransparency = useStorage<number>("frpcpilot:card-transparency", 55) const cardTransparency = useStorage<number>("frpcpilot:card-transparency", 55, preferences)
const modalTransparency = useStorage<number>("frpcpilot:modal-transparency", 38) const modalTransparency = useStorage<number>("frpcpilot:modal-transparency", 38, preferences)
/** 透明度百分比 → 背景 alpha(越大越透明,alpha 越小;并做范围兜底) */ /** 透明度百分比 → 背景 alpha(越大越透明,alpha 越小;并做范围兜底) */
function alphaFromTransparency(value: number) { function alphaFromTransparency(value: number) {
@@ -93,6 +97,7 @@ export const useAppStore = defineStore("app", () => {
return { return {
language, language,
sidebarCollapsed,
isDark, isDark,
toggleDark, toggleDark,
background, background,
+19
View File
@@ -0,0 +1,19 @@
import { defineStore } from "pinia"
import { preferences } from "@/utils/preferences"
export const useOnboardingStore = defineStore("onboarding", () => {
const dismissed = useStorage("frpcpilot:onboarding-dismissed", false, preferences)
const ready = ref(false)
const active = ref(false)
function start() {
active.value = true
}
function close() {
active.value = false
dismissed.value = true
}
return { dismissed, ready, active, start, close }
})
+3 -1
View File
@@ -1,4 +1,5 @@
import { defineStore } from "pinia" import { defineStore } from "pinia"
import { preferences, flushPreferences } from "@/utils/preferences"
import { message } from "ant-design-vue" import { message } from "ant-design-vue"
import type { Update } from "@tauri-apps/plugin-updater" import type { Update } from "@tauri-apps/plugin-updater"
import { checkForAppUpdate, relaunchApp } from "@/api/updater" import { checkForAppUpdate, relaunchApp } from "@/api/updater"
@@ -20,7 +21,7 @@ export interface UpdateInfo {
*/ */
export const useUpdateStore = defineStore("update", () => { 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 checking = ref(false)
const installing = ref(false) const installing = ref(false)
@@ -137,6 +138,7 @@ export const useUpdateStore = defineStore("update", () => {
installing.value = true installing.value = true
resetProgress() resetProgress()
try { try {
await flushPreferences()
await pending.downloadAndInstall((event) => { await pending.downloadAndInstall((event) => {
switch (event.event) { switch (event.event) {
case "Started": case "Started":
+3 -2
View File
@@ -1,4 +1,5 @@
import { defineStore } from "pinia" import { defineStore } from "pinia"
import { preferences } from "@/utils/preferences"
import { exitApp, hideWindow, isTauri, revealWindow } from "@/utils/window" import { exitApp, hideWindow, isTauri, revealWindow } from "@/utils/window"
import { useFrpcStore } from "@/stores/frpc" import { useFrpcStore } from "@/stores/frpc"
@@ -24,9 +25,9 @@ export const useWindowStore = defineStore("window", () => {
/** 关闭确认对话框是否打开 */ /** 关闭确认对话框是否打开 */
const closeConfirmOpen = ref(false) 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) const quitting = ref(false)
+2
View File
@@ -46,11 +46,13 @@ declare module 'vue' {
ATag: typeof import('ant-design-vue/es')['Tag'] ATag: typeof import('ant-design-vue/es')['Tag']
ATextarea: typeof import('ant-design-vue/es')['Textarea'] ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATooltip: typeof import('ant-design-vue/es')['Tooltip'] ATooltip: typeof import('ant-design-vue/es')['Tooltip']
ATour: typeof import('ant-design-vue/es')['Tour']
CardRadioGroup: typeof import('./../components/CardRadioGroup.vue')['default'] CardRadioGroup: typeof import('./../components/CardRadioGroup.vue')['default']
CloseConfirmModal: typeof import('./../components/CloseConfirmModal.vue')['default'] CloseConfirmModal: typeof import('./../components/CloseConfirmModal.vue')['default']
FrpCard: typeof import('./../components/frp/FrpCard.vue')['default'] FrpCard: typeof import('./../components/frp/FrpCard.vue')['default']
MacTitleBar: typeof import('./../components/MacTitleBar.vue')['default'] MacTitleBar: typeof import('./../components/MacTitleBar.vue')['default']
MarkdownBlock: typeof import('./../components/MarkdownBlock.vue')['default'] MarkdownBlock: typeof import('./../components/MarkdownBlock.vue')['default']
OnboardingTour: typeof import('./../components/OnboardingTour.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
ServerCard: typeof import('./../components/server/ServerCard.vue')['default'] ServerCard: typeof import('./../components/server/ServerCard.vue')['default']
+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` 调试。 * 在纯浏览器环境(非 Tauri)下调用会被安全忽略,方便 `pnpm dev` 调试。
*/ */
import { flushPreferences } from "@/utils/preferences"
export function isTauri() { export function isTauri() {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window
} }
@@ -49,6 +51,7 @@ export async function revealWindow() {
/** 退出整个应用(退出前由调用方负责停止穿透等收尾工作) */ /** 退出整个应用(退出前由调用方负责停止穿透等收尾工作) */
export async function exitApp() { export async function exitApp() {
if (!isTauri()) return if (!isTauri()) return
await flushPreferences()
const { exit } = await import("@tauri-apps/plugin-process") const { exit } = await import("@tauri-apps/plugin-process")
await exit(0) await exit(0)
} }
+1
View File
@@ -83,6 +83,7 @@
<!-- 快速操作 --> <!-- 快速操作 -->
<div <div
data-tour="dashboard-start"
class="glass-panel glass-panel-flat flex shrink-0 flex-wrap items-center justify-between gap-3 px-5 py-3" class="glass-panel glass-panel-flat flex shrink-0 flex-wrap items-center justify-between gap-3 px-5 py-3"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
+53 -16
View File
@@ -2,6 +2,7 @@
<div class="flex h-full flex-col gap-3"> <div class="flex h-full flex-col gap-3">
<!-- 顶部操作栏(独立磨砂卡片) --> <!-- 顶部操作栏(独立磨砂卡片) -->
<div <div
data-tour="frp-download"
class="glass-panel glass-panel-flat flex shrink-0 flex-wrap items-center justify-between gap-3 px-5 py-3" class="glass-panel glass-panel-flat flex shrink-0 flex-wrap items-center justify-between gap-3 px-5 py-3"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -10,13 +11,15 @@
</div> </div>
<div class="flex flex-wrap items-center gap-x-5 gap-y-2"> <div class="flex flex-wrap items-center gap-x-5 gap-y-2">
<label class="flex cursor-pointer items-center gap-2 text-xs"> <label class="flex items-center gap-2 text-xs">
<a-switch v-model:checked="showAllArchFrpc" size="small" /> <span class="shrink-0 opacity-70">{{ t("frp.platformFilter") }}</span>
<span>{{ t("frp.showAllArchFrpc") }}</span> <a-select
</label> v-model:value="selectedPlatform"
<label class="flex cursor-pointer items-center gap-2 text-xs"> :aria-label="t('frp.platformFilter')"
<a-switch v-model:checked="showAllArchFrps" size="small" /> :options="platformOptions"
<span>{{ t("frp.showAllArchFrps") }}</span> class="w-44"
size="small"
/>
</label> </label>
<a-space> <a-space>
@@ -41,7 +44,6 @@
:key="`${item.version}-${item.platform}-${item.file_name}`" :key="`${item.version}-${item.platform}-${item.file_name}`"
:release="item" :release="item"
:progress="progressMap[item.file_name]" :progress="progressMap[item.file_name]"
:show-frps="showAllArchFrps"
@download="onDownload(item)" @download="onDownload(item)"
@copy="onCopy(item)" @copy="onCopy(item)"
@delete="onDelete(item)" @delete="onDelete(item)"
@@ -71,21 +73,46 @@ defineOptions({ name: "Download" })
const loading = ref(false) const loading = ref(false)
const importing = ref(false) const importing = ref(false)
/** 是否展示所有架构的 Frpc */ const ALL_PLATFORMS = "all"
const showAllArchFrpc = useStorage("frpcpilot:frp:allArchFrpc", false) const selectedPlatform = ref(ALL_PLATFORMS)
/** 是否作为 frp 下载器展示所有架构的 Frps */ const platformInitialized = ref(false)
const showAllArchFrps = useStorage("frpcpilot:frp:allArchFrps", false)
/** 后端返回的全部发行包(含所有架构) */ /** 后端返回的全部发行包(含所有架构) */
const allReleases = ref<FrpRelease[]>([]) const allReleases = ref<FrpRelease[]>([])
/** /**
* 按开关过滤后的展示列表: * 从发行包数据中生成平台筛选项,首项固定为“全部”。
* 默认仅展示当前运行平台(以及已下载的记录);开启任一开关后展示所有架构。
*/ */
const platformOptions = computed(() => [
{ value: ALL_PLATFORMS, label: t("frp.allPlatforms") },
...Array.from(new Set(allReleases.value.map((item) => item.platform)))
.sort((a, b) => a.localeCompare(b))
.map((platform) => ({ value: platform, label: platform })),
])
/** 按版本降序排列;同版本按平台稳定排序,保证“全部”视图按版本分组展示。 */
function compareReleases(a: FrpRelease, b: FrpRelease) {
const aVersion = (a.version.match(/\d+/g) ?? []).slice(0, 3).map(Number)
const bVersion = (b.version.match(/\d+/g) ?? []).slice(0, 3).map(Number)
for (let index = 0; index < 3; index += 1) {
const difference = (bVersion[index] ?? 0) - (aVersion[index] ?? 0)
if (difference !== 0) return difference
}
return (
b.published_at.localeCompare(a.published_at) ||
a.platform.localeCompare(b.platform) ||
a.file_name.localeCompare(b.file_name)
)
}
const releases = computed(() => { const releases = computed(() => {
if (showAllArchFrpc.value || showAllArchFrps.value) return allReleases.value const filtered =
return allReleases.value.filter((item) => item.is_current || item.downloaded) selectedPlatform.value === ALL_PLATFORMS
? allReleases.value
: allReleases.value.filter((item) => item.platform === selectedPlatform.value)
return [...filtered].sort(compareReleases)
}) })
/** 各发行包的下载进度(以文件名为键) */ /** 各发行包的下载进度(以文件名为键) */
@@ -101,6 +128,16 @@ async function refresh(silent = false) {
if (!silent) loading.value = true if (!silent) loading.value = true
try { try {
allReleases.value = (await getFrpReleases()) ?? [] allReleases.value = (await getFrpReleases()) ?? []
if (!platformInitialized.value) {
selectedPlatform.value =
allReleases.value.find((item) => item.is_current)?.platform ?? ALL_PLATFORMS
platformInitialized.value = true
} else if (
selectedPlatform.value !== ALL_PLATFORMS &&
!allReleases.value.some((item) => item.platform === selectedPlatform.value)
) {
selectedPlatform.value = ALL_PLATFORMS
}
} catch (e) { } catch (e) {
message.error(errMsg(e)) message.error(errMsg(e))
} finally { } finally {
+1 -1
View File
@@ -7,7 +7,7 @@
<a-button :loading="loading" :icon="h(ReloadOutlined)" @click="refresh()"> <a-button :loading="loading" :icon="h(ReloadOutlined)" @click="refresh()">
{{ t("server.refresh") }} {{ t("server.refresh") }}
</a-button> </a-button>
<a-button type="primary" :icon="h(PlusOutlined)" @click="openCreate"> <a-button data-tour="server-create" type="primary" :icon="h(PlusOutlined)" @click="openCreate">
{{ t("server.add") }} {{ t("server.add") }}
</a-button> </a-button>
</a-space> </a-space>
+1 -1
View File
@@ -7,7 +7,7 @@
<a-button :loading="loading" :icon="h(ReloadOutlined)" @click="refresh()"> <a-button :loading="loading" :icon="h(ReloadOutlined)" @click="refresh()">
{{ t("tunnel.refresh") }} {{ t("tunnel.refresh") }}
</a-button> </a-button>
<a-button type="primary" :icon="h(PlusOutlined)" @click="openCreate"> <a-button data-tour="tunnel-create" type="primary" :icon="h(PlusOutlined)" @click="openCreate">
{{ t("tunnel.add") }} {{ t("tunnel.add") }}
</a-button> </a-button>
</a-space> </a-space>
+30 -14
View File
@@ -3,6 +3,33 @@
<h2 class="mb-1 text-lg font-semibold">{{ t("settings.general.title") }}</h2> <h2 class="mb-1 text-lg font-semibold">{{ t("settings.general.title") }}</h2>
<p class="mb-5 text-xs opacity-60">{{ t("settings.general.subtitle") }}</p> <p class="mb-5 text-xs opacity-60">{{ t("settings.general.subtitle") }}</p>
<!-- 重新开始引导:与引导弹窗一致,可在此再次触发 -->
<section class="flex items-center justify-between gap-4">
<div>
<div class="text-sm font-medium">{{ t('onboarding.title') }}</div>
<p class="mt-1 text-xs opacity-60">{{ t('onboarding.replayDescription') }}</p>
</div>
<a-button class="shrink-0" @click="onboarding.start">{{ t('onboarding.replay') }}</a-button>
</section>
<a-divider class="general-divider" />
<!-- 语言:与顶部栏语言切换共用同一状态 -->
<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="languageSelectOptions"
:aria-label="t('settings.general.language')"
class="w-36 shrink-0"
/>
</section>
<a-divider class="general-divider" />
<!-- 开机自启:开关直接写入系统启动项,状态由系统决定 --> <!-- 开机自启:开关直接写入系统启动项,状态由系统决定 -->
<section> <section>
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
@@ -34,19 +61,6 @@
<CardRadioGroup v-model="windowStore.closeChoice" :options="choiceOptions" /> <CardRadioGroup v-model="windowStore.closeChoice" :options="choiceOptions" />
</section> </section>
<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
v-model:value="appStore.language"
:options="languageRadios"
option-type="button"
button-style="solid"
/>
</section>
</div> </div>
</template> </template>
@@ -56,11 +70,13 @@ import { languageOptions } from "@/i18n"
import { useAppStore } from "@/stores/app" import { useAppStore } from "@/stores/app"
import { useAutostartStore } from "@/stores/autostart" import { useAutostartStore } from "@/stores/autostart"
import { useWindowStore } from "@/stores/window" import { useWindowStore } from "@/stores/window"
import { useOnboardingStore } from "@/stores/onboarding"
const { t } = useI18n() const { t } = useI18n()
const appStore = useAppStore() const appStore = useAppStore()
const autostartStore = useAutostartStore() const autostartStore = useAutostartStore()
const windowStore = useWindowStore() const windowStore = useWindowStore()
const onboarding = useOnboardingStore()
const behaviorOptions = computed(() => [ const behaviorOptions = computed(() => [
{ value: "ask" as const, label: t("closeDialog.ask"), description: t("closeDialog.askDesc") }, { value: "ask" as const, label: t("closeDialog.ask"), description: t("closeDialog.askDesc") },
@@ -77,7 +93,7 @@ const choiceOptions = computed(() => [
]) ])
/** 语言选项与顶部栏共用 languageOptions,保证两端一致 */ /** 语言选项与顶部栏共用 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(() => const autostartTip = computed(() =>