//! SkillHub SDK — 纯 HTTP + zip 操作,不依赖 Tauri 框架。 //! 供 skills.rs Tauri 命令层薄包装调用。 use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::time::{Duration, Instant}; const COS_BASE: &str = "https://skillhub-1388575217.cos.ap-guangzhou.myqcloud.com"; const API_BASE: &str = "https://lightmake.site/api/v1"; const INDEX_TTL: Duration = Duration::from_secs(600); // 10 分钟缓存 // ── 数据结构 ────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SkillHubItem { pub slug: String, #[serde(default)] pub name: Option, #[serde(default, alias = "displayName")] pub display_name: Option, #[serde(default)] pub summary: Option, #[serde(default)] pub version: Option, #[serde(default)] pub description: Option, #[serde(default)] pub author: Option, #[serde(default)] pub tags: Option>, #[serde(default)] pub categories: Option>, #[serde(default)] pub homepage: Option, #[serde(default)] pub downloads: Option, #[serde(default)] pub stars: Option, } #[derive(Debug, Deserialize)] struct SearchResponse { #[serde(default)] results: Vec, } #[derive(Debug, Deserialize)] struct IndexResponse { #[serde(default)] skills: Vec, } // ── 全量索引缓存 ────────────────────────────────────────── static INDEX_CACHE: Mutex)>> = Mutex::new(None); // ── HTTP 客户端 ────────────────────────────────────────── fn client() -> Result { super::build_http_client(Duration::from_secs(30), Some("ClawPanel-SkillHub/1.0")) } // ── 公开接口 ────────────────────────────────────────────── /// 搜索 SkillHub pub async fn search(query: &str, limit: u32) -> Result, String> { let q = query.trim(); if q.is_empty() { return Ok(vec![]); } let url = format!( "{}/search?q={}&limit={}", API_BASE, urlencoding::encode(q), limit ); let resp = client()? .get(&url) .send() .await .map_err(|e| format!("SkillHub 搜索请求失败: {e}"))?; if !resp.status().is_success() { return Err(format!("SkillHub 搜索失败: HTTP {}", resp.status())); } let data: SearchResponse = resp .json() .await .map_err(|e| format!("SkillHub 搜索结果解析失败: {e}"))?; Ok(data.results) } /// 拉取全量索引(带 10 分钟内存缓存) pub async fn fetch_index() -> Result, String> { // 命中缓存 if let Ok(guard) = INDEX_CACHE.lock() { if let Some((ts, ref items)) = *guard { if ts.elapsed() < INDEX_TTL { return Ok(items.clone()); } } } // 拉取远程索引 let url = format!("{}/skills.json", COS_BASE); let resp = client()? .get(&url) .send() .await .map_err(|e| format!("拉取技能索引失败: {e}"))?; if !resp.status().is_success() { return Err(format!("拉取技能索引失败: HTTP {}", resp.status())); } let data: IndexResponse = resp .json() .await .map_err(|e| format!("解析技能索引失败: {e}"))?; let items = data.skills; // 写入缓存 if let Ok(mut guard) = INDEX_CACHE.lock() { *guard = Some((Instant::now(), items.clone())); } Ok(items) } /// 下载 Skill zip(COS 镜像优先,回退主站 API) pub async fn download_zip(slug: &str) -> Result, String> { let c = client()?; // 1. 优先 COS 镜像(国内 CDN) let cos_url = format!("{}/skills/{}.zip", COS_BASE, slug); match c.get(&cos_url).send().await { Ok(resp) if resp.status().is_success() => { return resp .bytes() .await .map(|b| b.to_vec()) .map_err(|e| format!("COS 下载读取失败: {e}")); } _ => {} } // 2. 回退主站 API let api_url = format!("{}/download?slug={}", API_BASE, urlencoding::encode(slug)); let resp = c .get(&api_url) .send() .await .map_err(|e| format!("主站下载请求失败: {e}"))?; if !resp.status().is_success() { return Err(format!("下载失败: HTTP {}", resp.status())); } resp.bytes() .await .map(|b| b.to_vec()) .map_err(|e| format!("下载读取失败: {e}")) } /// 下载并安装 Skill:zip → 解压到 skills_dir/{slug}/ pub async fn install(slug: &str, skills_dir: &Path) -> Result { validate_slug(slug)?; let target_dir = skills_dir.join(slug); let zip_bytes = download_zip(slug).await?; extract_zip(&zip_bytes, &target_dir)?; Ok(target_dir) } // ── 内部工具 ────────────────────────────────────────────── /// 校验 slug 安全性 fn validate_slug(slug: &str) -> Result<(), String> { if slug.is_empty() { return Err("Skill slug 不能为空".into()); } if slug.contains("..") || slug.contains('/') || slug.contains('\\') { return Err(format!("无效的 Skill slug: {slug}")); } Ok(()) } /// 将 zip 字节解压到目标目录 fn extract_zip(zip_bytes: &[u8], target_dir: &Path) -> Result<(), String> { use std::io::Cursor; use zip::ZipArchive; // 清理旧目录 if target_dir.exists() { std::fs::remove_dir_all(target_dir).map_err(|e| format!("清理旧目录失败: {e}"))?; } std::fs::create_dir_all(target_dir).map_err(|e| format!("创建目录失败: {e}"))?; let reader = Cursor::new(zip_bytes); let mut archive = ZipArchive::new(reader).map_err(|e| format!("打开 zip 失败: {e}"))?; // 收集所有文件名,检测是否都在同一个顶层目录下(常见的 zip 打包方式) let names: Vec = (0..archive.len()) .filter_map(|i| archive.by_index_raw(i).ok().map(|f| f.name().to_string())) .collect(); let strip_prefix = detect_single_root_dir(&names); for i in 0..archive.len() { let mut file = archive .by_index(i) .map_err(|e| format!("读取 zip 条目失败: {e}"))?; let raw_name = file.name().to_string(); // 安全检查:防止路径穿越 if raw_name.contains("..") { continue; } // 如果 zip 内有单一根目录,剥掉它 let relative = if let Some(ref prefix) = strip_prefix { match raw_name.strip_prefix(prefix.as_str()) { Some(rest) if !rest.is_empty() => rest.to_string(), _ => continue, // 跳过根目录本身 } } else { raw_name.clone() }; if relative.is_empty() { continue; } let out_path = target_dir.join(&relative); if file.is_dir() { std::fs::create_dir_all(&out_path).ok(); } else { if let Some(parent) = out_path.parent() { std::fs::create_dir_all(parent).ok(); } let mut outfile = std::fs::File::create(&out_path) .map_err(|e| format!("创建文件失败 {relative}: {e}"))?; std::io::copy(&mut file, &mut outfile) .map_err(|e| format!("写入文件失败 {relative}: {e}"))?; } } Ok(()) } /// 检测 zip 是否有单一顶层目录(如 `skill-name/...`),返回要剥掉的前缀 fn detect_single_root_dir(names: &[String]) -> Option { let mut root: Option = None; for name in names { let first_segment = name.split('/').next().unwrap_or(""); if first_segment.is_empty() { continue; } match &root { None => root = Some(format!("{}/", first_segment)), Some(existing) => { if !name.starts_with(existing.as_str()) { return None; // 多个顶层目录 } } } } root }