use tauri::{AppHandle, State}; use tauri_plugin_http::reqwest; use tauri_plugin_store::StoreExt; use crate::achievements::{get_achievement_info, load_achievements, AchievementUnlockedEvent}; use crate::bridge_manager::SharedBridgeManager; use crate::config::{ClaudeStartOptions, HikariConfig}; use crate::stats::UsageStats; const CONFIG_STORE_KEY: &str = "config"; #[tauri::command] pub async fn start_claude( bridge_manager: State<'_, SharedBridgeManager>, conversation_id: String, options: ClaudeStartOptions, ) -> Result<(), String> { let mut manager = bridge_manager.lock(); manager.start_claude(&conversation_id, options) } #[tauri::command] pub async fn stop_claude( bridge_manager: State<'_, SharedBridgeManager>, conversation_id: String, ) -> Result<(), String> { let mut manager = bridge_manager.lock(); manager.stop_claude(&conversation_id) } #[tauri::command] pub async fn interrupt_claude( bridge_manager: State<'_, SharedBridgeManager>, conversation_id: String, ) -> Result<(), String> { let mut manager = bridge_manager.lock(); manager.interrupt_claude(&conversation_id) } #[tauri::command] pub async fn send_prompt( bridge_manager: State<'_, SharedBridgeManager>, conversation_id: String, message: String, ) -> Result<(), String> { let mut manager = bridge_manager.lock(); manager.send_prompt(&conversation_id, message) } #[tauri::command] pub async fn is_claude_running( bridge_manager: State<'_, SharedBridgeManager>, conversation_id: String, ) -> Result { let manager = bridge_manager.lock(); Ok(manager.is_claude_running(&conversation_id)) } #[tauri::command] pub async fn get_working_directory( bridge_manager: State<'_, SharedBridgeManager>, conversation_id: String, ) -> Result { let manager = bridge_manager.lock(); manager.get_working_directory(&conversation_id) } #[tauri::command] pub async fn select_wsl_directory() -> Result { Ok("/home".to_string()) } #[tauri::command] pub async fn get_config(app: AppHandle) -> Result { let store = app.store("hikari-config.json").map_err(|e| e.to_string())?; match store.get(CONFIG_STORE_KEY) { Some(value) => serde_json::from_value(value.clone()).map_err(|e| e.to_string()), None => Ok(HikariConfig::default()), } } #[tauri::command] pub async fn save_config(app: AppHandle, config: HikariConfig) -> Result<(), String> { let store = app.store("hikari-config.json").map_err(|e| e.to_string())?; let value = serde_json::to_value(&config).map_err(|e| e.to_string())?; store.set(CONFIG_STORE_KEY, value); store.save().map_err(|e| e.to_string())?; Ok(()) } #[tauri::command] pub async fn get_usage_stats( bridge_manager: State<'_, SharedBridgeManager>, conversation_id: String, ) -> Result { let manager = bridge_manager.lock(); manager.get_usage_stats(&conversation_id) } #[tauri::command] pub async fn validate_directory( path: String, current_dir: Option, ) -> Result { use std::path::Path; let path = Path::new(&path); // Expand ~ to home directory let expanded_path = if path.starts_with("~") { if let Some(home) = std::env::var_os("HOME") { let home_path = Path::new(&home); if path == Path::new("~") { home_path.to_path_buf() } else { home_path.join(path.strip_prefix("~").unwrap()) } } else { return Err("Could not determine home directory".to_string()); } } else if path.is_relative() { // Handle relative paths (., .., or any relative path) by resolving against current_dir if let Some(ref cwd) = current_dir { Path::new(cwd).join(path) } else { path.to_path_buf() } } else { path.to_path_buf() }; // Check if the path exists and is a directory if !expanded_path.exists() { return Err(format!( "Directory does not exist: {}", expanded_path.display() )); } if !expanded_path.is_dir() { return Err(format!( "Path is not a directory: {}", expanded_path.display() )); } // Return the canonicalized (absolute) path expanded_path .canonicalize() .map(|p| p.to_string_lossy().to_string()) .map_err(|e| format!("Failed to resolve path: {}", e)) } #[tauri::command] pub async fn load_saved_achievements( app: AppHandle, ) -> Result, String> { use chrono::Utc; // Load achievements from persistent store let progress = load_achievements(&app).await; // Create events for all previously unlocked achievements let mut events = Vec::new(); for achievement_id in &progress.unlocked { let mut info = get_achievement_info(achievement_id); info.unlocked_at = Some(Utc::now()); // We don't store timestamps, so just use now events.push(AchievementUnlockedEvent { achievement: info }); } Ok(events) } #[tauri::command] pub async fn answer_question( bridge_manager: State<'_, SharedBridgeManager>, conversation_id: String, tool_use_id: String, answers: serde_json::Value, ) -> Result<(), String> { let mut manager = bridge_manager.lock(); manager.send_tool_result(&conversation_id, &tool_use_id, answers) } #[tauri::command] pub async fn list_skills() -> Result, String> { use std::fs; use std::path::Path; // Get the home directory let home = std::env::var_os("HOME").ok_or_else(|| "Could not determine home directory".to_string())?; let skills_dir = Path::new(&home).join(".claude").join("skills"); // If the skills directory doesn't exist, return empty list if !skills_dir.exists() { return Ok(Vec::new()); } // Read the directory and collect skill names let mut skills = Vec::new(); let entries = fs::read_dir(&skills_dir).map_err(|e| format!("Failed to read skills directory: {}", e))?; for entry in entries { let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?; let path = entry.path(); // Only include directories that contain a SKILL.md file if path.is_dir() { let skill_file = path.join("SKILL.md"); if skill_file.exists() { if let Some(name) = path.file_name() { skills.push(name.to_string_lossy().to_string()); } } } } // Sort alphabetically skills.sort(); Ok(skills) } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct UpdateInfo { pub current_version: String, pub latest_version: String, pub has_update: bool, pub release_url: String, pub release_notes: Option, } #[derive(Debug, serde::Deserialize)] struct GiteaRelease { tag_name: String, html_url: String, body: Option, prerelease: bool, } #[tauri::command] pub async fn check_for_updates() -> Result { const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); const RELEASES_API: &str = "https://git.nhcarrigan.com/api/v1/repos/nhcarrigan/hikari-desktop/releases"; // Fetch releases from Gitea API let client = reqwest::Client::new(); let response = client .get(RELEASES_API) .header("Accept", "application/json") .send() .await .map_err(|e| format!("Failed to fetch releases: {}", e))?; if !response.status().is_success() { return Err(format!("API returned status: {}", response.status())); } let text = response .text() .await .map_err(|e| format!("Failed to read response: {}", e))?; let releases: Vec = serde_json::from_str(&text).map_err(|e| format!("Failed to parse releases: {}", e))?; // Find the latest non-prerelease, or fall back to latest prerelease let latest = releases .iter() .find(|r| !r.prerelease) .or_else(|| releases.first()); let latest = match latest { Some(r) => r, None => return Err("No releases found".to_string()), }; // Parse version strings (remove 'v' prefix if present) let current = semver::Version::parse(CURRENT_VERSION) .map_err(|e| format!("Failed to parse current version: {}", e))?; let latest_tag = latest.tag_name.trim_start_matches('v'); let latest_ver = semver::Version::parse(latest_tag) .map_err(|e| format!("Failed to parse latest version: {}", e))?; Ok(UpdateInfo { current_version: CURRENT_VERSION.to_string(), latest_version: latest.tag_name.clone(), has_update: latest_ver > current, release_url: latest.html_url.clone(), release_notes: latest.body.clone(), }) }