Files
hikari-desktop/src-tauri/src/commands.rs
T
hikari 2db858080d feat: add font size and zoom settings
- 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
2026-01-23 18:16:45 -08:00

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(),
})
}