feat: Windows 兼容性全面改进
- Windows Gateway 启动改为前台 spawn 模式(绕过 schtasks 管理员权限) - 添加全局 Gateway 未启动引导横幅(黄色提示条 + 一键启动按钮) - 所有页面加载动画改为脉冲效果 - 统一 Windows cmd /c 调用加 CREATE_NO_WINDOW 标志 - 托盘菜单复用 service.rs 逻辑 - 新增 utils.rs 封装 openclaw_command - 修复 config 文件 UI 字段污染问题 - 添加 dev.ps1 启动脚本
10
CHANGELOG.md
@@ -26,9 +26,19 @@
|
||||
- **CI/CD** — GitHub Actions 持续集成 + 全平台发布构建(macOS ARM64/Intel、Windows x64、Linux x64)
|
||||
- **手动发布** — 支持 workflow_dispatch 手动触发构建,填入版本号即可一键发布
|
||||
|
||||
### 优化 (Improvements)
|
||||
|
||||
- **全局异步加载** — 所有页面 render() 非阻塞返回 DOM,数据在后台异步加载,消除页面切换卡顿
|
||||
- **路由模块缓存** — 已加载的页面模块缓存复用,二次切换跳过动态 import
|
||||
- **Tauri API 预加载** — invoke 模块启动时预加载,避免每次 API 调用的动态 import 开销
|
||||
- **页面过渡动画** — 进入动画(220ms 上滑淡入)+ 退出动画(100ms 淡出),丝滑切换体验
|
||||
- **Windows 兼容** — Rust 后端通过 `#[cfg(target_os)]` 条件编译支持 Windows 平台(服务管理、版本检测、扩展工具等)
|
||||
- **Setup 引导模式** — 未安装 OpenClaw 时自动进入引导页面,安装完成后切换到正常模式
|
||||
|
||||
### 技术亮点
|
||||
|
||||
- 零框架依赖:纯 Vanilla JS,无 React/Vue 等框架
|
||||
- Tauri v2 + Rust 后端,原生性能
|
||||
- 玻璃拟态暗色主题,现代化 UI
|
||||
- 全中文界面与代码注释
|
||||
- 跨平台支持:macOS (ARM64/Intel) + Windows + Linux
|
||||
|
||||
@@ -19,9 +19,80 @@ cd clawpanel
|
||||
|
||||
# 安装前端依赖
|
||||
npm install
|
||||
```
|
||||
|
||||
# 启动开发模式
|
||||
cargo tauri dev
|
||||
#### macOS / Linux
|
||||
|
||||
```bash
|
||||
# 启动开发模式(完整 Tauri 桌面应用)
|
||||
./scripts/dev.sh
|
||||
|
||||
# 仅启动前端(浏览器调试,使用 mock 数据)
|
||||
./scripts/dev.sh web
|
||||
```
|
||||
|
||||
#### Windows
|
||||
|
||||
```powershell
|
||||
# 启动开发模式(完整 Tauri 桌面应用)
|
||||
npm run tauri dev
|
||||
|
||||
# 仅启动前端(浏览器调试,使用 mock 数据)
|
||||
npm run dev
|
||||
```
|
||||
|
||||
> Windows 开发需要安装 [Visual Studio Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)(勾选「使用 C++ 的桌面开发」工作负载)和 [WebView2](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)(Win10+ 通常已预装)。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
clawpanel/
|
||||
├── src/ # 前端源码(Vanilla JS)
|
||||
│ ├── pages/ # 页面模块(每个页面导出 render 函数)
|
||||
│ ├── components/ # 通用组件(侧边栏、弹窗、Toast)
|
||||
│ ├── lib/ # 工具库(Tauri API 封装、主题切换)
|
||||
│ ├── style/ # CSS 样式(CSS Variables 驱动)
|
||||
│ ├── router.js # Hash 路由
|
||||
│ └── main.js # 入口文件
|
||||
├── src-tauri/ # Rust 后端
|
||||
│ ├── src/commands/ # Tauri 命令(按功能模块拆分)
|
||||
│ ├── Cargo.toml # Rust 依赖
|
||||
│ └── tauri.conf.json # Tauri 配置
|
||||
├── public/ # 静态资源(图片、图标)
|
||||
└── .github/workflows/ # CI/CD 工作流
|
||||
```
|
||||
|
||||
### 前端页面开发约定
|
||||
|
||||
每个页面是一个独立 JS 模块,导出 `render()` 函数:
|
||||
|
||||
```javascript
|
||||
export async function render() {
|
||||
const page = document.createElement('div')
|
||||
page.className = 'page'
|
||||
page.innerHTML = `<!-- 页面骨架,含加载占位符 -->`
|
||||
|
||||
// 非阻塞:先返回 DOM,数据在后台异步加载
|
||||
loadData(page)
|
||||
return page
|
||||
}
|
||||
```
|
||||
|
||||
关键原则:`render()` 必须立即返回 DOM 元素,不要 `await` 数据加载,否则会阻塞页面切换。
|
||||
|
||||
### Rust 跨平台开发约定
|
||||
|
||||
平台相关代码使用条件编译:
|
||||
|
||||
```rust
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// macOS: launchctl / plist
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Windows: openclaw CLI / tasklist
|
||||
}
|
||||
```
|
||||
|
||||
## 分支策略
|
||||
@@ -48,6 +119,7 @@ cargo tauri dev
|
||||
| `docs` | 文档变更 |
|
||||
| `style` | 代码格式调整(不影响逻辑) |
|
||||
| `refactor` | 重构(非新功能、非 Bug 修复) |
|
||||
| `perf` | 性能优化 |
|
||||
| `test` | 测试相关 |
|
||||
| `chore` | 构建/工具/依赖变更 |
|
||||
|
||||
@@ -56,6 +128,7 @@ cargo tauri dev
|
||||
```
|
||||
feat(model): 新增模型批量测试功能
|
||||
fix(gateway): 修复端口配置未生效的问题
|
||||
perf(router): 添加模块缓存避免重复加载
|
||||
docs: 更新安装说明
|
||||
```
|
||||
|
||||
@@ -76,6 +149,7 @@ docs: 更新安装说明
|
||||
- **风格**:简洁清晰,避免过度封装
|
||||
- **命名**:变量和函数使用驼峰命名(camelCase),CSS 类名使用短横线命名(kebab-case)
|
||||
- **资源**:静态资源本地化,禁止引用远程 CDN
|
||||
- **异步**:页面 render() 中禁止阻塞式 await,数据加载走后台异步
|
||||
|
||||
## 问题反馈
|
||||
|
||||
|
||||
37
README.md
@@ -1,9 +1,7 @@
|
||||
<p align="center">
|
||||
<img src="public/images/logo.svg" width="120" alt="ClawPanel Logo">
|
||||
<img src="public/images/logo-brand.png" width="360" alt="ClawPanel">
|
||||
</p>
|
||||
|
||||
<h1 align="center">ClawPanel</h1>
|
||||
|
||||
<p align="center">
|
||||
OpenClaw 可视化管理面板 — 基于 Tauri v2 的跨平台桌面应用
|
||||
</p>
|
||||
@@ -72,7 +70,7 @@ ClawPanel 是 [OpenClaw](https://github.com/openclaw-labs/openclaw) AI Agent 框
|
||||
|
||||
## 功能截图
|
||||
|
||||
> 截图待补充
|
||||
> 截图待补充 — 欢迎提交 PR 补充各页面截图
|
||||
|
||||
## 技术架构
|
||||
|
||||
@@ -122,7 +120,11 @@ clawpanel/
|
||||
git clone https://github.com/qingchencloud/clawpanel.git
|
||||
cd clawpanel
|
||||
npm install
|
||||
```
|
||||
|
||||
#### macOS / Linux
|
||||
|
||||
```bash
|
||||
# 启动完整 Tauri 桌面应用
|
||||
./scripts/dev.sh
|
||||
|
||||
@@ -130,8 +132,20 @@ npm install
|
||||
./scripts/dev.sh web
|
||||
```
|
||||
|
||||
#### Windows
|
||||
|
||||
```powershell
|
||||
# 启动完整 Tauri 桌面应用
|
||||
npm run tauri dev
|
||||
|
||||
# 仅启动 Vite 前端(浏览器调试,使用 mock 数据)
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 构建
|
||||
|
||||
#### macOS / Linux
|
||||
|
||||
```bash
|
||||
# 编译 debug 版本
|
||||
./scripts/build.sh
|
||||
@@ -143,6 +157,21 @@ npm install
|
||||
./scripts/build.sh release
|
||||
```
|
||||
|
||||
#### Windows
|
||||
|
||||
```powershell
|
||||
# 检查 Rust 编译
|
||||
cd src-tauri && cargo check
|
||||
|
||||
# 编译正式发布版本
|
||||
npm run tauri build
|
||||
|
||||
# 指定打包格式(NSIS 安装器)
|
||||
npm run tauri build -- --bundles nsis
|
||||
```
|
||||
|
||||
产物位于 `src-tauri/target/release/` 目录。
|
||||
|
||||
## 相关项目
|
||||
|
||||
| 项目 | 说明 |
|
||||
|
||||
30
dev.ps1
Normal file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# ClawPanel 开发服务器启动脚本
|
||||
|
||||
Write-Host "🚀 启动 ClawPanel 开发服务器..." -ForegroundColor Cyan
|
||||
|
||||
# 检查 Node.js
|
||||
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
|
||||
Write-Host "❌ 未找到 Node.js,请先安装" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 检查 Rust
|
||||
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
|
||||
Write-Host "❌ 未找到 Rust,请先安装" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 检查依赖
|
||||
if (-not (Test-Path "node_modules")) {
|
||||
Write-Host "📦 安装前端依赖..." -ForegroundColor Yellow
|
||||
npm install
|
||||
}
|
||||
|
||||
if (-not (Test-Path "src-tauri/target")) {
|
||||
Write-Host "🦀 首次编译 Rust(可能需要几分钟)..." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# 启动开发服务器
|
||||
Write-Host "✨ 启动中..." -ForegroundColor Green
|
||||
npm run tauri dev
|
||||
@@ -4,12 +4,16 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ClawPanel</title>
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<!-- 样式由 main.js 通过 Vite 统一加载 -->
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<aside id="sidebar"></aside>
|
||||
<main id="content"></main>
|
||||
<div id="main-col">
|
||||
<div id="gw-banner" class="gw-banner gw-banner-hidden"></div>
|
||||
<main id="content"></main>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
|
||||
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
public/images/logo-brand.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/images/logo.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
@@ -1,21 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" fill="none">
|
||||
<!-- 背景圆角方块 -->
|
||||
<rect x="8" y="8" width="112" height="112" rx="24" fill="url(#bg)"/>
|
||||
<!-- 爪子:三个爪尖 -->
|
||||
<path d="M40 38c0-6 4-12 8-12s8 6 8 12v16h-16V38z" fill="#fff" opacity="0.95"/>
|
||||
<path d="M56 32c0-6 4-12 8-12s8 6 8 12v22H56V32z" fill="#fff"/>
|
||||
<path d="M72 38c0-6 4-12 8-12s8 6 8 12v16H72V38z" fill="#fff" opacity="0.95"/>
|
||||
<!-- 爪掌 -->
|
||||
<path d="M34 56c0 0 4-4 30-4s30 4 30 4v12c0 16-10 28-30 32-20-4-30-16-30-32V56z" fill="#fff" opacity="0.9"/>
|
||||
<!-- 面板线条 -->
|
||||
<rect x="46" y="62" width="36" height="3" rx="1.5" fill="url(#bg)" opacity="0.4"/>
|
||||
<rect x="46" y="70" width="28" height="3" rx="1.5" fill="url(#bg)" opacity="0.3"/>
|
||||
<rect x="46" y="78" width="20" height="3" rx="1.5" fill="url(#bg)" opacity="0.2"/>
|
||||
<!-- 渐变定义 -->
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="8" y1="8" x2="120" y2="120" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#6366f1"/>
|
||||
<stop offset="1" stop-color="#8b5cf6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
0
scripts/build.sh
Executable file → Normal file
0
scripts/dev.sh
Executable file → Normal file
60
src-tauri/Cargo.lock
generated
@@ -181,6 +181,12 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
@@ -1464,7 +1470,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1581,6 +1587,19 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png 0.18.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "1.9.3"
|
||||
@@ -1922,6 +1941,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.7.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.17.1"
|
||||
@@ -1937,7 +1966,7 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"once_cell",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"windows-sys 0.60.2",
|
||||
@@ -2516,6 +2545,19 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.4"
|
||||
@@ -2624,6 +2666,15 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
@@ -3649,6 +3700,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"image",
|
||||
"jni",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -3716,7 +3768,7 @@ dependencies = [
|
||||
"ico",
|
||||
"json-patch",
|
||||
"plist",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"semver",
|
||||
@@ -4205,7 +4257,7 @@ dependencies = [
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation",
|
||||
"once_cell",
|
||||
"png",
|
||||
"png 0.17.16",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"windows-sys 0.60.2",
|
||||
|
||||
@@ -11,7 +11,7 @@ crate-type = ["lib", "cdylib", "staticlib"]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-shell = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
2564
src-tauri/gen/schemas/windows-schema.json
Normal file
|
Before Width: | Height: | Size: 645 KiB After Width: | Height: | Size: 625 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 740 B |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 663 B After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 740 B |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 645 KiB After Width: | Height: | Size: 625 KiB |
@@ -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}"))?;
|
||||
|
||||
@@ -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", ®istry]);
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
cmd
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let mut cmd = Command::new("npm");
|
||||
cmd.args(["--registry", ®istry]);
|
||||
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 命令的 PATH),fallback 到 npm list
|
||||
/// macOS: 优先检查 homebrew symlink,fallback 到 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();
|
||||
}
|
||||
// 方法2:fallback 到 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(¤t_source);
|
||||
if current_source != source {
|
||||
let old_pkg = npm_package_name(¤t_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
|
||||
/// 获取当前用户 UID(macOS/Linux 用 id -u,Windows 返回 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())
|
||||
|
||||
@@ -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") {
|
||||
// 尝试提取 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
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,9 @@
|
||||
*/
|
||||
import { navigate, getCurrentRoute } from '../router.js'
|
||||
import { toggleTheme, getTheme } from '../lib/theme.js'
|
||||
import { isOpenclawReady } from '../lib/app-state.js'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
const NAV_ITEMS_FULL = [
|
||||
{
|
||||
section: '概览',
|
||||
items: [
|
||||
@@ -42,7 +43,29 @@ const NAV_ITEMS = [
|
||||
}
|
||||
]
|
||||
|
||||
const NAV_ITEMS_SETUP = [
|
||||
{
|
||||
section: '',
|
||||
items: [
|
||||
{ route: '/setup', label: '初始设置', icon: 'setup' },
|
||||
]
|
||||
},
|
||||
{
|
||||
section: '扩展',
|
||||
items: [
|
||||
{ route: '/extensions', label: '扩展工具', icon: 'extensions' },
|
||||
]
|
||||
},
|
||||
{
|
||||
section: '',
|
||||
items: [
|
||||
{ route: '/about', label: '关于', icon: 'about' },
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const ICONS = {
|
||||
setup: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>',
|
||||
dashboard: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>',
|
||||
chat: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>',
|
||||
services: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg>',
|
||||
@@ -63,21 +86,16 @@ export function renderSidebar(el) {
|
||||
let html = `
|
||||
<div class="sidebar-header">
|
||||
<div class="sidebar-logo">
|
||||
<svg viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="currentColor">
|
||||
<circle cx="256" cy="280" r="55"/>
|
||||
<ellipse cx="140" cy="200" rx="32" ry="70" transform="rotate(-30 140 200)"/>
|
||||
<ellipse cx="372" cy="200" rx="32" ry="70" transform="rotate(30 372 200)"/>
|
||||
<ellipse cx="256" cy="380" rx="32" ry="70"/>
|
||||
</g>
|
||||
</svg>
|
||||
<img src="/images/logo.png" alt="ClawPanel">
|
||||
</div>
|
||||
<span class="sidebar-title">ClawPanel</span>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
`
|
||||
|
||||
for (const section of NAV_ITEMS) {
|
||||
const navItems = isOpenclawReady() ? NAV_ITEMS_FULL : NAV_ITEMS_SETUP
|
||||
|
||||
for (const section of navItems) {
|
||||
html += `<div class="nav-section">
|
||||
<div class="nav-section-title">${section.section}</div>`
|
||||
|
||||
|
||||
81
src/lib/app-state.js
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 全局应用状态
|
||||
* 管理 openclaw 安装状态,供各组件查询
|
||||
*/
|
||||
import { api } from './tauri-api.js'
|
||||
|
||||
let _openclawReady = false
|
||||
let _gatewayRunning = false
|
||||
let _listeners = []
|
||||
let _gwListeners = []
|
||||
|
||||
/** openclaw 是否就绪(CLI 已安装 + 配置文件存在) */
|
||||
export function isOpenclawReady() {
|
||||
return _openclawReady
|
||||
}
|
||||
|
||||
/** Gateway 是否正在运行 */
|
||||
export function isGatewayRunning() {
|
||||
return _gatewayRunning
|
||||
}
|
||||
|
||||
/** 监听 Gateway 状态变化 */
|
||||
export function onGatewayChange(fn) {
|
||||
_gwListeners.push(fn)
|
||||
return () => { _gwListeners = _gwListeners.filter(cb => cb !== fn) }
|
||||
}
|
||||
|
||||
/** 检测 openclaw 安装状态 */
|
||||
export async function detectOpenclawStatus() {
|
||||
try {
|
||||
const [installation, services] = await Promise.allSettled([
|
||||
api.checkInstallation(),
|
||||
api.getServicesStatus(),
|
||||
])
|
||||
const configExists = installation.status === 'fulfilled' && installation.value?.installed
|
||||
const cliInstalled = services.status === 'fulfilled'
|
||||
&& services.value?.length > 0
|
||||
&& services.value[0]?.cli_installed !== false
|
||||
_openclawReady = configExists && cliInstalled
|
||||
|
||||
// 顺便检测 Gateway 运行状态
|
||||
if (services.status === 'fulfilled' && services.value?.length > 0) {
|
||||
_setGatewayRunning(services.value[0]?.running === true)
|
||||
}
|
||||
} catch {
|
||||
_openclawReady = false
|
||||
}
|
||||
_listeners.forEach(fn => { try { fn(_openclawReady) } catch {} })
|
||||
return _openclawReady
|
||||
}
|
||||
|
||||
function _setGatewayRunning(val) {
|
||||
const changed = _gatewayRunning !== val
|
||||
_gatewayRunning = val
|
||||
if (changed) _gwListeners.forEach(fn => { try { fn(val) } catch {} })
|
||||
}
|
||||
|
||||
/** 刷新 Gateway 运行状态(轻量,仅查服务状态) */
|
||||
export async function refreshGatewayStatus() {
|
||||
try {
|
||||
const services = await api.getServicesStatus()
|
||||
if (services?.length > 0) _setGatewayRunning(services[0]?.running === true)
|
||||
} catch {}
|
||||
return _gatewayRunning
|
||||
}
|
||||
|
||||
let _pollTimer = null
|
||||
/** 启动 Gateway 状态轮询(每 5 秒) */
|
||||
export function startGatewayPoll() {
|
||||
if (_pollTimer) return
|
||||
_pollTimer = setInterval(() => refreshGatewayStatus(), 5000)
|
||||
}
|
||||
export function stopGatewayPoll() {
|
||||
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null }
|
||||
}
|
||||
|
||||
/** 监听状态变化 */
|
||||
export function onReadyChange(fn) {
|
||||
_listeners.push(fn)
|
||||
return () => { _listeners = _listeners.filter(cb => cb !== fn) }
|
||||
}
|
||||
@@ -5,9 +5,14 @@
|
||||
|
||||
const isTauri = !!window.__TAURI_INTERNALS__
|
||||
|
||||
// 预加载 Tauri invoke,避免每次 API 调用都做动态 import
|
||||
const _invokeReady = isTauri
|
||||
? import('@tauri-apps/api/core').then(m => m.invoke)
|
||||
: null
|
||||
|
||||
async function invoke(cmd, args = {}) {
|
||||
if (isTauri) {
|
||||
const { invoke: tauriInvoke } = await import('@tauri-apps/api/core')
|
||||
if (_invokeReady) {
|
||||
const tauriInvoke = await _invokeReady
|
||||
return tauriInvoke(cmd, args)
|
||||
}
|
||||
return mockInvoke(cmd, args)
|
||||
@@ -17,7 +22,7 @@ async function invoke(cmd, args = {}) {
|
||||
function mockInvoke(cmd, args) {
|
||||
const mocks = {
|
||||
get_services_status: () => [
|
||||
{ label: 'ai.openclaw.gateway', pid: null, running: false, description: 'OpenClaw Gateway' },
|
||||
{ label: 'ai.openclaw.gateway', pid: null, running: false, description: 'OpenClaw Gateway', cli_installed: true },
|
||||
],
|
||||
get_version_info: () => ({
|
||||
current: '2026.2.23',
|
||||
@@ -83,6 +88,7 @@ function mockInvoke(cmd, args) {
|
||||
delete_memory_file: () => true,
|
||||
export_memory_zip: ({ category }) => `/tmp/openclaw-${category}-20260226-160000.zip`,
|
||||
check_installation: () => ({ installed: true, path: '/usr/local/bin/openclaw', version: '2026.2.23' }),
|
||||
check_node: () => ({ installed: true, version: 'v20.11.0' }),
|
||||
get_deploy_config: () => ({ gatewayUrl: 'http://127.0.0.1:18789', authToken: '', version: '2026.2.23' }),
|
||||
read_mcp_config: () => ({
|
||||
mcpServers: {
|
||||
@@ -171,6 +177,7 @@ export const api = {
|
||||
|
||||
// 安装/部署
|
||||
checkInstallation: () => invoke('check_installation'),
|
||||
checkNode: () => invoke('check_node'),
|
||||
getDeployConfig: () => invoke('get_deploy_config'),
|
||||
writeEnvFile: (path, config) => invoke('write_env_file', { path, config }),
|
||||
|
||||
|
||||
91
src/main.js
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* ClawPanel 入口
|
||||
*/
|
||||
import { registerRoute, initRouter } from './router.js'
|
||||
import { registerRoute, initRouter, navigate, setDefaultRoute } from './router.js'
|
||||
import { renderSidebar } from './components/sidebar.js'
|
||||
import { initTheme } from './lib/theme.js'
|
||||
import { detectOpenclawStatus, isOpenclawReady, isGatewayRunning, onGatewayChange, startGatewayPoll } from './lib/app-state.js'
|
||||
import { api } from './lib/tauri-api.js'
|
||||
|
||||
// 样式
|
||||
import './style/variables.css'
|
||||
@@ -14,24 +16,81 @@ import './style/pages.css'
|
||||
import './style/chat.css'
|
||||
import './style/agents.css'
|
||||
|
||||
// 注册页面路由(懒加载)
|
||||
registerRoute('/dashboard', () => import('./pages/dashboard.js'))
|
||||
registerRoute('/chat', () => import('./pages/chat.js'))
|
||||
registerRoute('/services', () => import('./pages/services.js'))
|
||||
registerRoute('/logs', () => import('./pages/logs.js'))
|
||||
registerRoute('/models', () => import('./pages/models.js'))
|
||||
registerRoute('/agents', () => import('./pages/agents.js'))
|
||||
registerRoute('/gateway', () => import('./pages/gateway.js'))
|
||||
registerRoute('/memory', () => import('./pages/memory.js'))
|
||||
registerRoute('/extensions', () => import('./pages/extensions.js'))
|
||||
registerRoute('/about', () => import('./pages/about.js'))
|
||||
|
||||
// 初始化主题
|
||||
initTheme()
|
||||
|
||||
// 初始化
|
||||
const sidebar = document.getElementById('sidebar')
|
||||
const content = document.getElementById('content')
|
||||
|
||||
renderSidebar(sidebar)
|
||||
initRouter(content)
|
||||
async function boot() {
|
||||
await detectOpenclawStatus()
|
||||
|
||||
if (isOpenclawReady()) {
|
||||
// 正常模式:注册所有页面
|
||||
registerRoute('/dashboard', () => import('./pages/dashboard.js'))
|
||||
registerRoute('/chat', () => import('./pages/chat.js'))
|
||||
registerRoute('/services', () => import('./pages/services.js'))
|
||||
registerRoute('/logs', () => import('./pages/logs.js'))
|
||||
registerRoute('/models', () => import('./pages/models.js'))
|
||||
registerRoute('/agents', () => import('./pages/agents.js'))
|
||||
registerRoute('/gateway', () => import('./pages/gateway.js'))
|
||||
registerRoute('/memory', () => import('./pages/memory.js'))
|
||||
registerRoute('/extensions', () => import('./pages/extensions.js'))
|
||||
registerRoute('/about', () => import('./pages/about.js'))
|
||||
} else {
|
||||
// 未安装模式:只注册 setup、extensions、about
|
||||
setDefaultRoute('/setup')
|
||||
registerRoute('/setup', () => import('./pages/setup.js'))
|
||||
registerRoute('/extensions', () => import('./pages/extensions.js'))
|
||||
registerRoute('/about', () => import('./pages/about.js'))
|
||||
}
|
||||
|
||||
renderSidebar(sidebar)
|
||||
initRouter(content)
|
||||
|
||||
// 未安装时强制跳转到 setup
|
||||
if (!isOpenclawReady()) {
|
||||
navigate('/setup')
|
||||
return
|
||||
}
|
||||
|
||||
// Gateway 未启动引导横幅
|
||||
setupGatewayBanner()
|
||||
startGatewayPoll()
|
||||
}
|
||||
|
||||
function setupGatewayBanner() {
|
||||
const banner = document.getElementById('gw-banner')
|
||||
if (!banner) return
|
||||
|
||||
function update(running) {
|
||||
if (running) {
|
||||
banner.classList.add('gw-banner-hidden')
|
||||
} else {
|
||||
banner.classList.remove('gw-banner-hidden')
|
||||
banner.innerHTML = `
|
||||
<div class="gw-banner-content">
|
||||
<span class="gw-banner-icon">⚠</span>
|
||||
<span>Gateway 未启动,部分功能不可用</span>
|
||||
<button class="btn btn-sm btn-primary" id="btn-gw-start">启动 Gateway</button>
|
||||
</div>
|
||||
`
|
||||
banner.querySelector('#btn-gw-start')?.addEventListener('click', async (e) => {
|
||||
const btn = e.target
|
||||
btn.disabled = true
|
||||
btn.textContent = '启动中...'
|
||||
try {
|
||||
await api.startService('ai.openclaw.gateway')
|
||||
} catch (err) {
|
||||
btn.textContent = '启动失败,重试'
|
||||
btn.disabled = false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
update(isGatewayRunning())
|
||||
onGatewayChange(update)
|
||||
}
|
||||
|
||||
boot()
|
||||
|
||||
@@ -12,7 +12,7 @@ export async function render() {
|
||||
|
||||
page.innerHTML = `
|
||||
<div class="page-header" style="display:flex;align-items:center;gap:16px">
|
||||
<img src="/images/logo.svg" alt="ClawPanel" style="width:48px;height:48px;border-radius:var(--radius-md)">
|
||||
<img src="/images/logo-brand.png" alt="ClawPanel" style="height:48px;width:auto">
|
||||
<div>
|
||||
<h1 class="page-title" style="margin:0">ClawPanel</h1>
|
||||
<p class="page-desc" style="margin:0">OpenClaw 可视化管理面板</p>
|
||||
|
||||
@@ -21,12 +21,13 @@ export async function render() {
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-content">
|
||||
<div id="agents-list"></div>
|
||||
<div id="agents-list" class="loading-text">加载中...</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
const state = { agents: [] }
|
||||
await loadAgents(page, state)
|
||||
// 非阻塞:先返回 DOM,后台加载数据
|
||||
loadAgents(page, state)
|
||||
|
||||
page.querySelector('#btn-add-agent').addEventListener('click', () => showAddAgentDialog(page, state))
|
||||
|
||||
|
||||
@@ -109,7 +109,8 @@ export async function render() {
|
||||
_cmdPanelEl = page.querySelector('#chat-cmd-panel')
|
||||
|
||||
bindEvents(page)
|
||||
await connectGateway()
|
||||
// 非阻塞:先返回 DOM,后台连接 Gateway
|
||||
connectGateway()
|
||||
return page
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function render() {
|
||||
</div>
|
||||
<div class="config-section">
|
||||
<div class="config-section-title">最近日志</div>
|
||||
<div class="log-viewer" id="recent-logs" style="max-height:300px">加载中...</div>
|
||||
<div class="log-viewer" id="recent-logs" style="max-height:300px"><div class="loading-text">加载中...</div></div>
|
||||
</div>
|
||||
`
|
||||
|
||||
|
||||
@@ -27,12 +27,12 @@ export async function render() {
|
||||
<div id="cftunnel-card" class="config-section">
|
||||
<div class="config-section-title">cftunnel 内网穿透</div>
|
||||
<div class="form-hint" style="margin-bottom:var(--space-md)">通过 Cloudflare Tunnel 将本地服务暴露到公网,无需公网 IP 和端口映射。</div>
|
||||
<div id="cftunnel-content">加载中...</div>
|
||||
<div id="cftunnel-content" class="loading-text">加载中...</div>
|
||||
</div>
|
||||
<div id="clawapp-card" class="config-section">
|
||||
<div class="config-section-title">ClawApp 移动客户端</div>
|
||||
<div class="form-hint" style="margin-bottom:var(--space-md)">基于 LobeChat 的 AI 对话客户端,通过 Gateway 连接模型服务。支持本地和外网访问。</div>
|
||||
<div id="clawapp-content">加载中...</div>
|
||||
<div id="clawapp-content" class="loading-text">加载中...</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function render() {
|
||||
<h1 class="page-title">Gateway 配置</h1>
|
||||
<p class="page-desc">Gateway 是 AI 模型的统一入口,所有应用通过它来调用模型服务</p>
|
||||
</div>
|
||||
<div id="gateway-config">加载中...</div>
|
||||
<div id="gateway-config" class="loading-text">加载中...</div>
|
||||
<div class="gw-save-bar">
|
||||
<button class="btn btn-primary" id="btn-save-gw">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/></svg>
|
||||
@@ -24,7 +24,8 @@ export async function render() {
|
||||
`
|
||||
|
||||
const state = { config: null }
|
||||
await loadConfig(page, state)
|
||||
// 非阻塞:先返回 DOM,后台加载数据
|
||||
loadConfig(page, state)
|
||||
page.querySelector('#btn-save-gw').onclick = async () => {
|
||||
const btn = page.querySelector('#btn-save-gw')
|
||||
btn.disabled = true
|
||||
|
||||
@@ -33,7 +33,7 @@ export async function render() {
|
||||
<input type="checkbox" id="log-autoscroll" checked> 自动滚动
|
||||
</label>
|
||||
</div>
|
||||
<div class="log-viewer" id="log-content" style="height:calc(100vh - 280px)">加载中...</div>
|
||||
<div class="log-viewer" id="log-content" style="height:calc(100vh - 280px)"><div class="loading-text">加载中...</div></div>
|
||||
`
|
||||
|
||||
let currentTab = 'gateway'
|
||||
|
||||
@@ -15,23 +15,12 @@ export async function render() {
|
||||
const page = document.createElement('div')
|
||||
page.className = 'page'
|
||||
|
||||
// 先获取 agent 列表
|
||||
let agents = []
|
||||
try {
|
||||
agents = await api.listAgents()
|
||||
} catch { agents = [{ id: 'main', identityName: '默认' }] }
|
||||
|
||||
const agentOptions = agents.map(a => {
|
||||
const label = a.identityName ? a.identityName.split(',')[0].trim() : a.id
|
||||
return `<option value="${a.id}">${a.id}${a.id !== label ? ' — ' + label : ''}</option>`
|
||||
}).join('')
|
||||
|
||||
page.innerHTML = `
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">记忆文件</h1>
|
||||
<div class="page-actions" style="display:flex;align-items:center;gap:var(--space-sm)">
|
||||
<label style="font-size:var(--font-size-sm);color:var(--text-tertiary)">Agent:</label>
|
||||
<select class="form-input" id="agent-select" style="width:auto;min-width:140px">${agentOptions}</select>
|
||||
<select class="form-input" id="agent-select" style="width:auto;min-width:140px"><option value="main">main</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-bar">
|
||||
@@ -47,7 +36,7 @@ export async function render() {
|
||||
<div style="padding:0 var(--space-sm) var(--space-sm)">
|
||||
<button class="btn btn-sm btn-secondary" id="btn-export-zip" style="width:100%">打包下载全部</button>
|
||||
</div>
|
||||
<div id="file-tree">加载中...</div>
|
||||
<div id="file-tree" class="loading-text">加载中...</div>
|
||||
</div>
|
||||
<div class="memory-editor">
|
||||
<div class="editor-toolbar">
|
||||
@@ -65,6 +54,17 @@ export async function render() {
|
||||
|
||||
const state = { category: 'memory', currentPath: null, agentId: 'main' }
|
||||
|
||||
// 非阻塞加载 agent 列表,然后填充下拉框
|
||||
api.listAgents().then(agents => {
|
||||
const select = page.querySelector('#agent-select')
|
||||
if (!select) return
|
||||
const options = agents.map(a => {
|
||||
const label = a.identityName ? a.identityName.split(',')[0].trim() : a.id
|
||||
return `<option value="${a.id}">${a.id}${a.id !== label ? ' — ' + label : ''}</option>`
|
||||
}).join('')
|
||||
select.innerHTML = options
|
||||
}).catch(() => {})
|
||||
|
||||
// Agent 切换
|
||||
page.querySelector('#agent-select').onchange = (e) => {
|
||||
state.agentId = e.target.value
|
||||
|
||||
@@ -64,11 +64,12 @@ export async function render() {
|
||||
<div style="margin-bottom:var(--space-md)">
|
||||
<input class="form-input" id="model-search" placeholder="搜索模型(按 ID 或名称过滤)" style="max-width:360px">
|
||||
</div>
|
||||
<div id="providers-list">加载中...</div>
|
||||
<div id="providers-list" class="loading-text">加载中...</div>
|
||||
`
|
||||
|
||||
const state = { config: null, search: '', undoStack: [] }
|
||||
await loadConfig(page, state)
|
||||
// 非阻塞:先返回 DOM,后台加载数据
|
||||
loadConfig(page, state)
|
||||
bindTopActions(page, state)
|
||||
|
||||
// 搜索框实时过滤
|
||||
|
||||
@@ -26,10 +26,10 @@ export async function render() {
|
||||
<p class="page-desc">管理 OpenClaw 服务、检查更新、配置备份</p>
|
||||
</div>
|
||||
<div id="version-bar"></div>
|
||||
<div id="services-list">加载中...</div>
|
||||
<div id="services-list" class="loading-text">加载中...</div>
|
||||
<div class="config-section" id="registry-section">
|
||||
<div class="config-section-title">npm 源设置</div>
|
||||
<div id="registry-bar">加载中...</div>
|
||||
<div id="registry-bar" class="loading-text">加载中...</div>
|
||||
</div>
|
||||
<div class="config-section" id="backup-section">
|
||||
<div class="config-section-title">配置备份</div>
|
||||
@@ -37,7 +37,7 @@ export async function render() {
|
||||
<div id="backup-actions" style="margin-bottom:var(--space-md)">
|
||||
<button class="btn btn-primary btn-sm" data-action="create-backup">创建备份</button>
|
||||
</div>
|
||||
<div id="backup-list">加载中...</div>
|
||||
<div id="backup-list" class="loading-text">加载中...</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
@@ -141,25 +141,37 @@ async function loadServices(page) {
|
||||
function renderServices(container, services) {
|
||||
const gw = services.find(s => s.label === 'ai.openclaw.gateway')
|
||||
|
||||
// Gateway 专属卡片(带安装/卸载)
|
||||
let html = ''
|
||||
if (gw) {
|
||||
// 检测 CLI 是否安装
|
||||
const cliMissing = gw.cli_installed === false
|
||||
|
||||
html += `
|
||||
<div class="service-card" data-label="${gw.label}">
|
||||
<div class="service-info">
|
||||
<span class="status-dot ${gw.running ? 'running' : 'stopped'}"></span>
|
||||
<span class="status-dot ${cliMissing ? 'stopped' : gw.running ? 'running' : 'stopped'}"></span>
|
||||
<div>
|
||||
<div class="service-name">${gw.label}</div>
|
||||
<div class="service-desc">${gw.description || ''}${gw.pid ? ' (PID: ' + gw.pid + ')' : ''}</div>
|
||||
<div class="service-desc">${cliMissing
|
||||
? 'OpenClaw CLI 未安装'
|
||||
: (gw.description || '') + (gw.pid ? ' (PID: ' + gw.pid + ')' : '')
|
||||
}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="service-actions">
|
||||
${gw.running
|
||||
? `<button class="btn btn-secondary btn-sm" data-action="restart" data-label="${gw.label}">重启</button>
|
||||
<button class="btn btn-danger btn-sm" data-action="stop" data-label="${gw.label}">停止</button>
|
||||
<button class="btn btn-danger btn-sm" data-action="uninstall-gateway">卸载</button>`
|
||||
: `<button class="btn btn-primary btn-sm" data-action="start" data-label="${gw.label}">启动</button>
|
||||
<button class="btn btn-danger btn-sm" data-action="uninstall-gateway">卸载</button>`
|
||||
${cliMissing
|
||||
? `<div style="display:flex;flex-direction:column;gap:var(--space-xs);align-items:flex-end">
|
||||
<div style="color:var(--text-tertiary);font-size:var(--font-size-xs)">请先安装 OpenClaw CLI:</div>
|
||||
<code style="font-size:var(--font-size-xs);background:var(--bg-tertiary);padding:2px 8px;border-radius:4px;user-select:all">npm install -g @qingchencloud/openclaw-zh</code>
|
||||
<button class="btn btn-secondary btn-sm" data-action="refresh-services" style="margin-top:4px">刷新状态</button>
|
||||
</div>`
|
||||
: gw.running
|
||||
? `<button class="btn btn-secondary btn-sm" data-action="restart" data-label="${gw.label}">重启</button>
|
||||
<button class="btn btn-danger btn-sm" data-action="stop" data-label="${gw.label}">停止</button>
|
||||
<button class="btn btn-danger btn-sm" data-action="uninstall-gateway">卸载</button>`
|
||||
: `<button class="btn btn-primary btn-sm" data-action="start" data-label="${gw.label}">启动</button>
|
||||
<button class="btn btn-primary btn-sm" data-action="install-gateway">安装</button>
|
||||
<button class="btn btn-danger btn-sm" data-action="uninstall-gateway">卸载</button>`
|
||||
}
|
||||
</div>
|
||||
</div>`
|
||||
@@ -255,6 +267,9 @@ function bindEvents(page) {
|
||||
case 'uninstall-gateway':
|
||||
await handleUninstallGateway(btn, page)
|
||||
break
|
||||
case 'refresh-services':
|
||||
await loadServices(page)
|
||||
break
|
||||
case 'save-registry':
|
||||
await handleSaveRegistry(btn, page)
|
||||
break
|
||||
|
||||
215
src/pages/setup.js
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* 初始设置页面 — openclaw 未安装时的引导
|
||||
* 自动检测环境 → 版本选择 → 一键安装 → 自动跳转
|
||||
*/
|
||||
import { api } from '../lib/tauri-api.js'
|
||||
import { showUpgradeModal } from '../components/modal.js'
|
||||
import { toast } from '../components/toast.js'
|
||||
|
||||
export async function render() {
|
||||
const page = document.createElement('div')
|
||||
page.className = 'page'
|
||||
|
||||
page.innerHTML = `
|
||||
<div style="max-width:560px;margin:48px auto;text-align:center">
|
||||
<div style="margin-bottom:var(--space-lg)">
|
||||
<img src="/images/logo-brand.png" alt="ClawPanel" style="max-width:160px;width:100%;height:auto">
|
||||
</div>
|
||||
<h1 style="font-size:var(--font-size-xl);margin-bottom:var(--space-xs)">欢迎使用 ClawPanel</h1>
|
||||
<p style="color:var(--text-secondary);margin-bottom:var(--space-xl);line-height:1.6">
|
||||
OpenClaw AI Agent 框架的桌面管理面板
|
||||
</p>
|
||||
|
||||
<div id="setup-steps"></div>
|
||||
|
||||
<div style="margin-top:var(--space-lg)">
|
||||
<button class="btn btn-secondary btn-sm" id="btn-recheck" style="min-width:120px">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:4px"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
|
||||
重新检测
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
page.querySelector('#btn-recheck').addEventListener('click', () => runDetect(page))
|
||||
runDetect(page)
|
||||
return page
|
||||
}
|
||||
|
||||
async function runDetect(page) {
|
||||
const stepsEl = page.querySelector('#setup-steps')
|
||||
stepsEl.innerHTML = `
|
||||
<div class="stat-card loading-placeholder" style="height:48px"></div>
|
||||
<div class="stat-card loading-placeholder" style="height:48px;margin-top:8px"></div>
|
||||
<div class="stat-card loading-placeholder" style="height:48px;margin-top:8px"></div>
|
||||
`
|
||||
// 并行检测 Node.js、OpenClaw CLI、配置文件
|
||||
const [nodeRes, clawRes, configRes] = await Promise.allSettled([
|
||||
api.checkNode(),
|
||||
api.getServicesStatus(),
|
||||
api.checkInstallation(),
|
||||
])
|
||||
|
||||
const node = nodeRes.status === 'fulfilled' ? nodeRes.value : { installed: false }
|
||||
const cliOk = clawRes.status === 'fulfilled'
|
||||
&& clawRes.value?.length > 0
|
||||
&& clawRes.value[0]?.cli_installed !== false
|
||||
const config = configRes.status === 'fulfilled' ? configRes.value : { installed: false }
|
||||
|
||||
renderSteps(page, { node, cliOk, config })
|
||||
}
|
||||
|
||||
function stepIcon(ok) {
|
||||
const color = ok ? 'var(--success)' : 'var(--text-tertiary)'
|
||||
return `<span style="color:${color};font-weight:700;width:18px;display:inline-block">${ok ? '✓' : '✗'}</span>`
|
||||
}
|
||||
|
||||
function renderSteps(page, { node, cliOk, config }) {
|
||||
const stepsEl = page.querySelector('#setup-steps')
|
||||
const nodeOk = node.installed
|
||||
const allOk = nodeOk && cliOk && config.installed
|
||||
|
||||
let html = ''
|
||||
|
||||
// 第一步:Node.js
|
||||
html += `
|
||||
<div class="config-section" style="text-align:left">
|
||||
<div class="config-section-title" style="display:flex;align-items:center;gap:4px">
|
||||
${stepIcon(nodeOk)} Node.js 环境
|
||||
</div>
|
||||
${nodeOk
|
||||
? `<p style="color:var(--success);font-size:var(--font-size-sm)">已安装 ${node.version || ''}</p>`
|
||||
: `<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm)">
|
||||
OpenClaw 基于 Node.js 运行,请先安装。
|
||||
</p>
|
||||
<a class="btn btn-primary btn-sm" href="https://nodejs.org/" target="_blank" rel="noopener">下载 Node.js</a>
|
||||
<span class="form-hint" style="margin-left:8px">安装后点击「重新检测」</span>`
|
||||
}
|
||||
</div>
|
||||
`
|
||||
|
||||
// 第二步:OpenClaw CLI
|
||||
html += `
|
||||
<div class="config-section" style="text-align:left;${nodeOk ? '' : 'opacity:0.4;pointer-events:none'}">
|
||||
<div class="config-section-title" style="display:flex;align-items:center;gap:4px">
|
||||
${stepIcon(cliOk)} OpenClaw CLI
|
||||
</div>
|
||||
${cliOk
|
||||
? `<p style="color:var(--success);font-size:var(--font-size-sm)">CLI 可用</p>`
|
||||
: renderInstallSection()
|
||||
}
|
||||
</div>
|
||||
`
|
||||
// 第三步:配置文件
|
||||
html += `
|
||||
<div class="config-section" style="text-align:left;${cliOk ? '' : 'opacity:0.4;pointer-events:none'}">
|
||||
<div class="config-section-title" style="display:flex;align-items:center;gap:4px">
|
||||
${stepIcon(config.installed)} 配置文件
|
||||
</div>
|
||||
${config.installed
|
||||
? `<p style="color:var(--success);font-size:var(--font-size-sm)">配置文件位于 ${config.path || ''}</p>`
|
||||
: `<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm)">
|
||||
安装 CLI 后会自动生成配置,也可手动执行 <code>openclaw configure</code>
|
||||
</p>`
|
||||
}
|
||||
</div>
|
||||
`
|
||||
|
||||
// 全部就绪 → 进入面板
|
||||
if (allOk) {
|
||||
html += `
|
||||
<div style="margin-top:var(--space-lg)">
|
||||
<button class="btn btn-primary" id="btn-enter" style="min-width:200px">进入面板</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
stepsEl.innerHTML = html
|
||||
bindEvents(page, nodeOk)
|
||||
}
|
||||
|
||||
function renderInstallSection() {
|
||||
return `
|
||||
<p style="color:var(--text-secondary);font-size:var(--font-size-sm);margin-bottom:var(--space-sm)">
|
||||
选择版本后点击安装,将自动执行 npm 全局安装。
|
||||
</p>
|
||||
<div style="display:flex;gap:var(--space-sm);margin-bottom:var(--space-sm)">
|
||||
<label class="setup-source-option" style="flex:1;cursor:pointer">
|
||||
<input type="radio" name="install-source" value="chinese" checked style="margin-right:6px">
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:var(--font-size-sm)">汉化优化版(推荐)</div>
|
||||
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary)">@qingchencloud/openclaw-zh</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="setup-source-option" style="flex:1;cursor:pointer">
|
||||
<input type="radio" name="install-source" value="official" style="margin-right:6px">
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:var(--font-size-sm)">官方原版</div>
|
||||
<div style="font-size:var(--font-size-xs);color:var(--text-tertiary)">openclaw</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div style="margin-bottom:var(--space-sm)">
|
||||
<label style="font-size:var(--font-size-xs);color:var(--text-tertiary);display:block;margin-bottom:4px">npm 镜像源</label>
|
||||
<select id="registry-select" style="width:100%;padding:6px 8px;border-radius:var(--radius-sm);border:1px solid var(--border-primary);background:var(--bg-secondary);color:var(--text-primary);font-size:var(--font-size-sm)">
|
||||
<option value="https://registry.npmmirror.com">淘宝镜像(推荐国内用户)</option>
|
||||
<option value="https://registry.npmjs.org">npm 官方源</option>
|
||||
<option value="https://repo.huaweicloud.com/repository/npm/">华为云镜像</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" id="btn-install">一键安装</button>
|
||||
`
|
||||
}
|
||||
|
||||
function bindEvents(page, nodeOk) {
|
||||
// 进入面板
|
||||
page.querySelector('#btn-enter')?.addEventListener('click', () => {
|
||||
window.location.reload()
|
||||
})
|
||||
|
||||
// 一键安装
|
||||
const installBtn = page.querySelector('#btn-install')
|
||||
if (!installBtn || !nodeOk) return
|
||||
|
||||
installBtn.addEventListener('click', async () => {
|
||||
const source = page.querySelector('input[name="install-source"]:checked')?.value || 'chinese'
|
||||
const registry = page.querySelector('#registry-select')?.value
|
||||
const modal = showUpgradeModal()
|
||||
let unlistenLog, unlistenProgress
|
||||
|
||||
try {
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
unlistenLog = await listen('upgrade-log', (e) => modal.appendLog(e.payload))
|
||||
unlistenProgress = await listen('upgrade-progress', (e) => modal.setProgress(e.payload))
|
||||
|
||||
// 先设置镜像源
|
||||
if (registry) {
|
||||
modal.appendLog(`设置 npm 镜像源: ${registry}`)
|
||||
try { await api.setNpmRegistry(registry) } catch {}
|
||||
}
|
||||
|
||||
const msg = await api.upgradeOpenclaw(source)
|
||||
modal.setDone(msg)
|
||||
|
||||
// 安装成功后自动安装 Gateway
|
||||
modal.appendLog('正在安装 Gateway 服务...')
|
||||
try {
|
||||
await api.installGateway()
|
||||
modal.appendLog('✅ Gateway 服务已安装')
|
||||
} catch (e) {
|
||||
modal.appendLog('⚠️ Gateway 安装失败: ' + e)
|
||||
}
|
||||
|
||||
toast('OpenClaw 安装成功', 'success')
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
} catch (e) {
|
||||
modal.appendLog(String(e))
|
||||
modal.setError('安装失败')
|
||||
} finally {
|
||||
unlistenLog?.()
|
||||
unlistenProgress?.()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,15 +2,22 @@
|
||||
* 极简 hash 路由
|
||||
*/
|
||||
const routes = {}
|
||||
const _moduleCache = {}
|
||||
let _contentEl = null
|
||||
let _loadId = 0
|
||||
let _currentCleanup = null
|
||||
let _initialized = false
|
||||
|
||||
let _defaultRoute = '/dashboard'
|
||||
|
||||
export function registerRoute(path, loader) {
|
||||
routes[path] = loader
|
||||
}
|
||||
|
||||
export function setDefaultRoute(path) {
|
||||
_defaultRoute = path
|
||||
}
|
||||
|
||||
export function navigate(path) {
|
||||
window.location.hash = path
|
||||
}
|
||||
@@ -25,7 +32,7 @@ export function initRouter(contentEl) {
|
||||
}
|
||||
|
||||
async function loadRoute() {
|
||||
const hash = window.location.hash.slice(1) || '/dashboard'
|
||||
const hash = window.location.hash.slice(1) || _defaultRoute
|
||||
const loader = routes[hash]
|
||||
if (!loader || !_contentEl) return
|
||||
|
||||
@@ -38,18 +45,32 @@ async function loadRoute() {
|
||||
_currentCleanup = null
|
||||
}
|
||||
|
||||
_contentEl.innerHTML = ''
|
||||
// 退出动画:如果有旧页面,播放退出动画后再替换
|
||||
const oldPage = _contentEl.querySelector('.page, .page-loader, .chat-page')
|
||||
if (oldPage) {
|
||||
oldPage.classList.add('page-exit')
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
if (thisLoad !== _loadId) return
|
||||
}
|
||||
|
||||
// 显示加载动画
|
||||
const loader_el = document.createElement('div')
|
||||
loader_el.className = 'page-loader'
|
||||
loader_el.innerHTML = `
|
||||
<div class="page-loader-spinner"></div>
|
||||
<div class="page-loader-text">加载中...</div>
|
||||
`
|
||||
_contentEl.appendChild(loader_el)
|
||||
// 已缓存的模块:跳过 spinner,直接渲染
|
||||
let mod = _moduleCache[hash]
|
||||
if (!mod) {
|
||||
_contentEl.innerHTML = ''
|
||||
// 仅首次加载显示 spinner
|
||||
const spinnerEl = document.createElement('div')
|
||||
spinnerEl.className = 'page-loader'
|
||||
spinnerEl.innerHTML = `
|
||||
<div class="page-loader-spinner"></div>
|
||||
<div class="page-loader-text">加载中...</div>
|
||||
`
|
||||
_contentEl.appendChild(spinnerEl)
|
||||
|
||||
const mod = await loader()
|
||||
mod = await loader()
|
||||
_moduleCache[hash] = mod
|
||||
} else {
|
||||
_contentEl.innerHTML = ''
|
||||
}
|
||||
|
||||
// 如果加载期间路由又变了,丢弃本次结果
|
||||
if (thisLoad !== _loadId) return
|
||||
@@ -57,7 +78,7 @@ async function loadRoute() {
|
||||
const page = mod.render ? await mod.render() : mod.default ? await mod.default() : mod
|
||||
if (thisLoad !== _loadId) return
|
||||
|
||||
// 移除加载动画,插入页面内容
|
||||
// 插入页面内容
|
||||
_contentEl.innerHTML = ''
|
||||
if (typeof page === 'string') {
|
||||
_contentEl.innerHTML = page
|
||||
@@ -75,5 +96,5 @@ async function loadRoute() {
|
||||
}
|
||||
|
||||
export function getCurrentRoute() {
|
||||
return window.location.hash.slice(1) || '/dashboard'
|
||||
return window.location.hash.slice(1) || _defaultRoute
|
||||
}
|
||||
|
||||
@@ -205,6 +205,16 @@ mark {
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
.loading-text {
|
||||
color: var(--text-tertiary);
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
|
||||
@@ -24,15 +24,17 @@
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-inverse);
|
||||
flex-shrink: 0;
|
||||
padding: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-logo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.sidebar-logo svg {
|
||||
@@ -106,10 +108,18 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 主内容列(横幅 + 内容区) */
|
||||
#main-col {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 主内容区 */
|
||||
#content {
|
||||
flex: 1;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
@@ -117,7 +127,7 @@
|
||||
.page {
|
||||
padding: var(--space-xl) var(--space-2xl);
|
||||
max-width: 1200px;
|
||||
animation: fadeIn 200ms ease;
|
||||
animation: pageIn 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
@@ -143,7 +153,70 @@
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
@keyframes pageIn {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes pageOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
|
||||
.page-exit {
|
||||
animation: pageOut 100ms ease forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Setup 页面版本选择卡片 */
|
||||
.setup-source-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.setup-source-option:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-glass-hover);
|
||||
}
|
||||
|
||||
.setup-source-option:has(input:checked) {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
/* Gateway 未启动引导横幅 */
|
||||
.gw-banner {
|
||||
background: var(--warning, #f59e0b);
|
||||
color: #000;
|
||||
padding: 8px 16px;
|
||||
font-size: var(--font-size-sm);
|
||||
z-index: 100;
|
||||
transition: all 300ms ease;
|
||||
overflow: hidden;
|
||||
max-height: 50px;
|
||||
}
|
||||
.gw-banner-hidden {
|
||||
max-height: 0;
|
||||
padding: 0 16px;
|
||||
opacity: 0;
|
||||
}
|
||||
.gw-banner-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.gw-banner-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
.gw-banner .btn {
|
||||
margin-left: auto;
|
||||
background: rgba(0,0,0,0.15);
|
||||
border: none;
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||