feat: 新增 Frpc 一键启动与实时日志回显功能
新增一键启动/停止 Frpc 的命令及后端进程管理模块,支持生成配置文件、独立进程拉起、日志实时推送与退出事件通知。新增 Frpc 状态与日志类型定义,同步更新前端仪表盘逻辑以展示运行状态与实时日志。新增中英文启动失败提示文案,完善应用退出时自动终止 Frpc 子进程的逻辑。
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod system;
|
||||
pub mod runner;
|
||||
pub mod system;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user