feat: 版本管理 + macOS提示优化 + 部署文档更新

- OpenClaw 版本管理: 安装/升级/降级/切换版本, 汉化版/原版选择
- 新增 list_openclaw_versions API (Rust + Web)
- upgrade_openclaw 支持指定版本号
- 版本选择器弹窗 (about.js)
- macOS Gatekeeper 提示优化: 强调拖入应用程序, No such file 备选
- 部署文档统一使用 npm run serve 替代 npx vite
- showUpgradeModal 支持自定义标题 + onClose 回调
- serve.js 路径分隔符跨平台修复
- 扩展工具页面优化 + AI助手危险工具确认
This commit is contained in:
晴天
2026-03-08 01:46:27 +08:00
parent dbc2aa8a61
commit 02e1ef6b14
23 changed files with 1892 additions and 381 deletions

View File

@@ -495,16 +495,55 @@ fn npm_package_name(source: &str) -> &'static str {
}
}
/// 执行 npm 全局升级 openclaw流式推送日志
/// 获取指定源的所有可用版本列表(从 npm registry 查询
#[tauri::command]
pub async fn upgrade_openclaw(app: tauri::AppHandle, source: String) -> Result<String, String> {
pub async fn list_openclaw_versions(source: String) -> Result<Vec<String>, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| format!("HTTP 初始化失败: {e}"))?;
let pkg = npm_package_name(&source)
.replace('/', "%2F");
let registry = get_configured_registry();
let url = format!("{registry}/{pkg}");
let resp = client
.get(&url)
.header("Accept", "application/json")
.send()
.await
.map_err(|e| format!("查询版本失败: {e}"))?;
let json: Value = resp
.json()
.await
.map_err(|e| format!("解析响应失败: {e}"))?;
let versions = json
.get("versions")
.and_then(|v| v.as_object())
.map(|obj| {
let mut vers: Vec<String> = obj.keys().cloned().collect();
// 按版本号排序(新版本在前)
vers.sort_by(|a, b| {
let pa: Vec<u32> = a.split(|c: char| !c.is_ascii_digit()).filter_map(|s| s.parse().ok()).collect();
let pb: Vec<u32> = b.split(|c: char| !c.is_ascii_digit()).filter_map(|s| s.parse().ok()).collect();
pb.cmp(&pa)
});
vers
})
.unwrap_or_default();
Ok(versions)
}
/// 执行 npm 全局安装/升级/降级 openclaw流式推送日志
#[tauri::command]
pub async fn upgrade_openclaw(app: tauri::AppHandle, source: String, version: Option<String>) -> Result<String, String> {
use std::io::{BufRead, BufReader};
use std::process::Stdio;
use tauri::Emitter;
let current_source = detect_installed_source();
let pkg_name = npm_package_name(&source);
let pkg = format!("{}@latest", pkg_name);
let ver = version.as_deref().unwrap_or("latest");
let pkg = format!("{}@{}", pkg_name, ver);
// 切换源时需要卸载旧包,但为避免安装失败导致 CLI 丢失,
// 先安装新包,成功后再卸载旧包
@@ -652,11 +691,128 @@ pub async fn upgrade_openclaw(app: tauri::AppHandle, source: String) -> Result<S
}
let new_ver = get_local_version().await.unwrap_or_else(|| "未知".into());
let msg = format!("✅ 升级成功,当前版本: {new_ver}");
let action = if ver == "latest" { "升级" } else { "安装" };
let msg = format!("{action}成功,当前版本: {new_ver}");
let _ = app.emit("upgrade-log", &msg);
Ok(msg)
}
/// 卸载 OpenClawnpm uninstall + 可选清理配置)
#[tauri::command]
pub async fn uninstall_openclaw(
app: tauri::AppHandle,
clean_config: bool,
) -> Result<String, String> {
use std::io::{BufRead, BufReader};
use std::process::Stdio;
use tauri::Emitter;
let source = detect_installed_source();
let pkg = npm_package_name(&source);
// 1. 先停止 Gateway
let _ = app.emit("upgrade-log", "正在停止 Gateway...");
#[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();
}
// 2. 卸载 Gateway 服务
let _ = app.emit("upgrade-log", "正在卸载 Gateway 服务...");
#[cfg(not(target_os = "macos"))]
{
let _ = openclaw_command()
.args(["gateway", "uninstall"])
.output();
}
// 3. npm uninstall
let _ = app.emit("upgrade-log", format!("$ npm uninstall -g {pkg}"));
let _ = app.emit("upgrade-progress", 20);
let mut child = npm_command()
.args(["uninstall", "-g", pkg])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("执行卸载命令失败: {e}"))?;
let stderr = child.stderr.take();
let stdout = child.stdout.take();
let app2 = app.clone();
let handle = std::thread::spawn(move || {
if let Some(pipe) = stderr {
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
let _ = app2.emit("upgrade-log", &line);
}
}
});
if let Some(pipe) = stdout {
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
let _ = app.emit("upgrade-log", &line);
}
}
let _ = handle.join();
let _ = app.emit("upgrade-progress", 60);
let status = child.wait().map_err(|e| format!("等待进程失败: {e}"))?;
if !status.success() {
let code = status
.code()
.map(|c| c.to_string())
.unwrap_or("unknown".into());
return Err(format!("卸载失败exit code: {code}"));
}
// 4. 两个包都尝试卸载(确保干净)
let other_pkg = if source == "official" {
"@qingchencloud/openclaw-zh"
} else {
"openclaw"
};
let _ = app.emit("upgrade-log", format!("清理 {other_pkg}..."));
let _ = npm_command()
.args(["uninstall", "-g", other_pkg])
.output();
let _ = app.emit("upgrade-progress", 80);
// 5. 可选:清理配置目录
if clean_config {
let config_dir = super::openclaw_dir();
if config_dir.exists() {
let _ = app.emit(
"upgrade-log",
format!("清理配置目录: {}", config_dir.display()),
);
if let Err(e) = std::fs::remove_dir_all(&config_dir) {
let _ = app.emit(
"upgrade-log",
format!("⚠️ 清理配置目录失败: {e}(可能有文件被占用)"),
);
}
}
}
let _ = app.emit("upgrade-progress", 100);
let msg = if clean_config {
"✅ OpenClaw 已完全卸载(包括配置文件)"
} else {
"✅ OpenClaw 已卸载(配置文件保留在 ~/.openclaw/"
};
let _ = app.emit("upgrade-log", msg);
Ok(msg.into())
}
/// 自动初始化配置文件CLI 已装但 openclaw.json 不存在时)
#[tauri::command]
pub fn init_openclaw_config() -> Result<Value, String> {

View File

@@ -10,6 +10,8 @@ pub mod logs;
pub mod memory;
pub mod pairing;
pub mod service;
pub mod skills;
pub mod update;
/// 获取 OpenClaw 配置目录 (~/.openclaw/)
pub fn openclaw_dir() -> PathBuf {

View File

@@ -0,0 +1,271 @@
use crate::utils::openclaw_command_async;
use serde_json::Value;
/// 列出所有 Skills 及其状态openclaw skills list --json
#[tauri::command]
pub async fn skills_list() -> Result<Value, String> {
let output = openclaw_command_async()
.args(["skills", "list", "--json", "--verbose"])
.output()
.await;
match output {
Ok(o) if o.status.success() => {
let stdout = String::from_utf8_lossy(&o.stdout);
serde_json::from_str(&stdout).map_err(|e| format!("解析失败: {e}"))
}
_ => {
// CLI 不可用时,兜底扫描本地 skills 目录
scan_local_skills()
}
}
}
/// 查看单个 Skill 详情openclaw skills info <name> --json
#[tauri::command]
pub async fn skills_info(name: String) -> Result<Value, String> {
let output = openclaw_command_async()
.args(["skills", "info", &name, "--json"])
.output()
.await
.map_err(|e| format!("执行 openclaw 失败: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("获取详情失败: {}", stderr.trim()));
}
let stdout = String::from_utf8_lossy(&output.stdout);
serde_json::from_str(&stdout).map_err(|e| format!("解析详情失败: {e}"))
}
/// 检查 Skills 依赖状态openclaw skills check --json
#[tauri::command]
pub async fn skills_check() -> Result<Value, String> {
let output = openclaw_command_async()
.args(["skills", "check", "--json"])
.output()
.await
.map_err(|e| format!("执行 openclaw 失败: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("检查失败: {}", stderr.trim()));
}
let stdout = String::from_utf8_lossy(&output.stdout);
serde_json::from_str(&stdout).map_err(|e| format!("解析失败: {e}"))
}
/// 安装 Skill 依赖(根据 install spec 执行 brew/npm/go/uv/download
#[tauri::command]
pub async fn skills_install_dep(kind: String, spec: Value) -> Result<Value, String> {
let path_env = super::enhanced_path();
let (program, args) = match kind.as_str() {
"brew" => {
let formula = spec
.get("formula")
.and_then(|v| v.as_str())
.ok_or("缺少 formula 参数")?
.to_string();
("brew".to_string(), vec!["install".to_string(), formula])
}
"node" => {
let package = spec
.get("package")
.and_then(|v| v.as_str())
.ok_or("缺少 package 参数")?
.to_string();
("npm".to_string(), vec!["install".to_string(), "-g".to_string(), package])
}
"go" => {
let module = spec
.get("module")
.and_then(|v| v.as_str())
.ok_or("缺少 module 参数")?
.to_string();
("go".to_string(), vec!["install".to_string(), module])
}
"uv" => {
let package = spec
.get("package")
.and_then(|v| v.as_str())
.ok_or("缺少 package 参数")?
.to_string();
("uv".to_string(), vec!["tool".to_string(), "install".to_string(), package])
}
other => return Err(format!("不支持的安装类型: {other}")),
};
let output = tokio::process::Command::new(&program)
.args(&args)
.env("PATH", &path_env)
.output()
.await
.map_err(|e| format!("执行 {program} 失败: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if !output.status.success() {
return Err(format!(
"安装失败 ({program} {}): {}",
output.status,
stderr.trim()
));
}
Ok(serde_json::json!({
"success": true,
"output": stdout.trim(),
}))
}
/// 从 ClawHub 安装 Skillnpx clawhub install <slug>
#[tauri::command]
pub async fn skills_clawhub_install(slug: String) -> Result<Value, String> {
let path_env = super::enhanced_path();
let home = dirs::home_dir().unwrap_or_default();
// 确保 skills 目录存在
let skills_dir = super::openclaw_dir().join("skills");
if !skills_dir.exists() {
std::fs::create_dir_all(&skills_dir)
.map_err(|e| format!("创建 skills 目录失败: {e}"))?;
}
let output = tokio::process::Command::new("npx")
.args(["-y", "clawhub", "install", &slug])
.env("PATH", &path_env)
.current_dir(&home)
.output()
.await
.map_err(|e| format!("执行 clawhub 失败: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if !output.status.success() {
return Err(format!("安装失败: {}", stderr.trim()));
}
Ok(serde_json::json!({
"success": true,
"slug": slug,
"output": stdout.trim(),
}))
}
/// 从 ClawHub 搜索 Skillsnpx clawhub search <query>
#[tauri::command]
pub async fn skills_clawhub_search(query: String) -> Result<Value, String> {
let q = query.trim().to_string();
if q.is_empty() {
return Ok(Value::Array(vec![]));
}
let path_env = super::enhanced_path();
let output = tokio::process::Command::new("npx")
.args(["-y", "clawhub", "search", &q])
.env("PATH", &path_env)
.output()
.await
.map_err(|e| format!("执行 clawhub 失败: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("搜索失败: {}", stderr.trim()));
}
let stdout = String::from_utf8_lossy(&output.stdout);
// clawhub search 输出是文本行,每行一个 skill
let items: Vec<Value> = stdout
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty() && !l.starts_with('-') && !l.starts_with("Search"))
.map(|l| {
let parts: Vec<&str> = l.splitn(2, char::is_whitespace).collect();
let slug = parts.first().unwrap_or(&"").trim();
let desc = parts.get(1).unwrap_or(&"").trim();
serde_json::json!({
"slug": slug,
"description": desc,
"source": "clawhub"
})
})
.filter(|v| !v["slug"].as_str().unwrap_or("").is_empty())
.collect();
Ok(Value::Array(items))
}
/// CLI 不可用时的兜底:扫描 ~/.openclaw/skills 目录
fn scan_local_skills() -> Result<Value, String> {
let skills_dir = super::openclaw_dir().join("skills");
if !skills_dir.exists() {
return Ok(serde_json::json!({
"skills": [],
"source": "local-scan",
"cliAvailable": false
}));
}
let mut skills = Vec::new();
if let Ok(entries) = std::fs::read_dir(&skills_dir) {
for entry in entries.flatten() {
let ft = match entry.file_type() {
Ok(ft) => ft,
Err(_) => continue,
};
if !ft.is_dir() && !ft.is_symlink() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
let skill_md = entry.path().join("SKILL.md");
let description = if skill_md.exists() {
// 尝试从 SKILL.md 的 frontmatter 中提取 description
parse_skill_description(&skill_md)
} else {
String::new()
};
skills.push(serde_json::json!({
"name": name,
"description": description,
"source": "managed",
"eligible": true,
"bundled": false,
"filePath": skill_md.to_string_lossy(),
}));
}
}
Ok(serde_json::json!({
"skills": skills,
"source": "local-scan",
"cliAvailable": false
}))
}
/// 从 SKILL.md 的 YAML frontmatter 中提取 description
fn parse_skill_description(path: &std::path::Path) -> String {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return String::new(),
};
// frontmatter 格式: ---\n...\n---
if !content.starts_with("---") {
return String::new();
}
if let Some(end) = content[3..].find("---") {
let fm = &content[3..3 + end];
for line in fm.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("description:") {
return rest.trim().trim_matches('"').trim_matches('\'').to_string();
}
}
}
String::new()
}

View File

@@ -0,0 +1,215 @@
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::fs;
use std::io::Read;
use std::path::PathBuf;
/// 前端热更新目录 (~/.openclaw/clawpanel/web-update/)
pub fn update_dir() -> PathBuf {
super::openclaw_dir().join("clawpanel").join("web-update")
}
/// 更新清单 URLGitHub Pages 托管)
const LATEST_JSON_URL: &str = "https://claw.qt.cool/update/latest.json";
/// 检查前端是否有新版本可用
#[tauri::command]
pub async fn check_frontend_update() -> Result<Value, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.user_agent("ClawPanel")
.build()
.map_err(|e| format!("HTTP 客户端错误: {e}"))?;
let resp = client
.get(LATEST_JSON_URL)
.send()
.await
.map_err(|e| format!("请求失败: {e}"))?;
if !resp.status().is_success() {
return Err(format!("服务器返回 {}", resp.status()));
}
let manifest: Value = resp.json().await.map_err(|e| format!("解析失败: {e}"))?;
let latest = manifest
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let current = env!("CARGO_PKG_VERSION");
// 检查最低兼容的 app 版本(前端可能依赖较新的 Rust 后端命令)
let min_app = manifest
.get("minAppVersion")
.and_then(|v| v.as_str())
.unwrap_or("0.0.0");
let compatible = version_ge(current, min_app);
let has_update = !latest.is_empty() && latest != current && compatible;
let update_ready = update_dir().join("index.html").exists();
Ok(serde_json::json!({
"currentVersion": current,
"latestVersion": latest,
"hasUpdate": has_update,
"compatible": compatible,
"updateReady": update_ready,
"manifest": manifest
}))
}
/// 下载并解压前端更新包
#[tauri::command]
pub async fn download_frontend_update(url: String, expected_hash: String) -> Result<Value, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.user_agent("ClawPanel")
.build()
.map_err(|e| format!("HTTP 客户端错误: {e}"))?;
let resp = client
.get(&url)
.send()
.await
.map_err(|e| format!("下载失败: {e}"))?;
if !resp.status().is_success() {
return Err(format!("下载失败: HTTP {}", resp.status()));
}
let bytes = resp
.bytes()
.await
.map_err(|e| format!("读取数据失败: {e}"))?;
// 校验 SHA-256
if !expected_hash.is_empty() {
let mut hasher = Sha256::new();
hasher.update(&bytes);
let hash = format!("{:x}", hasher.finalize());
let expected = expected_hash
.strip_prefix("sha256:")
.unwrap_or(&expected_hash);
if hash != expected {
return Err(format!("哈希校验失败: 期望 {},实际 {}", expected, hash));
}
}
// 清理旧更新,解压新包
let dir = update_dir();
if dir.exists() {
fs::remove_dir_all(&dir).map_err(|e| format!("清理旧更新失败: {e}"))?;
}
fs::create_dir_all(&dir).map_err(|e| format!("创建更新目录失败: {e}"))?;
let cursor = std::io::Cursor::new(bytes.as_ref());
let mut archive = zip::ZipArchive::new(cursor).map_err(|e| format!("解压失败: {e}"))?;
for i in 0..archive.len() {
let mut file = archive
.by_index(i)
.map_err(|e| format!("读取压缩条目失败: {e}"))?;
let name = file.name().to_string();
let target = dir.join(&name);
if name.ends_with('/') {
fs::create_dir_all(&target).map_err(|e| format!("创建子目录失败: {e}"))?;
} else {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|e| format!("创建父目录失败: {e}"))?;
}
let mut buf = Vec::new();
file.read_to_end(&mut buf)
.map_err(|e| format!("读取文件内容失败: {e}"))?;
fs::write(&target, &buf).map_err(|e| format!("写入文件失败: {e}"))?;
}
}
Ok(serde_json::json!({
"success": true,
"files": archive.len(),
"path": dir.to_string_lossy()
}))
}
/// 回退前端更新(删除热更新目录,下次启动使用内嵌资源)
#[tauri::command]
pub fn rollback_frontend_update() -> Result<Value, String> {
let dir = update_dir();
if dir.exists() {
fs::remove_dir_all(&dir).map_err(|e| format!("回退失败: {e}"))?;
}
Ok(serde_json::json!({ "success": true }))
}
/// 获取当前热更新状态
#[tauri::command]
pub fn get_update_status() -> Result<Value, String> {
let dir = update_dir();
let ready = dir.join("index.html").exists();
// 尝试读取已下载更新的版本信息
let update_version = if ready {
dir.join(".version")
.exists()
.then(|| fs::read_to_string(dir.join(".version")).ok())
.flatten()
.unwrap_or_default()
} else {
String::new()
};
Ok(serde_json::json!({
"currentVersion": env!("CARGO_PKG_VERSION"),
"updateReady": ready,
"updateVersion": update_version,
"updateDir": dir.to_string_lossy()
}))
}
/// 简单的语义化版本比较current >= required
fn version_ge(current: &str, required: &str) -> bool {
let parse = |s: &str| -> Vec<u32> {
s.trim_start_matches('v')
.split('.')
.filter_map(|p| p.parse().ok())
.collect()
};
let c = parse(current);
let r = parse(required);
for i in 0..r.len().max(c.len()) {
let cv = c.get(i).copied().unwrap_or(0);
let rv = r.get(i).copied().unwrap_or(0);
if cv > rv {
return true;
}
if cv < rv {
return false;
}
}
true
}
/// 根据文件扩展名推断 MIME 类型
pub fn mime_from_path(path: &str) -> &'static str {
match path.rsplit('.').next().unwrap_or("") {
"html" => "text/html",
"js" | "mjs" => "application/javascript",
"css" => "text/css",
"json" => "application/json",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"svg" => "image/svg+xml",
"ico" => "image/x-icon",
"woff" => "font/woff",
"woff2" => "font/woff2",
"ttf" => "font/ttf",
"wasm" => "application/wasm",
_ => "application/octet-stream",
}
}

