From 99a277088ab40d31388065087557d6bb2164a172 Mon Sep 17 00:00:00 2001 From: Alexandr Bogomyakov Date: Tue, 22 Jul 2025 23:23:18 +0300 Subject: [PATCH] UI tray app added * Works with egui --------- Co-authored-by: Ultradesu --- .DS_Store | Bin 0 -> 6148 bytes Cargo.lock | 3618 +++++++++++++++++++++++++++++++- Cargo.toml | 22 +- src/client.rs | 4 +- src/gui/admin/mod.rs | 5 + src/gui/admin/state.rs | 178 ++ src/gui/admin/ui.rs | 451 ++++ src/gui/api/client.rs | 254 +++ src/gui/api/mod.rs | 3 + src/gui/common/mod.rs | 3 + src/gui/common/settings.rs | 143 ++ src/gui/mod.rs | 43 + src/gui/settings/connection.rs | 202 ++ src/gui/settings/mod.rs | 5 + src/gui/settings/ui.rs | 549 +++++ src/gui/settings/window.rs | 584 ++++++ src/gui/tray/app.rs | 275 +++ src/gui/tray/icon.rs | 281 +++ src/gui/tray/mod.rs | 6 + src/main.rs | 99 +- static/.DS_Store | Bin 0 -> 6148 bytes static/khm-icon.svg | 10 + 22 files changed, 6647 insertions(+), 88 deletions(-) create mode 100644 .DS_Store create mode 100644 src/gui/admin/mod.rs create mode 100644 src/gui/admin/state.rs create mode 100644 src/gui/admin/ui.rs create mode 100644 src/gui/api/client.rs create mode 100644 src/gui/api/mod.rs create mode 100644 src/gui/common/mod.rs create mode 100644 src/gui/common/settings.rs create mode 100644 src/gui/mod.rs create mode 100644 src/gui/settings/connection.rs create mode 100644 src/gui/settings/mod.rs create mode 100644 src/gui/settings/ui.rs create mode 100644 src/gui/settings/window.rs create mode 100644 src/gui/tray/app.rs create mode 100644 src/gui/tray/icon.rs create mode 100644 src/gui/tray/mod.rs create mode 100644 static/.DS_Store create mode 100644 static/khm-icon.svg diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..f280d8746a7540c16abf1ed749d94ff743a5df5e GIT binary patch literal 6148 zcmeHK%}T>S5Z<-5CKMqDg&r5Y7Hn-1i&|40V1_SEUDR>OY3D+VW{byuuL zN4>5X4vyDr+uA=kJiVAc$1jO|(?oLMT*J)&UJ(pE2G"] +description = "KHM - Known Hosts Manager for SSH key management and synchronization" +homepage = "https://github.com/house-of-vanity/khm" +repository = "https://github.com/house-of-vanity/khm" +license = "WTFPL" +keywords = ["ssh", "known-hosts", "security", "system-admin", "automation"] +categories = ["command-line-utilities", "network-programming"] [dependencies] actix-web = "4" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -env_logger = "0.11.3" log = "0.4" regex = "1.10.5" base64 = "0.21" @@ -22,3 +27,18 @@ trust-dns-resolver = "0.23" futures = "0.3" hostname = "0.3" rust-embed = "8.0" +tray-icon = { version = "0.19", optional = true } +notify = { version = "6.1", optional = true } +notify-debouncer-mini = { version = "0.4", optional = true } +dirs = "5.0" +eframe = { version = "0.29", optional = true } +egui = { version = "0.29", optional = true } +winit = { version = "0.30", optional = true } +env_logger = "0.11" +urlencoding = "2.1" + +[features] +default = ["gui"] +gui = ["tray-icon", "eframe", "egui", "winit", "notify", "notify-debouncer-mini"] +server = [] + diff --git a/src/client.rs b/src/client.rs index 78d3006..a6a26dd 100644 --- a/src/client.rs +++ b/src/client.rs @@ -8,14 +8,14 @@ use std::io::{self, BufRead, Write}; use std::path::Path; #[derive(Serialize, Deserialize, Clone, Debug)] -struct SshKey { +pub struct SshKey { server: String, public_key: String, #[serde(default)] deprecated: bool, } -fn read_known_hosts(file_path: &str) -> io::Result> { +pub fn read_known_hosts(file_path: &str) -> io::Result> { let path = Path::new(file_path); let file = File::open(&path)?; let reader = io::BufReader::new(file); diff --git a/src/gui/admin/mod.rs b/src/gui/admin/mod.rs new file mode 100644 index 0000000..7f50756 --- /dev/null +++ b/src/gui/admin/mod.rs @@ -0,0 +1,5 @@ +mod state; +mod ui; + +pub use state::*; +pub use ui::*; diff --git a/src/gui/admin/state.rs b/src/gui/admin/state.rs new file mode 100644 index 0000000..357d019 --- /dev/null +++ b/src/gui/admin/state.rs @@ -0,0 +1,178 @@ +use eframe::egui; +use log::{error, info}; +use std::collections::HashMap; +use std::sync::mpsc; +use crate::gui::api::{SshKey, fetch_keys}; +use crate::gui::common::KhmSettings; + +#[derive(Debug, Clone)] +pub enum AdminOperation { + LoadingKeys, + DeprecatingKey, + RestoringKey, + DeletingKey, + BulkDeprecating, + BulkRestoring, + None, +} + +#[derive(Debug, Clone)] +pub struct AdminState { + pub keys: Vec, + pub filtered_keys: Vec, + pub search_term: String, + pub show_deprecated_only: bool, + pub selected_servers: HashMap, + pub expanded_servers: HashMap, + pub current_operation: AdminOperation, + pub last_load_time: Option, +} + +impl Default for AdminState { + fn default() -> Self { + Self { + keys: Vec::new(), + filtered_keys: Vec::new(), + search_term: String::new(), + show_deprecated_only: false, + selected_servers: HashMap::new(), + expanded_servers: HashMap::new(), + current_operation: AdminOperation::None, + last_load_time: None, + } + } +} + +impl AdminState { + /// Filter keys based on current search term and deprecated filter + pub fn filter_keys(&mut self) { + let mut filtered = self.keys.clone(); + + // Apply deprecated filter + if self.show_deprecated_only { + filtered.retain(|key| key.deprecated); + } + + // Apply search filter + if !self.search_term.is_empty() { + let search_term = self.search_term.to_lowercase(); + filtered.retain(|key| { + key.server.to_lowercase().contains(&search_term) || + key.public_key.to_lowercase().contains(&search_term) + }); + } + + self.filtered_keys = filtered; + } + + /// Load keys from server + pub fn load_keys(&mut self, settings: &KhmSettings, ctx: &egui::Context) -> Option, String>>> { + if settings.host.is_empty() || settings.flow.is_empty() { + return None; + } + + self.current_operation = AdminOperation::LoadingKeys; + + let (tx, rx) = mpsc::channel(); + + let host = settings.host.clone(); + let flow = settings.flow.clone(); + let basic_auth = settings.basic_auth.clone(); + let ctx_clone = ctx.clone(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(async { + fetch_keys(host, flow, basic_auth).await + }); + + let _ = tx.send(result); + ctx_clone.request_repaint(); + }); + + Some(rx) + } + + /// Handle keys load result + pub fn handle_keys_loaded(&mut self, result: Result, String>) { + match result { + Ok(keys) => { + self.keys = keys; + self.last_load_time = Some(std::time::Instant::now()); + self.filter_keys(); + self.current_operation = AdminOperation::None; + info!("Keys loaded successfully: {} keys", self.keys.len()); + } + Err(error) => { + self.current_operation = AdminOperation::None; + error!("Failed to load keys: {}", error); + } + } + } + + /// Get selected servers list + pub fn get_selected_servers(&self) -> Vec { + self.selected_servers + .iter() + .filter_map(|(server, &selected)| if selected { Some(server.clone()) } else { None }) + .collect() + } + + /// Clear selected servers + pub fn clear_selection(&mut self) { + self.selected_servers.clear(); + } + + /// Get statistics + pub fn get_statistics(&self) -> AdminStatistics { + let total_keys = self.keys.len(); + let active_keys = self.keys.iter().filter(|k| !k.deprecated).count(); + let deprecated_keys = total_keys - active_keys; + let unique_servers = self.keys.iter().map(|k| &k.server).collect::>().len(); + + AdminStatistics { + total_keys, + active_keys, + deprecated_keys, + unique_servers, + } + } +} + +#[derive(Debug, Clone)] +pub struct AdminStatistics { + pub total_keys: usize, + pub active_keys: usize, + pub deprecated_keys: usize, + pub unique_servers: usize, +} + +/// Get SSH key type from public key string +pub fn get_key_type(public_key: &str) -> String { + if public_key.starts_with("ssh-rsa") { + "RSA".to_string() + } else if public_key.starts_with("ssh-ed25519") { + "ED25519".to_string() + } else if public_key.starts_with("ecdsa-sha2-nistp") { + "ECDSA".to_string() + } else if public_key.starts_with("ssh-dss") { + "DSA".to_string() + } else { + "Unknown".to_string() + } +} + +/// Get preview of SSH key (first 12 characters of key part) +pub fn get_key_preview(public_key: &str) -> String { + let parts: Vec<&str> = public_key.split_whitespace().collect(); + if parts.len() >= 2 { + let key_part = parts[1]; + if key_part.len() > 12 { + format!("{}...", &key_part[..12]) + } else { + key_part.to_string() + } + } else { + format!("{}...", &public_key[..std::cmp::min(12, public_key.len())]) + } +} diff --git a/src/gui/admin/ui.rs b/src/gui/admin/ui.rs new file mode 100644 index 0000000..bed66fe --- /dev/null +++ b/src/gui/admin/ui.rs @@ -0,0 +1,451 @@ +use eframe::egui; +use std::collections::BTreeMap; +use super::state::{AdminState, get_key_type, get_key_preview}; +use crate::gui::api::SshKey; + +/// Render statistics cards +pub fn render_statistics(ui: &mut egui::Ui, admin_state: &AdminState) { + let stats = admin_state.get_statistics(); + + ui.group(|ui| { + ui.set_min_width(ui.available_width()); + ui.vertical(|ui| { + ui.label(egui::RichText::new("📊 Statistics").size(16.0).strong()); + ui.add_space(8.0); + + ui.horizontal(|ui| { + ui.columns(4, |cols| { + // Total keys + cols[0].vertical_centered_justified(|ui| { + ui.label(egui::RichText::new("📊").size(20.0)); + ui.label(egui::RichText::new(stats.total_keys.to_string()).size(24.0).strong()); + ui.label(egui::RichText::new("Total Keys").size(11.0).color(egui::Color32::GRAY)); + }); + + // Active keys + cols[1].vertical_centered_justified(|ui| { + ui.label(egui::RichText::new("✅").size(20.0)); + ui.label(egui::RichText::new(stats.active_keys.to_string()).size(24.0).strong().color(egui::Color32::LIGHT_GREEN)); + ui.label(egui::RichText::new("Active").size(11.0).color(egui::Color32::GRAY)); + }); + + // Deprecated keys + cols[2].vertical_centered_justified(|ui| { + ui.label(egui::RichText::new("❌").size(20.0)); + ui.label(egui::RichText::new(stats.deprecated_keys.to_string()).size(24.0).strong().color(egui::Color32::LIGHT_RED)); + ui.label(egui::RichText::new("Deprecated").size(11.0).color(egui::Color32::GRAY)); + }); + + // Servers + cols[3].vertical_centered_justified(|ui| { + ui.label(egui::RichText::new("💻").size(20.0)); + ui.label(egui::RichText::new(stats.unique_servers.to_string()).size(24.0).strong().color(egui::Color32::LIGHT_BLUE)); + ui.label(egui::RichText::new("Servers").size(11.0).color(egui::Color32::GRAY)); + }); + }); + }); + }); + }); +} + +/// Render search and filter controls +pub fn render_search_controls(ui: &mut egui::Ui, admin_state: &mut AdminState) -> bool { + let mut changed = false; + + ui.group(|ui| { + ui.set_min_width(ui.available_width()); + ui.vertical(|ui| { + ui.label(egui::RichText::new("🔍 Search").size(16.0).strong()); + ui.add_space(8.0); + + // Search field with full width + ui.horizontal(|ui| { + ui.label(egui::RichText::new("🔍").size(14.0)); + let search_response = ui.add_sized( + [ui.available_width() * 0.6, 20.0], + egui::TextEdit::singleline(&mut admin_state.search_term) + .hint_text("Search servers or keys...") + ); + + if admin_state.search_term.is_empty() { + ui.label(egui::RichText::new("Type to search").size(11.0).color(egui::Color32::GRAY)); + } else { + ui.label(egui::RichText::new(format!("{} results", admin_state.filtered_keys.len())).size(11.0)); + if ui.add(egui::Button::new(egui::RichText::new("❌").color(egui::Color32::WHITE)) + .fill(egui::Color32::from_rgb(170, 170, 170)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(89, 89, 89))) + .rounding(egui::Rounding::same(3.0)) + .min_size(egui::vec2(18.0, 18.0)) + ).on_hover_text("Clear search").clicked() { + admin_state.search_term.clear(); + changed = true; + } + } + + // Handle search text changes + if search_response.changed() { + changed = true; + } + }); + + ui.add_space(5.0); + + // Filter controls + ui.horizontal(|ui| { + ui.label("Filter:"); + let show_deprecated = admin_state.show_deprecated_only; + if ui.selectable_label(!show_deprecated, "✅ Active").clicked() { + admin_state.show_deprecated_only = false; + changed = true; + } + if ui.selectable_label(show_deprecated, "❗ Deprecated").clicked() { + admin_state.show_deprecated_only = true; + changed = true; + } + }); + }); + }); + + if changed { + admin_state.filter_keys(); + } + + changed +} + +/// Render bulk actions controls +pub fn render_bulk_actions(ui: &mut egui::Ui, admin_state: &mut AdminState) -> BulkAction { + let selected_count = admin_state.selected_servers.values().filter(|&&v| v).count(); + + if selected_count == 0 { + return BulkAction::None; + } + + let mut action = BulkAction::None; + + ui.group(|ui| { + ui.set_min_width(ui.available_width()); + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("📋").size(14.0)); + ui.label(egui::RichText::new(format!("Selected {} servers", selected_count)) + .size(14.0) + .strong() + .color(egui::Color32::LIGHT_BLUE)); + }); + + ui.add_space(5.0); + + ui.horizontal(|ui| { + if ui.add(egui::Button::new(egui::RichText::new("❗ Deprecate Selected").color(egui::Color32::BLACK)) + .fill(egui::Color32::from_rgb(255, 200, 0)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(102, 94, 72))) + .rounding(egui::Rounding::same(6.0)) + .min_size(egui::vec2(130.0, 28.0)) + ).clicked() { + action = BulkAction::DeprecateSelected; + } + + if ui.add(egui::Button::new(egui::RichText::new("✅ Restore Selected").color(egui::Color32::WHITE)) + .fill(egui::Color32::from_rgb(101, 199, 40)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(94, 105, 25))) + .rounding(egui::Rounding::same(6.0)) + .min_size(egui::vec2(120.0, 28.0)) + ).clicked() { + action = BulkAction::RestoreSelected; + } + + if ui.add(egui::Button::new(egui::RichText::new("X Clear Selection").color(egui::Color32::WHITE)) + .fill(egui::Color32::from_rgb(170, 170, 170)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(89, 89, 89))) + .rounding(egui::Rounding::same(6.0)) + .min_size(egui::vec2(110.0, 28.0)) + ).clicked() { + admin_state.clear_selection(); + action = BulkAction::ClearSelection; + } + }); + }); + }); + + action +} + +/// Render keys table grouped by servers +pub fn render_keys_table(ui: &mut egui::Ui, admin_state: &mut AdminState) -> KeyAction { + if admin_state.filtered_keys.is_empty() { + render_empty_state(ui, admin_state); + return KeyAction::None; + } + + let mut action = KeyAction::None; + + // Group keys by server + let mut servers: BTreeMap> = BTreeMap::new(); + for key in &admin_state.filtered_keys { + servers.entry(key.server.clone()).or_insert_with(Vec::new).push(key.clone()); + } + + // Render each server group + for (server_name, server_keys) in servers { + let is_expanded = admin_state.expanded_servers.get(&server_name).copied().unwrap_or(false); + let active_count = server_keys.iter().filter(|k| !k.deprecated).count(); + let deprecated_count = server_keys.len() - active_count; + + // Server header + ui.group(|ui| { + ui.horizontal(|ui| { + // Server selection checkbox + let mut selected = admin_state.selected_servers.get(&server_name).copied().unwrap_or(false); + if ui.add(egui::Checkbox::new(&mut selected, "") + .indeterminate(false) + ).changed() { + admin_state.selected_servers.insert(server_name.clone(), selected); + } + + // Expand/collapse button + let expand_icon = if is_expanded { "▼" } else { "▶" }; + if ui.add(egui::Button::new(expand_icon) + .fill(egui::Color32::TRANSPARENT) + .stroke(egui::Stroke::NONE) + .min_size(egui::vec2(20.0, 20.0)) + ).clicked() { + admin_state.expanded_servers.insert(server_name.clone(), !is_expanded); + } + + // Server icon and name + ui.label(egui::RichText::new("💻").size(16.0)); + ui.label(egui::RichText::new(&server_name) + .size(15.0) + .strong() + .color(egui::Color32::WHITE)); + + // Keys count badge + render_badge(ui, &format!("{} keys", server_keys.len()), egui::Color32::from_rgb(52, 152, 219), egui::Color32::WHITE); + + ui.add_space(5.0); + + // Deprecated count badge + if deprecated_count > 0 { + render_badge(ui, &format!("{} depr", deprecated_count), egui::Color32::from_rgb(231, 76, 60), egui::Color32::WHITE); + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Server action buttons + if deprecated_count > 0 { + if ui.add(egui::Button::new(egui::RichText::new("✅ Restore").color(egui::Color32::WHITE)) + .fill(egui::Color32::from_rgb(101, 199, 40)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(94, 105, 25))) + .rounding(egui::Rounding::same(4.0)) + .min_size(egui::vec2(70.0, 24.0)) + ).clicked() { + action = KeyAction::RestoreServer(server_name.clone()); + } + } + + if active_count > 0 { + if ui.add(egui::Button::new(egui::RichText::new("❗ Deprecate").color(egui::Color32::BLACK)) + .fill(egui::Color32::from_rgb(255, 200, 0)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(102, 94, 72))) + .rounding(egui::Rounding::same(4.0)) + .min_size(egui::vec2(85.0, 24.0)) + ).clicked() { + action = KeyAction::DeprecateServer(server_name.clone()); + } + } + }); + }); + }); + + // Expanded key details + if is_expanded { + ui.indent("server_keys", |ui| { + for key in &server_keys { + if let Some(key_action) = render_key_item(ui, key, &server_name) { + action = key_action; + } + } + }); + } + + ui.add_space(5.0); + } + + action +} + +/// Render empty state when no keys are available +fn render_empty_state(ui: &mut egui::Ui, admin_state: &AdminState) { + ui.vertical_centered(|ui| { + ui.add_space(60.0); + if admin_state.keys.is_empty() { + ui.label(egui::RichText::new("🔑").size(48.0).color(egui::Color32::GRAY)); + ui.label(egui::RichText::new("No SSH keys available") + .size(18.0) + .color(egui::Color32::GRAY)); + ui.label(egui::RichText::new("Keys will appear here once loaded from the server") + .size(14.0) + .color(egui::Color32::DARK_GRAY)); + } else if !admin_state.search_term.is_empty() { + ui.label(egui::RichText::new("🔍").size(48.0).color(egui::Color32::GRAY)); + ui.label(egui::RichText::new("No results found") + .size(18.0) + .color(egui::Color32::GRAY)); + ui.label(egui::RichText::new(format!("Try adjusting your search: '{}'", admin_state.search_term)) + .size(14.0) + .color(egui::Color32::DARK_GRAY)); + } else { + ui.label(egui::RichText::new("❌").size(48.0).color(egui::Color32::GRAY)); + ui.label(egui::RichText::new("No keys match current filters") + .size(18.0) + .color(egui::Color32::GRAY)); + ui.label(egui::RichText::new("Try adjusting your search or filter settings") + .size(14.0) + .color(egui::Color32::DARK_GRAY)); + } + }); +} + +/// Render individual key item +fn render_key_item(ui: &mut egui::Ui, key: &SshKey, server_name: &str) -> Option { + let mut action = None; + + ui.group(|ui| { + ui.horizontal(|ui| { + // Key type badge + let key_type = get_key_type(&key.public_key); + let (badge_color, text_color) = match key_type.as_str() { + "RSA" => (egui::Color32::from_rgb(52, 144, 220), egui::Color32::WHITE), + "ED25519" => (egui::Color32::from_rgb(46, 204, 113), egui::Color32::WHITE), + "ECDSA" => (egui::Color32::from_rgb(241, 196, 15), egui::Color32::BLACK), + "DSA" => (egui::Color32::from_rgb(230, 126, 34), egui::Color32::WHITE), + _ => (egui::Color32::GRAY, egui::Color32::WHITE), + }; + + render_small_badge(ui, &key_type, badge_color, text_color); + ui.add_space(5.0); + + // Status badge + if key.deprecated { + ui.label(egui::RichText::new("❗ DEPR") + .size(10.0) + .color(egui::Color32::from_rgb(231, 76, 60)) + .strong()); + } else { + ui.label(egui::RichText::new("[OK] ACTIVE") + .size(10.0) + .color(egui::Color32::from_rgb(46, 204, 113)) + .strong()); + } + + ui.add_space(5.0); + + // Key preview + ui.label(egui::RichText::new(get_key_preview(&key.public_key)) + .font(egui::FontId::monospace(10.0)) + .color(egui::Color32::LIGHT_GRAY)); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Key action buttons + if key.deprecated { + if ui.add(egui::Button::new(egui::RichText::new("[R]").color(egui::Color32::WHITE)) + .fill(egui::Color32::from_rgb(101, 199, 40)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(94, 105, 25))) + .rounding(egui::Rounding::same(3.0)) + .min_size(egui::vec2(22.0, 18.0)) + ).on_hover_text("Restore key").clicked() { + action = Some(KeyAction::RestoreKey(server_name.to_string())); + } + if ui.add(egui::Button::new(egui::RichText::new("Del").color(egui::Color32::WHITE)) + .fill(egui::Color32::from_rgb(246, 36, 71)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(129, 18, 17))) + .rounding(egui::Rounding::same(3.0)) + .min_size(egui::vec2(26.0, 18.0)) + ).on_hover_text("Delete key").clicked() { + action = Some(KeyAction::DeleteKey(server_name.to_string())); + } + } else { + if ui.add(egui::Button::new(egui::RichText::new("❗").color(egui::Color32::BLACK)) + .fill(egui::Color32::from_rgb(255, 200, 0)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(102, 94, 72))) + .rounding(egui::Rounding::same(3.0)) + .min_size(egui::vec2(22.0, 18.0)) + ).on_hover_text("Deprecate key").clicked() { + action = Some(KeyAction::DeprecateKey(server_name.to_string())); + } + } + + if ui.add(egui::Button::new(egui::RichText::new("Copy").color(egui::Color32::WHITE)) + .fill(egui::Color32::from_rgb(0, 111, 230)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(35, 84, 97))) + .rounding(egui::Rounding::same(3.0)) + .min_size(egui::vec2(30.0, 18.0)) + ).on_hover_text("Copy to clipboard").clicked() { + ui.output_mut(|o| o.copied_text = key.public_key.clone()); + } + }); + }); + }); + + action +} + +/// Render a badge with text +fn render_badge(ui: &mut egui::Ui, text: &str, bg_color: egui::Color32, text_color: egui::Color32) { + let (rect, _) = ui.allocate_exact_size( + egui::vec2(50.0, 18.0), + egui::Sense::hover() + ); + ui.painter().rect_filled( + rect, + egui::Rounding::same(8.0), + bg_color + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + text, + egui::FontId::proportional(10.0), + text_color, + ); +} + +/// Render a small badge with text +fn render_small_badge(ui: &mut egui::Ui, text: &str, bg_color: egui::Color32, text_color: egui::Color32) { + let (rect, _) = ui.allocate_exact_size( + egui::vec2(40.0, 16.0), + egui::Sense::hover() + ); + ui.painter().rect_filled( + rect, + egui::Rounding::same(3.0), + bg_color + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + text, + egui::FontId::proportional(9.0), + text_color, + ); +} + +/// Actions that can be performed on keys +#[derive(Debug, Clone)] +pub enum KeyAction { + None, + DeprecateKey(String), + RestoreKey(String), + DeleteKey(String), + DeprecateServer(String), + RestoreServer(String), +} + +/// Bulk actions that can be performed +#[derive(Debug, Clone)] +pub enum BulkAction { + None, + DeprecateSelected, + RestoreSelected, + ClearSelection, +} diff --git a/src/gui/api/client.rs b/src/gui/api/client.rs new file mode 100644 index 0000000..ea0ed50 --- /dev/null +++ b/src/gui/api/client.rs @@ -0,0 +1,254 @@ +use reqwest::Client; +use log::info; +use serde::{Deserialize, Serialize}; +use crate::gui::common::{KhmSettings, perform_sync}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SshKey { + pub server: String, + pub public_key: String, + #[serde(default)] + pub deprecated: bool, +} + +/// Test connection to KHM server +pub async fn test_connection(host: String, flow: String, basic_auth: String) -> Result { + if host.is_empty() || flow.is_empty() { + return Err("Host and flow must be specified".to_string()); + } + + let url = format!("{}/{}/keys", host.trim_end_matches('/'), flow); + info!("Testing connection to: {}", url); + + let client = create_http_client()?; + let mut request = client.get(&url); + + request = add_auth_if_needed(request, &basic_auth)?; + + let response = request.send().await + .map_err(|e| format!("Request failed: {}", e))?; + + check_response_status(&response)?; + + let body = response.text().await + .map_err(|e| format!("Failed to read response: {}", e))?; + + check_html_response(&body)?; + + let keys: Vec = serde_json::from_str(&body) + .map_err(|e| format!("Failed to parse response: {}", e))?; + + let message = format!("Found {} SSH keys from flow '{}'", keys.len(), flow); + info!("Connection test successful: {}", message); + Ok(message) +} + +/// Fetch all SSH keys including deprecated ones +pub async fn fetch_keys(host: String, flow: String, basic_auth: String) -> Result, String> { + if host.is_empty() || flow.is_empty() { + return Err("Host and flow must be specified".to_string()); + } + + let url = format!("{}/{}/keys?include_deprecated=true", host.trim_end_matches('/'), flow); + info!("Fetching keys from: {}", url); + + let client = create_http_client()?; + let mut request = client.get(&url); + + request = add_auth_if_needed(request, &basic_auth)?; + + let response = request.send().await + .map_err(|e| format!("Request failed: {}", e))?; + + check_response_status(&response)?; + + let body = response.text().await + .map_err(|e| format!("Failed to read response: {}", e))?; + + check_html_response(&body)?; + + let keys: Vec = serde_json::from_str(&body) + .map_err(|e| format!("Failed to parse response: {}", e))?; + + info!("Fetched {} SSH keys", keys.len()); + Ok(keys) +} + +/// Deprecate a key for a specific server +pub async fn deprecate_key(host: String, flow: String, basic_auth: String, server: String) -> Result { + let url = format!("{}/{}/keys/{}", host.trim_end_matches('/'), flow, urlencoding::encode(&server)); + info!("Deprecating key for server '{}' at: {}", server, url); + + let client = create_http_client()?; + let mut request = client.delete(&url); + + request = add_auth_if_needed(request, &basic_auth)?; + + let response = request.send().await + .map_err(|e| format!("Request failed: {}", e))?; + + check_response_status(&response)?; + + let body = response.text().await + .map_err(|e| format!("Failed to read response: {}", e))?; + + parse_api_response(&body, &format!("Successfully deprecated key for server '{}'", server)) +} + +/// Restore a key for a specific server +pub async fn restore_key(host: String, flow: String, basic_auth: String, server: String) -> Result { + let url = format!("{}/{}/keys/{}/restore", host.trim_end_matches('/'), flow, urlencoding::encode(&server)); + info!("Restoring key for server '{}' at: {}", server, url); + + let client = create_http_client()?; + let mut request = client.post(&url); + + request = add_auth_if_needed(request, &basic_auth)?; + + let response = request.send().await + .map_err(|e| format!("Request failed: {}", e))?; + + check_response_status(&response)?; + + let body = response.text().await + .map_err(|e| format!("Failed to read response: {}", e))?; + + parse_api_response(&body, &format!("Successfully restored key for server '{}'", server)) +} + +/// Delete a key permanently for a specific server +pub async fn delete_key(host: String, flow: String, basic_auth: String, server: String) -> Result { + let url = format!("{}/{}/keys/{}/delete", host.trim_end_matches('/'), flow, urlencoding::encode(&server)); + info!("Permanently deleting key for server '{}' at: {}", server, url); + + let client = create_http_client()?; + let mut request = client.delete(&url); + + request = add_auth_if_needed(request, &basic_auth)?; + + let response = request.send().await + .map_err(|e| format!("Request failed: {}", e))?; + + check_response_status(&response)?; + + let body = response.text().await + .map_err(|e| format!("Failed to read response: {}", e))?; + + parse_api_response(&body, &format!("Successfully deleted key for server '{}'", server)) +} + +/// Bulk deprecate multiple servers +pub async fn bulk_deprecate_servers(host: String, flow: String, basic_auth: String, servers: Vec) -> Result { + let url = format!("{}/{}/bulk-deprecate", host.trim_end_matches('/'), flow); + info!("Bulk deprecating {} servers at: {}", servers.len(), url); + + let client = create_http_client()?; + let mut request = client.post(&url) + .json(&serde_json::json!({ + "servers": servers + })); + + request = add_auth_if_needed(request, &basic_auth)?; + + let response = request.send().await + .map_err(|e| format!("Request failed: {}", e))?; + + check_response_status(&response)?; + + let body = response.text().await + .map_err(|e| format!("Failed to read response: {}", e))?; + + parse_api_response(&body, "Successfully deprecated servers") +} + +/// Bulk restore multiple servers +pub async fn bulk_restore_servers(host: String, flow: String, basic_auth: String, servers: Vec) -> Result { + let url = format!("{}/{}/bulk-restore", host.trim_end_matches('/'), flow); + info!("Bulk restoring {} servers at: {}", servers.len(), url); + + let client = create_http_client()?; + let mut request = client.post(&url) + .json(&serde_json::json!({ + "servers": servers + })); + + request = add_auth_if_needed(request, &basic_auth)?; + + let response = request.send().await + .map_err(|e| format!("Request failed: {}", e))?; + + check_response_status(&response)?; + + let body = response.text().await + .map_err(|e| format!("Failed to read response: {}", e))?; + + parse_api_response(&body, "Successfully restored servers") +} + +/// Perform manual sync operation +pub async fn perform_manual_sync(settings: KhmSettings) -> Result { + match perform_sync(&settings).await { + Ok(keys_count) => Ok(format!("Sync completed successfully with {} keys", keys_count)), + Err(e) => Err(e.to_string()), + } +} + +// Helper functions + +fn create_http_client() -> Result { + Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e)) +} + +fn add_auth_if_needed(request: reqwest::RequestBuilder, basic_auth: &str) -> Result { + if basic_auth.is_empty() { + return Ok(request); + } + + let auth_parts: Vec<&str> = basic_auth.splitn(2, ':').collect(); + if auth_parts.len() == 2 { + Ok(request.basic_auth(auth_parts[0], Some(auth_parts[1]))) + } else { + Err("Basic auth format should be 'username:password'".to_string()) + } +} + +fn check_response_status(response: &reqwest::Response) -> Result<(), String> { + let status = response.status().as_u16(); + + if status == 401 { + return Err("Authentication required. Please provide valid basic auth credentials.".to_string()); + } + + if status >= 300 && status < 400 { + return Err("Server redirects to login page. Authentication may be required.".to_string()); + } + + if !response.status().is_success() { + return Err(format!("Server returned error: {} {}", status, response.status().canonical_reason().unwrap_or("Unknown"))); + } + + Ok(()) +} + +fn check_html_response(body: &str) -> Result<(), String> { + if body.trim_start().starts_with(" Result { + if let Ok(json_response) = serde_json::from_str::(body) { + if let Some(message) = json_response.get("message").and_then(|v| v.as_str()) { + Ok(message.to_string()) + } else { + Ok(default_message.to_string()) + } + } else { + Ok(default_message.to_string()) + } +} diff --git a/src/gui/api/mod.rs b/src/gui/api/mod.rs new file mode 100644 index 0000000..be50984 --- /dev/null +++ b/src/gui/api/mod.rs @@ -0,0 +1,3 @@ +mod client; + +pub use client::*; diff --git a/src/gui/common/mod.rs b/src/gui/common/mod.rs new file mode 100644 index 0000000..a10a586 --- /dev/null +++ b/src/gui/common/mod.rs @@ -0,0 +1,3 @@ +mod settings; + +pub use settings::*; diff --git a/src/gui/common/settings.rs b/src/gui/common/settings.rs new file mode 100644 index 0000000..c30f34e --- /dev/null +++ b/src/gui/common/settings.rs @@ -0,0 +1,143 @@ +use dirs::home_dir; +use log::{debug, error, info}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KhmSettings { + pub host: String, + pub flow: String, + pub known_hosts: String, + pub basic_auth: String, + pub in_place: bool, + pub auto_sync_interval_minutes: u32, +} + +impl Default for KhmSettings { + fn default() -> Self { + Self { + host: String::new(), + flow: String::new(), + known_hosts: get_default_known_hosts_path(), + basic_auth: String::new(), + in_place: true, + auto_sync_interval_minutes: 60, + } + } +} + +/// Get default known_hosts file path based on OS +fn get_default_known_hosts_path() -> String { + #[cfg(target_os = "windows")] + { + if let Ok(user_profile) = std::env::var("USERPROFILE") { + format!("{}/.ssh/known_hosts", user_profile) + } else { + "~/.ssh/known_hosts".to_string() + } + } + #[cfg(not(target_os = "windows"))] + { + "~/.ssh/known_hosts".to_string() + } +} + +/// Get configuration file path +pub fn get_config_path() -> PathBuf { + let mut path = home_dir().expect("Could not find home directory"); + path.push(".khm"); + fs::create_dir_all(&path).ok(); + path.push("khm_config.json"); + path +} + +/// Load settings from configuration file +pub fn load_settings() -> KhmSettings { + let path = get_config_path(); + match fs::read_to_string(&path) { + Ok(contents) => { + let mut settings: KhmSettings = serde_json::from_str(&contents).unwrap_or_else(|e| { + error!("Failed to parse KHM config: {}", e); + KhmSettings::default() + }); + + // Fill in default known_hosts path if empty + if settings.known_hosts.is_empty() { + settings.known_hosts = get_default_known_hosts_path(); + } + + settings + } + Err(_) => { + debug!("KHM config file not found, using defaults"); + KhmSettings::default() + } + } +} + +/// Save settings to configuration file +pub fn save_settings(settings: &KhmSettings) -> Result<(), std::io::Error> { + let path = get_config_path(); + let json = serde_json::to_string_pretty(settings)?; + fs::write(&path, json)?; + info!("KHM settings saved"); + Ok(()) +} + +/// Expand path with ~ substitution +pub fn expand_path(path: &str) -> String { + if path.starts_with("~/") { + if let Some(home) = home_dir() { + return home.join(&path[2..]).to_string_lossy().to_string(); + } + } + path.to_string() +} + +/// Perform sync operation using KHM client logic +pub async fn perform_sync(settings: &KhmSettings) -> Result { + use crate::Args; + + info!("Starting sync with settings: host={}, flow={}, known_hosts={}, in_place={}", + settings.host, settings.flow, settings.known_hosts, settings.in_place); + + // Convert KhmSettings to Args for client module + let args = Args { + server: false, + gui: false, + settings_ui: false, + in_place: settings.in_place, + flows: vec!["default".to_string()], // Not used in client mode + ip: "127.0.0.1".to_string(), // Not used in client mode + port: 8080, // Not used in client mode + db_host: "127.0.0.1".to_string(), // Not used in client mode + db_name: "khm".to_string(), // Not used in client mode + db_user: None, // Not used in client mode + db_password: None, // Not used in client mode + host: Some(settings.host.clone()), + flow: Some(settings.flow.clone()), + known_hosts: expand_path(&settings.known_hosts), + basic_auth: settings.basic_auth.clone(), + }; + + info!("Expanded known_hosts path: {}", args.known_hosts); + + // Get keys count before and after sync + let keys_before = crate::client::read_known_hosts(&args.known_hosts) + .unwrap_or_else(|_| Vec::new()) + .len(); + + crate::client::run_client(args.clone()).await?; + + let keys_after = if args.in_place { + crate::client::read_known_hosts(&args.known_hosts) + .unwrap_or_else(|_| Vec::new()) + .len() + } else { + keys_before + }; + + info!("Sync completed: {} keys before, {} keys after", keys_before, keys_after); + Ok(keys_after) +} diff --git a/src/gui/mod.rs b/src/gui/mod.rs new file mode 100644 index 0000000..da9ba4c --- /dev/null +++ b/src/gui/mod.rs @@ -0,0 +1,43 @@ +use log::info; + +// Modules +mod api; +mod admin; +mod common; + +#[cfg(feature = "gui")] +mod settings; +#[cfg(feature = "gui")] +mod tray; + +// Re-exports for backward compatibility and external usage +#[cfg(feature = "gui")] +pub use settings::run_settings_window; +#[cfg(feature = "gui")] +pub use tray::run_tray_app; + +// User events for GUI communication +#[cfg(feature = "gui")] +#[derive(Debug)] +pub enum UserEvent { + TrayIconEvent, + MenuEvent(tray_icon::menu::MenuEvent), + ConfigFileChanged, + UpdateMenu, +} + +/// Run GUI application in tray mode +#[cfg(feature = "gui")] +pub async fn run_gui() -> std::io::Result<()> { + info!("Starting KHM tray application"); + run_tray_app().await +} + +/// Stub function when GUI is disabled +#[cfg(not(feature = "gui"))] +pub async fn run_gui() -> std::io::Result<()> { + return Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "GUI features not compiled. Install system dependencies and rebuild with --features gui" + )); +} diff --git a/src/gui/settings/connection.rs b/src/gui/settings/connection.rs new file mode 100644 index 0000000..83fc6be --- /dev/null +++ b/src/gui/settings/connection.rs @@ -0,0 +1,202 @@ +use eframe::egui; +use log::{error, info}; +use std::sync::mpsc; +use crate::gui::api::{test_connection, perform_manual_sync}; +use crate::gui::common::{KhmSettings, save_settings}; + +#[derive(Debug, Clone)] +pub enum ConnectionStatus { + Unknown, + Connected { keys_count: usize, flow: String }, + Error(String), +} + +#[derive(Debug, Clone)] +pub enum SyncStatus { + Unknown, + Success { keys_count: usize }, + Error(String), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum SettingsTab { + Connection, + Admin, +} + +pub struct ConnectionTab { + pub connection_status: ConnectionStatus, + pub is_testing_connection: bool, + pub test_result_receiver: Option>>, + pub is_syncing: bool, + pub sync_result_receiver: Option>>, + pub sync_status: SyncStatus, +} + +impl Default for ConnectionTab { + fn default() -> Self { + Self { + connection_status: ConnectionStatus::Unknown, + is_testing_connection: false, + test_result_receiver: None, + is_syncing: false, + sync_result_receiver: None, + sync_status: SyncStatus::Unknown, + } + } +} + +impl ConnectionTab { + /// Start connection test + pub fn start_test(&mut self, settings: &KhmSettings, ctx: &egui::Context) { + if self.is_testing_connection { + return; + } + + self.is_testing_connection = true; + self.connection_status = ConnectionStatus::Unknown; + + let (tx, rx) = mpsc::channel(); + self.test_result_receiver = Some(rx); + + let host = settings.host.clone(); + let flow = settings.flow.clone(); + let basic_auth = settings.basic_auth.clone(); + let ctx_clone = ctx.clone(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(async { + test_connection(host, flow, basic_auth).await + }); + + let _ = tx.send(result); + ctx_clone.request_repaint(); + }); + } + + /// Start manual sync + pub fn start_sync(&mut self, settings: &KhmSettings, ctx: &egui::Context) { + if self.is_syncing { + return; + } + + self.is_syncing = true; + self.sync_status = SyncStatus::Unknown; + + let (tx, rx) = mpsc::channel(); + self.sync_result_receiver = Some(rx); + + let settings = settings.clone(); + let ctx_clone = ctx.clone(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(async { + perform_manual_sync(settings).await + }); + + let _ = tx.send(result); + ctx_clone.request_repaint(); + }); + } + + /// Check for test/sync results + pub fn check_results(&mut self, ctx: &egui::Context, settings: &KhmSettings, operation_log: &mut Vec) { + // Check for test connection result + if let Some(receiver) = &self.test_result_receiver { + if let Ok(result) = receiver.try_recv() { + self.is_testing_connection = false; + match result { + Ok(message) => { + // Parse keys count from message + let keys_count = if let Some(start) = message.find("Found ") { + if let Some(end) = message[start + 6..].find(" SSH keys") { + message[start + 6..start + 6 + end].parse::().unwrap_or(0) + } else { 0 } + } else { 0 }; + + self.connection_status = ConnectionStatus::Connected { + keys_count, + flow: settings.flow.clone() + }; + info!("Connection test successful: {}", message); + + // Add to UI log + super::ui::add_log_entry(operation_log, format!("✅ Connection test successful: {}", message)); + } + Err(error) => { + self.connection_status = ConnectionStatus::Error(error.clone()); + error!("Connection test failed"); + + // Add to UI log + super::ui::add_log_entry(operation_log, format!("❌ Connection test failed: {}", error)); + } + } + self.test_result_receiver = None; + ctx.request_repaint(); + } + } + + // Check for sync result + if let Some(receiver) = &self.sync_result_receiver { + if let Ok(result) = receiver.try_recv() { + self.is_syncing = false; + match result { + Ok(message) => { + // Parse keys count from message + let keys_count = parse_keys_count(&message); + self.sync_status = SyncStatus::Success { keys_count }; + info!("Sync successful: {}", message); + + // Add to UI log + super::ui::add_log_entry(operation_log, format!("✅ Sync completed: {}", message)); + } + Err(error) => { + self.sync_status = SyncStatus::Error(error.clone()); + error!("Sync failed"); + + // Add to UI log + super::ui::add_log_entry(operation_log, format!("❌ Sync failed: {}", error)); + } + } + self.sync_result_receiver = None; + ctx.request_repaint(); + } + } + } +} + +/// Parse keys count from sync result message +fn parse_keys_count(message: &str) -> usize { + if let Some(start) = message.find("updated with ") { + let search_start = start + "updated with ".len(); + if let Some(end) = message[search_start..].find(" keys") { + let number_str = &message[search_start..search_start + end]; + return number_str.parse::().unwrap_or(0); + } + } else if let Some(start) = message.find("Retrieved ") { + let search_start = start + "Retrieved ".len(); + if let Some(end) = message[search_start..].find(" keys") { + let number_str = &message[search_start..search_start + end]; + return number_str.parse::().unwrap_or(0); + } + } else if let Some(keys_pos) = message.find(" keys") { + let before_keys = &message[..keys_pos]; + if let Some(space_pos) = before_keys.rfind(' ') { + let number_str = &before_keys[space_pos + 1..]; + return number_str.parse::().unwrap_or(0); + } + } + + 0 +} + +/// Save settings with validation +pub fn save_settings_validated(settings: &KhmSettings) -> Result<(), String> { + if settings.host.is_empty() || settings.flow.is_empty() { + return Err("Host URL and Flow Name are required".to_string()); + } + + save_settings(settings).map_err(|e| format!("Failed to save settings: {}", e)) +} diff --git a/src/gui/settings/mod.rs b/src/gui/settings/mod.rs new file mode 100644 index 0000000..32c7ddd --- /dev/null +++ b/src/gui/settings/mod.rs @@ -0,0 +1,5 @@ +mod connection; +mod ui; +mod window; + +pub use window::*; diff --git a/src/gui/settings/ui.rs b/src/gui/settings/ui.rs new file mode 100644 index 0000000..58c897d --- /dev/null +++ b/src/gui/settings/ui.rs @@ -0,0 +1,549 @@ +use eframe::egui; +use crate::gui::common::{KhmSettings, get_config_path}; +use super::connection::{ConnectionTab, ConnectionStatus, SyncStatus, save_settings_validated}; + +/// Render connection settings tab with modern horizontal UI design +pub fn render_connection_tab( + ui: &mut egui::Ui, + ctx: &egui::Context, + settings: &mut KhmSettings, + auto_sync_interval_str: &mut String, + connection_tab: &mut ConnectionTab, + operation_log: &mut Vec +) { + // Check for connection test and sync results + connection_tab.check_results(ctx, settings, operation_log); + + // Use scrollable area for the entire content + egui::ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + ui.spacing_mut().item_spacing = egui::vec2(6.0, 8.0); + ui.spacing_mut().button_padding = egui::vec2(12.0, 6.0); + ui.spacing_mut().indent = 16.0; + + // Connection Status Card at top (full width) + render_connection_status_card(ui, connection_tab); + + // Main configuration area - horizontal layout + ui.horizontal_top(|ui| { + let available_width = ui.available_width(); + let left_panel_width = available_width * 0.6; + let right_panel_width = available_width * 0.38; + + // Left panel - Connection and Local config + ui.allocate_ui_with_layout( + [left_panel_width, ui.available_height()].into(), + egui::Layout::top_down(egui::Align::Min), + |ui| { + // Connection Configuration Card + render_connection_config_card(ui, settings); + + // Local Configuration Card + render_local_config_card(ui, settings); + } + ); + + ui.add_space(8.0); + + // Right panel - Auto-sync and System info + ui.allocate_ui_with_layout( + [right_panel_width, ui.available_height()].into(), + egui::Layout::top_down(egui::Align::Min), + |ui| { + // Auto-sync Configuration Card + render_auto_sync_card(ui, settings, auto_sync_interval_str); + + // System Information Card + render_system_info_card(ui); + } + ); + }); + + ui.add_space(12.0); + + // Action buttons at bottom + render_action_section(ui, ctx, settings, connection_tab, operation_log); + }); +} + +/// Connection status card with modern visual design +fn render_connection_status_card(ui: &mut egui::Ui, connection_tab: &ConnectionTab) { + let frame = egui::Frame::group(ui.style()) + .fill(ui.visuals().faint_bg_color) + .stroke(egui::Stroke::new(1.0, ui.visuals().widgets.noninteractive.bg_stroke.color)) + .rounding(6.0) + .inner_margin(egui::Margin::same(12.0)); + + frame.show(ui, |ui| { + // Header with status indicator + ui.horizontal(|ui| { + let (status_icon, status_text, status_color) = match &connection_tab.connection_status { + ConnectionStatus::Connected { keys_count, flow } => { + let text = if flow.is_empty() { + format!("Connected • {} keys", keys_count) + } else { + format!("Connected to '{}' • {} keys", flow, keys_count) + }; + ("🟢", text, egui::Color32::GREEN) + } + ConnectionStatus::Error(error_msg) => { + ("🔴", format!("Connection Error: {}", error_msg), egui::Color32::RED) + } + ConnectionStatus::Unknown => { + ("⚫", "Not Connected".to_string(), ui.visuals().text_color()) + } + }; + + ui.label(egui::RichText::new(status_icon).size(14.0)); + ui.label(egui::RichText::new("Connection Status").size(14.0).strong()); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if connection_tab.is_testing_connection { + ui.spinner(); + ui.label(egui::RichText::new("Testing...").italics().color(ui.visuals().weak_text_color())); + } else { + ui.label(egui::RichText::new(&status_text).size(13.0).color(status_color)); + } + }); + }); + + // Sync status - always visible + ui.add_space(6.0); + ui.separator(); + ui.add_space(6.0); + + ui.horizontal(|ui| { + ui.label("🔄"); + ui.label("Last Sync:"); + + match &connection_tab.sync_status { + SyncStatus::Success { keys_count } => { + ui.label(egui::RichText::new(format!("✅ {} keys synced", keys_count)) + .size(13.0).color(egui::Color32::GREEN)); + } + SyncStatus::Error(error_msg) => { + ui.label(egui::RichText::new("❌ Failed") + .size(13.0).color(egui::Color32::RED)) + .on_hover_text(error_msg); + } + SyncStatus::Unknown => { + ui.label(egui::RichText::new("No sync performed yet") + .size(13.0).color(ui.visuals().weak_text_color())); + } + } + }); + }); + + ui.add_space(8.0); +} + +/// Connection configuration card with input fields +fn render_connection_config_card(ui: &mut egui::Ui, settings: &mut KhmSettings) { + let frame = egui::Frame::group(ui.style()) + .fill(ui.visuals().faint_bg_color) + .stroke(egui::Stroke::new(1.0, ui.visuals().widgets.noninteractive.bg_stroke.color)) + .rounding(6.0) + .inner_margin(egui::Margin::same(12.0)); + + frame.show(ui, |ui| { + // Header + ui.horizontal(|ui| { + ui.label("🌐"); + ui.label(egui::RichText::new("Server Configuration").size(14.0).strong()); + }); + + ui.add_space(8.0); + + // Input fields with better spacing + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 8.0; + + // Host URL + ui.vertical(|ui| { + ui.label(egui::RichText::new("Host URL").size(13.0).strong()); + ui.add_space(3.0); + ui.add_sized( + [ui.available_width(), 28.0], // Smaller height for better centering + egui::TextEdit::singleline(&mut settings.host) + .hint_text("https://your-khm-server.com") + .font(egui::FontId::new(14.0, egui::FontFamily::Monospace)) + .margin(egui::Margin::symmetric(8.0, 6.0)) // Better vertical centering + ); + }); + + // Flow Name + ui.vertical(|ui| { + ui.label(egui::RichText::new("Flow Name").size(13.0).strong()); + ui.add_space(3.0); + ui.add_sized( + [ui.available_width(), 28.0], + egui::TextEdit::singleline(&mut settings.flow) + .hint_text("production, staging, development") + .font(egui::FontId::new(14.0, egui::FontFamily::Proportional)) + .margin(egui::Margin::symmetric(8.0, 6.0)) + ); + }); + + // Basic Auth (optional) + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Basic Authentication").size(13.0).strong()); + ui.label(egui::RichText::new("(optional)").size(12.0).weak().italics()); + }); + ui.add_space(3.0); + ui.add_sized( + [ui.available_width(), 28.0], + egui::TextEdit::singleline(&mut settings.basic_auth) + .hint_text("username:password") + .password(true) + .font(egui::FontId::new(14.0, egui::FontFamily::Monospace)) + .margin(egui::Margin::symmetric(8.0, 6.0)) + ); + }); + }); + }); + + ui.add_space(8.0); +} + +/// Local configuration card +fn render_local_config_card(ui: &mut egui::Ui, settings: &mut KhmSettings) { + let frame = egui::Frame::group(ui.style()) + .fill(ui.visuals().faint_bg_color) + .stroke(egui::Stroke::new(1.0, ui.visuals().widgets.noninteractive.bg_stroke.color)) + .rounding(6.0) + .inner_margin(egui::Margin::same(12.0)); + + frame.show(ui, |ui| { + // Header + ui.horizontal(|ui| { + ui.label("📁"); + ui.label(egui::RichText::new("Local Configuration").size(14.0).strong()); + }); + + ui.add_space(8.0); + + // Known hosts file + ui.vertical(|ui| { + ui.label(egui::RichText::new("Known Hosts File Path").size(13.0).strong()); + ui.add_space(3.0); + ui.add_sized( + [ui.available_width(), 28.0], + egui::TextEdit::singleline(&mut settings.known_hosts) + .hint_text("~/.ssh/known_hosts") + .font(egui::FontId::new(14.0, egui::FontFamily::Monospace)) + .margin(egui::Margin::symmetric(8.0, 6.0)) + ); + + ui.add_space(8.0); + + // In-place update option with better styling + ui.horizontal(|ui| { + ui.checkbox(&mut settings.in_place, ""); + ui.vertical(|ui| { + ui.label(egui::RichText::new("Update file in-place after sync").size(13.0).strong()); + ui.label(egui::RichText::new("Automatically modify the known_hosts file when synchronizing").size(12.0).weak().italics()); + }); + }); + }); + }); + + ui.add_space(8.0); +} + +/// Auto-sync configuration card +fn render_auto_sync_card(ui: &mut egui::Ui, settings: &mut KhmSettings, auto_sync_interval_str: &mut String) { + let frame = egui::Frame::group(ui.style()) + .fill(ui.visuals().faint_bg_color) + .stroke(egui::Stroke::new(1.0, ui.visuals().widgets.noninteractive.bg_stroke.color)) + .rounding(6.0) + .inner_margin(egui::Margin::same(12.0)); + + frame.show(ui, |ui| { + let is_auto_sync_enabled = !settings.host.is_empty() + && !settings.flow.is_empty() + && settings.in_place; + + // Header with status + ui.horizontal(|ui| { + ui.label("🔄"); + ui.label(egui::RichText::new("Auto Sync").size(14.0).strong()); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let (status_text, status_color) = if is_auto_sync_enabled { + ("● Active", egui::Color32::GREEN) + } else { + ("○ Inactive", egui::Color32::from_gray(128)) + }; + + ui.label(egui::RichText::new(status_text).size(12.0).color(status_color)); + }); + }); + + ui.add_space(8.0); + + // Interval setting + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Interval").size(13.0).strong()); + ui.add_space(6.0); + ui.add_sized( + [80.0, 26.0], // Smaller height + egui::TextEdit::singleline(auto_sync_interval_str) + .font(egui::FontId::new(14.0, egui::FontFamily::Monospace)) + .margin(egui::Margin::symmetric(6.0, 5.0)) + ); + ui.label("min"); + + // Update the actual setting + if let Ok(value) = auto_sync_interval_str.parse::() { + if value > 0 { + settings.auto_sync_interval_minutes = value; + } + } + }); + + // Requirements - always visible + ui.add_space(8.0); + ui.separator(); + ui.add_space(8.0); + + ui.vertical(|ui| { + ui.label(egui::RichText::new("Requirements:").size(12.0).strong()); + ui.add_space(3.0); + + let host_ok = !settings.host.is_empty(); + let flow_ok = !settings.flow.is_empty(); + let in_place_ok = settings.in_place; + + ui.horizontal(|ui| { + let (icon, color) = if host_ok { ("✅", egui::Color32::GREEN) } else { ("❌", egui::Color32::RED) }; + ui.label(egui::RichText::new(icon).color(color)); + ui.label(egui::RichText::new("Host URL").size(11.0)); + }); + + ui.horizontal(|ui| { + let (icon, color) = if flow_ok { ("✅", egui::Color32::GREEN) } else { ("❌", egui::Color32::RED) }; + ui.label(egui::RichText::new(icon).color(color)); + ui.label(egui::RichText::new("Flow name").size(11.0)); + }); + + ui.horizontal(|ui| { + let (icon, color) = if in_place_ok { ("✅", egui::Color32::GREEN) } else { ("❌", egui::Color32::RED) }; + ui.label(egui::RichText::new(icon).color(color)); + ui.label(egui::RichText::new("In-place update").size(11.0)); + }); + }); + }); + + ui.add_space(8.0); +} + +/// System information card +fn render_system_info_card(ui: &mut egui::Ui) { + let frame = egui::Frame::group(ui.style()) + .fill(ui.visuals().extreme_bg_color) + .stroke(egui::Stroke::new(1.0, ui.visuals().widgets.noninteractive.bg_stroke.color)) + .rounding(6.0) + .inner_margin(egui::Margin::same(12.0)); + + frame.show(ui, |ui| { + // Header + ui.horizontal(|ui| { + ui.label("⚙️"); + ui.label(egui::RichText::new("System Info").size(14.0).strong()); + }); + + ui.add_space(8.0); + + // Config file location + ui.vertical(|ui| { + ui.label(egui::RichText::new("Config File").size(13.0).strong()); + ui.add_space(3.0); + + let config_path = get_config_path(); + let path_str = config_path.display().to_string(); + + ui.vertical(|ui| { + ui.add_sized( + [ui.available_width(), 26.0], // Smaller height + egui::TextEdit::singleline(&mut path_str.clone()) + .interactive(false) + .font(egui::FontId::new(12.0, egui::FontFamily::Monospace)) + .margin(egui::Margin::symmetric(8.0, 5.0)) + ); + + ui.add_space(4.0); + + if ui.small_button("📋 Copy Path").clicked() { + ui.output_mut(|o| o.copied_text = path_str); + } + }); + }); + }); + + ui.add_space(8.0); +} + +/// Action section with buttons only (Activity Log moved to bottom panel) +fn render_action_section( + ui: &mut egui::Ui, + ctx: &egui::Context, + settings: &KhmSettings, + connection_tab: &mut ConnectionTab, + operation_log: &mut Vec +) { + ui.add_space(8.0); + + // Validation message + let save_enabled = !settings.host.is_empty() && !settings.flow.is_empty(); + if !save_enabled { + ui.horizontal(|ui| { + ui.label("⚠️"); + ui.label(egui::RichText::new("Complete server configuration to enable saving") + .size(12.0) + .color(egui::Color32::LIGHT_YELLOW) + .italics()); + }); + ui.add_space(8.0); + } + + // Action buttons with modern styling + render_modern_action_buttons(ui, ctx, settings, connection_tab, save_enabled, operation_log); +} + +/// Modern action buttons with improved styling and layout +fn render_modern_action_buttons( + ui: &mut egui::Ui, + ctx: &egui::Context, + settings: &KhmSettings, + connection_tab: &mut ConnectionTab, + save_enabled: bool, + operation_log: &mut Vec +) { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 8.0; + + // Primary actions (left side) + if ui.add_enabled( + save_enabled, + egui::Button::new( + egui::RichText::new("💾 Save & Close") + .size(13.0) + .color(egui::Color32::WHITE) + ) + .fill(if save_enabled { + egui::Color32::from_rgb(0, 120, 212) + } else { + ui.visuals().widgets.inactive.bg_fill + }) + .min_size(egui::vec2(120.0, 32.0)) + .rounding(6.0) + ).clicked() { + match save_settings_validated(settings) { + Ok(()) => { + add_log_entry(operation_log, "✅ Settings saved successfully".to_string()); + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + Err(e) => { + add_log_entry(operation_log, format!("❌ Failed to save settings: {}", e)); + } + } + } + + if ui.add( + egui::Button::new( + egui::RichText::new("✖ Cancel") + .size(13.0) + .color(ui.visuals().text_color()) + ) + .stroke(egui::Stroke::new(1.0, ui.visuals().text_color())) + .fill(egui::Color32::TRANSPARENT) + .min_size(egui::vec2(80.0, 32.0)) + .rounding(6.0) + ).clicked() { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + + // Spacer + ui.add_space(ui.available_width() - 220.0); + + // Secondary actions (right side) + let can_test = !settings.host.is_empty() && !settings.flow.is_empty() && !connection_tab.is_testing_connection; + let can_sync = !settings.host.is_empty() && !settings.flow.is_empty() && !connection_tab.is_syncing; + + if ui.add_enabled( + can_test, + egui::Button::new( + egui::RichText::new( + if connection_tab.is_testing_connection { + "🔄 Testing..." + } else { + "🔍 Test" + } + ) + .size(13.0) + .color(egui::Color32::WHITE) + ) + .fill(if can_test { + egui::Color32::from_rgb(16, 124, 16) + } else { + ui.visuals().widgets.inactive.bg_fill + }) + .min_size(egui::vec2(80.0, 32.0)) + .rounding(6.0) + ).on_hover_text("Test server connection").clicked() { + add_log_entry(operation_log, "🔍 Testing connection...".to_string()); + connection_tab.start_test(settings, ctx); + } + + if ui.add_enabled( + can_sync, + egui::Button::new( + egui::RichText::new( + if connection_tab.is_syncing { + "🔄 Syncing..." + } else { + "🔄 Sync" + } + ) + .size(13.0) + .color(egui::Color32::WHITE) + ) + .fill(if can_sync { + egui::Color32::from_rgb(255, 140, 0) + } else { + ui.visuals().widgets.inactive.bg_fill + }) + .min_size(egui::vec2(80.0, 32.0)) + .rounding(6.0) + ).on_hover_text("Synchronize SSH keys now").clicked() { + add_log_entry(operation_log, "🔄 Starting sync...".to_string()); + connection_tab.start_sync(settings, ctx); + } + }); +} + +/// Add entry to operation log with timestamp +pub fn add_log_entry(operation_log: &mut Vec, message: String) { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap(); + let secs = now.as_secs(); + let millis = now.subsec_millis(); + + // Format as HH:MM:SS.mmm + let hours = (secs / 3600) % 24; + let minutes = (secs / 60) % 60; + let seconds = secs % 60; + let timestamp = format!("{:02}:{:02}:{:02}.{:03}", hours, minutes, seconds, millis); + + let log_entry = format!("{} {}", timestamp, message); + + operation_log.push(log_entry); + + // Keep only last 20 entries to prevent memory growth + if operation_log.len() > 20 { + operation_log.remove(0); + } +} diff --git a/src/gui/settings/window.rs b/src/gui/settings/window.rs new file mode 100644 index 0000000..c69eafc --- /dev/null +++ b/src/gui/settings/window.rs @@ -0,0 +1,584 @@ +use eframe::egui; +use log::info; +use std::sync::mpsc; +use crate::gui::common::{load_settings, KhmSettings}; +use crate::gui::admin::{AdminState, AdminOperation, render_statistics, render_search_controls, + render_bulk_actions, render_keys_table, KeyAction, BulkAction}; +use crate::gui::api::{SshKey, bulk_deprecate_servers, bulk_restore_servers, + deprecate_key, restore_key, delete_key}; + +use super::connection::{ConnectionTab, SettingsTab}; +use super::ui::{render_connection_tab, add_log_entry}; + +pub struct SettingsWindow { + settings: KhmSettings, + auto_sync_interval_str: String, + current_tab: SettingsTab, + connection_tab: ConnectionTab, + admin_state: AdminState, + admin_receiver: Option, String>>>, + operation_receiver: Option>>, + operation_log: Vec, +} + +impl SettingsWindow { + pub fn new() -> Self { + let settings = load_settings(); + let auto_sync_interval_str = settings.auto_sync_interval_minutes.to_string(); + + Self { + settings, + auto_sync_interval_str, + current_tab: SettingsTab::Connection, + connection_tab: ConnectionTab::default(), + admin_state: AdminState::default(), + admin_receiver: None, + operation_receiver: None, + operation_log: Vec::new(), + } + } +} + +impl eframe::App for SettingsWindow { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // Check for admin operation results + self.check_admin_results(ctx); + + // Apply enhanced modern dark theme + apply_modern_theme(ctx); + + // Bottom panel for Activity Log (fixed at bottom) + egui::TopBottomPanel::bottom("activity_log_panel") + .resizable(false) + .min_height(140.0) + .max_height(140.0) + .frame(egui::Frame::none() + .fill(egui::Color32::from_gray(12)) + .stroke(egui::Stroke::new(1.0, egui::Color32::from_gray(60))) + ) + .show(ctx, |ui| { + render_bottom_activity_log(ui, &mut self.operation_log); + }); + + egui::CentralPanel::default() + .frame(egui::Frame::none() + .fill(egui::Color32::from_gray(18)) + .inner_margin(egui::Margin::same(20.0)) + ) + .show(ctx, |ui| { + // Modern header with gradient-like styling + let header_frame = egui::Frame::none() + .fill(ui.visuals().panel_fill) + .rounding(egui::Rounding::same(8.0)) + .inner_margin(egui::Margin::same(12.0)) + .stroke(egui::Stroke::new(1.0, ui.visuals().widgets.noninteractive.bg_stroke.color)); + + header_frame.show(ui, |ui| { + ui.horizontal(|ui| { + ui.add_space(4.0); + ui.label("🔑"); + ui.heading(egui::RichText::new("KHM Settings").size(20.0).strong()); + ui.label(egui::RichText::new( + "(Known Hosts Manager for SSH key management and synchronization)" + ).size(11.0).weak().italics()); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Version from Cargo.toml + let version = env!("CARGO_PKG_VERSION"); + if ui.small_button(format!("v{}", version)) + .on_hover_text(format!( + "{}\n{}\nRepository: {}\nLicense: {}", + env!("CARGO_PKG_DESCRIPTION"), + env!("CARGO_PKG_AUTHORS"), + env!("CARGO_PKG_REPOSITORY"), + "WTFPL" + )) + .clicked() + { + // Open repository URL + if let Err(_) = std::process::Command::new("open") + .arg(env!("CARGO_PKG_REPOSITORY")) + .spawn() + { + // Fallback for non-macOS systems + let _ = std::process::Command::new("xdg-open") + .arg(env!("CARGO_PKG_REPOSITORY")) + .spawn(); + } + } + }); + }); + }); + + ui.add_space(12.0); + + // Modern tab selector with card styling + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + + // Connection/Settings Tab + let connection_selected = matches!(self.current_tab, SettingsTab::Connection); + let connection_button = egui::Button::new( + egui::RichText::new("🌐 Connection").size(13.0) + ) + .fill(if connection_selected { + egui::Color32::from_rgb(0, 120, 212) + } else { + ui.visuals().widgets.inactive.bg_fill + }) + .stroke(if connection_selected { + egui::Stroke::new(1.0, egui::Color32::from_rgb(0, 120, 212)) + } else { + egui::Stroke::new(1.0, ui.visuals().widgets.noninteractive.bg_stroke.color) + }) + .rounding(6.0) + .min_size(egui::vec2(110.0, 32.0)); + + if ui.add(connection_button).clicked() { + self.current_tab = SettingsTab::Connection; + } + + // Admin Tab + let admin_selected = matches!(self.current_tab, SettingsTab::Admin); + let admin_button = egui::Button::new( + egui::RichText::new("🔧 Admin Panel").size(13.0) + ) + .fill(if admin_selected { + egui::Color32::from_rgb(120, 80, 0) + } else { + ui.visuals().widgets.inactive.bg_fill + }) + .stroke(if admin_selected { + egui::Stroke::new(1.0, egui::Color32::from_rgb(120, 80, 0)) + } else { + egui::Stroke::new(1.0, ui.visuals().widgets.noninteractive.bg_stroke.color) + }) + .rounding(6.0) + .min_size(egui::vec2(110.0, 32.0)); + + if ui.add(admin_button).clicked() { + self.current_tab = SettingsTab::Admin; + } + }); + + ui.add_space(16.0); + + // Content area with proper spacing + match self.current_tab { + SettingsTab::Connection => { + render_connection_tab( + ui, + ctx, + &mut self.settings, + &mut self.auto_sync_interval_str, + &mut self.connection_tab, + &mut self.operation_log + ); + } + SettingsTab::Admin => { + self.render_admin_tab(ui, ctx); + } + } + }); + } +} + +impl SettingsWindow { + fn check_admin_results(&mut self, ctx: &egui::Context) { + // Check for admin keys loading result + if let Some(receiver) = &self.admin_receiver { + if let Ok(result) = receiver.try_recv() { + self.admin_state.handle_keys_loaded(result); + self.admin_receiver = None; + ctx.request_repaint(); + } + } + + // Check for operation results + if let Some(receiver) = &self.operation_receiver { + if let Ok(result) = receiver.try_recv() { + match result { + Ok(message) => { + info!("Operation completed: {}", message); + add_log_entry(&mut self.operation_log, format!("✅ {}", message)); + // Reload keys after operation + self.load_admin_keys(ctx); + } + Err(error) => { + add_log_entry(&mut self.operation_log, format!("❌ Operation failed: {}", error)); + } + } + self.admin_state.current_operation = AdminOperation::None; + self.operation_receiver = None; + ctx.request_repaint(); + } + } + } + + fn render_admin_tab(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { + // Admin tab header + ui.horizontal(|ui| { + ui.label(egui::RichText::new("🔧 Admin Panel").size(18.0).strong()); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("🔁 Refresh").clicked() { + self.load_admin_keys(ctx); + } + + if let Some(last_load) = self.admin_state.last_load_time { + let elapsed = last_load.elapsed().as_secs(); + ui.label(format!("Updated {}s ago", elapsed)); + } + }); + }); + + ui.separator(); + ui.add_space(10.0); + + // Check if connection is configured + if self.settings.host.is_empty() || self.settings.flow.is_empty() { + ui.vertical_centered(|ui| { + ui.label(egui::RichText::new("❗ Please configure connection settings first") + .size(16.0) + .color(egui::Color32::YELLOW)); + ui.add_space(10.0); + if ui.button("Go to Connection Settings").clicked() { + self.current_tab = SettingsTab::Connection; + } + }); + return; + } + + // Load keys automatically on first view + if self.admin_state.keys.is_empty() && !matches!(self.admin_state.current_operation, AdminOperation::LoadingKeys) { + self.load_admin_keys(ctx); + } + + // Show loading state + if matches!(self.admin_state.current_operation, AdminOperation::LoadingKeys) { + ui.vertical_centered(|ui| { + ui.spinner(); + ui.label("Loading keys..."); + }); + return; + } + + // Statistics section + render_statistics(ui, &self.admin_state); + ui.add_space(10.0); + + // Search and filters + render_search_controls(ui, &mut self.admin_state); + ui.add_space(10.0); + + // Bulk actions + let bulk_action = render_bulk_actions(ui, &mut self.admin_state); + self.handle_bulk_action(bulk_action, ctx); + + if self.admin_state.selected_servers.values().any(|&v| v) { + ui.add_space(8.0); + } + + // Keys table + egui::ScrollArea::vertical() + .max_height(450.0) + .auto_shrink([false; 2]) + .show(ui, |ui| { + let key_action = render_keys_table(ui, &mut self.admin_state); + self.handle_key_action(key_action, ctx); + }); + } + + fn load_admin_keys(&mut self, ctx: &egui::Context) { + if let Some(receiver) = self.admin_state.load_keys(&self.settings, ctx) { + self.admin_receiver = Some(receiver); + } + } + + fn handle_bulk_action(&mut self, action: BulkAction, ctx: &egui::Context) { + match action { + BulkAction::DeprecateSelected => { + let selected = self.admin_state.get_selected_servers(); + if !selected.is_empty() { + self.start_bulk_deprecate(selected, ctx); + } + } + BulkAction::RestoreSelected => { + let selected = self.admin_state.get_selected_servers(); + if !selected.is_empty() { + self.start_bulk_restore(selected, ctx); + } + } + BulkAction::ClearSelection => { + // Selection already cleared in UI + } + BulkAction::None => {} + } + } + + fn handle_key_action(&mut self, action: KeyAction, ctx: &egui::Context) { + match action { + KeyAction::DeprecateKey(server) | KeyAction::DeprecateServer(server) => { + self.start_deprecate_key(&server, ctx); + } + KeyAction::RestoreKey(server) | KeyAction::RestoreServer(server) => { + self.start_restore_key(&server, ctx); + } + KeyAction::DeleteKey(server) => { + self.start_delete_key(&server, ctx); + } + KeyAction::None => {} + } + } + + fn start_bulk_deprecate(&mut self, servers: Vec, ctx: &egui::Context) { + self.admin_state.current_operation = AdminOperation::BulkDeprecating; + add_log_entry(&mut self.operation_log, format!("Deprecating {} servers...", servers.len())); + + let (tx, rx) = mpsc::channel(); + self.operation_receiver = Some(rx); + + let host = self.settings.host.clone(); + let flow = self.settings.flow.clone(); + let basic_auth = self.settings.basic_auth.clone(); + let ctx_clone = ctx.clone(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(async { + bulk_deprecate_servers(host, flow, basic_auth, servers).await + }); + + let _ = tx.send(result); + ctx_clone.request_repaint(); + }); + } + + fn start_bulk_restore(&mut self, servers: Vec, ctx: &egui::Context) { + self.admin_state.current_operation = AdminOperation::BulkRestoring; + add_log_entry(&mut self.operation_log, format!("Restoring {} servers...", servers.len())); + + let (tx, rx) = mpsc::channel(); + self.operation_receiver = Some(rx); + + let host = self.settings.host.clone(); + let flow = self.settings.flow.clone(); + let basic_auth = self.settings.basic_auth.clone(); + let ctx_clone = ctx.clone(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(async { + bulk_restore_servers(host, flow, basic_auth, servers).await + }); + + let _ = tx.send(result); + ctx_clone.request_repaint(); + }); + } + + fn start_deprecate_key(&mut self, server: &str, ctx: &egui::Context) { + self.admin_state.current_operation = AdminOperation::DeprecatingKey; + add_log_entry(&mut self.operation_log, format!("Deprecating key for server: {}", server)); + + let (tx, rx) = mpsc::channel(); + self.operation_receiver = Some(rx); + + let host = self.settings.host.clone(); + let flow = self.settings.flow.clone(); + let basic_auth = self.settings.basic_auth.clone(); + let server_name = server.to_string(); + let ctx_clone = ctx.clone(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(async { + deprecate_key(host, flow, basic_auth, server_name).await + }); + + let _ = tx.send(result); + ctx_clone.request_repaint(); + }); + } + + fn start_restore_key(&mut self, server: &str, ctx: &egui::Context) { + self.admin_state.current_operation = AdminOperation::RestoringKey; + add_log_entry(&mut self.operation_log, format!("Restoring key for server: {}", server)); + + let (tx, rx) = mpsc::channel(); + self.operation_receiver = Some(rx); + + let host = self.settings.host.clone(); + let flow = self.settings.flow.clone(); + let basic_auth = self.settings.basic_auth.clone(); + let server_name = server.to_string(); + let ctx_clone = ctx.clone(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(async { + restore_key(host, flow, basic_auth, server_name).await + }); + + let _ = tx.send(result); + ctx_clone.request_repaint(); + }); + } + + fn start_delete_key(&mut self, server: &str, ctx: &egui::Context) { + self.admin_state.current_operation = AdminOperation::DeletingKey; + add_log_entry(&mut self.operation_log, format!("Deleting key for server: {}", server)); + + let (tx, rx) = mpsc::channel(); + self.operation_receiver = Some(rx); + + let host = self.settings.host.clone(); + let flow = self.settings.flow.clone(); + let basic_auth = self.settings.basic_auth.clone(); + let server_name = server.to_string(); + let ctx_clone = ctx.clone(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + let result = rt.block_on(async { + delete_key(host, flow, basic_auth, server_name).await + }); + + let _ = tx.send(result); + ctx_clone.request_repaint(); + }); + } +} + +/// Apply modern dark theme for the settings window with enhanced styling +fn apply_modern_theme(ctx: &egui::Context) { + let mut visuals = egui::Visuals::dark(); + + // Modern color palette + visuals.window_fill = egui::Color32::from_gray(18); // Darker background + visuals.panel_fill = egui::Color32::from_gray(24); // Panel background + visuals.faint_bg_color = egui::Color32::from_gray(32); // Card background + visuals.extreme_bg_color = egui::Color32::from_gray(12); // Darkest areas + + // Enhanced widget styling + visuals.button_frame = true; + visuals.collapsing_header_frame = true; + visuals.indent_has_left_vline = true; + visuals.striped = true; + + // Modern rounded corners + let rounding = egui::Rounding::same(8.0); + visuals.menu_rounding = rounding; + visuals.window_rounding = egui::Rounding::same(16.0); + visuals.widgets.noninteractive.rounding = rounding; + visuals.widgets.inactive.rounding = rounding; + visuals.widgets.hovered.rounding = rounding; + visuals.widgets.active.rounding = rounding; + + // Better widget colors + visuals.widgets.noninteractive.bg_fill = egui::Color32::from_gray(40); + visuals.widgets.inactive.bg_fill = egui::Color32::from_gray(45); + visuals.widgets.hovered.bg_fill = egui::Color32::from_gray(55); + visuals.widgets.active.bg_fill = egui::Color32::from_gray(60); + + // Subtle borders + let border_color = egui::Color32::from_gray(60); + visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, border_color); + visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, border_color); + visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.5, egui::Color32::from_gray(80)); + visuals.widgets.active.bg_stroke = egui::Stroke::new(1.5, egui::Color32::from_gray(100)); + + ctx.set_visuals(visuals); +} + +/// Render bottom activity log panel +fn render_bottom_activity_log(ui: &mut egui::Ui, operation_log: &mut Vec) { + ui.add_space(18.0); // Larger top padding + + ui.horizontal(|ui| { + ui.add_space(8.0); + ui.label("📋"); + ui.label(egui::RichText::new("Activity Log").size(13.0).strong()); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + ui.add_space(8.0); + if ui.small_button("🗑 Clear").clicked() { + operation_log.clear(); + } + }); + }); + + ui.add_space(8.0); + + // Add horizontal margin for the text area + ui.horizontal(|ui| { + ui.add_space(8.0); // Left margin + + // Show last 5 log entries in multiline text + let log_text = if operation_log.is_empty() { + "No recent activity".to_string() + } else { + let start_idx = if operation_log.len() > 5 { + operation_log.len() - 5 + } else { + 0 + }; + operation_log[start_idx..].join("\n") + }; + + ui.add_sized( + [ui.available_width() - 8.0, 80.0], // Account for right margin + egui::TextEdit::multiline(&mut log_text.clone()) + .font(egui::FontId::new(11.0, egui::FontFamily::Monospace)) + .interactive(false) + ); + + ui.add_space(8.0); // Right margin + }); +} + +/// Create window icon for settings window +pub fn create_window_icon() -> egui::IconData { + // Create a simple programmatic icon (blue square with white border) + let icon_size = 32; + let icon_data: Vec = (0..icon_size * icon_size) + .flat_map(|i| { + let y = i / icon_size; + let x = i % icon_size; + if x < 2 || x >= 30 || y < 2 || y >= 30 { + [255, 255, 255, 255] // White border + } else { + [64, 128, 255, 255] // Blue center + } + }) + .collect(); + + egui::IconData { + rgba: icon_data, + width: icon_size as u32, + height: icon_size as u32, + } +} + +/// Run the settings window application with modern horizontal styling +pub fn run_settings_window() { + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_title("KHM Settings") + .with_inner_size([900.0, 905.0]) // Decreased height by another 15px + .with_min_inner_size([900.0, 905.0]) // Fixed size + .with_max_inner_size([900.0, 905.0]) // Same as min - fixed size + .with_resizable(false) // Disable resizing since window is fixed size + .with_icon(create_window_icon()) + .with_decorations(true) + .with_transparent(false), + centered: true, + ..Default::default() + }; + + let _ = eframe::run_native( + "KHM Settings", + options, + Box::new(|_cc| Ok(Box::new(SettingsWindow::new()))), + ); +} diff --git a/src/gui/tray/app.rs b/src/gui/tray/app.rs new file mode 100644 index 0000000..b02d738 --- /dev/null +++ b/src/gui/tray/app.rs @@ -0,0 +1,275 @@ +use log::{error, info}; + +#[cfg(feature = "gui")] +use notify::RecursiveMode; +#[cfg(feature = "gui")] +use notify_debouncer_mini::{new_debouncer, DebounceEventResult}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tray_icon::{ + menu::MenuEvent, + TrayIcon, +}; +use winit::{ + application::ApplicationHandler, + event_loop::{EventLoop, EventLoopProxy}, +}; + +#[cfg(target_os = "macos")] +use winit::platform::macos::EventLoopBuilderExtMacOS; + +use super::{SyncStatus, TrayMenuIds, create_tray_icon, update_tray_menu, + create_tooltip, start_auto_sync_task, update_sync_status}; +use crate::gui::common::{load_settings, get_config_path, perform_sync, KhmSettings}; + +pub struct TrayApplication { + tray_icon: Option, + menu_ids: Option, + settings: Arc>, + sync_status: Arc>, + #[cfg(feature = "gui")] + _debouncer: Option>, + proxy: EventLoopProxy, + auto_sync_handle: Option>, +} + +impl TrayApplication { + pub fn new(proxy: EventLoopProxy) -> Self { + Self { + tray_icon: None, + menu_ids: None, + settings: Arc::new(Mutex::new(load_settings())), + sync_status: Arc::new(Mutex::new(SyncStatus::default())), + #[cfg(feature = "gui")] + _debouncer: None, + proxy, + auto_sync_handle: None, + } + } + + #[cfg(feature = "gui")] + fn setup_file_watcher(&mut self) { + let config_path = get_config_path(); + let (tx, rx) = std::sync::mpsc::channel::(); + let proxy = self.proxy.clone(); + + std::thread::spawn(move || { + while let Ok(result) = rx.recv() { + if let Ok(events) = result { + if events.iter().any(|e| e.path.to_string_lossy().contains("khm_config.json")) { + let _ = proxy.send_event(crate::gui::UserEvent::ConfigFileChanged); + } + } + } + }); + + if let Ok(mut debouncer) = new_debouncer(Duration::from_millis(500), tx) { + if let Some(config_dir) = config_path.parent() { + if debouncer.watcher().watch(config_dir, RecursiveMode::NonRecursive).is_ok() { + info!("File watcher started"); + self._debouncer = Some(debouncer); + } else { + error!("Failed to start file watcher"); + } + } + } + } + + fn handle_config_change(&mut self) { + info!("Config file changed"); + let new_settings = load_settings(); + let old_interval = self.settings.lock().unwrap().auto_sync_interval_minutes; + let new_interval = new_settings.auto_sync_interval_minutes; + + *self.settings.lock().unwrap() = new_settings; + + // Update menu + if let Some(tray_icon) = &self.tray_icon { + let settings = self.settings.lock().unwrap(); + let new_menu_ids = update_tray_menu(tray_icon, &settings); + self.menu_ids = Some(new_menu_ids); + } + + // Update tooltip + self.update_tooltip(); + + // Restart auto sync if interval changed + if old_interval != new_interval { + info!("Auto sync interval changed from {} to {} minutes, restarting auto sync", old_interval, new_interval); + self.start_auto_sync(); + } + } + + fn start_auto_sync(&mut self) { + if let Some(handle) = self.auto_sync_handle.take() { + // Note: In a real implementation, you'd want to properly signal the thread to stop + drop(handle); + } + + self.auto_sync_handle = start_auto_sync_task( + Arc::clone(&self.settings), + Arc::clone(&self.sync_status), + self.proxy.clone() + ); + } + + fn update_tooltip(&self) { + if let Some(tray_icon) = &self.tray_icon { + let settings = self.settings.lock().unwrap(); + let sync_status = self.sync_status.lock().unwrap(); + let tooltip = create_tooltip(&settings, &sync_status); + let _ = tray_icon.set_tooltip(Some(&tooltip)); + } + } + + fn handle_menu_event(&mut self, event: MenuEvent, event_loop: &winit::event_loop::ActiveEventLoop) { + if let Some(menu_ids) = &self.menu_ids { + if event.id == menu_ids.settings_id { + info!("Settings menu clicked"); + self.launch_settings_window(); + } else if event.id == menu_ids.quit_id { + info!("Quitting KHM application"); + event_loop.exit(); + } else if event.id == menu_ids.sync_id { + info!("Starting manual sync operation"); + self.start_manual_sync(); + } + } + } + + fn launch_settings_window(&self) { + if let Ok(exe_path) = std::env::current_exe() { + std::thread::spawn(move || { + if let Err(e) = std::process::Command::new(&exe_path) + .arg("--gui") + .arg("--settings-ui") + .spawn() + { + error!("Failed to launch settings window: {}", e); + } + }); + } + } + + fn start_manual_sync(&self) { + let settings = self.settings.lock().unwrap().clone(); + let sync_status_clone: Arc> = Arc::clone(&self.sync_status); + let proxy_clone = self.proxy.clone(); + + // Check if settings are valid + if settings.host.is_empty() || settings.flow.is_empty() { + error!("Cannot sync: host or flow not configured"); + return; + } + + info!("Syncing with host: {}, flow: {}", settings.host, settings.flow); + + // Run sync in separate thread with its own tokio runtime + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + match perform_sync(&settings).await { + Ok(keys_count) => { + info!("Sync completed successfully with {} keys", keys_count); + let mut status = sync_status_clone.lock().unwrap(); + status.last_sync_time = Some(std::time::Instant::now()); + status.last_sync_keys = Some(keys_count); + let _ = proxy_clone.send_event(crate::gui::UserEvent::UpdateMenu); + } + Err(e) => { + error!("Sync failed: {}", e); + } + } + }); + }); + } + + fn handle_update_menu(&mut self) { + let settings = self.settings.lock().unwrap(); + if !settings.host.is_empty() && !settings.flow.is_empty() && settings.in_place { + let mut sync_status = self.sync_status.lock().unwrap(); + update_sync_status(&settings, &mut sync_status); + } + drop(settings); + + self.update_tooltip(); + } +} + +impl ApplicationHandler for TrayApplication { + fn window_event( + &mut self, + _event_loop: &winit::event_loop::ActiveEventLoop, + _window_id: winit::window::WindowId, + _event: winit::event::WindowEvent, + ) {} + + fn resumed(&mut self, _event_loop: &winit::event_loop::ActiveEventLoop) { + if self.tray_icon.is_none() { + info!("Creating tray icon"); + let settings = self.settings.lock().unwrap(); + let sync_status = self.sync_status.lock().unwrap(); + let (tray_icon, menu_ids) = create_tray_icon(&settings, &sync_status); + drop(settings); + drop(sync_status); + + self.tray_icon = Some(tray_icon); + self.menu_ids = Some(menu_ids); + + self.setup_file_watcher(); + self.start_auto_sync(); + info!("KHM tray application ready"); + } + } + + fn user_event(&mut self, event_loop: &winit::event_loop::ActiveEventLoop, event: crate::gui::UserEvent) { + match event { + crate::gui::UserEvent::TrayIconEvent => {} + crate::gui::UserEvent::UpdateMenu => { + self.handle_update_menu(); + } + crate::gui::UserEvent::MenuEvent(event) => { + self.handle_menu_event(event, event_loop); + } + crate::gui::UserEvent::ConfigFileChanged => { + self.handle_config_change(); + } + } + } +} + +/// Run tray application +pub async fn run_tray_app() -> std::io::Result<()> { + #[cfg(target_os = "macos")] + let event_loop = { + use winit::platform::macos::ActivationPolicy; + EventLoop::::with_user_event() + .with_activation_policy(ActivationPolicy::Accessory) + .build() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("Failed to create event loop: {}", e)))? + }; + + #[cfg(not(target_os = "macos"))] + let event_loop = EventLoop::::with_user_event().build() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("Failed to create event loop: {}", e)))?; + + let proxy = event_loop.create_proxy(); + + // Setup event handlers + let proxy_clone = proxy.clone(); + tray_icon::TrayIconEvent::set_event_handler(Some(move |_event| { + let _ = proxy_clone.send_event(crate::gui::UserEvent::TrayIconEvent); + })); + + let proxy_clone = proxy.clone(); + MenuEvent::set_event_handler(Some(move |event: MenuEvent| { + let _ = proxy_clone.send_event(crate::gui::UserEvent::MenuEvent(event)); + })); + + let mut app = TrayApplication::new(proxy); + + event_loop.run_app(&mut app) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("Event loop error: {:?}", e)))?; + + Ok(()) +} diff --git a/src/gui/tray/icon.rs b/src/gui/tray/icon.rs new file mode 100644 index 0000000..3d1c8de --- /dev/null +++ b/src/gui/tray/icon.rs @@ -0,0 +1,281 @@ +use log::{error, info}; +use std::sync::{Arc, Mutex}; +use tray_icon::{ + menu::{Menu, MenuItem, MenuId}, + TrayIcon, TrayIconBuilder, +}; +use crate::gui::common::{KhmSettings, perform_sync}; + +#[derive(Debug, Clone)] +pub struct SyncStatus { + pub last_sync_time: Option, + pub last_sync_keys: Option, + pub next_sync_in_seconds: Option, +} + +impl Default for SyncStatus { + fn default() -> Self { + Self { + last_sync_time: None, + last_sync_keys: None, + next_sync_in_seconds: None, + } + } +} + +pub struct TrayMenuIds { + pub settings_id: MenuId, + pub quit_id: MenuId, + pub sync_id: MenuId, +} + +/// Create tray icon with menu +pub fn create_tray_icon(settings: &KhmSettings, sync_status: &SyncStatus) -> (TrayIcon, TrayMenuIds) { + // Create simple blue icon + let icon_data: Vec = (0..32*32).flat_map(|i| { + let y = i / 32; + let x = i % 32; + if x < 2 || x >= 30 || y < 2 || y >= 30 { + [255, 255, 255, 255] // White border + } else { + [64, 128, 255, 255] // Blue center + } + }).collect(); + + let icon = tray_icon::Icon::from_rgba(icon_data, 32, 32).unwrap(); + let menu = Menu::new(); + + // Show current configuration status (static) + let host_text = if settings.host.is_empty() { + "Host: Not configured" + } else { + &format!("Host: {}", settings.host) + }; + menu.append(&MenuItem::new(host_text, false, None)).unwrap(); + + let flow_text = if settings.flow.is_empty() { + "Flow: Not configured" + } else { + &format!("Flow: {}", settings.flow) + }; + menu.append(&MenuItem::new(flow_text, false, None)).unwrap(); + + let is_auto_sync_enabled = !settings.host.is_empty() && !settings.flow.is_empty() && settings.in_place; + let sync_text = format!("Auto sync: {} ({}min)", + if is_auto_sync_enabled { "On" } else { "Off" }, + settings.auto_sync_interval_minutes); + menu.append(&MenuItem::new(&sync_text, false, None)).unwrap(); + + menu.append(&tray_icon::menu::PredefinedMenuItem::separator()).unwrap(); + + // Sync Now menu item + let sync_item = MenuItem::new("Sync Now", !settings.host.is_empty() && !settings.flow.is_empty(), None); + let sync_id = sync_item.id().clone(); + menu.append(&sync_item).unwrap(); + + menu.append(&tray_icon::menu::PredefinedMenuItem::separator()).unwrap(); + + // Settings menu item + let settings_item = MenuItem::new("Settings", true, None); + let settings_id = settings_item.id().clone(); + menu.append(&settings_item).unwrap(); + + menu.append(&tray_icon::menu::PredefinedMenuItem::separator()).unwrap(); + + // Quit menu item + let quit_item = MenuItem::new("Quit", true, None); + let quit_id = quit_item.id().clone(); + menu.append(&quit_item).unwrap(); + + // Create initial tooltip + let tooltip = create_tooltip(settings, sync_status); + + let tray_icon = TrayIconBuilder::new() + .with_tooltip(&tooltip) + .with_icon(icon) + .with_menu(Box::new(menu)) + .build() + .unwrap(); + + let menu_ids = TrayMenuIds { + settings_id, + quit_id, + sync_id, + }; + + (tray_icon, menu_ids) +} + +/// Update tray menu with new settings +pub fn update_tray_menu(tray_icon: &TrayIcon, settings: &KhmSettings) -> TrayMenuIds { + let menu = Menu::new(); + + // Show current configuration status (static) + let host_text = if settings.host.is_empty() { + "Host: Not configured" + } else { + &format!("Host: {}", settings.host) + }; + menu.append(&MenuItem::new(host_text, false, None)).unwrap(); + + let flow_text = if settings.flow.is_empty() { + "Flow: Not configured" + } else { + &format!("Flow: {}", settings.flow) + }; + menu.append(&MenuItem::new(flow_text, false, None)).unwrap(); + + let is_auto_sync_enabled = !settings.host.is_empty() && !settings.flow.is_empty() && settings.in_place; + let sync_text = format!("Auto sync: {} ({}min)", + if is_auto_sync_enabled { "On" } else { "Off" }, + settings.auto_sync_interval_minutes); + menu.append(&MenuItem::new(&sync_text, false, None)).unwrap(); + + menu.append(&tray_icon::menu::PredefinedMenuItem::separator()).unwrap(); + + // Sync Now menu item + let sync_item = MenuItem::new("Sync Now", !settings.host.is_empty() && !settings.flow.is_empty(), None); + let sync_id = sync_item.id().clone(); + menu.append(&sync_item).unwrap(); + + menu.append(&tray_icon::menu::PredefinedMenuItem::separator()).unwrap(); + + // Settings menu item + let settings_item = MenuItem::new("Settings", true, None); + let settings_id = settings_item.id().clone(); + menu.append(&settings_item).unwrap(); + + menu.append(&tray_icon::menu::PredefinedMenuItem::separator()).unwrap(); + + // Quit menu item + let quit_item = MenuItem::new("Quit", true, None); + let quit_id = quit_item.id().clone(); + menu.append(&quit_item).unwrap(); + + tray_icon.set_menu(Some(Box::new(menu))); + + TrayMenuIds { + settings_id, + quit_id, + sync_id, + } +} + +/// Create tooltip text for tray icon +pub fn create_tooltip(settings: &KhmSettings, sync_status: &SyncStatus) -> String { + let mut tooltip = format!("KHM - SSH Key Manager\nHost: {}\nFlow: {}", settings.host, settings.flow); + + if let Some(keys_count) = sync_status.last_sync_keys { + tooltip.push_str(&format!("\nLast sync: {} keys", keys_count)); + } else { + tooltip.push_str("\nLast sync: Never"); + } + + if let Some(seconds) = sync_status.next_sync_in_seconds { + if seconds > 60 { + tooltip.push_str(&format!("\nNext sync: {}m {}s", seconds / 60, seconds % 60)); + } else { + tooltip.push_str(&format!("\nNext sync: {}s", seconds)); + } + } + + tooltip +} + +/// Start auto sync background task +pub fn start_auto_sync_task( + settings: Arc>, + sync_status: Arc>, + event_sender: winit::event_loop::EventLoopProxy +) -> Option> { + let initial_settings = settings.lock().unwrap().clone(); + + // Only start auto sync if settings are valid and in_place is enabled + if initial_settings.host.is_empty() || initial_settings.flow.is_empty() || !initial_settings.in_place { + info!("Auto sync disabled or settings invalid"); + return None; + } + + info!("Starting auto sync with interval {} minutes", initial_settings.auto_sync_interval_minutes); + + let handle = std::thread::spawn(move || { + // Initial sync on startup + info!("Performing initial sync on startup"); + let current_settings = settings.lock().unwrap().clone(); + if !current_settings.host.is_empty() && !current_settings.flow.is_empty() { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + match perform_sync(¤t_settings).await { + Ok(keys_count) => { + info!("Initial sync completed successfully with {} keys", keys_count); + let mut status = sync_status.lock().unwrap(); + status.last_sync_time = Some(std::time::Instant::now()); + status.last_sync_keys = Some(keys_count); + let _ = event_sender.send_event(crate::gui::UserEvent::UpdateMenu); + } + Err(e) => { + error!("Initial sync failed: {}", e); + } + } + }); + } + + // Start menu update timer + let timer_sender = event_sender.clone(); + std::thread::spawn(move || { + loop { + std::thread::sleep(std::time::Duration::from_secs(1)); + let _ = timer_sender.send_event(crate::gui::UserEvent::UpdateMenu); + } + }); + + // Periodic sync + loop { + let interval_minutes = current_settings.auto_sync_interval_minutes; + std::thread::sleep(std::time::Duration::from_secs(interval_minutes as u64 * 60)); + + let current_settings = settings.lock().unwrap().clone(); + if current_settings.host.is_empty() || current_settings.flow.is_empty() || !current_settings.in_place { + info!("Auto sync stopped due to invalid settings or disabled in_place"); + break; + } + + info!("Performing scheduled auto sync"); + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + match perform_sync(¤t_settings).await { + Ok(keys_count) => { + info!("Auto sync completed successfully with {} keys", keys_count); + let mut status = sync_status.lock().unwrap(); + status.last_sync_time = Some(std::time::Instant::now()); + status.last_sync_keys = Some(keys_count); + let _ = event_sender.send_event(crate::gui::UserEvent::UpdateMenu); + } + Err(e) => { + error!("Auto sync failed: {}", e); + } + } + }); + } + }); + + Some(handle) +} + +/// Update sync status for tooltip +pub fn update_sync_status(settings: &KhmSettings, sync_status: &mut SyncStatus) { + if !settings.host.is_empty() && !settings.flow.is_empty() && settings.in_place { + if let Some(last_sync) = sync_status.last_sync_time { + let elapsed = last_sync.elapsed().as_secs(); + let interval_seconds = settings.auto_sync_interval_minutes as u64 * 60; + + if elapsed < interval_seconds { + sync_status.next_sync_in_seconds = Some(interval_seconds - elapsed); + } else { + sync_status.next_sync_in_seconds = Some(0); + } + } else { + sync_status.next_sync_in_seconds = None; + } + } +} diff --git a/src/gui/tray/mod.rs b/src/gui/tray/mod.rs new file mode 100644 index 0000000..661c6bf --- /dev/null +++ b/src/gui/tray/mod.rs @@ -0,0 +1,6 @@ +mod app; +mod icon; + +pub use app::*; +pub use icon::{SyncStatus, TrayMenuIds, create_tray_icon, update_tray_menu, + create_tooltip, start_auto_sync_task, update_sync_status}; diff --git a/src/main.rs b/src/main.rs index a0857da..7488926 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod client; mod db; mod server; mod web; +mod gui; use clap::Parser; use env_logger; @@ -10,7 +11,7 @@ use log::{error, info}; /// This application manages SSH keys and flows, either as a server or client. /// In server mode, it stores keys and flows in a PostgreSQL database. /// In client mode, it sends keys to the server and can update the known_hosts file with keys from the server. -#[derive(Parser, Debug)] +#[derive(Parser, Debug, Clone)] #[command( author = env!("CARGO_PKG_AUTHORS"), version = env!("CARGO_PKG_VERSION"), @@ -26,21 +27,29 @@ use log::{error, info}; \n\ " )] -struct Args { +pub struct Args { /// Run in server mode (default: false) #[arg(long, help = "Run in server mode")] - server: bool, + pub server: bool, + + /// Run with GUI tray interface (default: false) + #[arg(long, help = "Run with GUI tray interface")] + pub gui: bool, + + /// Run settings UI window (used with --gui) + #[arg(long, help = "Run settings UI window (used with --gui)")] + pub settings_ui: bool, /// Update the known_hosts file with keys from the server after sending keys (default: false) #[arg( long, help = "Server mode: Sync the known_hosts file with keys from the server" )] - in_place: bool, + pub in_place: bool, /// Comma-separated list of flows to manage (default: default) #[arg(long, default_value = "default", value_parser, num_args = 1.., value_delimiter = ',', help = "Server mode: Comma-separated list of flows to manage")] - flows: Vec, + pub flows: Vec, /// IP address to bind the server or client to (default: 127.0.0.1) #[arg( @@ -49,7 +58,7 @@ struct Args { default_value = "127.0.0.1", help = "Server mode: IP address to bind the server to" )] - ip: String, + pub ip: String, /// Port to bind the server or client to (default: 8080) #[arg( @@ -58,7 +67,7 @@ struct Args { default_value = "8080", help = "Server mode: Port to bind the server to" )] - port: u16, + pub port: u16, /// Hostname or IP address of the PostgreSQL database (default: 127.0.0.1) #[arg( @@ -66,7 +75,7 @@ struct Args { default_value = "127.0.0.1", help = "Server mode: Hostname or IP address of the PostgreSQL database" )] - db_host: String, + pub db_host: String, /// Name of the PostgreSQL database (default: khm) #[arg( @@ -74,7 +83,7 @@ struct Args { default_value = "khm", help = "Server mode: Name of the PostgreSQL database" )] - db_name: String, + pub db_name: String, /// Username for the PostgreSQL database (required in server mode) #[arg( @@ -82,7 +91,7 @@ struct Args { required_if_eq("server", "true"), help = "Server mode: Username for the PostgreSQL database" )] - db_user: Option, + pub db_user: Option, /// Password for the PostgreSQL database (required in server mode) #[arg( @@ -90,7 +99,7 @@ struct Args { required_if_eq("server", "true"), help = "Server mode: Password for the PostgreSQL database" )] - db_password: Option, + pub db_password: Option, /// Host address of the server to connect to in client mode (required in client mode) #[arg( @@ -98,7 +107,7 @@ struct Args { required_if_eq("server", "false"), help = "Client mode: Full host address of the server to connect to. Like https://khm.example.com" )] - host: Option, + pub host: Option, /// Flow name to use on the server #[arg( @@ -106,7 +115,7 @@ struct Args { required_if_eq("server", "false"), help = "Client mode: Flow name to use on the server" )] - flow: Option, + pub flow: Option, /// Path to the known_hosts file (default: ~/.ssh/known_hosts) #[arg( @@ -114,24 +123,66 @@ struct Args { default_value = "~/.ssh/known_hosts", help = "Client mode: Path to the known_hosts file" )] - known_hosts: String, + pub known_hosts: String, /// Basic auth string for client mode. Format: user:pass #[arg(long, default_value = "", help = "Client mode: Basic Auth credentials")] - basic_auth: String, + pub basic_auth: String, } #[actix_web::main] async fn main() -> std::io::Result<()> { - env_logger::init(); + // Configure logging to show only khm logs, filtering out noisy library logs + env_logger::Builder::from_default_env() + .filter_level(log::LevelFilter::Warn) // Default level for all modules + .filter_module("khm", log::LevelFilter::Debug) // Our app logs + .filter_module("actix_web", log::LevelFilter::Info) // Server logs + .filter_module("reqwest", log::LevelFilter::Warn) // HTTP client + .filter_module("winit", log::LevelFilter::Error) // Window management + .filter_module("egui", log::LevelFilter::Error) // GUI framework + .filter_module("eframe", log::LevelFilter::Error) // GUI framework + .filter_module("tray_icon", log::LevelFilter::Error) // Tray icon + .filter_module("wgpu", log::LevelFilter::Error) // Graphics + .filter_module("naga", log::LevelFilter::Error) // Graphics + .filter_module("glow", log::LevelFilter::Error) // Graphics + .filter_module("tracing", log::LevelFilter::Error) // Tracing spans + .init(); + info!("Starting SSH Key Manager"); let args = Args::parse(); - // Check if we have the minimum required arguments - if !args.server && (args.host.is_none() || args.flow.is_none()) { - // Neither server mode nor client mode properly configured - eprintln!("Error: You must specify either server mode (--server) or client mode (--host and --flow)"); + // Settings UI mode - just show settings window and exit + if args.settings_ui { + #[cfg(feature = "gui")] + { + info!("Running settings UI window"); + gui::run_settings_window(); + return Ok(()); + } + #[cfg(not(feature = "gui"))] + { + error!("GUI features not compiled. Install system dependencies and rebuild with --features gui"); + return Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "GUI features not compiled" + )); + } + } + + // GUI mode has priority + if args.gui { + info!("Running in GUI mode"); + if let Err(e) = gui::run_gui().await { + error!("Failed to run GUI: {}", e); + } + return Ok(()); + } + + // Check if we have the minimum required arguments for server/client mode + if !args.server && !args.gui && (args.host.is_none() || args.flow.is_none()) { + // Neither server mode nor client mode nor GUI mode properly configured + eprintln!("Error: You must specify either server mode (--server), client mode (--host and --flow), or GUI mode (--gui)"); eprintln!(); eprintln!("Examples:"); eprintln!( @@ -142,6 +193,14 @@ async fn main() -> std::io::Result<()> { " Client mode: {} --host https://khm.example.com --flow work", env!("CARGO_PKG_NAME") ); + eprintln!( + " GUI mode: {} --gui", + env!("CARGO_PKG_NAME") + ); + eprintln!( + " Settings window: {} --gui --settings-ui", + env!("CARGO_PKG_NAME") + ); eprintln!(); eprintln!("Use --help for more information."); std::process::exit(1); diff --git a/static/.DS_Store b/static/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5008ddfcf53c02e82d7eee2e57c38e5672ef89f6 GIT binary patch literal 6148 zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0 + + + + + + + + K +