feat: 新增 Frpc 一键启动与实时日志回显功能

新增一键启动/停止 Frpc 的命令及后端进程管理模块,支持生成配置文件、独立进程拉起、日志实时推送与退出事件通知。新增 Frpc 状态与日志类型定义,同步更新前端仪表盘逻辑以展示运行状态与实时日志。新增中英文启动失败提示文案,完善应用退出时自动终止 Frpc 子进程的逻辑。
This commit is contained in:
Yuhang Wu
2026-10-13 17:35:50 +08:00
parent 85a52dfbe9
commit a6276680cc
13 changed files with 1105 additions and 40 deletions
+11 -1
View File
@@ -94,4 +94,14 @@ Layout布局content区域的磨砂玻璃卡片圆角样式我要改动一下:
- 点击卡片上的下载按钮,卡片下方显示一个下载进度的进度条
- 展示获取到的frp数据使用卡片展示,卡片上有对应按钮可以点击进行下载,卡片展示的信息包含frp版本、大小、下载次数、发布日期。在有一个按钮,点击复制下载链接,便于去浏览器等环境下载。再加一个按钮删除已下载的版本
开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发
开发过程中使用到的组件尽可能全部使用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 -2
View File
@@ -1,4 +1,5 @@
pub mod tunnel;
pub mod frp;
pub mod runner;
pub mod server;
pub mod system;
pub mod frp;
pub mod tunnel;
+171
View File
@@ -0,0 +1,171 @@
//! 一键启动 / 停止 Frpc 相关命令。
//!
//! 启动流程:读取所选 frpc 版本 → 汇总已启用的服务器与隧道 → 生成 frpc.toml
//! → 交给 `FrpcRunner` 以独立进程方式拉起,并实时回传日志。
use std::path::PathBuf;
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;
// 统一查询字段,使用 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 路径(应用数据目录/frp/run/frpc.toml)
fn config_file_path(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
.app_data_dir()
.map_err(|e| e.to_string())?
.join("frp")
.join("run");
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();
match runner.spawn(&app, &frpc_path, &config_path_str, &download.version) {
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)),
}
}
+16 -4
View File
@@ -2,11 +2,12 @@ mod commands;
mod models;
mod services;
use tauri::Manager;
use tauri::{Manager, RunEvent};
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,
@@ -17,11 +18,12 @@ use crate::commands::tunnel::{
update_tunnel,
};
use crate::services::db::init_pool;
use crate::services::runner::FrpcRunner;
use crate::services::system::SystemStats;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
let app = tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init()) // 对话框插件
.plugin(tauri_plugin_updater::Builder::new().build()) // 更新插件
@@ -40,11 +42,21 @@ pub fn run() {
stats.start();
app.manage(stats);
// Frpc 进程管理器(一键启动 / 停止与日志实时回显)
app.manage(FrpcRunner::default());
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(tauri::generate_context!())
.expect("error while building tauri application");
app.run(|app_handle, event| {
// 应用退出时结束 frpc 子进程,避免残留后台进程
if let RunEvent::Exit = event {
app_handle.state::<FrpcRunner>().shutdown();
}
});
}
+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 为 Frpilot 自身补充的日志)
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>,
}
+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("# 该文件由 Frpilot 自动生成,请勿手动修改(重新启动 Frpc 会覆盖此文件)\n\n");
// 日志:输出到控制台,由 Frpilot 采集并实时展示
// 关闭 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 -1
View File
@@ -1,2 +1,4 @@
pub mod config;
pub mod db;
pub mod system;
pub mod runner;
pub mod system;
+445
View File
@@ -0,0 +1,445 @@
//! Frpc 进程管理。
//!
//! 通过 `std::process::Command` 直接拉起 frpc 可执行文件(**刻意绕开 plugin-shell**):
//! - frpc 与主进程互相独立,frpc 异常退出不会影响 Frpilot 本体;
//! - Windows 下使用 `CREATE_NO_WINDOW` 启动,避免弹出黑色控制台窗口;
//! - stdout / stderr 通过管道读取,按行解析后以 `frpc-log` 事件实时推送给前端;
//! - 后台守护线程轮询子进程状态,退出时推送 `frpc-exit` 事件。
use std::io::{BufRead, BufReader, Read};
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 tauri::{AppHandle, Emitter};
use crate::models::{FrpcExitEvent, FrpcLogEvent, FrpcStatus};
/// 实时日志事件名
pub const LOG_EVENT: &str = "frpc-log";
/// 进程退出事件名
pub const EXIT_EVENT: &str = "frpc-exit";
/// 守护线程轮询间隔
const POLL_INTERVAL: Duration = Duration::from_millis(250);
/// 运行中的 frpc
struct Running {
child: Child,
/// 本次运行的唯一标识,避免停止后新进程被旧守护线程误判
token: u64,
version: String,
frpc_path: String,
config_path: String,
}
/// 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,
) -> 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 mut command = Command::new(exe);
command
.arg("-c")
.arg(config_path)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
// 工作目录设为 frpc 所在目录,保证其相对路径配置可正常解析
if let Some(dir) = exe.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(),
});
drop(guard);
// 采集 stdout / stderr
if let Some(stdout) = stdout {
spawn_reader(app.clone(), stdout, "stdout");
}
if let Some(stderr) = stderr {
spawn_reader(app.clone(), stderr, "stderr");
}
// 守护线程:轮询子进程是否退出
spawn_waiter(app.clone(), self.slot.clone(), token);
emit_log(
app,
FrpcLogEvent {
time: None,
level: "INFO".to_string(),
source: "system".to_string(),
message: format!("Frpc {version} 已启动(PID {pid})"),
},
);
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());
}
emit_log(
app,
FrpcLogEvent {
time: None,
level: "WARN".to_string(),
source: "system".to_string(),
message: "正在停止 Frpc…".to_string(),
},
);
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_log(
app,
FrpcLogEvent {
time: None,
level: "WARN".to_string(),
source: "system".to_string(),
message: "Frpc 已停止".to_string(),
},
);
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();
}
*guard = None;
}
}
/// 持续读取子进程输出并推送日志事件
fn spawn_reader<R: Read + Send + 'static>(app: AppHandle, pipe: R, source: &'static str) {
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_log(&app, parse_line(source, &line));
}
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()))
}
// 已被停止 / 已被新进程替换
_ => None,
};
match probe {
None => return,
Some((Ok(Some(status)), version)) => {
*guard = None;
drop(guard);
let code = status.code();
emit_log(
&app,
FrpcLogEvent {
time: None,
level: if code.unwrap_or(1) == 0 { "WARN" } else { "ERROR" }.to_string(),
source: "system".to_string(),
message: match code {
Some(code) => format!("Frpc 进程已退出(退出码 {code})"),
None => "Frpc 进程已退出".to_string(),
},
},
);
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_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 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 47.113.122.255:7000"),
"ERROR"
);
assert_eq!(detect_level("connection timeout, retrying"), "INFO");
}
}
+26 -1
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。
@@ -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")
}
+2 -1
View File
@@ -50,7 +50,8 @@
"logEmpty": "No logs yet. Live logs will appear here once Frpc starts.",
"logReady": "Frpilot is ready. Waiting for Frpc to start.",
"logStarted": "Frpc {version} started",
"logStopped": "Frpc stopped"
"logStopped": "Frpc stopped",
"logStartFailed": "Failed to start Frpc: {message}"
},
"settings": {
"title": "Settings",
+2 -1
View File
@@ -50,7 +50,8 @@
"logEmpty": "暂无日志,启动 Frpc 后将在此显示实时日志",
"logReady": "Frpilot 已就绪,等待启动 Frpc",
"logStarted": "已启动 Frpc {version}",
"logStopped": "已停止 Frpc"
"logStopped": "已停止 Frpc",
"logStartFailed": "启动 Frpc 失败:{message}"
},
"settings": {
"title": "设置",
+36
View File
@@ -87,3 +87,39 @@ export interface FrpDownloadProgress {
/** 附加信息(错误原因等) */
message: string | null
}
/** Frpc 运行状态 */
export interface FrpcStatus {
/** 是否正在运行 */
running: boolean
/** 当前运行的 Frpc 版本号 */
version: string | null
/** frpc 可执行文件路径 */
frpc_path: string | null
/** 本次运行生成的 frpc.toml 路径 */
config_path: string | null
/** frpc 进程 PID */
pid: number | null
}
/** Frpc 实时日志事件(`frpc-log`) */
export interface FrpcLogEvent {
/** frpc 输出中的时间戳(HH:MM:SS,可能为空) */
time: string | null
/** 日志级别:INFO / WARN / ERROR / DEBUG / TRACE */
level: string
/** 来源:stdout / stderr / system */
source: string
/** 日志内容 */
message: string
}
/** Frpc 进程退出事件(`frpc-exit`) */
export interface FrpcExitEvent {
/** 退出原因:stopped(用户主动停止)/ exited(进程自行退出) */
reason: string
/** 退出码(主动终止时可能为空) */
code: number | null
/** 退出时对应的 Frpc 版本号 */
version: string | null
}
+110 -29
View File
@@ -102,12 +102,19 @@
<a-button
type="primary"
:icon="h(PlayCircleOutlined)"
:loading="starting"
:disabled="isRunning || !selectedId"
@click="start"
>
{{ t("dashboard.start") }}
</a-button>
<a-button danger :icon="h(PoweroffOutlined)" :disabled="!isRunning" @click="stop">
<a-button
danger
:icon="h(PoweroffOutlined)"
:loading="stopping"
:disabled="!isRunning"
@click="stop"
>
{{ t("dashboard.stop") }}
</a-button>
</a-space>
@@ -120,7 +127,7 @@
>
<div class="flex items-center gap-2">
<h3 class="text-sm font-semibold">{{ t("dashboard.logs") }}</h3>
<a-tag class="!me-0">{{ t("dashboard.mock") }}</a-tag>
<a-tag v-if="runningVersion" class="!me-0">{{ runningVersion }}</a-tag>
<a-tag :color="statusTagColor" class="!me-0">{{ statusText }}</a-tag>
</div>
@@ -155,14 +162,16 @@ import {
PlayCircleOutlined,
PoweroffOutlined,
} from "@ant-design/icons-vue"
import { message } from "ant-design-vue"
import { listen, type UnlistenFn } from "@tauri-apps/api/event"
import { useI18n } from "vue-i18n"
import { getFrpDownloads } from "@/api/frp"
import { getFrpDownloads, getFrpcStatus, startFrpc, stopFrpc } from "@/api/frp"
import { getServerList } from "@/api/server"
import { getSystemDetails } from "@/api/system"
import { getTunnelList } from "@/api/tunnel"
import { useAppStore } from "@/stores/app"
import { useSystemStats } from "@/composables/useSystemStats"
import type { FrpDownload } from "@/types/frp"
import type { FrpDownload, FrpcExitEvent, FrpcLogEvent } from "@/types/frp"
import type { SystemDetails } from "@/types/system"
import { isTauri } from "@/utils/window"
@@ -210,6 +219,12 @@ const selectedVersion = computed(() => selectedDownload.value?.version ?? "-")
const isRunning = computed(() => appStore.runStatus === "running")
/** 当前运行的 Frpc 版本(用于日志区标签) */
const runningVersion = ref("")
/** 启动 / 停止按钮的 loading 状态 */
const starting = ref(false)
const stopping = ref(false)
type LogLevel = "INFO" | "WARN" | "ERROR" | "DEBUG"
interface LogLine {
@@ -222,8 +237,8 @@ const logs = ref<LogLine[]>([])
const logBox = ref<HTMLElement | null>(null)
/** 追加一行日志并自动滚动到底部(最多保留 500 行) */
function pushLog(level: LogLevel, message: string) {
logs.value.push({ time: dayjs().format("HH:mm:ss"), level, message })
function pushLog(level: LogLevel, message: string, time?: string) {
logs.value.push({ time: time || dayjs().format("HH:mm:ss"), level, message })
if (logs.value.length > 500) {
logs.value.splice(0, logs.value.length - 500)
}
@@ -237,33 +252,76 @@ function clearLogs() {
logs.value = []
}
function start() {
if (isRunning.value) return
appStore.runStatus = "running"
pushLog("INFO", t("dashboard.logStarted", { version: selectedVersion.value }))
function errMsg(e: unknown) {
return e instanceof Error ? e.message : String(e)
}
function stop() {
if (!isRunning.value) return
appStore.runStatus = "stopped"
pushLog("WARN", t("dashboard.logStopped"))
/** 将后端日志级别归一化为 UI 级别 */
function normalizeLevel(level: string): LogLevel {
switch (level.toUpperCase()) {
case "WARN":
case "WARNING":
return "WARN"
case "ERROR":
case "FATAL":
return "ERROR"
case "DEBUG":
case "TRACE":
return "DEBUG"
default:
return "INFO"
}
}
/** 模拟实时日志:运行中定时追加 */
const MOCK_LOGS: { level: LogLevel; message: string }[] = [
{ level: "INFO", message: "login to server success" },
{ level: "INFO", message: "start proxy success" },
{ level: "DEBUG", message: "heartbeat sent to server" },
{ level: "INFO", message: "new connection established" },
{ level: "WARN", message: "connection timeout, retrying..." },
{ level: "DEBUG", message: "connection closed by remote" },
]
/** 后端实时日志事件(frpc-log) */
function onBackendLog(event: FrpcLogEvent) {
pushLog(normalizeLevel(event.level), event.message, event.time ?? undefined)
}
useIntervalFn(() => {
if (!isRunning.value) return
const item = MOCK_LOGS[Math.floor(Math.random() * MOCK_LOGS.length)]
pushLog(item.level, item.message)
}, 1500)
/** 后端进程退出事件(frpc-exit) */
function onBackendExit(event: FrpcExitEvent) {
runningVersion.value = ""
if (event.reason === "stopped") {
appStore.runStatus = "stopped"
return
}
appStore.runStatus = event.code != null && event.code !== 0 ? "error" : "stopped"
}
/** 一键启动:由后端生成 frpc.toml 并拉起独立进程 */
async function start() {
if (isRunning.value || starting.value) return
if (!selectedId.value) return
starting.value = true
try {
const status = await startFrpc(Number(selectedId.value))
appStore.runStatus = "running"
runningVersion.value = status.version ?? selectedVersion.value
} catch (e) {
appStore.runStatus = "error"
runningVersion.value = ""
pushLog("ERROR", t("dashboard.logStartFailed", { message: errMsg(e) }))
message.error(errMsg(e))
} finally {
starting.value = false
}
}
/** 停止 Frpc 服务进程 */
async function stop() {
if (!isRunning.value || stopping.value) return
stopping.value = true
try {
await stopFrpc()
appStore.runStatus = "stopped"
runningVersion.value = ""
} catch (e) {
message.error(errMsg(e))
} finally {
stopping.value = false
}
}
/** 拉取服务器 / 隧道数量 */
async function loadCounts() {
@@ -304,11 +362,34 @@ async function loadFrpDownloads() {
}
}
onMounted(() => {
let unlistenLog: UnlistenFn | null = null
let unlistenExit: UnlistenFn | null = null
onMounted(async () => {
pushLog("INFO", t("dashboard.logReady"))
loadCounts()
loadSystemDetails()
loadFrpDownloads()
if (!isTauri()) return
// 订阅后端实时日志与进程退出事件
unlistenLog = await listen<FrpcLogEvent>("frpc-log", (event) => onBackendLog(event.payload))
unlistenExit = await listen<FrpcExitEvent>("frpc-exit", (event) => onBackendExit(event.payload))
// 同步当前运行状态(避免页面切换后状态丢失)
try {
const status = await getFrpcStatus()
appStore.runStatus = status.running ? "running" : "stopped"
runningVersion.value = status.running ? status.version ?? "" : ""
} catch {
// 读取失败时保持默认状态
}
})
onUnmounted(() => {
unlistenLog?.()
unlistenExit?.()
})
/** 进度条颜色:低=蓝,中=橙,高=红 */