Compare commits

...
9 Commits
Author SHA1 Message Date
Yuhang Wu 74f7b7d30f feat: 新增自定义窗口背景功能 2026-10-28 11:17:28 +08:00
Yuhang Wu 135e465b16 feat: 新增系统主题跟随、窗口遮罩和运行日志持久化
- 新增系统主题模式,支持跟随操作系统明暗主题
- 添加窗口背景遮罩组件,可调节透明度改善对比度
- 实现 Frpc 运行日志按日期持久化到 logs 目录
- 优化侧边栏收起时的系统资源显示样式
- 新增中英文界面的相关翻译文本
- 实现系统资源统计的共享复用,避免重复请求
2026-10-28 10:55:01 +08:00
Yuhang Wu 538b502c52 build: 升级版本至1.4.1并更新变更日志 2026-10-26 15:25:41 +08:00
Yuhang Wu 8082c69d22 refactor(UpdateModal): 调整下载进度和操作按钮的布局位置 2026-10-26 15:15:49 +08:00
Yuhang Wu 04f6d3f851 style(aside): 优化侧边栏切换按钮的布局适配
调整侧边栏展开/折叠状态下切换按钮的对齐方式和内边距,使其与菜单项排版保持一致
2026-10-26 15:01:49 +08:00
Yuhang Wu dad406b4cd docs: 更新 CHANGELOG 至 v1.4.0 版本说明 2026-10-26 14:23:12 +08:00
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
49 changed files with 2009 additions and 246 deletions
+2
View File
@@ -15,6 +15,8 @@
</div>
运行数据按安装位置分类保存,目录规则与平台差异见 [数据目录说明](docs/数据目录说明.md)。
> 📘 **新手必读**:第一次搭建内网穿透?请阅读 **[《Frp 内网穿透搭建指南》](docs/Frp搭建内网穿透指南.md)** —— 从公网服务端搭建到用 FrpcPilot 一键开启穿透,手把手带你跑通第一个隧道。
***
+69
View File
@@ -4,6 +4,75 @@
---
## v1.5.0(2026-10-28)
### ✨ 新增功能
- **跟随系统主题**:「设置 - 外观」新增「跟随系统」,可随操作系统的明暗主题自动切换;也可继续手动选择浅色或深色主题。
- **自定义窗口背景**:可在「设置 - 外观」中选择或更换自己的背景图片,支持 PNG、JPG 和 WebP,单张图片不超过 15 MB。图片会保存到应用数据目录,重新打开软件后仍可使用。
- **窗口背景遮罩**:在窗口背景上叠加随明暗主题变化的遮罩,并可在外观设置中单独调整透明度,改善背景与顶部栏操作图标之间的对比度。
### 🎨 优化
- **主题背景**:默认背景颜色改为适配明暗主题的纯色,并调整内置背景图片与明暗主题下的整体观感。
- **折叠侧栏资源占用**:菜单栏折叠后直接显示 CPU、内存百分比和运行状态,无需悬停查看。
- **系统资源数据复用**:仪表盘与侧栏共享 CPU、内存占用数据缓存,减少进入仪表盘后等待数据加载的时间。
### 📁 数据与日志
- **Frpc 运行日志持久化**:运行日志按日期追加保存到安装目录的 `logs/frpc-YYYY-MM-DD.log`,包含 Frpc 输出及启动、停止和退出等状态记录。
### 🧱 工程
- 应用版本升级至 **v1.5.0**。
## v1.4.1(2026-10-26)
### 🎨 优化
- **侧边栏折叠操作布局**:展开时与「设置」入口左侧对齐;折叠后图标保持居中,提升导航布局的一致性。
- **更新下载进度展示**:将下载进度条移至更新弹窗底部操作区,并固定显示在操作按钮上方;更新日志较长时,滚动正文也能随时查看进度和操作按钮。
### 🧱 工程
- 应用版本升级至 **v1.4.1**。
## v1.4.0(2026-10-26)
### ⚠️ 升级提示
- **数据保存位置变更**:本版本不自动迁移旧版数据。升级后需重新配置服务器与隧道,并重新下载或导入 frpc;原系统默认数据目录中的文件保留不动,原有界面设置也不会自动继承。
- **安装位置要求**:请将软件安装在当前用户可写的专用目录。目录不可写时会提示错误并停止启动,不回退到系统默认数据目录。移动安装位置后,如已保存的 frp 路径失效,需重新下载或导入。
### ✨ 新增功能
- **新手引导**:首次启动时,通过四步引导依次介绍新增服务器、新增隧道、下载 Frp 和一键启动穿透;支持跳过,关闭后不再自动弹出,可在「设置 - 常规 - 新手引导」中重新查看。同步支持简体中文与英文。
- **侧边栏折叠**:支持在完整菜单与图标模式之间切换,并记住折叠状态,为主内容区留出更多空间;折叠后仍可导航、进入设置,并通过悬浮提示查看运行状态与 CPU、内存信息。
- **按安装目录隔离数据**:不同安装目录分别保存各自的数据库与设置,可独立运行;同一数据目录通过文件锁防止多个进程同时使用。开发构建使用独立的 `FrpcPilot-dev` 目录,避免与正式版配置混用。
### 📁 数据存储
- **分类保存运行数据**:数据库与界面设置保存在 `data`,下载、解压和导入的 frp 保存在 `frp/versions`,启动穿透时生成的 `frpc.toml` 保存在 `run`;浏览器数据、缓存和临时文件分别使用 `webview`、`cache`、`tmp` 等目录。
- **适配不同平台的安装位置**:Windows 使用可执行文件所在目录,Linux AppImage 使用实际 AppImage 文件所在目录,普通 Linux 二进制使用可执行文件所在目录;macOS 使用 `.app` 同级的 `FrpcPilot-data`,避免影响应用签名与更新。macOS 系统管理的 WebView 缓存仍可能保存在用户 Library 中。
- **设置随安装目录保存**:语言、外观、关闭行为、侧边栏状态、新手引导状态及忽略的更新版本等统一保存在 `data/preferences.json`,启动时读取,并在退出、更新或重启前完成待保存的设置写入。
### 🎨 优化
- **Frp 平台筛选**:下载页更名为「Frp下载」,用平台与架构下拉框替代原有的 Frpc / Frps 架构开关;首次加载默认选择当前平台,也可查看全部平台,刷新时尽量保留当前选择。
- **版本列表排序与展示**:按版本号数值降序排列,避免字符串排序造成新旧版本顺序错误;查看全部平台时按版本分组,下载卡片统一标明包含 `frpc` 与 `frps`。
- **本地导入更独立**:导入的 frpc / frps 会复制到安装目录内统一管理,原文件保持不变,后续使用不再依赖原始文件的位置。
- **启动加载反馈**:新增应用启动加载动画与提示,等待页面就绪并完成首帧渲染后再显示主界面,减少启动白屏与闪烁;加载动画与侧边栏过渡适配减少动态效果的系统偏好。
- **更新提示时机**:启动时的静默更新检查延后至新手引导结束或关闭后执行,避免引导与更新弹窗同时出现。
### 📝 文档
- 新增[《数据目录说明》](./数据目录说明.md),说明目录分类、各平台保存位置、写入权限、备份及安装位置变更注意事项;README 补充文档入口。
### 🧱 工程
- Tauri 最低依赖版本调整为 `2.12.3`,支持应用数据目录覆盖;新增存储目录初始化与独占锁测试。
- 应用版本升级至 **v1.4.0**。
## v1.3.2(2026-10-22)
### 🐛 修复
+183
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.1 开发服务器配置功能
@@ -180,6 +324,10 @@ Layout布局content区域的磨砂玻璃卡片圆角样式我要改动一下:
- 不需要做持久化缓存,只需要在应用运行时生效即可
- 应用运行时只有在第一次进入Frpc下载页面的时候采取请求GitHub,后面再切换回Frpc下载页面的时候,因为缓存了组件,所以不必再次获取GitHub,需要刷新的时候由用户手动点击刷新按钮
#### 3.11 自定义背景
外面的背景图片,增加一个自定义,由用户自己选择图片并设置为背景图片,自定义的图片需要保证下次打开软件仍然是这个背景,可以使用Antdv里面的上传组件,或者你有更好的方案可以替换也可以
### 4. 修改
#### 4.1 项目名称修改
@@ -189,3 +337,38 @@ 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软件目录所在位置
- 迁移脚本需要考虑不同操作系统之间的差别
- 或许可以考虑直接把之前的数据目录直接剪切到安装所在目录里面
- 数据迁移之后需要保证功能和已配置的服务器、隧道可以正常启动
#### 4.4 页面UI修改
- 左侧导航栏里面的CPU和内存占用,在菜单栏折叠起来之后,要显示;目前是鼠标放上去之后才显示,需要改成在折叠之后的菜单栏里面显示,上面显示CPU占用,下面显示内存占用。具体表现为:CPU:换行显示百分比,换行,内存:换行显示百分比,换行,状态。也可以不要CPU:和内存:,只显示百分比,字体可以小一点,但是主要的是占用百分比
- CPU和内存占用增加一个缓存,现在存在一个问题是仪表盘页面每次进入都会重新去获取CPU和内存占用,会有1-2s的等待仪表盘页面才显示CPU和内存占用,现在可以增加一个缓存,保持跟左侧菜单栏里面同步
- 外观设置里面的背景图片,默认的背景颜色,使用ui-ux-pro-max重新设计一个,不要AI常用的渐变色,同时需要主题明暗主题下的不同
- 整体窗口增加一个适应明暗主题变化的一个遮罩,大小是整个窗口的大小,因为目前存在一点问题是有些背景图片的一部分区域的颜色跟顶部栏的操作图标过于接近,导致视觉效果不佳,我想增加一个遮罩来减少一部分影响,遮罩要随明暗主题有反差,确保顶部栏图标的视觉效果,而且遮罩的透明度需要在外观设置里面可以单独调节
- 明暗主题切换增加一个跟随系统
- 运行日志持久化写入运行目录所在的logs目录里面
+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>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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>
<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>
<script type="module" src="/src/main.ts"></script>
</body>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frpcpilot",
"private": true,
"version": "1.3.2",
"version": "1.5.0",
"type": "module",
"scripts": {
"dev": "vite",
+7
View File
@@ -1843,6 +1843,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
[[package]]
name = "httparse"
version = "1.10.1"
@@ -4595,6 +4601,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"http-range",
"jni 0.21.1",
"libc",
"log",
+1 -1
View File
@@ -22,7 +22,7 @@ tauri-build = { version = "2", features = [] }
tauri-helper = "0.2.1"
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri = { version = "2.12.3", features = ["protocol-asset", "tray-icon"] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+41
View File
@@ -0,0 +1,41 @@
use tauri::{AppHandle, State};
use tauri_helper::auto_collect_command;
use tauri_plugin_dialog::DialogExt;
use crate::models::ApiResponse;
use crate::services::background::{find_custom_background, import_custom_background};
use crate::services::storage::StoragePaths;
/// 打开原生文件选择器并将图片复制到应用数据目录。
#[tauri::command]
#[auto_collect_command]
pub async fn choose_custom_background(
app: AppHandle,
paths: State<'_, StoragePaths>,
) -> Result<ApiResponse<Option<String>>, String> {
let picked = app
.dialog()
.file()
.add_filter("图片", &["png", "jpg", "jpeg", "webp"])
.set_title("选择背景图片")
.blocking_pick_file();
let Some(file) = picked else {
return Ok(ApiResponse::success(None));
};
let source = file.into_path().map_err(|e| e.to_string())?;
let saved = import_custom_background(&paths, &source)?;
Ok(ApiResponse::success(Some(
saved.to_string_lossy().into_owned(),
)))
}
/// 获取已保存的自定义背景路径,图片内容通过受限的 asset 协议读取。
#[tauri::command]
#[auto_collect_command]
pub async fn get_custom_background(
paths: State<'_, StoragePaths>,
) -> Result<ApiResponse<Option<String>>, String> {
Ok(ApiResponse::success(
find_custom_background(&paths).map(|path| path.to_string_lossy().into_owned()),
))
}
+70 -19
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
};
@@ -174,8 +194,8 @@ fn emit_progress(
/// 获取 Frp 版本列表(返回所有架构的发行包,并合并本机已下载 / 已导入记录)
///
/// 前端根据「展示所有架构的 Frpc / Frps」开关在本地过滤,避免频繁请求
/// GitHub API 触发限流;默认仅展示当前运行平台由前端完成。
/// 前端根据平台下拉列表在本地过滤,避免频繁请求 GitHub API 触发限流;
/// 默认仅展示当前运行平台由前端完成。
#[tauri::command]
#[auto_collect_command]
pub async fn get_frp_releases(
@@ -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);
+2
View File
@@ -1,5 +1,7 @@
pub mod background;
pub mod frp;
pub mod runner;
pub mod server;
pub mod storage;
pub mod system;
pub mod tunnel;
+21 -9
View File
@@ -5,6 +5,7 @@
use std::path::PathBuf;
use chrono::Local;
use sqlx::SqlitePool;
use tauri::{AppHandle, Manager, State};
use tauri_helper::auto_collect_command;
@@ -12,6 +13,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 +34,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 +74,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())),
};
@@ -151,7 +153,17 @@ pub async fn start_frpc(
// 5. 启动 frpc 进程
let config_path_str = config_path.to_string_lossy().to_string();
match runner.spawn(&app, &frpc_path, &config_path_str, &download.version) {
let paths = app.state::<StoragePaths>();
let log_path = paths
.logs()
.join(format!("frpc-{}.log", Local::now().format("%Y-%m-%d")));
match runner.spawn(
&app,
&frpc_path,
&config_path_str,
&download.version,
&log_path,
) {
Ok(status) => Ok(ApiResponse::success(status)),
Err(e) => Ok(ApiResponse::error(500, &e)),
}
+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)
}
+50 -12
View File
@@ -5,6 +5,7 @@ mod services;
use tauri::{Manager, RunEvent};
use tauri_plugin_autostart::ManagerExt as _;
use crate::commands::background::{choose_custom_background, get_custom_background};
use crate::commands::frp::{
delete_frp, download_frp, get_frp_downloads, get_frp_releases, import_frp,
};
@@ -13,25 +14,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 +61,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 +95,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 +103,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| {
+137
View File
@@ -0,0 +1,137 @@
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::services::storage::StoragePaths;
const MAX_BACKGROUND_SIZE: u64 = 15 * 1024 * 1024;
pub fn import_custom_background(paths: &StoragePaths, source: &Path) -> Result<PathBuf, String> {
let size = fs::metadata(source)
.map_err(|e| format!("读取图片信息失败:{e}"))?
.len();
if size == 0 || size > MAX_BACKGROUND_SIZE {
return Err("图片大小必须在 1 字节到 15 MB 之间".into());
}
let bytes = fs::read(source).map_err(|e| format!("读取背景图片失败:{e}"))?;
if bytes.is_empty() || bytes.len() as u64 > MAX_BACKGROUND_SIZE {
return Err("图片大小必须在 1 字节到 15 MB 之间".into());
}
let extension = detect_extension(&bytes).ok_or("仅支持 PNG、JPG 和 WebP 图片")?;
let directory = paths.data().join("backgrounds");
fs::create_dir_all(&directory).map_err(|e| format!("创建背景图片目录失败:{e}"))?;
let destination = directory.join(format!("custom.{extension}"));
let temporary = directory.join(format!("custom.{extension}.tmp"));
let mut file = OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&temporary)
.map_err(|e| format!("创建背景图片文件失败:{e}"))?;
if let Err(error) = file.write_all(&bytes).and_then(|()| file.sync_all()) {
drop(file);
let _ = fs::remove_file(&temporary);
return Err(format!("保存背景图片失败:{error}"));
}
drop(file);
fs::rename(&temporary, &destination).map_err(|e| {
let _ = fs::remove_file(&temporary);
format!("更新背景图片失败:{e}")
})?;
for stale_extension in ["png", "jpg", "webp"] {
if stale_extension != extension {
let stale = directory.join(format!("custom.{stale_extension}"));
let _ = fs::remove_file(stale);
}
}
Ok(destination)
}
pub fn find_custom_background(paths: &StoragePaths) -> Option<PathBuf> {
["png", "jpg", "webp"]
.into_iter()
.map(|extension| {
paths
.data()
.join("backgrounds")
.join(format!("custom.{extension}"))
})
.filter(|path| path.is_file())
.max_by_key(|path| {
fs::metadata(path)
.and_then(|metadata| metadata.modified())
.unwrap_or(std::time::UNIX_EPOCH)
})
}
fn detect_extension(bytes: &[u8]) -> Option<&'static str> {
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
Some("png")
} else if bytes.starts_with(b"\xff\xd8\xff") {
Some("jpg")
} else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
Some("webp")
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn imports_supported_image_and_replaces_previous_format() {
let root = std::env::temp_dir().join(format!(
"frpcpilot-background-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&root).unwrap();
let paths = StoragePaths { root: root.clone() };
let source = root.join("source-image.bin");
let png = b"\x89PNG\r\n\x1a\nimage-data";
fs::write(&source, png).unwrap();
let saved = import_custom_background(&paths, &source).unwrap();
assert_eq!(saved, paths.data().join("backgrounds/custom.png"));
assert_eq!(fs::read(&saved).unwrap(), png);
assert_eq!(find_custom_background(&paths), Some(saved.clone()));
let jpeg = b"\xff\xd8\xffimage-data";
fs::write(&source, jpeg).unwrap();
let replaced = import_custom_background(&paths, &source).unwrap();
assert_eq!(replaced, paths.data().join("backgrounds/custom.jpg"));
assert!(!saved.exists());
assert_eq!(fs::read(&replaced).unwrap(), jpeg);
assert_eq!(find_custom_background(&paths), Some(replaced));
fs::remove_dir_all(root).unwrap();
}
#[test]
fn rejects_non_image_files() {
let root = std::env::temp_dir().join(format!(
"frpcpilot-background-invalid-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&root).unwrap();
let paths = StoragePaths { root: root.clone() };
let source = root.join("not-an-image");
fs::write(&source, b"not an image").unwrap();
assert!(import_custom_background(&paths, &source).is_err());
fs::remove_dir_all(root).unwrap();
}
}
+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)
+2
View File
@@ -1,4 +1,6 @@
pub mod background;
pub mod config;
pub mod db;
pub mod runner;
pub mod storage;
pub mod system;
+161 -50
View File
@@ -6,7 +6,8 @@
//! - stdout / stderr 通过管道读取,按行解析后以 `frpc-log` 事件实时推送给前端;
//! - 后台守护线程轮询子进程状态,退出时推送 `frpc-exit` 事件。
use std::io::{BufRead, BufReader, Read};
use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Read, Write};
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
@@ -14,6 +15,7 @@ use std::sync::{Arc, Mutex, MutexGuard};
use std::thread;
use std::time::Duration;
use chrono::Local;
use tauri::{AppHandle, Emitter};
use crate::models::{FrpcExitEvent, FrpcLogEvent, FrpcStatus};
@@ -23,6 +25,8 @@ pub const LOG_EVENT: &str = "frpc-log";
/// 进程退出事件名
pub const EXIT_EVENT: &str = "frpc-exit";
type SharedLogFile = Arc<Mutex<File>>;
/// 守护线程轮询间隔
const POLL_INTERVAL: Duration = Duration::from_millis(250);
@@ -34,6 +38,7 @@ struct Running {
version: String,
frpc_path: String,
config_path: String,
log_file: SharedLogFile,
}
/// Frpc 进程管理器(应用级单例)
@@ -78,6 +83,7 @@ impl FrpcRunner {
frpc_path: &str,
config_path: &str,
version: &str,
log_path: &Path,
) -> Result<FrpcStatus, String> {
let mut guard = self.lock();
if guard.is_some() {
@@ -89,6 +95,14 @@ impl FrpcRunner {
return Err(format!("未找到 frpc 可执行文件:{frpc_path}"));
}
let log_file = Arc::new(Mutex::new(
OpenOptions::new()
.create(true)
.append(true)
.open(log_path)
.map_err(|e| format!("无法打开运行日志 {}:{e}", log_path.display()))?,
));
let mut command = Command::new(exe);
command
.arg("-c")
@@ -97,8 +111,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);
}
@@ -126,21 +140,22 @@ impl FrpcRunner {
version: version.to_string(),
frpc_path: frpc_path.to_string(),
config_path: config_path.to_string(),
log_file: log_file.clone(),
});
drop(guard);
// 采集 stdout / stderr
if let Some(stdout) = stdout {
spawn_reader(app.clone(), stdout, "stdout");
spawn_reader(app.clone(), stdout, "stdout", log_file.clone());
}
if let Some(stderr) = stderr {
spawn_reader(app.clone(), stderr, "stderr");
spawn_reader(app.clone(), stderr, "stderr", log_file.clone());
}
// 守护线程:轮询子进程是否退出
spawn_waiter(app.clone(), self.slot.clone(), token);
emit_log(
emit_persisted_log(
app,
FrpcLogEvent {
time: None,
@@ -148,6 +163,7 @@ impl FrpcRunner {
source: "system".to_string(),
message: format!("Frpc {version} 已启动(PID {pid})"),
},
Some(&log_file),
);
Ok(self.status())
@@ -160,7 +176,9 @@ impl FrpcRunner {
return Ok(FrpcStatus::default());
}
emit_log(
let log_file = guard.as_ref().map(|running| running.log_file.clone());
emit_persisted_log(
app,
FrpcLogEvent {
time: None,
@@ -168,6 +186,7 @@ impl FrpcRunner {
source: "system".to_string(),
message: "正在停止 Frpc…".to_string(),
},
log_file.as_ref(),
);
let version = {
@@ -181,7 +200,7 @@ impl FrpcRunner {
*guard = None;
drop(guard);
emit_log(
emit_persisted_log(
app,
FrpcLogEvent {
time: None,
@@ -189,6 +208,7 @@ impl FrpcRunner {
source: "system".to_string(),
message: "Frpc 已停止".to_string(),
},
log_file.as_ref(),
);
emit_exit(
app,
@@ -208,13 +228,27 @@ impl FrpcRunner {
if let Some(running) = guard.as_mut() {
let _ = running.child.kill();
let _ = running.child.wait();
persist_log(
&running.log_file,
&FrpcLogEvent {
time: None,
level: "WARN".to_string(),
source: "system".to_string(),
message: "FrpcPilot 正在退出,Frpc 已停止".to_string(),
},
);
}
*guard = None;
}
}
/// 持续读取子进程输出并推送日志事件
fn spawn_reader<R: Read + Send + 'static>(app: AppHandle, pipe: R, source: &'static str) {
fn spawn_reader<R: Read + Send + 'static>(
app: AppHandle,
pipe: R,
source: &'static str,
log_file: SharedLogFile,
) {
thread::spawn(move || {
let mut reader = BufReader::new(pipe);
let mut buf: Vec<u8> = Vec::new();
@@ -228,7 +262,7 @@ fn spawn_reader<R: Read + Send + 'static>(app: AppHandle, pipe: R, source: &'sta
if line.trim().is_empty() {
continue;
}
emit_log(&app, parse_line(source, &line));
emit_persisted_log(&app, parse_line(source, &line), Some(&log_file));
}
Err(_) => break,
}
@@ -238,48 +272,58 @@ fn spawn_reader<R: Read + Send + 'static>(app: AppHandle, pipe: R, source: &'sta
/// 守护线程:轮询子进程状态,退出时清理并推送事件
fn spawn_waiter(app: AppHandle, slot: Arc<Mutex<Option<Running>>>, token: u64) {
thread::spawn(move || loop {
thread::sleep(POLL_INTERVAL);
thread::spawn(move || {
loop {
thread::sleep(POLL_INTERVAL);
let mut guard = slot.lock().unwrap_or_else(|e| e.into_inner());
let probe = match guard.as_mut() {
Some(running) if running.token == token => {
Some((running.child.try_wait(), running.version.clone()))
}
// 已被停止 / 已被新进程替换
_ => None,
};
let mut guard = slot.lock().unwrap_or_else(|e| e.into_inner());
let probe = match guard.as_mut() {
Some(running) if running.token == token => Some((
running.child.try_wait(),
running.version.clone(),
running.log_file.clone(),
)),
// 已被停止 / 已被新进程替换
_ => None,
};
match probe {
None => return,
Some((Ok(Some(status)), version)) => {
*guard = None;
drop(guard);
let code = status.code();
emit_log(
&app,
FrpcLogEvent {
time: None,
level: if code.unwrap_or(1) == 0 { "WARN" } else { "ERROR" }.to_string(),
source: "system".to_string(),
message: match code {
Some(code) => format!("Frpc 进程已退出(退出码 {code})"),
None => "Frpc 进程已退出".to_string(),
match probe {
None => return,
Some((Ok(Some(status)), version, log_file)) => {
*guard = None;
drop(guard);
let code = status.code();
emit_persisted_log(
&app,
FrpcLogEvent {
time: None,
level: if code.unwrap_or(1) == 0 {
"WARN"
} else {
"ERROR"
}
.to_string(),
source: "system".to_string(),
message: match code {
Some(code) => format!("Frpc 进程已退出(退出码 {code})"),
None => "Frpc 进程已退出".to_string(),
},
},
},
);
emit_exit(
&app,
FrpcExitEvent {
reason: "exited".to_string(),
code,
version: Some(version),
},
);
return;
Some(&log_file),
);
emit_exit(
&app,
FrpcExitEvent {
reason: "exited".to_string(),
code,
version: Some(version),
},
);
return;
}
Some((Ok(None), _, _)) => {} // 仍在运行
Some((Err(_), _, _)) => return,
}
Some((Ok(None), _)) => {} // 仍在运行
Some((Err(_), _)) => return,
}
});
}
@@ -393,6 +437,27 @@ fn emit_log(app: &AppHandle, event: FrpcLogEvent) {
let _ = app.emit(LOG_EVENT, event);
}
fn emit_persisted_log(app: &AppHandle, event: FrpcLogEvent, log_file: Option<&SharedLogFile>) {
if let Some(log_file) = log_file {
persist_log(log_file, &event);
}
emit_log(app, event);
}
fn persist_log(log_file: &SharedLogFile, event: &FrpcLogEvent) {
let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S");
let mut file = log_file.lock().unwrap_or_else(|error| error.into_inner());
if let Err(error) = writeln!(
file,
"{timestamp} [{}] [{}] {}",
event.level, event.source, event.message
)
.and_then(|()| file.flush())
{
eprintln!("Failed to persist Frpc runtime log: {error}");
}
}
/// 发送退出事件
fn emit_exit(app: &AppHandle, event: FrpcExitEvent) {
let _ = app.emit(EXIT_EVENT, event);
@@ -404,8 +469,14 @@ mod tests {
#[test]
fn detects_log_level() {
assert_eq!(detect_level("2026/06/18 22:02:03 [I] [root.go:200] ok"), "INFO");
assert_eq!(detect_level("2026/06/18 22:02:03 [E] [root.go:200] bad"), "ERROR");
assert_eq!(
detect_level("2026/06/18 22:02:03 [I] [root.go:200] ok"),
"INFO"
);
assert_eq!(
detect_level("2026/06/18 22:02:03 [E] [root.go:200] bad"),
"ERROR"
);
assert_eq!(detect_level("[W] warn only"), "WARN");
assert_eq!(detect_level("plain message"), "INFO");
}
@@ -419,6 +490,46 @@ mod tests {
assert_eq!(extract_time("no time here"), None);
}
#[test]
fn persists_runtime_log_lines() {
let path = std::env::temp_dir().join(format!(
"frpcpilot-runtime-log-{}-{}.log",
std::process::id(),
Local::now().timestamp_millis()
));
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.unwrap();
let log_file = Arc::new(Mutex::new(file));
persist_log(
&log_file,
&FrpcLogEvent {
time: None,
level: "INFO".to_string(),
source: "system".to_string(),
message: "started".to_string(),
},
);
persist_log(
&log_file,
&FrpcLogEvent {
time: None,
level: "ERROR".to_string(),
source: "stderr".to_string(),
message: "connection failed".to_string(),
},
);
drop(log_file);
let contents = std::fs::read_to_string(&path).unwrap();
std::fs::remove_file(path).unwrap();
assert!(contents.contains("[INFO] [system] started"));
assert!(contents.contains("[ERROR] [stderr] connection failed"));
}
#[test]
fn strips_ansi_escape_codes() {
let input = "\u{1b}[1;34m2026-10-13 17:19:48.171 [I] hello\u{1b}[0m";
+156
View File
@@ -0,0 +1,156 @@
//! 按安装位置分类存储应用数据。
//! 此模块在创建任何 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 logs(&self) -> PathBuf {
self.root.join("logs")
}
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.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;
+75
View File
@@ -0,0 +1,75 @@
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.logs().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;
});
}
+5 -1
View File
@@ -22,7 +22,11 @@
}
],
"security": {
"csp": null
"csp": null,
"assetProtocol": {
"enable": true,
"scope": ["$APPDATA/backgrounds/**"]
}
}
},
"bundle": {
+20
View File
@@ -0,0 +1,20 @@
import { invoke } from "@tauri-apps/api/core"
import type { ApiResponse } from "@/types/api"
async function call<T>(command: string): Promise<T> {
const response = await invoke<ApiResponse<T>>(command)
if (!response?.success) {
throw new Error(response?.msg || "操作失败")
}
return response.data as T
}
/** 让用户选择并持久化一张自定义背景图片,取消时返回 null。 */
export function chooseCustomBackground() {
return call<string | null>("choose_custom_background")
}
/** 获取已保存的自定义背景图片路径。 */
export function getCustomBackground() {
return call<string | null>("get_custom_background")
}
+1 -1
View File
@@ -22,7 +22,7 @@ async function call<T>(command: string, args?: Record<string, unknown>): Promise
}
/**
* 获取 Frp 版本列表(返回所有架构,前端按开关过滤)
* 获取 Frp 版本列表(返回所有架构,前端按平台筛选)
*/
export function getFrpReleases() {
return call<FrpRelease[]>("get_frp_releases")
+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()
}
+27 -14
View File
@@ -1,6 +1,6 @@
@import 'tailwindcss';
/* 使用 class 方式控制暗色模式(配合 @vueuse/core 的 useDark) */
/* 使用 class 方式控制暗色模式 */
@custom-variant dark (&:where(.dark, .dark *));
html,
@@ -11,7 +11,7 @@ body,
padding: 0;
}
/* 窗口背景色与渐变(供 html / #app 复用,避免重复定义) */
/* 窗口纯色背景与磨砂透明度 */
:root {
/* 全局文字颜色:普通 HTML 文本继承使用(Antd 组件由 CSS-in-JS 自行着色,不受影响) */
--app-text-color: rgba(15, 23, 42, 0.88);
@@ -19,20 +19,13 @@ body,
--app-card-alpha: 0.45;
--app-card-alpha-hover: 0.62;
--app-modal-alpha: 0.62;
--app-bg-color: #eef2ff;
--app-bg-gradient:
radial-gradient(1200px 600px at 10% -10%, rgba(99, 102, 241, 0.28), transparent 60%),
radial-gradient(1000px 500px at 110% 10%, rgba(34, 211, 238, 0.28), transparent 55%),
linear-gradient(160deg, #eef2ff 0%, #f8fafc 45%, #eef7ff 100%);
--app-background-overlay-alpha: 0.18;
--app-bg-color: #e9edef;
}
html.dark {
--app-text-color: rgba(226, 232, 240, 0.92);
--app-bg-color: #0b1220;
--app-bg-gradient:
radial-gradient(1200px 600px at 10% -10%, rgba(99, 102, 241, 0.35), transparent 60%),
radial-gradient(1000px 500px at 110% 10%, rgba(14, 165, 233, 0.28), transparent 55%),
linear-gradient(160deg, #0b1220 0%, #0f172a 45%, #111827 100%);
--app-bg-color: #151c1d;
}
/*
@@ -43,7 +36,6 @@ html.dark {
*/
html {
background-color: var(--app-bg-color);
background-image: var(--app-bg-gradient);
background-repeat: no-repeat;
}
@@ -71,8 +63,9 @@ html.is-macos #app {
inset: 0;
border-radius: 8px;
overflow: hidden;
position: relative;
isolation: isolate;
background-color: var(--app-bg-color);
background-image: var(--app-bg-gradient);
background-repeat: no-repeat;
}
@@ -113,10 +106,30 @@ body {
/* 窗口圆角固定 8px,与 Windows 11 系统默认圆角保持一致 */
#app {
position: relative;
isolation: isolate;
border-radius: 8px;
overflow: hidden;
}
#app::before {
position: fixed;
z-index: 0;
inset: 0;
background: rgba(255, 255, 255, var(--app-background-overlay-alpha));
content: "";
pointer-events: none;
}
.dark #app::before {
background: rgba(0, 0, 0, var(--app-background-overlay-alpha));
}
#app > * {
position: relative;
z-index: 1;
}
/* 悬浮磨砂玻璃面板 */
.glass-panel {
border-radius: 16px;
+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>
+23 -16
View File
@@ -27,25 +27,26 @@
<p v-else class="m-0 opacity-80">{{ t("update.emptyLog") }}</p>
</div>
<!-- 下载 / 安装进度 -->
<div v-if="store.installing" class="update-progress">
<a-progress :percent="store.progress" :stroke-color="'#6366f1'" />
<p class="m-0 text-xs opacity-60">{{ t("update.downloading") }}</p>
</div>
<!-- 底部操作:忽略该版本 / 取消 / 确认更新 -->
<template #footer>
<div class="update-footer">
<a-button v-if="!store.installing" type="text" size="small" @click="onIgnore">
{{ t("update.ignore") }}
</a-button>
<div class="ml-auto flex items-center gap-2">
<a-button :disabled="store.installing" @click="onCancel">
{{ t("common.cancel") }}
</a-button>
<a-button type="primary" :loading="store.installing" @click="onConfirm">
{{ t("update.confirm") }}
<!-- 下载进度固定在 footer 的按钮操作上方 -->
<div v-if="store.installing" class="update-progress">
<a-progress :percent="store.progress" :stroke-color="'#6366f1'" />
<p class="m-0 text-xs opacity-60">{{ t("update.downloading") }}</p>
</div>
<div class="update-footer-actions">
<a-button v-if="!store.installing" type="text" size="small" @click="onIgnore">
{{ t("update.ignore") }}
</a-button>
<div class="ml-auto flex items-center gap-2">
<a-button :disabled="store.installing" @click="onCancel">
{{ t("common.cancel") }}
</a-button>
<a-button type="primary" :loading="store.installing" @click="onConfirm">
{{ t("update.confirm") }}
</a-button>
</div>
</div>
</div>
</template>
@@ -135,10 +136,16 @@ function onConfirm() {
}
.update-progress {
margin-top: 14px;
width: 100%;
}
.update-footer {
display: flex;
flex-direction: column;
gap: 12px;
}
.update-footer-actions {
display: flex;
align-items: center;
gap: 8px;
+1 -2
View File
@@ -11,7 +11,7 @@
<div class="mt-1 flex flex-wrap items-center gap-2">
<a-tag color="blue" class="!me-0">{{ release.platform }}</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>
@@ -93,7 +93,6 @@ import type { FrpDownloadProgress, FrpRelease } from "@/types/frp"
const props = defineProps<{
release: FrpRelease
progress?: FrpDownloadProgress | null
showFrps?: boolean
}>()
const emit = defineEmits<{
+7 -1
View File
@@ -1,5 +1,6 @@
import { getSystemInfo } from "@/api/system"
import { isTauri } from "@/utils/window"
import { createSharedComposable } from "@vueuse/core"
/**
* 系统资源占用(CPU / 内存 百分比)。
@@ -7,7 +8,7 @@ import { isTauri } from "@/utils/window"
* 调用后端命令 `get_system_info`(返回 `ApiResponse<{ cpu, memory }>`),
* 在非 Tauri 环境或后端不可用时回退到模拟数据,保证界面可用。
*/
export function useSystemStats(interval = 2000) {
const useSharedSystemStats = createSharedComposable((interval: number) => {
const cpu = ref(0)
const memory = ref(0)
const isMock = ref(true)
@@ -41,4 +42,9 @@ export function useSystemStats(interval = 2000) {
const { pause, resume } = useIntervalFn(refresh, interval, { immediate: true })
return { cpu, memory, isMock, refresh, pause, resume }
})
/** 所有页面共享同一份指标缓存与轮询,路由切换时不会重新请求。 */
export function useSystemStats(interval = 2000) {
return useSharedSystemStats(interval)
}
+2 -1
View File
@@ -9,7 +9,7 @@ import bg8 from "@/assets/images/bg-8.webp"
import bg9 from "@/assets/images/bg-9.webp"
/** 窗口背景图片标识 */
export type BackgroundKey = "none" | "bg-1" | "bg-2" | "bg-3" | "bg-4" | "bg-5" | "bg-6" | "bg-7" | "bg-8" | "bg-9"
export type BackgroundKey = "none" | "custom" | "bg-1" | "bg-2" | "bg-3" | "bg-4" | "bg-5" | "bg-6" | "bg-7" | "bg-8" | "bg-9"
export interface BackgroundOption {
/** 唯一标识 */
@@ -38,5 +38,6 @@ export const DEFAULT_BACKGROUND: BackgroundKey = "none"
/** 根据标识取图片地址 */
export function getBackgroundUrl(key: BackgroundKey): string {
if (key === "custom") return ""
return backgroundOptions.find((item) => item.key === key)?.url ?? ""
}
+33 -7
View File
@@ -25,9 +25,27 @@
"dashboard": "Dashboard",
"servers": "Servers",
"tunnels": "Tunnels",
"download": "Download frpc",
"download": "Frp Download",
"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": {
"show": "Show window",
"start": "Start tunnel",
@@ -62,7 +80,7 @@
"arch": "Architecture",
"ip": "IP address",
"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",
"stop": "Stop",
"logs": "Live logs",
@@ -103,11 +121,19 @@
"title": "Appearance",
"subtitle": "Customize the theme mode and window background",
"themeMode": "Theme mode",
"themeModeTip": "Switch between light and dark mode",
"themeModeTip": "Choose light, dark, or follow your operating system",
"light": "Light",
"dark": "Dark",
"system": "System",
"background": "Background image",
"backgroundTip": "Pick an image as the window background. It keeps its original aspect ratio and is never stretched.",
"customBackground": "Custom",
"chooseCustomBackground": "Choose image",
"changeCustomBackground": "Change custom background",
"backgroundOverlay": "Window background overlay",
"backgroundOverlayTip": "Apply a theme-aware overlay across the window to improve contrast with title bar icons.",
"overlayTransparency": "Overlay transparency",
"backgroundOverlayPreview": "Title bar icon preview",
"none": "Default",
"bg1": "Background 1",
"bg2": "Background 2",
@@ -236,8 +262,8 @@
"frp": {
"refresh": "Refresh",
"import": "Import local",
"showAllArchFrpc": "All-arch Frpc",
"showAllArchFrps": "All-arch Frps",
"platformFilter": "Platform",
"allPlatforms": "All",
"empty": "No Frp release data. Click Refresh to retry.",
"downloaded": "Downloaded",
"local": "Local",
@@ -272,8 +298,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.",
"flowTitle": "How to use",
"flow": {
"step1Title": "Download frpc",
"step1Desc": "Open \"Download frpc\", grab the build for your platform and architecture, or import a local one.",
"step1Title": "Download Frp",
"step1Desc": "Open \"Frp Download\", grab the build for your platform and architecture, or import a local one.",
"step2Title": "Configure a server",
"step2Desc": "Add the frps address, port and auth token under \"Servers\", then enable one of them.",
"step3Title": "Configure tunnels",
+33 -7
View File
@@ -25,9 +25,27 @@
"dashboard": "仪表盘",
"servers": "服务器",
"tunnels": "隧道列表",
"download": "Frpc 下载",
"download": "Frp下载",
"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": {
"show": "显示窗口",
"start": "启动穿透",
@@ -62,7 +80,7 @@
"arch": "系统架构",
"ip": "IP 地址",
"frpcVersion": "Frpc 版本",
"frpcVersionEmpty": "暂无已下载版本,请先前往 Frpc 下载页面下载",
"frpcVersionEmpty": "暂无已下载版本,请先前往 Frp下载页面下载",
"start": "一键启动",
"stop": "停止",
"logs": "实时日志",
@@ -103,11 +121,19 @@
"title": "外观设置",
"subtitle": "自定义窗口的主题模式与背景图片",
"themeMode": "主题模式",
"themeModeTip": "切换窗口的明暗显示模式",
"themeModeTip": "选择浅色、深色或跟随操作系统主题",
"light": "浅色",
"dark": "深色",
"system": "跟随系统",
"background": "背景图片",
"backgroundTip": "选择一张图片作为窗口背景,图片保持原始比例自适应,不会变形拉伸",
"customBackground": "自定义",
"chooseCustomBackground": "选择图片",
"changeCustomBackground": "更换自定义背景",
"backgroundOverlay": "窗口背景遮罩",
"backgroundOverlayTip": "为整个窗口叠加随主题变化的遮罩,改善背景与顶部栏图标的对比度",
"overlayTransparency": "遮罩透明度",
"backgroundOverlayPreview": "顶部栏图标预览",
"none": "默认",
"bg1": "背景一",
"bg2": "背景二",
@@ -236,8 +262,8 @@
"frp": {
"refresh": "刷新",
"import": "导入本地",
"showAllArchFrpc": "所有架构 Frpc",
"showAllArchFrps": "所有架构 Frps",
"platformFilter": "架构",
"allPlatforms": "全部",
"empty": "暂无 Frp 版本数据,点击刷新重试",
"downloaded": "已下载",
"local": "本地",
@@ -272,8 +298,8 @@
"intro": "FrpcPilot 是一个基于 Tauri 2 + Vue 3 + TypeScript 打造的 frp 可视化客户端,帮你用图形界面管理 frpc 版本、服务器与隧道,从此告别手写 frpc.toml。",
"flowTitle": "使用流程",
"flow": {
"step1Title": "下载 frpc",
"step1Desc": "进入「Frpc 下载」,按平台与架构下载,或导入本地已有的 frpc。",
"step1Title": "下载 Frp",
"step1Desc": "进入「Frp下载」,按平台与架构下载,或导入本地已有的 frpc。",
"step2Title": "配置服务器",
"step2Desc": "在「服务器」中填写 frps 地址、端口与鉴权 Token,并启用其中一台。",
"step3Title": "配置隧道",
+12 -3
View File
@@ -23,6 +23,7 @@
<!-- 关闭确认对话框(最小化到系统托盘 / 直接退出) -->
<CloseConfirmModal />
<OnboardingTour />
</div>
</template>
@@ -32,9 +33,12 @@ import Aside from "./aside/Aside.vue"
import UpdateModal from "@/components/UpdateModal.vue"
import CloseConfirmModal from "@/components/CloseConfirmModal.vue"
import { useUpdateStore } from "@/stores/update"
import { useOnboardingStore } from "@/stores/onboarding"
import OnboardingTour from "@/components/OnboardingTour.vue"
const router = useRouter()
const updateStore = useUpdateStore()
const onboarding = useOnboardingStore()
/**
* 需要 keep-alive 缓存的页面组件名列表(由路由 meta.isCache 决定)。
@@ -55,7 +59,12 @@ function goToSetting() {
}
// 应用启动后静默检查一次更新(已忽略的版本不会打扰用户)
onMounted(() => {
updateStore.checkForUpdate(true)
})
const stopUpdateWatch = watch(
() => onboarding.ready && onboarding.dismissed && !onboarding.active,
(canCheck) => {
if (!canCheck) return
void updateStore.checkForUpdate(true)
stopUpdateWatch()
},
)
</script>
+176 -3
View File
@@ -1,14 +1,19 @@
<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">
<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 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 -->
<div class="mb-2">
<div class="mb-1 flex items-center justify-between text-xs">
@@ -35,15 +40,54 @@
</div>
</div>
<div
v-else
class="collapsed-system-stats"
tabindex="0"
:aria-label="`${t('system.cpu')}: ${cpu}% · ${t('system.memory')}: ${memory}% · ${statusText}`"
>
<div class="collapsed-system-metric">
<span class="collapsed-system-label">CPU</span>
<span class="collapsed-system-value">{{ cpu }}%</span>
<span class="collapsed-system-track"><span :style="{ width: `${cpu}%`, backgroundColor: barColor(cpu) }" /></span>
</div>
<div class="collapsed-system-metric">
<span class="collapsed-system-label">MEM</span>
<span class="collapsed-system-value">{{ memory }}%</span>
<span class="collapsed-system-track"><span :style="{ width: `${memory}%`, backgroundColor: barColor(memory) }" /></span>
</div>
<div class="collapsed-system-status">
<span class="h-2 w-2 shrink-0 rounded-full" :class="statusDotClass" />
<span>{{ statusText }}</span>
</div>
</div>
<!-- 设置入口(位于运行状态下方) -->
<div class="system-divider mt-3 border-t pt-2">
<a-menu
class="aside-menu"
mode="inline"
:inline-collapsed="appStore.sidebarCollapsed"
:selected-keys="settingSelectedKeys"
:items="settingItems"
@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>
</aside>
@@ -56,6 +100,8 @@ import {
CloudServerOutlined,
DashboardOutlined,
DownloadOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
SettingOutlined,
} from "@ant-design/icons-vue"
import { useI18n } from "vue-i18n"
@@ -67,6 +113,7 @@ const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const collapseLabel = computed(() => t(appStore.sidebarCollapsed ? 'sidebar.expand' : 'sidebar.collapse'))
const { cpu, memory } = useSystemStats()
@@ -121,10 +168,136 @@ const statusText = computed(() => {
</script>
<style scoped lang="scss">
.app-sidebar {
width: 224px;
transition: width 200ms ease;
&.is-collapsed {
width: 72px;
}
}
.sidebar-toggle {
width: 100%;
height: 44px;
border: 0;
border-radius: 10px;
}
/* 展开时沿用菜单项的左内边距,让折叠操作与“设置”入口排成同一列。 */
.app-sidebar:not(.is-collapsed) .sidebar-toggle {
justify-content: flex-start;
gap: 10px;
padding-inline: 24px;
:deep(.anticon + span) {
margin-inline-start: 0;
}
}
/* 折叠时隐藏文字后,按钮和菜单图标都回到侧栏中心。 */
.app-sidebar.is-collapsed .sidebar-toggle {
justify-content: center;
padding-inline: 0;
}
.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 {
border-color: rgba(15, 23, 42, 0.08);
}
.collapsed-system-stats {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 2px 0 4px;
outline: none;
}
.collapsed-system-metric {
display: flex;
width: 100%;
flex-direction: column;
align-items: center;
gap: 1px;
line-height: 1.2;
}
.collapsed-system-label {
font-size: 9px;
opacity: 0.62;
}
.collapsed-system-value {
font-size: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.collapsed-system-track {
width: 38px;
height: 2px;
overflow: hidden;
border-radius: 999px;
background: rgba(100, 116, 139, 0.2);
> span {
display: block;
height: 100%;
transition: width 200ms ease;
}
}
.collapsed-system-status {
display: flex;
max-width: 100%;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 9px;
line-height: 1.2;
opacity: 0.8;
}
.dark .system-panel {
border-color: rgba(255, 255, 255, 0.08);
}
+12 -25
View File
@@ -1,28 +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 { 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)
app.mount("#app")
void start()
+69 -14
View File
@@ -1,4 +1,7 @@
import { defineStore } from "pinia"
import { convertFileSrc } from "@tauri-apps/api/core"
import { preferences } from "@/utils/preferences"
import { chooseCustomBackground as chooseCustomBackgroundFile, getCustomBackground } from "@/api/background"
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"
@@ -7,30 +10,74 @@ import {
getBackgroundUrl,
type BackgroundKey,
} from "@/config/backgrounds"
import { isTauri } from "@/utils/window"
/** Frpc 运行状态 */
export type RunStatus = "running" | "error" | "stopped"
export type ThemeMode = "light" | "dark" | "system"
/**
* 全局应用配置:语言、主题、窗口背景、Frpc 运行状态
*/
export const useAppStore = defineStore("app", () => {
// 语言(持久化到 localStorage)
const language = useStorage<Language>("frpcpilot:language", "zh-CN")
// 安装目录内的持久化设置
const language = useStorage<Language>("frpcpilot:language", "zh-CN", preferences)
// 明暗主题:useDark 会在 <html> 上切换 .dark 类名
const isDark = useDark({
storageKey: "frpcpilot:theme",
selector: "html",
attribute: "class",
valueDark: "dark",
valueLight: "",
const sidebarCollapsed = useStorage("frpcpilot:sidebar-collapsed", false, preferences)
// 兼容旧版保存的 "dark" / 空浅色值,并支持跟随操作系统主题。
const themePreference = useStorage<ThemeMode | "">("frpcpilot:theme", "system", preferences)
const preferredDark = usePreferredDark()
const themeMode = computed<ThemeMode>({
get: () => {
if (themePreference.value === "dark" || themePreference.value === "system") return themePreference.value
return "light"
},
set: (value) => { themePreference.value = value },
})
const isDark = computed({
get: () => themeMode.value === "dark" || (themeMode.value === "system" && preferredDark.value),
set: (value: boolean) => { themeMode.value = value ? "dark" : "light" },
})
function toggleDark() {
themeMode.value = isDark.value ? "light" : "dark"
}
watchEffect(() => {
document.documentElement.classList.toggle("dark", isDark.value)
})
const toggleDark = useToggle(isDark)
// 窗口背景图片(持久化)
const background = useStorage<BackgroundKey>("frpcpilot:background", DEFAULT_BACKGROUND)
const backgroundUrl = computed(() => getBackgroundUrl(background.value))
const background = useStorage<BackgroundKey>("frpcpilot:background", DEFAULT_BACKGROUND, preferences)
const customBackgroundPath = ref("")
const customBackgroundRevision = ref(0)
const customBackgroundUrl = computed(() => {
if (!customBackgroundPath.value) return ""
return `${convertFileSrc(customBackgroundPath.value)}?v=${customBackgroundRevision.value}`
})
const backgroundUrl = computed(() => background.value === "custom" ? customBackgroundUrl.value : getBackgroundUrl(background.value))
async function loadCustomBackground() {
if (!isTauri()) return
try {
customBackgroundPath.value = (await getCustomBackground()) ?? ""
customBackgroundRevision.value = Date.now()
} catch (error) {
console.error("Failed to load custom background", error)
}
}
async function chooseCustomBackground() {
if (!isTauri()) throw new Error("自定义背景图片仅在桌面应用中可用")
const path = await chooseCustomBackgroundFile()
if (!path) return false
customBackgroundPath.value = path
customBackgroundRevision.value = Date.now()
background.value = "custom"
return true
}
void loadCustomBackground()
// 把背景图同步到 <html>(整窗背景,避免元素高度误差造成的上下留白)
watchEffect(() => {
@@ -46,8 +93,9 @@ 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)
const backgroundOverlayTransparency = useStorage<number>("frpcpilot:background-overlay-transparency", 82, preferences)
/** 透明度百分比 → 背景 alpha(越大越透明,alpha 越小;并做范围兜底) */
function alphaFromTransparency(value: number) {
@@ -57,6 +105,7 @@ export const useAppStore = defineStore("app", () => {
const cardAlpha = computed(() => alphaFromTransparency(cardTransparency.value))
const modalAlpha = computed(() => alphaFromTransparency(modalTransparency.value))
const backgroundOverlayAlpha = computed(() => alphaFromTransparency(backgroundOverlayTransparency.value))
// 把透明度实时写入 <html> 的 CSS 变量,供磨砂玻璃样式(卡片 / 弹窗)即时生效
watchEffect(() => {
@@ -64,6 +113,7 @@ export const useAppStore = defineStore("app", () => {
root.style.setProperty("--app-card-alpha", String(cardAlpha.value))
root.style.setProperty("--app-card-alpha-hover", String(Math.min(cardAlpha.value + 0.15, 0.95)))
root.style.setProperty("--app-modal-alpha", String(modalAlpha.value))
root.style.setProperty("--app-background-overlay-alpha", String(backgroundOverlayAlpha.value))
})
// Frpc 运行状态(后续由后端驱动)
@@ -93,12 +143,17 @@ export const useAppStore = defineStore("app", () => {
return {
language,
sidebarCollapsed,
isDark,
themeMode,
toggleDark,
background,
backgroundUrl,
customBackgroundUrl,
chooseCustomBackground,
cardTransparency,
modalTransparency,
backgroundOverlayTransparency,
runStatus,
antdLocale,
setLanguage,
+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 { 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)
+2
View File
@@ -46,11 +46,13 @@ declare module 'vue' {
ATag: typeof import('ant-design-vue/es')['Tag']
ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
ATour: typeof import('ant-design-vue/es')['Tour']
CardRadioGroup: typeof import('./../components/CardRadioGroup.vue')['default']
CloseConfirmModal: typeof import('./../components/CloseConfirmModal.vue')['default']
FrpCard: typeof import('./../components/frp/FrpCard.vue')['default']
MacTitleBar: typeof import('./../components/MacTitleBar.vue')['default']
MarkdownBlock: typeof import('./../components/MarkdownBlock.vue')['default']
OnboardingTour: typeof import('./../components/OnboardingTour.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
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` 调试。
*/
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)
}
+1
View File
@@ -83,6 +83,7 @@
<!-- 快速操作 -->
<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"
>
<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
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"
>
<div class="flex items-center gap-2">
@@ -10,13 +11,15 @@
</div>
<div class="flex flex-wrap items-center gap-x-5 gap-y-2">
<label class="flex cursor-pointer items-center gap-2 text-xs">
<a-switch v-model:checked="showAllArchFrpc" size="small" />
<span>{{ t("frp.showAllArchFrpc") }}</span>
</label>
<label class="flex cursor-pointer items-center gap-2 text-xs">
<a-switch v-model:checked="showAllArchFrps" size="small" />
<span>{{ t("frp.showAllArchFrps") }}</span>
<label class="flex items-center gap-2 text-xs">
<span class="shrink-0 opacity-70">{{ t("frp.platformFilter") }}</span>
<a-select
v-model:value="selectedPlatform"
:aria-label="t('frp.platformFilter')"
:options="platformOptions"
class="w-44"
size="small"
/>
</label>
<a-space>
@@ -41,7 +44,6 @@
:key="`${item.version}-${item.platform}-${item.file_name}`"
:release="item"
:progress="progressMap[item.file_name]"
:show-frps="showAllArchFrps"
@download="onDownload(item)"
@copy="onCopy(item)"
@delete="onDelete(item)"
@@ -71,21 +73,46 @@ defineOptions({ name: "Download" })
const loading = ref(false)
const importing = ref(false)
/** 是否展示所有架构的 Frpc */
const showAllArchFrpc = useStorage("frpcpilot:frp:allArchFrpc", false)
/** 是否作为 frp 下载器展示所有架构的 Frps */
const showAllArchFrps = useStorage("frpcpilot:frp:allArchFrps", false)
const ALL_PLATFORMS = "all"
const selectedPlatform = ref(ALL_PLATFORMS)
const platformInitialized = ref(false)
/** 后端返回的全部发行包(含所有架构) */
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(() => {
if (showAllArchFrpc.value || showAllArchFrps.value) return allReleases.value
return allReleases.value.filter((item) => item.is_current || item.downloaded)
const filtered =
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
try {
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) {
message.error(errMsg(e))
} finally {
+1 -1
View File
@@ -7,7 +7,7 @@
<a-button :loading="loading" :icon="h(ReloadOutlined)" @click="refresh()">
{{ t("server.refresh") }}
</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") }}
</a-button>
</a-space>
+1 -1
View File
@@ -7,7 +7,7 @@
<a-button :loading="loading" :icon="h(ReloadOutlined)" @click="refresh()">
{{ t("tunnel.refresh") }}
</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") }}
</a-button>
</a-space>
+149 -16
View File
@@ -6,12 +6,28 @@
<!-- 主题模式 -->
<section class="mb-6">
<div class="mb-2 text-sm font-medium">{{ t("settings.appearance.themeMode") }}</div>
<a-segmented v-model:value="themeMode" :options="themeOptions" block />
<a-segmented v-model:value="appStore.themeMode" :options="themeOptions" block />
<p class="mt-2 text-xs opacity-60">{{ t("settings.appearance.themeModeTip") }}</p>
</section>
<a-divider />
<section class="mb-6">
<div class="mb-1 text-sm font-medium">{{ t("settings.appearance.backgroundOverlay") }}</div>
<p class="mb-3 text-xs opacity-60">{{ t("settings.appearance.backgroundOverlayTip") }}</p>
<div class="mb-1 flex items-center justify-between text-xs">
<span>{{ t("settings.appearance.overlayTransparency") }}</span>
<span class="font-medium tabular-nums">{{ appStore.backgroundOverlayTransparency }}%</span>
</div>
<a-slider v-model:value="appStore.backgroundOverlayTransparency" :min="0" :max="100" />
<div class="overlay-preview" :style="{ '--preview-overlay-alpha': `${(100 - appStore.backgroundOverlayTransparency) / 100}` }" aria-hidden="true">
<span>{{ t("settings.appearance.backgroundOverlayPreview") }}</span>
<div class="overlay-preview-toolbar"><i /><i /><i /></div>
</div>
</section>
<a-divider />
<!-- 背景图片 -->
<section>
<div class="mb-1 text-sm font-medium">{{ t("settings.appearance.background") }}</div>
@@ -31,6 +47,44 @@
<CheckCircleFilled v-if="appStore.background === bg.key" class="shrink-0 text-[#6366f1]" />
</div>
</a-card>
<a-card
hoverable
size="small"
class="bg-card overflow-hidden"
:class="{ 'bg-card--active': appStore.background === 'custom' }"
:body-style="{ padding: '8px 10px' }"
@click="selectCustomBackground"
>
<template #cover>
<div
v-if="appStore.customBackgroundUrl"
class="bg-thumb"
:style="{ backgroundImage: `url('${appStore.customBackgroundUrl}')` }"
/>
<div v-else class="bg-thumb bg-thumb--custom-empty">
<a-button type="link" :icon="h(PictureOutlined)" @click.stop="chooseCustomBackground">
{{ t("settings.appearance.chooseCustomBackground") }}
</a-button>
</div>
</template>
<div class="flex items-center justify-between gap-1 text-xs">
<span class="truncate">{{ t("settings.appearance.customBackground") }}</span>
<a-space :size="2">
<a-tooltip :title="t('settings.appearance.changeCustomBackground')">
<a-button
type="text"
size="small"
:icon="h(UploadOutlined)"
:aria-label="t('settings.appearance.changeCustomBackground')"
@click.stop="chooseCustomBackground"
/>
</a-tooltip>
<CheckCircleFilled v-if="appStore.background === 'custom'" class="shrink-0 text-[#6366f1]" />
</a-space>
</div>
</a-card>
</div>
</section>
@@ -69,7 +123,8 @@
<script setup lang="ts">
import { h } from "vue"
import { BulbFilled, BulbOutlined, CheckCircleFilled } from "@ant-design/icons-vue"
import { BulbFilled, BulbOutlined, CheckCircleFilled, DesktopOutlined, PictureOutlined, UploadOutlined } from "@ant-design/icons-vue"
import { message } from "ant-design-vue"
import { useI18n } from "vue-i18n"
import { backgroundOptions, type BackgroundKey } from "@/config/backgrounds"
import { useAppStore } from "@/stores/app"
@@ -77,14 +132,6 @@ import { useAppStore } from "@/stores/app"
const { t } = useI18n()
const appStore = useAppStore()
/** 主题模式:与 store 中的 isDark 双向绑定 */
const themeMode = computed({
get: () => (appStore.isDark ? "dark" : "light"),
set: (value: string | number) => {
appStore.isDark = value === "dark"
},
})
const themeOptions = computed(() => [
{
value: "light",
@@ -100,11 +147,34 @@ const themeOptions = computed(() => [
t("settings.appearance.dark"),
]),
},
{
value: "system",
label: h("span", { class: "inline-flex items-center gap-2" }, [
h(DesktopOutlined),
t("settings.appearance.system"),
]),
},
])
function selectBackground(key: BackgroundKey) {
appStore.setBackground(key)
}
async function selectCustomBackground() {
if (appStore.customBackgroundUrl) {
appStore.setBackground("custom")
return
}
await chooseCustomBackground()
}
async function chooseCustomBackground() {
try {
await appStore.chooseCustomBackground()
} catch (error) {
message.error(error instanceof Error ? error.message : String(error))
}
}
</script>
<style scoped lang="scss">
@@ -132,23 +202,86 @@ function selectBackground(key: BackgroundKey) {
}
.bg-thumb--none {
background-image: repeating-linear-gradient(45deg,
rgba(148, 163, 184, 0.25) 0 8px,
transparent 8px 16px);
background-color: #e9edef;
}
/* 透明度实时预览:纯色底图(暖调中性色,不用渐变)+ 卡片 / 弹窗两种磨砂效果 */
.bg-thumb--custom-empty {
display: flex;
align-items: center;
justify-content: center;
background: #dfe5e4;
}
.dark .bg-thumb--custom-empty {
background: #252f30;
}
.dark .bg-thumb--none {
background-color: #151c1d;
}
.overlay-preview {
position: relative;
display: flex;
height: 88px;
align-items: flex-end;
justify-content: space-between;
overflow: hidden;
padding: 12px;
border: 1px solid rgba(15, 23, 42, 0.12);
border-radius: 8px;
background-color: #71817b;
color: #14201d;
font-size: 11px;
}
.overlay-preview::before {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, var(--preview-overlay-alpha));
content: "";
pointer-events: none;
}
.overlay-preview > * {
position: relative;
}
.dark .overlay-preview {
border-color: rgba(255, 255, 255, 0.12);
background-color: #71817b;
color: #f1f5f3;
}
.dark .overlay-preview::before {
background: rgba(0, 0, 0, var(--preview-overlay-alpha));
}
.overlay-preview-toolbar {
display: flex;
gap: 5px;
}
.overlay-preview-toolbar i {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
opacity: 0.7;
}
/* 透明度实时预览:中性纯色底图 + 卡片 / 弹窗两种磨砂效果 */
.glass-preview {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px;
border-radius: 12px;
background-color: #a09adf;
background-color: #778880;
}
.dark .glass-preview {
background-color: #a09adf;
background-color: #778880;
}
.glass-preview-card,
+30 -14
View File
@@ -3,6 +3,33 @@
<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>
<!-- 重新开始引导:与引导弹窗一致,可在此再次触发 -->
<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>
<div class="flex items-center justify-between gap-4">
@@ -34,19 +61,6 @@
<CardRadioGroup v-model="windowStore.closeChoice" :options="choiceOptions" />
</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>
</template>
@@ -56,11 +70,13 @@ import { languageOptions } from "@/i18n"
import { useAppStore } from "@/stores/app"
import { useAutostartStore } from "@/stores/autostart"
import { useWindowStore } from "@/stores/window"
import { useOnboardingStore } from "@/stores/onboarding"
const { t } = useI18n()
const appStore = useAppStore()
const autostartStore = useAutostartStore()
const windowStore = useWindowStore()
const onboarding = useOnboardingStore()
const behaviorOptions = computed(() => [
{ value: "ask" as const, label: t("closeDialog.ask"), description: t("closeDialog.askDesc") },
@@ -77,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(() =>