use tauri::State; use crate::docker; use crate::models::gateway_settings::GatewaySettings; use crate::models::AppSettings; use crate::AppState; #[tauri::command] pub async fn get_settings(state: State<'_, AppState>) -> Result { Ok(state.settings_store.get()) } #[tauri::command] pub async fn update_settings( settings: AppSettings, state: State<'_, AppState>, ) -> Result { let before = state.settings_store.get(); let saved = state.settings_store.update(settings)?; // Persisting a setting is not the same as applying it. The gateway is the // one settings block that owns a *container*, so a saved change that the // running container doesn't reflect is a live desync, not a preference. reconcile_gateway(&before.gateway, &saved.gateway).await; Ok(saved) } /// What a settings save has to do to the gateway container to stay honest. /// /// Kept separate from the IPC command and expressed over plain settings so the /// decision is testable without Docker. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum GatewayAction { /// Nothing to do. None, /// The gateway is off — a container left running must be stopped. StopIfRunning, /// The published shape moved. A *running* container is now serving on the /// old binding while status reports the new one, so it has to be recreated. RestartIfRunning, } /// Whether the container's published shape (as opposed to a purely cosmetic /// field) changed. Provider, models and base URL all change the rendered /// LiteLLM config, which is only read at boot. fn gateway_shape_changed(before: &GatewaySettings, after: &GatewaySettings) -> bool { before.port != after.port || before.provider.trim() != after.provider.trim() || before.api_base.as_deref().unwrap_or("").trim() != after.api_base.as_deref().unwrap_or("").trim() || before.valid_models() != after.valid_models() } fn gateway_action(before: &GatewaySettings, after: &GatewaySettings) -> GatewayAction { if !after.enabled { // Includes the case where it was already disabled: a container found // running while the feature is off should not stay up. return GatewayAction::StopIfRunning; } if gateway_shape_changed(before, after) { return GatewayAction::RestartIfRunning; } GatewayAction::None } /// Apply [`gateway_action`]. Never fails the settings save: the settings *are* /// saved by this point, and a Docker hiccup must not make the UI think they /// weren't. Both paths are no-ops when no container exists, so this stays cheap /// on the overwhelmingly common "gateway not in use" save. async fn reconcile_gateway(before: &GatewaySettings, after: &GatewaySettings) { let action = gateway_action(before, after); if action == GatewayAction::None { return; } let (exists, running) = match docker::gateway::gateway_container_presence().await { Ok(presence) => presence, // Docker down: there is nothing running to desync from. Err(e) => { log::debug!("Gateway reconcile skipped ({})", e); return; } }; if !exists || !running { return; } match action { GatewayAction::StopIfRunning => { log::info!("Model gateway disabled in settings — stopping the container"); if let Err(e) = docker::gateway::stop_gateway_container().await { log::error!("Failed to stop the model gateway after it was disabled: {}", e); } } GatewayAction::RestartIfRunning => { log::info!("Model gateway settings changed — recreating the container"); // The fingerprint no longer matches, so this stops, removes and // recreates with the new port/config in one step. if let Err(e) = docker::gateway::ensure_gateway_running(after).await { log::error!("Failed to apply the new model gateway settings: {}", e); } } GatewayAction::None => unreachable!(), } } #[tauri::command] pub async fn pull_image( image_name: String, app_handle: tauri::AppHandle, ) -> Result<(), String> { use tauri::Emitter; docker::pull_image(&image_name, move |msg| { let _ = app_handle.emit("image-pull-progress", msg); }) .await } #[tauri::command] pub async fn detect_host_timezone() -> Result { // Try the iana-time-zone crate first (cross-platform) match iana_time_zone::get_timezone() { Ok(tz) => return Ok(tz), Err(e) => log::debug!("iana_time_zone::get_timezone() failed: {}", e), } // Fallback: check TZ env var if let Ok(tz) = std::env::var("TZ") { if !tz.is_empty() { return Ok(tz); } } // Fallback: read /etc/timezone (Linux) if let Ok(tz) = std::fs::read_to_string("/etc/timezone") { let tz = tz.trim().to_string(); if !tz.is_empty() { return Ok(tz); } } // Default to UTC if detection fails Ok("UTC".to_string()) } #[tauri::command] pub async fn detect_aws_config() -> Result, String> { if let Some(home) = dirs::home_dir() { let aws_dir = home.join(".aws"); if aws_dir.exists() { return Ok(Some(aws_dir.to_string_lossy().to_string())); } } Ok(None) } /// What the UI shows next to a corporate CA certificate path. /// /// Errors are returned *inside* the payload rather than as `Err` so the field /// can render its own inline message while the user is still typing — a toast /// per keystroke would be unusable. The same check runs again, as a hard error, /// when the container is created. #[derive(Debug, serde::Serialize)] pub struct CaCertInfo { pub exists: bool, pub is_directory: bool, /// How many certificate files were found. pub cert_count: usize, /// The names they will be installed as inside the container. Surfacing /// these makes the silent `.pem` → `.crt` rename visible, which is the one /// step users most often do by hand and get wrong. pub installed_names: Vec, /// Why the path is unusable, if it is. pub error: Option, } #[tauri::command] pub async fn inspect_ca_cert_path(path: String) -> Result { use crate::docker::ca_certs; let trimmed = path.trim(); if trimmed.is_empty() { return Ok(CaCertInfo { exists: false, is_directory: false, cert_count: 0, installed_names: Vec::new(), error: None, }); } let p = std::path::Path::new(trimmed); let exists = p.exists(); let is_directory = p.is_dir(); match ca_certs::resolve(Some(trimmed)) { Ok(Some(resolved)) => Ok(CaCertInfo { exists, is_directory, cert_count: resolved.cert_files.len(), installed_names: resolved .cert_files .iter() .map(|f| { ca_certs::container_cert_name( &f.file_name().unwrap_or_default().to_string_lossy(), ) }) .collect(), error: None, }), Ok(None) => Ok(CaCertInfo { exists, is_directory, cert_count: 0, installed_names: Vec::new(), error: None, }), Err(e) => Ok(CaCertInfo { exists, is_directory, cert_count: 0, installed_names: Vec::new(), error: Some(e), }), } } #[tauri::command] pub async fn list_aws_profiles() -> Result, String> { let mut profiles = Vec::new(); let home = match dirs::home_dir() { Some(h) => h, None => return Ok(profiles), }; // Parse ~/.aws/credentials let credentials_path = home.join(".aws").join("credentials"); if credentials_path.exists() { if let Ok(contents) = std::fs::read_to_string(&credentials_path) { for line in contents.lines() { let trimmed = line.trim(); if trimmed.starts_with('[') && trimmed.ends_with(']') { let profile = trimmed[1..trimmed.len() - 1].to_string(); if !profiles.contains(&profile) { profiles.push(profile); } } } } } // Parse ~/.aws/config (profiles are prefixed with "profile ") let config_path = home.join(".aws").join("config"); if config_path.exists() { if let Ok(contents) = std::fs::read_to_string(&config_path) { for line in contents.lines() { let trimmed = line.trim(); if trimmed.starts_with('[') && trimmed.ends_with(']') { let section = &trimmed[1..trimmed.len() - 1]; let profile = if let Some(name) = section.strip_prefix("profile ") { name.to_string() } else { section.to_string() }; if !profiles.contains(&profile) { profiles.push(profile); } } } } } Ok(profiles) } #[cfg(test)] mod tests { use super::*; use crate::models::gateway_settings::GatewayModel; fn enabled_gateway() -> GatewaySettings { GatewaySettings { enabled: true, port: 4000, provider: "openai".to_string(), api_base: None, models: vec![GatewayModel { name: "gpt-5.1".to_string(), model_id: "gpt-5.1".to_string(), }], } } #[test] fn disabling_the_gateway_stops_it() { // The bug: turning the toggle off only persisted `enabled: false` and // hid the Stop button, leaving a container serving with no way to stop // it. let before = enabled_gateway(); let mut after = before.clone(); after.enabled = false; assert_eq!(gateway_action(&before, &after), GatewayAction::StopIfRunning); // Still true when it was already off — a stray running container is // still a container that shouldn't be up. assert_eq!(gateway_action(&after, &after), GatewayAction::StopIfRunning); } #[test] fn changing_the_port_reconciles_the_container() { // Otherwise status reports the new port while the container keeps the // old binding, and every project gets a broken ANTHROPIC_BASE_URL. let before = enabled_gateway(); let mut after = before.clone(); after.port = 4100; assert_eq!( gateway_action(&before, &after), GatewayAction::RestartIfRunning ); } #[test] fn config_changes_that_only_take_effect_at_boot_reconcile_too() { let before = enabled_gateway(); let mut provider = before.clone(); provider.provider = "groq".to_string(); assert_eq!( gateway_action(&before, &provider), GatewayAction::RestartIfRunning ); let mut api_base = before.clone(); api_base.api_base = Some("https://example.test/v1".to_string()); assert_eq!( gateway_action(&before, &api_base), GatewayAction::RestartIfRunning ); let mut models = before.clone(); models.models[0].model_id = "gpt-4.1".to_string(); assert_eq!( gateway_action(&before, &models), GatewayAction::RestartIfRunning ); } #[test] fn saving_an_unchanged_or_half_typed_gateway_touches_nothing() { let before = enabled_gateway(); assert_eq!(gateway_action(&before, &before), GatewayAction::None); // Whitespace-only edits don't reach the rendered config. let mut trimmed = before.clone(); trimmed.provider = " openai ".to_string(); trimmed.api_base = Some(" ".to_string()); assert_eq!(gateway_action(&before, &trimmed), GatewayAction::None); // A half-filled model row is skipped when rendering, so it must not // bounce a live container either. let mut half_typed = before.clone(); half_typed.models.push(GatewayModel { name: "gpt".to_string(), model_id: String::new(), }); assert_eq!(gateway_action(&before, &half_typed), GatewayAction::None); } }