mirror of
https://github.com/house-of-vanity/khm.git
synced 2025-08-21 14:27:14 +00:00
works with macos native ui
This commit is contained in:
3637
Cargo.lock
generated
3637
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
13
Cargo.toml
13
Cargo.toml
@@ -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
331
src/gui/mod.rs
Normal 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
70
src/gui/settings/cross.rs
Normal 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
327
src/gui/settings/macos.rs
Normal 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
76
src/gui/settings/mod.rs
Normal 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()
|
||||||
|
}
|
41
src/main.rs
41
src/main.rs
@@ -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
10
static/khm-icon.svg
Normal 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 |
Reference in New Issue
Block a user