//! Profile editor (right dock): create and edit every protocol's settings. A port of the //! `profiles.js` drawer plus the Serial protocol. Switching protocol keeps the common fields //! (including the group), and list editors keep unsaved edits. use crate::state::{Request, Shared}; use crate::theme::{self, Tokens}; use crate::widgets; use egui::{Align, Button, ComboBox, DragValue, Layout, Response, RichText, Stroke, TextEdit, Ui, vec2}; use egui_phosphor::regular as icon; use nexus_core::clients::serial; use nexus_core::keyfiles; use nexus_core::models::*; use nexus_core::validation::{ValidationError, validate_profile}; use std::collections::{BTreeMap, HashMap}; use std::path::Path; use std::sync::{Arc, Mutex}; type KeyChecks = HashMap>; pub struct ProfileEditor { open: bool, is_new: bool, p: Profile, // Text buffers parsed on save. tags: String, env: String, rooms: String, /// Secrets typed into the editor, by session-manager kind ("password", "github-token", …); /// stored when the profile is saved. secrets: BTreeMap<&'static str, String>, totp: String, totp_show: bool, totp_saved: bool, /// Text shown on the TOTP Test button until the given time. totp_flash: Option<(String, f64)>, keys: Vec, folders: Vec, ports: Vec, key_checks: KeyChecks, /// Inline validation shows after the first Save/Test attempt. show_errors: bool, confirm_delete: bool, testing: bool, test: Arc>>>, } impl Default for ProfileEditor { fn default() -> Self { ProfileEditor { open: false, is_new: true, p: Profile::new(Protocol::Ssh), tags: String::new(), env: String::new(), rooms: String::new(), secrets: BTreeMap::new(), totp: String::new(), totp_show: false, totp_saved: false, totp_flash: None, keys: vec![], folders: vec![], ports: vec![], key_checks: HashMap::new(), show_errors: false, confirm_delete: false, testing: false, test: Default::default(), } } } impl ProfileEditor { pub fn open_new(&mut self, s: &Shared, protocol: Option, folder: &str) { let proto = protocol.or_else(|| Protocol::from_id(&s.prefs.default_protocol)).filter(|p| *p != Protocol::Local).unwrap_or_default(); let mut p = Profile::new(proto); p.folder = folder.to_string(); self.load(s, p, true); } pub fn open_edit(&mut self, s: &Shared, id: &str) { if let Some(p) = s.profile(id) { self.load(s, p.clone(), false); } } pub fn is_open(&self) -> bool { self.open } fn load(&mut self, s: &Shared, mut p: Profile, is_new: bool) { prep(&mut p); self.tags = p.tags.join(", "); self.env = p.ssh.as_ref().map(|c| c.env_vars.iter().map(|(k, v)| format!("{k}={v}")).collect::>().join("\n")).unwrap_or_default(); self.rooms = p.irc.as_ref().map(|c| c.auto_join_rooms.join("\n")).unwrap_or_default(); self.totp_saved = !is_new && s.svc.has_totp(&p.id); self.p = p; self.is_new = is_new; self.secrets.clear(); self.totp.clear(); self.totp_show = false; self.totp_flash = None; self.keys = keyfiles::list_ssh_keys().unwrap_or_default().iter().map(|k| k.display().to_string()).collect(); let mut folders: Vec = s.profiles.iter().map(|p| p.folder.trim().to_string()).filter(|f| !f.is_empty()).collect(); folders.sort_by_key(|f| f.to_lowercase()); folders.dedup(); self.folders = folders; self.ports = serial::list_ports(); self.key_checks.clear(); self.show_errors = false; self.confirm_delete = false; self.open = true; } /// Keeps the common fields (folder included) and resets the port to the new protocol's /// default. Protocol configs edited earlier in this session are kept until save. fn switch(&mut self, proto: Protocol) { if self.p.protocol == proto { return; } let d = Profile::new(proto); let p = &mut self.p; p.protocol = proto; p.port = d.port; if proto == Protocol::Github && p.host.trim().is_empty() { p.host = d.host; } p.ssh = p.ssh.take().or(d.ssh); p.telnet = p.telnet.take().or(d.telnet); p.raw = p.raw.take().or(d.raw); p.github = p.github.take().or(d.github); p.vnc = p.vnc.take().or(d.vnc); p.irc = p.irc.take().or(d.irc); p.serial = p.serial.take().or(d.serial); prep(p); } /// The profile as it would be saved: buffers parsed, other protocols' configs dropped. fn collect(&self) -> Profile { let mut p = self.p.clone(); let proto = p.protocol; if proto != Protocol::Ssh { p.ssh = None; } if proto != Protocol::Telnet { p.telnet = None; } if proto != Protocol::Raw { p.raw = None; } if proto != Protocol::Github { p.github = None; } if proto != Protocol::Vnc { p.vnc = None; } if proto != Protocol::Irc { p.irc = None; } if proto != Protocol::Serial { p.serial = None; } p.folder = p.folder.trim().to_string(); p.host = p.host.trim().to_string(); p.tags = self.tags.split(',').map(str::trim).filter(|t| !t.is_empty()).map(String::from).collect(); if let Some(c) = &mut p.ssh { c.env_vars = parse_env(&self.env); if c.proxy_type == "none" { c.proxy_type.clear(); } } if let Some(c) = &mut p.irc { c.auto_join_rooms = self.rooms.split(['\n', ',']).map(str::trim).filter(|r| !r.is_empty()).map(String::from).collect(); } p } fn validate(&self) -> Vec { let mut p = self.collect(); p.normalize(); validate_profile(&p).err().map(|e| e.errors).unwrap_or_default() } /// Drawn inside the right dock while open. pub fn ui(&mut self, ui: &mut Ui, s: &mut Shared) { let t = Tokens::get(ui.ctx()); if let Some(r) = self.test.lock().unwrap().take() { self.testing = false; match r { Ok(ms) => s.success(format!("Reachable — {ms} ms")), Err(e) => s.error(format!("Test failed: {e}")), } } ui.horizontal(|ui| { ui.label(RichText::new(if self.is_new { "New Connection" } else { "Edit Connection" }).font(theme::semibold(15.0))); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { if widgets::icon_button(ui, icon::X, "Close").clicked() { self.open = false; } }); }); ui.horizontal_wrapped(|ui| { for proto in Protocol::SAVED { if ui.selectable_label(self.p.protocol == proto, proto.label()).clicked() { self.switch(proto); } } }); ui.add_space(2.0); let errs = if self.show_errors { self.validate() } else { vec![] }; egui::Panel::bottom("profile-editor-footer") .resizable(false) .frame(egui::Frame::NONE.fill(t.dock).inner_margin(egui::Margin::symmetric(0, 6))) .show(ui, |ui| self.footer(ui, s, &errs)); egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { ui.add_space(4.0); self.general(ui, &errs); match self.p.protocol { Protocol::Ssh => self.ssh(ui, s, &errs), Protocol::Telnet => self.telnet(ui), Protocol::Raw => self.raw(ui, &errs), Protocol::Github => self.github(ui, &errs), Protocol::Vnc => self.vnc(ui, &errs), Protocol::Irc => self.irc(ui, &errs), Protocol::Serial => self.serial(ui, &errs), Protocol::Local => {} } if matches!(self.p.protocol, Protocol::Ssh | Protocol::Telnet | Protocol::Irc | Protocol::Serial) { self.terminal(ui, &errs); } let (p, tags) = (&mut self.p, &mut self.tags); section(ui, "Notes & Tags", |ui| { text(ui, "Tags", tags, "production, london, vpn"); widgets::hint(ui, "Comma-separated tags for filtering"); label(ui, "Notes"); ui.add(TextEdit::multiline(&mut p.notes).hint_text("Free-form notes…").desired_rows(4).desired_width(f32::INFINITY)); }); }); } fn footer(&mut self, ui: &mut Ui, s: &mut Shared, errs: &[ValidationError]) { let t = Tokens::get(ui.ctx()); if !errs.is_empty() { let all = errs.iter().map(|e| e.message.clone()).collect::>().join("\n"); let n = errs.len(); ui.label( RichText::new(format!("{} {n} problem{} — see the highlighted fields", icon::WARNING_CIRCLE, if n == 1 { "" } else { "s" })) .color(t.danger) .size(12.0), ) .on_hover_text(all); } if self.confirm_delete { ui.horizontal(|ui| { ui.label(RichText::new(format!("Delete \"{}\"? This can't be undone.", self.p.display_name)).color(t.danger)); }); ui.horizontal(|ui| { if ui.add(Button::new(RichText::new(format!("{} Delete", icon::TRASH)).color(t.accent_text)).fill(t.danger)).clicked() { match s.svc.delete_profile(&self.p.id) { Ok(()) => { s.success("Profile deleted"); s.request(Request::ReloadProfiles); self.open = false; } Err(e) => s.error(format!("Failed to delete profile: {e}")), } self.confirm_delete = false; } if ui.button("Keep").clicked() { self.confirm_delete = false; } }); return; } ui.horizontal(|ui| { if !self.is_new && ui.add(Button::new(RichText::new(icon::TRASH).color(t.danger))).on_hover_text("Delete profile").clicked() { self.confirm_delete = true; } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { let connect = if self.is_new { "Create & Connect" } else { "Save & Connect" }; if widgets::primary(ui, icon::PLUGS_CONNECTED, connect).clicked() { self.save(s, true); } if widgets::button(ui, icon::FLOPPY_DISK, if self.is_new { "Create" } else { "Save" }).clicked() { self.save(s, false); } let can_test = !self.testing && self.p.protocol != Protocol::Serial; if ui.add_enabled(can_test, Button::new(format!("{} {}", icon::PULSE, if self.testing { "Testing…" } else { "Test" }))).clicked() { self.test_connection(s); } if ui.button("Cancel").clicked() { self.open = false; } }); }); } fn save(&mut self, s: &mut Shared, and_connect: bool) { self.show_errors = true; let errs = self.validate(); if let Some(e) = errs.first() { s.warn(format!("Please fix the highlighted fields ({}).", e.message)); return; } let p = self.collect(); let r = if self.is_new { s.svc.create_profile(p) } else { s.svc.update_profile(p) }; match r { Err(e) => s.error(format!("Save failed: {e}")), Ok(saved) => { for (kind, v) in &self.secrets { if !v.is_empty() && let Err(e) = s.svc.set_profile_secret(&saved.id, kind, v) { s.error(format!("Couldn't store the {kind} secret: {e}")); } } s.success(if self.is_new { "Profile created" } else { "Profile updated" }); s.request(Request::ReloadProfiles); self.open = false; if and_connect { s.request(Request::Connect(saved.id)); } } } } fn test_connection(&mut self, s: &mut Shared) { self.show_errors = true; let p = self.collect(); let (host, port) = match &p.vnc { Some(c) if p.protocol == Protocol::Vnc => (if c.vnc_host.trim().is_empty() { p.host.clone() } else { c.vnc_host.trim().to_string() }, c.vnc_port), _ => (p.host.clone(), p.port), }; s.info(format!("Testing connection to {host}:{port}…")); self.testing = true; let (slot, svc, ctx) = (self.test.clone(), s.svc.clone(), s.ctx.clone()); std::thread::spawn(move || { let r = svc.test_connection(&host, port).map_err(|e| e.to_string()); *slot.lock().unwrap() = Some(r); ctx.request_repaint(); }); } // --- sections --------------------------------------------------------------------------- fn general(&mut self, ui: &mut Ui, errs: &[ValidationError]) { let Self { p, folders, ports, .. } = self; section(ui, "General", |ui| { text(ui, "Display Name", &mut p.display_name, "My Server"); if p.protocol == Protocol::Serial { label(ui, "Device"); let mut refresh = false; suggest(ui, &mut p.host, "/dev/ttyUSB0, /dev/cu.usbserial…, COM3", ports, |ui, _| { refresh = widgets::icon_button(ui, icon::ARROWS_CLOCKWISE, "Refresh the device list").clicked(); }); if refresh { *ports = serial::list_ports(); } if ports.is_empty() { widgets::hint(ui, "No serial devices found. Type a device path or plug one in and refresh."); } err(ui, errs, "host"); } else { let hint = if p.protocol == Protocol::Github { "github.com" } else { "192.168.1.1 or example.com" }; text(ui, "Host", &mut p.host, hint); err(ui, errs, "host"); if p.protocol != Protocol::Github { num(ui, "Port", &mut p.port, 1..=65535); err(ui, errs, "port"); text(ui, "Username", &mut p.username, "root"); err(ui, errs, "username"); } } label(ui, "Group"); suggest(ui, &mut p.folder, "e.g. Production, Staging, Home Lab", folders, |_, _| {}); widgets::hint(ui, "Choose an existing group or type a new name to create one"); text(ui, "Description", &mut p.description, "Optional description"); err(ui, errs, "displayName"); }); } fn ssh(&mut self, ui: &mut Ui, s: &mut Shared, errs: &[ValidationError]) { let Self { p, keys, key_checks, secrets, env, totp, totp_show, totp_saved, totp_flash, is_new, .. } = self; let id = p.id.clone(); let c = p.ssh.get_or_insert_with(SshConfig::defaults); section(ui, "Authentication", |ui| { mode(ui, "Password Mode", &mut c.secret_mode); if c.secret_mode == SecretMode::Saved { secret(ui, "Password", secrets, "password", "Leave blank to keep the saved password"); } label(ui, "Auth Methods"); ui.horizontal_wrapped(|ui| { for (m, l) in [("publickey", "Public key"), ("password", "Password"), ("keyboard-interactive", "Keyboard-interactive")] { let mut on = c.auth_methods.iter().any(|x| x == m); if ui.checkbox(&mut on, l).changed() { if on { c.auth_methods.push(m.into()); } else { c.auth_methods.retain(|x| x != m); } } } }); err(ui, errs, "ssh.authMethods"); }); section(ui, "Key & Passphrase", |ui| { label(ui, "Private Key Path"); key_path(ui, &mut c.private_key_path, keys, key_checks, "/Users/username/.ssh/id_ed25519"); widgets::hint(ui, "Select a key from your .ssh folder or type a full path"); mode(ui, "Passphrase Mode", &mut c.key_secret_mode); if c.key_secret_mode == SecretMode::Saved { secret(ui, "Passphrase", secrets, "passphrase", "Leave blank to keep the saved passphrase"); } }); section(ui, "Host Verification", |ui| { let opts: Vec<_> = HostKeyPolicy::ALL.iter().map(|k| (*k, k.label())).collect(); combo(ui, "Host Key Policy", &mut c.host_key_policy, &opts); }); section(ui, "Jump Hosts", |ui| { let n = c.jump_hosts.len(); let mut act = None; for (i, jh) in c.jump_hosts.iter_mut().enumerate() { item(ui, |ui| { rtl(ui, |ui| { act = row_buttons(ui, i, n).or(act.take()); ui.add(TextEdit::singleline(&mut jh.username).hint_text("User").desired_width(ui.available_width() * 0.35)); ui.add(DragValue::new(&mut jh.port).range(1..=65535)); ui.add(TextEdit::singleline(&mut jh.host).hint_text("Host").desired_width(ui.available_width())); }); key_path(ui, &mut jh.private_key_path, keys, key_checks, "Full key path (optional)"); }); } apply(&mut c.jump_hosts, act); list_errs(ui, errs, "ssh.jumpHosts"); if ui.button(format!("{} Add Jump Host", icon::PLUS)).clicked() { c.jump_hosts.push(JumpHost { port: 22, auth_method: "publickey".into(), secret_mode: SecretMode::AskEveryTime, ..Default::default() }); } }); section(ui, "Port Forwarding", |ui| { let n = c.port_forwards.len(); let mut act = None; for (i, pf) in c.port_forwards.iter_mut().enumerate() { item(ui, |ui| { ui.horizontal(|ui| { let opts = [(ForwardDirection::Local, "Local →"), (ForwardDirection::Remote, "← Remote")]; ComboBox::from_id_salt(("pf-dir", i)) .width(84.0) .selected_text(if pf.direction == ForwardDirection::Local { opts[0].1 } else { opts[1].1 }) .show_ui(ui, |ui| { for (v, l) in opts { ui.selectable_value(&mut pf.direction, v, l); } }); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { act = row_buttons(ui, i, n).or(act.take()); ui.checkbox(&mut pf.enabled, "Active").on_hover_text("Start on connect"); ui.add(TextEdit::singleline(&mut pf.label).hint_text("Label (optional)").desired_width(ui.available_width())); }); }); rtl(ui, |ui| { // Two host fields share what the ports and arrow leave. let w = ((ui.available_width() - 150.0) / 2.0).max(40.0); ui.add(DragValue::new(&mut pf.dest_port).range(1..=65535)); ui.add(TextEdit::singleline(&mut pf.dest_host).hint_text("Dest host").desired_width(w)); ui.label("⟶"); ui.add(DragValue::new(&mut pf.bind_port).range(1..=65535)); ui.add(TextEdit::singleline(&mut pf.bind_host).hint_text("Bind host").desired_width(ui.available_width())); }); }); } apply(&mut c.port_forwards, act); list_errs(ui, errs, "ssh.portForwards"); if ui.button(format!("{} Add Port Forward", icon::PLUS)).clicked() { c.port_forwards.push(PortForward { id: new_id(), bind_host: "127.0.0.1".into(), bind_port: 1, dest_port: 1, enabled: true, ..Default::default() }); } }); section(ui, "Startup", |ui| { text(ui, "Startup Command", &mut c.startup_command, "Optional command to run after connecting"); label(ui, "Environment Variables"); ui.add( TextEdit::multiline(env).hint_text("KEY=VALUE (one per line)").desired_rows(3).desired_width(f32::INFINITY).font(egui::TextStyle::Monospace), ); }); section(ui, "Login Script", |ui| login_script(ui, "ssh-login", &mut c.login_scripts)); section(ui, "SSH Agent", |ui| { check(ui, "Use SSH Agent for Authentication", &mut c.agent_auth, "Authenticate using keys from the local SSH agent (ssh-agent)"); check(ui, "Forward Agent to Remote", &mut c.agent_forwarding, "Allow the remote host to use your local SSH agent keys (equivalent to ssh -A)"); }); section(ui, "TOTP / 2FA", |ui| { widgets::hint(ui, "Configure a TOTP secret to auto-fill 2FA codes during SSH keyboard-interactive authentication. The secret is stored encrypted."); label(ui, "TOTP Secret"); let hint = if *totp_saved { "••••••••• (secret saved)" } else { "Base32-encoded secret (e.g. from authenticator app setup)" }; rtl(ui, |ui| { if widgets::icon_button(ui, if *totp_show { icon::EYE_SLASH } else { icon::EYE }, "Show/hide secret").clicked() { *totp_show = !*totp_show; } ui.add(TextEdit::singleline(totp).password(!*totp_show).hint_text(hint).desired_width(ui.available_width())); }); ui.horizontal(|ui| { let flash = totp_flash.as_ref().filter(|(_, until)| *until > s.now).map(|(t, _)| t.clone()); if let Some((_, until)) = totp_flash { ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64((*until - s.now).max(0.0))); } let test = ui .add(Button::new(RichText::new(flash.as_deref().unwrap_or("Test")).font(theme::mono(12.5)))) .on_hover_text("Generate a code with the saved secret"); if test.clicked() { *totp_flash = Some(match (*is_new, s.svc.totp_code(&id)) { (false, Ok(code)) => (code, s.now + 5.0), _ => ("Save first".into(), s.now + 2.0), }); } if ui.button("Save").on_hover_text("Encrypt and save the TOTP secret").clicked() { let v = totp.trim().to_string(); if *is_new { s.warn("Save the profile first, then set the TOTP secret."); } else if v.is_empty() { s.svc.delete_totp(&id); *totp_saved = false; s.info("TOTP secret removed."); } else { match s.svc.set_profile_secret(&id, "totp", &v) { Ok(()) => { totp.clear(); *totp_saved = true; s.success("TOTP secret saved securely."); } Err(e) => s.error(format!("Failed to save TOTP secret: {e}")), } } } if ui.add_enabled(!*is_new, Button::new("Delete")).on_hover_text("Remove the TOTP secret").clicked() { s.svc.delete_totp(&id); *totp_saved = false; s.info("TOTP secret removed."); } }); }); section(ui, "Proxy", |ui| { combo_s(ui, "Proxy Type", &mut c.proxy_type, &[("none", "None (direct connection)"), ("socks5", "SOCKS5"), ("http", "HTTP CONNECT")]); if c.proxy_type != "none" { text(ui, "Proxy Host", &mut c.proxy_host, "proxy.example.com"); num(ui, "Proxy Port", &mut c.proxy_port, 1..=65535); text(ui, "Proxy Username", &mut c.proxy_username, "Optional"); label(ui, "Proxy Password"); ui.add(TextEdit::singleline(&mut c.proxy_password).password(true).hint_text("Optional").desired_width(f32::INFINITY)); } }); section(ui, "Advanced", |ui| { text(ui, "Terminal Type", &mut c.terminal_type, DEFAULT_TERMINAL_TYPE); num(ui, "Keepalive Interval (s)", &mut c.keepalive_interval, 0..=86400); num(ui, "Max Missed Keepalives", &mut c.keepalive_count_max, 0..=1000); check(ui, "Compression", &mut c.compression, ""); reconnect(ui, &mut c.auto_reconnect, &mut c.max_reconnect_tries); }); } fn telnet(&mut self, ui: &mut Ui) { let c = self.p.telnet.get_or_insert_with(TelnetConfig::defaults); section(ui, "Login", |ui| { check(ui, "Local Echo", &mut c.local_echo, ""); check(ui, "Line Mode", &mut c.line_mode, ""); }); section(ui, "Terminal & Encoding", |ui| { text(ui, "Terminal Type", &mut c.terminal_type, DEFAULT_TERMINAL_TYPE); combo_s(ui, "Encoding", &mut c.encoding, ENCODINGS); }); section(ui, "Echo & Line Endings", |ui| line_ending(ui, &mut c.line_ending)); section(ui, "Negotiation", |ui| check(ui, "Debug Negotiation", &mut c.negotiation_debug, "")); section(ui, "Login Script", |ui| login_script(ui, "telnet-login", &mut c.login_scripts)); section(ui, "Reconnection", |ui| reconnect(ui, &mut c.auto_reconnect, &mut c.max_reconnect_tries)); } fn raw(&mut self, ui: &mut Ui, errs: &[ValidationError]) { let p = &mut self.p; let c = p.raw.get_or_insert_with(RawConfig::defaults); section(ui, "Encoding & Framing", |ui| { combo_s(ui, "Encoding", &mut c.encoding, &[("utf-8", "UTF-8"), ("ascii", "ASCII"), ("hex", "Hex")]); line_ending(ui, &mut c.line_ending); }); section(ui, "Display", |ui| { check(ui, "Show Timestamps", &mut c.show_timestamps, ""); check(ui, "Show Direction", &mut c.show_direction, ""); }); section(ui, "Payload Presets", |ui| { let n = c.payload_presets.len(); let mut act = None; for (i, ps) in c.payload_presets.iter_mut().enumerate() { rtl(ui, |ui| { act = row_buttons(ui, i, n).or(act.take()); ComboBox::from_id_salt(("preset-fmt", i)).width(76.0).selected_text(fmt_label(&ps.format)).show_ui(ui, |ui| { for f in ["text", "hex", "escaped"] { ui.selectable_value(&mut ps.format, f.to_string(), fmt_label(f)); } }); let w = (ui.available_width() - ui.spacing().item_spacing.x) * 0.6; ui.add(TextEdit::singleline(&mut ps.payload).hint_text("Payload").font(egui::TextStyle::Monospace).desired_width(w)); ui.add(TextEdit::singleline(&mut ps.name).hint_text("Name").desired_width(ui.available_width())); }); } apply(&mut c.payload_presets, act); list_errs(ui, errs, "raw.payloadPresets"); if ui.button(format!("{} Add Preset", icon::PLUS)).clicked() { c.payload_presets.push(PayloadPreset { id: new_id(), format: "text".into(), ..Default::default() }); } }); section(ui, "Timeouts & Reconnect", |ui| { num(ui, "Connect Timeout (s)", &mut p.connect_timeout, 0..=3600); num(ui, "Read Timeout (s)", &mut p.read_timeout, 0..=3600); check(ui, "Auto-reconnect", &mut c.auto_reconnect, ""); }); } fn github(&mut self, ui: &mut Ui, errs: &[ValidationError]) { let Self { p, secrets, .. } = self; let c = p.github.get_or_insert_with(GitHubConfig::defaults); section(ui, "API", |ui| { text(ui, "API Base URL", &mut c.base_url, "https://api.github.com"); err(ui, errs, "github.baseURL"); mode(ui, "Token Storage", &mut c.secret_mode); if c.secret_mode == SecretMode::Saved { secret(ui, "Personal Access Token", secrets, "github-token", "Leave blank to keep the saved token"); } }); section(ui, "Refresh", |ui| { num(ui, "Refresh Interval (seconds)", &mut c.refresh_interval_secs, 60..=3600); err(ui, errs, "github.refreshIntervalSecs"); }); } fn vnc(&mut self, ui: &mut Ui, errs: &[ValidationError]) { let Self { p, keys, key_checks, secrets, .. } = self; let c = p.vnc.get_or_insert_with(VncConfig::defaults); section(ui, "VNC Server", |ui| { text(ui, "VNC Host", &mut c.vnc_host, "192.168.1.100 (defaults to Host)"); err(ui, errs, "vnc.vncHost"); num(ui, "VNC Port", &mut c.vnc_port, 1..=65535); err(ui, errs, "vnc.vncPort"); mode(ui, "Password Storage", &mut c.secret_mode); if c.secret_mode == SecretMode::Saved { secret(ui, "VNC Password", secrets, "vnc-password", "Leave blank to keep the saved password"); } }); section(ui, "Display", |ui| { check(ui, "View Only", &mut c.view_only, ""); check(ui, "Scale to Fit", &mut c.scale_to_fit, ""); check(ui, "Shared Session", &mut c.shared_session, ""); num(ui, "Quality (0-9)", &mut c.quality, 0..=9); err(ui, errs, "vnc.quality"); }); section(ui, "SSH Tunnel", |ui| { tunnel( ui, "vnc", Tunnel { on: &mut c.ssh_tunnel, host: &mut c.ssh_host, port: &mut c.ssh_port, user: &mut c.ssh_username, auth: &mut c.ssh_auth_method, key: &mut c.ssh_private_key, agent: &mut c.ssh_agent_auth, mode: &mut c.ssh_secret_mode, }, keys, key_checks, secrets, errs, ); }); } fn irc(&mut self, ui: &mut Ui, errs: &[ValidationError]) { let Self { p, keys, key_checks, secrets, rooms, .. } = self; let port = &mut p.port; let c = p.irc.get_or_insert_with(IrcConfig::defaults); section(ui, "IRC", |ui| { text(ui, "Nick", &mut c.nick, "yourNick"); err(ui, errs, "irc.nick"); text(ui, "Real Name", &mut c.real_name, "Shown in WHOIS"); err(ui, errs, "irc.realName"); if ui.checkbox(&mut c.use_tls, "Use TLS").changed() { // Follow the protocol default unless the port was customised. match (c.use_tls, *port) { (true, p) if p == i64::from(DEFAULT_IRC_PORT) => *port = DEFAULT_IRC_TLS_PORT.into(), (false, p) if p == i64::from(DEFAULT_IRC_TLS_PORT) => *port = DEFAULT_IRC_PORT.into(), _ => {} } } widgets::hint(ui, "Use port 6697 for most TLS IRC networks."); check(ui, "Server Password", &mut c.server_password_required, "Prompt for an IRC PASS secret before registration."); if c.server_password_required { mode(ui, "Server Password Storage", &mut c.server_password_mode); if c.server_password_mode == SecretMode::Saved { secret(ui, "Server Password", secrets, "irc-server-password", "Leave blank to keep the saved password"); } } }); section(ui, "NickServ", |ui| { check(ui, "Identify on Connect", &mut c.nick_serv_identify, ""); if c.nick_serv_identify { text(ui, "Account", &mut c.nick_serv_account, "Optional if it matches your nick"); err(ui, errs, "irc.nickServAccount"); mode(ui, "Password Storage", &mut c.nick_serv_mode); if c.nick_serv_mode == SecretMode::Saved { secret(ui, "NickServ Password", secrets, "irc-nickserv-password", "Leave blank to keep the saved password"); } } }); section(ui, "Rooms", |ui| { label(ui, "Auto-Join Rooms"); ui.add(TextEdit::multiline(rooms).hint_text("#general\n#ops").desired_rows(4).desired_width(f32::INFINITY)); widgets::hint(ui, "One room per line or comma-separated."); let names: Vec<&str> = rooms.split(['\n', ',']).map(str::trim).filter(|r| !r.is_empty()).collect(); for e in errs.iter().filter(|e| e.field.starts_with("irc.autoJoinRooms")) { let room = index_of(&e.field).and_then(|i| names.get(i)).copied().unwrap_or(""); error_line(ui, &format!("{room}: {}", e.message)); } }); section(ui, "SSH Tunnel", |ui| { tunnel( ui, "irc", Tunnel { on: &mut c.ssh_tunnel, host: &mut c.ssh_host, port: &mut c.ssh_port, user: &mut c.ssh_username, auth: &mut c.ssh_auth_method, key: &mut c.ssh_private_key, agent: &mut c.ssh_agent_auth, mode: &mut c.ssh_secret_mode, }, keys, key_checks, secrets, errs, ); }); section(ui, "Reconnection", |ui| reconnect(ui, &mut c.auto_reconnect, &mut c.max_reconnect_tries)); } fn serial(&mut self, ui: &mut Ui, errs: &[ValidationError]) { let c = self.p.serial.get_or_insert_with(SerialConfig::default); section(ui, "Line Settings", |ui| { label(ui, "Baud Rate"); ui.horizontal(|ui| { ComboBox::from_id_salt("serial-baud") .selected_text(if BAUDS.contains(&c.baud_rate) { c.baud_rate.to_string() } else { "Custom".into() }) .show_ui(ui, |ui| { for b in BAUDS { ui.selectable_value(&mut c.baud_rate, b, b.to_string()); } }); ui.add(DragValue::new(&mut c.baud_rate).range(1..=20_000_000)).on_hover_text("Any rate the adapter supports"); }); err(ui, errs, "serial.baudRate"); combo(ui, "Data Bits", &mut c.data_bits, &[(5, "5"), (6, "6"), (7, "7"), (8, "8")]); err(ui, errs, "serial.dataBits"); combo(ui, "Parity", &mut c.parity, &[(Parity::None, "None"), (Parity::Odd, "Odd"), (Parity::Even, "Even")]); combo(ui, "Stop Bits", &mut c.stop_bits, &[(1, "1"), (2, "2")]); err(ui, errs, "serial.stopBits"); combo( ui, "Flow Control", &mut c.flow_control, &[(FlowControl::None, "None"), (FlowControl::Xonxoff, "XON/XOFF (software)"), (FlowControl::Rtscts, "RTS/CTS (hardware)")], ); }); section(ui, "Behaviour", |ui| { line_ending(ui, &mut c.line_ending); combo_s(ui, "Encoding", &mut c.encoding, ENCODINGS); check(ui, "Local Echo", &mut c.local_echo, "Show typed characters locally (for devices that don't echo)"); check(ui, "Assert DTR on connect", &mut c.dtr_on_connect, ""); check(ui, "Assert RTS on connect", &mut c.rts_on_connect, ""); reconnect(ui, &mut c.auto_reconnect, &mut c.max_reconnect_tries); widgets::hint(ui, "Reconnects when the device is plugged back in."); }); } fn terminal(&mut self, ui: &mut Ui, errs: &[ValidationError]) { let p = &mut self.p; section(ui, "Terminal", |ui| { text(ui, "Font Family", &mut p.terminal_font_family, DEFAULT_FONT_FAMILY); num(ui, "Font Size", &mut p.terminal_font_size, 8..=36); err(ui, errs, "terminalFontSize"); num(ui, "Scrollback Lines", &mut p.terminal_scrollback, 0..=100_000); err(ui, errs, "terminalScrollback"); combo_s(ui, "Cursor Style", &mut p.terminal_cursor_style, &[("block", "Block"), ("underline", "Underline"), ("bar", "Bar")]); }); } } const ENCODINGS: &[(&str, &str)] = &[("utf-8", "UTF-8"), ("ascii", "ASCII"), ("iso-8859-1", "ISO 8859-1")]; const BAUDS: [u32; 14] = [300, 1200, 2400, 4800, 9600, 14400, 19200, 38400, 57600, 115200, 230400, 460800, 921600, 1_000_000]; /// Fills the zero values the JS form defaulted (`|| 5`, `|| 1080`…), so range-clamped inputs /// start from the same numbers. fn prep(p: &mut Profile) { fn tries(n: &mut i64) { if *n <= 0 { *n = 5; } } fn port(n: &mut i64, d: i64) { if *n <= 0 { *n = d; } } fn steps(v: &mut [LoginScriptStep]) { for s in v.iter_mut().filter(|s| s.kind == "wait" && s.timeout <= 0) { s.timeout = 10; } } if p.terminal_font_size == 0 { p.terminal_font_size = DEFAULT_FONT_SIZE; } if p.terminal_font_family.is_empty() { p.terminal_font_family = DEFAULT_FONT_FAMILY.into(); } if p.terminal_cursor_style.is_empty() { p.terminal_cursor_style = DEFAULT_CURSOR_STYLE.into(); } if let Some(c) = &mut p.ssh { tries(&mut c.max_reconnect_tries); port(&mut c.proxy_port, 1080); if c.proxy_type.is_empty() { c.proxy_type = "none".into(); } for j in &mut c.jump_hosts { port(&mut j.port, 22); } steps(&mut c.login_scripts); } if let Some(c) = &mut p.telnet { tries(&mut c.max_reconnect_tries); steps(&mut c.login_scripts); } if let Some(c) = &mut p.irc { tries(&mut c.max_reconnect_tries); port(&mut c.ssh_port, 22); } if let Some(c) = &mut p.vnc { port(&mut c.vnc_port, DEFAULT_VNC_PORT.into()); port(&mut c.ssh_port, 22); } if let Some(c) = &mut p.github && c.refresh_interval_secs == 0 { c.refresh_interval_secs = 300; } if let Some(c) = &mut p.serial { tries(&mut c.max_reconnect_tries); } } fn parse_env(text: &str) -> BTreeMap { text.lines().filter_map(|l| l.split_once('=')).map(|(k, v)| (k.trim().to_string(), v.trim().to_string())).filter(|(k, _)| !k.is_empty()).collect() } /// "ssh.jumpHosts[2].host" → 2 fn index_of(field: &str) -> Option { field.split_once('[')?.1.split_once(']')?.0.parse().ok() } // --- form widgets ----------------------------------------------------------------------------- /// A collapsible card with a section header. fn section(ui: &mut Ui, title: &str, body: impl FnOnce(&mut Ui)) { let t = Tokens::get(ui.ctx()); egui::Frame::NONE.fill(t.card).stroke(Stroke::new(1.0, t.card_border)).corner_radius(6).inner_margin(10).show(ui, |ui| { ui.set_width(ui.available_width()); egui::CollapsingHeader::new(RichText::new(title.to_uppercase()).font(theme::semibold(10.5)).color(t.text_faint).extra_letter_spacing(0.6)) .id_salt(("pe-section", title)) .default_open(true) .show(ui, |ui| { ui.spacing_mut().item_spacing.y = 4.0; body(ui) }); }); ui.add_space(6.0); } /// A list entry (jump host, forward) on a faint background. fn item(ui: &mut Ui, body: impl FnOnce(&mut Ui)) { let t = Tokens::get(ui.ctx()); egui::Frame::NONE.fill(t.text.gamma_multiply(0.04)).corner_radius(4).inner_margin(6).show(ui, |ui| { ui.set_width(ui.available_width()); body(ui) }); } fn label(ui: &mut Ui, text: &str) { let t = Tokens::get(ui.ctx()); ui.add_space(2.0); ui.label(RichText::new(text).color(t.text_dim).size(12.0)); } fn error_line(ui: &mut Ui, msg: &str) { let t = Tokens::get(ui.ctx()); ui.label(RichText::new(format!("{} {msg}", icon::WARNING_CIRCLE)).color(t.danger).size(11.5)); } fn err(ui: &mut Ui, errs: &[ValidationError], field: &str) { for e in errs.iter().filter(|e| e.field == field) { error_line(ui, &e.message); } } /// Errors for a list's rows ("ssh.jumpHosts[1].port" → "Row 2: …"). fn list_errs(ui: &mut Ui, errs: &[ValidationError], prefix: &str) { for e in errs.iter().filter(|e| e.field.starts_with(prefix)) { match index_of(&e.field) { Some(i) => error_line(ui, &format!("Row {}: {}", i + 1, e.message)), None => error_line(ui, &e.message), } } } fn text(ui: &mut Ui, l: &str, v: &mut String, hint: &str) -> Response { label(ui, l); ui.add(TextEdit::singleline(v).hint_text(hint).desired_width(f32::INFINITY)) } fn num(ui: &mut Ui, l: &str, v: &mut i64, range: std::ops::RangeInclusive) { label(ui, l); ui.add(DragValue::new(v).range(range).speed(0.25)); } fn check(ui: &mut Ui, l: &str, v: &mut bool, help: &str) { ui.checkbox(v, l); if !help.is_empty() { widgets::hint(ui, help); } } fn combo(ui: &mut Ui, l: &str, v: &mut T, opts: &[(T, &str)]) { label(ui, l); let cur = opts.iter().find(|o| o.0 == *v).map_or("", |o| o.1); ComboBox::from_id_salt(("pe-combo", l)).selected_text(cur).width(ui.available_width().min(320.0)).show_ui(ui, |ui| { for (o, text) in opts { ui.selectable_value(v, *o, *text); } }); } fn combo_s(ui: &mut Ui, l: &str, v: &mut String, opts: &[(&str, &str)]) { label(ui, l); let cur = opts.iter().find(|o| o.0 == v).map_or(v.clone(), |o| o.1.to_string()); ComboBox::from_id_salt(("pe-combo", l)).selected_text(cur).width(ui.available_width().min(320.0)).show_ui(ui, |ui| { for (o, text) in opts { ui.selectable_value(v, o.to_string(), *text); } }); } fn mode(ui: &mut Ui, l: &str, v: &mut SecretMode) { let opts: Vec<_> = SecretMode::ALL.iter().map(|m| (*m, m.label())).collect(); combo(ui, l, v, &opts); } fn line_ending(ui: &mut Ui, v: &mut LineEnding) { let opts: Vec<_> = LineEnding::ALL.iter().map(|e| (*e, e.label())).collect(); combo(ui, "Line Ending", v, &opts); } fn reconnect(ui: &mut Ui, on: &mut bool, tries: &mut i64) { check(ui, "Auto-Reconnect", on, "Automatically reconnect with exponential backoff when the connection drops"); if *on { num(ui, "Max Reconnect Attempts", tries, 1..=20); } } /// A password field whose value is stored in the vault/keychain under `kind` on save. fn secret(ui: &mut Ui, l: &str, secrets: &mut BTreeMap<&'static str, String>, kind: &'static str, hint: &str) { label(ui, l); ui.add(TextEdit::singleline(secrets.entry(kind).or_default()).password(true).hint_text(hint).desired_width(f32::INFINITY)); } /// A text field with a drop-down of suggestions (an HTML datalist). `extra` draws trailing /// buttons, right to left, so the field takes exactly the width they leave. fn suggest(ui: &mut Ui, v: &mut String, hint: &str, opts: &[String], extra: impl FnOnce(&mut Ui, &mut String)) -> Response { rtl(ui, |ui| { extra(ui, v); ui.add_enabled_ui(!opts.is_empty(), |ui| { ui.menu_button(icon::CARET_DOWN, |ui| { egui::ScrollArea::vertical().max_height(280.0).show(ui, |ui| { for o in opts { if ui.selectable_label(v == o, o).clicked() { *v = o.clone(); ui.close(); } } }); }) .response .on_hover_text("Suggestions") }); ui.add(TextEdit::singleline(v).hint_text(hint).desired_width(ui.available_width())) }) } /// A row laid out right to left: add fixed-size widgets first, then one that fills the rest. fn rtl(ui: &mut Ui, body: impl FnOnce(&mut Ui) -> R) -> R { ui.horizontal(|ui| ui.with_layout(Layout::right_to_left(Align::Center), body).inner).inner } /// Key path with `~/.ssh` suggestions, Browse, and the JS checks once the field loses focus. fn key_path(ui: &mut Ui, v: &mut String, keys: &[String], checks: &mut KeyChecks, hint: &str) { let r = suggest(ui, v, hint, keys, |ui, v| { if ui.button("Browse").clicked() && let Some(p) = rfd::FileDialog::new().set_title("Select SSH Private Key").pick_file() { *v = p.display().to_string(); } }); let path = v.trim(); if path.is_empty() || r.has_focus() { return; } let t = Tokens::get(ui.ctx()); match checks.entry(path.to_string()).or_insert_with(|| check_key(path)) { Ok(m) => ui.label(RichText::new(format!("{} {m}", icon::CHECK_CIRCLE)).color(t.success).size(11.5)), Err(m) => ui.label(RichText::new(format!("{} {m}", icon::WARNING_CIRCLE)).color(t.danger).size(11.5)), }; } fn check_key(path: &str) -> Result<&'static str, &'static str> { if path.starts_with('~') { Err("Please use a full path (no ~).") } else if !Path::new(path).exists() { Err("Key file not found.") } else if !keyfiles::is_likely_private_key(Path::new(path)) { Err("File is not a recognised private key.") } else { Ok("Key verified.") } } enum Act { Up(usize), Down(usize), Remove(usize), } fn row_buttons(ui: &mut Ui, i: usize, n: usize) -> Option { let mut a = None; ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; let mut order = [0, 1, 2]; // Always ▲ ▼ ✕ on screen, also inside right-to-left rows. if ui.layout().prefer_right_to_left() { order.reverse(); } for k in order { let (glyph, tip, enabled, act) = match k { 0 => (icon::ARROW_UP, "Move up", i > 0, Act::Up(i)), 1 => (icon::ARROW_DOWN, "Move down", i + 1 < n, Act::Down(i)), _ => (icon::X, "Remove", true, Act::Remove(i)), }; if ui.add_enabled(enabled, Button::new(glyph).small()).on_hover_text(tip).clicked() { a = Some(act); } } }); a } fn apply(v: &mut Vec, a: Option) { match a { Some(Act::Up(i)) if i > 0 && i < v.len() => v.swap(i - 1, i), Some(Act::Down(i)) if i + 1 < v.len() => v.swap(i, i + 1), Some(Act::Remove(i)) if i < v.len() => { v.remove(i); } _ => {} } } fn fmt_label(f: &str) -> &'static str { match f { "hex" => "Hex", "escaped" => "Escaped", _ => "Text", } } /// Expect-style steps: wait for text (timeout 1–120 s) or send text. fn login_script(ui: &mut Ui, id: &str, steps: &mut Vec) { widgets::hint(ui, "Automate login sequences: wait for text from the server, then send a response. Steps execute in order after connection."); if steps.is_empty() { widgets::hint(ui, "No login script steps configured."); } let n = steps.len(); let mut act = None; // Rows are fixed-width columns around a value field that fills the rest, so they line up. for (i, st) in steps.iter_mut().enumerate() { ui.horizontal(|ui| { let wait = st.kind == "wait"; ComboBox::from_id_salt((id, i)).width(84.0).selected_text(if wait { "Wait for" } else { "Send" }).show_ui(ui, |ui| { ui.selectable_value(&mut st.kind, "wait".into(), "Wait for"); ui.selectable_value(&mut st.kind, "send".into(), "Send"); }); if st.kind == "wait" && st.timeout <= 0 { st.timeout = 10; } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { act = row_buttons(ui, i, n).or(act.take()); if wait { ui.add_sized(vec2(56.0, 22.0), DragValue::new(&mut st.timeout).range(1..=120).suffix(" s")).on_hover_text("Timeout (seconds)"); } else { ui.add_space(56.0 + ui.spacing().item_spacing.x); } ui.add( TextEdit::singleline(&mut st.value).hint_text(if wait { "Text to match…" } else { "Text to send…" }).desired_width(ui.available_width()), ); }); }); } apply(steps, act); ui.horizontal(|ui| { if ui.button(format!("{} Wait", icon::PLUS)).clicked() { steps.push(LoginScriptStep { kind: "wait".into(), value: String::new(), timeout: 10 }); } if ui.button(format!("{} Send", icon::PLUS)).clicked() { steps.push(LoginScriptStep { kind: "send".into(), value: String::new(), timeout: 0 }); } }); } /// The SSH-tunnel fields VNC and IRC share. struct Tunnel<'a> { on: &'a mut bool, host: &'a mut String, port: &'a mut i64, user: &'a mut String, auth: &'a mut String, key: &'a mut String, agent: &'a mut bool, mode: &'a mut SecretMode, } fn tunnel(ui: &mut Ui, kind: &str, c: Tunnel, keys: &[String], checks: &mut KeyChecks, secrets: &mut BTreeMap<&'static str, String>, errs: &[ValidationError]) { check(ui, "Enable SSH Tunnel", c.on, ""); if !*c.on { return; } text(ui, "SSH Host", c.host, "jump.example.com"); err(ui, errs, &format!("{kind}.sshHost")); num(ui, "SSH Port", c.port, 1..=65535); err(ui, errs, &format!("{kind}.sshPort")); text(ui, "SSH Username", c.user, "admin"); err(ui, errs, &format!("{kind}.sshUsername")); combo_s(ui, "SSH Auth Method", c.auth, &[("publickey", "Public Key"), ("password", "Password")]); err(ui, errs, &format!("{kind}.sshAuthMethod")); if c.auth != "password" { label(ui, "SSH Private Key Path"); key_path(ui, c.key, keys, checks, "/Users/username/.ssh/id_ed25519"); check(ui, "Use SSH Agent", c.agent, ""); } mode(ui, "SSH Secret Storage", c.mode); if *c.mode == SecretMode::Saved { // Kinds match the session manager's tunnel secrets ("vnc-ssh-password", "irc-ssh-passphrase"). let (l, k): (&str, &'static str) = match (kind, c.auth.as_str()) { ("vnc", "password") => ("SSH Password", "vnc-ssh-password"), ("vnc", _) => ("SSH Key Passphrase", "vnc-ssh-passphrase"), (_, "password") => ("SSH Password", "irc-ssh-password"), _ => ("SSH Key Passphrase", "irc-ssh-passphrase"), }; secret(ui, l, secrets, k, "Leave blank to keep the saved secret"); } } #[cfg(test)] mod tests { use super::*; #[test] fn list_moves_and_parsing() { let mut v = vec![1, 2, 3]; apply(&mut v, Some(Act::Up(2))); assert_eq!(v, [1, 3, 2]); apply(&mut v, Some(Act::Down(0))); assert_eq!(v, [3, 1, 2]); apply(&mut v, Some(Act::Remove(1))); assert_eq!(v, [3, 2]); apply(&mut v, Some(Act::Down(1))); // last row: no-op assert_eq!(v, [3, 2]); assert_eq!(index_of("ssh.jumpHosts[12].host"), Some(12)); let env = parse_env("A=1\n =x\nB = two=2\nnoeq"); assert_eq!(env.len(), 2); assert_eq!(env["B"], "two=2"); } #[test] fn switching_protocol_keeps_common_fields() { let mut e = ProfileEditor::default(); e.p.folder = "Prod".into(); e.p.host = "box".into(); e.p.display_name = "Box".into(); e.switch(Protocol::Serial); assert_eq!((e.p.folder.as_str(), e.p.host.as_str(), e.p.port), ("Prod", "box", 0)); assert!(e.p.serial.is_some()); e.switch(Protocol::Irc); assert_eq!(e.p.port, 6667); let saved = e.collect(); assert!(saved.ssh.is_none() && saved.serial.is_none() && saved.irc.is_some()); } }