View File

@@ -3,11 +3,57 @@ mod models;
mod tray;
mod utils;
use commands::{agent, assistant, config, device, extensions, logs, memory, pairing, service};
use commands::{
agent, assistant, config, device, extensions, logs, memory, pairing, service, skills, update,
};
pub fn run() {
let hot_update_dir = commands::openclaw_dir()
.join("clawpanel")
.join("web-update");
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.register_uri_scheme_protocol("tauri", move |ctx, request| {
let uri_path = request.uri().path();
let path = if uri_path == "/" || uri_path.is_empty() {
"index.html"
} else {
uri_path.strip_prefix('/').unwrap_or(uri_path)
};
// 1. 优先检查热更新目录
let update_file = hot_update_dir.join(path);
if update_file.is_file() {
if let Ok(data) = std::fs::read(&update_file) {
return tauri::http::Response::builder()
.header(
tauri::http::header::CONTENT_TYPE,
update::mime_from_path(path),
)
.body(data)
.unwrap();
}
}
// 2. 回退到内嵌资源
if let Some(asset) = ctx.app_handle().asset_resolver().get(path.to_string()) {
let builder = tauri::http::Response::builder()
.header(tauri::http::header::CONTENT_TYPE, &asset.mime_type);
// Tauri 内嵌资源可能带 CSP header
let builder = if let Some(csp) = asset.csp_header {
builder.header("Content-Security-Policy", csp)
} else {
builder
};
builder.body(asset.bytes).unwrap()
} else {
tauri::http::Response::builder()
.status(tauri::http::StatusCode::NOT_FOUND)
.body(b"Not Found".to_vec())
.unwrap()
}
})
.setup(|app| {
tray::setup_tray(app.handle())?;
Ok(())
@@ -34,7 +80,9 @@ pub fn run() {
config::restart_gateway,
config::test_model,
config::list_remote_models,
config::list_openclaw_versions,
config::upgrade_openclaw,
config::uninstall_openclaw,
config::install_gateway,
config::uninstall_gateway,
config::patch_model_vision,
@@ -91,6 +139,18 @@ pub fn run() {
assistant::assistant_save_image,
assistant::assistant_load_image,
assistant::assistant_delete_image,
// Skills 管理openclaw skills CLI
skills::skills_list,
skills::skills_info,
skills::skills_check,
skills::skills_install_dep,
skills::skills_clawhub_search,
skills::skills_clawhub_install,
// 前端热更新
update::check_frontend_update,
update::download_frontend_update,
update::rollback_frontend_update,
update::get_update_status,
])
.run(tauri::generate_context!())
.expect("启动 ClawPanel 失败");