mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-12 02:20:58 +08:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
/// 卸载 OpenClaw(npm 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> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
271
src-tauri/src/commands/skills.rs
Normal file
271
src-tauri/src/commands/skills.rs
Normal 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 安装 Skill(npx 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 搜索 Skills(npx 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()
|
||||
}
|
||||
215
src-tauri/src/commands/update.rs
Normal file
215
src-tauri/src/commands/update.rs
Normal 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")
|
||||
}
|
||||
|
||||
/// 更新清单 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 = 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",
|
||||
}
|
||||
}
|
||||
@@ -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 失败");
|
||||
|
||||
Reference in New Issue
Block a user