feat: Windows 兼容性全面改进

- Windows Gateway 启动改为前台 spawn 模式(绕过 schtasks 管理员权限)
- 添加全局 Gateway 未启动引导横幅(黄色提示条 + 一键启动按钮)
- 所有页面加载动画改为脉冲效果
- 统一 Windows cmd /c 调用加 CREATE_NO_WINDOW 标志
- 托盘菜单复用 service.rs 逻辑
- 新增 utils.rs 封装 openclaw_command
- 修复 config 文件 UI 字段污染问题
- 添加 dev.ps1 启动脚本
This commit is contained in:
晴天
2026-03-02 13:00:16 +08:00
parent 5352337eaa
commit 53f46d8ef2
64 changed files with 4260 additions and 451 deletions

View File

@@ -1,13 +1,13 @@
/// Agent 管理命令 — 调用 openclaw CLI 实现增删改查
use serde_json::Value;
use std::process::Command;
use std::fs;
use std::io::Write;
use crate::utils::openclaw_command;
/// 获取 agent 列表
#[tauri::command]
pub fn list_agents() -> Result<Value, String> {
let output = Command::new("openclaw")
let output = openclaw_command()
.args(["agents", "list", "--json"])
.output()
.map_err(|e| format!("执行失败: {e}"))?;
@@ -48,7 +48,7 @@ pub fn add_agent(name: String, model: String, workspace: Option<String>) -> Resu
args.push(model);
}
let output = Command::new("openclaw")
let output = openclaw_command()
.args(&args)
.output()
.map_err(|e| format!("执行失败: {e}"))?;
@@ -71,7 +71,7 @@ pub fn delete_agent(id: String) -> Result<String, String> {
return Err("不能删除默认 Agent".into());
}
let output = Command::new("openclaw")
let output = openclaw_command()
.args(["agents", "delete", &id])
.output()
.map_err(|e| format!("执行失败: {e}"))?;
@@ -112,7 +112,7 @@ pub fn update_agent_identity(
}
}
let output = Command::new("openclaw")
let output = openclaw_command()
.args(&args)
.output()
.map_err(|e| format!("执行失败: {e}"))?;

View File

