From a6276680cc8ceec724b3a2ae1e2dea15cf00b5c2 Mon Sep 17 00:00:00 2001 From: Yuhang Wu Date: Tue, 13 Oct 2026 17:35:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20Frpc=20=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E5=90=AF=E5=8A=A8=E4=B8=8E=E5=AE=9E=E6=97=B6=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E5=9B=9E=E6=98=BE=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增一键启动/停止 Frpc 的命令及后端进程管理模块,支持生成配置文件、独立进程拉起、日志实时推送与退出事件通知。新增 Frpc 状态与日志类型定义,同步更新前端仪表盘逻辑以展示运行状态与实时日志。新增中英文启动失败提示文案,完善应用退出时自动终止 Frpc 子进程的逻辑。 --- docs/Frpilot开发提示词.md | 12 +- src-tauri/src/commands/mod.rs | 5 +- src-tauri/src/commands/runner.rs | 171 ++++++++++++ src-tauri/src/lib.rs | 20 +- src-tauri/src/models.rs | 39 +++ src-tauri/src/services/config.rs | 241 +++++++++++++++++ src-tauri/src/services/mod.rs | 4 +- src-tauri/src/services/runner.rs | 445 +++++++++++++++++++++++++++++++ src/api/frp.ts | 27 +- src/i18n/locales/en-US.json | 3 +- src/i18n/locales/zh-CN.json | 3 +- src/types/frp.ts | 36 +++ src/views/Dashboard.vue | 139 ++++++++-- 13 files changed, 1105 insertions(+), 40 deletions(-) create mode 100644 src-tauri/src/commands/runner.rs create mode 100644 src-tauri/src/services/config.rs create mode 100644 src-tauri/src/services/runner.rs diff --git a/docs/Frpilot开发提示词.md b/docs/Frpilot开发提示词.md index 795bcae..4d26deb 100644 --- a/docs/Frpilot开发提示词.md +++ b/docs/Frpilot开发提示词.md @@ -94,4 +94,14 @@ Layout布局content区域的磨砂玻璃卡片圆角样式我要改动一下: - 点击卡片上的下载按钮,卡片下方显示一个下载进度的进度条 - 展示获取到的frp数据使用卡片展示,卡片上有对应按钮可以点击进行下载,卡片展示的信息包含frp版本、大小、下载次数、发布日期。在有一个按钮,点击复制下载链接,便于去浏览器等环境下载。再加一个按钮删除已下载的版本 -开发过程中使用到的组件尽可能全部使用Antdv库里面的,另外我引入的有tailwindcss原子化组件、auto-import和components依赖可以简化引入,开发之前尽可能先阅读一下package.json,看一下有哪些依赖可以简化代码开发 \ No newline at end of file +开发过程中使用到的组件尽可能全部使用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运行的黑色窗口 \ No newline at end of file diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index d4de695..5f002c0 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,4 +1,5 @@ -pub mod tunnel; +pub mod frp; +pub mod runner; pub mod server; pub mod system; -pub mod frp; \ No newline at end of file +pub mod tunnel; diff --git a/src-tauri/src/commands/runner.rs b/src-tauri/src/commands/runner.rs new file mode 100644 index 0000000..7f9b7f0 --- /dev/null +++ b/src-tauri/src/commands/runner.rs @@ -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 { + 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, 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, 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, String> { + match runner.stop(&app) { + Ok(status) => Ok(ApiResponse::success(status)), + Err(e) => Ok(ApiResponse::error(500, &e)), + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ce2c054..300f05f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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::().shutdown(); + } + }); } diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index f1cea17..ae70298 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -307,3 +307,42 @@ pub struct SystemDetails { pub ips: Vec, } +/// Frpc 运行状态(仪表盘快速操作区 / 侧边栏状态点使用) +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct FrpcStatus { + /// 是否正在运行 + pub running: bool, + /// 当前运行的 Frpc 版本号 + pub version: Option, + /// frpc 可执行文件路径 + pub frpc_path: Option, + /// 本次运行生成的 frpc.toml 路径 + pub config_path: Option, + /// frpc 进程 PID + pub pid: Option, +} + +/// Frpc 实时日志事件(通过 `frpc-log` 事件推送) +#[derive(Debug, Clone, Serialize)] +pub struct FrpcLogEvent { + /// frpc 输出中的时间戳(HH:MM:SS,可能为空) + pub time: Option, + /// 日志级别: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, + /// 退出时对应的 Frpc 版本号 + pub version: Option, +} + diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs new file mode 100644 index 0000000..c573750 --- /dev/null +++ b/src-tauri/src/services/config.rs @@ -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) -> Option { + value + .as_ref() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +/// 英文逗号分隔字符串 → 字符串数组(空返回 None) +fn split_list(value: &Option) -> Option> { + let list: Vec = 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 = 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); + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 17b0a41..0290153 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,2 +1,4 @@ +pub mod config; pub mod db; -pub mod system; \ No newline at end of file +pub mod runner; +pub mod system; diff --git a/src-tauri/src/services/runner.rs b/src-tauri/src/services/runner.rs new file mode 100644 index 0000000..840ece6 --- /dev/null +++ b/src-tauri/src/services/runner.rs @@ -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>>, + counter: AtomicU64, +} + +impl FrpcRunner { + fn lock(&self) -> MutexGuard<'_, Option> { + // 中毒的锁直接恢复内部数据,避免一次 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 { + 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 { + 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(app: AppHandle, pipe: R, source: &'static str) { + thread::spawn(move || { + let mut reader = BufReader::new(pipe); + let mut buf: Vec = 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>>, 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 { + 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"); + } +} diff --git a/src/api/frp.ts b/src/api/frp.ts index 2c2bd84..344a333 100644 --- a/src/api/frp.ts +++ b/src/api/frp.ts @@ -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("import_frp") } + +/** + * 一键启动 Frpc。 + * + * 后端会依据所选版本、已启用的服务器与隧道生成 frpc.toml 并拉起独立进程, + * 随后通过 `frpc-log` / `frpc-exit` 事件回传运行日志与退出信息。 + */ +export function startFrpc(downloadId: number) { + return call("start_frpc", { downloadId }) +} + +/** 停止 Frpc 服务进程 */ +export function stopFrpc() { + return call("stop_frpc") +} + +/** 获取 Frpc 当前运行状态 */ +export function getFrpcStatus() { + return call("get_frpc_status") +} diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index f65fa1c..901c8b9 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -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", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 94dc77d..83540d6 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -50,7 +50,8 @@ "logEmpty": "暂无日志,启动 Frpc 后将在此显示实时日志", "logReady": "Frpilot 已就绪,等待启动 Frpc", "logStarted": "已启动 Frpc {version}", - "logStopped": "已停止 Frpc" + "logStopped": "已停止 Frpc", + "logStartFailed": "启动 Frpc 失败:{message}" }, "settings": { "title": "设置", diff --git a/src/types/frp.ts b/src/types/frp.ts index 4d0ba21..b16e4f2 100644 --- a/src/types/frp.ts +++ b/src/types/frp.ts @@ -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 +} diff --git a/src/views/Dashboard.vue b/src/views/Dashboard.vue index 19ae2a2..7ea689f 100644 --- a/src/views/Dashboard.vue +++ b/src/views/Dashboard.vue @@ -102,12 +102,19 @@ {{ t("dashboard.start") }} - + {{ t("dashboard.stop") }} @@ -120,7 +127,7 @@ >

{{ t("dashboard.logs") }}

- {{ t("dashboard.mock") }} + {{ runningVersion }} {{ statusText }}
@@ -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([]) const logBox = ref(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("frpc-log", (event) => onBackendLog(event.payload)) + unlistenExit = await listen("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?.() }) /** 进度条颜色:低=蓝,中=橙,高=红 */