Files
clawpanel/src-tauri/src/commands/skillhub.rs
2026-04-07 03:30:48 +08:00

258 lines
8.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<String>,
#[serde(default, alias = "displayName")]
pub display_name: Option<String>,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub author: Option<String>,
#[serde(default)]
pub tags: Option<Vec<String>>,
#[serde(default)]
pub categories: Option<Vec<String>>,
#[serde(default)]
pub homepage: Option<String>,
#[serde(default)]
pub downloads: Option<u64>,
#[serde(default)]
pub stars: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct SearchResponse {
#[serde(default)]
results: Vec<SkillHubItem>,
}
#[derive(Debug, Deserialize)]
struct IndexResponse {
#[serde(default)]
skills: Vec<SkillHubItem>,
}
// ── 全量索引缓存 ──────────────────────────────────────────
static INDEX_CACHE: Mutex<Option<(Instant, Vec<SkillHubItem>)>> = Mutex::new(None);
// ── HTTP 客户端 ──────────────────────────────────────────
fn client() -> Result<reqwest::Client, String> {
super::build_http_client(Duration::from_secs(30), Some("ClawPanel-SkillHub/1.0"))
}
// ── 公开接口 ──────────────────────────────────────────────
/// 搜索 SkillHub
pub async fn search(query: &str, limit: u32) -> Result<Vec<SkillHubItem>, 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<Vec<SkillHubItem>, 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 zipCOS 镜像优先,回退主站 API
pub async fn download_zip(slug: &str) -> Result<Vec<u8>, 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}"))
}
/// 下载并安装 Skillzip → 解压到 skills_dir/{slug}/
pub async fn install(slug: &str, skills_dir: &Path) -> Result<PathBuf, String> {
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<String> = (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<String> {
let mut root: Option<String> = 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
}