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

@@ -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",
}
}