mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-31 05:10:14 +08:00
541 lines
17 KiB
Rust
541 lines
17 KiB
Rust
/// 扩展工具命令(cftunnel + ClawApp)
|
||
use serde_json::Value;
|
||
#[cfg(target_os = "windows")]
|
||
use std::os::windows::process::CommandExt;
|
||
use std::process::Command;
|
||
|
||
/// 按第一个冒号(半角 ':' 或全角 ':')分割,返回冒号之后的内容(trim 后)
|
||
fn split_after_colon(s: &str) -> &str {
|
||
// 半角冒号是 1 字节,全角冒号是 3 字节(UTF-8: \xef\xbc\x9a)
|
||
if let Some(pos) = s.find(':') {
|
||
return s[pos + 1..].trim();
|
||
}
|
||
if let Some(pos) = s.find(':') {
|
||
return s[pos + ':'.len_utf8()..].trim();
|
||
}
|
||
""
|
||
}
|
||
|
||
/// 解析 cftunnel status 输出
|
||
fn parse_cftunnel_status(output: &str) -> serde_json::Map<String, Value> {
|
||
let mut map = serde_json::Map::new();
|
||
for line in output.lines() {
|
||
let line = line.trim();
|
||
if line.starts_with("隧道:") || line.starts_with("隧道:") {
|
||
let rest = split_after_colon(line);
|
||
let name = rest.split('(').next().unwrap_or(rest).trim();
|
||
map.insert("tunnel_name".into(), Value::String(name.to_string()));
|
||
} else if line.starts_with("状态:") || line.starts_with("状态:") {
|
||
let rest = split_after_colon(line);
|
||
let running = rest.contains("运行中");
|
||
map.insert("running".into(), Value::Bool(running));
|
||
// 匹配英文和全角 'PID:' / 'PID:'
|
||
let pid_rest = rest
|
||
.split("PID:")
|
||
.nth(1)
|
||
.or_else(|| rest.split("PID:").nth(1));
|
||
if let Some(pid_str) = pid_rest {
|
||
let pid = pid_str.trim().trim_end_matches(')').trim();
|
||
if let Ok(p) = pid.parse::<u64>() {
|
||
map.insert("pid".into(), Value::Number(p.into()));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
map
|
||
}
|
||
|
||
/// 解析 cftunnel list 输出为路由数组
|
||
fn parse_cftunnel_routes(output: &str) -> Vec<Value> {
|
||
let mut routes = Vec::new();
|
||
for line in output.lines() {
|
||
let line = line.trim();
|
||
if line.is_empty() || line.starts_with("名称") || line.starts_with("---") {
|
||
continue;
|
||
}
|
||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||
if parts.len() >= 3 {
|
||
let mut obj = serde_json::Map::new();
|
||
obj.insert("name".into(), Value::String(parts[0].to_string()));
|
||
obj.insert("domain".into(), Value::String(parts[1].to_string()));
|
||
obj.insert("service".into(), Value::String(parts[2].to_string()));
|
||
routes.push(Value::Object(obj));
|
||
}
|
||
}
|
||
routes
|
||
}
|
||
|
||
/// 查找 cftunnel 可执行文件路径
|
||
fn cftunnel_bin() -> String {
|
||
let home = dirs::home_dir().unwrap_or_default();
|
||
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
// Windows: 查找 cftunnel.exe
|
||
let candidates = [
|
||
home.join("bin").join("cftunnel.exe"),
|
||
home.join(".cftunnel").join("cftunnel.exe"),
|
||
home.join("AppData")
|
||
.join("Local")
|
||
.join("cftunnel")
|
||
.join("cftunnel.exe"),
|
||
];
|
||
for path in &candidates {
|
||
if path.exists() {
|
||
return path.to_string_lossy().to_string();
|
||
}
|
||
}
|
||
"cftunnel.exe".to_string()
|
||
}
|
||
|
||
#[cfg(not(target_os = "windows"))]
|
||
{
|
||
let user_bin = home.join("bin").join("cftunnel");
|
||
if user_bin.exists() {
|
||
return user_bin.to_string_lossy().to_string();
|
||
}
|
||
"cftunnel".to_string()
|
||
}
|
||
}
|
||
|
||
/// 创建 cftunnel 命令,Windows 上自动隐藏窗口
|
||
fn cftunnel_cmd(bin: &str) -> Command {
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
let mut cmd = Command::new(bin);
|
||
cmd.creation_flags(0x08000000);
|
||
cmd
|
||
}
|
||
#[cfg(not(target_os = "windows"))]
|
||
{
|
||
Command::new(bin)
|
||
}
|
||
}
|
||
|
||
/// 检测 cftunnel 进程是否在运行(平台相关的补充检测)
|
||
fn check_cftunnel_process() -> Option<(Option<u64>, bool)> {
|
||
#[cfg(target_os = "macos")]
|
||
{
|
||
// macOS: 通过 launchctl 检测
|
||
let output = Command::new("launchctl").args(["list"]).output().ok()?;
|
||
let text = String::from_utf8_lossy(&output.stdout);
|
||
for line in text.lines() {
|
||
if line.contains("com.cftunnel") {
|
||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||
if parts.len() >= 3 {
|
||
let pid = parts[0].parse::<u64>().ok();
|
||
let running = pid.is_some();
|
||
return Some((pid, running));
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
// Windows: 通过 tasklist 检测 cftunnel.exe 进程
|
||
let mut cmd = Command::new("tasklist");
|
||
cmd.args(["/FI", "IMAGENAME eq cftunnel.exe", "/FO", "CSV", "/NH"]);
|
||
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
||
let output = cmd.output().ok()?;
|
||
let text = String::from_utf8_lossy(&output.stdout);
|
||
if text.contains("cftunnel.exe") {
|
||
// 尝试提取 PID(CSV 格式: "cftunnel.exe","1234",...)
|
||
let pid = text
|
||
.lines()
|
||
.next()
|
||
.and_then(|line| line.split(',').nth(1))
|
||
.and_then(|s| s.trim_matches('"').parse::<u64>().ok());
|
||
return Some((pid, true));
|
||
}
|
||
None
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
{
|
||
// Linux: 通过 pgrep 检测
|
||
let output = Command::new("pgrep")
|
||
.args(["-f", "cftunnel"])
|
||
.output()
|
||
.ok()?;
|
||
if output.status.success() {
|
||
let text = String::from_utf8_lossy(&output.stdout);
|
||
let pid = text
|
||
.lines()
|
||
.next()
|
||
.and_then(|s| s.trim().parse::<u64>().ok());
|
||
return Some((pid, true));
|
||
}
|
||
None
|
||
}
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn get_cftunnel_status() -> Result<Value, String> {
|
||
let bin = cftunnel_bin();
|
||
let mut result = serde_json::Map::new();
|
||
|
||
// 快速路径:如果是 fallback 名称且不在已知路径,直接返回未安装
|
||
#[cfg(target_os = "windows")]
|
||
if bin == "cftunnel.exe" {
|
||
result.insert("installed".into(), Value::Bool(false));
|
||
return Ok(Value::Object(result));
|
||
}
|
||
#[cfg(not(target_os = "windows"))]
|
||
if bin == "cftunnel" {
|
||
result.insert("installed".into(), Value::Bool(false));
|
||
return Ok(Value::Object(result));
|
||
}
|
||
|
||
// 二进制存在即已安装,跳过 cftunnel version 调用
|
||
result.insert("installed".into(), Value::Bool(true));
|
||
|
||
// 获取状态(单次 CLI 调用)
|
||
if let Ok(out) = cftunnel_cmd(&bin).arg("status").output() {
|
||
let text = String::from_utf8_lossy(&out.stdout);
|
||
let status = parse_cftunnel_status(&text);
|
||
// 从 status 输出中提取版本号(如果有)
|
||
for (k, v) in status {
|
||
result.insert(k, v);
|
||
}
|
||
}
|
||
|
||
// 仅当 status 报未运行时才做进程检测补充
|
||
let reported_running = result
|
||
.get("running")
|
||
.and_then(|v| v.as_bool())
|
||
.unwrap_or(false);
|
||
if !reported_running {
|
||
if let Some((pid, running)) = check_cftunnel_process() {
|
||
if running {
|
||
result.insert("running".into(), Value::Bool(true));
|
||
if let Some(p) = pid {
|
||
result.insert("pid".into(), Value::Number(p.into()));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 获取路由列表
|
||
if let Ok(out) = cftunnel_cmd(&bin).arg("list").output() {
|
||
let text = String::from_utf8_lossy(&out.stdout);
|
||
let routes = parse_cftunnel_routes(&text);
|
||
result.insert("routes".into(), Value::Array(routes));
|
||
}
|
||
|
||
Ok(Value::Object(result))
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn cftunnel_action(action: String) -> Result<(), String> {
|
||
let bin = cftunnel_bin();
|
||
let args = match action.as_str() {
|
||
"up" => vec!["up"],
|
||
"down" => vec!["down"],
|
||
"restart" => vec!["restart"],
|
||
_ => return Err(format!("不支持的操作: {action}")),
|
||
};
|
||
let output = cftunnel_cmd(&bin)
|
||
.args(&args)
|
||
.output()
|
||
.map_err(|e| format!("执行 cftunnel {action} 失败: {e}"))?;
|
||
|
||
if !output.status.success() {
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
return Err(format!("cftunnel {action} 失败: {stderr}"));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub fn get_cftunnel_logs(lines: Option<u32>) -> Result<String, String> {
|
||
let bin = cftunnel_bin();
|
||
let n = lines.unwrap_or(20).to_string();
|
||
let output = cftunnel_cmd(&bin)
|
||
.args(["logs", "--tail", &n])
|
||
.output()
|
||
.map_err(|e| format!("读取 cftunnel 日志失败: {e}"))?;
|
||
|
||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||
}
|
||
|
||
/// 检测 ClawApp 状态(端口 3210)
|
||
/// 使用 TcpStream 跨平台检测端口,macOS 额外用 lsof 获取 PID
|
||
#[tauri::command]
|
||
pub fn get_clawapp_status() -> Result<Value, String> {
|
||
let mut result = serde_json::Map::new();
|
||
|
||
// 检测是否已安装(检查 npm 全局包)
|
||
let installed = check_clawapp_installed();
|
||
result.insert("installed".into(), Value::Bool(installed));
|
||
|
||
// 跨平台方式:尝试连接端口检测是否在运行
|
||
let running = std::net::TcpStream::connect_timeout(
|
||
&"127.0.0.1:3210".parse().unwrap(),
|
||
std::time::Duration::from_millis(150),
|
||
)
|
||
.is_ok();
|
||
|
||
result.insert("running".into(), Value::Bool(running));
|
||
|
||
// macOS: 用 lsof 获取 PID
|
||
#[cfg(target_os = "macos")]
|
||
if running {
|
||
if let Ok(out) = Command::new("lsof")
|
||
.args(["-i", ":3210", "-P", "-t"])
|
||
.output()
|
||
{
|
||
let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||
if let Ok(pid) = text.lines().next().unwrap_or("").parse::<u64>() {
|
||
result.insert("pid".into(), Value::Number(pid.into()));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Windows: TCP 探测已足够,不再 spawn netstat 取 PID
|
||
#[cfg(target_os = "windows")]
|
||
{}
|
||
|
||
result.insert("port".into(), Value::Number(3210.into()));
|
||
result.insert("url".into(), Value::String("http://localhost:3210".into()));
|
||
Ok(Value::Object(result))
|
||
}
|
||
|
||
/// 检测 ClawApp 是否已安装
|
||
fn check_clawapp_installed() -> bool {
|
||
#[cfg(target_os = "windows")]
|
||
{
|
||
let mut cmd = Command::new("cmd");
|
||
cmd.args(["/c", "npm", "list", "-g", "clawapp"]);
|
||
cmd.creation_flags(0x08000000);
|
||
if let Ok(out) = cmd.output() {
|
||
return out.status.success();
|
||
}
|
||
false
|
||
}
|
||
#[cfg(not(target_os = "windows"))]
|
||
{
|
||
if let Ok(out) = Command::new("npm").args(["list", "-g", "clawapp"]).output() {
|
||
return out.status.success();
|
||
}
|
||
false
|
||
}
|
||
}
|
||
|
||
/// 一键安装 cftunnel
|
||
/// macOS/Linux: bash 脚本安装
|
||
/// Windows: PowerShell 下载安装
|
||
#[tauri::command]
|
||
pub async fn install_cftunnel(app: tauri::AppHandle) -> Result<String, String> {
|
||
use std::io::{BufRead, BufReader};
|
||
use std::process::Stdio;
|
||
use tauri::Emitter;
|
||
|
||
let _ = app.emit("install-log", "开始安装 cftunnel...");
|
||
let _ = app.emit("install-progress", 10);
|
||
|
||
let _ = app.emit("install-log", "下载安装脚本...");
|
||
let _ = app.emit("install-progress", 30);
|
||
|
||
#[cfg(not(target_os = "windows"))]
|
||
let mut child = {
|
||
let install_script = r#"
|
||
#!/bin/bash
|
||
set -e
|
||
cd /tmp
|
||
echo "下载 cftunnel..."
|
||
curl -fsSL https://raw.githubusercontent.com/qingchencloud/cftunnel/main/install.sh -o cftunnel-install.sh
|
||
chmod +x cftunnel-install.sh
|
||
echo "执行安装..."
|
||
./cftunnel-install.sh
|
||
echo "安装完成"
|
||
"#;
|
||
Command::new("bash")
|
||
.arg("-c")
|
||
.arg(install_script)
|
||
.stdout(Stdio::piped())
|
||
.stderr(Stdio::piped())
|
||
.spawn()
|
||
.map_err(|e| format!("启动安装进程失败: {e}"))?
|
||
};
|
||
|
||
#[cfg(target_os = "windows")]
|
||
let mut child = {
|
||
let install_script = r#"
|
||
$ErrorActionPreference = 'Stop'
|
||
Write-Output '通过官方安装脚本安装 cftunnel...'
|
||
$tmp = Join-Path $env:TEMP 'install-cftunnel.ps1'
|
||
Write-Output '下载安装脚本到临时文件...'
|
||
Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/qingchencloud/cftunnel/main/install.ps1' -OutFile $tmp -UseBasicParsing
|
||
Write-Output '执行安装脚本...'
|
||
& $tmp
|
||
Remove-Item $tmp -ErrorAction SilentlyContinue
|
||
Write-Output '安装完成'
|
||
"#;
|
||
// 使用完整路径调用 PowerShell,避免 MSYS2/Git Bash 环境下找不到
|
||
let ps_path = std::env::var("SystemRoot")
|
||
.map(|root| {
|
||
format!(
|
||
"{}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
|
||
root
|
||
)
|
||
})
|
||
.unwrap_or_else(|_| "powershell.exe".to_string());
|
||
Command::new(&ps_path)
|
||
.args([
|
||
"-NoProfile",
|
||
"-ExecutionPolicy",
|
||
"Bypass",
|
||
"-Command",
|
||
install_script,
|
||
])
|
||
.stdout(Stdio::piped())
|
||
.stderr(Stdio::piped())
|
||
.spawn()
|
||
.map_err(|e| format!("启动安装进程失败: {e}"))?
|
||
};
|
||
|
||
let stderr = child.stderr.take();
|
||
let stdout = child.stdout.take();
|
||
|
||
let app2 = app.clone();
|
||
let handle = std::thread::spawn(move || {
|
||
if let Some(pipe) = stderr {
|
||
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
|
||
let _ = app2.emit("install-log", &line);
|
||
}
|
||
}
|
||
});
|
||
|
||
let mut progress = 40;
|
||
if let Some(pipe) = stdout {
|
||
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
|
||
let _ = app.emit("install-log", &line);
|
||
if progress < 90 {
|
||
progress += 5;
|
||
let _ = app.emit("install-progress", progress);
|
||
}
|
||
}
|
||
}
|
||
|
||
let _ = handle.join();
|
||
let _ = app.emit("install-progress", 95);
|
||
|
||
let status = child.wait().map_err(|e| format!("等待安装进程失败: {e}"))?;
|
||
let _ = app.emit("install-progress", 100);
|
||
|
||
if !status.success() {
|
||
let _ = app.emit("install-log", "❌ 安装失败");
|
||
return Err("安装失败,请查看日志".into());
|
||
}
|
||
|
||
let _ = app.emit("install-log", "✅ cftunnel 安装成功");
|
||
Ok("安装成功".into())
|
||
}
|
||
|
||
/// 一键安装 ClawApp(通过官方安装脚本)
|
||
#[tauri::command]
|
||
pub async fn install_clawapp(app: tauri::AppHandle) -> Result<String, String> {
|
||
use std::io::{BufRead, BufReader};
|
||
use std::process::Stdio;
|
||
use tauri::Emitter;
|
||
|
||
let _ = app.emit("install-log", "开始安装 ClawApp...");
|
||
let _ = app.emit("install-progress", 10);
|
||
|
||
let _ = app.emit("install-log", "下载安装脚本...");
|
||
let _ = app.emit("install-progress", 30);
|
||
|
||
#[cfg(not(target_os = "windows"))]
|
||
let mut child = {
|
||
let install_script = r#"
|
||
#!/bin/bash
|
||
set -e
|
||
echo "通过官方安装脚本安装 ClawApp..."
|
||
curl -fsSL https://raw.githubusercontent.com/qingchencloud/clawapp/main/install.sh | bash
|
||
echo "安装完成"
|
||
"#;
|
||
Command::new("bash")
|
||
.arg("-c")
|
||
.arg(install_script)
|
||
.stdout(Stdio::piped())
|
||
.stderr(Stdio::piped())
|
||
.spawn()
|
||
.map_err(|e| format!("启动安装进程失败: {e}"))?
|
||
};
|
||
|
||
#[cfg(target_os = "windows")]
|
||
let mut child = {
|
||
let install_script = r#"
|
||
$ErrorActionPreference = 'Stop'
|
||
Write-Output '通过官方安装脚本安装 ClawApp...'
|
||
$tmp = Join-Path $env:TEMP 'install-clawapp.ps1'
|
||
Write-Output '下载安装脚本到临时文件...'
|
||
Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/qingchencloud/clawapp/main/install.ps1' -OutFile $tmp -UseBasicParsing
|
||
Write-Output '执行安装脚本...'
|
||
& $tmp -Auto
|
||
Remove-Item $tmp -ErrorAction SilentlyContinue
|
||
Write-Output '安装完成'
|
||
"#;
|
||
let ps_path = std::env::var("SystemRoot")
|
||
.map(|root| {
|
||
format!(
|
||
"{}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
|
||
root
|
||
)
|
||
})
|
||
.unwrap_or_else(|_| "powershell.exe".to_string());
|
||
let mut cmd = Command::new(&ps_path);
|
||
cmd.args([
|
||
"-NoProfile",
|
||
"-ExecutionPolicy",
|
||
"Bypass",
|
||
"-Command",
|
||
install_script,
|
||
]);
|
||
cmd.creation_flags(0x08000000);
|
||
cmd.stdout(Stdio::piped())
|
||
.stderr(Stdio::piped())
|
||
.spawn()
|
||
.map_err(|e| format!("启动安装进程失败: {e}"))?
|
||
};
|
||
|
||
let stderr = child.stderr.take();
|
||
let stdout = child.stdout.take();
|
||
|
||
let app2 = app.clone();
|
||
let handle = std::thread::spawn(move || {
|
||
if let Some(pipe) = stderr {
|
||
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
|
||
let _ = app2.emit("install-log", &line);
|
||
}
|
||
}
|
||
});
|
||
|
||
let mut progress = 40;
|
||
if let Some(pipe) = stdout {
|
||
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
|
||
let _ = app.emit("install-log", &line);
|
||
if progress < 90 {
|
||
progress += 5;
|
||
let _ = app.emit("install-progress", progress);
|
||
}
|
||
}
|
||
}
|
||
|
||
let _ = handle.join();
|
||
let _ = app.emit("install-progress", 95);
|
||
|
||
let status = child.wait().map_err(|e| format!("等待安装进程失败: {e}"))?;
|
||
let _ = app.emit("install-progress", 100);
|
||
|
||
if !status.success() {
|
||
let _ = app.emit("install-log", "❌ 安装失败");
|
||
return Err("安装失败,请查看日志".into());
|
||
}
|
||
|
||
let _ = app.emit("install-log", "✅ ClawApp 安装成功");
|
||
Ok("安装成功".into())
|
||
}
|