diff --git a/src/config_observer.rs b/src/config_observer.rs index b693806..dfdc37e 100644 --- a/src/config_observer.rs +++ b/src/config_observer.rs @@ -1,12 +1,26 @@ use std::fs; +use std::path::PathBuf; use directories::UserDirs; +pub fn expand_tilde(path: &str) -> PathBuf { + if path == "~" { + if let Some(home) = UserDirs::new().map(|d| d.home_dir().to_path_buf()) { + return home; + } + } else if let Some(rest) = path.strip_prefix("~/") + && let Some(home) = UserDirs::new().map(|d| d.home_dir().to_path_buf()) { + return home.join(rest); + } + PathBuf::from(path) +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct SshHost { pub alias: String, pub hostname: String, pub user: Option, pub port: Option, + pub identity_file: Option, } pub fn get_default_config_path() -> Option { @@ -55,6 +69,7 @@ pub fn parse_ssh_config(content: &str) -> Vec { hostname: String::new(), user: None, port: None, + identity_file: None, }); } "hostname" => { @@ -68,10 +83,14 @@ pub fn parse_ssh_config(content: &str) -> Vec { } } "port" => { - if let Some(ref mut host) = current_host { - if let Ok(p) = value.parse::() { + if let Some(ref mut host) = current_host + && let Ok(p) = value.parse::() { host.port = Some(p); } + } + "identityfile" => { + if let Some(ref mut host) = current_host { + host.identity_file = Some(value.to_string()); } } _ => {} @@ -88,7 +107,6 @@ pub fn parse_ssh_config(content: &str) -> Vec { pub fn add_host_to_config(host: &SshHost) -> anyhow::Result<()> { let path = get_default_config_path().ok_or_else(|| anyhow::anyhow!("Could not find SSH config path"))?; - if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } @@ -109,7 +127,7 @@ pub fn add_host_to_config(host: &SshHost) -> anyhow::Result<()> { host.alias.clone() }; - let entry = format!( + let mut entry = format!( "\nHost {}\n HostName {}\n User {}\n Port {}\n", alias_quoted, host.hostname, @@ -117,15 +135,25 @@ pub fn add_host_to_config(host: &SshHost) -> anyhow::Result<()> { host.port.unwrap_or(22) ); + if let Some(ref id_file) = host.identity_file { + let id_file_quoted = if id_file.contains(' ') { + format!("\"{}\"", id_file) + } else { + id_file.clone() + }; + entry.push_str(&format!(" IdentityFile {}\n", id_file_quoted)); + } + content.push_str(&entry); - std::fs::write(path, content)?; + let tmp_path = path.with_extension("tmp"); + std::fs::write(&tmp_path, &content)?; + std::fs::rename(tmp_path, path)?; Ok(()) } pub fn delete_host_from_config(alias: &str) -> anyhow::Result<()> { let path = get_default_config_path().ok_or_else(|| anyhow::anyhow!("No config path"))?; if !path.exists() { return Ok(()); } - let content = std::fs::read_to_string(&path)?; let mut new_lines = Vec::new(); let mut skip = false; @@ -138,7 +166,6 @@ pub fn delete_host_from_config(alias: &str) -> anyhow::Result<()> { if val.starts_with('"') && val.ends_with('"') && val.len() >= 2 { val = &val[1..val.len()-1]; } - if val == target_alias { skip = true; continue; @@ -146,19 +173,17 @@ pub fn delete_host_from_config(alias: &str) -> anyhow::Result<()> { skip = false; } } - if skip && (line.starts_with(' ') || line.starts_with('\t') || line.trim().is_empty()) { continue; } - if skip { skip = false; } - new_lines.push(line); } - - std::fs::write(path, new_lines.join("\n"))?; + let tmp_path = path.with_extension("tmp"); + std::fs::write(&tmp_path, new_lines.join("\n"))?; + std::fs::rename(tmp_path, path)?; Ok(()) } @@ -184,4 +209,84 @@ mod tests { assert_eq!(hosts.len(), 1); assert_eq!(hosts[0].alias, "My Server"); } -} + + #[test] + fn test_parse_ssh_config_with_identity_file() { + let config = "Host my-server\n HostName 1.2.3.4\n User root\n Port 22\n IdentityFile ~/.ssh/id_ed25519"; + let hosts = parse_ssh_config(config); + assert_eq!(hosts.len(), 1); + assert_eq!(hosts[0].identity_file, Some("~/.ssh/id_ed25519".to_string())); + } + + #[test] + fn test_parse_ssh_config_with_quoted_identity_file() { + let config = "Host my-server\n HostName 1.2.3.4\n User root\n IdentityFile \"/home/user/my keys/id_ed25519\""; + let hosts = parse_ssh_config(config); + assert_eq!(hosts.len(), 1); + assert_eq!(hosts[0].identity_file, Some("/home/user/my keys/id_ed25519".to_string())); + } + + #[test] + fn test_add_host_to_config_emits_identity_file() { + let host = SshHost { + alias: "test-host".to_string(), + hostname: "192.168.1.1".to_string(), + user: Some("admin".to_string()), + port: Some(22), + identity_file: Some("~/.ssh/id_ed25519".to_string()), + }; + let alias_quoted = if host.alias.contains(' ') { + format!("\"{}\"", host.alias) + } else { + host.alias.clone() + }; + let mut entry = format!( + "\nHost {}\n HostName {}\n User {}\n Port {}\n", + alias_quoted, + host.hostname, + host.user.as_deref().unwrap_or("root"), + host.port.unwrap_or(22) + ); + if let Some(ref id_file) = host.identity_file { + let id_file_quoted = if id_file.contains(' ') { + format!("\"{}\"", id_file) + } else { + id_file.clone() + }; + entry.push_str(&format!(" IdentityFile {}\n", id_file_quoted)); + } + assert!(entry.contains("IdentityFile ~/.ssh/id_ed25519")); + let hosts = parse_ssh_config(&entry); + assert_eq!(hosts.len(), 1); + assert_eq!(hosts[0].identity_file, Some("~/.ssh/id_ed25519".to_string())); + } + + #[test] + fn test_add_host_to_config_quotes_identity_file_with_spaces() { + let host = SshHost { + alias: "spaced-host".to_string(), + hostname: "10.0.0.1".to_string(), + user: Some("user".to_string()), + port: Some(22), + identity_file: Some("/home/user/my keys/id_rsa".to_string()), + }; + let mut entry = format!( + "\nHost {}\n HostName {}\n User {}\n Port {}\n", + host.alias, host.hostname, + host.user.as_deref().unwrap_or("root"), + host.port.unwrap_or(22) + ); + if let Some(ref id_file) = host.identity_file { + let id_file_quoted = if id_file.contains(' ') { + format!("\"{}\"", id_file) + } else { + id_file.clone() + }; + entry.push_str(&format!(" IdentityFile {}\n", id_file_quoted)); + } + assert!(entry.contains("IdentityFile \"/home/user/my keys/id_rsa\"")); + let hosts = parse_ssh_config(&entry); + assert_eq!(hosts.len(), 1); + assert_eq!(hosts[0].identity_file, Some("/home/user/my keys/id_rsa".to_string())); + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 97823be..82f3004 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,7 +19,6 @@ fn log_debug(msg: &str) { #[tokio::main] async fn main() { let _args: Vec = std::env::args().collect(); - if let Ok(alias) = std::env::var("RUSTMIUS_ASKPASS_ALIAS") { log_debug(&format!("AskPass triggered for alias: {}", alias)); if let Ok(keyring) = oo7::Keyring::new().await { @@ -32,7 +31,6 @@ async fn main() { && let Ok(password) = item.secret().await && let Ok(pass_str) = std::str::from_utf8(&password) { log_debug("Password retrieved successfully, sending to SSH"); - print!("{}", pass_str); std::process::exit(0); } @@ -49,4 +47,4 @@ async fn main() { app.connect_activate(build_ui); app.run_with_args::<&str>(&[]); -} +} \ No newline at end of file diff --git a/src/sftp_engine.rs b/src/sftp_engine.rs index 14cb8b8..71b4c3f 100644 --- a/src/sftp_engine.rs +++ b/src/sftp_engine.rs @@ -1,6 +1,7 @@ use ssh2::Session; -use std::net::TcpStream; -use crate::config_observer::SshHost; +use std::net::{TcpStream, ToSocketAddrs}; +use std::time::Duration; +use crate::config_observer::{SshHost, expand_tilde}; use std::path::Path; use std::io::{Read, Write}; use std::sync::{Arc, Mutex, OnceLock}; @@ -14,7 +15,7 @@ pub struct RemoteFile { } pub struct ActiveSession { - _sess: Session, // Keep the session alive for the Sftp pointer + _sess: Session, pub sftp: ssh2::Sftp, } @@ -25,40 +26,59 @@ fn get_session_pool() -> &'static Mutex>> { fn get_or_connect_sftp(host: &SshHost, password: &Option) -> anyhow::Result> { let host_key = format!("{}@{}", host.user.as_deref().unwrap_or("root"), host.hostname); - - if let Ok(mut pool) = get_session_pool().lock() { - if let Some(active) = pool.get(&host_key) { - // Check if connection is still alive using a basic stat + if let Ok(mut pool) = get_session_pool().lock() + && let Some(active) = pool.get(&host_key) { if active.sftp.stat(Path::new(".")).is_ok() { return Ok(active.clone()); } else { - // Connection died, remove it to force reconnect pool.remove(&host_key); } } - } let port = host.port.unwrap_or(22); - let tcp = TcpStream::connect(format!("{}:{}", host.hostname, port))?; + let addrs = format!("{}:{}", host.hostname, port).to_socket_addrs()?; + let mut tcp_opt = None; + for addr in addrs { + if let Ok(stream) = TcpStream::connect_timeout(&addr, Duration::from_secs(5)) { + tcp_opt = Some(stream); + break; + } + } + let tcp = tcp_opt.ok_or_else(|| anyhow::anyhow!("Connection timeout to {}", host.hostname))?; let mut sess = Session::new()?; sess.set_tcp_stream(tcp); sess.handshake()?; let user = host.user.as_deref().unwrap_or("root"); - if let Some(pass) = password { - sess.userauth_password(user, pass)?; - } else { - sess.userauth_agent(user)?; + let mut authenticated = false; + if let Some(ref key_path) = host.identity_file { + let path = expand_tilde(key_path); + if sess.userauth_pubkey_file(user, None, &path, None).is_ok() { + println!("[DEBUG] SFTP connected to {} via Configure SSH Key ({})", host.hostname, key_path); + authenticated = true; + } + } + if !authenticated + && sess.userauth_agent(user).is_ok() { + println!("[DEBUG] SFTP connected to {} via SSH Agent", host.hostname); + authenticated = true; + } + + if !authenticated + && let Some(pass) = password + && sess.userauth_password(user, pass).is_ok() { + println!("[DEBUG] SFTP connected to {} via Password", host.hostname); + authenticated = true; + } + if !authenticated { + return Err(anyhow::anyhow!("Authentication failed (tried key, password, and agent)")); } let sftp = sess.sftp()?; - let active = Arc::new(ActiveSession { _sess: sess, sftp }); - if let Ok(mut pool) = get_session_pool().lock() { pool.insert(host_key, active.clone()); } - Ok(active) } @@ -66,7 +86,6 @@ pub async fn list_files(host: SshHost, password: Option, path: String) - tokio::task::spawn_blocking(move || { let active = get_or_connect_sftp(&host, &password)?; let dir = active.sftp.readdir(Path::new(&path))?; - let mut files = Vec::new(); for (path, stat) in dir { if let Some(name) = path.file_name().and_then(|n| n.to_str()) { @@ -77,7 +96,6 @@ pub async fn list_files(host: SshHost, password: Option, path: String) - }); } } - files.sort_by(|a, b| { if a.is_dir != b.is_dir { b.is_dir.cmp(&a.is_dir) @@ -132,7 +150,6 @@ pub async fn upload_file(host: SshHost, password: Option, local_path: St let active = get_or_connect_sftp(&host, &password)?; let mut local_file = std::fs::File::open(local_path)?; let mut remote_file = active.sftp.create(Path::new(&remote_path))?; - let mut buffer = [0; 16384]; while let Ok(n) = local_file.read(&mut buffer) { if n == 0 { break; } @@ -160,4 +177,4 @@ pub fn download_file_sync(host: SshHost, password: Option, remote_path: local_file.write_all(&buffer[..n])?; } Ok(()) -} +} \ No newline at end of file diff --git a/src/ssh_engine.rs b/src/ssh_engine.rs index 0e6cc3f..5fee3eb 100644 --- a/src/ssh_engine.rs +++ b/src/ssh_engine.rs @@ -1,18 +1,82 @@ use ssh2::Session; -use std::net::TcpStream; +use std::net::{TcpStream, ToSocketAddrs}; +use std::time::Duration; +use std::io::Write; use anyhow::Context; +use crate::config_observer::{SshHost, expand_tilde}; #[allow(dead_code)] pub fn connect(host: &str, _user: &str) -> anyhow::Result { - let tcp = TcpStream::connect(format!("{}:22", host)) - .with_context(|| format!("Failed to connect to {}:22", host))?; - + let addrs = format!("{}:22", host).to_socket_addrs() + .with_context(|| format!("Failed to resolve {}:22", host))?; + let mut tcp_opt = None; + for addr in addrs { + if let Ok(stream) = TcpStream::connect_timeout(&addr, Duration::from_secs(5)) { + tcp_opt = Some(stream); + break; + } + } + let tcp = tcp_opt.ok_or_else(|| anyhow::anyhow!("Connection timeout to {}:22", host))?; let mut sess = Session::new() .context("Failed to create SSH session")?; - sess.set_tcp_stream(tcp); sess.handshake() .context("SSH handshake failed")?; Ok(sess) } + +pub fn deploy_pubkey(host: &SshHost, password: Option, pubkey_content: &str) -> anyhow::Result<()> { + let port = host.port.unwrap_or(22); + let addrs = format!("{}:{}", host.hostname, port).to_socket_addrs()?; + let mut tcp_opt = None; + for addr in addrs { + if let Ok(stream) = TcpStream::connect_timeout(&addr, Duration::from_secs(5)) { + tcp_opt = Some(stream); + break; + } + } + let tcp = tcp_opt.ok_or_else(|| anyhow::anyhow!("Connection timeout to {}", host.hostname))?; + let mut sess = Session::new()?; + sess.set_tcp_stream(tcp); + sess.handshake()?; + let user = host.user.as_deref().unwrap_or("root"); + let mut authenticated = false; + if let Some(ref key_path) = host.identity_file { + let path = expand_tilde(key_path); + if sess.userauth_pubkey_file(user, None, &path, None).is_ok() { + println!("[DEBUG] SSH deploy connected to {} via Configure SSH Key ({})", host.hostname, key_path); + authenticated = true; + } + } + if !authenticated + && sess.userauth_agent(user).is_ok() { + println!("[DEBUG] SSH deploy connected to {} via SSH Agent", host.hostname); + authenticated = true; + } + + if !authenticated + && let Some(pass) = password + && sess.userauth_password(user, &pass).is_ok() { + println!("[DEBUG] SSH deploy connected to {} via Password", host.hostname); + authenticated = true; + } + if !authenticated { + return Err(anyhow::anyhow!("Authentication failed (tried key, password, and agent)")); + } + let mut channel = sess.channel_session()?; + channel.exec("mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys")?; + + if pubkey_content.ends_with('\n') { + channel.write_all(pubkey_content.as_bytes())?; + } else { + let mut content = pubkey_content.to_owned(); + content.push('\n'); + channel.write_all(content.as_bytes())?; + } + channel.send_eof()?; + channel.wait_eof()?; + channel.close()?; + channel.wait_close()?; + Ok(()) +} \ No newline at end of file diff --git a/src/ui/add_server_dialog.rs b/src/ui/add_server_dialog.rs index e9aac68..a520840 100644 --- a/src/ui/add_server_dialog.rs +++ b/src/ui/add_server_dialog.rs @@ -1,10 +1,11 @@ #![allow(deprecated)] use gtk4::prelude::*; use crate::config_observer::SshHost; +use crate::ui::ssh_keys::load_ssh_keys; pub fn show_server_dialog( - parent: >k4::Window, - initial_host: Option<&SshHost>, + parent: >k4::Window, + initial_host: Option<&SshHost>, existing_aliases: Vec, on_save: F ) @@ -51,6 +52,25 @@ where F: Fn(SshHost, String) + 'static } } + let keys = load_ssh_keys(); + let key_model = gtk4::StringList::new(&[]); + key_model.append("None (Default Auth)"); + for k in &keys { + key_model.append(&k.name); + } + let key_dropdown = gtk4::DropDown::new(Some(key_model), gtk4::Expression::NONE); + + if let Some(host) = initial_host + && let Some(ref id_file) = host.identity_file { + let id_file_expanded = crate::config_observer::expand_tilde(id_file); + for (i, k) in keys.iter().enumerate() { + if k.priv_path == id_file_expanded { + key_dropdown.set_selected((i + 1) as u32); + break; + } + } + } + content.append(>k4::Label::builder().label("Alias").halign(gtk4::Align::Start).build()); content.append(&alias_entry); content.append(&error_label); @@ -62,36 +82,42 @@ where F: Fn(SshHost, String) + 'static content.append(&user_entry); content.append(>k4::Label::builder().label("Password").halign(gtk4::Align::Start).build()); content.append(&pass_entry); + content.append(>k4::Label::builder().label("SSH Key").halign(gtk4::Align::Start).build()); + content.append(&key_dropdown); let ok_button = dialog.add_button(if initial_host.is_some() { "Save" } else { "Add" }, gtk4::ResponseType::Ok); dialog.add_button("Cancel", gtk4::ResponseType::Cancel); let existing_aliases = Rc::new(existing_aliases); let initial_alias = initial_host.map(|h| h.alias.to_lowercase()); - let alias_entry_clone = alias_entry.clone(); let error_label_clone = error_label.clone(); let ok_button_clone = ok_button.clone(); let existing_aliases_clone = existing_aliases.clone(); - alias_entry.connect_changed(move |e| { let text = e.text().to_string().trim().to_lowercase(); let is_duplicate = existing_aliases_clone.contains(&text) && Some(text.clone()) != initial_alias; - error_label_clone.set_visible(is_duplicate); ok_button_clone.set_sensitive(!is_duplicate && !text.is_empty()); }); dialog.connect_response(move |d, res| { if res == gtk4::ResponseType::Ok { + let selected_key_idx = key_dropdown.selected(); + let identity_file = if selected_key_idx > 0 { + let key = &keys[(selected_key_idx - 1) as usize]; + Some(key.priv_path.to_string_lossy().to_string()) + } else { + None + }; let host = SshHost { alias: alias_entry_clone.text().to_string().trim().to_string(), hostname: host_entry.text().to_string().trim().to_string(), user: Some(user_entry.text().to_string().trim().to_string()).filter(|s| !s.is_empty()), port: port_entry.text().to_string().trim().parse::().ok(), + identity_file, }; let password = pass_entry.text().to_string(); - if !host.alias.is_empty() && !host.hostname.is_empty() { on_save(host, password); } @@ -102,4 +128,4 @@ where F: Fn(SshHost, String) + 'static dialog.present(); } -use std::rc::Rc; +use std::rc::Rc; \ No newline at end of file diff --git a/src/ui/file_explorer.rs b/src/ui/file_explorer.rs index 1db4531..adcc98f 100644 --- a/src/ui/file_explorer.rs +++ b/src/ui/file_explorer.rs @@ -14,7 +14,6 @@ pub struct FileExplorer { status_label: gtk4::Label, host: SshHost, password: Option, - files: Rc>>, } @@ -144,7 +143,6 @@ impl FileExplorer { && let Ok(uris_str) = std::str::from_utf8(bytes.as_ref()) { paths.extend(parse_uri_list_paths(uris_str)); } - if paths.is_empty() && let Ok(uris_str) = value.get::() { paths.extend(parse_uri_list_paths(&uris_str)); @@ -352,7 +350,6 @@ impl ExplorerHandle { let h = h_drag.clone(); let f = f_drag.clone(); let remote_path = format!("{}{}", h.current_path.borrow(), f.name); - let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_micros(); let local_tmp_part = format!("/tmp/rustmius_dnd_{}_{}.part", ts, f.name); let local_tmp = format!("/tmp/rustmius_dnd_{}_{}", ts, f.name); @@ -370,7 +367,6 @@ impl ExplorerHandle { let host = h.host.clone(); let password = h.password.clone(); let rp = remote_path.clone(); - let lp_part = local_tmp_part.clone(); let lp_final = local_tmp.clone(); @@ -420,17 +416,15 @@ impl ExplorerHandle { let hi = h_dl.clone(); let fi = f_dl.clone(); let rp = format!("{}{}", hi.current_path.borrow(), fi.name); let parent_window = hi.list_box.root().and_then(|r| r.downcast::().ok()); - let dialog = gtk4::FileDialog::builder() .title("Save As") .initial_name(&fi.name) .build(); - let hii = hi.clone(); if let Some(w) = parent_window { dialog.save(Some(&w), gio::Cancellable::NONE, move |res| { - if let Ok(file) = res { - if let Some(path) = file.path() { + if let Ok(file) = res + && let Some(path) = file.path() { let lp = path.to_string_lossy().to_string(); hii.status_label.set_text(&format!("Downloading {}...", fi.name)); let hiii = hii.clone(); @@ -444,7 +438,6 @@ impl ExplorerHandle { } }); } - } }); } }); @@ -492,7 +485,6 @@ impl ExplorerHandle { group.add_action(&ren_action); if f.is_dir { - let h_nf = h.clone(); let f_nf = f.clone(); let nf_action = gio::SimpleAction::new("new_file", None); nf_action.connect_activate(move |_, _| { @@ -616,19 +608,15 @@ where F: Fn(String) + 'static if let Some(p) = parent { dialog.set_transient_for(Some(p)); } - let content = dialog.content_area(); content.set_margin_top(12); content.set_margin_bottom(12); content.set_margin_start(12); content.set_margin_end(12); content.set_spacing(12); content.append(>k4::Label::new(Some(label))); - let entry = gtk4::Entry::builder().text(initial).build(); content.append(&entry); - dialog.add_button("Cancel", gtk4::ResponseType::Cancel); dialog.add_button("OK", gtk4::ResponseType::Ok); - dialog.connect_response(move |d, res| { if res == gtk4::ResponseType::Ok { let text = entry.text().to_string(); @@ -655,4 +643,4 @@ where F: Fn() + 'static d.close(); }); dialog.present(); -} +} \ No newline at end of file diff --git a/src/ui/hud.rs b/src/ui/hud.rs index 4ec3d67..7172a4c 100644 --- a/src/ui/hud.rs +++ b/src/ui/hud.rs @@ -27,7 +27,6 @@ impl Hud { let scrolled = gtk4::ScrolledWindow::new(); scrolled.set_min_content_height(300); scrolled.set_min_content_width(400); - let list_box = gtk4::ListBox::new(); scrolled.set_child(Some(&list_box)); box_container.append(&scrolled); @@ -43,7 +42,6 @@ impl Hud { } pub fn update_results(&self, hosts: &[SshHost], query: &str) { - while let Some(row) = self.list_box.first_child() { self.list_box.remove(&row); } @@ -63,7 +61,6 @@ impl Hud { for host in hosts { let text = format!("{} {}", host.alias, host.hostname); let text_utf32 = Utf32String::from(text.as_str()); - if let Some(score) = matcher.fuzzy_match(text_utf32.slice(..), query_utf32.slice(..)) { matches.push((score, host)); } @@ -81,14 +78,12 @@ impl Hud { let alias_label = gtk4::Label::new(Some(&host.alias)); alias_label.set_halign(gtk4::Align::Start); alias_label.add_css_class("heading"); - let host_label = gtk4::Label::new(Some(&format!("{}@{}", host.user.as_deref().unwrap_or(""), host.hostname))); host_label.set_halign(gtk4::Align::Start); host_label.add_css_class("caption"); row_box.append(&alias_label); row_box.append(&host_label); - self.list_box.append(&row_box); } -} +} \ No newline at end of file diff --git a/src/ui/mod.rs b/src/ui/mod.rs index b93ccc4..47447e6 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -3,3 +3,4 @@ pub mod window; pub mod server_list; pub mod add_server_dialog; pub mod file_explorer; +pub mod ssh_keys; \ No newline at end of file diff --git a/src/ui/server_list.rs b/src/ui/server_list.rs index 2f7001a..eb715e0 100644 --- a/src/ui/server_list.rs +++ b/src/ui/server_list.rs @@ -13,14 +13,13 @@ pub struct ServerList { } impl ServerList { - pub fn new(on_action: F) -> Self + pub fn new(on_action: F) -> Self where F: Fn(ServerAction) + 'static + Clone { let scrolled = gtk4::ScrolledWindow::builder() .hscrollbar_policy(gtk4::PolicyType::Never) .vexpand(true) .build(); - let flow_box = gtk4::FlowBox::builder() .selection_mode(gtk4::SelectionMode::None) .valign(gtk4::Align::Start) @@ -33,7 +32,6 @@ impl ServerList { .margin_start(24) .margin_end(24) .build(); - scrolled.set_child(Some(&flow_box)); let sl = Self { container: scrolled, flow_box }; @@ -41,7 +39,7 @@ impl ServerList { sl } - pub fn refresh(&self, on_action: F) + pub fn refresh(&self, on_action: F) where F: Fn(ServerAction) + 'static + Clone { while let Some(child) = self.flow_box.first_child() { @@ -54,7 +52,7 @@ impl ServerList { } } - fn add_host_row(&self, host: &SshHost, on_action: F) + fn add_host_row(&self, host: &SshHost, on_action: F) where F: Fn(ServerAction) + 'static + Clone { let frame = gtk4::Frame::new(None); @@ -67,7 +65,6 @@ impl ServerList { content_box.set_margin_end(12); let header_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - let alias_label = gtk4::Label::builder() .label(&host.alias) .halign(gtk4::Align::Start) @@ -76,7 +73,6 @@ impl ServerList { .build(); let actions_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 4); - let edit_btn = gtk4::Button::from_icon_name("document-edit-symbolic"); edit_btn.add_css_class("flat"); let host_edit = host.clone(); @@ -121,4 +117,4 @@ impl ServerList { frame.set_child(Some(&content_box)); self.flow_box.insert(&frame, -1); } -} +} \ No newline at end of file diff --git a/src/ui/ssh_keys.rs b/src/ui/ssh_keys.rs new file mode 100644 index 0000000..a529f9a --- /dev/null +++ b/src/ui/ssh_keys.rs @@ -0,0 +1,652 @@ +#![allow(deprecated)] +use gtk4::prelude::*; +use gtk4::glib; +use std::rc::Rc; +use std::cell::RefCell; +use directories::UserDirs; +use std::path::PathBuf; +use crate::config_observer::load_hosts; + +fn is_valid_key_name(name: &str) -> bool { + if name.is_empty() || name.contains('\0') { + return false; + } + let p = std::path::Path::new(name); + p.components().count() == 1 + && p.file_name().map(|n| n == std::ffi::OsStr::new(name)).unwrap_or(false) +} + +fn make_error_alert(parent: Option<>k4::Window>, title: &str, secondary: &str) -> gtk4::MessageDialog { + let builder = gtk4::MessageDialog::builder() + .modal(true) + .message_type(gtk4::MessageType::Error) + .buttons(gtk4::ButtonsType::Ok) + .text(title) + .secondary_text(secondary); + let alert = if let Some(w) = parent { + builder.transient_for(w).build() + } else { + builder.build() + }; + alert.connect_response(|a, _| a.close()); + alert +} + +#[derive(Clone)] +pub struct SshKeyPair { + pub name: String, + pub pub_path: PathBuf, + pub priv_path: PathBuf, +} + +fn get_ssh_dir() -> Option { + UserDirs::new().map(|dirs| dirs.home_dir().join(".ssh")) +} + +pub fn load_ssh_keys() -> Vec { + let mut keys = Vec::new(); + if let Some(ssh_dir) = get_ssh_dir() + && let Ok(entries) = std::fs::read_dir(&ssh_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("pub") { + let mut priv_path = path.clone(); + priv_path.set_extension(""); + if priv_path.exists() { + let name = path.file_stem().unwrap_or_default().to_string_lossy().to_string(); + keys.push(SshKeyPair { + name, + pub_path: path, + priv_path, + }); + } + } + } + } + keys.sort_by(|a, b| a.name.cmp(&b.name)); + keys +} + +pub fn build_ssh_keys_ui(window: >k4::ApplicationWindow) -> gtk4::Box { + let main_box = gtk4::Box::new(gtk4::Orientation::Vertical, 12); + main_box.set_margin_top(24); main_box.set_margin_bottom(24); + main_box.set_margin_start(24); main_box.set_margin_end(24); + + let header_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + let title = gtk4::Label::builder().label("SSH Keys").halign(gtk4::Align::Start).hexpand(true).build(); + title.add_css_class("title-1"); + let gen_btn = gtk4::Button::from_icon_name("list-add-symbolic"); + gen_btn.set_tooltip_text(Some("Generate New Key")); + gen_btn.add_css_class("suggested-action"); + let import_btn = gtk4::Button::from_icon_name("document-import-symbolic"); + import_btn.set_tooltip_text(Some("Import Key")); + import_btn.add_css_class("flat"); + let refresh_btn = gtk4::Button::from_icon_name("view-refresh-symbolic"); + refresh_btn.set_tooltip_text(Some("Refresh")); + + header_box.append(&title); + header_box.append(&refresh_btn); + header_box.append(&import_btn); + header_box.append(&gen_btn); + + main_box.append(&header_box); + + let list_box = gtk4::ListBox::new(); + list_box.set_selection_mode(gtk4::SelectionMode::None); + list_box.add_css_class("boxed-list"); + let scrolled = gtk4::ScrolledWindow::builder() + .child(&list_box) + .vexpand(true) + .build(); + main_box.append(&scrolled); + + let list_box_rc = Rc::new(list_box); + let window_rc = window.clone(); + + let refresh_ui: Rc>>> = Rc::new(RefCell::new(None)); + + let do_refresh = { + let lb = list_box_rc.clone(); + let win = window_rc.clone(); + let rwh = Rc::downgrade(&refresh_ui); + Rc::new(move || { + while let Some(child) = lb.first_child() { + lb.remove(&child); + } + + let keys = load_ssh_keys(); + if keys.is_empty() { + let empty_lbl = gtk4::Label::new(Some("No SSH keys found in ~/.ssh/")); + empty_lbl.set_margin_top(24); + empty_lbl.set_margin_bottom(24); + empty_lbl.add_css_class("dim-label"); + lb.append(&empty_lbl); + } else { + for key in keys { + let row = gtk4::ListBoxRow::new(); + let hbox = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + hbox.set_margin_start(12); hbox.set_margin_end(12); + hbox.set_margin_top(12); hbox.set_margin_bottom(12); + let icon = gtk4::Image::from_icon_name("network-vpn-symbolic"); + icon.set_pixel_size(24); + let name_lbl = gtk4::Label::new(Some(&key.name)); + name_lbl.set_halign(gtk4::Align::Start); + name_lbl.set_hexpand(true); + + let deploy_btn = gtk4::Button::from_icon_name("document-send-symbolic"); + deploy_btn.set_tooltip_text(Some("Deploy to Server")); + deploy_btn.add_css_class("flat"); + + let del_btn = gtk4::Button::from_icon_name("user-trash-symbolic"); + del_btn.set_tooltip_text(Some("Delete Key")); + del_btn.add_css_class("destructive-action"); + del_btn.add_css_class("flat"); + + let key_clone1 = key.clone(); + let key_clone2 = key.clone(); + let w_clone = win.clone(); + let w_clone2 = win.clone(); + let handle = rwh.clone(); + + del_btn.connect_clicked(move |_| { + let dialog = gtk4::MessageDialog::builder() + .transient_for(&w_clone) + .modal(true) + .message_type(gtk4::MessageType::Warning) + .buttons(gtk4::ButtonsType::OkCancel) + .text(format!("Delete key '{}'?", key_clone1.name)) + .secondary_text("This action cannot be undone and will delete both public and private key files.") + .build(); + + let p1 = key_clone1.pub_path.clone(); + let p2 = key_clone1.priv_path.clone(); + let h = handle.clone(); + let w_del = w_clone.clone(); + + dialog.connect_response(move |d, res| { + if res == gtk4::ResponseType::Ok { + if let Err(e) = std::fs::remove_file(&p2) { + let alert = gtk4::MessageDialog::builder() + .transient_for(&w_del) + .modal(true) + .message_type(gtk4::MessageType::Error) + .buttons(gtk4::ButtonsType::Ok) + .text("Failed to Delete Key") + .secondary_text(format!("Could not delete private key: {}", e)) + .build(); + alert.connect_response(|a, _| a.close()); + alert.present(); + d.close(); + return; + } + if let Err(e) = std::fs::remove_file(&p1) { + let alert = gtk4::MessageDialog::builder() + .transient_for(&w_del) + .modal(true) + .message_type(gtk4::MessageType::Error) + .buttons(gtk4::ButtonsType::Ok) + .text("Failed to Delete Key") + .secondary_text(format!("Private key deleted, but could not delete public key: {}", e)) + .build(); + alert.connect_response(|a, _| a.close()); + alert.present(); + d.close(); + return; + } + if let Some(rc) = h.upgrade() + && let Some(r) = rc.borrow().as_ref() { r(); } + } + d.close(); + }); + dialog.present(); + }); + + deploy_btn.connect_clicked(move |_| { + show_deploy_dialog(&w_clone2, &key_clone2); + }); + + hbox.append(&icon); + hbox.append(&name_lbl); + hbox.append(&deploy_btn); + hbox.append(&del_btn); + row.set_child(Some(&hbox)); + lb.append(&row); + } + } + }) + }; + + *refresh_ui.borrow_mut() = Some(do_refresh.clone()); + do_refresh(); + + let r_refresh = do_refresh.clone(); + refresh_btn.connect_clicked(move |_| { r_refresh(); }); + + let r_win = window_rc.clone(); + let g_refresh = do_refresh.clone(); + gen_btn.connect_clicked(move |_| { + show_generate_dialog(&r_win, g_refresh.clone()); + }); + let w_win = window_rc.clone(); + let i_refresh = do_refresh.clone(); + import_btn.connect_clicked(move |_| { + show_import_dialog(&w_win, i_refresh.clone()); + }); + + main_box +} + +fn show_deploy_dialog(parent: >k4::ApplicationWindow, key: &SshKeyPair) { + let dialog = gtk4::Dialog::builder() + .transient_for(parent) + .modal(true) + .title(format!("Deploy key: {}", key.name)) + .default_width(350) + .build(); + + let content = dialog.content_area(); + content.set_margin_top(12); content.set_margin_bottom(12); + content.set_margin_start(12); content.set_margin_end(12); + content.set_spacing(12); + + let hosts = load_hosts(); + if hosts.is_empty() { + content.append(>k4::Label::new(Some("No servers available."))); + dialog.add_button("Close", gtk4::ResponseType::Close); + dialog.connect_response(|d, _| d.close()); + dialog.present(); + return; + } + + let model = gtk4::StringList::new(&[]); + for h in &hosts { + model.append(&h.alias); + } + + let dropdown = gtk4::DropDown::new(Some(model), gtk4::Expression::NONE); + content.append(>k4::Label::builder().label("Select Server").halign(gtk4::Align::Start).build()); + content.append(&dropdown); + + let pass_entry = gtk4::PasswordEntry::builder() + .placeholder_text("Server Password (optional if agent is running)") + .show_peek_icon(true) + .build(); + content.append(>k4::Label::builder().label("Password (for deployment)").halign(gtk4::Align::Start).build()); + content.append(&pass_entry); + + let status_label = gtk4::Label::new(None); + status_label.set_halign(gtk4::Align::Start); + content.append(&status_label); + + let _ok_btn = dialog.add_button("Deploy", gtk4::ResponseType::Ok); + dialog.add_button("Cancel", gtk4::ResponseType::Cancel); + + let key_path = key.pub_path.clone(); + dialog.connect_response(move |d, res| { + if res == gtk4::ResponseType::Ok { + let idx = dropdown.selected(); + if idx < hosts.len() as u32 { + let host = hosts[idx as usize].clone(); + let password = pass_entry.text().to_string(); + let pubkey = match std::fs::read_to_string(&key_path) { + Ok(content) => content, + Err(e) => { + let md = gtk4::MessageDialog::builder() + .modal(true) + .message_type(gtk4::MessageType::Error) + .buttons(gtk4::ButtonsType::Ok) + .text("Failed to Read Public Key") + .secondary_text(format!("Could not read '{}': {}", key_path.display(), e)) + .build(); + if let Some(w) = d.transient_for() { + md.set_transient_for(Some(&w)); + } + md.connect_response(|md, _| md.close()); + md.present(); + return; + } + }; + let parent_win_weak = d.transient_for().and_then(|w| w.downcast::().ok()); + let close_dialog = d.clone(); + glib::MainContext::default().spawn_local(async move { + let mut final_password = None; + if !password.is_empty() { + final_password = Some(password); + } else if let Ok(keyring) = oo7::Keyring::new().await { + let mut attr = std::collections::HashMap::new(); + let alias_lower = host.alias.to_lowercase(); + attr.insert("rustmius-server-alias", alias_lower.as_str()); + if let Ok(items) = keyring.search_items(&attr).await + && let Some(item) = items.first() + && let Ok(pass) = item.secret().await { + final_password = Some(String::from_utf8_lossy(&pass).to_string()); + } + } + + let h_c = host.clone(); + let pk_c = pubkey.clone(); + let result = tokio::task::spawn_blocking(move || { + crate::ssh_engine::deploy_pubkey(&h_c, final_password, &pk_c) + }).await.unwrap_or_else(|_| Err(anyhow::anyhow!("Task panic"))); + + match result { + Ok(_) => { + let md = gtk4::MessageDialog::builder() + .modal(true) + .message_type(gtk4::MessageType::Info) + .buttons(gtk4::ButtonsType::Ok) + .text("Deployed Successfully!") + .build(); + if let Some(ref w) = parent_win_weak { + md.set_transient_for(Some(w)); + } + md.connect_response(|md, _| md.close()); + md.present(); + close_dialog.close(); + }, + Err(e) => { + let md = gtk4::MessageDialog::builder() + .modal(true) + .message_type(gtk4::MessageType::Error) + .buttons(gtk4::ButtonsType::Ok) + .text("Deployment Failed") + .secondary_text(e.to_string()) + .build(); + if let Some(ref w) = parent_win_weak { + md.set_transient_for(Some(w)); + } + md.connect_response(|md, _| md.close()); + md.present(); + } + } + }); + } + } else { + d.close(); + } + }); + + dialog.present(); +} + +fn show_generate_dialog(parent: >k4::ApplicationWindow, on_save: Rc) { + let dialog = gtk4::Dialog::builder() + .transient_for(parent) + .modal(true) + .title("Generate SSH Key") + .default_width(350) + .build(); + + let content = dialog.content_area(); + content.set_margin_top(12); content.set_margin_bottom(12); + content.set_margin_start(12); content.set_margin_end(12); + content.set_spacing(12); + + let name_entry = gtk4::Entry::builder().placeholder_text("Key Name (e.g. id_ed25519_mykey)").build(); + let pass_entry = gtk4::PasswordEntry::builder().placeholder_text("Passphrase (optional)").show_peek_icon(true).build(); + let comment_entry = gtk4::Entry::builder().placeholder_text("Comment (optional, e.g. user@hostname)").build(); + + content.append(>k4::Label::builder().label("Key Filename").halign(gtk4::Align::Start).build()); + content.append(&name_entry); + content.append(>k4::Label::builder().label("Passphrase").halign(gtk4::Align::Start).build()); + content.append(&pass_entry); + content.append(>k4::Label::builder().label("Comment").halign(gtk4::Align::Start).build()); + content.append(&comment_entry); + + let ok_btn = dialog.add_button("Generate", gtk4::ResponseType::Ok); + ok_btn.set_sensitive(false); + dialog.add_button("Cancel", gtk4::ResponseType::Cancel); + + let ok_rc = ok_btn.clone(); + name_entry.connect_changed(move |e| { + ok_rc.set_sensitive(!e.text().is_empty()); + }); + + dialog.connect_response(move |d, res| { + if res == gtk4::ResponseType::Ok { + let name = name_entry.text().to_string(); + let pass = pass_entry.text().to_string(); + let comment = comment_entry.text().to_string(); + + if !is_valid_key_name(&name) { + let alert = make_error_alert( + d.transient_for().as_ref().map(|w| w.upcast_ref()), + "Invalid Key Name", + "The key name must be a simple filename with no path separators or special components.", + ); + alert.present(); + return; + } + if let Some(ssh_dir) = get_ssh_dir() { + let file_path = ssh_dir.join(&name); + let pub_path = ssh_dir.join(format!("{}.pub", name)); + + if file_path.exists() || pub_path.exists() { + let alert = make_error_alert( + d.transient_for().as_ref().map(|w| w.upcast_ref()), + "Key Already Exists", + &format!("A file named '{}' or its public key already exists in ~/.ssh/. Choose a different name.", name), + ); + alert.present(); + return; + } + + let parent_win = d.transient_for() + .and_then(|w| w.downcast::().ok()); + d.close(); + + let on_save_spawn = on_save.clone(); + glib::MainContext::default().spawn_local(async move { + let result = tokio::task::spawn_blocking(move || { + let mut cmd = std::process::Command::new("ssh-keygen"); + cmd.arg("-t").arg("ed25519") + .arg("-f").arg(&file_path) + .arg("-N").arg(&pass) + .arg("-q"); + if !comment.is_empty() { + cmd.arg("-C").arg(&comment); + } + cmd.output() + }).await; + + let (success, stderr_msg) = match result { + Ok(Ok(output)) => (output.status.success(), String::from_utf8_lossy(&output.stderr).to_string()), + Ok(Err(e)) => (false, e.to_string()), + Err(e) => (false, e.to_string()), + }; + + if success { + on_save_spawn(); + } else { + let secondary = if stderr_msg.is_empty() { + "ssh-keygen exited with a non-zero status.".to_string() + } else { + stderr_msg + }; + let alert = make_error_alert( + parent_win.as_ref().map(|w| w.upcast_ref()), + "Key Generation Failed!", + &secondary, + ); + alert.present(); + } + }); + } + } else { + d.close(); + } + }); + + dialog.present(); +} + +fn show_import_dialog(parent: >k4::ApplicationWindow, on_save: Rc) { + let dialog = gtk4::Dialog::builder() + .transient_for(parent) + .modal(true) + .title("Import Private Key") + .default_width(450) + .default_height(400) + .build(); + + let content = dialog.content_area(); + content.set_margin_top(12); content.set_margin_bottom(12); + content.set_margin_start(12); content.set_margin_end(12); + content.set_spacing(12); + + let name_entry = gtk4::Entry::builder().placeholder_text("Key Name (e.g. id_rsa)").build(); + let text_buffer = gtk4::TextBuffer::new(None); + let text_view = gtk4::TextView::builder() + .buffer(&text_buffer) + .monospace(true) + .vexpand(true) + .build(); + let scrolled = gtk4::ScrolledWindow::builder() + .child(&text_view) + .min_content_height(250) + .vexpand(true) + .build(); + + content.append(>k4::Label::builder().label("Key Filename").halign(gtk4::Align::Start).build()); + content.append(&name_entry); + content.append(>k4::Label::builder().label("Paste Private Key").halign(gtk4::Align::Start).build()); + content.append(&scrolled); + + let ok_btn = dialog.add_button("Import", gtk4::ResponseType::Ok); + ok_btn.set_sensitive(false); + dialog.add_button("Cancel", gtk4::ResponseType::Cancel); + + let ok_rc = ok_btn.clone(); + name_entry.connect_changed(move |e| { + ok_rc.set_sensitive(!e.text().is_empty()); + }); + + dialog.connect_response(move |d, res| { + if res == gtk4::ResponseType::Ok { + let name = name_entry.text().to_string(); + let (start, end) = text_buffer.bounds(); + let key_content = text_buffer.text(&start, &end, false).to_string(); + + if !is_valid_key_name(&name) { + let alert = make_error_alert( + d.transient_for().as_ref().map(|w| w.upcast_ref()), + "Invalid Key Name", + "The key name must be a simple filename with no path separators or special components.", + ); + alert.present(); + return; + } + if let Some(ssh_dir) = get_ssh_dir() { + let file_path = ssh_dir.join(&name); + let pub_path = ssh_dir.join(format!("{}.pub", name)); + + if file_path.exists() || pub_path.exists() { + let alert = make_error_alert( + d.transient_for().as_ref().map(|w| w.upcast_ref()), + "Key Already Exists", + &format!("A file named '{}' or its public key already exists in ~/.ssh/.", name), + ); + alert.present(); + return; + } + + #[cfg(unix)] + let write_result = { + use std::os::unix::fs::OpenOptionsExt; + std::fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .mode(0o600) + .open(&file_path) + .and_then(|mut f| { + use std::io::Write; + f.write_all(key_content.as_bytes()) + }) + }; + #[cfg(not(unix))] + let write_result = std::fs::write(&file_path, &key_content); + + if let Err(e) = write_result { + let alert = make_error_alert( + d.transient_for().as_ref().map(|w| w.upcast_ref()), + "Failed to Write Key File", + &e.to_string(), + ); + alert.present(); + return; + } + + let parent_win = d.transient_for() + .and_then(|w| w.downcast::().ok()); + d.close(); + + let on_save_spawn = on_save.clone(); + glib::MainContext::default().spawn_local(async move { + let pub_path = ssh_dir.join(format!("{}.pub", name)); + let file_path_cleanup = ssh_dir.join(&name); + let file_path_keygen = ssh_dir.join(&name); + let result = tokio::task::spawn_blocking(move || { + std::process::Command::new("ssh-keygen") + .arg("-y") + .arg("-f").arg(&file_path_keygen) + .output() + }).await; + + match result { + Ok(Ok(output)) if output.status.success() => { + if let Err(e) = std::fs::write(&pub_path, output.stdout) { + let _ = std::fs::remove_file(&file_path_cleanup); + let alert = make_error_alert( + parent_win.as_ref().map(|w| w.upcast_ref()), + "Failed to Write Public Key", + &e.to_string(), + ); + alert.present(); + } else { + on_save_spawn(); + } + } + Ok(Ok(output)) => { + let _ = std::fs::remove_file(&file_path_cleanup); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let secondary = if stderr.is_empty() { + "Check if the pasted key is a valid private key or if it is encrypted.".to_string() + } else { + stderr + }; + let alert = make_error_alert( + parent_win.as_ref().map(|w| w.upcast_ref()), + "Key Import Failed!", + &secondary, + ); + alert.present(); + } + Ok(Err(e)) => { + let _ = std::fs::remove_file(&file_path_cleanup); + let alert = make_error_alert( + parent_win.as_ref().map(|w| w.upcast_ref()), + "Key Import Failed!", + &e.to_string(), + ); + alert.present(); + } + Err(e) => { + let _ = std::fs::remove_file(&file_path_cleanup); + let alert = make_error_alert( + parent_win.as_ref().map(|w| w.upcast_ref()), + "Key Import Failed!", + &e.to_string(), + ); + alert.present(); + } + } + }); + } + } else { + d.close(); + } + }); + + dialog.present(); +} \ No newline at end of file diff --git a/src/ui/window.rs b/src/ui/window.rs index ab0fe7a..1d7c282 100644 --- a/src/ui/window.rs +++ b/src/ui/window.rs @@ -3,6 +3,7 @@ use gtk4::{glib, gio}; use crate::ui::server_list::{ServerList, ServerAction}; use crate::ui::add_server_dialog::show_server_dialog; use crate::ui::file_explorer::FileExplorer; +use crate::ui::ssh_keys::build_ssh_keys_ui; use crate::config_observer::{add_host_to_config, delete_host_from_config, load_hosts}; use vte4::prelude::*; use std::rc::Rc; @@ -78,7 +79,6 @@ pub fn build_ui(app: >k4::Application) { let window_clone = window.clone(); let notebook_clone = notebook.clone(); let refresh_ui_weak = Rc::downgrade(&refresh_ui); - notebook.connect_switch_page(move |nb, _, _| { *last_pg.borrow_mut() = nb.current_page().unwrap_or(0); @@ -90,10 +90,6 @@ pub fn build_ui(app: >k4::Application) { } }); - - - - let do_refresh = { let sc = stack_clone.clone(); let wc = window_clone.clone(); @@ -114,17 +110,14 @@ pub fn build_ui(app: >k4::Application) { let window = sl_window.clone(); let notebook = sl_notebook.clone(); let refresh = sl_refresh_handle.borrow().as_ref().unwrap().clone(); - match action { ServerAction::Connect(host) => { stack.set_visible_child_name("sessions"); - let session_box = gtk4::Box::new(gtk4::Orientation::Vertical, 0); let toolbar = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); toolbar.set_margin_top(4); toolbar.set_margin_bottom(4); toolbar.set_margin_start(6); - let explorer_btn = gtk4::Button::from_icon_name("folder-remote-symbolic"); explorer_btn.add_css_class("flat"); explorer_btn.set_tooltip_text(Some("File Explorer")); @@ -162,7 +155,6 @@ pub fn build_ui(app: >k4::Application) { close_btn.add_css_class("flat"); tab_label_box.append(&label); tab_label_box.append(&close_btn); - let mut insert_pos = notebook.n_pages(); for i in 0..notebook.n_pages() { if let Some(c) = notebook.nth_page(Some(i)) @@ -171,7 +163,6 @@ pub fn build_ui(app: >k4::Application) { notebook.insert_page(&session_box, Some(&tab_label_box), Some(insert_pos)); notebook.set_tab_reorderable(&session_box, true); notebook.set_current_page(Some(insert_pos)); - let nb_close = notebook.clone(); let sb_close = session_box.clone(); @@ -194,7 +185,6 @@ pub fn build_ui(app: >k4::Application) { let h_alias = h_exp.alias.clone(); let nb_spawn = nb_exp.clone(); - glib::MainContext::default().spawn_local(async move { let mut password = None; if let Ok(keyring) = oo7::Keyring::new().await { @@ -251,20 +241,34 @@ pub fn build_ui(app: >k4::Application) { }); }); - - let host_str = host.hostname.clone(); let user_str = host.user.clone().unwrap_or_else(|| "root".to_string()); let host_alias = host.alias.clone(); let exe_path = std::env::current_exe().unwrap_or_default().to_string_lossy().to_string(); let mut envv: Vec = std::env::vars().map(|(k, v)| format!("{}={}", k, v)).collect(); - envv.push(format!("SSH_ASKPASS={}", exe_path)); - envv.push("SSH_ASKPASS_REQUIRE=force".to_string()); - envv.push(format!("RUSTMIUS_ASKPASS_ALIAS={}", host_alias)); + if host.identity_file.is_none() { + envv.push(format!("SSH_ASKPASS={}", exe_path)); + envv.push("SSH_ASKPASS_REQUIRE=force".to_string()); + envv.push(format!("RUSTMIUS_ASKPASS_ALIAS={}", host_alias)); + } envv.push("DISPLAY=:0".to_string()); let env_refs: Vec<&str> = envv.iter().map(|s| s.as_str()).collect(); let port_str = host.port.unwrap_or(22).to_string(); - terminal.spawn_async(vte4::PtyFlags::DEFAULT, None, &["/usr/bin/ssh", "-p", &port_str, "-o", "StrictHostKeyChecking=no", "-o", "PubkeyAuthentication=no", &format!("{}@{}", user_str, host_str)], &env_refs, glib::SpawnFlags::SEARCH_PATH, || {}, -1, None::<&gio::Cancellable>, |_| {}); + let mut ssh_args = vec![ + "/usr/bin/ssh".to_string(), + "-p".to_string(), + port_str, + "-o".to_string(), + "StrictHostKeyChecking=no".to_string(), + ]; + if let Some(identity_file) = &host.identity_file { + ssh_args.push("-i".to_string()); + ssh_args.push(identity_file.clone()); + } + ssh_args.push(format!("{}@{}", user_str, host_str)); + let ssh_args_refs: Vec<&str> = ssh_args.iter().map(|s| s.as_str()).collect(); + + terminal.spawn_async(vte4::PtyFlags::DEFAULT, None, &ssh_args_refs, &env_refs, glib::SpawnFlags::SEARCH_PATH, || {}, -1, None::<&gio::Cancellable>, |_| {}); }, ServerAction::Delete(host) => { let _ = delete_host_from_config(&host.alias); @@ -308,12 +312,10 @@ pub fn build_ui(app: >k4::Application) { if let Some(c) = notebook.nth_page(Some(i)) && c.widget_name() == "server_list_tab" { server_list_idx = Some(i); break; } } - sl.container.set_widget_name("server_list_tab"); let tab_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); tab_box.append(>k4::Image::from_icon_name("view-grid-symbolic")); tab_box.append(>k4::Label::new(Some("Connect"))); - if let Some(idx) = server_list_idx { notebook.remove_page(Some(idx)); notebook.insert_page(&sl.container, Some(&tab_box), Some(idx)); @@ -351,16 +353,7 @@ pub fn build_ui(app: >k4::Application) { let stack_nav_settings = stack.clone(); btn_settings.connect_clicked(move |_| { stack_nav_settings.set_visible_child_name("settings"); }); - let keys_box = gtk4::Box::new(gtk4::Orientation::Vertical, 24); - keys_box.set_margin_top(48); keys_box.set_margin_bottom(48); keys_box.set_margin_start(48); keys_box.set_margin_end(48); - keys_box.set_halign(gtk4::Align::Center); keys_box.set_valign(gtk4::Align::Center); - let wip_icon = gtk4::Image::from_icon_name("system-shutdown-symbolic"); - wip_icon.set_pixel_size(96); wip_icon.add_css_class("dim-label"); - let wip_label = gtk4::Label::new(Some("SSH Keys Management - WIP")); - wip_label.add_css_class("title-1"); - let wip_subtitle = gtk4::Label::new(Some("This feature is under development")); - wip_subtitle.add_css_class("dim-label"); wip_subtitle.add_css_class("title-4"); - keys_box.append(&wip_icon); keys_box.append(&wip_label); keys_box.append(&wip_subtitle); + let keys_box = build_ssh_keys_ui(&window); stack.add_named(&keys_box, Some("ssh_keys")); let settings_box = gtk4::Box::new(gtk4::Orientation::Vertical, 24); @@ -401,4 +394,4 @@ pub fn build_ui(app: >k4::Application) { root.append(&sidebar); root.append(&separator); root.append(&content_box); window.set_child(Some(&root)); window.present(); -} +} \ No newline at end of file