mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-28 03:40:09 +08:00
- service.rs: macOS 平台两处 manual_strip,改用 strip_prefix - utils.rs: openclaw_command 在 macOS 未被调用,加 #[allow(dead_code)],函数体改用全路径 std::process::Command::new 避免 unused_imports
488 lines
16 KiB
Rust
488 lines
16 KiB
Rust
/// 服务管理命令
|
||
/// macOS: launchctl + LaunchAgents plist
|
||
/// Windows: openclaw CLI + 进程检测
|
||
use std::collections::HashMap;
|
||
|
||
use crate::models::types::ServiceStatus;
|
||
|
||
/// OpenClaw 官方服务的友好名称映射
|
||
fn description_map() -> HashMap<&'static str, &'static str> {
|
||
HashMap::from([
|
||
("ai.openclaw.gateway", "OpenClaw Gateway"),
|
||
("ai.openclaw.node", "OpenClaw Node Host"),
|
||
])
|
||
}
|
||
|
||
// ===== macOS 实现 =====
|
||
|
||
#[cfg(target_os = "macos")]
|
||
mod platform {
|
||
use std::fs;
|
||
use std::process::Command;
|
||
|
||
const OPENCLAW_PREFIXES: &[&str] = &["ai.openclaw."];
|
||
|
||
/// macOS 上 CLI 是否安装(检查 plist 是否存在即可)
|
||
pub fn is_cli_installed() -> bool {
|
||
true // macOS 通过 plist 扫描,不依赖 CLI 检测
|
||
}
|
||
|
||
pub fn current_uid() -> Result<u32, String> {
|
||
let output = Command::new("id")
|
||
.arg("-u")
|
||
.output()
|
||
.map_err(|e| format!("获取 UID 失败: {e}"))?;
|
||
let uid_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||
uid_str
|
||
.parse::<u32>()
|
||
.map_err(|e| format!("解析 UID 失败: {e}"))
|
||
}
|
||
|
||
/// 动态扫描 LaunchAgents 目录,只返回 OpenClaw 核心服务
|
||
pub fn scan_service_labels() -> Vec<String> {
|
||
let home = dirs::home_dir().unwrap_or_default();
|
||
let agents_dir = home.join("Library/LaunchAgents");
|
||
let mut labels = Vec::new();
|
||
|
||
if let Ok(entries) = fs::read_dir(&agents_dir) {
|
||
for entry in entries.flatten() {
|
||
let name = entry.file_name().to_string_lossy().to_string();
|
||
if !name.ends_with(".plist") {
|
||
continue;
|
||
}
|
||
let label = name.trim_end_matches(".plist");
|
||
if OPENCLAW_PREFIXES.iter().any(|p| label.starts_with(p)) {
|
||
labels.push(label.to_string());
|
||
}
|
||
}
|
||
}
|
||
labels.sort();
|
||
labels
|
||
}
|
||
|
||
fn plist_path(label: &str) -> String {
|
||
let home = dirs::home_dir().unwrap_or_default();
|
||
format!("{}/Library/LaunchAgents/{}.plist", home.display(), label)
|
||
}
|
||
|
||
/// 用 launchctl print 检测单个服务状态,返回 (running, pid)
|
||
pub fn check_service_status(uid: u32, label: &str) -> (bool, Option<u32>) {
|
||
let target = format!("gui/{}/{}", uid, label);
|
||
let output = Command::new("launchctl").args(["print", &target]).output();
|
||
|
||
let Ok(out) = output else {
|
||
return (false, None);
|
||
};
|
||
|
||
if !out.status.success() {
|
||
return (false, None);
|
||
}
|
||
|
||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||
let mut pid: Option<u32> = None;
|
||
let mut running = false;
|
||
|
||
for line in stdout.lines() {
|
||
if !line.starts_with('\t') || line.starts_with("\t\t") {
|
||
continue;
|
||
}
|
||
let trimmed = line.trim();
|
||
if let Some(rest) = trimmed.strip_prefix("pid = ") {
|
||
if let Ok(p) = rest.trim().parse::<u32>() {
|
||
pid = Some(p);
|
||
}
|
||
}
|
||
if let Some(rest) = trimmed.strip_prefix("state = ") {
|
||
running = rest.trim() == "running";
|
||
}
|
||
}
|
||
|
||
(running, pid)
|
||
}
|
||
|
||
pub fn start_service_impl(label: &str) -> Result<(), String> {
|
||
let uid = current_uid()?;
|
||
let path = plist_path(label);
|
||
let domain_target = format!("gui/{}", uid);
|
||
let service_target = format!("gui/{}/{}", uid, label);
|
||
|
||
let bootstrap_out = Command::new("launchctl")
|
||
.args(["bootstrap", &domain_target, &path])
|
||
.output()
|
||
.map_err(|e| format!("bootstrap 失败: {e}"))?;
|
||
|
||
if !bootstrap_out.status.success() {
|
||
let stderr = String::from_utf8_lossy(&bootstrap_out.stderr);
|
||
if !stderr.contains("already bootstrapped") && !stderr.trim().is_empty() {
|
||
return Err(format!("启动 {label} 失败: {stderr}"));
|
||
}
|
||
}
|
||
|
||
let kickstart_out = Command::new("launchctl")
|
||
.args(["kickstart", &service_target])
|
||
.output()
|
||
.map_err(|e| format!("kickstart 失败: {e}"))?;
|
||
|
||
if !kickstart_out.status.success() {
|
||
let stderr = String::from_utf8_lossy(&kickstart_out.stderr);
|
||
if !stderr.trim().is_empty() {
|
||
return Err(format!("kickstart {label} 失败: {stderr}"));
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
pub fn stop_service_impl(label: &str) -> Result<(), String> {
|
||
let uid = current_uid()?;
|
||
let service_target = format!("gui/{}/{}", uid, label);
|
||
|
||
let output = Command::new("launchctl")
|
||
.args(["bootout", &service_target])
|
||
.output()
|
||
.map_err(|e| format!("停止失败: {e}"))?;
|
||
|
||
if !output.status.success() {
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
if !stderr.contains("No such process")
|
||
&& !stderr.contains("Could not find specified service")
|
||
&& !stderr.trim().is_empty()
|
||
{
|
||
return Err(format!("停止 {label} 失败: {stderr}"));
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
pub fn restart_service_impl(label: &str) -> Result<(), String> {
|
||
let uid = current_uid()?;
|
||
let path = plist_path(label);
|
||
let domain_target = format!("gui/{}", uid);
|
||
let service_target = format!("gui/{}/{}", uid, label);
|
||
|
||
let _ = Command::new("launchctl")
|
||
.args(["bootout", &service_target])
|
||
.output();
|
||
|
||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
|
||
loop {
|
||
let (running, _) = check_service_status(uid, label);
|
||
if !running || std::time::Instant::now() >= deadline {
|
||
break;
|
||
}
|
||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||
}
|
||
|
||
let bootstrap_out = Command::new("launchctl")
|
||
.args(["bootstrap", &domain_target, &path])
|
||
.output()
|
||
.map_err(|e| format!("重启 bootstrap 失败: {e}"))?;
|
||
|
||
if !bootstrap_out.status.success() {
|
||
let stderr = String::from_utf8_lossy(&bootstrap_out.stderr);
|
||
if !stderr.contains("already bootstrapped") && !stderr.trim().is_empty() {
|
||
return Err(format!("重启 {label} 失败 (bootstrap): {stderr}"));
|
||
}
|
||
}
|
||
|
||
let kickstart_out = Command::new("launchctl")
|
||
.args(["kickstart", "-k", &service_target])
|
||
.output()
|
||
.map_err(|e| format!("重启 kickstart 失败: {e}"))?;
|
||
|
||
if !kickstart_out.status.success() {
|
||
let stderr = String::from_utf8_lossy(&kickstart_out.stderr);
|
||
if !stderr.trim().is_empty() {
|
||
return Err(format!("重启 {label} 失败 (kickstart): {stderr}"));
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
// ===== Windows 实现 =====
|
||
|
||
#[cfg(target_os = "windows")]
|
||
mod platform {
|
||
use tokio::process::Command as TokioCommand;
|
||
|
||
/// Windows 不需要 UID
|
||
pub fn current_uid() -> Result<u32, String> {
|
||
Ok(0)
|
||
}
|
||
|
||
/// 检测 openclaw CLI 是否已安装(文件系统检测,避免 spawn 进程)
|
||
pub fn is_cli_installed() -> bool {
|
||
if let Ok(appdata) = std::env::var("APPDATA") {
|
||
let cmd_path = std::path::Path::new(&appdata)
|
||
.join("npm")
|
||
.join("openclaw.cmd");
|
||
if cmd_path.exists() {
|
||
return true;
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
/// Windows 上始终返回 Gateway 标签(不管 CLI 是否安装)
|
||
pub fn scan_service_labels() -> Vec<String> {
|
||
vec!["ai.openclaw.gateway".to_string()]
|
||
}
|
||
|
||
/// 从 openclaw.json 读取 gateway 端口,fallback 到 18789
|
||
fn read_gateway_port() -> u16 {
|
||
let config_path = crate::commands::openclaw_dir().join("openclaw.json");
|
||
if let Ok(content) = std::fs::read_to_string(&config_path) {
|
||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
|
||
if let Some(port) = val
|
||
.get("gateway")
|
||
.and_then(|g| g.get("port"))
|
||
.and_then(|p| p.as_u64())
|
||
{
|
||
if port > 0 && port < 65536 {
|
||
return port as u16;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
18789
|
||
}
|
||
|
||
/// 通过端口探测检测 Gateway 状态
|
||
pub fn check_service_status(_uid: u32, _label: &str) -> (bool, Option<u32>) {
|
||
let port = read_gateway_port();
|
||
let addr = format!("127.0.0.1:{port}");
|
||
match std::net::TcpStream::connect_timeout(
|
||
&addr
|
||
.parse()
|
||
.unwrap_or_else(|_| "127.0.0.1:18789".parse().unwrap()),
|
||
std::time::Duration::from_millis(150),
|
||
) {
|
||
Ok(_) => (true, None),
|
||
Err(_) => (false, None),
|
||
}
|
||
}
|
||
|
||
/// 以前台模式 spawn Gateway(不需要管理员权限)
|
||
pub async fn start_service_impl(_label: &str) -> Result<(), String> {
|
||
if !is_cli_installed() {
|
||
return Err(
|
||
"openclaw CLI 未安装,请先通过 npm install -g @qingchencloud/openclaw-zh 安装"
|
||
.into(),
|
||
);
|
||
}
|
||
if check_service_status(0, "").0 {
|
||
return Ok(());
|
||
}
|
||
|
||
let log_dir = dirs::home_dir()
|
||
.unwrap_or_default()
|
||
.join(".openclaw")
|
||
.join("logs");
|
||
std::fs::create_dir_all(&log_dir).ok();
|
||
|
||
let stdout_log = std::fs::OpenOptions::new()
|
||
.create(true)
|
||
.append(true)
|
||
.open(log_dir.join("gateway.log"))
|
||
.map_err(|e| format!("创建日志文件失败: {e}"))?;
|
||
|
||
let stderr_log = std::fs::OpenOptions::new()
|
||
.create(true)
|
||
.append(true)
|
||
.open(log_dir.join("gateway.err.log"))
|
||
.map_err(|e| format!("创建错误日志文件失败: {e}"))?;
|
||
|
||
crate::utils::openclaw_command_async()
|
||
.arg("gateway")
|
||
.stdin(std::process::Stdio::null())
|
||
.stdout(stdout_log)
|
||
.stderr(stderr_log)
|
||
.spawn()
|
||
.map_err(|e| format!("启动 Gateway 失败: {e}"))?;
|
||
|
||
for _ in 0..25 {
|
||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||
if check_service_status(0, "").0 {
|
||
return Ok(());
|
||
}
|
||
}
|
||
Err("Gateway 启动超时,请检查日志".into())
|
||
}
|
||
|
||
pub async fn stop_service_impl(_label: &str) -> Result<(), String> {
|
||
let _ = crate::utils::openclaw_command_async()
|
||
.args(["gateway", "stop"])
|
||
.output()
|
||
.await;
|
||
if check_service_status(0, "").0 {
|
||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||
let _ = TokioCommand::new("cmd")
|
||
.args([
|
||
"/c",
|
||
"taskkill",
|
||
"/f",
|
||
"/im",
|
||
"node.exe",
|
||
"/fi",
|
||
"WINDOWTITLE eq openclaw*",
|
||
])
|
||
.creation_flags(CREATE_NO_WINDOW)
|
||
.output()
|
||
.await;
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub async fn restart_service_impl(_label: &str) -> Result<(), String> {
|
||
let _ = stop_service_impl(_label).await;
|
||
for _ in 0..10 {
|
||
if !check_service_status(0, "").0 {
|
||
break;
|
||
}
|
||
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||
}
|
||
start_service_impl(_label).await
|
||
}
|
||
}
|
||
|
||
// ===== Linux 实现(与 Windows 类似,使用 openclaw CLI) =====
|
||
|
||
#[cfg(target_os = "linux")]
|
||
mod platform {
|
||
use tokio::process::Command;
|
||
|
||
pub fn current_uid() -> Result<u32, String> {
|
||
let output = std::process::Command::new("id")
|
||
.arg("-u")
|
||
.output()
|
||
.map_err(|e| format!("获取 UID 失败: {e}"))?;
|
||
let uid_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||
uid_str
|
||
.parse::<u32>()
|
||
.map_err(|e| format!("解析 UID 失败: {e}"))
|
||
}
|
||
|
||
pub async fn is_cli_installed() -> bool {
|
||
Command::new("openclaw")
|
||
.arg("--version")
|
||
.output()
|
||
.await
|
||
.map(|o| o.status.success())
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
pub fn scan_service_labels() -> Vec<String> {
|
||
vec!["ai.openclaw.gateway".to_string()]
|
||
}
|
||
|
||
pub async fn check_service_status(_uid: u32, _label: &str) -> (bool, Option<u32>) {
|
||
match std::net::TcpStream::connect_timeout(
|
||
&"127.0.0.1:18789".parse().unwrap(),
|
||
std::time::Duration::from_secs(2),
|
||
) {
|
||
Ok(_) => (true, None),
|
||
Err(_) => {
|
||
if let Ok(output) = Command::new("openclaw").arg("health").output().await {
|
||
let text = String::from_utf8_lossy(&output.stdout);
|
||
if output.status.success() && !text.contains("not running") {
|
||
return (true, None);
|
||
}
|
||
}
|
||
(false, None)
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn gateway_command(action: &str) -> Result<(), String> {
|
||
if !is_cli_installed().await {
|
||
return Err(
|
||
"openclaw CLI 未安装,请先通过 npm install -g @qingchencloud/openclaw-zh 安装"
|
||
.into(),
|
||
);
|
||
}
|
||
let output = crate::utils::openclaw_command_async()
|
||
.args(["gateway", action])
|
||
.output()
|
||
.await
|
||
.map_err(|e| format!("执行 openclaw gateway {action} 失败: {e}"))?;
|
||
|
||
if !output.status.success() {
|
||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||
return Err(format!("openclaw gateway {action} 失败: {stderr}"));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub async fn start_service_impl(_label: &str) -> Result<(), String> {
|
||
gateway_command("start").await
|
||
}
|
||
|
||
pub async fn stop_service_impl(_label: &str) -> Result<(), String> {
|
||
gateway_command("stop").await
|
||
}
|
||
|
||
pub async fn restart_service_impl(_label: &str) -> Result<(), String> {
|
||
gateway_command("restart").await
|
||
}
|
||
}
|
||
|
||
// ===== 跨平台公共接口 =====
|
||
|
||
#[tauri::command]
|
||
pub async fn get_services_status() -> Result<Vec<ServiceStatus>, String> {
|
||
let uid = platform::current_uid()?;
|
||
let labels = platform::scan_service_labels();
|
||
let desc_map = description_map();
|
||
|
||
#[cfg(target_os = "linux")]
|
||
let cli_installed = platform::is_cli_installed().await;
|
||
#[cfg(not(target_os = "linux"))]
|
||
let cli_installed = platform::is_cli_installed();
|
||
|
||
let mut results = Vec::new();
|
||
|
||
for label in &labels {
|
||
#[cfg(target_os = "linux")]
|
||
let (running, pid) = platform::check_service_status(uid, label).await;
|
||
#[cfg(not(target_os = "linux"))]
|
||
let (running, pid) = platform::check_service_status(uid, label);
|
||
|
||
results.push(ServiceStatus {
|
||
label: label.clone(),
|
||
pid,
|
||
running,
|
||
description: desc_map.get(label.as_str()).unwrap_or(&"").to_string(),
|
||
cli_installed,
|
||
});
|
||
}
|
||
|
||
Ok(results)
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn start_service(label: String) -> Result<(), String> {
|
||
#[cfg(target_os = "macos")]
|
||
return platform::start_service_impl(&label);
|
||
#[cfg(not(target_os = "macos"))]
|
||
platform::start_service_impl(&label).await
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn stop_service(label: String) -> Result<(), String> {
|
||
#[cfg(target_os = "macos")]
|
||
return platform::stop_service_impl(&label);
|
||
#[cfg(not(target_os = "macos"))]
|
||
platform::stop_service_impl(&label).await
|
||
}
|
||
|
||
#[tauri::command]
|
||
pub async fn restart_service(label: String) -> Result<(), String> {
|
||
#[cfg(target_os = "macos")]
|
||
return platform::restart_service_impl(&label);
|
||
#[cfg(not(target_os = "macos"))]
|
||
platform::restart_service_impl(&label).await
|
||
}
|