Compare commits

...
34 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
Yuhang Wu ffd0ef7935 feat: 添加开机自启支持与开发版本提示 2026-10-22 16:36:11 +08:00
Yuhang Wu fcab06d28f docs: 更新 CHANGELOG 至 v1.3.0 版本内容 2026-10-20 10:29:57 +08:00
Yuhang Wu e66562d230 build: 升级版本号至1.3.0 2026-10-20 10:23:42 +08:00
Yuhang Wu 8eac325b75 feat(about): add version info and contact section to about page 2026-10-20 10:22:23 +08:00
Yuhang Wu bba146fd1a feat: 添加微信二维码及相关国际化配置
新增微信图片资源,补充联系方式相关的中英双语文案,新增复制功能的国际化文本,同步更新 README 格式优化
2026-10-20 10:22:14 +08:00
Yuhang Wu 7cbc80176d feat: 新增开机自启功能 2026-10-20 10:03:36 +08:00
Yuhang Wu 341161c20f docs: 修正 Frpc 下载图片路径的大小写 2026-10-16 16:43:06 +08:00
Yuhang Wu bf2cdddb6c docs: 添加 Frp 内网穿透搭建指南及相关截图
新增 Frp 内网穿透搭建指南文档,补充 README 相关说明,替换测试用例中的 IP 地址,新增相关界面截图。
2026-10-16 16:41:08 +08:00
Yuhang Wu 140c517191 refactor: 项目正式更名为 FrpcPilot,同步更新所有相关标识
本次修改将项目名称从 Frpilot 全面更改为 FrpcPilot,涉及:
- 应用名称、窗口标题、系统托盘提示、GitHub 仓库地址
- 包标识:npm 包名、Rust crate 名称、Tauri identifier
- 本地存储键前缀、数据库文件名、系统托盘 ID
- 文档、国际化文案、配置文件、代码注释等所有关联内容
2026-10-16 15:36:02 +08:00
Yuhang Wu 57b4d7be5a feat: 为Frpc下载页面添加运行时缓存,避免重复请求触发限流
扩展路由元信息类型以支持页面缓存开关,在Layout中实现基于路由元的keep-alive逻辑,为Download组件设置对应名称和缓存标识,仅在应用运行时缓存页面,刷新仍需手动触发更新。
2026-10-16 13:44:20 +08:00
Yuhang Wu 5c1ce2fa5c feat: 新增常规设置页面,支持关闭行为与语言切换
新增常规设置路由和页面,实现关闭行为(每次询问/保持选择)与应用语言的持久化配置;封装卡片式单选组组件复用在关闭弹窗和设置页;修改关闭逻辑,根据关闭行为决定是否显示确认弹窗,同步更新多语言文案和类型定义。
2026-10-16 11:38:00 +08:00
Yuhang Wu dbde020d7e feat: 新增磨砂玻璃透明度设置功能 2026-10-16 11:21:52 +08:00
Yuhang Wu aba606a570 feat: 新增6个背景选项并修复系统托盘图标重复问题
- 新增bg4-bg9共6个背景图片及对应配置
- 修复热更新时系统托盘图标重复叠加的问题
- 更新中英文文案及开发文档相关说明
2026-10-16 11:02:36 +08:00
Yuhang Wu 8185275937 feat: 新增系统托盘功能与关闭确认对话框
- 新增系统托盘图标及菜单,支持显示窗口、启动/停止穿透、退出应用
- 点击窗口关闭按钮或使用系统关闭快捷键时,弹出确认对话框,可选择最小化到托盘或直接退出
- 托盘菜单随语言切换同步更新,且根据穿透运行状态自动启用/禁用对应选项
- 升级应用版本至v1.1.0,添加Tauri tray-icon特性及相关权限配置
2026-10-14 14:54:07 +08:00
Yuhang Wu 827075bdef feat: 添加应用图标并实现更新提示徽标
- 新增应用图标资源
- 调整玻璃面板背景色优化视觉效果
- 重构标题栏布局,替换品牌Logo为应用图标
- 为检查更新按钮添加徽标,有更新时显示提示点
- 修改更新逻辑,保留已检测更新状态避免重复检查
- 简化模板代码格式,提升可读性
- 版本号从1.0.0更新为0.9.0
2026-10-14 14:09:41 +08:00
Yuhang Wu 4adde51bef feat: 为 macOS 适配无边框窗口圆角效果
新增 macOS 平台配置文件,修改平台检测逻辑,在 macOS 时给 html 加标记,调整 CSS 让 #app 绘制背景并裁切 8px 圆角,避免首帧方角闪烁,同时适配弹窗遮罩圆角。
2026-10-14 13:38:35 +08:00
Yuhang Wu cbd5096595 docs: 更新项目说明文档、添加界面截图与镜像加速配置
- 更新 README.md,完善功能特性、技术栈与使用说明
- 添加界面截图到 docs/snipastes 目录
- 配置 Tauri 更新镜像与 GitHub Actions 脚本
- 修正应用名称大小写与更新端点配置
2026-10-14 11:42:07 +08:00
Yuhang Wu bc4da1c7a4 feat: 新增 Markdown 渲染组件并适配 macOS 标题栏
- 新增 MarkdownBlock 组件实现更新日志的 Markdown 渲染
- 新增 markdown 工具函数和样式
- 适配 macOS 标题栏,隐藏最小化/关闭按钮并禁用最大化按钮
- 新增 1.0.0 版本更新日志文档
2026-10-14 10:40:34 +08:00
Yuhang Wu 746a4be2bc feat: 新增应用更新功能和关于页面
- 集成 Tauri updater 实现应用版本检查与更新
- 添加关于页面,展示项目介绍、使用流程和版本信息
- 新增顶部栏检查更新和 GitHub 入口
- 完善国际化文案和更新弹窗组件
2026-10-14 10:13:31 +08:00
Yuhang Wu f6d8d783b4 feat: 集成 Tauri 更新插件并配置自动发布工作流 2026-10-14 10:03:22 +08:00
Yuhang Wu cdc9507209 feat: 将卡片和弹窗改为透明磨砂玻璃效果
修改服务器、隧道、Frpc列表卡片及对应弹窗的背景为透明磨砂玻璃样式,同时调整弹窗高度限制为窗口的80%,优化滚动行为。
2026-10-14 09:42:53 +08:00
Yuhang Wu 58ac7931c8 build: 更新应用多平台图标及相关资源
新增并更新了 Tauri 项目所需的各类平台图标,包含 iOS、Android、Windows、macOS 等平台的图标文件,同时添加了 Android 自适应图标所需的背景资源配置,确保应用在各平台能正确显示图标。
2026-10-14 09:30:41 +08:00
Yuhang Wu 56d944ccc3 feat: 新增 frpc 状态与日志管理的 Pinia store
实现 frpc 状态、运行版本和实时日志的全局管理,日志在组件切换后仍保留,订阅事件在应用根组件初始化一次。同时调整仪表盘页面使用 store 中的状态,启动后禁用版本选择下拉框,解决切换页面后日志清空的问题。
2026-10-13 17:52:39 +08:00
Yuhang Wu a6276680cc feat: 新增 Frpc 一键启动与实时日志回显功能
新增一键启动/停止 Frpc 的命令及后端进程管理模块,支持生成配置文件、独立进程拉起、日志实时推送与退出事件通知。新增 Frpc 状态与日志类型定义,同步更新前端仪表盘逻辑以展示运行状态与实时日志。新增中英文启动失败提示文案,完善应用退出时自动终止 Frpc 子进程的逻辑。
2026-10-13 17:35:50 +08:00
SparksFly 85a52dfbe9 Add Apache License 2.0
Added the Apache License 2.0 to the project.
2026-10-13 16:45:45 +08:00
153 changed files with 6944 additions and 614 deletions
+181
View File
@@ -0,0 +1,181 @@
name: "Publish Release"
on:
push:
tags:
- "v*" # 推送 v1.0.0 之类的 tag 时触发
workflow_dispatch: # 也支持手动触发
permissions:
contents: write # 需要写入权限来创建 Release
env:
# 更新产物镜像前缀:latest.json 里的 GitHub 下载直链会被改写为 <前缀> + 原始直链。
# 更换镜像时,请同步修改 src-tauri/tauri.conf.json 中 updater.endpoints 的镜像地址。
MIRROR_PREFIX: "https://ghproxy.net/"
jobs:
publish-tauri:
strategy:
fail-fast: false
matrix:
include:
# macOS Apple Silicon (M1/M2/M3)
- platform: "macos-latest"
args: "--target aarch64-apple-darwin --bundles app,dmg"
# Windows x64
- platform: "windows-latest"
args: "--bundles nsis"
# Linux x64(用 22.04 以获得更宽的 glibc 兼容性)
- platform: "ubuntu-22.04"
args: "--bundles deb,appimage"
runs-on: ${{ matrix.platform }}
steps:
# 1. 检出代码
- name: Checkout
uses: actions/checkout@v5
# 2. 安装 pnpm(必须先于 setup-node 的 pnpm 缓存)
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 11
# 3. 安装 Node.js
- name: Setup Node
uses: actions/setup-node@v5
with:
node-version: 24
cache: "pnpm"
# 4. 安装 Rust
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Add macOS Apple Silicon target
if: matrix.platform == 'macos-latest'
run: rustup target add aarch64-apple-darwin
# 4.1 安装 Linux 构建所需的系统依赖(WebKitGTK / GTK / OpenSSL 等)
- name: Install Linux dependencies
if: matrix.platform == 'ubuntu-22.04'
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
build-essential \
curl \
wget \
file \
libxdo-dev \
libssl-dev \
libayatana-appindicator3-dev \
librsvg2-dev
# 5. Rust 缓存(大幅加速后续构建)
- name: Cache Rust build
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
# 6. 安装前端依赖
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
# 7. 从 docs/CHANGELOG.md 提取当前 tag 对应版本的更新日志
- name: Extract changelog for release
id: changelog
shell: bash
env:
RELEASE_TAG: ${{ github.ref_name }}
run: |
node -e '
const fs = require("fs");
const content = fs.readFileSync("docs/CHANGELOG.md", "utf8");
const lines = content.split(/\r?\n/);
const marker = "## " + process.env.RELEASE_TAG;
const startIdx = lines.findIndex((l) => l.trim().startsWith(marker));
if (startIdx === -1) {
fs.writeFileSync("release_body.md", "");
process.exit(0);
}
let endIdx = lines.length;
for (let i = startIdx + 1; i < lines.length; i++) {
if (/^##\s/.test(lines[i])) {
endIdx = i;
break;
}
}
fs.writeFileSync(
"release_body.md",
lines.slice(startIdx + 1, endIdx).join("\n").trim()
);
'
{
echo "body<<CHANGELOG_BODY_EOF"
cat release_body.md
# release_body.md is deliberately trimmed and may not end in a newline.
# Ensure the closing delimiter occupies its own line in GITHUB_OUTPUT.
printf '\nCHANGELOG_BODY_EOF\n'
} >> "$GITHUB_OUTPUT"
# 8. 构建并发布(核心步骤)
- name: Build app & publish release
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
tagName: v__VERSION__ # 自动替换为 src-tauri/tauri.conf.json(即 package.json)里的版本号
releaseName: "FrpcPilot v__VERSION__"
releaseBody: ${{ steps.changelog.outputs.body }}
releaseDraft: true # 先创建草稿,手动确认后再发布
prerelease: false
args: ${{ matrix.args }}
# 所有平台构建完成后,把 latest.json 里的下载直链改写为镜像前缀,
# 让「下载安装包」这一步也走镜像加速(此前只有检查更新走镜像)。
mirror-updater-manifest:
needs: publish-tauri
runs-on: ubuntu-latest
permissions:
contents: write
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Rewrite latest.json download URLs to mirror
run: |
set -euo pipefail
TAG="v$(node -p "require('./package.json').version")"
echo "Release tag: $TAG"
mkdir -p updater
# 从草稿 Release 中取回 tauri-action 生成的更新清单
gh release download "$TAG" --pattern latest.json --dir updater --clobber
node <<'NODE'
const fs = require("fs");
const file = "updater/latest.json";
const prefix = process.env.MIRROR_PREFIX;
const data = JSON.parse(fs.readFileSync(file, "utf8"));
let count = 0;
for (const info of Object.values(data.platforms || {})) {
if (info && typeof info.url === "string" && info.url.startsWith("https://github.com/")) {
info.url = prefix + info.url;
count += 1;
}
}
fs.writeFileSync(file, JSON.stringify(data, null, 2) + "\n");
console.log(`Rewrote ${count} download URL(s) with mirror prefix: ${prefix}`);
NODE
# 覆盖上传(签名针对安装包字节校验,与 URL 无关,改地址不影响更新校验)
gh release upload "$TAG" updater/latest.json --clobber
+1
View File
@@ -22,3 +22,4 @@ dist-ssr
*.njsproj
*.sln
*.sw?
.tauri
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+247 -131
View File
@@ -1,38 +1,155 @@
# Frpilot
<div align="center">
> 一个基于 **Tauri 2 + Vue 3 + TypeScript** 的 [frp](https://github.com/fatedier/frp) 可视化客户端,帮助用户快速配置 frpc 版本、服务器与隧道。
# FrpcPilot
Frpilot 目前处于早期开发阶段,已完成应用骨架(自定义标题栏、多语言、明暗主题、磨砂玻璃布局)。
**frp 可视化桌面客户端 —— 告别手写** **`frpc.toml`**
---
下载 frpc、配置服务器、编排隧道、一键启动并实时查看日志,全部在同一个窗口里完成。
[![Release](https://img.shields.io/github/v/release/SparksFly91/FrpcPilot?style=flat-square\&color=6366f1)](https://github.com/SparksFly91/FrpcPilot/releases)
[![License](https://img.shields.io/github/license/SparksFly91/FrpcPilot?style=flat-square\&color=6366f1)](./LICENSE)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-6366f1?style=flat-square)](#-下载安装)
[![Tauri](https://img.shields.io/badge/Tauri-2-24C8DB?style=flat-square\&logo=tauri\&logoColor=white)](https://v2.tauri.app/)
[![Vue](https://img.shields.io/badge/Vue-3-42B883?style=flat-square\&logo=vuedotjs\&logoColor=white)](https://vuejs.org/)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-6366f1?style=flat-square)](https://github.com/SparksFly91/FrpcPilot/pulls)
</div>
运行数据按安装位置分类保存,目录规则与平台差异见 [数据目录说明](docs/数据目录说明.md)。
> 📘 **新手必读**:第一次搭建内网穿透?请阅读 **[《Frp 内网穿透搭建指南》](docs/Frp搭建内网穿透指南.md)** —— 从公网服务端搭建到用 FrpcPilot 一键开启穿透,手把手带你跑通第一个隧道。
***
## 🏷️ 关于「FrpcPilot」这个名字
> **FrpcPilot = Frpc + Pilot**(领航员)
- **Frpc** —— 取自 [frp](https://github.com/fatedier/frp) 的客户端组件 `frpc`,也是本项目服务的核心对象;
- **Pilot** —— 飞行员 / 领航员。frp 本身足够强大,却总离不开手写 `frpc.toml` 与敲命令启动;FrpcPilot 想做你的「frpc 领航员」,把这些繁琐的配置与命令行统统收进图形界面,带你稳稳起飞。
一句话:**让 frpc 起飞,你只管掌舵。** ✈️
***
## 📖 项目简介
**FrpcPilot** 是一个基于 **Tauri 2 + Vue 3 + TypeScript** 的 [frp](https://github.com/fatedier/frp) 图形化客户端。它把原本需要手工编辑 `frpc.toml` 的繁琐流程,变成「点几下就能跑起来」:在线下载 frpc、管理 frps 服务器与隧道、一键启动内网穿透,并实时回显运行日志。
得益于 Tauri 的轻量内核,应用体积小、内存占用低;后端使用 Rust + SQLite 本地持久化,数据完全保存在你自己的电脑上,不经过任何第三方服务。
> 当前版本 **v1.2.1**,已支持 Windows / macOS(Apple Silicon)/ Linux 三平台,并内置应用内自动更新。
***
## 🖼️ 界面预览
| 仪表盘 | 服务器 |
| ------------------------------- | ------------------------------- |
| ![仪表盘](docs/snipastes/仪表盘.webp) | ![服务器](docs/snipastes/服务器.webp) |
| 隧道列表 | Frpc 下载 |
| --------------------------------- | -------------------------------------- |
| ![隧道列表](docs/snipastes/隧道列表.webp) | ![Frpc 下载](docs/snipastes/frpc下载.webp) |
| 设置 · 常规 | 设置 · 外观 |
| ---------------------------------- | ------------------------------- |
| ![常规设置](docs/snipastes/设置·常规.webp) | ![外观设置](docs/snipastes/设置.webp) |
<p align="center">
<img src="docs/snipastes/关于.webp" alt="关于" width="49%" />
</p>
***
## ✨ 功能特性
### 已实现
### 📊 仪表盘
- **i18n 国际化**:内置简体中文 / English,语言选择持久化,Ant Design Vue 组件库语言包联动
- **自定义标题栏**:按操作系统自动切换
- Windows:左侧 `Frpilot` 标题,右侧「语言 / 明暗 / 设置 / 最小化 / 关闭」
- macOS:左侧红绿灯(关闭 / 最小化 / 最大化),右侧同上一组按钮
- **明暗主题**:基于 `@vueuse/core` 的 `useDark`,Tailwind `dark:` 变体 + Ant Design Vue 暗色算法联动,选择持久化
- **悬浮磨砂玻璃布局**:侧边栏(Aside)与内容区(Content)均为圆角 + `backdrop-filter` 毛玻璃面板
- **侧边栏菜单**:仪表盘 / 服务器 / 隧道列表 / Frpc 下载
- **底部系统信息**:CPU 占用、内存占用(百分比 + 进度条)、运行状态指示点
- 🟢 运行正常 · 🔴 运行异常 · 🟡 未启动
- 一屏总览:服务器数量、隧道数量,以及主机名 / 系统 / 架构 / IP 地址等系统信息
- 选择 frpc 版本后「**一键启动 / 停止**」,运行状态实时联动
- **实时日志**:通过事件流回显 frpc 运行日志(保留最近 500 行,支持一键清空)
- 侧边栏常驻 **CPU / 内存占用** 与运行状态指示(🟢 运行正常 · 🔴 运行异常 · 🟡 未启动)
### 规划中
### 🖥️ 服务器管理
- frpc 版本管理与下载
- 服务器(frps)连接配置管理
- 隧道配置的增删改查与启停
- 配置文件的导入 / 导出与校验
- frps 服务器的新增 / 编辑 / 删除
- 支持服务器地址、端口、鉴权 Token、传输协议、TLS 开关与备注
- 置顶与排序;启用开关(**同一时间仅允许启用一台**服务器)
---
### 🔀 隧道管理
- 覆盖 **TCP / UDP / HTTP / HTTPS / STCP / XTCP / SUDP / TCPMUX** 等代理类型
- 本地 IP / 端口、远程端口、自定义域名、子域名、URL 前缀
- 高级配置:密钥、加密、压缩、带宽限制、Proxy Protocol、健康检查、负载均衡
- 统一管理启用 / 置顶 / 排序
### 📦 Frpc 下载管理
- 在线拉取 frp 官方版本列表,按平台与架构筛选,支持「所有架构 frpc / frps」开关
- 一键下载并自动解压,实时显示下载 / 解压进度与状态(支持镜像加速)
- 导入本地已有的 `frpc` / `frps`,删除已下载版本、复制下载直链
- **页面级缓存**:应用运行期间仅首次进入时拉取版本列表,避免重复请求触发限流,需要更新时手动点击「刷新」
### 🚀 一键启动与进程管理
- 依据所选版本、已启用的服务器与隧道,自动生成 `frpc.toml` 并拉起独立进程
- 通过 `frpc-log` / `frpc-exit` 事件实时推送日志与进程退出信息
- 应用退出时自动结束 frpc 子进程,避免残留后台进程
### 🗂️ 系统托盘
- 常驻托盘菜单:**显示窗口 / 启动穿透 / 停止穿透 / 退出**
- 左键单击直达主窗口,右键展开菜单;菜单随运行状态与界面语言自动联动
- 「退出」会先停止正在运行的穿透,再退出整个应用
### 🔔 关闭行为
- 关闭窗口时可选「**最小化到系统托盘**」或「**直接退出**」
- 支持「每次询问」与「保持以上选择」两种策略,也可在「设置 - 常规」中随时修改
### 🎨 界面与体验
- **自定义标题栏**:Windows / macOS 双套方案,窗口可拖拽移动;macOS 还原原生红绿灯
- **明暗主题**:一键切换,Tailwind 暗色变体与 Ant Design Vue 暗色算法同步
- **磨砂玻璃视觉**:侧边栏、内容区与卡片统一为圆角 + `backdrop-filter` 半透明面板,透明度可调
- **窗口背景**:内置 9 张背景图,可自由挑选,保持原始比例自适应
- **国际化**:内置简体中文 / English,语言选择持久化并与组件库语言包联动
### 🔄 应用内自动更新
- 启动时静默检查更新,支持手动检查
- 更新弹窗以 **Markdown** 渲染更新说明并展示下载进度
- 支持「忽略该版本」、一键更新,安装完成后自动重启
### ⚙️ 设置中心
- **常规**:关闭行为、应用语言
- **外观**:主题模式、背景图片、磨砂玻璃透明度(卡片 / 弹窗,实时预览)
- **关于**:项目介绍、技术栈、使用流程、开源地址与版本信息
***
## 📥 下载安装
前往 **[Releases](https://github.com/SparksFly91/FrpcPilot/releases/latest)** 下载对应平台的安装包:
| 平台 | 安装包 |
| -------------------- | -------------------- |
| Windows | `.exe`(NSIS 安装程序) |
| macOS(Apple Silicon) | `.dmg` / `.app` |
| Linux | `.deb` / `.AppImage` |
装好后打开应用即可,后续版本会在应用内自动检查并更新。
> 🧭 **首次使用?** 还不清楚怎么搭建 frps 服务端、怎么配置隧道?请参考 [《Frp 内网穿透搭建指南》](docs/Frp搭建内网穿透指南.md)。
***
## 🧱 技术栈
| 分类 | 选型 |
| ---------- | ---------------------------------------------------------------- |
| -------- | ----------------------------------------------------------------------------- |
| 桌面框架 | [Tauri 2](https://v2.tauri.app/)(Rust 后端) |
| 前端框架 | Vue 3(`<script setup>`)+ TypeScript |
| 构建工具 | Vite 8 |
@@ -41,89 +158,105 @@ Frpilot 目前处于早期开发阶段,已完成应用骨架(自定义标题
| 状态管理 | Pinia |
| 路由 | Vue Router |
| 国际化 | Vue I18n 11 |
| 工具库 | `@vueuse/core` |
| 自动化 | `unplugin-auto-import`、`unplugin-vue-components`(Antd 按需自动导入) |
| Markdown | `markdown-it`(更新日志渲染) |
| 工具库 | `@vueuse/core` · `dayjs` |
| 后端 | Rust:`sqlx` + SQLite、`reqwest`(下载)、`sysinfo`(系统采集)、`tokio` |
| Tauri 插件 | `updater` / `process` / `dialog` / `opener` / `store` / `prevent-default` |
| 工程化 | `unplugin-auto-import`、`unplugin-vue-components`(按需自动导入)、`tauri-helper`(命令注册) |
---
***
## 📦 环境要求
- **Node.js** ≥ 18(推荐 20 / 22 LTS)
- **Node.js** ≥ 20(推荐 20 / 22 LTS)
- **pnpm** ≥ 9(项目使用 pnpm,含 `pnpm-lock.yaml`)
- **Rust** 稳定版工具链(`rustup`)
- **系统依赖**:请参考 [Tauri 前置依赖](https://v2.tauri.app/start/prerequisites/)(Windows 需 WebView2 与 MSVC 构建工具;macOS 需 Xcode Command Line Tools;Linux 需 `webkit2gtk` 等)
- **Rust** 稳定版工具链(`rustup`,建议 ≥ 1.90)
- **系统依赖**:请参考 [Tauri 前置依赖](https://v2.tauri.app/start/prerequisites/)
- Windows:WebView2 + MSVC 构建工具
- macOS:Xcode Command Line Tools
- Linux:`webkit2gtk`、`libayatana-appindicator`、`librsvg` 等
---
***
## 🚀 快速开始
```bash
# 1. 安装依赖
pnpm install
# 1. 克隆仓库
git clone https://github.com/SparksFly91/FrpcPilot.git
cd FrpcPilot
# 2. 仅启动前端(浏览器调试,窗口控制类操作会安全空转)
pnpm dev
# 2. 安装依赖
pnpm install
# 3. 启动完整桌面应用(推荐,含 Rust 后端)
pnpm tauri dev
# 或者:仅启动前端(浏览器调试,窗口 / 命令类操作会安全空转)
pnpm dev
```
### 常用脚本
开机自启需要使用 `pnpm tauri build` 或 `pnpm tauri build --no-bundle` 生成的正式程序。正式程序内置前端资源,不依赖 Vite,Windows 下也不会弹出调试控制台。开发模式和 debug 构建不支持开启自启。
自启路径由插件动态获取当前运行的正式程序路径,不写死盘符、安装目录或项目构建目录。正式程序启动时,若已开启自启,会自动更新已有启动项的路径。因此重新安装或移动程序后,只需从新位置打开一次正式程序,即可校正旧路径;已关闭的自启不会被自动开启。
| 命令 | 说明 |
| ---------------- | -------------------------------------------------------- |
| ------------------ | ----------------------------------------- |
| `pnpm dev` | 启动 Vite 开发服务器(端口 `1420`) |
| `pnpm tauri dev` | 启动 Tauri 桌面开发环境(自动拉起前端) |
| `pnpm build` | 类型检查(`vue-tsc --noEmit`)+ 构建前端产物到 `dist/` |
| `pnpm preview` | 预览前端构建产物 |
| `pnpm tauri build` | 打包生成各平台安装包 |
> ⚠️ `pnpm dev` 的端口固定为 `1420` 且 `strictPort: true`,占用时会直接报错退出。
> ⚠️ `pnpm dev` 端口固定为 `1420` 且 `strictPort: true`,被占用时会直接报错退出。
---
***
## 📁 项目结构
```
Frpilot/
├── src/
│ ├── api/ # 接口封装(预留)
│ ├── assets/
│ │ └── main.css # Tailwind 入口 + 全局样式(.glass-panel / dark 变体)
FrpcPilot/
├── src/ # 前端(Vue 3 + TypeScript)
│ ├── api/ # Tauri 命令封装(frp / server / tunnel / system / updater)
│ ├── assets/ # Tailwind 入口 + 全局样式(磨砂玻璃 / Markdown / 弹窗)与背景图
│ ├── components/ # 通用组件
│ │ ├── TitleBarActions.vue # 标题栏右侧按钮组(语言/主题/设置/最小化/关闭)
│ │ ├── WindowsTitleBar.vue # Windows 自定义标题栏
│ │ └── MacTitleBar.vue # macOS 自定义标题栏(红绿灯)
│ ├── composables/
│ │ ├── usePlatform.ts # 操作系统类型判断
│ │ └── useSystemStats.ts # CPU / 内存占用采集
│ ├── i18n/
│ │ ├── index.ts # createI18n 初始化
│ │ └── locales/
│ │ ├── zh-CN.json # 中文语言包
│ │ └── en-US.json # 英文语言包
│ ├── layout/
│ │ ├── Layout.vue # 整体布局(背景 + Header + Aside + Content)
│ │ ├── aside/Aside.vue # 侧边栏(菜单 + 系统信息)
│ │ └── header/Header.vue # 顶部栏(按系统加载 TitleBar)
│ ├── router/router.ts # 路由表
│ ├── stores/app.ts # 全局状态:语言 / 主题 / 运行状态
│ ├── types/ # 自动生成的类型声明
│ ├── utils/window.ts # Tauri 窗口控制封装
│ ├── views/ # 页面(仪表盘 / 服务器 / 隧道 / 下载)
│ ├── App.vue # ConfigProvider(语言 + 主题)
│ └── main.ts # 应用入口
├── src-tauri/ # Rust 后端
│ ├── src/lib.rs # 命令注册入口
│ ├── capabilities/default.json# 权限声明(窗口控制等)
│ └── tauri.conf.json # Tauri 配置(无边框窗口 1100×720)
│ │ ├── frp / server / tunnel # 业务卡片与表单弹窗
│ │ ├── CardRadioGroup.vue # 卡片式单选组(关闭弹窗 / 设置页复用)
│ │ ├── CloseConfirmModal.vue # 关闭确认弹窗
│ │ ├── UpdateModal.vue # 更新弹窗(Markdown 更新日志 + 进度)
│ │ ├── MarkdownBlock.vue # Markdown 渲染
│ │ └── WindowsTitleBar / MacTitleBar / TitleBarActions.vue
│ ├── composables/ # usePlatform · useSystemStats · useTray
│ ├── config/backgrounds.ts # 内置窗口背景图
│ ├── i18n/ # 国际化(zh-CN / en-US)
│ ├── layout/ # 主布局 + 标题栏 + 侧边栏
│ ├── router/router.ts # 路由表(含 meta.isCache 页面缓存开关)
│ ├── stores/ # app · frpc · update · window(Pinia)
│ ├── types/ # 自动生成的类型声明与领域类型
│ ├── utils/ # window(窗口控制)· markdown(渲染)
│ └── views/ # 页面:仪表盘 / 服务器 / 隧道 / 下载 / 设置
│ └── setting/ # 常规 · 外观 · 关于
├── src-tauri/ # 后端(Rust)
│ ├── src/
│ │ ├── commands/ # Tauri 命令(frp / runner / server / system / tunnel)
│ │ ├── services/ # 数据库(SQLite) / frpc.toml 生成 / 进程管理 / 系统采集
│ │ ├── models.rs # 通用数据模型
│ │ └── lib.rs # 应用入口与命令注册
│ ├── capabilities/default.json # 权限声明(窗口 / updater / process 等)
│ ├── tauri.conf.json # Tauri 主配置(无边框窗口 1100×720)
│ └── tauri.macos.conf.json # macOS 平台覆盖配置(透明圆角窗口)
├── docs/
│ ├── CHANGELOG.md # 版本更新日志(发布流程会读取)
│ ├── Frp搭建内网穿透指南.md # 内网穿透(frps + FrpcPilot)搭建教程
│ └── snipastes/ # 界面截图
├── .github/workflows/release.yml # 多平台发布工作流
├── index.html
├── vite.config.ts # 别名 / 自动导入 / Tailwind 插件
└── package.json
```
---
***
## 🛠️ 开发指南
@@ -134,84 +267,67 @@ Frpilot/
- Vue / Vue Router / Pinia / `@vueuse/core` 的 API(如 `ref`、`computed`、`watch`、`useDark`、`useStorage`)
- Ant Design Vue 组件(模板中直接写 `<a-button>`、`<a-menu>` 等)与图标
- 类型声明自动生成在 `src/types/auto-imports.d.ts`、`src/types/components.d.ts`
> `vue-i18n` 的 `useI18n` 未加入自动导入,需手动 `import { useI18n } from 'vue-i18n'`。
- `vue-i18n` 的 `useI18n` **未**加入自动导入,需手动 `import { useI18n } from "vue-i18n"`。
### 新增 / 修改文案(i18n)
1. 到 `src/i18n/locales/zh-CN.json` 与 `en-US.json` 中按相同层级添加 key(两份文件需保持一致)。
2. 组件内使用:
```vue
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<template>
<span>{{ t('menu.dashboard') }}</span>
</template>
```
新增语言:在 `src/i18n/locales/` 添加 JSON,并在 `src/i18n/index.ts` 的 `messages` 与 `languageOptions` 中注册。
1. 在 `src/i18n/locales/zh-CN.json` 与 `en-US.json` 中按相同层级添加 key(两份文件需保持一致)。
2. 组件内使用 `const { t } = useI18n()`,模板中写 `{{ t("menu.dashboard") }}`。
3. 新增语言:在 `src/i18n/locales/` 添加 JSON,并在 `src/i18n/index.ts` 的 `messages` 与 `languageOptions` 中注册。
### 新增侧边栏菜单项与页面
1. 在 `src/views/` 新建页面组件。
2. 在 `src/router/router.ts` 的 `Layout` 子路由中注册(路径需以 `/` 开头,与菜单 `key` 一致)。
2. 在 `src/router/router.ts` 的 `Layout` 子路由中注册(路径与菜单 `key` 一致)。
3. 在 `src/layout/aside/Aside.vue` 的 `menuItems` 中追加一项(`key` = 路由路径)。
4. 在语言包中补充对应 `menu.*` 文案。
### 明暗主题
> 若希望页面在切换路由后保留状态(如避免重复请求),在该路由的 `meta` 中设置 `isCache: true`,并在页面组件上用 `defineOptions({ name })` 声明与路由 `name` 一致的组件名。
- 主题状态在 `src/stores/app.ts`(`isDark` / `toggleDark`),由 `useDark` 在 `<html>` 上切换 `.dark` 类名并持久化。
- Tailwind 使用 class 策略:在 `src/assets/main.css` 中已声明 `@custom-variant dark`。
- Ant Design Vue 主题在 `src/App.vue` 中通过 `a-config-provider` 的 `theme.algorithm` 联动。
### 主题与磨砂玻璃
### 玻璃拟态样式
`src/assets/main.css` 中的 `.glass-panel` 为通用毛玻璃面板,直接给元素加 `class="glass-panel"` 即可(已含暗色适配)。
### 接入真实的 CPU / 内存占用
`src/composables/useSystemStats.ts` 会优先调用 Tauri 命令 `get_system_info`,在命令不存在时回退到**模拟数据**。接入真实数据只需在 Rust 端实现该命令:
```rust
// src-tauri/src/lib.rs
#[tauri::command]
fn get_system_info() -> serde_json::Value {
// 返回 { "cpu": f64, "memory": f64 },单位为百分比
serde_json::json!({ "cpu": 12.0, "memory": 45.0 })
}
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![get_system_info])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
```
前端会自动切换为真实数据(`isMock` 会变为 `false`)。
### 运行状态指示点
运行状态由 `src/stores/app.ts` 的 `runStatus` 控制,取值:
```ts
type RunStatus = 'running' | 'error' | 'stopped' // 🟢 | 🔴 | 🟡
```
后续接入 frpc 进程状态时,更新该字段即可。
- 主题状态在 `src/stores/app.ts`(`isDark` / `toggleDark`),由 `useDark` 在 `<html>` 上切换 `.dark` 类名并持久化;Ant Design Vue 主题通过 `a-config-provider` 的 `theme.algorithm` 联动。
- `src/assets/main.css` 中的 `.glass-panel`(面板)与 `.glass-card`(卡片)为通用毛玻璃样式,直接加类名即可(已含暗色适配与透明度变量)。
### 窗口控制与权限
- 前端封装:`src/utils/window.ts`(`minimizeWindow` / `toggleMaximizeWindow` / `closeWindow` / `startDragging`),在非 Tauri 环境(浏览器)下自动空转。
- 自定义标题栏依赖无边框窗口(`src-tauri/tauri.conf.json` 中 `decorations: false`),拖拽区域通过 `data-tauri-drag-region` 实现。
- 相关权限在 `src-tauri/capabilities/default.json` 中声明(`core:window:allow-minimize`、`allow-close`、`allow-toggle-maximize`、`allow-start-dragging` 等)。**新增窗口 API 调用时需同步补充对应权限**,否则会报 `not allowed` 错误。
- 前端封装:`src/utils/window.ts`(`minimizeWindow` / `toggleMaximizeWindow` / `closeWindow` / `startDragging` 等),在非 Tauri 环境(浏览器)下自动空转。
- 自定义标题栏依赖无边框窗口(`decorations: false`),拖拽通过 `data-tauri-drag-region` 实现。
- **窗口圆角(8px)**:Windows 11 由系统 DWM 自动裁切;macOS 无边框窗口需在 `tauri.macos.conf.json` 中设 `transparent: true`,并在样式上由 `#app` 裁出圆角。
- ⚠️ 平台配置按 **JSON Merge Patch** 合并且**数组整体替换**,`tauri.macos.conf.json` 需完整重复 `app.windows` 字段;修改基础窗口参数时记得同步。
- 相关权限在 `src-tauri/capabilities/default.json` 中声明。**新增窗口 / 插件 API 调用时需同步补充对应权限**,否则会报 `not allowed`。
---
### 发布与自动更新
- 版本号取自 `package.json` 的 `version`(`src-tauri/tauri.conf.json` 引用之)。
- 推送 `v*` tag 会触发 `.github/workflows/release.yml`:构建 **Windows x64 / macOS Apple Silicon / Linux x64** 产物,签名生成更新产物与 `latest.json`,并自动从 `docs/CHANGELOG.md` 提取对应版本说明作为 Release 正文。
- 发布前请在 `docs/CHANGELOG.md` 中补上 `## v<版本号>` 段落。
***
## 🗺️ 路线图
- [x] 系统托盘与关闭到托盘
- [x] 开机自启
- [ ] 主题跟随系统
- [ ] frpc 配置文件的导入 / 导出与校验
- [ ] frps 服务端信息的集中管理与状态检测
- [ ] 日志持久化与导出
- [ ] 更多语言与更多内置背景
***
## 🤝 参与贡献
欢迎提交 Issue 与 Pull Request!
- 提交前请确保 `pnpm build`(类型检查 + 前端构建)通过。
- 遵循现有代码风格与目录结构;涉及新功能时请同步补充 i18n 中英文文案。
- Bug 反馈建议附上操作系统、应用版本与复现步骤。
如果 FrpcPilot 对你有帮助,欢迎点一个 ⭐ **Star** 支持一下~
***
## 📚 参考链接
@@ -223,8 +339,8 @@ type RunStatus = 'running' | 'error' | 'stopped' // 🟢 | 🔴 | 🟡
- [VueUse](https://vueuse.org/)
- [frp 官方仓库](https://github.com/fatedier/frp)
---
***
## 📄 License
License 待定(待补充)。
本项目基于 [Apache License 2.0](./LICENSE) 开源。
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 676 KiB

+231
View File
@@ -0,0 +1,231 @@
# 更新日志
本文件记录 FrpcPilot 各版本的更新内容。
---
## 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)
### 🐛 修复
- **开机自启路径动态校正**:自启项动态获取当前正式程序的 EXE 路径,不写死盘符、安装目录或项目构建目录。已开启自启时,正式程序启动后会自动更新已有启动项;重新安装或移动程序后,从新位置打开一次即可校正旧路径,已关闭的自启保持关闭。
- **阻止开发版本注册自启**:开发模式及 debug 构建不再注册自启插件,避免将依赖 Vite 本地服务的开发程序写入启动项,导致重启后主界面显示 `ERR_CONNECTION_REFUSED`,并弹出调试控制台窗口。
### 🎨 优化
- **自启设置提示**:开发版本的自启开关自动置灰,并明确提示需要在正式构建中开启;同步补充简体中文与英文文案。
### 📝 文档
- README 补充正式构建的自启使用说明,以及重新安装或移动程序后的路径校正方式。
## v1.3.0(2026-10-20)
### ✨ 新增功能
- **开机自启**:「设置 - 常规」新增「开机自启」开关,按 Tauri 官方 `autostart` 插件实现,直接读写系统启动项(Windows 注册表 Run 键 / macOS LaunchAgent / Linux XDG autostart)。
- 进入设置页时读取系统启动项的真实状态,切换后再次回读,界面状态始终与系统保持一致,不在本地另行持久化;
- 写入失败会回滚开关并提示失败原因,避免出现「界面已开启、系统未生效」的假象;
- 非桌面环境(如浏览器调试)无法访问启动项时,开关自动置灰并给出说明,而不是展示可疑状态。
- **关于页面 · 联系方式**:关于页新增「联系方式」区块,展示作者邮箱与微信二维码,方便反馈问题与交流。
- 邮箱点击可唤起系统默认邮件客户端,机器上没有可用客户端时自动退化为复制邮箱;
- 邮箱提供一键复制,复制失败会提示手动复制;
- 微信以二维码图片展示,使用 Ant Design Vue 的 `a-image` 组件,点击可放大预览,方便用手机扫码添加。
### 🎨 优化
- **关于页面信息层级调整**:把「版本信息 / 检查更新」从页面底部移到「项目介绍」之前,进入关于页即可先看到当前版本与更新入口;页面顺序调整为 版本信息 → 项目介绍 → 使用流程 → 开源地址 → 联系方式。
- **常规设置排版更紧凑**:「设置 - 常规」各组之间的间距与分隔线留白收紧,新增「开机自启」后整体仍保持整齐的纵向节奏。
- **卡片式单选组(`CardRadioGroup`)更紧凑**:缩小选项间距、内边距与圆角,统一下调标题与描述字号,关闭确认弹窗与常规设置页同步受益。
- **关闭确认弹窗更紧凑**:重新安排弹窗内边距、两组选项之间的分隔线与文案间距,减少纵向占用。
### 📝 文档
- 新增 **《Frp 内网穿透搭建指南》**:从公网服务端(frps)搭建到用 FrpcPilot 一键开启穿透,手把手跑通第一个隧道,并补充配套界面截图。
- README 新增「新手必读」与首次使用提示,引导新手先阅读上述指南;路线图中「开机自启」已标记完成。
- README 排版规范化(表格对齐、分隔线、徽章链接转义等),并补充下载安装与设置中心的说明。
- 修正《Frp 内网穿透搭建指南》中「frpc 下载」截图文件名大小写不一致的问题(Linux / macOS 等区分大小写的环境下图片会加载失败);同时把后端测试用例中的示例 IP 替换为通用地址。
### 🧱 工程
- 新增依赖:Rust 侧 `tauri-plugin-autostart`,前端侧 `@tauri-apps/plugin-autostart`;capabilities 增加 `autostart:default` 权限(可查询 / 开启 / 关闭开机自启)。
- 新增开机自启的插件封装(`src/api/autostart.ts`)与状态管理(`src/stores/autostart.ts`),并对非桌面环境做安全降级。
- 关于页引入微信二维码图片资源,`unplugin-vue-components` 自动注册 `AImage` 组件。
- 应用版本升级至 **v1.3.0**。
## v1.2.1(2026-10-16)
### 🔄 项目更名
- 项目正式更名为 **FrpcPilot**:应用名称、安装包与可执行文件名、窗口标题、系统托盘提示、GitHub 仓库地址等全部由 `Frpilot` 更新为 `FrpcPilot`。
- 构建与包标识同步调整:npm 包名改为 `frpcpilot`,Rust crate 改为 `frpcpilot` / `frpcpilot_lib`,Tauri `identifier` 改为 `FrpcPilot`。
- 本地标识同步调整:本地存储键前缀改为 `frpcpilot:`,系统托盘 id 改为 `frpcpilot-tray`,数据库文件改为 `frpcpilot.db`。
- ⚠️ 由于 Tauri `identifier` 变更,应用数据目录随之变化,升级到本版本后需要**重新配置服务器与隧道**等本地数据。
### 📝 文档
- 重写 README,面向 GitHub 重新组织:新增项目徽章、项目简介、**「FrpcPilot」名称由来的介绍**、界面预览、下载安装、功能特性、开发指南与参与贡献等章节。
- 更新界面截图,并新增「设置 · 常规」页面预览。
### 🧱 工程
- 应用版本升级至 **v1.2.1**。
## v1.2.0(2026-10-16)
### ✨ 新增功能
- **常规设置页面**:设置页新增「常规」子页面(位于设置菜单首位),集中管理基础行为:
- **关闭行为**:新增「每次询问 / 保持以上选择」单选组(默认「每次询问」)。
- *每次询问*:每次关闭都弹出对话框,由用户现场选择处理方式;
- *保持以上选择*:记住所选关闭方式并持久生效,之后关闭不再弹窗。
- **语言切换**:可直接在设置页切换界面语言,与顶部栏的语言切换保持同步。
- 关闭确认对话框同步新增「关闭行为」选项,与常规设置页内容一致,用户可随时再次修改。
- **磨砂玻璃透明度**:外观设置新增「磨砂玻璃透明度」,可分别调节**卡片透明度**与**弹窗透明度**(0 – 100,数值越大越透明),并提供实时预览,设置持久化保存。
- **更多窗口背景**:背景图片由 2 张扩充至 9 张(新增「背景三」~「背景九」),可在外观设置中自由挑选。
- **Frpc 下载页缓存**:下载页新增运行时缓存,应用运行期间**首次进入**才拉取 GitHub 版本列表,之后切换回该页面不再重复请求,避免触发接口限流;需要获取最新列表时手动点击「刷新」即可。
### 🎨 优化
- 新增卡片式单选组组件(`CardRadioGroup`),关闭弹窗与常规设置页复用,统一选项样式与交互。
- 关闭弹窗内的选项改为临时草稿状态,点击「确定」后才写回设置,避免误触单选框又取消时被改掉配置。
### 🐛 修复
- 修复开发模式下热更新 / 页面重载时系统托盘图标重复叠加的问题:创建托盘前按固定 id 清理残留图标,并在热更新时释放旧实例的监听。
- 修复暗色主题下普通(非组件库)文本仍显示为浏览器默认黑色的问题,文本颜色现随明暗主题正确切换。
### 🧱 工程
- 应用版本升级至 **v1.2.0**。
- 路由表新增 `isCache` 元信息,并补充 `RouteMeta` 类型声明(页面标题 `title` 与缓存开关 `isCache`),按需使用 `keep-alive` 缓存页面组件(仅运行时生效,不做持久化)。
- 更新开发文档相关说明。
## v1.1.0(2026-10-14)
### ✨ 新增功能
- **系统托盘**:新增常驻系统托盘图标与菜单(**显示窗口 / 启动穿透 / 停止穿透 / 退出**)。
- 左键单击托盘图标直接显示并聚焦主窗口,右键单击弹出菜单;
- 「启动穿透」「停止穿透」与仪表盘的一键启动 / 停止共用同一套逻辑,并随运行状态联动:未运行时禁用「停止穿透」;启动成功后自动禁用「启动穿透」、启用「停止穿透」,停止后恢复;
- 「退出」会在停止正在运行的穿透后再退出整个软件;
- 托盘菜单文案随语言切换同步更新。
- **关闭确认对话框**:点击标题栏关闭按钮(Windows 关闭按钮 / macOS 红绿灯)不再直接退出,改为先弹出对话框,提供两个选项:
- **最小化到系统托盘**:隐藏窗口、保持程序后台运行,可从托盘随时恢复;
- **直接退出**:停止穿透并完全退出软件;
- Windows 与 macOS 行为一致,同时拦截 `Alt+F4` 等系统原生关闭,一并走确认流程。
### 🎨 优化
- **更新提示**:顶部栏「检查更新」按钮新增「有新版本」圆点徽标;关闭更新弹窗后保留已检测到的版本,再次点击即可重新打开,避免重复联网检查。
- **标题栏**:以应用图标替换原先的文字 / 渐变方块 Logo。
- **macOS 窗口**:适配无边框窗口的圆角与透明背景,消除首帧方角闪烁;窗口失焦时红绿灯自动置灰。
### 🧱 工程
- 应用版本升级至 **v1.1.0**;`tauri` 启用 `tray-icon` 特性,capabilities 新增 `core:app:allow-default-window-icon` 权限。
- 更新端点增加 GitHub 直连作为镜像备用;修正应用名称与标识符大小写(统一为 `Frpilot`)。
- 重写 README,并补充仪表盘 / 服务器 / 隧道 / 下载 / 设置等页面的界面截图。
## v1.0.0(2026-10-14)
### 🎉 首个正式版本发布!
Frpilot 迎来 1.0.0 正式版 —— 从「手写 frpc.toml」到「点几下就能跑起来」,frp 的日常管理终于有了顺手的图形界面。
### ✨ 新增功能
- **仪表盘**:一屏总览系统信息(主机名 / 系统 / 架构 / IP)与服务器、隧道数量;选择 frpc 版本后「一键启动 / 停止」,并实时回显运行日志与进程退出信息。
- **服务器管理**:frps 服务器的新增 / 编辑 / 删除,支持地址、端口、鉴权 Token、传输协议、TLS、置顶与排序;同一时间仅允许启用一台服务器。
- **隧道管理**:覆盖 TCP / UDP / HTTP / HTTPS / STCP / XTCP / SUDP / TCPMUX 等代理类型,支持自定义域名、子域名、URL 前缀、密钥、加密与压缩、带宽限制、Proxy Protocol、健康检查、负载均衡等高级配置,并统一管理启用 / 置顶 / 排序。
- **Frpc 下载管理**:在线拉取 Frp 官方版本列表,按平台与架构筛选;支持下载(含进度与解压状态)、导入本地已下载的 frpc / frps、删除与复制下载直链。
- **一键启动与实时日志**:依据所选版本、已启用的服务器与隧道自动生成 frpc.toml 并拉起独立进程,通过 `frpc-log` / `frpc-exit` 事件实时推送日志。
- **应用内自动更新**:接入 Tauri updater 与 process 插件,启动时静默检查更新;支持手动检查、「忽略该版本」与下载进度展示,更新说明以 **Markdown** 渲染,安装完成后自动重启。
- **关于页面**:集中展示项目介绍、技术栈、使用流程、开源地址与版本信息,并提供「检查更新」入口。
- **国际化**:内置简体中文 / English,语言选择持久化,并与 Ant Design Vue 组件语言包联动。
- **明暗主题**:基于 `useDark` 一键切换,Tailwind 暗色变体与 Ant Design Vue 暗色算法同步,选择持久化。
- **自定义标题栏**:Windows 与 macOS 双套方案(macOS 还原原生红绿灯),支持拖拽移动窗口;顶部栏集成语言 / 主题 / 检查更新 / GitHub / 设置与窗口控制按钮。
### 🎨 优化
- **透明磨砂玻璃视觉**:侧边栏、内容区与卡片统一为圆角 + 背景模糊的半透明面板,明暗主题下均有细腻的层次与投影表现。
- **统一弹窗布局**:表单弹窗与更新弹窗统一为「标题 / 底部按钮固定、内容区滚动」的结构,内容过多时仅弹窗主体滚动,并限制最大高度。
- **macOS 标题栏细节**:隐藏右侧冗余的最小化 / 关闭按钮,禁用 macOS 下并不需要的最大化(绿灯)按钮。
- **下载卡片交互**:状态标签、大小 / 下载次数 / 发布日期等元信息与操作按钮重新排布,下载与解压进度直观可见。
### 🚀 工程
- 后端采用 **Rust + Tauri 2**,使用 `sqlx`(SQLite)持久化服务器、隧道与 Frpc 下载记录,并通过 `tauri-helper` 统一注册命令。
- 前端技术栈:**Vue 3 + TypeScript + Vite 8 + Pinia + Vue Router + Vue I18n 11**,UI 使用 **Ant Design Vue 4 + Tailwind CSS 4**,借助 `unplugin-auto-import` / `unplugin-vue-components` 实现按需自动导入。
- 开启 `createUpdaterArtifacts` 并结合签名密钥生成更新产物与 `latest.json`,打通应用内自动更新链路。
- 新增 GitHub Actions 发布工作流,覆盖 **Windows x64 / macOS Apple Silicon / Linux x64** 多平台构建,并自动从本文件提取对应版本说明作为 Release 正文。
- 补充多平台应用图标,项目以 **Apache License 2.0** 开源。
+374
View File
@@ -0,0 +1,374 @@
# FrpcPilot开发提示词
## 背景
> 该项目使用Tauri2 + Vue3为主要技术栈,主要解决Frpc配置繁琐的问题,方便用户快速配置frpc版本、隧道、服务器等
>
> UI库:Antdv
## 阶段提示词
### 1. Layout布局
本项目打算做一个Frpc的可视化客户端,方便用户快速配置frpc版本、隧道、服务器等,现在帮我完成以下功能:
1. i18n国际化初始化配置
- 在locales目录中写多语言翻译json文件,初步支持中文和英文
2. TitleBar页面开发
- 开发不同操作系统类型的自定义titlebar
- Windows系统的TitleBar最左侧显示FrpcPilot标题,右侧显示语言切换、明暗切换、设置、最小化、关闭,按照顺序展示;
- Mac系统的TitleBar最左侧显示红绿灯操作,右侧显示语言切换、明暗切换、设置、最小化、关闭,按照顺序展示;
3. Layout页面布局开发
- Aside侧边栏开发,在Aside.vue文件里面编写,左侧菜单初步暂定为仪表盘、服务器、隧道列表、Frpc下载
- Aside侧边栏最底部显示CPU占用(百分比)、内存占用(百分比)、运行状态(绿色点运行正常,红色点运行异常,黄色点未启动),跟上方的菜单有间隔
- Header顶部栏,根据操作系统类型引入components里面的WindowsTitleBar.vue或MacTitleBar.vue
4. Layout布局的side、content区域全部采用圆角矩形、类似于悬浮磨砂玻璃效果
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
### 2. 设置页面外观设置
#### 2.1 窗口背景设置开发
- 我新加了一个设置页面Setting.vue和一个外观设置Appearance.vue文件,帮我在路由表里面增加设置页路由,以及外观设置子路由
- 设置页面左侧为菜单栏,先增加第一项菜单-外观设置,设置项为主题模式切换(明/暗)、背景图片切换
- 背景图片使用assets/images目录中的bg-1.png、bg-2.jpg、bg-3.png作为窗口背景图片,在设置页面里面可以选择其中一个切换页面背景,注意尽可能保持原图片的比例大小不发生拉伸
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
#### 2.2 UI修改
Layout布局content区域的磨砂玻璃卡片圆角样式我要改动一下:
- 不是在整个main区域设置成glass-panel样式
- 而是在每一个页面单独设置,现在先以服务器配置页面为例,按照我图片中红色框选部分作为区域分割
- 左侧菜单栏已经是glass-panel样式,现在需要在main区域设置成圆角卡片磨砂玻璃效果
#### 2.3 弹窗修改
现在的弹窗里面内容比较多的时候会出现弹窗高度超出窗口高度,然后整个弹窗可以滚动,我想要改成弹窗高度不能超过窗口高度的80%,然后弹窗内容较多的时候,滚动仅限于弹窗里面可以进行滚动,按照我的要求修改一下
#### 2.4 弹窗、卡片效果修改
- 服务器列表卡片、新增服务器弹窗的背景效果都改成透明磨砂玻璃效果
- 隧道列表卡片、新建隧道弹窗的背景效果都改成透明磨砂玻璃效果
- Frpc下载列表卡片的背景效果都改成透明磨砂玻璃效果
#### 2.5 外观设置新增卡片透明度和弹窗透明度修改
- 在外观设置新增两个进度条,用于拖动控制卡片磨砂玻璃效果的透明度和弹窗磨砂玻璃效果的透明度
- 拖动进度条设置透明度的时候可以实时看到效果
#### 2.6 明暗主题切换,部分字体颜色没有跟随主题变化
- 切换暗色主题之后,部分字体颜色没有随主题切换
- 仪表盘页面里面,系统信息卡片、CPU和内存占用卡片、frpc版本、实时日志这些卡片里面的字体应该没有随主题变化
- 服务器页面里面,服务器三个字没有变化颜色
- 隧道列表页面里面,隧道列表三个字没有改变颜色
- Frpc卸载页面里面,Frpc下载、所有架构Frpc、所有架构Frps这些没有改变颜色
- 侧边栏的CPU/内存占用,状态文字没有改变颜色
- 设置页面里面也有一些没有随主题改变颜色
- 整体全部检查并且修复一下
#### 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 开发服务器配置功能
- Servers.vue页面右上方一个增加和刷新按钮,点击增加,弹窗填写服务器表单信息
- 服务器信息有名称、IP地址、端口号(默认7000)、备注、排序、是否置顶、是否启用等,这些是我能想到的字段,你需要按照Frpc配置的需要,决定是否增加新的字段,表单需要做必填检验
- 页面展示已经配置的服务器列表,列表服务器以卡片形式展示,卡片有开关可以直接操作是否置顶和是否启用,是否启用选项只能启用一个,另外还需要有编辑和删除,新增和编辑使用同一个表单
- 服务器信息在Rust端使用SQLite存储,Rust已经加入相关的sqlx依赖等,后端操作返回前端务必使用ApiResponse统一返回格式,前端也需要做好对应的类型匹配
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
#### 3.2 开发隧道列表功能
- Tunnels.vue页面右上方一个增加和刷新按钮,点击增加,弹窗填写隧道表单信息
- 隧道信息有名称、代理类型、本地IP、本地端口、远程端口、是否置顶、是否启用等,这些是我之前使用Frpc的时候常用的配置字段,你需要按照Frpc配置的需要,决定是否增加新的字段,表单需要做必填检验,如果你增加的是不常用的字段或者是更高级别的设置,可以用折叠面板作为高级设置,将这些更高级的字段放入
- 页面展示已经配置的隧道列表,列表隧道以卡片形式展示,卡片有开关可以直接操作是否置顶和是否启用,隧道可同时启用多个,另外还需要有编辑和删除,新增和编辑使用同一个表单
- 隧道信息在Rust端使用SQLite存储,Rust已经加入相关的sqlx依赖等,后端操作返回前端务必使用ApiResponse统一返回格式,前端也需要做好对应的类型匹配
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
#### 3.3 仪表盘页面开发
按照我给的图片布局开发仪表盘页面
- 三个卡片分别展示服务器数量、隧道数量、CPU/内存/运行状态,每个卡片都需要圆角磨砂玻璃效果
- 三个卡片下方是快速操作,可以选择已下载的Frpc版本,右侧按钮一键启动或停止,Frpc下载功能暂未实现,先用模拟数据展示
- 快速操作下方是Frpc运行实时日志展示,也先使用模拟数据展示
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
##### 3.3.1 仪表盘页面调整
- 三个卡片当中的服务器数量和隧道数量合并到一个卡片里面,这一个卡片里面上方展示服务器数量,下方展示隧道数量
- 原本的隧道数量卡片改为展示当前运行系统主机名、系统架构、IP地址等,通过Rust端sysinfo获取
- 其余卡片保持不动
#### 3.4 Frpc下载功能开发
- frp的GitHub仓库地址是https://github.com/fatedier/frp
- 初始化一张表,用来保存已下载的Frpc版本和保存路径等信息,已下载的Frpc在获取到的列表里面展示为已下载等标记
- 考虑到国内网络环境访问GitHub不稳定,所以拉取Frpc版本和下载的时候使用GitHub 镜像加速,拉取Frpc版本和下载的时候,需要根据软件运行系统架构展示对应的Frpc版本(默认情况);
- 页面上方卡片栏里面增加一个开关选项,用于开启是否展示所有架构的Frpc;再增加一个开关选项,用于开启是否展示所有架构的Frps,可以将该软件作为frp下载器使用;再有一个刷新按钮,用于刷新信息;再加一个按钮用于导入本地已下载的Frpc
- 点击卡片上的下载按钮,卡片下方显示一个下载进度的进度条
- 展示获取到的frp数据使用卡片展示,卡片上有对应按钮可以点击进行下载,卡片展示的信息包含frp版本、大小、下载次数、发布日期。在有一个按钮,点击复制下载链接,便于去浏览器等环境下载。再加一个按钮删除已下载的版本
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
#### 3.5 完成一键启动和日志实时回显功能
- 选择已下载的Frpc版本,点击一键启动,Rust端根据SQLite中保存并启用的服务器和隧道信息生成frpc.toml文件,然后启动frpc
- 将frpc所在进程与软件主进程分离,避免frpc进程异常导致软件退出
- **绕开 plugin-shell,直接用 Rust 的 `std::process::Command`**
- 将frpc的运行日志实时显示在仪表盘页面的日志区域内,并且可以清楚等
- 仪表盘页面的停止按钮也可以停止frpc服务进程
- frpc启动避免出现frpc运行的黑色窗口
##### 3.5.1 仪表盘功能优化调整
- frpc服务启动后,还可以在下拉列表修改frpc版本,启动后就不能再修改版本,而且下拉列表里面要显示正在使用的frpc版本
- 切换菜单之后再切回仪表盘页面,实时日志内容没了,需要保持实时日志内容不会随页面切换而清空
#### 3.6 自动更新功能开发
- 设置页增加一个子页面(关于),里面内容是项目介绍、使用流程、GitHub地址、检查更新相关等内容
- 顶部栏增加一个检查更新图标操作按钮,按钮样式注意风格跟顶部栏统一
- 顶部栏再增加一个GitHub图标,点击跳转浏览器打开本项目GitHub地址
- 做一个更新弹窗,弹窗标题是最新版本号,以及一些说明性文字,例如:发现新版本啦等,语气尽量俏皮可爱,二次元语气。弹窗内容是更新日志,注意弹窗高度不能超过窗口高度的60%,如果更新日志内容过多,则在弹窗主内容区域内可以上下滚动。
- 弹窗底部是忽略该版本、取消、确认更新等操作
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
#### 3.7 系统托盘功能开发
- 这是Tauri2文档里面关于系统托盘的指示:https://tauri.app/zh-cn/learn/system-tray/
- 使用文档中前端的方式开发系统托盘+托盘图标
- 托盘菜单内容暂定四项,显示窗口、启动穿透、停止穿透、退出。
- 点击显示窗口,窗口显示
- 穿透未启动时,停止穿透禁止操作,点击启动穿透,同步仪表盘页面的一键启动操作,启动成功后,启动穿透禁止操作,停止穿透允许操作
- 穿透启动时,启动穿透禁止操作,停止穿透允许操作,点击停止穿透,同步仪表盘页面的停止操作,停止后,启动穿透允许操作,暂停穿透禁止操作
- 点击退出,整个程序退出,如果穿透正在启动运行,一并停止并退出软件
- 点击Header栏里面的关闭按钮,目前是直接退出整个程序,现在改成先出现一个对话框,对话框内有两个单选项分别是最小化到系统托盘、直接退出,如果选择最小化到系统托盘,则保持程序运行状态,隐藏到系统托盘,如果是直接退出,则停止穿透并退出软件(Mac和Windows同步)
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
#### 3.8 系统托盘图标BUG修改
- 开发阶段每次修改代码热更新之后,系统托盘图标就会新增一个
- 需要修复确保只有一个系统托盘图标
#### 3.9 新增常规设置
- 设置页增加一个子页面-常规,第一项设置内容是关闭行为设置
- 目前的关闭行为弹窗分为最小化到系统托盘和直接退出两种,需要增加一个单选组,里面是每次询问和保持以上选择(默认每次询问)
- 每次询问即每次关闭都需要弹窗供用户选择,保持以上选择则是保持用户选择的关闭方式,持久生效
- 常规设置页面需要同步关闭弹窗里面的内容,使用户可以再次修改设置
- 常规设置页面再增加一个语言切换设置,保持跟Header栏同步
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
#### 3.10 Frpc下载页面增加缓存
目前每次进入Frpc下载页面都会重新去获取Frpc的下载列表,在路由表增加一个meta属性用于开关页面是否缓存isCache
- 使用keep-alive缓存页面组件,避免每次进入都会去请求GitHub,避免触发限流
- 不需要做持久化缓存,只需要在应用运行时生效即可
- 应用运行时只有在第一次进入Frpc下载页面的时候采取请求GitHub,后面再切换回Frpc下载页面的时候,因为缓存了组件,所以不必再次获取GitHub,需要刷新的时候由用户手动点击刷新按钮
#### 3.11 自定义背景
外面的背景图片,增加一个自定义,由用户自己选择图片并设置为背景图片,自定义的图片需要保证下次打开软件仍然是这个背景,可以使用Antdv里面的上传组件,或者你有更好的方案可以替换也可以
### 4. 修改
#### 4.1 项目名称修改
- 项目名称修改成了FrpcPilot,软件包构建相关的名称也改成这个
- 界面标题栏相关的也修改成FrpcPilot
- 其他与项目名称有关的也修改一下
先扫描一下有哪些需要修改的,列一个清单出来,经过我的确认之后再开始修改
#### 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目录里面
-97
View File
@@ -1,97 +0,0 @@
# Frpilot开发提示词
## 背景
> 该项目使用Tauri2 + Vue3为主要技术栈,主要解决Frpc配置繁琐的问题,方便用户快速配置frpc版本、隧道、服务器等
>
> UI库:Antdv
## 阶段提示词
### 1. Layout布局
本项目打算做一个Frpc的可视化客户端,方便用户快速配置frpc版本、隧道、服务器等,现在帮我完成以下功能:
1. i18n国际化初始化配置
- 在locales目录中写多语言翻译json文件,初步支持中文和英文
2. TitleBar页面开发
- 开发不同操作系统类型的自定义titlebar
- Windows系统的TitleBar最左侧显示Frpilot标题,右侧显示语言切换、明暗切换、设置、最小化、关闭,按照顺序展示;
- Mac系统的TitleBar最左侧显示红绿灯操作,右侧显示语言切换、明暗切换、设置、最小化、关闭,按照顺序展示;
3. Layout页面布局开发
- Aside侧边栏开发,在Aside.vue文件里面编写,左侧菜单初步暂定为仪表盘、服务器、隧道列表、Frpc下载
- Aside侧边栏最底部显示CPU占用(百分比)、内存占用(百分比)、运行状态(绿色点运行正常,红色点运行异常,黄色点未启动),跟上方的菜单有间隔
- Header顶部栏,根据操作系统类型引入components里面的WindowsTitleBar.vue或MacTitleBar.vue
4. Layout布局的side、content区域全部采用圆角矩形、类似于悬浮磨砂玻璃效果
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
### 2. 设置页面外观设置
#### 2.1 窗口背景设置开发
- 我新加了一个设置页面Setting.vue和一个外观设置Appearance.vue文件,帮我在路由表里面增加设置页路由,以及外观设置子路由
- 设置页面左侧为菜单栏,先增加第一项菜单-外观设置,设置项为主题模式切换(明/暗)、背景图片切换
- 背景图片使用assets/images目录中的bg-1.png、bg-2.jpg、bg-3.png作为窗口背景图片,在设置页面里面可以选择其中一个切换页面背景,注意尽可能保持原图片的比例大小不发生拉伸
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
#### 2.2 UI修改
Layout布局content区域的磨砂玻璃卡片圆角样式我要改动一下:
- 不是在整个main区域设置成glass-panel样式
- 而是在每一个页面单独设置,现在先以服务器配置页面为例,按照我图片中红色框选部分作为区域分割
- 左侧菜单栏已经是glass-panel样式,现在需要在main区域设置成圆角卡片磨砂玻璃效果
#### 2.3 弹窗修改
现在的弹窗里面内容比较多的时候会出现弹窗高度超出窗口高度,然后整个弹窗可以滚动,我想要改成弹窗高度不能超过窗口高度的80%,然后弹窗内容较多的时候,滚动仅限于弹窗里面可以进行滚动,按照我的要求修改一下
### 3. 功能开发
#### 3.1 开发服务器配置功能
- Servers.vue页面右上方一个增加和刷新按钮,点击增加,弹窗填写服务器表单信息
- 服务器信息有名称、IP地址、端口号(默认7000)、备注、排序、是否置顶、是否启用等,这些是我能想到的字段,你需要按照Frpc配置的需要,决定是否增加新的字段,表单需要做必填检验
- 页面展示已经配置的服务器列表,列表服务器以卡片形式展示,卡片有开关可以直接操作是否置顶和是否启用,是否启用选项只能启用一个,另外还需要有编辑和删除,新增和编辑使用同一个表单
- 服务器信息在Rust端使用SQLite存储,Rust已经加入相关的sqlx依赖等,后端操作返回前端务必使用ApiResponse统一返回格式,前端也需要做好对应的类型匹配
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
#### 3.2 开发隧道列表功能
- Tunnels.vue页面右上方一个增加和刷新按钮,点击增加,弹窗填写隧道表单信息
- 隧道信息有名称、代理类型、本地IP、本地端口、远程端口、是否置顶、是否启用等,这些是我之前使用Frpc的时候常用的配置字段,你需要按照Frpc配置的需要,决定是否增加新的字段,表单需要做必填检验,如果你增加的是不常用的字段或者是更高级别的设置,可以用折叠面板作为高级设置,将这些更高级的字段放入
- 页面展示已经配置的隧道列表,列表隧道以卡片形式展示,卡片有开关可以直接操作是否置顶和是否启用,隧道可同时启用多个,另外还需要有编辑和删除,新增和编辑使用同一个表单
- 隧道信息在Rust端使用SQLite存储,Rust已经加入相关的sqlx依赖等,后端操作返回前端务必使用ApiResponse统一返回格式,前端也需要做好对应的类型匹配
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
#### 3.3 仪表盘页面开发
按照我给的图片布局开发仪表盘页面
- 三个卡片分别展示服务器数量、隧道数量、CPU/内存/运行状态,每个卡片都需要圆角磨砂玻璃效果
- 三个卡片下方是快速操作,可以选择已下载的Frpc版本,右侧按钮一键启动或停止,Frpc下载功能暂未实现,先用模拟数据展示
- 快速操作下方是Frpc运行实时日志展示,也先使用模拟数据展示
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
##### 3.3.1 仪表盘页面调整
- 三个卡片当中的服务器数量和隧道数量合并到一个卡片里面,这一个卡片里面上方展示服务器数量,下方展示隧道数量
- 原本的隧道数量卡片改为展示当前运行系统主机名、系统架构、IP地址等,通过Rust端sysinfo获取
- 其余卡片保持不动
#### 3.4 Frpc下载功能开发
- frp的GitHub仓库地址是https://github.com/fatedier/frp
- 初始化一张表,用来保存已下载的Frpc版本和保存路径等信息,已下载的Frpc在获取到的列表里面展示为已下载等标记
- 考虑到国内网络环境访问GitHub不稳定,所以拉取Frpc版本和下载的时候使用GitHub 镜像加速,拉取Frpc版本和下载的时候,需要根据软件运行系统架构展示对应的Frpc版本(默认情况);
- 页面上方卡片栏里面增加一个开关选项,用于开启是否展示所有架构的Frpc;再增加一个开关选项,用于开启是否展示所有架构的Frps,可以将该软件作为frp下载器使用;再有一个刷新按钮,用于刷新信息;再加一个按钮用于导入本地已下载的Frpc
- 点击卡片上的下载按钮,卡片下方显示一个下载进度的进度条
- 展示获取到的frp数据使用卡片展示,卡片上有对应按钮可以点击进行下载,卡片展示的信息包含frp版本、大小、下载次数、发布日期。在有一个按钮,点击复制下载链接,便于去浏览器等环境下载。再加一个按钮删除已下载的版本
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
+371
View File
@@ -0,0 +1,371 @@
# Frp 内网穿透搭建指南
> 本文带你从 **0 到 1** 完成一次内网穿透:
> 先在公网服务器上搭好 frp 服务端(frps),再用 **[FrpcPilot](https://github.com/SparksFly91/FrpcPilot)** 图形化客户端在本机一键开启穿透,全程无需手写配置文件。
>
> 如果你还不会用命令行操作客户端,可以直接跳到「[三、使用 FrpcPilot 开启本地穿透](#三使用-frpcpilot-开启本地穿透推荐)」。
## 目录
- [一、下载 frp](#一下载-frp)
- [二、公网服务端搭建(frps)](#二公网服务端搭建frps)
- [三、使用 FrpcPilot 开启本地穿透(推荐)](#三使用-frpcpilot-开启本地穿透推荐)
- [四、附:手动配置 frpc 客户端(可选)](#四附手动配置-frpc-客户端可选)
- [五、常见问题排查](#五常见问题排查)
---
## 一、下载 frp
前往官方发布页下载**与服务器系统 / 架构匹配**的版本:
- GitHub 发布页:<https://github.com/fatedier/frp/releases>
![Frp releases页面](./snipastes/frp-releases.webp)
同一个压缩包里同时包含**客户端**与**服务端**:
| 文件 | 说明 |
| --- | --- |
| `frps` | 服务端程序(部署在**公网服务器**) |
| `frps.toml` | 服务端配置文件 |
| `frpc` | 客户端程序(运行在**内网机器**) |
| `frpc.toml` | 客户端配置文件 |
> 💡 使用 FrpcPilot 时,**服务端**仍需手动部署(见第二节),**客户端**则完全由 FrpcPilot 代劳 —— 它会自动下载 frpc、生成 `frpc.toml` 并启动进程。
---
## 二、公网服务端搭建(frps)
### 1. 准备工作
- 一台具有**公网 IP** 的服务器:阿里云、腾讯云、百度云、华为云等均可。
- 在云服务商的**安全组 / 防火墙**中放行以下端口:
- `7000`:frps 与 frpc 的通信端口(对应 `bindPort`);
- 你计划映射的**远程端口**(如 `6001`、`6002`…)。
- 将下载好的 frp 压缩包上传到服务器(推荐使用宝塔面板或 `scp`),解压到固定目录,例如 `/www/server/frp`。
### 2. 编写服务端配置 `frps.toml`
在 frps 所在目录创建 `frps.toml`:
```toml
bindPort = 7000
auth.token = "链接凭证,自己设置" # 客户端需与此处保持一致
```
> - `bindPort`:frps 监听端口,需在安全组放行。
> - `auth.token`:客户端连接令牌,务必记住,FrpcPilot 添加服务器时要填写同一个值。
### 3. 注册 systemd 服务(开机自启)
创建服务单元文件:
```shell
vim /lib/systemd/system/frps.service
```
写入以下内容(**请将 `ExecStart` 中的路径改成你自己的 frps 实际路径**):
```ini
[Unit]
Description=frps daemon
After=network.target
[Service]
Type=simple
# frps 程序所在位置,根据自己的路径修改
ExecStart=/www/server/frp/frps -c /www/server/frp/frps.toml
Restart=always
RestartSec=1min
ExecStop=/usr/bin/killall frps
[Install]
WantedBy=multi-user.target
```
> ⚠️ 注意:systemd 的配置项**不支持行内注释**,注释请单独占一行,否则 `#` 之后的内容会被当成命令的一部分。
### 4. 加载配置并启动
```shell
# 创建 / 修改 service 文件后,必须先重新加载
systemctl daemon-reload
# 设置开机自启
systemctl enable frps
# 启动服务
systemctl start frps
# 查看运行状态(看到 active (running) 即成功)
systemctl status frps
```
也可以一条命令完成「开机自启 + 立即启动」:
```shell
systemctl enable --now frps
```
### 5. 常用管理命令
| 操作 | 命令 |
| --- | --- |
| 启动 | `systemctl start frps` |
| 停止 | `systemctl stop frps` |
| 重启 | `systemctl restart frps` |
| 查看状态 | `systemctl status frps` |
| 开机自启 / 取消 | `systemctl enable frps` / `systemctl disable frps` |
| 修改配置后重载 | `systemctl daemon-reload && systemctl restart frps` |
### 6. 放行端口(重要)
除云服务商的**安全组**外,若服务器开启了系统防火墙,也需放行端口:
```shell
# firewalld(CentOS / RHEL)
firewall-cmd --permanent --add-port=7000/tcp
firewall-cmd --permanent --add-port=6001/tcp
firewall-cmd --reload
```
```shell
# ufw(Ubuntu / Debian)
ufw allow 7000/tcp
ufw allow 6001/tcp
```
到这里,服务端就搭建完成了。接下来用 **FrpcPilot** 在本机开启穿透。
---
## 三、使用 FrpcPilot 开启本地穿透(推荐)
[FrpcPilot](https://github.com/SparksFly91/FrpcPilot) 是一款 frp 图形化客户端,帮你把「下载 frpc → 写 `frpc.toml` → 敲命令启动」的流程,变成界面上的点几下即可完成。
### 1. 下载并安装 FrpcPilot
前往 [Releases](https://github.com/SparksFly91/FrpcPilot/releases/latest) 下载对应系统的安装包(Windows / macOS / Linux),安装后打开。
![FrpcPilot Releases下载页面](./snipastes/FrpcPilot-Releases.webp)
### 2. 下载 frpc 客户端
![Frpc下载](./snipastes/frpc下载.webp)
1. 打开侧边栏「**Frpc 下载**」页面;
2. 找到与**本机系统 / 架构**匹配的版本,点击「**下载**」,等待下载并自动解压完成(卡片会显示「已下载」);
3. 若本机已有 frpc,也可点击「**导入本地**」直接导入。
### 3. 添加服务器(对应 frps)
![添加服务器](./snipastes/服务器.webp)
进入「**服务器**」页面,点击右上角「**新增服务器**」,按服务端配置填写:
| 字段 | 填写内容 | 示例 |
| --- | --- | --- |
| 名称 | 自定义,仅用于展示 | 我的公网服务器 |
| 服务器地址 | frps 所在公网 **IP 或域名** | `frp.example.com` |
| 端口 | frps 的 `bindPort` | `7000` |
| 鉴权 Token | 与 `frps.toml` 中的 `auth.token` **完全一致** | `链接凭证` |
| 传输协议 | 默认 `TCP` 即可 | `TCP` |
| 启用 TLS | 按需,默认关闭 | 关闭 |
保存后,打开该服务器的「**启用**」开关。
> 📌 同一时间**只能启用一台**服务器。
### 4. 添加隧道(要穿透的本地服务)
![添加隧道](./snipastes/隧道列表.webp)
进入「**隧道列表**」页面,点击右上角「**新增隧道**」,以本地 MySQL 为例:
| 字段 | 填写内容 | 示例 |
| --- | --- | --- |
| 名称 | 自定义隧道名 | `mysql` |
| 代理类型 | 按服务类型选择 | `TCP` |
| 本地 IP | 本地服务地址 | `127.0.0.1` |
| 本地端口 | 本地服务端口 | `3306` |
| 远程端口 | 通过公网访问的端口(需在安全组放行) | `6001` |
保存后打开「**启用**」开关。
> - 可同时启用**多个**隧道(如再加一条 `ssh`:本地 `22` → 远程 `6002`)。
> - `HTTP / HTTPS` 类型支持自定义域名、子域名、URL 前缀;`STCP / XTCP / SUDP` 类型需填写密钥。
### 5. 一键启动
![仪表盘](./snipastes/仪表盘.webp)
回到「**仪表盘**」:
1. 在「Frpc 版本」下拉框中选择刚下载的版本;
2. 点击「**一键启动**」;
3. 下方「**实时日志**」出现 `login to server success`,即表示已成功连上服务端,隧道开始工作。
> FrpcPilot 会根据「已启用的服务器 + 已启用的隧道」**自动生成 `frpc.toml`** 并拉起独立进程;
> 关闭软件(或点击「停止」)时会自动结束 frpc 进程,不会留下后台残留。
### 6. 验证穿透是否生效
在外网通过 **`公网IP:远程端口`** 访问即可到达本地服务:
```text
mysql → frp.example.com:6001 → 127.0.0.1:3306
ssh → frp.example.com:6002 → 127.0.0.1:22
```
### 7. 界面字段与 frpc.toml 对照
FrpcPilot 的所有设置最终都会被转换成标准的 frpc 配置,方便你理解与排障:
| FrpcPilot | frpc.toml |
| --- | --- |
| 服务器地址 | `serverAddr` |
| 端口 | `serverPort` |
| 鉴权 Token | `auth.token` |
| 传输协议 | `transport.protocol` |
| 启用 TLS | `transport.tls.enable` |
| 隧道 · 名称 | `proxies.name` |
| 隧道 · 代理类型 | `proxies.type` |
| 隧道 · 本地 IP / 端口 | `proxies.localIP` / `proxies.localPort` |
| 隧道 · 远程端口 | `proxies.remotePort` |
| 隧道 · 自定义域名 / 子域名 / URL 前缀 | `proxies.customDomains` / `proxies.subdomain` / `proxies.locations` |
---
## 四、附:手动配置 frpc 客户端(可选)
如果你不使用 FrpcPilot,也可以手动运行 frpc。
### Windows
`frpc.toml` 示例:
```toml
serverAddr = "frps服务端所在公网ip"
serverPort = 7000 # 服务端占用的端口号
[[proxies]]
name = "mysql" # 隧道名称
type = "tcp" # 隧道类型
localIP = "127.0.0.1" # 本地 ip
localPort = 3306 # 本地端口
remotePort = 6001 # 远程端口
[[proxies]]
name = "Redis"
type = "tcp"
localIP = "127.0.0.1"
localPort = 6379
remotePort = 6002
```
启动客户端:
```shell
frpc.exe -c frpc.toml
```
也可在 `frpc.exe` 所在目录新建 `start.bat`,双击即可启动:
```bat
frpc.exe -c frpc.toml
```
### Linux(CentOS)
在 `/opt/frp` 目录放置 `frpc` 与 `frpc.toml`(linux-amd64 版本),配置示例(穿透本机 SSH):
```toml
serverAddr = "frps服务端所在公网ip"
serverPort = 7000
[[proxies]]
name = "centos7-ssh"
type = "tcp"
localIP = "127.0.0.1"
localPort = 22
remotePort = 10022
```
![](https://cdn.nlark.com/yuque/0/2024/png/40637119/1726882341045-63140844-2ea8-4cad-8c6d-8206dc9020c4.png)
注册为 systemd 服务,实现后台常驻与开机自启:
```shell
vim /lib/systemd/system/frpc.service
```
```ini
[Unit]
Description=frpc daemon
After=network.target
[Service]
Type=simple
# frpc 程序所在位置,根据自己的路径修改
ExecStart=/opt/frp/frpc -c /opt/frp/frpc.toml
Restart=always
RestartSec=1min
ExecStop=/usr/bin/killall frpc
[Install]
WantedBy=multi-user.target
```
```shell
systemctl daemon-reload
systemctl enable --now frpc
systemctl status frpc
```
![](https://cdn.nlark.com/yuque/0/2024/png/40637119/1726882549954-b9845d3a-4d4f-4591-a65b-0dc42505725d.png)
---
## 五、常见问题排查
### 连接成功但隧道启动失败:`start error: port unavailable`
```text
[client/control.go] [mysql] start error: port unavailable
```
含义:已连接到公网 frps,但**远程端口被占用**(示例中映射的是 `6001`,被其他程序占用)。
解决:换一个未被占用的**远程端口**,或在服务端释放该端口。
### 本地服务连接失败:`connect to local service ... refused`
```text
[proxy/proxy.go] [mysql] connect to local service [127.0.0.1:3306] error: ... target machine actively refused it.
```
含义:**本地服务没有启动**(或端口 / IP 写错)。
解决:确认本地服务已在 `localIP:localPort` 上正常运行。
### 查看端口占用 / 终止进程
```shell
# Linux / macOS:查看占用
lsof -i :6001
# 终止进程
kill <pid>
```
```bat
:: Windows:查看占用
netstat -ano | findstr 6001
:: 终止进程
taskkill /PID <pid> /F
```
### 完全连不上服务端
- 检查云服务商**安全组**与系统**防火墙**是否放行 `bindPort`(默认 `7000`);
- 检查「鉴权 Token」是否与 `frps.toml` 中的 `auth.token` 完全一致;
- 在服务器上确认 frps 正在运行:`systemctl status frps`。
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

+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。
+75 -2
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>Frpilot</title>
<title>FrpcPilot</title>
<!-- 首屏样式内联,脚本和样式资源尚未加载时也能显示启动状态。 -->
<style>
#startup-loader {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 20px;
background: radial-gradient(ellipse at 50% 40%, #e0e7ff, #f8fafc 70%);
color: #334155;
font: 500 15px/1.6 'Segoe UI', 'Microsoft YaHei', sans-serif;
}
#startup-loader[data-theme="dark"] {
background: radial-gradient(ellipse at 50% 40%, #1e1b4b, #0f172a 70%);
color: #e2e8f0;
}
#startup-loader svg { width: 76px; height: 76px; overflow: visible; }
#startup-loader .startup-ring {
transform-origin: 40px 40px;
animation: startup-orbit 3.6s linear infinite;
}
#startup-loader circle {
fill: #6366f1;
transform-box: fill-box;
transform-origin: center;
animation: startup-dot 1.6s ease-in-out infinite;
animation-delay: var(--delay);
}
@keyframes startup-orbit { to { transform: rotate(360deg); } }
@keyframes startup-dot {
0%, 100% { transform: scale(.55); opacity: .35; fill: #6366f1; }
50% { transform: scale(1.1); opacity: 1; fill: #06b6d4; }
}
@media (prefers-color-scheme: dark) {
#startup-loader:not([data-theme="light"]) {
background: radial-gradient(ellipse at 50% 40%, #1e1b4b, #0f172a 70%);
color: #e2e8f0;
}
}
@media (prefers-reduced-motion: reduce) {
#startup-loader .startup-ring, #startup-loader circle { animation: none; }
}
</style>
</head>
<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>
+6 -2
View File
@@ -1,7 +1,7 @@
{
"name": "frpilot",
"name": "frpcpilot",
"private": true,
"version": "0.1.0",
"version": "1.5.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -12,10 +12,14 @@
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-autostart": "^2.7.0",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.4.0",
"@tauri-apps/plugin-updater": "^2.13.3",
"@vueuse/core": "^15.0.0",
"ant-design-vue": "^4.2.6",
"dayjs": "^1.11.23",
"markdown-it": "^15.0.2",
"pinia": "^4.0.3",
"vue": "^3.5.13",
"vue-i18n": "^11.4.14",
+80
View File
@@ -14,9 +14,18 @@ importers:
'@tauri-apps/api':
specifier: ^2
version: 2.12.3
'@tauri-apps/plugin-autostart':
specifier: ^2.7.0
version: 2.7.0
'@tauri-apps/plugin-opener':
specifier: ^2
version: 2.7.1
'@tauri-apps/plugin-process':
specifier: ^2.4.0
version: 2.4.0
'@tauri-apps/plugin-updater':
specifier: ^2.13.3
version: 2.13.3
'@vueuse/core':
specifier: ^15.0.0
version: 15.0.0(vue@3.5.43(typescript@6.0.3))
@@ -26,6 +35,9 @@ importers:
dayjs:
specifier: ^1.11.23
version: 1.11.23
markdown-it:
specifier: ^15.0.2
version: 15.0.2
pinia:
specifier: ^4.0.3
version: 4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.43(typescript@6.0.3))
@@ -526,9 +538,18 @@ packages:
engines: {node: '>= 10'}
hasBin: true
'@tauri-apps/plugin-autostart@2.7.0':
resolution: {integrity: sha512-4uRq+rqKSAh6MWAxgk6FnUPigc8VMG8eDVYTLk3A7OfKs9sLVwfeM8kbUvzjpabRQvyl4zd9tfzXBTrrtuprsA==}
'@tauri-apps/plugin-opener@2.7.1':
resolution: {integrity: sha512-XkXdt30rontti9TII/gBBxFRXWfj8DiprFW7RgBFRBj1OhdfjBNVcFqSEWkkZ4hXafHnuEnB7F1sFsmu6uyywQ==}
'@tauri-apps/plugin-process@2.4.0':
resolution: {integrity: sha512-GddrmsSLAYrLa/yTXlXK/YRDzUgYeeOnUXTbiyvJWkmI424RmDPD3FVLeqcon7rv6O6ZfhBilcrHWnSo7o6hIQ==}
'@tauri-apps/plugin-updater@2.13.3':
resolution: {integrity: sha512-CtbA8CZ79WJry2HbRiFGx6WZN7uspho6A4jM+TxmuqwWL2EEfJEMw6BIYgRncIZyqp4vEXw3U/97ZjQ33dKUgg==}
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
@@ -640,6 +661,9 @@ packages:
peerDependencies:
vue: '>=3.2.0'
argparse@3.0.2:
resolution: {integrity: sha512-mFdDM6WqWKraGLsVb+C9CahPnzTXOefAOLq3jYcca2YZ8bEWpr++Tzj+zSaKW9+X9L5uSxcm1AZ3Y6aZJ09OhQ==}
array-tree-filter@2.1.0:
resolution: {integrity: sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==}
@@ -701,6 +725,10 @@ packages:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
entities@8.1.0:
resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==}
engines: {node: '>=20.19.0'}
escape-string-regexp@5.0.0:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
@@ -907,6 +935,9 @@ packages:
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
engines: {node: '>= 12.0.0'}
linkify-it@6.1.0:
resolution: {integrity: sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==}
local-pkg@1.2.1:
resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==}
engines: {node: '>=14'}
@@ -931,6 +962,13 @@ packages:
magic-string@1.4.3:
resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==}
markdown-it@15.0.2:
resolution: {integrity: sha512-q4IGxMv56jCqT4OCRCADBoDP3LO4MhmTXjFbphHPXs4g3j9Xg5RDnxqN8IF/3vIWEU+VCnUq+7JUg/cfy2E6Qw==}
hasBin: true
mdurl@2.1.0:
resolution: {integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==}
mlly@1.8.2:
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
@@ -991,6 +1029,10 @@ packages:
resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==}
engines: {node: ^10 || ^12 || >=14}
punycode.js@2.3.1:
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
engines: {node: '>=6'}
quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
@@ -1050,6 +1092,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
uc.micro@3.0.0:
resolution: {integrity: sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==}
ufo@1.6.4:
resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==}
@@ -1539,10 +1584,22 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.12.2
'@tauri-apps/cli-win32-x64-msvc': 2.12.2
'@tauri-apps/plugin-autostart@2.7.0':
dependencies:
'@tauri-apps/api': 2.12.3
'@tauri-apps/plugin-opener@2.7.1':
dependencies:
'@tauri-apps/api': 2.12.3
'@tauri-apps/plugin-process@2.4.0':
dependencies:
'@tauri-apps/api': 2.12.3
'@tauri-apps/plugin-updater@2.13.3':
dependencies:
'@tauri-apps/api': 2.12.3
'@types/estree@1.0.9': {}
'@types/node@26.6.5':
@@ -1703,6 +1760,8 @@ snapshots:
vue-types: 3.0.2(vue@3.5.43(typescript@6.0.3))
warning: 4.0.3
argparse@3.0.2: {}
array-tree-filter@2.1.0: {}
ast-kit@2.2.0:
@@ -1755,6 +1814,8 @@ snapshots:
entities@7.0.1: {}
entities@8.1.0: {}
escape-string-regexp@5.0.0: {}
estree-walker@2.0.2: {}
@@ -1892,6 +1953,10 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.33.0
lightningcss-win32-x64-msvc: 1.33.0
linkify-it@6.1.0:
dependencies:
uc.micro: 3.0.0
local-pkg@1.2.1:
dependencies:
mlly: 1.8.2
@@ -1918,6 +1983,17 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
markdown-it@15.0.2:
dependencies:
argparse: 3.0.2
entities: 8.1.0
linkify-it: 6.1.0
mdurl: 2.1.0
punycode.js: 2.3.1
uc.micro: 3.0.0
mdurl@2.1.0: {}
mlly@1.8.2:
dependencies:
acorn: 8.19.0
@@ -1974,6 +2050,8 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.2
punycode.js@2.3.1: {}
quansync@0.2.11: {}
readdirp@5.1.1: {}
@@ -2038,6 +2116,8 @@ snapshots:
typescript@6.0.3: {}
uc.micro@3.0.0: {}
ufo@1.6.4: {}
undici-types@8.9.0: {}
+120 -6
View File
@@ -231,6 +231,20 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "auto-launch"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17918dba7ecf78b9a14507ec9f984e7977d13fad87dc86d61038980a45d128cf"
dependencies = [
"dirs 6.0.0",
"os_info",
"smappservice-rs",
"thiserror 2.0.21",
"windows-registry",
"windows-result",
]
[[package]]
name = "autocfg"
version = "1.5.1"
@@ -493,6 +507,12 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
version = "0.10.2"
@@ -889,6 +909,15 @@ dependencies = [
"ctutils",
]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs"
version = "7.0.0"
@@ -1267,7 +1296,7 @@ dependencies = [
]
[[package]]
name = "frpilot"
name = "frpcpilot"
version = "0.1.0"
dependencies = [
"chrono",
@@ -1280,6 +1309,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-helper",
"tauri-plugin-autostart",
"tauri-plugin-dialog",
"tauri-plugin-opener",
"tauri-plugin-prevent-default",
@@ -1813,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"
@@ -2571,6 +2607,18 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags 2.13.2",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "ntapi"
version = "0.4.3"
@@ -2802,6 +2850,30 @@ dependencies = [
"objc2-foundation",
]
[[package]]
name = "objc2-security"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
dependencies = [
"bitflags 2.13.2",
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-service-management"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b213642d6959cc6023ceb1217aa595eaaf09b8094ce95127c103cab611fe65e8"
dependencies = [
"block2",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
"objc2-security",
]
[[package]]
name = "objc2-ui-kit"
version = "0.3.2"
@@ -2923,6 +2995,21 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_info"
version = "3.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b"
dependencies = [
"android_system_properties",
"log",
"nix",
"objc2",
"objc2-foundation",
"objc2-ui-kit",
"windows-sys 0.61.2",
]
[[package]]
name = "osakit"
version = "0.3.1"
@@ -3988,6 +4075,18 @@ dependencies = [
"serde",
]
[[package]]
name = "smappservice-rs"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52703b97a53101cf5d4580e0737aaa634ce1fdcfc123c16b2a9658e358013488"
dependencies = [
"objc2",
"objc2-foundation",
"objc2-service-management",
"thiserror 2.0.21",
]
[[package]]
name = "socket2"
version = "0.6.5"
@@ -4494,7 +4593,7 @@ dependencies = [
"anyhow",
"bytes",
"cookie",
"dirs",
"dirs 7.0.0",
"dunce",
"embed_plist",
"getrandom 0.3.4",
@@ -4502,6 +4601,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"http-range",
"jni 0.21.1",
"libc",
"log",
@@ -4544,7 +4644,7 @@ checksum = "2eea0b6302f82c574b21af6fc6ae6e86183b9280853c22621d0abf0936911ffc"
dependencies = [
"anyhow",
"cargo_toml",
"dirs",
"dirs 7.0.0",
"glob",
"heck 0.5.0",
"json-patch",
@@ -4629,6 +4729,20 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-autostart"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bce56ceb0a99b8ab5eebc977ba9ffc62391b862648cb9ab76fc8bc7231b8f7e2"
dependencies = [
"auto-launch",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.21",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.8.2"
@@ -4740,7 +4854,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "855a336eb389d2c1502244fd48fa2d8cb487a430f691bbaed025195f99a04489"
dependencies = [
"base64 0.22.1",
"dirs",
"dirs 7.0.0",
"flate2",
"futures-util",
"http",
@@ -5277,7 +5391,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72"
dependencies = [
"crossbeam-channel",
"dirs",
"dirs 7.0.0",
"libappindicator",
"muda",
"objc2",
@@ -6137,7 +6251,7 @@ dependencies = [
"block2",
"cookie",
"crossbeam-channel",
"dirs",
"dirs 7.0.0",
"dom_query",
"dpi",
"dunce",
+4 -3
View File
@@ -1,5 +1,5 @@
[package]
name = "frpilot"
name = "frpcpilot"
version = "0.1.0"
description = "一个基于 Tauri 2 + Vue 3 + TypeScript 的 [frp](https://github.com/fatedier/frp) 可视化客户端,帮助用户快速配置 frpc 版本、服务器与隧道。"
authors = ["SparksFly"]
@@ -11,7 +11,7 @@ edition = "2024"
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "frpilot_lib"
name = "frpcpilot_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[workspace]
@@ -22,7 +22,7 @@ tauri-build = { version = "2", features = [] }
tauri-helper = "0.2.1"
[dependencies]
tauri = { version = "2", features = [] }
tauri = { version = "2.12.3", features = ["protocol-asset", "tray-icon"] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@@ -39,6 +39,7 @@ sysinfo = "0.39"
tauri-plugin-dialog = "2.7.2"
tauri-plugin-updater = "2.10.1"
tauri-plugin-process = "2.3.1"
tauri-plugin-autostart = "2"
tauri-plugin-prevent-default = "6.0.1"
reqwest = { version = "0.12", features = ["json", "stream"] }
futures-util = "0.3"
+5 -1
View File
@@ -6,6 +6,9 @@
"permissions": [
"core:default",
"opener:default",
"updater:default",
"process:default",
"autostart:default",
"core:window:allow-start-dragging",
"core:window:allow-minimize",
"core:window:allow-unminimize",
@@ -16,6 +19,7 @@
"core:window:allow-close",
"core:window:allow-hide",
"core:window:allow-show",
"core:window:allow-set-focus"
"core:window:allow-set-focus",
"core:app:allow-default-window-icon"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 567 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 536 B

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 777 B

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1020 B

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 B

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+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/";
@@ -29,7 +30,7 @@ macro_rules! frp_fields {
fn http_client() -> Result<reqwest::Client, reqwest::Error> {
reqwest::Client::builder()
.user_agent("Frpilot/0.1 (+https://github.com/fatedier/frp)")
.user_agent("FrpcPilot/0.1 (+https://github.com/fatedier/frp)")
.connect_timeout(Duration::from_secs(15))
.build()
}
@@ -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,11 +583,13 @@ 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 {
if Path::new(archive).starts_with(&base) {
let _ = tokio::fs::remove_file(archive).await;
}
}
// 版本目录已空则一并删除
if let Ok(mut entries) = tokio::fs::read_dir(&ver_dir).await {
if matches!(entries.next_entry().await, Ok(None)) {
@@ -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);
+6 -3
View File
@@ -1,4 +1,7 @@
pub mod tunnel;
pub mod server;
pub mod system;
pub mod background;
pub mod frp;
pub mod runner;
pub mod server;
pub mod storage;
pub mod system;
pub mod tunnel;
+183
View File
@@ -0,0 +1,183 @@
//! 一键启动 / 停止 Frpc 相关命令。
//!
//! 启动流程:读取所选 frpc 版本 → 汇总已启用的服务器与隧道 → 生成 frpc.toml
//! → 交给 `FrpcRunner` 以独立进程方式拉起,并实时回传日志。
use std::path::PathBuf;
use chrono::Local;
use sqlx::SqlitePool;
use tauri::{AppHandle, Manager, State};
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 {
() => {
"id, name, addr, port, token, protocol, tls_enable, remark, sort, pinned, enabled, created_at, updated_at"
};
}
macro_rules! tunnel_fields {
() => {
"id, name, proxy_type, local_ip, local_port, remote_port, custom_domains, subdomain, locations, secret_key, use_encryption, use_compression, bandwidth_limit, proxy_protocol_version, health_check_type, health_check_timeout, health_check_max_failed, health_check_interval, health_check_path, load_balancer_group, load_balancer_group_key, remark, sort, pinned, enabled, created_at, updated_at"
};
}
macro_rules! download_fields {
() => {
"id, version, os, arch, platform, file_name, archive_path, frpc_path, frps_path, file_size, source, created_at, updated_at"
};
}
/// 本次运行生成的 frpc.toml 路径(安装目录/run/frpc.toml)
fn config_file_path(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app.state::<StoragePaths>().run();
std::fs::create_dir_all(&dir).map_err(|e| format!("创建配置目录失败:{e}"))?;
Ok(dir.join("frpc.toml"))
}
/// 获取 Frpc 当前运行状态
#[tauri::command]
#[auto_collect_command]
pub async fn get_frpc_status(
runner: State<'_, FrpcRunner>,
) -> Result<ApiResponse<FrpcStatus>, String> {
Ok(ApiResponse::success(runner.status()))
}
/// 一键启动 Frpc
#[tauri::command]
#[auto_collect_command]
pub async fn start_frpc(
app: AppHandle,
pool: State<'_, SqlitePool>,
runner: State<'_, FrpcRunner>,
download_id: i64,
) -> Result<ApiResponse<FrpcStatus>, String> {
if runner.is_running() {
return Ok(ApiResponse::error(409, "Frpc 已在运行中,请先停止后再启动"));
}
// 1. 读取所选 Frpc 版本记录
let download = match sqlx::query_as::<_, FrpDownload>(concat!(
"SELECT ",
download_fields!(),
" FROM frp_downloads WHERE id = ?"
))
.bind(download_id)
.fetch_optional(&*pool)
.await
{
Ok(Some(row)) => row,
Ok(None) => {
return Ok(ApiResponse::error(
404,
"所选 Frpc 版本不存在,请刷新后重试",
));
}
Err(e) => return Ok(ApiResponse::error(500, &e.to_string())),
};
let Some(frpc_path) = download
.frpc_path
.clone()
.filter(|path| !path.trim().is_empty())
else {
return Ok(ApiResponse::error(
400,
"所选版本缺少 frpc 可执行文件,请重新下载或导入",
));
};
if !std::path::Path::new(&frpc_path).is_file() {
return Ok(ApiResponse::error(
400,
&format!("frpc 可执行文件不存在:{frpc_path}"),
));
}
// 2. 已启用的服务器(全局同时只启用一个)
let server = match sqlx::query_as::<_, Server>(concat!(
"SELECT ",
server_fields!(),
" FROM servers WHERE enabled = 1 ORDER BY pinned DESC, sort ASC, id ASC LIMIT 1"
))
.fetch_optional(&*pool)
.await
{
Ok(Some(row)) => row,
Ok(None) => {
return Ok(ApiResponse::error(
400,
"尚未启用任何服务器,请先在服务器页面启用一个服务器",
));
}
Err(e) => return Ok(ApiResponse::error(500, &e.to_string())),
};
// 3. 已启用的隧道(可同时启用多个)
let tunnels = match sqlx::query_as::<_, Tunnel>(concat!(
"SELECT ",
tunnel_fields!(),
" FROM tunnels WHERE enabled = 1 ORDER BY pinned DESC, sort ASC, id ASC"
))
.fetch_all(&*pool)
.await
{
Ok(list) => list,
Err(e) => return Ok(ApiResponse::error(500, &e.to_string())),
};
if tunnels.is_empty() {
return Ok(ApiResponse::error(
400,
"尚未启用任何隧道,请先在隧道列表页面启用至少一个隧道",
));
}
// 4. 生成 frpc.toml
let config = generate_frpc_config(&server, &tunnels);
let config_path = match config_file_path(&app) {
Ok(path) => path,
Err(e) => return Ok(ApiResponse::error(500, &e)),
};
if let Err(e) = std::fs::write(&config_path, config) {
return Ok(ApiResponse::error(
500,
&format!("写入 frpc.toml 失败:{e}"),
));
}
// 5. 启动 frpc 进程
let config_path_str = config_path.to_string_lossy().to_string();
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)),
}
}
/// 停止 Frpc
#[tauri::command]
#[auto_collect_command]
pub async fn stop_frpc(
app: AppHandle,
runner: State<'_, FrpcRunner>,
) -> Result<ApiResponse<FrpcStatus>, String> {
match runner.stop(&app) {
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)
}
+6
View File
@@ -7,6 +7,12 @@ use tauri_helper::auto_collect_command;
use crate::models::{ApiResponse, SystemDetails, SystemInfo};
use crate::services::system::SystemStats;
#[tauri::command]
#[auto_collect_command]
pub fn is_autostart_supported() -> bool {
!tauri::is_dev() && !cfg!(debug_assertions)
}
/// 获取系统资源占用(CPU / 内存百分比)
#[tauri::command]
#[auto_collect_command]
+89 -12
View File
@@ -2,49 +2,126 @@ mod commands;
mod models;
mod services;
use tauri::Manager;
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,
};
use crate::commands::runner::{get_frpc_status, start_frpc, stop_frpc};
use crate::commands::server::{
create_server, delete_server, get_server_list, set_server_enabled, set_server_pinned,
update_server,
};
use crate::commands::system::{get_system_details, get_system_info};
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() {
tauri::Builder::default()
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())
.plugin(tauri_plugin_prevent_default::Builder::new().build());
// 开发版依赖 Vite,调试构建在 Windows 上还会打开控制台窗口。
if is_autostart_supported() {
builder = builder.plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
None,
));
}
let app = builder
.setup(|app| {
let pool = tauri::async_runtime::block_on(async {
init_pool(app.handle())
.await
.expect("sqlite数据库连接池初始化失败!")
});
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| {
if enabled {
autostart.enable()
} else {
Ok(())
}
});
if let Err(error) = result {
eprintln!("Failed to refresh autostart executable path: {error}");
}
}
// 系统资源占用(CPU / 内存)后台采集
let stats = SystemStats::default();
stats.start();
app.manage(stats);
// 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!())
.run(tauri::generate_context!())
.expect("error while running tauri application");
.build(context)
.expect("error while building tauri application");
app.run(|app_handle, event| {
// 应用退出时结束 frpc 子进程,避免残留后台进程
if let RunEvent::Exit = event {
app_handle.state::<FrpcRunner>().shutdown();
}
});
}
+1 -1
View File
@@ -2,5 +2,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
frpilot_lib::run()
frpcpilot_lib::run()
}
+39
View File
@@ -307,3 +307,42 @@ pub struct SystemDetails {
pub ips: Vec<String>,
}
/// Frpc 运行状态(仪表盘快速操作区 / 侧边栏状态点使用)
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FrpcStatus {
/// 是否正在运行
pub running: bool,
/// 当前运行的 Frpc 版本号
pub version: Option<String>,
/// frpc 可执行文件路径
pub frpc_path: Option<String>,
/// 本次运行生成的 frpc.toml 路径
pub config_path: Option<String>,
/// frpc 进程 PID
pub pid: Option<u32>,
}
/// Frpc 实时日志事件(通过 `frpc-log` 事件推送)
#[derive(Debug, Clone, Serialize)]
pub struct FrpcLogEvent {
/// frpc 输出中的时间戳(HH:MM:SS,可能为空)
pub time: Option<String>,
/// 日志级别:INFO / WARN / ERROR / DEBUG / TRACE
pub level: String,
/// 来源:stdout / stderr / system(system 为 FrpcPilot 自身补充的日志)
pub source: String,
/// 日志内容
pub message: String,
}
/// Frpc 进程退出事件(通过 `frpc-exit` 事件推送)
#[derive(Debug, Clone, Serialize)]
pub struct FrpcExitEvent {
/// 退出原因:stopped(用户主动停止)/ exited(进程自行退出)
pub reason: String,
/// 退出码(主动终止时可能为空)
pub code: Option<i32>,
/// 退出时对应的 Frpc 版本号
pub version: Option<String>,
}
+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();
}
}
+241
View File
@@ -0,0 +1,241 @@
//! frpc 配置生成。
//!
//! 根据数据库中「已启用的服务器」与「已启用的隧道」拼装出 frpc 的 TOML 配置。
//! 生成的文件仅包含当前启用的条目,Frpc 每次启动都会重新生成并覆盖。
use crate::models::{Server, Tunnel};
/// 生成 frpc.toml 内容。
///
/// 采用 frp v1 的 TOML 配置格式(`frpc -c frpc.toml`),字段命名与
/// 隧道表单中的注释一致,例如 `localIP` / `remotePort` / `transport.useEncryption`。
pub fn generate_frpc_config(server: &Server, tunnels: &[Tunnel]) -> String {
let mut out = String::new();
out.push_str("# 该文件由 FrpcPilot 自动生成,请勿手动修改(重新启动 Frpc 会覆盖此文件)\n\n");
// 日志:输出到控制台,由 FrpcPilot 采集并实时展示
// 关闭 ANSI 颜色,避免日志中混入 `\x1b[1;34m` 之类的转义码
out.push_str("log.to = \"console\"\n");
out.push_str("log.level = \"info\"\n");
out.push_str("log.disablePrintColor = true\n\n");
// 服务器(公共)配置
out.push_str(&format!("serverAddr = {}\n", toml_str(&server.addr)));
out.push_str(&format!("serverPort = {}\n", server.port));
if let Some(token) = non_empty(&server.token) {
out.push_str("auth.method = \"token\"\n");
out.push_str(&format!("auth.token = {}\n", toml_str(&token)));
}
out.push_str(&format!(
"transport.protocol = {}\n",
toml_str(&server.protocol)
));
out.push_str(&format!(
"transport.tls.enable = {}\n",
server.tls_enable
));
// 隧道(代理)配置
for tunnel in tunnels {
out.push('\n');
out.push_str("[[proxies]]\n");
out.push_str(&format!("name = {}\n", toml_str(&tunnel.name)));
out.push_str(&format!("type = {}\n", toml_str(&tunnel.proxy_type)));
out.push_str(&format!("localIP = {}\n", toml_str(&tunnel.local_ip)));
out.push_str(&format!("localPort = {}\n", tunnel.local_port));
if let Some(remote_port) = tunnel.remote_port {
out.push_str(&format!("remotePort = {remote_port}\n"));
}
if let Some(list) = split_list(&tunnel.custom_domains) {
out.push_str(&format!("customDomains = {}\n", toml_array(&list)));
}
if let Some(subdomain) = non_empty(&tunnel.subdomain) {
out.push_str(&format!("subdomain = {}\n", toml_str(&subdomain)));
}
if let Some(list) = split_list(&tunnel.locations) {
out.push_str(&format!("locations = {}\n", toml_array(&list)));
}
if let Some(secret_key) = non_empty(&tunnel.secret_key) {
out.push_str(&format!("secretKey = {}\n", toml_str(&secret_key)));
}
// 代理内部传输配置
let mut transport = String::new();
if tunnel.use_encryption {
transport.push_str("useEncryption = true\n");
}
if tunnel.use_compression {
transport.push_str("useCompression = true\n");
}
if let Some(limit) = non_empty(&tunnel.bandwidth_limit) {
transport.push_str(&format!("bandwidthLimit = {}\n", toml_str(&limit)));
}
if let Some(version) = non_empty(&tunnel.proxy_protocol_version) {
transport.push_str(&format!("proxyProtocolVersion = {}\n", toml_str(&version)));
}
if !transport.is_empty() {
out.push_str("\n[proxies.transport]\n");
out.push_str(&transport);
}
// 健康检查
if let Some(check_type) = non_empty(&tunnel.health_check_type) {
out.push_str("\n[proxies.healthCheck]\n");
out.push_str(&format!("type = {}\n", toml_str(&check_type)));
if let Some(value) = tunnel.health_check_timeout {
out.push_str(&format!("timeoutSeconds = {value}\n"));
}
if let Some(value) = tunnel.health_check_max_failed {
out.push_str(&format!("maxFailed = {value}\n"));
}
if let Some(value) = tunnel.health_check_interval {
out.push_str(&format!("intervalSeconds = {value}\n"));
}
if let Some(path) = non_empty(&tunnel.health_check_path) {
out.push_str(&format!("path = {}\n", toml_str(&path)));
}
}
// 负载均衡
if let Some(group) = non_empty(&tunnel.load_balancer_group) {
out.push_str("\n[proxies.loadBalancer]\n");
out.push_str(&format!("group = {}\n", toml_str(&group)));
if let Some(key) = non_empty(&tunnel.load_balancer_group_key) {
out.push_str(&format!("groupKey = {}\n", toml_str(&key)));
}
}
}
out
}
/// 去除首尾空白,空字符串返回 None
fn non_empty(value: &Option<String>) -> Option<String> {
value
.as_ref()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
}
/// 英文逗号分隔字符串 → 字符串数组(空返回 None)
fn split_list(value: &Option<String>) -> Option<Vec<String>> {
let list: Vec<String> = value
.as_deref()
.unwrap_or_default()
.split(',')
.map(|item| item.trim().to_string())
.filter(|item| !item.is_empty())
.collect();
if list.is_empty() { None } else { Some(list) }
}
/// 字符串数组 → TOML 数组字面量,如 `["a", "b"]`
fn toml_array(values: &[String]) -> String {
let items: Vec<String> = values.iter().map(|v| toml_str(v)).collect();
format!("[{}]", items.join(", "))
}
/// 转义并包裹为 TOML 字符串
fn toml_str(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for ch in value.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04X}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_server() -> Server {
Server {
id: 1,
name: "本地".into(),
addr: "1.2.3.4".into(),
port: 7000,
token: Some("secret".into()),
protocol: "tcp".into(),
tls_enable: true,
remark: None,
sort: 0,
pinned: false,
enabled: true,
created_at: String::new(),
updated_at: String::new(),
}
}
fn sample_tunnel() -> Tunnel {
Tunnel {
id: 1,
name: "ssh".into(),
proxy_type: "tcp".into(),
local_ip: "127.0.0.1".into(),
local_port: 22,
remote_port: Some(6000),
custom_domains: None,
subdomain: None,
locations: None,
secret_key: None,
use_encryption: true,
use_compression: false,
bandwidth_limit: None,
proxy_protocol_version: None,
health_check_type: None,
health_check_timeout: None,
health_check_max_failed: None,
health_check_interval: None,
health_check_path: None,
load_balancer_group: None,
load_balancer_group_key: None,
remark: None,
sort: 0,
pinned: false,
enabled: true,
created_at: String::new(),
updated_at: String::new(),
}
}
#[test]
fn generates_basic_config() {
let config = generate_frpc_config(&sample_server(), &[sample_tunnel()]);
assert!(config.contains("serverAddr = \"1.2.3.4\""));
assert!(config.contains("serverPort = 7000"));
assert!(config.contains("auth.token = \"secret\""));
assert!(config.contains("log.disablePrintColor = true"));
assert!(config.contains("[[proxies]]"));
assert!(config.contains("remotePort = 6000"));
assert!(config.contains("[proxies.transport]"));
assert!(config.contains("useEncryption = true"));
}
#[test]
fn escapes_special_characters() {
assert_eq!(toml_str("a\"b\\c"), "\"a\\\"b\\\\c\"");
}
#[test]
fn splits_domain_list() {
assert_eq!(
split_list(&Some("a.com, b.com ,".into())),
Some(vec!["a.com".to_string(), "b.com".to_string()])
);
assert_eq!(split_list(&Some("".into())), None);
}
}
+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("frpilot.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)
+4
View File
@@ -1,2 +1,6 @@
pub mod background;
pub mod config;
pub mod db;
pub mod runner;
pub mod storage;
pub mod system;
+556
View File
@@ -0,0 +1,556 @@
//! Frpc 进程管理。
//!
//! 通过 `std::process::Command` 直接拉起 frpc 可执行文件(**刻意绕开 plugin-shell**):
//! - frpc 与主进程互相独立,frpc 异常退出不会影响 FrpcPilot 本体;
//! - Windows 下使用 `CREATE_NO_WINDOW` 启动,避免弹出黑色控制台窗口;
//! - stdout / stderr 通过管道读取,按行解析后以 `frpc-log` 事件实时推送给前端;
//! - 后台守护线程轮询子进程状态,退出时推送 `frpc-exit` 事件。
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};
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};
/// 实时日志事件名
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);
/// 运行中的 frpc
struct Running {
child: Child,
/// 本次运行的唯一标识,避免停止后新进程被旧守护线程误判
token: u64,
version: String,
frpc_path: String,
config_path: String,
log_file: SharedLogFile,
}
/// Frpc 进程管理器(应用级单例)
#[derive(Default)]
pub struct FrpcRunner {
slot: Arc<Mutex<Option<Running>>>,
counter: AtomicU64,
}
impl FrpcRunner {
fn lock(&self) -> MutexGuard<'_, Option<Running>> {
// 中毒的锁直接恢复内部数据,避免一次 panic 后永远无法再启动 frpc
self.slot.lock().unwrap_or_else(|e| e.into_inner())
}
/// 当前是否有 frpc 在运行
pub fn is_running(&self) -> bool {
self.lock().is_some()
}
/// 运行状态快照
pub fn status(&self) -> FrpcStatus {
let guard = self.lock();
match guard.as_ref() {
Some(running) => FrpcStatus {
running: true,
version: Some(running.version.clone()),
frpc_path: Some(running.frpc_path.clone()),
config_path: Some(running.config_path.clone()),
pid: Some(running.child.id()),
},
None => FrpcStatus::default(),
}
}
/// 启动 frpc。
///
/// `frpc_path` 为可执行文件路径,`config_path` 为本次生成的 frpc.toml 路径。
pub fn spawn(
&self,
app: &AppHandle,
frpc_path: &str,
config_path: &str,
version: &str,
log_path: &Path,
) -> Result<FrpcStatus, String> {
let mut guard = self.lock();
if guard.is_some() {
return Err("Frpc 已在运行中".to_string());
}
let exe = Path::new(frpc_path);
if !exe.is_file() {
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")
.arg(config_path)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
// 相对路径的运行产物统一保存在生成配置所在的目录。
if let Some(dir) = Path::new(config_path).parent() {
command.current_dir(dir);
}
// Windows:不创建控制台窗口(避免黑色窗口)
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
command.creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP);
}
let mut child = command
.spawn()
.map_err(|e| format!("启动 Frpc 失败:{e}"))?;
let pid = child.id();
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let token = self.counter.fetch_add(1, Ordering::SeqCst) + 1;
*guard = Some(Running {
child,
token,
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", log_file.clone());
}
if let Some(stderr) = stderr {
spawn_reader(app.clone(), stderr, "stderr", log_file.clone());
}
// 守护线程:轮询子进程是否退出
spawn_waiter(app.clone(), self.slot.clone(), token);
emit_persisted_log(
app,
FrpcLogEvent {
time: None,
level: "INFO".to_string(),
source: "system".to_string(),
message: format!("Frpc {version} 已启动(PID {pid})"),
},
Some(&log_file),
);
Ok(self.status())
}
/// 停止 frpc(用户主动停止)。未运行时为空操作。
pub fn stop(&self, app: &AppHandle) -> Result<FrpcStatus, String> {
let mut guard = self.lock();
if guard.is_none() {
return Ok(FrpcStatus::default());
}
let log_file = guard.as_ref().map(|running| running.log_file.clone());
emit_persisted_log(
app,
FrpcLogEvent {
time: None,
level: "WARN".to_string(),
source: "system".to_string(),
message: "正在停止 Frpc…".to_string(),
},
log_file.as_ref(),
);
let version = {
let running = guard.as_mut().expect("guard 已确认非空");
let version = running.version.clone();
// kill + wait 回收进程,避免残留僵尸进程
let _ = running.child.kill();
let _ = running.child.wait();
version
};
*guard = None;
drop(guard);
emit_persisted_log(
app,
FrpcLogEvent {
time: None,
level: "WARN".to_string(),
source: "system".to_string(),
message: "Frpc 已停止".to_string(),
},
log_file.as_ref(),
);
emit_exit(
app,
FrpcExitEvent {
reason: "stopped".to_string(),
code: None,
version: Some(version),
},
);
Ok(FrpcStatus::default())
}
/// 应用退出时静默结束 frpc(不发送事件)
pub fn shutdown(&self) {
let mut guard = self.lock();
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,
log_file: SharedLogFile,
) {
thread::spawn(move || {
let mut reader = BufReader::new(pipe);
let mut buf: Vec<u8> = Vec::new();
loop {
buf.clear();
match reader.read_until(b'\n', &mut buf) {
Ok(0) => break,
Ok(_) => {
let raw = String::from_utf8_lossy(&buf);
let line = strip_ansi(raw.trim_end_matches(['\r', '\n']));
if line.trim().is_empty() {
continue;
}
emit_persisted_log(&app, parse_line(source, &line), Some(&log_file));
}
Err(_) => break,
}
}
});
}
/// 守护线程:轮询子进程状态,退出时清理并推送事件
fn spawn_waiter(app: AppHandle, slot: Arc<Mutex<Option<Running>>>, token: u64) {
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(),
running.log_file.clone(),
)),
// 已被停止 / 已被新进程替换
_ => None,
};
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(),
},
},
Some(&log_file),
);
emit_exit(
&app,
FrpcExitEvent {
reason: "exited".to_string(),
code,
version: Some(version),
},
);
return;
}
Some((Ok(None), _, _)) => {} // 仍在运行
Some((Err(_), _, _)) => return,
}
}
});
}
/// 解析一行 frpc 日志,识别时间戳与级别
fn parse_line(source: &str, line: &str) -> FrpcLogEvent {
FrpcLogEvent {
time: extract_time(line),
level: detect_level(line),
source: source.to_string(),
message: line.to_string(),
}
}
/// 从行首附近提取 `HH:MM:SS` 形式的时间戳
fn extract_time(line: &str) -> Option<String> {
for token in line.split_whitespace().take(3) {
let token = token.trim_end_matches(',');
let head: String = token.chars().take(8).collect();
if head.len() == 8
&& head.matches(':').count() == 2
&& head.chars().all(|c| c.is_ascii_digit() || c == ':')
{
return Some(head);
}
}
None
}
/// 识别 `[I]` / `[W]` / `[E]` / `[D]` / `[T]` 级别标记
fn detect_level(line: &str) -> String {
const MARKERS: [(&str, &str); 5] = [
("[I]", "INFO"),
("[W]", "WARN"),
("[E]", "ERROR"),
("[D]", "DEBUG"),
("[T]", "TRACE"),
];
let mut best: Option<(usize, &str)> = None;
for (marker, name) in MARKERS {
if let Some(index) = line.find(marker) {
if index <= 30 && best.is_none_or(|(best_index, _)| index < best_index) {
best = Some((index, name));
}
}
}
if let Some((_, name)) = best {
return name.to_string();
}
// 兜底:frpc 部分输出不带级别标记(如登录失败提示),按关键词判定
let lower = line.to_ascii_lowercase();
if lower.contains("failed") || lower.contains("error") || lower.contains("fatal") {
"ERROR".to_string()
} else if lower.contains("warn") {
"WARN".to_string()
} else {
"INFO".to_string()
}
}
/// 剥离 ANSI 转义序列(CSI / OSC),避免终端颜色码混入界面日志
///
/// frpc 已由配置项 `log.disablePrintColor` 关闭颜色,这里作为兜底,
/// 兼容用户自行导入的旧版 frpc 或未来可能变化的输出。
fn strip_ansi(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
if ch != '\u{1b}' {
out.push(ch);
continue;
}
// ESC 序列:根据引导字符决定消费方式
match chars.peek().copied() {
// CSI:ESC [ ... 直到终止字节 0x40 - 0x7E
Some('[') => {
chars.next();
for c in chars.by_ref() {
if ('\u{40}'..='\u{7e}').contains(&c) {
break;
}
}
}
// OSC:ESC ] ... 直到 BEL 或 ST(ESC \)
Some(']') => {
chars.next();
while let Some(c) = chars.next() {
if c == '\u{7}' {
break;
}
if c == '\u{1b}' && chars.peek() == Some(&'\\') {
chars.next();
break;
}
}
}
// 其它两字节转义序列:ESC + 单个字符
Some(_) => {
chars.next();
}
None => {}
}
}
out
}
/// 发送日志事件(失败时忽略,避免影响进程管理)
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);
}
#[cfg(test)]
mod tests {
use super::*;
#[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("[W] warn only"), "WARN");
assert_eq!(detect_level("plain message"), "INFO");
}
#[test]
fn extracts_timestamp() {
assert_eq!(
extract_time("2026/06/18 22:02:03 [I] msg"),
Some("22:02:03".to_string())
);
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";
assert_eq!(strip_ansi(input), "2026-10-13 17:19:48.171 [I] hello");
// 无转义码时保持原样
assert_eq!(strip_ansi("plain text"), "plain text");
}
#[test]
fn detects_level_after_stripping_ansi() {
let line =
strip_ansi("\u{1b}[1;33m2026-10-13 17:19:50.288 [W] connect to server error\u{1b}[0m");
assert_eq!(detect_level(&line), "WARN");
}
#[test]
fn falls_back_to_error_for_failed_lines() {
assert_eq!(
detect_level("login to the server failed: dial tcp 1.2.3.4:7000"),
"ERROR"
);
assert_eq!(detect_level("connection timeout, retrying"), "INFO");
}
}
+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;
});
}
+15 -7
View File
@@ -1,8 +1,8 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "frpilot",
"productName": "FrpcPilot",
"version": "../package.json",
"identifier": "frpilot",
"identifier": "FrpcPilot",
"build": {
"beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:1420",
@@ -12,7 +12,7 @@
"app": {
"windows": [
{
"title": "Frpilot",
"title": "FrpcPilot",
"width": 1100,
"height": 720,
"minWidth": 900,
@@ -22,12 +22,17 @@
}
],
"security": {
"csp": null
"csp": null,
"assetProtocol": {
"enable": true,
"scope": ["$APPDATA/backgrounds/**"]
}
}
},
"bundle": {
"active": true,
"targets": "all",
"createUpdaterArtifacts": true,
"icon": [
"icons/32x32.png",
"icons/128x128.png",
@@ -39,9 +44,12 @@
"plugins": {
"updater": {
"active": true,
"dialog": true,
"endpoints": ["https://ghproxy.net/https://github.com/SparksFly91/Frpilot/releases/latest/download/latest.json"],
"pubkey": ""
"dialog": false,
"endpoints": [
"https://ghproxy.net/https://github.com/SparksFly91/FrpcPilot/releases/latest/download/latest.json",
"https://github.com/SparksFly91/FrpcPilot/releases/latest/download/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU0REVGMTREODFFOTI2MUEKUldRYUp1bUJUZkhlNU1qRmlPQm5xSmdQd24waFltVmNtTjlUallYVmdva1VGNkp5bTVGcVlsZUgK"
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://schema.tauri.app/config/2",
"app": {
"windows": [
{
"title": "FrpcPilot",
"width": 1100,
"height": 720,
"minWidth": 900,
"minHeight": 600,
"decorations": false,
"transparent": true
}
]
}
}
+13
View File
@@ -7,8 +7,13 @@
<script setup lang="ts">
import { theme as antdTheme } from "ant-design-vue"
import { useAppStore } from "@/stores/app"
import { useFrpcStore } from "@/stores/frpc"
import { useWindowStore } from "@/stores/window"
import { initTray } from "@/composables/useTray"
const appStore = useAppStore()
const frpcStore = useFrpcStore()
const windowStore = useWindowStore()
/** 跟随明暗主题切换 Ant Design Vue 的算法 */
const themeConfig = computed(() => ({
@@ -18,6 +23,14 @@ const themeConfig = computed(() => ({
borderRadius: 8,
},
}))
// 应用根组件初始化一次:订阅 frpc 日志/退出事件,离开仪表盘后仍持续收集
onMounted(() => {
frpcStore.init()
// 系统托盘(前端 API 方式)与窗口关闭拦截
initTray()
windowStore.init()
})
</script>
<style lang="scss" scoped></style>
+29
View File
@@ -0,0 +1,29 @@
import { isTauri } from "@/utils/window"
import { invoke } from "@tauri-apps/api/core"
export async function isAutostartSupported(): Promise<boolean> {
if (!isTauri()) return false
return invoke<boolean>("is_autostart_supported")
}
/**
* 开机自启封装(Tauri 官方 autostart 插件)。
*
* 自启状态由系统启动项决定(Windows 注册表 Run 键 / macOS LaunchAgent /
* Linux XDG autostart),这里只做读写,不在前端另行持久化,避免两端不一致。
* 非 Tauri 环境(纯浏览器调试)下调用会被安全忽略。
*/
/** 读取系统启动项:本应用是否已设为开机自启 */
export async function isAutostartEnabled(): Promise<boolean> {
if (!isTauri()) return false
const { isEnabled } = await import("@tauri-apps/plugin-autostart")
return isEnabled()
}
/** 开启 / 关闭开机自启(直接写入系统启动项) */
export async function setAutostartEnabled(enabled: boolean): Promise<void> {
if (!isTauri()) return
const { enable, disable } = await import("@tauri-apps/plugin-autostart")
await (enabled ? enable() : disable())
}
+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")
}
+27 -2
View File
@@ -1,6 +1,11 @@
import { invoke } from "@tauri-apps/api/core"
import type { ApiResponse } from "@/types/api"
import type { FrpDownload, FrpDownloadPayload, FrpRelease } from "@/types/frp"
import type {
FrpDownload,
FrpDownloadPayload,
FrpRelease,
FrpcStatus,
} from "@/types/frp"
/**
* 调用后端命令并统一解包 ApiResponse。
@@ -17,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")
@@ -42,3 +47,23 @@ export function deleteFrp(id: number) {
export function importFrp() {
return call<FrpDownload[]>("import_frp")
}
/**
* 一键启动 Frpc。
*
* 后端会依据所选版本、已启用的服务器与隧道生成 frpc.toml 并拉起独立进程,
* 随后通过 `frpc-log` / `frpc-exit` 事件回传运行日志与退出信息。
*/
export function startFrpc(downloadId: number) {
return call<FrpcStatus>("start_frpc", { downloadId })
}
/** 停止 Frpc 服务进程 */
export function stopFrpc() {
return call<FrpcStatus>("stop_frpc")
}
/** 获取 Frpc 当前运行状态 */
export function getFrpcStatus() {
return call<FrpcStatus>("get_frpc_status")
}
+48
View File
@@ -0,0 +1,48 @@
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"
/**
* 检查是否有可用更新。
*
* 非 Tauri 环境(纯浏览器调试)下无法调用插件,直接返回 null。
*/
export async function checkForAppUpdate(): Promise<Update | null> {
if (!isTauri()) return null
const { check } = await import("@tauri-apps/plugin-updater")
return check()
}
/** 重启应用(macOS / Linux 安装更新后需要重启才能运行新版本) */
export async function relaunchApp() {
if (!isTauri()) return
await flushPreferences()
const { relaunch } = await import("@tauri-apps/plugin-process")
await relaunch()
}
/** 用系统默认浏览器打开外部链接 */
export async function openExternal(url: string) {
if (!isTauri()) {
// 浏览器调试环境退化为新标签页打开
window.open(url, "_blank", "noopener,noreferrer")
return
}
const { openUrl } = await import("@tauri-apps/plugin-opener")
await openUrl(url)
}
/** 打开项目 GitHub 仓库首页 */
export function openProjectRepo() {
return openExternal(PROJECT_GITHUB_URL)
}
/** 读取当前应用版本(来自 tauri.conf.json / package.json) */
export async function getAppVersion(): Promise<string> {
if (!isTauri()) return "dev"
const { getVersion } = await import("@tauri-apps/api/app")
return getVersion()
}

Some files were not shown because too many files have changed in this diff Show More