works with macos native ui

This commit is contained in:
Ultradesu
2025-07-22 12:50:01 +03:00
parent af6c4d7e61
commit 07ff4454d2
8 changed files with 4438 additions and 67 deletions

3637
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,6 @@ authors = ["AB <ab@hexor.cy>"]
actix-web = "4" actix-web = "4"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
env_logger = "0.11.3"
log = "0.4" log = "0.4"
regex = "1.10.5" regex = "1.10.5"
base64 = "0.21" base64 = "0.21"
@@ -22,3 +21,15 @@ trust-dns-resolver = "0.23"
futures = "0.3" futures = "0.3"
hostname = "0.3" hostname = "0.3"
rust-embed = "8.0" rust-embed = "8.0"
tray-icon = "0.19"
notify = "6.1"
notify-debouncer-mini = "0.4"
dirs = "5.0"
eframe = "0.29"
egui = "0.29"
winit = "0.30"
env_logger = "0.11"
[target.'cfg(target_os = "macos")'.dependencies]
cocoa = "0.25"
objc = "0.2.7"

331
src/gui/mod.rs Normal file
View File

@@ -0,0 +1,331 @@
use log::{debug, error, info};
use notify::RecursiveMode;
use notify_debouncer_mini::{new_debouncer, DebounceEventResult};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tray_icon::{
menu::{Menu, MenuEvent, MenuItem, MenuId},
TrayIcon, TrayIconBuilder,
};
use winit::{
application::ApplicationHandler,
event_loop::{EventLoop, EventLoopProxy},
};
#[cfg(target_os = "macos")]
use winit::platform::macos::EventLoopBuilderExtMacOS;
mod settings;
pub use settings::{KhmSettings, load_settings};
// Function to run settings window (for --settings-ui mode)
pub fn run_settings_window() {
settings::run_settings_window();
}
// Function to perform sync operation using KHM client logic
async fn perform_sync(settings: &KhmSettings) -> std::io::Result<()> {
use crate::Args;
// 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: settings::expand_path(&settings.known_hosts),
basic_auth: settings.basic_auth.clone(),
};
crate::client::run_client(args).await
}
#[derive(Debug)]
enum UserEvent {
TrayIconEvent,
MenuEvent(tray_icon::menu::MenuEvent),
ConfigFileChanged,
}
fn create_tray_icon(settings: &KhmSettings) -> (TrayIcon, MenuId, MenuId, MenuId) {
// Create simple blue icon with "KHM" text representation
let icon_data: Vec<u8> = (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
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 sync_text = format!("Auto sync: {}", if settings.in_place { "On" } else { "Off" });
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();
let tray_icon = TrayIconBuilder::new()
.with_tooltip("KHM - SSH Key Manager")
.with_icon(icon)
.with_menu(Box::new(menu))
.build()
.unwrap();
(tray_icon, settings_id, quit_id, sync_id)
}
struct Application {
tray_icon: Option<TrayIcon>,
settings_id: Option<MenuId>,
quit_id: Option<MenuId>,
sync_id: Option<MenuId>,
settings: Arc<Mutex<KhmSettings>>,
_debouncer: Option<notify_debouncer_mini::Debouncer<notify::FsEventWatcher>>,
proxy: EventLoopProxy<UserEvent>,
}
impl Application {
fn new(proxy: EventLoopProxy<UserEvent>) -> Self {
Self {
tray_icon: None,
settings_id: None,
quit_id: None,
sync_id: None,
settings: Arc::new(Mutex::new(load_settings())),
_debouncer: None,
proxy,
}
}
fn update_menu(&mut self) {
if let Some(tray_icon) = &self.tray_icon {
let settings = self.settings.lock().unwrap();
let menu = Menu::new();
// Show current configuration status
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 sync_text = format!("Auto sync: {}", if settings.in_place { "On" } else { "Off" });
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 new_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 new_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 new_quit_id = quit_item.id().clone();
menu.append(&quit_item).unwrap();
tray_icon.set_menu(Some(Box::new(menu)));
self.settings_id = Some(new_settings_id);
self.quit_id = Some(new_quit_id);
self.sync_id = Some(new_sync_id);
}
}
fn setup_file_watcher(&mut self) {
let config_path = settings::get_config_path();
let (tx, rx) = std::sync::mpsc::channel::<DebounceEventResult>();
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(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() {
debug!("File watcher started");
self._debouncer = Some(debouncer);
} else {
error!("Failed to start file watcher");
}
}
}
}
}
impl ApplicationHandler<UserEvent> for Application {
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 (tray_icon, settings_id, quit_id, sync_id) = create_tray_icon(&settings);
drop(settings);
self.tray_icon = Some(tray_icon);
self.settings_id = Some(settings_id);
self.quit_id = Some(quit_id);
self.sync_id = Some(sync_id);
self.setup_file_watcher();
info!("KHM tray application ready");
}
}
fn user_event(&mut self, event_loop: &winit::event_loop::ActiveEventLoop, event: UserEvent) {
match event {
UserEvent::TrayIconEvent => {}
UserEvent::MenuEvent(event) => {
if let (Some(settings_id), Some(quit_id), Some(sync_id)) = (&self.settings_id, &self.quit_id, &self.sync_id) {
if event.id == *settings_id {
info!("Settings menu clicked");
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);
}
});
}
} else if event.id == *quit_id {
info!("Quitting KHM application");
event_loop.exit();
} else if event.id == *sync_id {
info!("Starting sync operation");
let settings = self.settings.lock().unwrap().clone();
tokio::spawn(async move {
if let Err(e) = perform_sync(&settings).await {
error!("Sync failed: {}", e);
} else {
info!("Sync completed successfully");
}
});
}
}
}
UserEvent::ConfigFileChanged => {
debug!("Config file changed");
let new_settings = load_settings();
*self.settings.lock().unwrap() = new_settings;
self.update_menu();
}
}
}
}
async fn run_tray() -> std::io::Result<()> {
#[cfg(target_os = "macos")]
let event_loop = {
use winit::platform::macos::ActivationPolicy;
EventLoop::<UserEvent>::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::<UserEvent>::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();
let proxy_clone = proxy.clone();
tray_icon::TrayIconEvent::set_event_handler(Some(move |_event| {
let _ = proxy_clone.send_event(UserEvent::TrayIconEvent);
}));
let proxy_clone = proxy.clone();
MenuEvent::set_event_handler(Some(move |event: MenuEvent| {
let _ = proxy_clone.send_event(UserEvent::MenuEvent(event));
}));
let mut app = Application::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(())
}
pub async fn run_gui() -> std::io::Result<()> {
info!("Starting KHM tray application");
run_tray().await
}

70
src/gui/settings/cross.rs Normal file
View File

@@ -0,0 +1,70 @@
use super::{load_settings, save_settings, KhmSettings};
use eframe::egui;
use log::error;
struct KhmSettingsWindow {
settings: KhmSettings,
}
impl eframe::App for KhmSettingsWindow {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("KHM Settings");
ui.separator();
ui.horizontal(|ui| {
ui.label("Host URL:");
ui.text_edit_singleline(&mut self.settings.host);
});
ui.horizontal(|ui| {
ui.label("Flow Name:");
ui.text_edit_singleline(&mut self.settings.flow);
});
ui.horizontal(|ui| {
ui.label("Known Hosts:");
ui.text_edit_singleline(&mut self.settings.known_hosts);
});
ui.horizontal(|ui| {
ui.label("Basic Auth:");
ui.text_edit_singleline(&mut self.settings.basic_auth);
});
ui.checkbox(&mut self.settings.in_place, "Update known_hosts file in-place after sync");
ui.separator();
ui.horizontal(|ui| {
if ui.button("Save").clicked() {
if let Err(e) = save_settings(&self.settings) {
error!("Failed to save KHM settings: {}", e);
}
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
if ui.button("Cancel").clicked() {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
});
});
}
}
pub fn run_settings_window() {
let settings = load_settings();
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("KHM Settings")
.with_inner_size([450.0, 350.0]),
..Default::default()
};
let _ = eframe::run_native(
"KHM Settings",
options,
Box::new(|_cc| Ok(Box::new(KhmSettingsWindow { settings }))),
);
}

