From 69b5e960e881ffa623829c847fc708b4bc2bf9e4 Mon Sep 17 00:00:00 2001 From: Gerard-Devlin Date: Sat, 13 Jun 2026 15:34:17 +0800 Subject: [PATCH] feat: enhance skills downloading and proxy configuration --- apps/desktop/src-tauri/Cargo.lock | 60 +- apps/desktop/src-tauri/Cargo.toml | 2 +- apps/desktop/src-tauri/src/skills.rs | 744 ++++++++++++++++-- .../scientific-skills-onboarding.tsx | 301 +++---- 4 files changed, 868 insertions(+), 239 deletions(-) diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 4f07585..464958a 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -2172,9 +2172,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2 0.5.10", + "system-configuration 0.7.0", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -4054,7 +4056,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "sync_wrapper 0.1.2", - "system-configuration", + "system-configuration 0.5.1", "tokio", "tokio-native-tls", "tower-service", @@ -4968,7 +4970,18 @@ checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", - "system-configuration-sys", + "system-configuration-sys 0.5.0", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "system-configuration-sys 0.6.0", ] [[package]] @@ -4981,6 +4994,16 @@ dependencies = [ "libc", ] +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -6876,8 +6899,8 @@ dependencies = [ "windows-implement", "windows-interface", "windows-link 0.1.3", - "windows-result", - "windows-strings", + "windows-result 0.3.4", + "windows-strings 0.4.2", ] [[package]] @@ -6935,6 +6958,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -6944,6 +6978,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -6953,6 +6996,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-sys" version = "0.45.0" diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 5543430..3510b84 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -23,7 +23,7 @@ serde_json = "1" serde_yaml = "0.9" tokio = { version = "1", features = ["full"] } dirs = "5" -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "system-proxy", "socks"] } hmac = "0.12" sha1 = "0.10" base64 = "0.22" diff --git a/apps/desktop/src-tauri/src/skills.rs b/apps/desktop/src-tauri/src/skills.rs index 3ff415e..aa7fae5 100644 --- a/apps/desktop/src-tauri/src/skills.rs +++ b/apps/desktop/src-tauri/src/skills.rs @@ -1,12 +1,18 @@ use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; +use std::time::Duration; use tauri::{Emitter, Manager, WebviewWindow}; -const TARBALL_URL: &str = - "https://github.com/K-Dense-AI/scientific-agent-skills/archive/refs/heads/main.tar.gz"; -const SKILLS_DOWNLOAD_TIMEOUT_SECS: u64 = 120; +const TARBALL_URLS: &[&str] = &[ + "https://github.com/K-Dense-AI/scientific-agent-skills/archive/refs/heads/main.tar.gz", + "https://codeload.github.com/K-Dense-AI/scientific-agent-skills/tar.gz/refs/heads/main", + "https://github.com/K-Dense-AI/claude-scientific-skills/archive/refs/heads/main.tar.gz", +]; +const SKILLS_DOWNLOAD_ATTEMPTS: usize = 3; +const SKILLS_DOWNLOAD_TIMEOUT_SECS: u64 = 240; const SKILLS_CONNECT_TIMEOUT_SECS: u64 = 20; -const SKILLS_INSTALL_TIMEOUT_SECS: u64 = 180; +const SKILLS_INSTALL_TIMEOUT_SECS: u64 = 420; +const SKILL_CONTENT_TIMEOUT_SECS: u64 = 45; const RAW_SKILL_URLS: &[&str] = &[ "https://raw.githubusercontent.com/K-Dense-AI/scientific-agent-skills/main/skills", "https://raw.githubusercontent.com/K-Dense-AI/claude-scientific-skills/main/scientific-skills", @@ -423,24 +429,404 @@ fn collect_skill_dirs(root: &Path, output: &mut Vec) { } } -/// Download and extract tarball. -async fn download_tarball(window: &WebviewWindow, tmp_dir: &Path) -> Result<(), String> { - let client = reqwest::Client::builder() - .connect_timeout(std::time::Duration::from_secs(SKILLS_CONNECT_TIMEOUT_SECS)) - .timeout(std::time::Duration::from_secs(SKILLS_DOWNLOAD_TIMEOUT_SECS)) +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ProxyKind { + All, + Http, + Https, +} + +struct ProxyRule { + kind: ProxyKind, + url: String, + source: String, +} + +fn first_env_value(names: &[&str]) -> Option<(String, String)> { + for name in names { + let Ok(value) = std::env::var(name) else { + continue; + }; + let trimmed = value.trim(); + if !trimmed.is_empty() { + return Some(((*name).to_string(), trimmed.to_string())); + } + } + + None +} + +fn normalize_proxy_url(raw: &str) -> Option { + normalize_proxy_url_with_default(raw, "http") +} + +fn normalize_proxy_url_with_default(raw: &str, default_scheme: &str) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return None; + } + + if trimmed.contains("://") { + Some(trimmed.to_string()) + } else { + Some(format!("{}://{}", default_scheme, trimmed)) + } +} + +fn explicit_env_proxy_rules() -> Vec { + let mut rules = Vec::new(); + let mut has_https_proxy = false; + let mut has_all_proxy = false; + let mut http_proxy = None; + + if let Some((source, raw)) = first_env_value(&["HTTPS_PROXY", "https_proxy"]) { + if let Some(url) = normalize_proxy_url(&raw) { + rules.push(ProxyRule { + kind: ProxyKind::Https, + url, + source, + }); + has_https_proxy = true; + } + } + + if let Some((source, raw)) = first_env_value(&["HTTP_PROXY", "http_proxy"]) { + if let Some(url) = normalize_proxy_url(&raw) { + http_proxy = Some((source.clone(), url.clone())); + rules.push(ProxyRule { + kind: ProxyKind::Http, + url, + source, + }); + } + } + + if let Some((source, raw)) = first_env_value(&["ALL_PROXY", "all_proxy"]) { + if let Some(url) = normalize_proxy_url(&raw) { + rules.push(ProxyRule { + kind: ProxyKind::All, + url, + source, + }); + has_all_proxy = true; + } + } + + if !has_https_proxy && !has_all_proxy { + if let Some((source, url)) = http_proxy { + rules.insert( + 0, + ProxyRule { + kind: ProxyKind::Https, + url, + source: format!("{} (HTTPS fallback)", source), + }, + ); + } + } + + rules +} + +#[cfg(target_os = "windows")] +fn windows_proxy_override_to_no_proxy(raw: &str) -> Option { + let entries = raw + .split([';', ',']) + .filter_map(|part| { + let trimmed = part.trim(); + if trimmed.is_empty() { + return None; + } + + if trimmed.eq_ignore_ascii_case("") { + return Some("localhost,127.0.0.1,::1".to_string()); + } + + if trimmed == "*" { + return Some(trimmed.to_string()); + } + + if trimmed.contains('*') { + return trimmed + .strip_prefix("*.") + .map(|domain| format!(".{}", domain.trim_start_matches('.'))); + } + + Some(trimmed.to_string()) + }) + .collect::>(); + + if entries.is_empty() { + None + } else { + reqwest::NoProxy::from_string(&entries.join(",")) + } +} + +#[cfg(target_os = "windows")] +fn windows_system_no_proxy() -> Option { + use winreg::enums::HKEY_CURRENT_USER; + use winreg::RegKey; + + let settings = RegKey::predef(HKEY_CURRENT_USER) + .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Internet Settings") + .ok()?; + let raw = settings.get_value::("ProxyOverride").ok()?; + windows_proxy_override_to_no_proxy(&raw) +} + +#[cfg(target_os = "windows")] +fn parse_windows_proxy_server(raw: &str) -> Vec { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Vec::new(); + } + + if !trimmed.contains('=') { + return normalize_proxy_url(trimmed) + .map(|url| { + vec![ProxyRule { + kind: ProxyKind::All, + url, + source: "Windows system proxy".to_string(), + }] + }) + .unwrap_or_default(); + } + + let mut rules = Vec::new(); + for entry in trimmed.split(';') { + let Some((scheme, value)) = entry.split_once('=') else { + continue; + }; + let scheme = scheme.trim(); + let (kind, default_proxy_scheme) = match scheme.to_ascii_lowercase().as_str() { + "http" => (ProxyKind::Http, "http"), + "https" => (ProxyKind::Https, "http"), + "socks" | "socks5" => (ProxyKind::All, "socks5"), + "socks4" => (ProxyKind::All, "socks4"), + _ => continue, + }; + let Some(url) = normalize_proxy_url_with_default(value, default_proxy_scheme) else { + continue; + }; + + rules.push(ProxyRule { + kind, + url, + source: format!("Windows system proxy ({})", scheme.trim()), + }); + } + + rules +} + +#[cfg(target_os = "windows")] +fn windows_system_proxy_rules() -> Vec { + use winreg::enums::HKEY_CURRENT_USER; + use winreg::RegKey; + + let Ok(settings) = RegKey::predef(HKEY_CURRENT_USER) + .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Internet Settings") + else { + return Vec::new(); + }; + + let proxy_enabled = settings.get_value::("ProxyEnable").unwrap_or(0) != 0; + if !proxy_enabled { + return Vec::new(); + } + + settings + .get_value::("ProxyServer") + .map(|raw| parse_windows_proxy_server(&raw)) + .unwrap_or_default() +} + +#[cfg(not(target_os = "windows"))] +fn windows_system_proxy_rules() -> Vec { + Vec::new() +} + +#[cfg(not(target_os = "windows"))] +fn windows_system_no_proxy() -> Option { + None +} + +fn redacted_proxy_url(url: &str) -> String { + let Ok(mut parsed) = reqwest::Url::parse(url) else { + return "".to_string(); + }; + + if !parsed.username().is_empty() { + let _ = parsed.set_username("***"); + if parsed.password().is_some() { + let _ = parsed.set_password(Some("***")); + } + } + + parsed.to_string() +} + +fn add_proxy_rule( + builder: reqwest::ClientBuilder, + rule: &ProxyRule, + no_proxy: Option, +) -> Result { + let proxy = match rule.kind { + ProxyKind::All => reqwest::Proxy::all(&rule.url), + ProxyKind::Http => reqwest::Proxy::http(&rule.url), + ProxyKind::Https => reqwest::Proxy::https(&rule.url), + } + .map_err(|e| { + format!( + "Invalid proxy from {} ({}): {}", + rule.source, + redacted_proxy_url(&rule.url), + e + ) + })?; + + let proxy = proxy.no_proxy(no_proxy); + Ok(builder.proxy(proxy)) +} + +fn configure_proxy_for_client( + mut builder: reqwest::ClientBuilder, + window: Option<&WebviewWindow>, +) -> Result { + let mut rules = explicit_env_proxy_rules(); + let mut no_proxy = reqwest::NoProxy::from_env(); + + if rules.is_empty() { + let windows_rules = windows_system_proxy_rules(); + if !windows_rules.is_empty() { + rules = windows_rules; + no_proxy = windows_system_no_proxy(); + } + } + + if rules.is_empty() { + if let Some(window) = window { + emit_log(window, "Using system proxy settings when available"); + } + return Ok(builder); + } + + if let Some(window) = window { + for rule in &rules { + emit_log( + window, + &format!( + "Using proxy from {}: {}", + rule.source, + redacted_proxy_url(&rule.url) + ), + ); + } + } + + for rule in &rules { + builder = add_proxy_rule(builder, rule, no_proxy.clone())?; + } + + Ok(builder) +} + +fn build_skills_http_client( + timeout_secs: u64, + window: Option<&WebviewWindow>, +) -> Result { + let builder = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(SKILLS_CONNECT_TIMEOUT_SECS)) + .timeout(Duration::from_secs(timeout_secs)); + + configure_proxy_for_client(builder, window)? .build() - .map_err(|e| format!("Failed to create download client: {}", e))?; + .map_err(|e| format!("Failed to create download client: {}", e)) +} + +fn tarball_source_label(url: &str) -> &'static str { + if url.contains("codeload.github.com") { + "GitHub codeload" + } else if url.contains("claude-scientific-skills") { + "legacy GitHub archive" + } else { + "GitHub archive" + } +} + +fn reset_download_workspace(tmp_dir: &Path) { + let _ = std::fs::remove_dir_all(tmp_dir.join("repo")); + let _ = std::fs::remove_dir_all(tmp_dir.join("repo-raw")); +} + +fn find_extracted_repo_dir(raw_dir: &Path) -> Option { + let mut candidates = Vec::new(); + let entries = std::fs::read_dir(raw_dir).ok()?; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + candidates.push(path); + } + } + candidates.sort(); + + for candidate in &candidates { + if find_skills_source(candidate).is_some() { + return Some(candidate.clone()); + } + } + + candidates.into_iter().next() +} + +fn unpack_tarball(bytes: &[u8], tmp_dir: &Path) -> Result<(), String> { + reset_download_workspace(tmp_dir); + + let raw_dir = tmp_dir.join("repo-raw"); + std::fs::create_dir_all(&raw_dir) + .map_err(|e| format!("Failed to create extraction dir: {}", e))?; + + let decoder = flate2::read::GzDecoder::new(bytes); + let mut archive = tar::Archive::new(decoder); + + archive + .unpack(&raw_dir) + .map_err(|e| format!("Failed to extract tarball: {}", e))?; + + let repo_source = find_extracted_repo_dir(&raw_dir) + .ok_or_else(|| "Downloaded tarball did not contain a repository directory".to_string())?; + + std::fs::rename(&repo_source, tmp_dir.join("repo")) + .map_err(|e| format!("Failed to prepare extracted repo: {}", e))?; + + let _ = std::fs::remove_dir_all(&raw_dir); + Ok(()) +} + +async fn download_tarball_once( + client: &reqwest::Client, + window: &WebviewWindow, + tmp_dir: &Path, + url: &str, +) -> Result<(), String> { + reset_download_workspace(tmp_dir); + + let source_label = tarball_source_label(url); + emit_log(window, &format!("Downloading from {}...", source_label)); let mut response = client - .get(TARBALL_URL) + .get(url) .header(reqwest::header::USER_AGENT, "ClaudePrism skills installer") .send() .await - .map_err(|e| format!("Failed to download tarball: {}", e))?; + .map_err(|e| format!("Failed to start download: {}", e))?; if !response.status().is_success() { return Err(format!( - "Tarball download failed with status: {}", + "Download failed with status: {}", response.status() )); } @@ -454,7 +840,7 @@ async fn download_tarball(window: &WebviewWindow, tmp_dir: &Path) -> Result<(), while let Some(chunk) = response .chunk() .await - .map_err(|e| format!("Failed to read tarball bytes: {}", e))? + .map_err(|e| format!("Failed to read download bytes: {}", e))? { downloaded += chunk.len() as u64; bytes.extend_from_slice(&chunk); @@ -473,28 +859,48 @@ async fn download_tarball(window: &WebviewWindow, tmp_dir: &Path) -> Result<(), } } - // Decompress gzip - let decoder = flate2::read::GzDecoder::new(&bytes[..]); - let mut archive = tar::Archive::new(decoder); + unpack_tarball(&bytes, tmp_dir) +} - archive - .unpack(tmp_dir.join("repo-raw")) - .map_err(|e| format!("Failed to extract tarball: {}", e))?; +/// Download and extract tarball. +async fn download_tarball(window: &WebviewWindow, tmp_dir: &Path) -> Result<(), String> { + let client = build_skills_http_client(SKILLS_DOWNLOAD_TIMEOUT_SECS, Some(window))?; - // The tarball extracts to scientific-agent-skills-main/ - // We need to find it and rename to repo/ - let raw_dir = tmp_dir.join("repo-raw"); - if let Ok(mut entries) = std::fs::read_dir(&raw_dir) { - if let Some(Ok(entry)) = entries.next() { - std::fs::rename(entry.path(), tmp_dir.join("repo")) - .map_err(|e| format!("Failed to rename extracted dir: {}", e))?; + let mut last_error = None; + for attempt in 1..=SKILLS_DOWNLOAD_ATTEMPTS { + for url in TARBALL_URLS { + let label = tarball_source_label(url); + emit_log( + window, + &format!( + "Download attempt {}/{} ({})", + attempt, SKILLS_DOWNLOAD_ATTEMPTS, label + ), + ); + + match download_tarball_once(&client, window, tmp_dir, url).await { + Ok(()) => return Ok(()), + Err(e) => { + let message = format!("{} failed: {}", label, e); + emit_log(window, &message); + last_error = Some(message); + reset_download_workspace(tmp_dir); + } + } + } + + if attempt < SKILLS_DOWNLOAD_ATTEMPTS { + let delay_secs = attempt as u64 * 2; + emit_log(window, &format!("Retrying in {} seconds...", delay_secs)); + tokio::time::sleep(Duration::from_secs(delay_secs)).await; } } - // Clean up the raw extraction directory - let _ = std::fs::remove_dir_all(&raw_dir); - - Ok(()) + Err(format!( + "Failed to download skills after {} attempts. Last error: {}", + SKILLS_DOWNLOAD_ATTEMPTS, + last_error.unwrap_or_else(|| "unknown download error".to_string()) + )) } fn contains_skill_dirs(path: &Path) -> bool { @@ -526,6 +932,81 @@ fn find_skills_source(repo_dir: &Path) -> Option { None } +fn skills_staging_dir(target_dir: &Path) -> PathBuf { + let parent = target_dir + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + let target_name = target_dir + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("skills"); + + parent.join(format!( + ".{}-installing-{}", + target_name, + uuid::Uuid::new_v4().simple() + )) +} + +fn hidden_sibling_path(path: &Path, label: &str) -> PathBuf { + let parent = path + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("skill"); + + parent.join(format!( + ".{}-{}-{}", + name, + label, + uuid::Uuid::new_v4().simple() + )) +} + +fn replace_dir_from_staging(staged: &Path, target: &Path) -> Result<(), String> { + let backup = hidden_sibling_path(target, "backup"); + let had_existing = target.exists(); + + if had_existing { + std::fs::rename(target, &backup).map_err(|e| { + format!( + "Failed to prepare replacement for {}: {}", + target.display(), + e + ) + })?; + } + + match std::fs::rename(staged, target) { + Ok(()) => { + if had_existing { + let _ = std::fs::remove_dir_all(&backup); + } + Ok(()) + } + Err(e) => { + let restore_error = if had_existing { + std::fs::rename(&backup, target) + .err() + .map(|restore| format!(" Restore also failed: {}", restore)) + } else { + None + }; + + Err(format!( + "Failed to install {}: {}{}", + target.display(), + e, + restore_error.unwrap_or_default() + )) + } + } +} + /// Copy the skills directory from the downloaded repo to the target. fn copy_skills(repo_dir: &Path, target_dir: &Path) -> Result { let src = find_skills_source(repo_dir).ok_or_else(|| { @@ -539,26 +1020,60 @@ fn copy_skills(repo_dir: &Path, target_dir: &Path) -> Result { std::fs::create_dir_all(target_dir) .map_err(|e| format!("Failed to create target dir: {}", e))?; - let mut count = 0; - let mut skill_dirs = Vec::new(); collect_skill_dirs(&src, &mut skill_dirs); skill_dirs.sort(); - for entry_path in skill_dirs { - let Some(skill_name) = entry_path.file_name().and_then(|name| name.to_str()) else { - continue; - }; - let target_skill = target_dir.join(skill_name); - if target_skill.exists() { - std::fs::remove_dir_all(&target_skill) - .map_err(|e| format!("Failed to replace {}: {}", target_skill.display(), e))?; - } - copy_dir_recursive(&entry_path, &target_skill)?; - count += 1; + if skill_dirs.is_empty() { + return Err("No skills found in downloaded repository".into()); } - Ok(count) + let staging_dir = skills_staging_dir(target_dir); + std::fs::create_dir_all(&staging_dir) + .map_err(|e| format!("Failed to create staging dir: {}", e))?; + + let mut staged_names = Vec::new(); + let stage_result = (|| -> Result<(), String> { + for entry_path in &skill_dirs { + let Some(skill_name) = entry_path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + + let staged_skill = staging_dir.join(skill_name); + copy_dir_recursive(entry_path, &staged_skill)?; + staged_names.push(skill_name.to_string()); + } + + Ok(()) + })(); + + if let Err(e) = stage_result { + let _ = std::fs::remove_dir_all(&staging_dir); + return Err(e); + } + + let replace_result = (|| -> Result { + let mut count = 0; + for skill_name in staged_names { + let staged_skill = staging_dir.join(&skill_name); + let target_skill = target_dir.join(&skill_name); + + replace_dir_from_staging(&staged_skill, &target_skill).map_err(|e| { + format!( + "Failed to replace {} with staged skill {}: {}", + target_skill.display(), + skill_name, + e + ) + })?; + count += 1; + } + + Ok(count) + })(); + + let _ = std::fs::remove_dir_all(&staging_dir); + replace_result } /// Recursively copy a directory. @@ -839,36 +1354,43 @@ async fn install_skills_to( msg })?; - // Download via tarball (faster, no git/git-lfs dependency) - emit_log(window, "Downloading skills..."); - download_tarball(window, &tmp_dir).await.map_err(|e| { - emit_log(window, &format!("Download failed: {}", e)); - e - })?; - emit_log(window, "Download complete"); + let result = async { + // Download via tarball (faster, no git/git-lfs dependency) + emit_log(window, "Downloading skills..."); + download_tarball(window, &tmp_dir).await.map_err(|e| { + emit_log(window, &format!("Download failed: {}", e)); + e + })?; + emit_log(window, "Download complete"); - let repo_dir = tmp_dir.join("repo"); + let repo_dir = tmp_dir.join("repo"); - // Copy skills to target directory - emit_log(window, "Copying skills..."); - let count = copy_skills(&repo_dir, target).map_err(|e| { - emit_log(window, &format!("Copy failed: {}", e)); - e - })?; - emit_log(window, &format!("Copied {} skills", count)); + // Copy skills to target directory + emit_log(window, "Copying skills..."); + let count = copy_skills(&repo_dir, target).map_err(|e| { + emit_log(window, &format!("Copy failed: {}", e)); + e + })?; + emit_log(window, &format!("Copied {} skills", count)); - // Clean up temp directory - let _ = std::fs::remove_dir_all(&tmp_dir); - emit_log(window, "Cleanup complete"); + let target_str = target.to_string_lossy().to_string(); - let target_str = target.to_string_lossy().to_string(); + Ok(InstallResult { + success: true, + skills_installed: count, + target_dir: target_str.clone(), + message: format!("Successfully installed {} skills to {}", count, target_str), + }) + } + .await; - Ok(InstallResult { - success: true, - skills_installed: count, - target_dir: target_str.clone(), - message: format!("Successfully installed {} skills to {}", count, target_str), - }) + match std::fs::remove_dir_all(&tmp_dir) { + Ok(_) => emit_log(window, "Cleanup complete"), + Err(e) if tmp_dir.exists() => emit_log(window, &format!("Cleanup failed: {}", e)), + Err(_) => {} + } + + result } #[tauri::command] @@ -1010,27 +1532,44 @@ pub async fn get_skill_content( // Fallback: fetch from GitHub. The upstream project moved from // claude-scientific-skills/scientific-skills to scientific-agent-skills/skills. - let mut last_status = None; + let client = build_skills_http_client(SKILL_CONTENT_TIMEOUT_SECS, None) + .map_err(|e| format!("Failed to create GitHub client: {}", e))?; + + let mut last_error = None; for base_url in RAW_SKILL_URLS { - let url = format!("{}/{}/SKILL.md", base_url, skill_folder); - let response = reqwest::get(&url) - .await - .map_err(|e| format!("Failed to fetch from GitHub: {}", e))?; - - if response.status().is_success() { - return response - .text() + for skill_file in ["SKILL.md", "skill.md"] { + let url = format!("{}/{}/{}", base_url, skill_folder, skill_file); + let response = match client + .get(&url) + .header(reqwest::header::USER_AGENT, "ClaudePrism skills viewer") + .send() .await - .map_err(|e| format!("Failed to read response: {}", e)); - } + { + Ok(response) => response, + Err(e) => { + last_error = Some(format!("{}: {}", url, e)); + continue; + } + }; - last_status = Some(response.status().to_string()); + if response.status().is_success() { + match response.text().await { + Ok(text) => return Ok(text), + Err(e) => { + last_error = Some(format!("{}: failed to read response: {}", url, e)); + continue; + } + } + } + + last_error = Some(format!("{}: HTTP {}", url, response.status())); + } } Err(format!( - "Skill '{}' not found (HTTP {})", + "Skill '{}' not found. Last error: {}", skill_folder, - last_status.unwrap_or_else(|| "unknown".to_string()) + last_error.unwrap_or_else(|| "unknown".to_string()) )) } @@ -1065,6 +1604,43 @@ mod tests { ); } + #[test] + fn test_normalize_proxy_url_defaults_to_http() { + assert_eq!( + normalize_proxy_url("127.0.0.1:7890"), + Some("http://127.0.0.1:7890".to_string()) + ); + assert_eq!( + normalize_proxy_url("socks5://127.0.0.1:7891"), + Some("socks5://127.0.0.1:7891".to_string()) + ); + assert_eq!(normalize_proxy_url(" "), None); + } + + #[cfg(target_os = "windows")] + #[test] + fn test_parse_windows_proxy_server_single_proxy() { + let rules = parse_windows_proxy_server("127.0.0.1:7890"); + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].kind, ProxyKind::All); + assert_eq!(rules[0].url, "http://127.0.0.1:7890"); + } + + #[cfg(target_os = "windows")] + #[test] + fn test_parse_windows_proxy_server_per_scheme_proxy() { + let rules = parse_windows_proxy_server( + "http=127.0.0.1:7890;https=127.0.0.1:7890;socks=127.0.0.1:7891", + ); + assert_eq!(rules.len(), 3); + assert_eq!(rules[0].kind, ProxyKind::Http); + assert_eq!(rules[0].url, "http://127.0.0.1:7890"); + assert_eq!(rules[1].kind, ProxyKind::Https); + assert_eq!(rules[1].url, "http://127.0.0.1:7890"); + assert_eq!(rules[2].kind, ProxyKind::All); + assert_eq!(rules[2].url, "socks5://127.0.0.1:7891"); + } + #[test] fn test_skill_categories_count() { let cats = skill_categories(); diff --git a/apps/desktop/src/components/scientific-skills/scientific-skills-onboarding.tsx b/apps/desktop/src/components/scientific-skills/scientific-skills-onboarding.tsx index ccf546d..1d9a560 100644 --- a/apps/desktop/src/components/scientific-skills/scientific-skills-onboarding.tsx +++ b/apps/desktop/src/components/scientific-skills/scientific-skills-onboarding.tsx @@ -78,8 +78,7 @@ export function ScientificSkillsOnboarding({ const [installedSkills, setInstalledSkills] = useState([]); const [isUninstalling, setIsUninstalling] = useState(false); const [isImporting, setIsImporting] = useState(false); - const [confirmUninstallAllOpen, setConfirmUninstallAllOpen] = - useState(false); + const [confirmUninstallAllOpen, setConfirmUninstallAllOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); const [deletingSkillFolder, setDeletingSkillFolder] = useState( null, @@ -195,10 +194,7 @@ export function ScientificSkillsOnboarding({ (line) => !line.startsWith("Preparing installer"), ); if (hasBackendLog) return previous; - return [ - ...previous, - "Waiting for the installer command to start...", - ]; + return [...previous, "Waiting for the installer command to start..."]; }); }, 2500); @@ -308,7 +304,7 @@ export function ScientificSkillsOnboarding({ }, [checkStatus, deleteTarget]); // ─── Installing / Complete state ─── - if (isInstalling || isComplete) { + if (isInstalling || isComplete || error) { return ( {isComplete ? ( + ) : error ? ( + ) : ( )} - {isComplete ? "Installation Complete" : "Installing Skills"} + {isComplete + ? "Installation Complete" + : error + ? "Installation Failed" + : "Installing Skills"} {isComplete && ( @@ -370,10 +372,7 @@ export function ScientificSkillsOnboarding({ - - - ) : ( - - )} - - - - - - {/* Body — sidebar + detail */} -
- {/* Category sidebar */} - +
+ {isInstalled ? ( + <> + + + {status?.skill_count} installed + + + + + ) : ( + + )} + +
+
+ - {/* Detail panel */} -
- {selected ? ( - -
- + {/* Body — sidebar + detail */} +
+ {/* Category sidebar */} +
-
+ - {/* Footer */} -
-

- {status?.location ?? "~/.claude/skills/"} -

- -
+ {/* Detail panel */} +
+ {selected ? ( + +
+ +
+
+ ) : ( +
+ Select a category +
+ )} +
+
+ + {/* Footer */} +
+

+ {status?.location ?? "~/.claude/skills/"} +

+ +
@@ -558,8 +559,8 @@ export function ScientificSkillsOnboarding({ Delete Skill - Delete {deleteTarget?.name ?? "this skill"} from - ~/.claude/skills. This cannot be undone. + Delete {deleteTarget?.name ?? "this skill"} from ~/.claude/skills. + This cannot be undone.
@@ -823,7 +824,7 @@ function CategoryDetail({ event.stopPropagation(); onDeleteSkill(skill); }} - className="mr-1 flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-70 transition-colors hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100 disabled:opacity-50" + className="mr-1 flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-70 transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-50 group-hover:opacity-100" > {isDeleting ? (