generated from nhcarrigan/template
2db858080d
- Add font_size config field (10-24px, default 14px) - Add keyboard shortcuts: Ctrl++/- to adjust, Ctrl+0 to reset - Add font size slider in Settings > Appearance - Apply font size to Terminal and InputBar via CSS variable - Persist font size preference between sessions Closes #19
301 lines
8.8 KiB
Rust
301 lines
8.8 KiB
Rust
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<bool, String> {
|
|
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<String, String> {
|
|
let manager = bridge_manager.lock();
|
|
manager.get_working_directory(&conversation_id)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn select_wsl_directory() -> Result<String, String> {
|
|
Ok("/home".to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_config(app: AppHandle) -> Result<HikariConfig, String> {
|
|
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<UsageStats, String> {
|
|
let manager = bridge_manager.lock();
|
|
manager.get_usage_stats(&conversation_id)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn validate_directory(
|
|
path: String,
|
|
current_dir: Option<String>,
|
|
) -> Result<String, String> {
|
|
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<Vec<AchievementUnlockedEvent>, 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<Vec<String>, 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<String>,
|
|
}
|
|
|
|
#[derive(Debug, serde::Deserialize)]
|
|
struct GiteaRelease {
|
|
tag_name: String,
|
|
html_url: String,
|
|
body: Option<String>,
|
|
prerelease: bool,
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn check_for_updates() -> Result<UpdateInfo, String> {
|
|
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<GiteaRelease> =
|
|
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(),
|
|
})
|
|
}
|