327
src/gui/settings/macos.rs Normal file
View File

@@ -0,0 +1,327 @@
#![allow(unexpected_cfgs)]
use super::{load_settings, save_settings, KhmSettings};
use cocoa::appkit::*;
use cocoa::base::{id, nil, NO, YES};
use cocoa::foundation::{NSAutoreleasePool, NSPoint, NSRect, NSSize, NSString, NSDefaultRunLoopMode};
use log::{debug, error, info};
use objc::{msg_send, sel, sel_impl};
use std::ffi::CStr;
const WINDOW_WIDTH: f64 = 450.0;
const WINDOW_HEIGHT: f64 = 350.0;
const MARGIN: f64 = 20.0;
const FIELD_HEIGHT: f64 = 24.0;
const BUTTON_HEIGHT: f64 = 32.0;
// NSControl state constants
const NS_CONTROL_STATE_VALUE_OFF: i32 = 0;
const NS_CONTROL_STATE_VALUE_ON: i32 = 1;
// NSButton type constants
const NS_SWITCH_BUTTON: u32 = 3;
struct MacOSKhmSettingsWindow {
window: id,
host_field: id,
flow_field: id,
known_hosts_field: id,
basic_auth_field: id,
in_place_checkbox: id,
}
impl MacOSKhmSettingsWindow {
fn new() -> Self {
info!("Creating macOS KHM settings window");
unsafe {
let settings = load_settings();
info!("KHM Settings loaded: host={}, flow={}", settings.host, settings.flow);
// Create window
let window: id = msg_send![NSWindow::alloc(nil),
initWithContentRect: NSRect::new(
NSPoint::new(100.0, 100.0),
NSSize::new(WINDOW_WIDTH, WINDOW_HEIGHT),
)
styleMask: NSWindowStyleMask::NSTitledWindowMask | NSWindowStyleMask::NSClosableWindowMask | NSWindowStyleMask::NSMiniaturizableWindowMask
backing: NSBackingStoreType::NSBackingStoreBuffered
defer: NO
];
info!("Window allocated and initialized");
let _: () = msg_send![window, setTitle: NSString::alloc(nil).init_str("KHM Settings")];
let _: () = msg_send![window, center];
let _: () = msg_send![window, setReleasedWhenClosed: NO];
let content_view: id = msg_send![window, contentView];
let mut current_y = WINDOW_HEIGHT - MARGIN - 30.0;
// Host label and field
let host_label: id = msg_send![NSTextField::alloc(nil),
initWithFrame: NSRect::new(
NSPoint::new(MARGIN, current_y),
NSSize::new(100.0, 20.0),
)
];
let _: () = msg_send![host_label, setStringValue: NSString::alloc(nil).init_str("Host URL:")];
let _: () = msg_send![host_label, setBezeled: NO];
let _: () = msg_send![host_label, setDrawsBackground: NO];
let _: () = msg_send![host_label, setEditable: NO];
let _: () = msg_send![host_label, setSelectable: NO];
let _: () = msg_send![content_view, addSubview: host_label];
let host_field: id = msg_send![NSTextField::alloc(nil),
initWithFrame: NSRect::new(
NSPoint::new(MARGIN + 110.0, current_y),
NSSize::new(310.0, FIELD_HEIGHT),
)
];
let _: () = msg_send![host_field, setStringValue: NSString::alloc(nil).init_str(&settings.host)];
let _: () = msg_send![content_view, addSubview: host_field];
current_y -= 35.0;
// Flow label and field
let flow_label: id = msg_send![NSTextField::alloc(nil),
initWithFrame: NSRect::new(
NSPoint::new(MARGIN, current_y),
NSSize::new(100.0, 20.0),
)
];
let _: () = msg_send![flow_label, setStringValue: NSString::alloc(nil).init_str("Flow Name:")];
let _: () = msg_send![flow_label, setBezeled: NO];
let _: () = msg_send![flow_label, setDrawsBackground: NO];
let _: () = msg_send![flow_label, setEditable: NO];
let _: () = msg_send![flow_label, setSelectable: NO];
let _: () = msg_send![content_view, addSubview: flow_label];
let flow_field: id = msg_send![NSTextField::alloc(nil),
initWithFrame: NSRect::new(
NSPoint::new(MARGIN + 110.0, current_y),
NSSize::new(310.0, FIELD_HEIGHT),
)
];
let _: () = msg_send![flow_field, setStringValue: NSString::alloc(nil).init_str(&settings.flow)];
let _: () = msg_send![content_view, addSubview: flow_field];
current_y -= 35.0;
// Known hosts label and field
let known_hosts_label: id = msg_send![NSTextField::alloc(nil),
initWithFrame: NSRect::new(
NSPoint::new(MARGIN, current_y),
NSSize::new(100.0, 20.0),
)
];
let _: () = msg_send![known_hosts_label, setStringValue: NSString::alloc(nil).init_str("Known Hosts:")];
let _: () = msg_send![known_hosts_label, setBezeled: NO];
let _: () = msg_send![known_hosts_label, setDrawsBackground: NO];
let _: () = msg_send![known_hosts_label, setEditable: NO];
let _: () = msg_send![known_hosts_label, setSelectable: NO];
let _: () = msg_send![content_view, addSubview: known_hosts_label];
let known_hosts_field: id = msg_send![NSTextField::alloc(nil),
initWithFrame: NSRect::new(
NSPoint::new(MARGIN + 110.0, current_y),
NSSize::new(310.0, FIELD_HEIGHT),
)
];
let _: () = msg_send![known_hosts_field, setStringValue: NSString::alloc(nil).init_str(&settings.known_hosts)];
let _: () = msg_send![content_view, addSubview: known_hosts_field];
current_y -= 35.0;
// Basic auth label and field
let basic_auth_label: id = msg_send![NSTextField::alloc(nil),
initWithFrame: NSRect::new(
NSPoint::new(MARGIN, current_y),
NSSize::new(100.0, 20.0),
)
];
let _: () = msg_send![basic_auth_label, setStringValue: NSString::alloc(nil).init_str("Basic Auth:")];
let _: () = msg_send![basic_auth_label, setBezeled: NO];
let _: () = msg_send![basic_auth_label, setDrawsBackground: NO];
let _: () = msg_send![basic_auth_label, setEditable: NO];
let _: () = msg_send![basic_auth_label, setSelectable: NO];
let _: () = msg_send![content_view, addSubview: basic_auth_label];
let basic_auth_field: id = msg_send![NSTextField::alloc(nil),
initWithFrame: NSRect::new(
NSPoint::new(MARGIN + 110.0, current_y),
NSSize::new(310.0, FIELD_HEIGHT),
)
];
let _: () = msg_send![basic_auth_field, setStringValue: NSString::alloc(nil).init_str(&settings.basic_auth)];
let _: () = msg_send![content_view, addSubview: basic_auth_field];
current_y -= 40.0;
// In place checkbox
let in_place_checkbox: id = msg_send![NSButton::alloc(nil),
initWithFrame: NSRect::new(
NSPoint::new(MARGIN, current_y),
NSSize::new(400.0, 24.0),
)
];
let _: () = msg_send![in_place_checkbox, setButtonType: NS_SWITCH_BUTTON];
let _: () = msg_send![in_place_checkbox, setTitle: NSString::alloc(nil).init_str("Update known_hosts file in-place after sync")];
let _: () = msg_send![in_place_checkbox, setState: if settings.in_place { NS_CONTROL_STATE_VALUE_ON } else { NS_CONTROL_STATE_VALUE_OFF }];
let _: () = msg_send![content_view, addSubview: in_place_checkbox];
// Save button
let save_button: id = msg_send![NSButton::alloc(nil),
initWithFrame: NSRect::new(
NSPoint::new(WINDOW_WIDTH - 180.0, MARGIN),
NSSize::new(80.0, BUTTON_HEIGHT),
)
];
let _: () = msg_send![save_button, setTitle: NSString::alloc(nil).init_str("Save")];
let _: () = msg_send![content_view, addSubview: save_button];
// Cancel button
let cancel_button: id = msg_send![NSButton::alloc(nil),
initWithFrame: NSRect::new(
NSPoint::new(WINDOW_WIDTH - 90.0, MARGIN),
NSSize::new(80.0, BUTTON_HEIGHT),
)
];
let _: () = msg_send![cancel_button, setTitle: NSString::alloc(nil).init_str("Cancel")];
let _: () = msg_send![content_view, addSubview: cancel_button];
info!("All KHM UI elements created successfully");
Self {
window,
host_field,
flow_field,
known_hosts_field,
basic_auth_field,
in_place_checkbox,
}
}
}
fn collect_settings(&self) -> KhmSettings {
unsafe {
// Get host
let host_ns_string: id = msg_send![self.host_field, stringValue];
let host_ptr: *const i8 = msg_send![host_ns_string, UTF8String];
let host = CStr::from_ptr(host_ptr).to_string_lossy().to_string();
// Get flow
let flow_ns_string: id = msg_send![self.flow_field, stringValue];
let flow_ptr: *const i8 = msg_send![flow_ns_string, UTF8String];
let flow = CStr::from_ptr(flow_ptr).to_string_lossy().to_string();
// Get known hosts path
let known_hosts_ns_string: id = msg_send![self.known_hosts_field, stringValue];
let known_hosts_ptr: *const i8 = msg_send![known_hosts_ns_string, UTF8String];
let known_hosts = CStr::from_ptr(known_hosts_ptr).to_string_lossy().to_string();
// Get basic auth
let basic_auth_ns_string: id = msg_send![self.basic_auth_field, stringValue];
let basic_auth_ptr: *const i8 = msg_send![basic_auth_ns_string, UTF8String];
let basic_auth = CStr::from_ptr(basic_auth_ptr).to_string_lossy().to_string();
// Get checkbox state
let in_place_state: i32 = msg_send![self.in_place_checkbox, state];
let in_place = in_place_state == NS_CONTROL_STATE_VALUE_ON;
KhmSettings {
host,
flow,
known_hosts,
basic_auth,
in_place,
}
}
}
}
pub fn run_settings_window() {
info!("Starting native macOS KHM settings window");
unsafe {
let pool = NSAutoreleasePool::new(nil);
let app = NSApp();
info!("NSApp created for settings window");
// Set activation policy to regular for this standalone window
let _: () = msg_send![app, setActivationPolicy: 0]; // NSApplicationActivationPolicyRegular
info!("Activation policy set to Regular for settings window");
let settings_window = MacOSKhmSettingsWindow::new();
let window = settings_window.window;
info!("KHM settings window created");
// Show window and activate app
let _: () = msg_send![app, activateIgnoringOtherApps: YES];
let _: () = msg_send![window, makeKeyAndOrderFront: nil];
let _: () = msg_send![window, orderFrontRegardless];
info!("Settings window should be visible now");
// Run event loop until window is closed
let mut should_close = false;
while !should_close {
let event: id = msg_send![app,
nextEventMatchingMask: NSEventMask::NSAnyEventMask.bits()
untilDate: nil
inMode: NSDefaultRunLoopMode
dequeue: YES
];
if event == nil {
continue;
}
let event_type: NSEventType = msg_send![event, type];
// Handle window close button
if event_type == NSEventType::NSLeftMouseDown {
let event_window: id = msg_send![event, window];
if event_window == window {
let location: NSPoint = msg_send![event, locationInWindow];
// Check if click is on Save button
if location.x >= WINDOW_WIDTH - 180.0 && location.x <= WINDOW_WIDTH - 100.0 &&
location.y >= MARGIN && location.y <= MARGIN + BUTTON_HEIGHT {
info!("Save button clicked");
let settings = settings_window.collect_settings();
if let Err(e) = save_settings(&settings) {
error!("Failed to save KHM settings: {}", e);
} else {
info!("KHM settings saved from native macOS window");
}
should_close = true;
continue;
}
// Check if click is on Cancel button
if location.x >= WINDOW_WIDTH - 90.0 && location.x <= WINDOW_WIDTH - 10.0 &&
location.y >= MARGIN && location.y <= MARGIN + BUTTON_HEIGHT {
info!("Cancel button clicked");
should_close = true;
continue;
}
}
}
// Check if window is closed via close button or ESC
if event_type == NSEventType::NSKeyDown {
let key_code: u16 = msg_send![event, keyCode];
if key_code == 53 { // ESC key
info!("ESC pressed, closing settings window");
should_close = true;
continue;
}
}
// Forward event to application
let _: () = msg_send![app, sendEvent: event];
}
let _: () = msg_send![window, close];
info!("Native macOS KHM settings window closed");
pool.drain();
}
}

