mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-31 05:10:14 +08:00
发布 0.15.0: - 新增内核版本兼容层、特性门控、低版本阻断和升级提示 - 新增 PATH 中 OpenClaw CLI 冲突检测、隔离与恢复 - 修复 Hermes Gateway loopback 自动拉起与 /v1/runs 诊断 - 修复 standalone 一键安装包在 About/仪表盘显示未知版本 - 同步 OpenClaw 2026.5.6 推荐版本和热更新 minAppVersion - 补齐本地 JS/Rust 测试与发布前检查说明 验证: - npm run build - node --test tests/*.test.js - node --check src/scripts JS 文件 - cargo fmt --all -- --check - cargo check - cargo clippy --all-targets -- -D warnings - cargo test
232 lines
7.2 KiB
Rust
232 lines
7.2 KiB
Rust
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")
|
||
}
|
||
|
||
/// 更新清单 URL(GitHub 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 = super::build_http_client(std::time::Duration::from_secs(10), Some("ClawPanel"))
|
||
.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();
|
||
|
||
// 优先读取已热更新的版本,避免 macOS/Linux 用户安装旧包后永远提示有更新
|
||
let current = {
|
||
let version_file = update_dir().join(".version");
|
||
std::fs::read_to_string(&version_file)
|
||
.ok()
|
||
.map(|s| s.trim().to_string())
|
||
.filter(|s| !s.is_empty())
|
||
.unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string())
|
||
};
|
||
|
||
// 检查最低兼容的 app 版本(前端可能依赖较新的 Rust 后端命令)
|
||
let min_app = manifest
|
||
.get("minAppVersion")
|
||
.and_then(|v| v.as_str())
|
||
.unwrap_or("0.0.0");
|
||
|
||
let compatible = version_ge(¤t, min_app);
|
||
let remote_newer = !latest.is_empty() && compatible && version_gt(&latest, ¤t);
|
||
let update_ready = remote_newer && update_dir().join("index.html").exists();
|
||
let has_update = remote_newer && !update_ready;
|
||
|
||
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,
|
||
version: String,
|
||
) -> Result<Value, String> {
|
||
let client = super::build_http_client(std::time::Duration::from_secs(120), Some("ClawPanel"))
|
||
.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}"))?;
|
||
}
|
||
}
|
||
|
||
// 写入版本号文件,供下次 check_frontend_update 读取
|
||
if !version.is_empty() {
|
||
let _ = std::fs::write(dir.join(".version"), &version);
|
||
}
|
||
|
||
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
|
||
pub 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
|
||
}
|
||
|
||
fn version_gt(left: &str, right: &str) -> bool {
|
||
version_ge(left, right) && !version_ge(right, left)
|
||
}
|
||
|
||
/// 根据文件扩展名推断 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",
|
||
}
|
||
}
|