@@ -3,6 +3,9 @@ use serde_json::Value;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use crate::utils::openclaw_command;
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
use crate::models::types::VersionInfo;
@@ -20,10 +23,23 @@ fn get_configured_registry() -> String {
}
/// 创建使用配置源的 npm Command
/// Windows 上 npm 是 npm.cmd需要通过 cmd /c 调用,并隐藏窗口
fn npm_command() -> Command {
let mut cmd = Command::new("npm");
cmd.args(["--registry", &get_configured_registry()]);
cmd
let registry = get_configured_registry();
#[cfg(target_os = "windows")]
{
const CREATE_NO_WINDOW: u32 = 0x08000000;
let mut cmd = Command::new("cmd");
cmd.args(["/c", "npm", "--registry", &registry]);
cmd.creation_flags(CREATE_NO_WINDOW);
cmd
}
#[cfg(not(target_os = "windows"))]
{
let mut cmd = Command::new("npm");
cmd.args(["--registry", &registry]);
cmd
}
}
fn backups_dir() -> PathBuf {
@@ -45,13 +61,50 @@ pub fn write_openclaw_config(config: Value) -> Result<(), String> {
// 备份
let bak = super::openclaw_dir().join("openclaw.json.bak");
let _ = fs::copy(&path, &bak);
// 清理 UI 专属字段,避免 CLI schema 校验失败
let cleaned = strip_ui_fields(config);
// 写入
let json = serde_json::to_string_pretty(&config)
let json = serde_json::to_string_pretty(&cleaned)
.map_err(|e| format!("序列化失败: {e}"))?;
fs::write(&path, json)
.map_err(|e| format!("写入失败: {e}"))
}
/// 递归清理 models 数组中的 UI 专属字段lastTestAt, latency, testStatus, testError
/// 并为缺少 name 字段的模型自动补上 name = id
fn strip_ui_fields(mut val: Value) -> Value {
if let Some(obj) = val.as_object_mut() {
// 递归处理 providers -> xxx -> models 数组
if let Some(models) = obj.get("models") {
if let Some(providers) = models.as_object() {
let mut new_models = providers.clone();
for (_key, provider) in new_models.iter_mut() {
if let Some(pobj) = provider.as_object_mut() {
if let Some(Value::Array(arr)) = pobj.get_mut("models") {
for model in arr.iter_mut() {
if let Some(mobj) = model.as_object_mut() {
mobj.remove("lastTestAt");
mobj.remove("latency");
mobj.remove("testStatus");
mobj.remove("testError");
// 补上 name 字段CLI 要求)
if !mobj.contains_key("name") {
if let Some(id) = mobj.get("id").and_then(|v| v.as_str()) {
mobj.insert("name".into(), Value::String(id.to_string()));
}
}
}
}
}
}
}
obj.insert("models".into(), Value::Object(new_models));
}
}
}
val
}
#[tauri::command]
pub fn read_mcp_config() -> Result<Value, String> {
let path = super::openclaw_dir().join("mcp.json");
@@ -74,25 +127,29 @@ pub fn write_mcp_config(config: Value) -> Result<(), String> {
}
/// 获取本地安装的 openclaw 版本号
/// 优先从 npm 包的 package.json 读取含完整后缀fallback 到 CLI
/// macOS: 优先从 npm 包的 package.json 读取含完整后缀fallback 到 CLI
/// Windows/Linux: 直接用 CLI
fn get_local_version() -> Option<String> {
// 通过 symlink 找到包目录,读 package.json 的 version
if let Ok(target) = fs::read_link("/opt/homebrew/bin/openclaw") {
let pkg_json = PathBuf::from("/opt/homebrew/bin")
.join(&target)
.parent()?
.join("package.json");
if let Ok(content) = fs::read_to_string(&pkg_json) {
if let Some(ver) = serde_json::from_str::<Value>(&content)
.ok()
.and_then(|v| v.get("version")?.as_str().map(String::from))
{
return Some(ver);
// macOS: 通过 symlink 找到包目录,读 package.json 的 version
#[cfg(target_os = "macos")]
{
if let Ok(target) = fs::read_link("/opt/homebrew/bin/openclaw") {
let pkg_json = PathBuf::from("/opt/homebrew/bin")
.join(&target)
.parent()?
.join("package.json");
if let Ok(content) = fs::read_to_string(&pkg_json) {
if let Some(ver) = serde_json::from_str::<Value>(&content)
.ok()
.and_then(|v| v.get("version")?.as_str().map(String::from))
{
return Some(ver);
}
}
}
}
// fallback: CLI 输出
let output = Command::new("openclaw").arg("--version").output().ok()?;
// 所有平台通用 fallback: CLI 输出
let output = openclaw_command().arg("--version").output().ok()?;
let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
raw.split_whitespace().last().filter(|s| !s.is_empty()).map(String::from)
}
@@ -114,25 +171,48 @@ async fn get_latest_version_for(source: &str) -> Option<String> {
}
/// 检测当前安装的是官方版还是汉化版
/// 优先检查文件系统(不依赖 npm 命令的 PATHfallback 到 npm list
/// macOS: 优先检查 homebrew symlinkfallback 到 npm list
/// Windows: 优先检查 npm 全局目录下的 package.json避免调用 npm list 阻塞
/// Linux: 直接用 npm list
fn detect_installed_source() -> String {
// 方法1直接检查 openclaw bin 的 symlink 指向
if let Ok(target) = std::fs::read_link("/opt/homebrew/bin/openclaw") {
if target.to_string_lossy().contains("openclaw-zh") {
return "chinese".into();
// macOS: 检查 openclaw bin 的 symlink 指向
#[cfg(target_os = "macos")]
{
if let Ok(target) = std::fs::read_link("/opt/homebrew/bin/openclaw") {
if target.to_string_lossy().contains("openclaw-zh") {
return "chinese".into();
}
return "official".into();
}
}
// Windows: 优先通过文件系统检测,避免 npm list 阻塞
#[cfg(target_os = "windows")]
{
if let Some(appdata) = std::env::var_os("APPDATA") {
let zh_dir = PathBuf::from(&appdata)
.join("npm")
.join("node_modules")
.join("@qingchencloud")
.join("openclaw-zh");
if zh_dir.exists() {
return "chinese".into();
}
}
return "official".into();
}
// 方法2fallback 到 npm list
if let Ok(o) = npm_command()
.args(["list", "-g", "@qingchencloud/openclaw-zh", "--depth=0"])
.output()
// 所有平台通用: npm list 检测
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
if String::from_utf8_lossy(&o.stdout).contains("openclaw-zh@") {
return "chinese".into();
if let Ok(o) = npm_command()
.args(["list", "-g", "@qingchencloud/openclaw-zh", "--depth=0"])
.output()
{
if String::from_utf8_lossy(&o.stdout).contains("openclaw-zh@") {
return "chinese".into();
}
}
"official".into()
}
"official".into()
}
#[tauri::command]
@@ -175,13 +255,15 @@ pub async fn upgrade_openclaw(app: tauri::AppHandle, source: String) -> Result<S
let current_source = detect_installed_source();
let pkg = format!("{}@latest", npm_package_name(&source));
// 切换源时先卸载旧包,避免 bin 冲突
// 切换源时,或者未安装时(检测 source 和 target或者目前未安装
// 如果系统里已经安装了别的源,先卸载
let old_pkg = npm_package_name(&current_source);
if current_source != source {
let old_pkg = npm_package_name(&current_source);
let _ = app.emit("upgrade-log", format!("正在卸载旧版本 ({old_pkg})..."));
// 先检查是否真的安装了旧包如果没有安装npm uninstall 会报错但不影响
let _ = app.emit("upgrade-log", format!("清理遗留环境 ({old_pkg})..."));
let _ = app.emit("upgrade-progress", 5);
let _ = npm_command()
.args(["uninstall", "-g", old_pkg])
.args(["uninstall", "-g", &old_pkg])
.output();
}
@@ -189,7 +271,7 @@ pub async fn upgrade_openclaw(app: tauri::AppHandle, source: String) -> Result<S
let _ = app.emit("upgrade-progress", 10);
let mut child = npm_command()
.args(["install", "-g", &pkg])
.args(["install", "-g", &pkg, "--verbose"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
@@ -230,16 +312,25 @@ pub async fn upgrade_openclaw(app: tauri::AppHandle, source: String) -> Result<S
return Err("升级失败,请查看日志".into());
}
// 切换源后重装 Gateway 服务,更新 plist 中的路径
// 切换源后重装 Gateway 服务
if current_source != source {
let _ = app.emit("upgrade-log", "正在重装 Gateway 服务(更新启动路径)...");
// 先停掉旧的
let uid = get_uid().unwrap_or(501);
let _ = Command::new("launchctl")
.args(["bootout", &format!("gui/{uid}/ai.openclaw.gateway")])
.output();
// 重新安装(生成新 plist
let gw_out = Command::new("openclaw")
#[cfg(target_os = "macos")]
{
let uid = get_uid().unwrap_or(501);
let _ = Command::new("launchctl")
.args(["bootout", &format!("gui/{uid}/ai.openclaw.gateway")])
.output();
}
#[cfg(not(target_os = "macos"))]
{
let _ = openclaw_command()
.args(["gateway", "stop"])
.output();
}
// 重新安装
let gw_out = openclaw_command()
.args(["gateway", "install"])
.output();
match gw_out {
@@ -268,6 +359,28 @@ pub fn check_installation() -> Result<Value, String> {
Ok(Value::Object(result))
}
/// 检测 Node.js 是否已安装,返回版本号
#[tauri::command]
pub fn check_node() -> Result<Value, String> {
let mut result = serde_json::Map::new();
let mut cmd = Command::new("node");
cmd.arg("--version");
#[cfg(target_os = "windows")]
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
match cmd.output() {
Ok(o) if o.status.success() => {
let ver = String::from_utf8_lossy(&o.stdout).trim().to_string();
result.insert("installed".into(), Value::Bool(true));
result.insert("version".into(), Value::String(ver));
}
_ => {
result.insert("installed".into(), Value::Bool(false));
result.insert("version".into(), Value::Null);
}
}
Ok(Value::Object(result))
}
#[tauri::command]
pub fn write_env_file(path: String, config: String) -> Result<(), String> {
let expanded = if path.starts_with("~/") {
@@ -359,10 +472,14 @@ pub fn create_backup() -> Result<Value, String> {
Ok(Value::Object(obj))
}
/// 检查备份文件名是否安全
fn is_unsafe_backup_name(name: &str) -> bool {
name.contains("..") || name.contains('/') || name.contains('\\')
}
#[tauri::command]
pub fn restore_backup(name: String) -> Result<(), String> {
// 安全检查
if name.contains("..") || name.contains('/') {
if is_unsafe_backup_name(&name) {
return Err("非法文件名".into());
}
let backup_path = backups_dir().join(&name);
@@ -383,7 +500,7 @@ pub fn restore_backup(name: String) -> Result<(), String> {
#[tauri::command]
pub fn delete_backup(name: String) -> Result<(), String> {
if name.contains("..") || name.contains('/') {
if is_unsafe_backup_name(&name) {
return Err("非法文件名".into());
}
let path = backups_dir().join(&name);
@@ -394,32 +511,63 @@ pub fn delete_backup(name: String) -> Result<(), String> {
.map_err(|e| format!("删除失败: {e}"))
}
/// 获取当前用户 UID
/// 获取当前用户 UIDmacOS/Linux 用 id -uWindows 返回 0
#[allow(dead_code)]
fn get_uid() -> Result<u32, String> {
let output = Command::new("id")
.arg("-u")
.output()
.map_err(|e| format!("获取 UID 失败: {e}"))?;
String::from_utf8_lossy(&output.stdout)
.trim()
.parse::<u32>()
.map_err(|e| format!("解析 UID 失败: {e}"))
#[cfg(target_os = "windows")]
{
Ok(0)
}
#[cfg(not(target_os = "windows"))]
{
let output = Command::new("id")
.arg("-u")
.output()
.map_err(|e| format!("获取 UID 失败: {e}"))?;
String::from_utf8_lossy(&output.stdout)
.trim()
.parse::<u32>()
.map_err(|e| format!("解析 UID 失败: {e}"))
}
}
/// 重载 Gateway 服务(使用 kickstart -k 强制重启)
/// 重载 Gateway 服务
/// macOS: launchctl kickstart -k
/// Windows/Linux: openclaw gateway restart
#[tauri::command]
pub fn reload_gateway() -> Result<String, String> {
let uid = get_uid()?;
let target = format!("gui/{uid}/ai.openclaw.gateway");
let output = Command::new("launchctl")
.args(["kickstart", "-k", &target])
.output()
.map_err(|e| format!("重载失败: {e}"))?;
if output.status.success() {
Ok("Gateway 已重载".to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(format!("重载失败: {stderr}"))
#[cfg(target_os = "macos")]
{
let uid = get_uid()?;
let target = format!("gui/{uid}/ai.openclaw.gateway");
let output = Command::new("launchctl")
.args(["kickstart", "-k", &target])
.output()
.map_err(|e| format!("重载失败: {e}"))?;
if output.status.success() {
Ok("Gateway 已重载".to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(format!("重载失败: {stderr}"))
}
}
#[cfg(not(target_os = "macos"))]
{
let cli_check = openclaw_command().arg("--version").output();
match cli_check {
Ok(o) if o.status.success() => {}
_ => return Err("openclaw CLI 未安装,无法重载 Gateway".into()),
}
let output = openclaw_command()
.args(["gateway", "restart"])
.output()
.map_err(|e| format!("重载失败: {e}"))?;
if output.status.success() {
Ok("Gateway 已重载".to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(format!("重载失败: {stderr}"))
}
}
}
@@ -564,7 +712,20 @@ pub async fn list_remote_models(
/// 安装 Gateway 服务(执行 openclaw gateway install
#[tauri::command]
pub fn install_gateway() -> Result<String, String> {
let output = Command::new("openclaw")
// 先检测 openclaw CLI 是否可用
let cli_check = openclaw_command().arg("--version").output();
match cli_check {
Ok(o) if o.status.success() => {}
_ => {
return Err(
"openclaw CLI 未安装。请先执行以下命令安装:\n\n\
npm install -g @qingchencloud/openclaw-zh\n\n\
安装完成后再点击此按钮安装 Gateway 服务。".into()
);
}
}
let output = openclaw_command()
.args(["gateway", "install"])
.output()
.map_err(|e| format!("安装失败: {e}"))?;
@@ -577,23 +738,35 @@ pub fn install_gateway() -> Result<String, String> {
}
}
/// 卸载 Gateway 服务(先 bootout 再删除 plist
/// 卸载 Gateway 服务
/// macOS: launchctl bootout + 删除 plist
/// Windows/Linux: openclaw gateway stop
#[tauri::command]
pub fn uninstall_gateway() -> Result<String, String> {
let uid = get_uid()?;
let target = format!("gui/{uid}/ai.openclaw.gateway");
#[cfg(target_os = "macos")]
{
let uid = get_uid()?;
let target = format!("gui/{uid}/ai.openclaw.gateway");
// 先停止服务
let _ = Command::new("launchctl")
.args(["bootout", &target])
.output();
// 先停止服务
let _ = Command::new("launchctl")
.args(["bootout", &target])
.output();
// 删除 plist 文件
let home = dirs::home_dir().unwrap_or_default();
let plist = home.join("Library/LaunchAgents/ai.openclaw.gateway.plist");
if plist.exists() {
fs::remove_file(&plist)
.map_err(|e| format!("删除 plist 失败: {e}"))?;
// 删除 plist 文件
let home = dirs::home_dir().unwrap_or_default();
let plist = home.join("Library/LaunchAgents/ai.openclaw.gateway.plist");
if plist.exists() {
fs::remove_file(&plist)
.map_err(|e| format!("删除 plist 失败: {e}"))?;
}
}
#[cfg(not(target_os = "macos"))]
{
// Windows/Linux: 停止 Gateway 服务
let _ = openclaw_command()
.args(["gateway", "stop"])
.output();
}
Ok("Gateway 服务已卸载".to_string())

View File

@@ -1,6 +1,8 @@
/// 扩展工具命令cftunnel + ClawApp
use serde_json::Value;
use std::process::Command;
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
/// 解析 cftunnel status 输出
fn parse_cftunnel_status(output: &str) -> serde_json::Map<String, Value> {
@@ -9,14 +11,12 @@ fn parse_cftunnel_status(output: &str) -> serde_json::Map<String, Value> {
let line = line.trim();
if line.starts_with("隧道:") || line.starts_with("隧道:") {
let rest = line.splitn(2, ':').nth(1).unwrap_or("").trim();
// "mac-home (uuid)" → 取名称
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 = line.splitn(2, ':').nth(1).unwrap_or("").trim();
let running = rest.contains("运行中");
map.insert("running".into(), Value::Bool(running));
// 提取 PID
if let Some(pid_str) = rest.split("PID:").nth(1) {
let pid = pid_str.trim().trim_end_matches(')').trim();
if let Ok(p) = pid.parse::<u64>() {
@@ -33,7 +33,6 @@ 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;
}
@@ -49,35 +48,92 @@ fn parse_cftunnel_routes(output: &str) -> Vec<Value> {
routes
}
/// 查找 cftunnel 可执行文件路径
fn cftunnel_bin() -> String {
// 优先查找用户 bin 目录
let home = dirs::home_dir().unwrap_or_default();
let user_bin = home.join("bin").join("cftunnel");
if user_bin.exists() {
return user_bin.to_string_lossy().to_string();
}
"cftunnel".to_string()
}
/// 通过 launchctl 检测 cftunnel 服务实际运行状态
fn check_cftunnel_launchctl() -> Option<(Option<u64>, bool)> {
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();
// 第一列是 PID数字表示在运行- 表示未运行)
let running = pid.is_some();
return Some((pid, running));
#[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 进程是否在运行(平台相关的补充检测)
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") {
// 尝试提取 PIDCSV 格式: "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
}
None
}
#[tauri::command]
@@ -108,10 +164,10 @@ pub fn get_cftunnel_status() -> Result<Value, String> {
}
}
// 补充检测:如果 cftunnel status 报已停止,但 launchctl 显示进程在跑,以实际为准
// 补充检测:如果 cftunnel 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_launchctl() {
if let Some((pid, running)) = check_cftunnel_process() {
if running {
result.insert("running".into(), Value::Bool(true));
if let Some(p) = pid {
@@ -164,29 +220,53 @@ pub fn get_cftunnel_logs(lines: Option<u32>) -> Result<String, String> {
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();
// 用 lsof 检测 :3210 端口
let output = Command::new("lsof")
.args(["-i", ":3210", "-P", "-t"])
.output();
// 跨平台方式:尝试连接端口检测是否在运行
let running = std::net::TcpStream::connect_timeout(
&"127.0.0.1:3210".parse().unwrap(),
std::time::Duration::from_millis(500),
).is_ok();
match output {
Ok(out) => {
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 text.is_empty() {
result.insert("running".into(), Value::Bool(false));
} else {
result.insert("running".into(), Value::Bool(true));
if let Ok(pid) = text.lines().next().unwrap_or("").parse::<u64>() {
result.insert("pid".into(), Value::Number(pid.into()));
}
if let Ok(pid) = text.lines().next().unwrap_or("").parse::<u64>() {
result.insert("pid".into(), Value::Number(pid.into()));
}
}
Err(_) => {
result.insert("running".into(), Value::Bool(false));
}
// Windows: 用 netstat 获取 PID
#[cfg(target_os = "windows")]
if running {
let mut cmd = Command::new("netstat");
cmd.args(["-ano"]);
cmd.creation_flags(0x08000000); // CREATE_NO_WINDOW
if let Ok(out) = cmd.output()
{
let text = String::from_utf8_lossy(&out.stdout);
for line in text.lines() {
if line.contains(":3210") && line.contains("LISTENING") {
if let Some(pid_str) = line.split_whitespace().last() {
if let Ok(pid) = pid_str.parse::<u64>() {
result.insert("pid".into(), Value::Number(pid.into()));
break;
}
}
}
}
}
}
@@ -196,6 +276,8 @@ pub fn get_clawapp_status() -> Result<Value, String> {
}
/// 一键安装 cftunnel
/// macOS/Linux: bash 脚本安装
/// Windows: PowerShell 下载安装
#[tauri::command]
pub async fn install_cftunnel(app: tauri::AppHandle) -> Result<String, String> {
use std::process::Stdio;
@@ -205,8 +287,12 @@ pub async fn install_cftunnel(app: tauri::AppHandle) -> Result<String, String> {
let _ = app.emit("install-log", "开始安装 cftunnel...");
let _ = app.emit("install-progress", 10);
// 下载安装脚本
let install_script = r#"
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
@@ -217,22 +303,43 @@ 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}"))?
};
let _ = app.emit("install-log", "下载安装脚本...");
let _ = app.emit("install-progress", 30);
let mut child = 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'
$binDir = Join-Path $env:USERPROFILE 'bin'
if (-not (Test-Path $binDir)) { New-Item -ItemType Directory -Path $binDir -Force | Out-Null }
Write-Output '下载 cftunnel...'
$url = 'https://github.com/qingchencloud/cftunnel/releases/latest/download/cftunnel-windows-amd64.exe'
$dest = Join-Path $binDir 'cftunnel.exe'
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Invoke-WebRequest -Uri $url -OutFile $dest -UseBasicParsing
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();
// 读取 stderr
let app2 = app.clone();
let handle = std::thread::spawn(move || {
if let Some(pipe) = stderr {
@@ -242,7 +349,6 @@ echo "安装完成"
}
});
// 读取 stdout
let mut progress = 40;
if let Some(pipe) = stdout {
for line in BufReader::new(pipe).lines().map_while(Result::ok) {

View File

@@ -2,11 +2,21 @@
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use crate::utils::openclaw_command;
/// 检查路径是否包含不安全字符(目录遍历、绝对路径等)
fn is_unsafe_path(path: &str) -> bool {
path.contains("..")
|| path.contains('\0')
|| path.starts_with('/')
|| path.starts_with('\\')
|| (path.len() >= 2 && path.as_bytes()[1] == b':') // Windows 绝对路径 C:\
}
/// 根据 agent_id 获取 workspace 路径
/// 调用 openclaw agents list --json 解析
fn agent_workspace(agent_id: &str) -> Result<PathBuf, String> {
let output = std::process::Command::new("openclaw")
let output = openclaw_command()
.args(["agents", "list", "--json"])
.output()
.map_err(|e| format!("执行 openclaw 失败: {e}"))?;
@@ -96,7 +106,7 @@ fn collect_files(
#[tauri::command]
pub fn read_memory_file(path: String, agent_id: Option<String>) -> Result<String, String> {
if path.contains("..") || path.starts_with('/') || path.contains('\0') {
if is_unsafe_path(&path) {
return Err("非法路径".to_string());
}
@@ -122,7 +132,7 @@ pub fn read_memory_file(path: String, agent_id: Option<String>) -> Result<String
#[tauri::command]
pub fn write_memory_file(path: String, content: String, category: Option<String>, agent_id: Option<String>) -> Result<(), String> {
if path.contains("..") || path.starts_with('/') || path.contains('\0') {
if is_unsafe_path(&path) {
return Err("非法路径".to_string());
}
@@ -139,7 +149,7 @@ pub fn write_memory_file(path: String, content: String, category: Option<String>
#[tauri::command]
pub fn delete_memory_file(path: String, agent_id: Option<String>) -> Result<(), String> {
if path.contains("..") || path.starts_with('/') || path.contains('\0') {
if is_unsafe_path(&path) {
return Err("非法路径".to_string());
}

View File

@@ -1,22 +1,10 @@
/// 服务管理命令 (macOS launchd)
/// 只扫描 OpenClaw 核心服务 (ai.openclaw.* / com.openclaw.guardian.* / com.openclaw.watchdog)
/// 使用新版 launchctl bootstrap/bootout/kickstart API
/// 服务管理命令
/// macOS: launchctl + LaunchAgents plist
/// Windows: openclaw CLI + 进程检测
use std::collections::HashMap;
use std::fs;
use std::process::Command;
use crate::models::types::ServiceStatus;
/// 获取当前用户 UID
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}"))
}
/// OpenClaw 官方服务的友好名称映射
fn description_map() -> HashMap<&'static str, &'static str> {
HashMap::from([
@@ -25,92 +13,372 @@ fn description_map() -> HashMap<&'static str, &'static str> {
])
}
/// OpenClaw 官方服务前缀ai.openclaw.gateway / ai.openclaw.node 等)
const OPENCLAW_PREFIXES: &[&str] = &[
"ai.openclaw.",
];
// ===== macOS 实现 =====
/// 动态扫描 LaunchAgents 目录,只返回 OpenClaw 核心服务
fn scan_plist_labels() -> Vec<String> {
let home = dirs::home_dir().unwrap_or_default();
let agents_dir = home.join("Library/LaunchAgents");
let mut labels = Vec::new();
#[cfg(target_os = "macos")]
mod platform {
use std::fs;
use std::process::Command;
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") {
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 label = name.trim_end_matches(".plist");
if OPENCLAW_PREFIXES.iter().any(|p| label.starts_with(p)) {
labels.push(label.to_string());
let trimmed = line.trim();
if trimmed.starts_with("pid = ") {
if let Ok(p) = trimmed["pid = ".len()..].trim().parse::<u32>() {
pid = Some(p);
}
}
if trimmed.starts_with("state = ") {
let state = trimmed["state = ".len()..].trim();
running = state == "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 crate::utils::openclaw_command;
/// Windows 不需要 UID
pub fn current_uid() -> Result<u32, String> {
Ok(0)
}
/// 检测 openclaw CLI 是否已安装
pub fn is_cli_installed() -> bool {
openclaw_command()
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Windows 上始终返回 Gateway 标签(不管 CLI 是否安装)
pub fn scan_service_labels() -> Vec<String> {
vec!["ai.openclaw.gateway".to_string()]
}
/// 通过端口探测检测 Gateway 状态
pub 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_millis(500),
) {
Ok(_) => (true, None),
Err(_) => (false, None),
}
}
/// 以前台模式 spawn Gateway不需要管理员权限
pub 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(());
}
crate::utils::openclaw_command()
.arg("gateway")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| format!("启动 Gateway 失败: {e}"))?;
for _ in 0..25 {
std::thread::sleep(std::time::Duration::from_millis(200));
if check_service_status(0, "").0 {
return Ok(());
}
}
Err("Gateway 启动超时,请检查日志".into())
}
pub fn stop_service_impl(_label: &str) -> Result<(), String> {
let _ = crate::utils::openclaw_command()
.args(["gateway", "stop"])
.output();
if check_service_status(0, "").0 {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = std::process::Command::new("cmd")
.args(["/c", "taskkill", "/f", "/im", "node.exe", "/fi", "WINDOWTITLE eq openclaw*"])
.creation_flags(CREATE_NO_WINDOW)
.output();
}
Ok(())
}
pub fn restart_service_impl(_label: &str) -> Result<(), String> {
let _ = stop_service_impl(_label);
for _ in 0..10 {
if !check_service_status(0, "").0 { break; }
std::thread::sleep(std::time::Duration::from_millis(300));
}
start_service_impl(_label)
}
}
// ===== Linux 实现(与 Windows 类似,使用 openclaw CLI =====
#[cfg(target_os = "linux")]
mod platform {
use std::process::Command;
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}"))
}
pub fn is_cli_installed() -> bool {
Command::new("openclaw")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn scan_service_labels() -> Vec<String> {
vec!["ai.openclaw.gateway".to_string()]
}
pub 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() {
let text = String::from_utf8_lossy(&output.stdout);
if output.status.success() && !text.contains("not running") {
return (true, None);
}
}
(false, None)
}
}
}
labels.sort();
labels
}
fn plist_path(label: &str) -> String {
let home = dirs::home_dir().unwrap_or_default();
format!(
"{}/Library/LaunchAgents/{}.plist",
home.display(),
label
)
}
fn gateway_command(action: &str) -> Result<(), String> {
if !is_cli_installed() {
return Err("openclaw CLI 未安装,请先通过 npm install -g @qingchencloud/openclaw-zh 安装".into());
}
let output = crate::utils::openclaw_command()
.args(["gateway", action])
.output()
.map_err(|e| format!("执行 openclaw gateway {action} 失败: {e}"))?;
/// 用 `launchctl print gui/{uid}/{label}` 检测单个服务状态
/// 返回 (running, pid)
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);
};
// launchctl print 返回非零 → 服务未注册
if !out.status.success() {
return (false, None);
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("openclaw gateway {action} 失败: {stderr}"));
}
Ok(())
}
let stdout = String::from_utf8_lossy(&out.stdout);
let mut pid: Option<u32> = None;
let mut running = false;
for line in stdout.lines() {
// 只解析顶层字段(单个 tab 缩进),忽略嵌套的 state = active 等
if !line.starts_with('\t') || line.starts_with("\t\t") {
continue;
}
let trimmed = line.trim();
if trimmed.starts_with("pid = ") {
if let Ok(p) = trimmed["pid = ".len()..].trim().parse::<u32>() {
pid = Some(p);
}
}
if trimmed.starts_with("state = ") {
let state = trimmed["state = ".len()..].trim();
running = state == "running";
}
pub fn start_service_impl(_label: &str) -> Result<(), String> {
gateway_command("start")
}
(running, pid)
pub fn stop_service_impl(_label: &str) -> Result<(), String> {
gateway_command("stop")
}
pub fn restart_service_impl(_label: &str) -> Result<(), String> {
gateway_command("restart")
}
}
// ===== 跨平台公共接口 =====
#[tauri::command]
pub fn get_services_status() -> Result<Vec<ServiceStatus>, String> {
let uid = current_uid()?;
let labels = scan_plist_labels();
let uid = platform::current_uid()?;
let labels = platform::scan_service_labels();
let desc_map = description_map();
let cli_installed = platform::is_cli_installed();
let mut results = Vec::new();
for label in &labels {
let (running, pid) = check_service_status(uid, label);
let (running, pid) = platform::check_service_status(uid, label);
results.push(ServiceStatus {
label: label.clone(),
pid,
@@ -119,6 +387,7 @@ pub fn get_services_status() -> Result<Vec<ServiceStatus>, String> {
.get(label.as_str())
.unwrap_or(&"")
.to_string(),
cli_installed,
});
}
@@ -127,115 +396,15 @@ pub fn get_services_status() -> Result<Vec<ServiceStatus>, String> {
#[tauri::command]
pub fn start_service(label: String) -> Result<(), String> {
let uid = current_uid()?;
let path = plist_path(&label);
let domain_target = format!("gui/{}", uid);
let service_target = format!("gui/{}/{}", uid, label);
// bootstrap 加载 plist
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);
// 如果已经加载过,忽略该错误,继续 kickstart
if !stderr.contains("already bootstrapped") && !stderr.trim().is_empty() {
return Err(format!("启动 {label} 失败: {stderr}"));
}
}
// kickstart 触发服务运行
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(())
platform::start_service_impl(&label)
}
#[tauri::command]
pub fn stop_service(label: String) -> 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(())
platform::stop_service_impl(&label)
}
#[tauri::command]
pub fn restart_service(label: String) -> Result<(), String> {
let uid = current_uid()?;
let path = plist_path(&label);
let domain_target = format!("gui/{}", uid);
let service_target = format!("gui/{}/{}", uid, label);
// 第一步bootout 停止服务(忽略未加载错误)
let _ = Command::new("launchctl")
.args(["bootout", &service_target])
.output();
// 第二步:轮询等待旧进程退出,最多等 3 秒
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
loop {
let (running, _) = check_service_status(uid, &label);
if !running {
break;
}
if std::time::Instant::now() >= deadline {
break; // 超时后继续尝试
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
// 第三步bootstrap 重新加载 plist
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}"));
}
}
// 第四步kickstart -k 强制重启
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(())
platform::restart_service_impl(&label)
}

View File

@@ -1,11 +1,17 @@
mod commands;
mod models;
mod tray;
mod utils;
use commands::{agent, config, device, extensions, logs, memory, service};
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.setup(|app| {
tray::setup_tray(app.handle())?;
Ok(())
})
.invoke_handler(tauri::generate_handler![
// 配置
config::read_openclaw_config,
@@ -14,6 +20,7 @@ pub fn run() {
config::write_mcp_config,
config::get_version_info,
config::check_installation,
config::check_node,
config::write_env_file,
config::list_backups,
config::create_backup,

View File

@@ -6,6 +6,8 @@ pub struct ServiceStatus {
pub pid: Option<u32>,
pub running: bool,
pub description: String,
/// CLI 工具是否已安装Windows/Linux: openclaw CLI
pub cli_installed: bool,
}
#[derive(Debug, Serialize, Deserialize)]

77
src-tauri/src/tray.rs Normal file
View File

@@ -0,0 +1,77 @@
/// 系统托盘模块
/// Windows / macOS / Linux 通用Tauri v2 内置跨平台支持
use tauri::{
AppHandle, Manager,
menu::{MenuBuilder, MenuItemBuilder, PredefinedMenuItem},
tray::TrayIconBuilder,
image::Image,
};
pub fn setup_tray(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
// 菜单项
let show = MenuItemBuilder::with_id("show", "显示主窗口").build(app)?;
let separator1 = PredefinedMenuItem::separator(app)?;
let gateway_start = MenuItemBuilder::with_id("gateway_start", "启动 Gateway").build(app)?;
let gateway_stop = MenuItemBuilder::with_id("gateway_stop", "停止 Gateway").build(app)?;
let gateway_restart = MenuItemBuilder::with_id("gateway_restart", "重启 Gateway").build(app)?;
let separator2 = PredefinedMenuItem::separator(app)?;
let quit = MenuItemBuilder::with_id("quit", "退出 ClawPanel").build(app)?;
let menu = MenuBuilder::new(app)
.item(&show)
.item(&separator1)
.item(&gateway_start)
.item(&gateway_stop)
.item(&gateway_restart)
.item(&separator2)
.item(&quit)
.build()?;
// 托盘图标(使用内嵌 32x32 PNG
let icon = Image::from_bytes(include_bytes!("../icons/32x32.png"))?;
let _tray = TrayIconBuilder::new()
.icon(icon)
.tooltip("ClawPanel")
.menu(&menu)
.on_menu_event(move |app, event| {
handle_menu_event(app, event.id().as_ref());
})
.on_tray_icon_event(|tray, event| {
if let tauri::tray::TrayIconEvent::DoubleClick { .. } = event {
if let Some(window) = tray.app_handle().get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
})
.build(app)?;
Ok(())
}
fn handle_menu_event(app: &AppHandle, id: &str) {
match id {
"show" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
"gateway_start" => {
let _ = crate::commands::service::start_service("ai.openclaw.gateway".into());
}
"gateway_stop" => {
let _ = crate::commands::service::stop_service("ai.openclaw.gateway".into());
}
"gateway_restart" => {
let _ = crate::commands::service::restart_service("ai.openclaw.gateway".into());
}
"quit" => {
app.exit(0);
}
_ => {}
}
}

20
src-tauri/src/utils.rs Normal file
View File

@@ -0,0 +1,20 @@
use std::process::Command;
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
/// 跨平台获取 openclaw 命令的方法
/// 在 Windows 上使用 `cmd /c openclaw` 以兼容全局 npm 路径下的 `.cmd` 脚本
pub fn openclaw_command() -> Command {
#[cfg(target_os = "windows")]
{
const CREATE_NO_WINDOW: u32 = 0x08000000;
let mut cmd = Command::new("cmd");
cmd.arg("/c").arg("openclaw");
cmd.creation_flags(CREATE_NO_WINDOW);
cmd
}
#[cfg(not(target_os = "windows"))]
{
Command::new("openclaw")
}
}