76
src/gui/settings/mod.rs Normal file
View File

@@ -0,0 +1,76 @@
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,
}
impl Default for KhmSettings {
fn default() -> Self {
Self {
host: String::new(),
flow: String::new(),
known_hosts: "~/.ssh/known_hosts".to_string(),
basic_auth: String::new(),
in_place: false,
}
}
}
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
}
pub fn load_settings() -> KhmSettings {
let path = get_config_path();
match fs::read_to_string(&path) {
Ok(contents) => serde_json::from_str(&contents).unwrap_or_else(|e| {
error!("Failed to parse KHM config: {}", e);
KhmSettings::default()
}),
Err(_) => {
debug!("KHM config file not found, using defaults");
KhmSettings::default()
}
}
}
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(())
}
#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "macos")]
pub use macos::run_settings_window;
#[cfg(not(target_os = "macos"))]
mod cross;
#[cfg(not(target_os = "macos"))]
pub use cross::run_settings_window;
// Helper function to expand tilde in path
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()
}

View File

@@ -2,6 +2,7 @@ mod client;
mod db; mod db;
mod server; mod server;
mod web; mod web;
mod gui;
use clap::Parser; use clap::Parser;
use env_logger; use env_logger;
@@ -31,6 +32,14 @@ struct Args {
#[arg(long, help = "Run in server mode")] #[arg(long, help = "Run in server mode")]
server: bool, server: bool,
/// Run with GUI tray interface (default: false)
#[arg(long, help = "Run with GUI tray interface")]
gui: bool,
/// Run settings UI window (used with --gui)
#[arg(long, help = "Run settings UI window (used with --gui)")]
settings_ui: bool,
/// Update the known_hosts file with keys from the server after sending keys (default: false) /// Update the known_hosts file with keys from the server after sending keys (default: false)
#[arg( #[arg(
long, long,
@@ -128,10 +137,26 @@ async fn main() -> std::io::Result<()> {
let args = Args::parse(); let args = Args::parse();
// Check if we have the minimum required arguments // Settings UI mode - just show settings window and exit
if !args.server && (args.host.is_none() || args.flow.is_none()) { if args.settings_ui {
// Neither server mode nor client mode properly configured info!("Running settings UI window");
eprintln!("Error: You must specify either server mode (--server) or client mode (--host and --flow)"); gui::run_settings_window();
return Ok(());
}
// 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!();
eprintln!("Examples:"); eprintln!("Examples:");
eprintln!( eprintln!(
@@ -142,6 +167,14 @@ async fn main() -> std::io::Result<()> {
" Client mode: {} --host https://khm.example.com --flow work", " Client mode: {} --host https://khm.example.com --flow work",
env!("CARGO_PKG_NAME") env!("CARGO_PKG_NAME")
); );
eprintln!(
" GUI mode: {} --gui",
env!("CARGO_PKG_NAME")
);
eprintln!(
" Settings window: {} --gui --settings-ui",
env!("CARGO_PKG_NAME")
);
eprintln!(); eprintln!();
eprintln!("Use --help for more information."); eprintln!("Use --help for more information.");
std::process::exit(1); std::process::exit(1);

10
static/khm-icon.svg Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<rect width="32" height="32" rx="4" fill="#2c3e50"/>
<rect x="4" y="8" width="24" height="2" rx="1" fill="#3498db"/>
<rect x="4" y="12" width="20" height="2" rx="1" fill="#e74c3c"/>
<rect x="4" y="16" width="22" height="2" rx="1" fill="#2ecc71"/>
<rect x="4" y="20" width="18" height="2" rx="1" fill="#f39c12"/>
<circle cx="24" cy="6" r="3" fill="#e67e22"/>
<text x="24" y="9" text-anchor="middle" font-family="Arial, sans-serif" font-size="6" fill="white">K</text>
</svg>

After

Width:  |  Height:  |  Size: 610 B