From 2bb12c3a5847366465574bdc4b4956049ef043bb Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Sun, 11 Oct 2026 17:40:48 +0100 Subject: [PATCH] rust: merge UI feature modules; fix Ctrl+key on Linux/Windows (egui command_only shadowing); clippy clean; rustfmt config --- .github/workflows/ci.yml | 3 +- rust/app/src/app.rs | 115 ++++++++++------ rust/app/src/chrome.rs | 82 ++++++++--- rust/app/src/forwards.rs | 26 +++- rust/app/src/github_view.rs | 104 ++++++++------ rust/app/src/irc_view.rs | 9 +- rust/app/src/profile_editor.rs | 109 +++++++++++---- rust/app/src/quick_connect.rs | 14 +- rust/app/src/raw_view.rs | 8 +- rust/app/src/recordings.rs | 41 ++++-- rust/app/src/sftp.rs | 92 ++++++++++--- rust/app/src/sidebar.rs | 42 +++++- rust/app/src/snippets.rs | 29 +++- rust/app/src/splits.rs | 49 +++++-- rust/app/src/ssh_keys.rs | 10 +- rust/app/src/state.rs | 30 +---- rust/app/src/tabs.rs | 18 ++- rust/app/src/terminal.rs | 44 +++--- rust/app/src/terminal_themes.rs | 209 +++++++++++++++++++++++++++-- rust/app/src/theme.rs | 7 +- rust/app/src/transfer.rs | 59 ++++++-- rust/app/src/triggers.rs | 11 +- rust/app/src/views.rs | 9 -- rust/app/src/vnc_view.rs | 18 ++- rust/core/src/clients/github.rs | 83 +++--------- rust/core/src/clients/irc.rs | 72 +++------- rust/core/src/clients/local.rs | 4 +- rust/core/src/clients/mod.rs | 15 +-- rust/core/src/clients/raw.rs | 14 +- rust/core/src/clients/serial.rs | 19 +-- rust/core/src/clients/sftp.rs | 23 ++-- rust/core/src/clients/ssh.rs | 64 +++++---- rust/core/src/clients/telnet.rs | 3 +- rust/core/src/clients/vnc.rs | 60 ++++----- rust/core/src/exportimport.rs | 2 +- rust/core/src/ircformat.rs | 16 +-- rust/core/src/keyfiles.rs | 25 +--- rust/core/src/models.rs | 3 +- rust/core/src/recording.rs | 5 +- rust/core/src/secrets.rs | 15 +-- rust/core/src/service.rs | 13 +- rust/core/src/sessions.rs | 88 +++++++++--- rust/core/src/sshconfig.rs | 9 +- rust/core/src/storage.rs | 33 ++--- rust/core/src/terminalenv.rs | 6 +- rust/core/src/updater.rs | 26 +--- rust/core/src/validation.rs | 13 +- rust/core/tests/ssh_integration.rs | 48 +++++-- rust/rustfmt.toml | 2 + 49 files changed, 1140 insertions(+), 659 deletions(-) create mode 100644 rust/rustfmt.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc00f43..20a2c5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,4 +25,5 @@ jobs: sudo apt-get update sudo apt-get install -y libxkbcommon-dev libgl1-mesa-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev libwayland-dev libgtk-3-dev openssh-server - run: cargo test --locked --workspace - - run: cargo clippy --locked --workspace -- -D warnings + - run: cargo fmt --all --check + - run: cargo clippy --locked --workspace --all-targets -- -D warnings diff --git a/rust/app/src/app.rs b/rust/app/src/app.rs index e78cf0c..c650ac5 100644 --- a/rust/app/src/app.rs +++ b/rust/app/src/app.rs @@ -41,17 +41,43 @@ pub enum Prompt { /// Modal dialogs owned by the main window. pub enum Dialog { /// A profile already has a live session. - Reuse { profile: String, existing: String }, + Reuse { + profile: String, + existing: String, + }, /// Unencrypted VNC without an SSH tunnel. - DirectVnc { profile: String }, - ConfirmClose { session: String }, - ConfirmDisconnectMany { sessions: Vec }, - Rename { session: String, name: String }, - Broadcast { picks: Vec<(String, String, bool)> }, + DirectVnc { + profile: String, + }, + ConfirmClose { + session: String, + }, + ConfirmDisconnectMany { + sessions: Vec, + }, + Rename { + session: String, + name: String, + }, + Broadcast { + picks: Vec<(String, String, bool)>, + }, /// Guided help for an auth or host-key failure. - Guided { session: String, title: String, body: String, known_hosts: bool }, - Delete { profiles: Vec }, - Input { title: String, label: String, value: String, then: InputThen }, + Guided { + session: String, + title: String, + body: String, + known_hosts: bool, + }, + Delete { + profiles: Vec, + }, + Input { + title: String, + label: String, + value: String, + then: InputThen, + }, } pub enum InputThen { @@ -205,7 +231,9 @@ impl App { /// Registers a just-opened session as a pane (and a tab unless it's a split child). pub fn add_session(&mut self, id: &str, tab: bool) { - let Some(info) = self.s.svc.sessions.info(id) else { return }; + let Some(info) = self.s.svc.sessions.info(id) else { + return; + }; let profile = self.s.svc.sessions.profile(id); let view = self.view_for(&info, profile.as_ref()); self.s.panes.insert(id.into(), Pane { info, view, recording: false, local_exited: false, connected_since: None, highlight_until: 0.0 }); @@ -224,7 +252,8 @@ impl App { }; if !skip_checks { let live = self.s.panes.values().find(|pn| { - pn.info.profile_id == id && matches!(self.s.session_state(&pn.info.id), Some(SessionState::Connected | SessionState::Connecting | SessionState::Reconnecting)) + pn.info.profile_id == id + && matches!(self.s.session_state(&pn.info.id), Some(SessionState::Connected | SessionState::Connecting | SessionState::Reconnecting)) }); if let Some(pn) = live { self.dialog = Some(Dialog::Reuse { profile: id.into(), existing: pn.info.id.clone() }); @@ -365,10 +394,10 @@ impl App { fn mark_activity(&mut self, session: &str) { let active = self.s.active.clone(); - if let Some(t) = self.s.tabs.iter_mut().find(|t| t.id == session || t.split.as_ref().is_some_and(|sp| sp.sessions().iter().any(|x| x == session))) { - if active.as_deref() != Some(&t.id) { - t.activity = true; - } + if let Some(t) = self.s.tabs.iter_mut().find(|t| t.id == session || t.split.as_ref().is_some_and(|sp| sp.sessions().iter().any(|x| x == session))) + && active.as_deref() != Some(&t.id) + { + t.activity = true; } } @@ -467,7 +496,9 @@ impl App { p.info.error = error.into(); } } - let Some(p) = self.s.pane_mut(session) else { return }; + let Some(p) = self.s.pane_mut(session) else { + return; + }; let local = p.info.protocol == Protocol::Local; match state { SessionState::Connected => { @@ -477,7 +508,9 @@ impl App { let _ = self.s.svc.sessions.write(session, format!("{cmd}\n").as_bytes()); self.run_local = None; } - let Some(p) = self.s.pane_mut(session) else { return }; + let Some(p) = self.s.pane_mut(session) else { + return; + }; p.local_exited = false; p.connected_since = Some(chrono::Local::now()); // The PTY was created before the view knew its size: send it now. @@ -520,7 +553,6 @@ impl App { self.add_session(&sid, true); self.s.info(format!("Connecting to {name}…")); } - Request::ConnectLocal => self.connect_local(), Request::ReloadProfiles => self.s.reload_profiles(), Request::ReloadPreferences => { self.s.prefs = self.s.svc.preferences(); @@ -528,26 +560,14 @@ impl App { self.theme = theme_for(&self.s.prefs.theme); theme::apply(&self.s.ctx, self.theme); } - Request::QuickConnect => self.quick.open(), - Request::Settings(t) => self.settings.open(t), - Request::SshKeys => self.keys.open(), Request::ExportEncrypted(ids) => self.transfer.export_encrypted(ids), Request::ImportEncrypted => self.transfer.import_encrypted(), - Request::ExportJson => self.transfer.export_json(), - Request::ImportJson => self.transfer.import_json(), - Request::Recordings => self.recordings.open(), - Request::Snippets => self.snippets.open(), - Request::Triggers => self.triggers.open(), Request::Sftp(id) => self.sftp.toggle(&id), - Request::PortForwards(id) => self.forwards.open(&id), Request::SendToActive(text) => match self.s.focused_session() { Some(id) if self.s.session_state(&id) == Some(SessionState::Connected) => self.send_input(&id, text.as_bytes()), _ => self.s.error("No active connected session"), }, Request::Write(id, data) => self.send_input(&id, &data), - Request::Disconnect(id) => self.close_session(&id), - Request::CommandPalette => self.palette = Some(chrome::Palette::default()), - Request::About => self.settings.open(SettingsTab::About), } } } @@ -754,7 +774,9 @@ impl App { // --- screenshot automation --------------------------------------------------------- fn screenshot(&mut self, ctx: &egui::Context) { - let Some((path, frames)) = &mut self.screenshot else { return }; + let Some((path, frames)) = &mut self.screenshot else { + return; + }; *frames += 1; if *frames < 40 { std::thread::sleep(std::time::Duration::from_millis(15)); @@ -824,26 +846,33 @@ impl eframe::App for App { if std::mem::take(&mut self.resize_sidebar) { panel = panel.exact_size(width); } - let side = panel - .min_size(180.0) - .max_size(500.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(t.dock)) - .show(ui, |ui| sidebar::ui(self, ui)); + let side = panel.min_size(180.0).max_size(500.0).resizable(true).frame(egui::Frame::NONE.fill(t.dock)).show(ui, |ui| sidebar::ui(self, ui)); let w = side.response.rect.width().round() as i64; if (w - self.s.prefs.sidebar_width).abs() > 2 && !ctx.input(|i| i.pointer.any_down()) { self.s.prefs.sidebar_width = w; let _ = self.s.svc.save_preferences(&self.s.prefs); } if self.editor.is_open() { - egui::Panel::right("editor").default_size(460.0).min_size(380.0).max_size(800.0).resizable(true).frame(egui::Frame::NONE.fill(t.dock).inner_margin(6)).show(ui, |ui| { - self.editor.ui(ui, &mut self.s); - }); + egui::Panel::right("editor") + .default_size(460.0) + .min_size(380.0) + .max_size(800.0) + .resizable(true) + .frame(egui::Frame::NONE.fill(t.dock).inner_margin(6)) + .show(ui, |ui| { + self.editor.ui(ui, &mut self.s); + }); } else if let Some(id) = self.sftp.open_for().map(String::from) { if self.s.pane(&id).is_some() { - egui::Panel::right("sftp").default_size(380.0).min_size(280.0).max_size(800.0).resizable(true).frame(egui::Frame::NONE.fill(t.dock).inner_margin(6)).show(ui, |ui| { - self.sftp.ui(ui, &mut self.s); - }); + egui::Panel::right("sftp") + .default_size(380.0) + .min_size(280.0) + .max_size(800.0) + .resizable(true) + .frame(egui::Frame::NONE.fill(t.dock).inner_margin(6)) + .show(ui, |ui| { + self.sftp.ui(ui, &mut self.s); + }); } else { self.sftp.close(&id); } diff --git a/rust/app/src/chrome.rs b/rust/app/src/chrome.rs index 0317ea7..ee5deef 100644 --- a/rust/app/src/chrome.rs +++ b/rust/app/src/chrome.rs @@ -194,7 +194,13 @@ pub fn title_bar(app: &mut App, ui: &mut Ui) { let resp = ui.interact(r, ui.id().with("palette-launch"), Sense::click()); ui.painter().rect(r, 6.0, if resp.hovered() { t.hover } else { t.field }, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); ui.painter().text(r.left_center() + vec2(9.0, 0.0), Align2::LEFT_CENTER, icon::MAGNIFYING_GLASS, egui::FontId::proportional(13.0), t.text_faint); - ui.painter().with_clip_rect(r.shrink(2.0)).text(r.left_center() + vec2(28.0, 0.0), Align2::LEFT_CENTER, "Search connections and commands", egui::FontId::proportional(12.0), t.text_faint); + ui.painter().with_clip_rect(r.shrink(2.0)).text( + r.left_center() + vec2(28.0, 0.0), + Align2::LEFT_CENTER, + "Search connections and commands", + egui::FontId::proportional(12.0), + t.text_faint, + ); if w > 250.0 { ui.painter().text(r.right_center() - vec2(8.0, 0.0), Align2::RIGHT_CENTER, keys("Mod+K").replace('+', " "), theme::semibold(10.5), t.text_faint); } @@ -257,7 +263,9 @@ pub fn resize_edges(ctx: &egui::Context) { (Rect::from_min_max(r.min, pos2(r.left() + e, r.bottom())), D::West, CursorIcon::ResizeHorizontal), (Rect::from_min_max(pos2(r.right() - e, r.top()), r.max), D::East, CursorIcon::ResizeHorizontal), ]; - let Some(p) = ctx.input(|i| i.pointer.hover_pos()) else { return }; + let Some(p) = ctx.input(|i| i.pointer.hover_pos()) else { + return; + }; for (zone, dir, cursor) in zones { if zone.contains(p) { ctx.set_cursor_icon(cursor); @@ -317,7 +325,13 @@ pub fn status_bar(app: &mut App, ui: &mut Ui) { && active.as_deref() == Some(sid) && let Ok(ms) = r { - let c = if *ms < 100 { t.success } else if *ms < 250 { t.warning } else { t.danger }; + let c = if *ms < 100 { + t.success + } else if *ms < 250 { + t.warning + } else { + t.danger + }; ui.label(RichText::new(format!("{ms} ms")).size(11.5).color(c)); } if app.s.broadcast { @@ -328,7 +342,15 @@ pub fn status_bar(app: &mut App, ui: &mut Ui) { let vault = app.s.svc.secrets.vault.is_unlocked(); let keychain = app.s.svc.secrets.keychain_ok; let label = if vault { format!("{} Vault unlocked", icon::LOCK_OPEN) } else { format!("{} Vault locked", icon::LOCK) }; - if ui.add(egui::Label::new(dim(label)).sense(Sense::click())).on_hover_text(if keychain { "Saved credentials use the OS keychain; the vault is the fallback." } else { "No OS keychain available: saved credentials use the encrypted vault." }).clicked() { + if ui + .add(egui::Label::new(dim(label)).sense(Sense::click())) + .on_hover_text(if keychain { + "Saved credentials use the OS keychain; the vault is the fallback." + } else { + "No OS keychain available: saved credentials use the encrypted vault." + }) + .clicked() + { app.settings.open(SettingsTab::Security); } let total = app.s.panes.len(); @@ -337,7 +359,11 @@ pub fn status_bar(app: &mut App, ui: &mut Ui) { if let Some(u) = &app.update && u.available { - let txt = format!("{} Update available{}", icon::ARROW_CIRCLE_UP, if u.latest_version.is_empty() { String::new() } else { format!(" ({})", u.latest_version) }); + let txt = format!( + "{} Update available{}", + icon::ARROW_CIRCLE_UP, + if u.latest_version.is_empty() { String::new() } else { format!(" ({})", u.latest_version) } + ); if ui.add(egui::Label::new(RichText::new(txt).size(11.5).color(t.accent)).sense(Sense::click())).clicked() { app.settings.open(SettingsTab::About); } @@ -397,6 +423,8 @@ enum Cmd { Settings, Import, Export, + ExportJson, + ImportJson, Lock, Unlock, Keys, @@ -435,7 +463,12 @@ fn commands(app: &App) -> Vec<(Cmd, String, &'static str, &'static str, String)> let name = if p.display_name.is_empty() { &p.host } else { &p.display_name }; v.push((Cmd::Connect(p.id.clone()), format!("Connect to {name}"), "Connections", icon::PLUGS_CONNECTED, String::new())); } - for (pr, label) in [(Protocol::Ssh, "New SSH Connection"), (Protocol::Telnet, "New Telnet Connection"), (Protocol::Raw, "New RAW Connection"), (Protocol::Serial, "New Serial Connection")] { + for (pr, label) in [ + (Protocol::Ssh, "New SSH Connection"), + (Protocol::Telnet, "New Telnet Connection"), + (Protocol::Raw, "New RAW Connection"), + (Protocol::Serial, "New Serial Connection"), + ] { v.push((Cmd::NewProfile(pr), label.into(), "Actions", icon::PLUS_CIRCLE, if pr == Protocol::Ssh { keys("Mod+N") } else { String::new() })); } v.push((Cmd::Quick, "Quick Connect…".into(), "Actions", icon::LIGHTNING, keys("Mod+Shift+Q"))); @@ -456,6 +489,8 @@ fn commands(app: &App) -> Vec<(Cmd, String, &'static str, &'static str, String)> v.push((Cmd::Settings, "Settings".into(), "Application", icon::GEAR, keys("Mod+,"))); v.push((Cmd::Import, "Import Profiles".into(), "Application", icon::DOWNLOAD_SIMPLE, String::new())); v.push((Cmd::Export, "Export Profiles".into(), "Application", icon::UPLOAD_SIMPLE, String::new())); + v.push((Cmd::ImportJson, "Import Profiles (JSON)".into(), "Application", icon::FILE_ARROW_DOWN, String::new())); + v.push((Cmd::ExportJson, "Export Profiles (JSON)".into(), "Application", icon::FILE_ARROW_UP, String::new())); for k in ThemeKind::ALL { v.push((Cmd::Theme(k), format!("Theme: {}", k.label()), "Application", icon::PALETTE, String::new())); } @@ -501,6 +536,8 @@ fn run(app: &mut App, ctx: &egui::Context, c: Cmd) { Cmd::Settings => app.settings.open(SettingsTab::General), Cmd::Import => app.s.request(Request::ImportEncrypted), Cmd::Export => app.s.request(Request::ExportEncrypted(vec![])), + Cmd::ExportJson => app.transfer.export_json(), + Cmd::ImportJson => app.transfer.import_json(), Cmd::Lock => { app.s.svc.secrets.vault.lock(); app.s.info("Secret store locked"); @@ -536,14 +573,13 @@ pub fn modal_backdrop(ctx: &egui::Context) { } pub fn palette(app: &mut App, ctx: &egui::Context) { - let Some(mut pal) = app.palette.take() else { return }; + let Some(mut pal) = app.palette.take() else { + return; + }; let t = Tokens::get(ctx); - let mut matches: Vec<_> = commands(app) - .into_iter() - .filter_map(|c| fuzzy_score(&format!("{} {}", c.1, c.2), pal.query.trim()).map(|s| (s, c))) - .collect(); + let mut matches: Vec<_> = commands(app).into_iter().filter_map(|c| fuzzy_score(&format!("{} {}", c.1, c.2), pal.query.trim()).map(|s| (s, c))).collect(); if !pal.query.trim().is_empty() { - matches.sort_by(|a, b| b.0.cmp(&a.0)); + matches.sort_by_key(|m| std::cmp::Reverse(m.0)); } let matches: Vec<_> = matches.into_iter().map(|(_, c)| c).collect(); pal.sel = pal.sel.min(matches.len().saturating_sub(1)); @@ -564,7 +600,13 @@ pub fn palette(app: &mut App, ctx: &egui::Context) { ui.set_width(580.0); ui.horizontal(|ui| { ui.label(RichText::new(icon::MAGNIFYING_GLASS).size(16.0).color(t.text_faint)); - let r = ui.add(egui::TextEdit::singleline(&mut pal.query).hint_text("Connect, switch tab, or run a command…").frame(egui::Frame::NONE).desired_width(f32::INFINITY).font(egui::FontId::proportional(15.0))); + let r = ui.add( + egui::TextEdit::singleline(&mut pal.query) + .hint_text("Connect, switch tab, or run a command…") + .frame(egui::Frame::NONE) + .desired_width(f32::INFINITY) + .font(egui::FontId::proportional(15.0)), + ); r.request_focus(); if r.changed() { pal.sel = 0; @@ -622,7 +664,9 @@ pub fn modal(ctx: &egui::Context, id: &str, width: f32, body: impl FnOnce(&mut U } pub fn prompts(app: &mut App, ctx: &egui::Context) { - let Some(p) = app.prompts.front_mut() else { return }; + let Some(p) = app.prompts.front_mut() else { + return; + }; let t = Tokens::get(ctx); let esc = ctx.input(|i| i.key_pressed(Key::Escape)); let enter = ctx.input(|i| i.key_pressed(Key::Enter)); @@ -680,7 +724,9 @@ pub fn prompts(app: &mut App, ctx: &egui::Context) { // --- dialogs -------------------------------------------------------------------------------- pub fn dialogs(app: &mut App, ctx: &egui::Context) { - let Some(mut d) = app.dialog.take() else { return }; + let Some(mut d) = app.dialog.take() else { + return; + }; let t = Tokens::get(ctx); let esc = ctx.input(|i| i.key_pressed(Key::Escape)); let mut keep = !esc; @@ -853,7 +899,11 @@ pub fn dialogs(app: &mut App, ctx: &egui::Context) { let names: Vec = profiles.iter().filter_map(|id| app.s.profile(id)).map(|p| p.display_name.clone()).collect(); modal(ctx, "delete", 380.0, |ui| { ui.label(RichText::new("Delete connection?").font(theme::semibold(15.0))); - ui.label(if names.len() == 1 { format!("\"{}\" will be deleted, with its saved credentials.", names[0]) } else { format!("{} connections will be deleted.", names.len()) }); + ui.label(if names.len() == 1 { + format!("\"{}\" will be deleted, with its saved credentials.", names[0]) + } else { + format!("{} connections will be deleted.", names.len()) + }); ui.add_space(8.0); ui.horizontal(|ui| { if ui.add(egui::Button::new(RichText::new(format!("{} Delete", icon::TRASH)).color(Color32::WHITE)).fill(t.danger)).clicked() { diff --git a/rust/app/src/forwards.rs b/rust/app/src/forwards.rs index b16fa76..0554554 100644 --- a/rust/app/src/forwards.rs +++ b/rust/app/src/forwards.rs @@ -20,7 +20,14 @@ struct Form { impl Default for Form { fn default() -> Form { - Form { remote: false, bind_host: "127.0.0.1".into(), bind_port: String::new(), dest_host: "localhost".into(), dest_port: String::new(), label: String::new() } + Form { + remote: false, + bind_host: "127.0.0.1".into(), + bind_port: String::new(), + dest_host: "localhost".into(), + dest_port: String::new(), + label: String::new(), + } } } @@ -52,7 +59,9 @@ impl Forwards { s.info("Forward stopped"); } } - let Some(sid) = self.session.clone() else { return }; + let Some(sid) = self.session.clone() else { + return; + }; // A lock and a clone in the client; no network. let list = match s.svc.sessions.forwards(&sid) { Ok(l) => l, @@ -84,7 +93,9 @@ impl Forwards { ui.horizontal(|ui| { let local = fwd.direction == ForwardDirection::Local; widgets::chip(ui, if local { "L" } else { "R" }, if local { t.accent } else { t.warning }); - let or = |s: &str, d: &str| if s.is_empty() { d.to_string() } else { s.to_string() }; + let or = |s: &str, d: &str| { + if s.is_empty() { d.to_string() } else { s.to_string() } + }; let text = format!("{}:{} → {}:{}", or(&fwd.bind_host, "127.0.0.1"), fwd.bind_port, or(&fwd.dest_host, "localhost"), fwd.dest_port); ui.label(RichText::new(text).font(theme::mono(12.0))); if !fwd.label.is_empty() { @@ -126,7 +137,9 @@ impl Forwards { let f = &self.form; match (port(&f.bind_port), port(&f.dest_port)) { (Some(bind_port), Some(dest_port)) => { - let or = |s: &str, d: &str| if s.trim().is_empty() { d.to_string() } else { s.trim().to_string() }; + let or = |s: &str, d: &str| { + if s.trim().is_empty() { d.to_string() } else { s.trim().to_string() } + }; let rule = PortForward { id: new_id(), direction: if f.remote { ForwardDirection::Remote } else { ForwardDirection::Local }, @@ -138,7 +151,10 @@ impl Forwards { label: f.label.trim().into(), }; let (svc, done) = (s.svc.clone(), self.done.clone()); - s.spawn(move || svc.sessions.start_forward(&sid, rule).map_err(|e| format!("Failed to start forward: {e}").into()), move |_| done.lock().unwrap().push(true)); + s.spawn( + move || svc.sessions.start_forward(&sid, rule).map_err(|e| format!("Failed to start forward: {e}").into()), + move |_| done.lock().unwrap().push(true), + ); } _ => s.error("Bind port and destination port are required"), } diff --git a/rust/app/src/github_view.rs b/rust/app/src/github_view.rs index 583598d..493e384 100644 --- a/rust/app/src/github_view.rs +++ b/rust/app/src/github_view.rs @@ -61,12 +61,16 @@ fn grouped<'a>(repos: &'a [GitHubRepo], login: &str, filter: &str) -> Vec<(Strin /// "just now", "3 minutes ago", "1 day ago"… (the web build's relativeTime). fn relative_time(iso: &str, now: DateTime) -> String { - let Some(d) = parse_time(iso) else { return String::new() }; + let Some(d) = parse_time(iso) else { + return String::new(); + }; let secs = (now - d).num_seconds(); if secs < 60 { return "just now".into(); } - let plural = |n: i64, unit: &str| if n == 1 { format!("1 {unit} ago") } else { format!("{n} {unit}s ago") }; + let plural = |n: i64, unit: &str| { + if n == 1 { format!("1 {unit} ago") } else { format!("{n} {unit}s ago") } + }; let mins = secs / 60; let hrs = mins / 60; let days = hrs / 24; @@ -120,6 +124,7 @@ fn lang_colour(lang: &str) -> Color32 { Color32::from_rgb((hex >> 16) as u8, (hex >> 8) as u8, hex as u8) } +#[allow(clippy::large_enum_variant)] // short-lived, one per click enum Action { Zip(GitHubRepo), Visit(String), @@ -178,7 +183,9 @@ impl GithubView { fn download(&mut self, s: &Shared, repo: &GitHubRepo) { let reference = repo.default_branch.clone(); let name = if reference.is_empty() { format!("{}.zip", repo.name) } else { format!("{}-{reference}.zip", repo.name) }; - let Some(path) = rfd::FileDialog::new().set_file_name(name).add_filter("ZIP archive", &["zip"]).save_file() else { return }; + let Some(path) = rfd::FileDialog::new().set_file_name(name).add_filter("ZIP archive", &["zip"]).save_file() else { + return; + }; self.on_status(format!("Downloading {}...", repo.full_name)); let (svc, sid, inbox, ctx, full) = (s.svc.clone(), self.session.clone(), self.inbox.clone(), s.ctx.clone(), repo.full_name.clone()); std::thread::spawn(move || { @@ -221,7 +228,11 @@ impl GithubView { if widgets::icon_button(ui, icon::ARROW_CLOCKWISE, "Refresh now").clicked() { self.refresh(s); } - ui.add(egui::TextEdit::singleline(&mut self.filter).hint_text(format!("{} Filter repositories…", icon::MAGNIFYING_GLASS)).desired_width(240.0)); + ui.add( + egui::TextEdit::singleline(&mut self.filter) + .hint_text(format!("{} Filter repositories…", icon::MAGNIFYING_GLASS)) + .desired_width(240.0), + ); }); }); let c = if self.status_error { t.danger } else { t.text_dim }; @@ -270,50 +281,55 @@ impl GithubView { fn card(ui: &mut Ui, r: &GitHubRepo, now: DateTime) -> Option { let t = Tokens::get(ui.ctx()); let mut action = None; - let resp = egui::Frame::new().fill(t.card).stroke(egui::Stroke::new(1.0, t.card_border)).corner_radius(6).inner_margin(egui::Margin::symmetric(12, 9)).show(ui, |ui| { - ui.set_width(ui.available_width()); - ui.horizontal(|ui| { - ui.label(RichText::new(&r.name).font(theme::semibold(13.5)).color(t.accent)); - if r.private { - widgets::chip(ui, "Private", t.warning); - } - if r.fork { - widgets::chip(ui, "Fork", t.text_dim); - } - if r.archived { - widgets::chip(ui, "Archived", t.danger); - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui.button(format!("{} Visit", icon::ARROW_SQUARE_OUT)).on_hover_text("Open repository in browser").clicked() { - action = Some(Action::Visit(r.html_url.clone())); + let resp = egui::Frame::new() + .fill(t.card) + .stroke(egui::Stroke::new(1.0, t.card_border)) + .corner_radius(6) + .inner_margin(egui::Margin::symmetric(12, 9)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + ui.label(RichText::new(&r.name).font(theme::semibold(13.5)).color(t.accent)); + if r.private { + widgets::chip(ui, "Private", t.warning); } - if ui.button(format!("{} ZIP", icon::FILE_ZIP)).on_hover_text("Download repository as zip").clicked() { - action = Some(Action::Zip(r.clone())); + if r.fork { + widgets::chip(ui, "Fork", t.text_dim); + } + if r.archived { + widgets::chip(ui, "Archived", t.danger); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button(format!("{} Visit", icon::ARROW_SQUARE_OUT)).on_hover_text("Open repository in browser").clicked() { + action = Some(Action::Visit(r.html_url.clone())); + } + if ui.button(format!("{} ZIP", icon::FILE_ZIP)).on_hover_text("Download repository as zip").clicked() { + action = Some(Action::Zip(r.clone())); + } + }); + }); + if !r.latest_committer.is_empty() && !r.latest_commit_at.is_empty() { + ui.label(RichText::new(format!("Last commit: {} by {}", local_time(&r.latest_commit_at), r.latest_committer)).color(t.text_faint).size(11.5)); + } + if !r.description.is_empty() { + ui.label(RichText::new(&r.description).color(t.text_dim)); + } + ui.horizontal(|ui| { + if !r.language.is_empty() { + let (dot, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover()); + ui.painter().circle_filled(dot.center(), 5.0, lang_colour(&r.language)); + ui.label(RichText::new(&r.language).color(t.text_dim).size(11.5)); + ui.add_space(8.0); + } + if r.stars > 0 { + ui.label(RichText::new(format!("{} {}", icon::STAR, widgets::group(r.stars as u64))).color(t.text_dim).size(11.5)); + ui.add_space(8.0); + } + if !r.pushed_at.is_empty() { + ui.label(RichText::new(relative_time(&r.pushed_at, now)).color(t.text_faint).size(11.5)).on_hover_text(local_time(&r.pushed_at)); } }); }); - if !r.latest_committer.is_empty() && !r.latest_commit_at.is_empty() { - ui.label(RichText::new(format!("Last commit: {} by {}", local_time(&r.latest_commit_at), r.latest_committer)).color(t.text_faint).size(11.5)); - } - if !r.description.is_empty() { - ui.label(RichText::new(&r.description).color(t.text_dim)); - } - ui.horizontal(|ui| { - if !r.language.is_empty() { - let (dot, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover()); - ui.painter().circle_filled(dot.center(), 5.0, lang_colour(&r.language)); - ui.label(RichText::new(&r.language).color(t.text_dim).size(11.5)); - ui.add_space(8.0); - } - if r.stars > 0 { - ui.label(RichText::new(format!("{} {}", icon::STAR, widgets::group(r.stars as u64))).color(t.text_dim).size(11.5)); - ui.add_space(8.0); - } - if !r.pushed_at.is_empty() { - ui.label(RichText::new(relative_time(&r.pushed_at, now)).color(t.text_faint).size(11.5)).on_hover_text(local_time(&r.pushed_at)); - } - }); - }); let resp = resp.response.interact(Sense::click()); if resp.double_clicked() { action = Some(Action::Visit(r.html_url.clone())); diff --git a/rust/app/src/irc_view.rs b/rust/app/src/irc_view.rs index a4c974d..9d75926 100644 --- a/rust/app/src/irc_view.rs +++ b/rust/app/src/irc_view.rs @@ -317,7 +317,9 @@ impl IrcView { fn log(&mut self, ui: &mut Ui, theme: &'static TermTheme) { let font = FontId::monospace(self.font_size); let wrap = ui.available_width().max(100.0); - let Some(target) = self.targets.iter_mut().find(|t| t.name == self.active) else { return }; + let Some(target) = self.targets.iter_mut().find(|t| t.name == self.active) else { + return; + }; // Lay out lines once per width/theme; the galley cache keeps scrolling cheap. let mut tops = Vec::with_capacity(target.lines.len()); let mut y = 0.0; @@ -453,7 +455,8 @@ impl IrcView { ui.label(RichText::new(r.users.to_string()).color(t.text_faint).size(11.0)); ui.label(RichText::new(ircformat::plain_text(&r.topic)).color(t.text_faint).size(11.0)); }); - ui.interact(ui.max_rect(), ui.id().with("tip"), Sense::hover()).on_hover_text(format!("{}\nClick to join", ircformat::plain_text(if r.topic.is_empty() { &r.name } else { &r.topic }))); + ui.interact(ui.max_rect(), ui.id().with("tip"), Sense::hover()) + .on_hover_text(format!("{}\nClick to join", ircformat::plain_text(if r.topic.is_empty() { &r.name } else { &r.topic }))); }); join_room = clicked.map(|i| listed[i].name.clone()); }); @@ -474,7 +477,7 @@ impl IrcView { ui.interact(ui.max_rect(), ui.id().with("tip"), Sense::hover()).on_hover_text(format!("{}\nClick to mention", u[i])); }); if let Some(i) = clicked { - self.insert_mention(&display_nick(&u[i]).to_string()); + self.insert_mention(display_nick(&u[i])); } } _ => widgets::hint(ui, "No users yet"), diff --git a/rust/app/src/profile_editor.rs b/rust/app/src/profile_editor.rs index 7e4d40d..c9025a5 100644 --- a/rust/app/src/profile_editor.rs +++ b/rust/app/src/profile_editor.rs @@ -208,7 +208,10 @@ impl ProfileEditor { }); 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::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); @@ -240,7 +243,12 @@ impl ProfileEditor { 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); + 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| { @@ -435,11 +443,14 @@ impl ProfileEditor { 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); - } - }); + 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"); @@ -460,13 +471,22 @@ impl ProfileEditor { 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() }); + 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)); + 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| { @@ -488,7 +508,9 @@ impl ProfileEditor { 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"); + 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), @@ -634,7 +656,24 @@ impl ProfileEditor { 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); + 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, + ); }); } @@ -686,7 +725,24 @@ impl ProfileEditor { } }); 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); + 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)); } @@ -696,11 +752,13 @@ impl ProfileEditor { 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()); - } - }); + 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"); @@ -709,7 +767,12 @@ impl ProfileEditor { 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)")]); + 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); @@ -799,11 +862,7 @@ fn prep(p: &mut Profile) { } 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() + 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 @@ -1059,7 +1118,9 @@ fn login_script(ui: &mut Ui, id: &str, steps: &mut Vec) { } 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())); + ui.add( + TextEdit::singleline(&mut st.value).hint_text(if wait { "Text to match…" } else { "Text to send…" }).desired_width(ui.available_width()), + ); }); }); } diff --git a/rust/app/src/quick_connect.rs b/rust/app/src/quick_connect.rs index 1b80f6f..39140a3 100644 --- a/rust/app/src/quick_connect.rs +++ b/rust/app/src/quick_connect.rs @@ -24,7 +24,16 @@ pub struct QuickConnect { impl Default for QuickConnect { fn default() -> Self { - QuickConnect { open: false, focus: false, protocol: Protocol::Ssh, host: String::new(), port: 22, username: String::new(), nick: "nexus".into(), ports: vec![] } + QuickConnect { + open: false, + focus: false, + protocol: Protocol::Ssh, + host: String::new(), + port: 22, + username: String::new(), + nick: "nexus".into(), + ports: vec![], + } } } @@ -97,7 +106,8 @@ impl QuickConnect { let r = ui .horizontal(|ui| { let w = if serial { ui.available_width() - 34.0 } else { ui.available_width() }; - let r = ui.add(TextEdit::singleline(&mut self.host).hint_text(if serial { "/dev/ttyUSB0 or COM3" } else { "e.g. 192.168.1.1" }).desired_width(w)); + let r = ui + .add(TextEdit::singleline(&mut self.host).hint_text(if serial { "/dev/ttyUSB0 or COM3" } else { "e.g. 192.168.1.1" }).desired_width(w)); if serial { ui.menu_button(icon::CARET_DOWN, |ui| { if self.ports.is_empty() { diff --git a/rust/app/src/raw_view.rs b/rust/app/src/raw_view.rs index ba1e5c4..facdb83 100644 --- a/rust/app/src/raw_view.rs +++ b/rust/app/src/raw_view.rs @@ -227,7 +227,9 @@ impl RawView { s.warn("Nothing to save"); return; } - let Some(path) = rfd::FileDialog::new().set_file_name("capture.json").add_filter("JSON", &["json"]).save_file() else { return }; + let Some(path) = rfd::FileDialog::new().set_file_name("capture.json").add_filter("JSON", &["json"]).save_file() else { + return; + }; match s.svc.sessions.raw_save_capture(&self.session, &path) { Ok(()) => s.success("Capture saved"), Err(e) => s.error(format!("Save failed: {e}")), @@ -450,7 +452,9 @@ fn hex_job(line: &str, font: &FontId, t: &Tokens, fg: Color32) -> LayoutJob { /// HH:MM:SS for today, otherwise "Mon DD HH:MM:SS" (the web build's formatTimestamp). fn format_time(ms: i64) -> String { use chrono::{Local, TimeZone}; - let Some(d) = Local.timestamp_millis_opt(ms).single() else { return String::new() }; + let Some(d) = Local.timestamp_millis_opt(ms).single() else { + return String::new(); + }; if d.date_naive() == Local::now().date_naive() { d.format("%H:%M:%S").to_string() } else { d.format("%b %d %H:%M:%S").to_string() } } diff --git a/rust/app/src/recordings.rs b/rust/app/src/recordings.rs index bbc6ba5..d053333 100644 --- a/rust/app/src/recordings.rs +++ b/rust/app/src/recordings.rs @@ -15,7 +15,7 @@ const SPEEDS: [f64; 5] = [0.5, 1.0, 2.0, 5.0, 10.0]; /// m:ss, as the JS `_formatSeconds`. pub fn mmss(secs: f64) -> String { - if !(secs > 0.0) { + if secs.is_nan() || secs <= 0.0 { return "0:00".into(); } let s = secs.floor() as u64; @@ -97,7 +97,18 @@ pub struct Recordings { impl Default for Recordings { fn default() -> Self { - Recordings { open: false, list: vec![], info: None, loading: false, rx: None, age: 30, size: 1 << 30, status: String::new(), confirm_delete: None, playback: None } + Recordings { + open: false, + list: vec![], + info: None, + loading: false, + rx: None, + age: 30, + size: 1 << 30, + status: String::new(), + confirm_delete: None, + playback: None, + } } } @@ -150,7 +161,8 @@ impl Recordings { } let total = events.last().map_or(1.0, |e| e.elapsed).max(f64::EPSILON); let title = if header.title.is_empty() { title } else { header.title }; - let mut p = Playback { title, term: TermView::new(10_000, 13.0, CursorStyle::Block), events, total, pos: 0.0, next: 0, playing: false, speed: 1.0 }; + let mut p = + Playback { title, term: TermView::new(10_000, 13.0, CursorStyle::Block), events, total, pos: 0.0, next: 0, playing: false, speed: 1.0 }; p.play(); // autoplay self.playback = Some(p); } @@ -222,7 +234,11 @@ impl Recordings { ui.set_width(ui.available_width() - 64.0); ui.label(RichText::new(if r.title.is_empty() { &r.id } else { &r.title }).font(theme::semibold(13.0))); let date = r.created_at.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M"); - ui.label(RichText::new(format!("{date} · {} · {} · {}×{}", mmss(r.duration), widgets::human_size(r.size), r.width, r.height)).size(11.5).color(t.text_dim)); + ui.label( + RichText::new(format!("{date} · {} · {} · {}×{}", mmss(r.duration), widgets::human_size(r.size), r.width, r.height)) + .size(11.5) + .color(t.text_dim), + ); }); if widgets::icon_button(ui, icon::PLAY, "Play").clicked() { play = Some(i); @@ -245,7 +261,9 @@ impl Recordings { } fn delete_dialog(&mut self, ctx: &egui::Context, s: &mut Shared) { - let Some(r) = &self.confirm_delete else { return }; + let Some(r) = &self.confirm_delete else { + return; + }; let (mut yes, mut no) = (false, ctx.input(|i| i.key_pressed(Key::Escape))); let name = if r.title.is_empty() { r.id.clone() } else { r.title.clone() }; crate::chrome::modal(ctx, "rec-delete", 380.0, |ui| { @@ -309,11 +327,14 @@ impl Recordings { }); }); }); - egui::Frame::NONE.fill(egui::Color32::from_rgba_unmultiplied(theme.background[0], theme.background[1], theme.background[2], 255)).corner_radius(t.radius).show(ui, |ui| { - ui.set_min_size(ui.available_size()); - // Read-only: keyboard input and terminal replies are dropped. - let _ = p.term.ui(ui, theme, false); - }); + egui::Frame::NONE + .fill(egui::Color32::from_rgba_unmultiplied(theme.background[0], theme.background[1], theme.background[2], 255)) + .corner_radius(t.radius) + .show(ui, |ui| { + ui.set_min_size(ui.available_size()); + // Read-only: keyboard input and terminal replies are dropped. + let _ = p.term.ui(ui, theme, false); + }); }); if !open { self.playback = None; diff --git a/rust/app/src/sftp.rs b/rust/app/src/sftp.rs index c9c8c41..5afef0f 100644 --- a/rust/app/src/sftp.rs +++ b/rust/app/src/sftp.rs @@ -19,14 +19,27 @@ use std::sync::{Arc, Mutex}; /// Worker results, applied on the UI thread. enum Done { - Listed { session: String, path: String, files: Result, String> }, - Picker { session: String, result: Result<(String, Vec, bool), String> }, + Listed { + session: String, + path: String, + files: Result, String>, + }, + Picker { + session: String, + result: Result<(String, Vec, bool), String>, + }, Refresh(String), - TransferEnded { session: String, path: String }, + TransferEnded { + session: String, + path: String, + }, /// Always shown. Toast(String), /// Shown only while the panel for `session` is open (otherwise per-file toasts cover it). - Summary { session: String, text: String }, + Summary { + session: String, + text: String, + }, } #[derive(Clone)] @@ -372,10 +385,14 @@ impl Sftp { fn download(&mut self, s: &Shared, session: &str, files: Vec) { let targets: Vec<(FileEntry, PathBuf)> = if files.len() == 1 { - let Some(p) = rfd::FileDialog::new().set_file_name(&files[0].name).save_file() else { return }; + let Some(p) = rfd::FileDialog::new().set_file_name(&files[0].name).save_file() else { + return; + }; vec![(files[0].clone(), p)] } else { - let Some(dir) = rfd::FileDialog::new().set_title("Download to folder").pick_folder() else { return }; + let Some(dir) = rfd::FileDialog::new().set_title("Download to folder").pick_folder() else { + return; + }; files.into_iter().filter_map(|e| safe_sftp_upload_name(&e.name).ok().map(|n| (e, dir.join(n)))).collect() }; for (e, _) in &targets { @@ -422,7 +439,14 @@ impl Sftp { } /// Runs a single SFTP operation, then toasts `ok` and refreshes the listing. - fn op(&mut self, s: &Shared, session: &str, ok: &'static str, what: &'static str, f: impl FnOnce(&Service, &str) -> nexus_core::Result<()> + Send + 'static) { + fn op( + &mut self, + s: &Shared, + session: &str, + ok: &'static str, + what: &'static str, + f: impl FnOnce(&Service, &str) -> nexus_core::Result<()> + Send + 'static, + ) { let sid = session.to_string(); self.work(s, move |svc, out| { match f(svc, &sid) { @@ -460,7 +484,9 @@ impl Sftp { /// The dock panel. pub fn ui(&mut self, ui: &mut Ui, s: &mut Shared) { self.drain(s); - let Some(session) = self.open_for.clone() else { return }; + let Some(session) = self.open_for.clone() else { + return; + }; if !self.panels.contains_key(&session) { self.navigate(s, &session, None); } @@ -535,7 +561,9 @@ impl Sftp { }); }); - let Some(p) = self.panels.get(&session) else { return }; + let Some(p) = self.panels.get(&session) else { + return; + }; if let Some(go) = breadcrumb(ui, &p.cwd) { act = Some(Act::Nav(go)); } @@ -555,7 +583,11 @@ impl Sftp { ui.add(egui::Label::new(base_name(&x.path)).truncate()).on_hover_text(&x.path); ui.add(egui::ProgressBar::new(frac).desired_height(4.0).fill(t.accent)); let total = if x.total > 0 { widgets::human_size(x.total) } else { "?".into() }; - ui.label(RichText::new(format!("{} / {total} ({:.0}%)", widgets::human_size(x.transferred), frac * 100.0)).size(11.0).color(t.text_faint)); + ui.label( + RichText::new(format!("{} / {total} ({:.0}%)", widgets::human_size(x.transferred), frac * 100.0)) + .size(11.0) + .color(t.text_faint), + ); }); }); } @@ -595,7 +627,13 @@ impl Sftp { if p.selected.contains(&e.path) { ui.painter().rect_filled(r.expand2(vec2(6.0, 0.0)), 3.0, t.row_selected); } - ui.painter().text(r.left_center() + vec2(8.0, 0.0), egui::Align2::CENTER_CENTER, file_icon(e), egui::FontId::proportional(14.0), if e.is_dir { t.accent } else { t.text_dim }); + ui.painter().text( + r.left_center() + vec2(8.0, 0.0), + egui::Align2::CENTER_CENTER, + file_icon(e), + egui::FontId::proportional(14.0), + if e.is_dir { t.accent } else { t.text_dim }, + ); let meta_w = if e.is_dir { 0.0 } else { 72.0 }; let nr = egui::Rect::from_min_max(egui::pos2(r.min.x + 22.0, r.min.y), egui::pos2(r.max.x - meta_w - 4.0, r.max.y)); ui.scope_builder(egui::UiBuilder::new().max_rect(nr).layout(Layout::left_to_right(Align::Center)), |ui| { @@ -603,7 +641,13 @@ impl Sftp { }); if !e.is_dir { let mr = egui::Rect::from_min_max(egui::pos2(r.max.x - meta_w, r.min.y), r.max); - ui.painter().text(mr.right_center(), egui::Align2::RIGHT_CENTER, widgets::human_size(e.size), egui::FontId::proportional(11.5), t.text_faint); + ui.painter().text( + mr.right_center(), + egui::Align2::RIGHT_CENTER, + widgets::human_size(e.size), + egui::FontId::proportional(11.5), + t.text_faint, + ); if e.modified_at > 0 { ui.interact(mr, ui.id().with(("meta", i)), Sense::hover()).on_hover_text(modified(e.modified_at)); } @@ -718,14 +762,14 @@ impl Sftp { } } - - // --- dialogs -------------------------------------------------------------------------- /// Modal dialogs (file drop, directory picker), drawn every frame. pub fn dialogs(&mut self, ctx: &egui::Context, s: &mut Shared) { self.drain(s); - let Some(mut d) = self.dialog.take() else { return }; + let Some(mut d) = self.dialog.take() else { + return; + }; let t = Tokens::get(ctx); let esc = ctx.input(|i| i.key_pressed(Key::Escape)); let enter = ctx.input(|i| i.key_pressed(Key::Enter)); @@ -802,7 +846,9 @@ impl Sftp { modal(ctx, "sftp-drop", 420.0, |ui| { ui.label(RichText::new(format!("{} File Drop", icon::FILE_ARROW_UP)).font(theme::semibold(15.0))); ui.label(RichText::new(title).strong()); - ui.label(RichText::new(format!("Choose what to do with the dropped {}.", if paths.len() == 1 { "file" } else { "files" })).color(t.text_dim)); + ui.label( + RichText::new(format!("Choose what to do with the dropped {}.", if paths.len() == 1 { "file" } else { "files" })).color(t.text_dim), + ); ui.add_space(8.0); ui.horizontal(|ui| { upload = ui @@ -894,8 +940,15 @@ impl Sftp { // Keyboard: ↑↓ move (skipping unreadable folders), Enter uses, Backspace goes up. if ready { - let (down, up, back) = ctx.input_mut(|i| (i.consume_key(Modifiers::NONE, Key::ArrowDown), i.consume_key(Modifiers::NONE, Key::ArrowUp), i.key_pressed(Key::Backspace))); - let delta: isize = if down { 1 } else if up { -1 } else { 0 }; + let (down, up, back) = ctx + .input_mut(|i| (i.consume_key(Modifiers::NONE, Key::ArrowDown), i.consume_key(Modifiers::NONE, Key::ArrowUp), i.key_pressed(Key::Backspace))); + let delta: isize = if down { + 1 + } else if up { + -1 + } else { + 0 + }; if delta != 0 && !visible.is_empty() { let n = visible.len() as isize; let mut idx = pk.cursor.map_or(if delta > 0 { -1 } else { n }, |c| c as isize); @@ -1003,7 +1056,8 @@ impl Sftp { ui.separator(); ui.horizontal(|ui| { let suffix = if sel.is_none() && !pk.cwd_writable { " (read only)" } else { "" }; - ui.add(egui::Label::new(RichText::new(format!("{target}{suffix}")).font(theme::mono(11.5)).color(t.text_dim)).truncate()).on_hover_text(&target); + ui.add(egui::Label::new(RichText::new(format!("{target}{suffix}")).font(theme::mono(11.5)).color(t.text_dim)).truncate()) + .on_hover_text(&target); let status = match (sel.is_some(), writable) { (_, true) => "", (true, false) => "No write permission", diff --git a/rust/app/src/sidebar.rs b/rust/app/src/sidebar.rs index 9e97533..2997b17 100644 --- a/rust/app/src/sidebar.rs +++ b/rust/app/src/sidebar.rs @@ -74,7 +74,7 @@ fn list(app: &mut App, ui: &mut Ui) { fuzzy_score(&fields, &q).map(|s| (s, p.clone())) }) .collect(); - hits.sort_by(|a, b| b.0.cmp(&a.0)); + hits.sort_by_key(|h| std::cmp::Reverse(h.0)); if hits.is_empty() { widgets::empty(ui, icon::MAGNIFYING_GLASS, "No matches", "Try a host, name, tag or protocol."); } @@ -91,7 +91,7 @@ fn list(app: &mut App, ui: &mut Ui) { section(app, ui, "★ Favourites", "favourites", &favs); } let mut recent: Vec<&Profile> = profiles.iter().filter(|p| p.last_connected.is_some()).collect(); - recent.sort_by(|a, b| b.last_connected.cmp(&a.last_connected)); + recent.sort_by_key(|p| std::cmp::Reverse(p.last_connected)); recent.truncate(RECENT); if !recent.is_empty() { section(app, ui, "Recent", "recent", &recent); @@ -169,7 +169,13 @@ fn local_row(app: &mut App, ui: &mut Ui) { if resp.hovered() { ui.painter().rect_filled(r, 6.0, t.hover); } - ui.painter().text(r.left_center() + vec2(10.0, 0.0), Align2::LEFT_CENTER, icon::TERMINAL_WINDOW, egui::FontId::proportional(15.0), crate::views::protocol_color(nexus_core::models::Protocol::Local)); + ui.painter().text( + r.left_center() + vec2(10.0, 0.0), + Align2::LEFT_CENTER, + icon::TERMINAL_WINDOW, + egui::FontId::proportional(15.0), + crate::views::protocol_color(nexus_core::models::Protocol::Local), + ); ui.painter().text(r.left_center() + vec2(32.0, 0.0), Align2::LEFT_CENTER, "Local Machine", egui::FontId::proportional(13.0), t.text); if resp.on_hover_text("Open a terminal on this computer").clicked() { app.connect_local(); @@ -204,7 +210,14 @@ fn row(app: &mut App, ui: &mut Ui, p: &Profile, section: &str) { if p.favourite { ui.painter().text(r.right_center() - vec2(36.0, 0.0), Align2::RIGHT_CENTER, "★", egui::FontId::proportional(11.0), t.warning); } - let tip = format!("{}\n{} {}:{}{}", name, protocol_badge(p.protocol), p.host, p.port, if p.description.is_empty() { String::new() } else { format!("\n{}", p.description) }); + let tip = format!( + "{}\n{} {}:{}{}", + name, + protocol_badge(p.protocol), + p.host, + p.port, + if p.description.is_empty() { String::new() } else { format!("\n{}", p.description) } + ); let resp = resp.on_hover_text(tip); let multi = ui.input(|i| i.modifiers.command); if resp.double_clicked() { @@ -281,16 +294,31 @@ fn bulk_bar(app: &mut App, ui: &mut Ui) { } ui.menu_button(format!("{} Tags", icon::TAG), |ui| { if ui.button("Add tags…").clicked() { - app.dialog = Some(Dialog::Input { title: "Add tags".into(), label: "Comma-separated tags to add".into(), value: String::new(), then: InputThen::Tags { profiles: ids.clone(), replace: false } }); + app.dialog = Some(Dialog::Input { + title: "Add tags".into(), + label: "Comma-separated tags to add".into(), + value: String::new(), + then: InputThen::Tags { profiles: ids.clone(), replace: false }, + }); ui.close(); } if ui.button("Replace tags…").clicked() { - app.dialog = Some(Dialog::Input { title: "Replace tags".into(), label: "Comma-separated tags".into(), value: String::new(), then: InputThen::Tags { profiles: ids.clone(), replace: true } }); + app.dialog = Some(Dialog::Input { + title: "Replace tags".into(), + label: "Comma-separated tags".into(), + value: String::new(), + then: InputThen::Tags { profiles: ids.clone(), replace: true }, + }); ui.close(); } }); if ui.small_button(format!("{} Move group", icon::FOLDER)).clicked() { - app.dialog = Some(Dialog::Input { title: "Move to group".into(), label: "Group name (empty for Ungrouped)".into(), value: String::new(), then: InputThen::MoveGroup { profiles: ids.clone() } }); + app.dialog = Some(Dialog::Input { + title: "Move to group".into(), + label: "Group name (empty for Ungrouped)".into(), + value: String::new(), + then: InputThen::MoveGroup { profiles: ids.clone() }, + }); } if ui.small_button(format!("{} Delete", icon::TRASH)).clicked() { app.dialog = Some(Dialog::Delete { profiles: ids.clone() }); diff --git a/rust/app/src/snippets.rs b/rust/app/src/snippets.rs index 44cfadf..598caea 100644 --- a/rust/app/src/snippets.rs +++ b/rust/app/src/snippets.rs @@ -71,7 +71,15 @@ pub struct Snippets { impl Default for Snippets { fn default() -> Self { - Snippets { open: false, list: vec![], search: String::new(), pane: Pane::Empty("Select a snippet or create a new one"), confirm_delete: None, vars: None, loaded: false } + Snippets { + open: false, + list: vec![], + search: String::new(), + pane: Pane::Empty("Select a snippet or create a new one"), + confirm_delete: None, + vars: None, + loaded: false, + } } } @@ -263,7 +271,9 @@ impl Snippets { /// Resolves variables then types the command (plus newline) into the focused session. fn send(&mut self, s: &mut Shared, command: &str) { - let Some(id) = s.focused_session() else { return s.error("No active session") }; + let Some(id) = s.focused_session() else { + return s.error("No active session"); + }; if s.session_state(&id) != Some(SessionState::Connected) { return s.error("Session is not connected"); } @@ -306,7 +316,9 @@ impl Snippets { } fn delete_dialog(&mut self, ctx: &egui::Context, s: &mut Shared) { - let Some(id) = self.confirm_delete.clone() else { return }; + let Some(id) = self.confirm_delete.clone() else { + return; + }; let name = self.list.iter().find(|x| x.id == id).map(|x| x.name.clone()).unwrap_or_default(); let (mut yes, mut no) = (false, ctx.input(|i| i.key_pressed(Key::Escape))); crate::chrome::modal(ctx, "snippet-delete", 360.0, |ui| { @@ -335,7 +347,16 @@ impl Snippets { fn blank() -> Snippet { let now = nexus_core::models::now(); - Snippet { id: String::new(), name: String::new(), command: String::new(), description: String::new(), folder: String::new(), tags: vec![], created_at: now, updated_at: now } + Snippet { + id: String::new(), + name: String::new(), + command: String::new(), + description: String::new(), + folder: String::new(), + tags: vec![], + created_at: now, + updated_at: now, + } } fn send_text(s: &mut Shared, text: String) { diff --git a/rust/app/src/splits.rs b/rust/app/src/splits.rs index 124d921..a8d3766 100644 --- a/rust/app/src/splits.rs +++ b/rust/app/src/splits.rs @@ -52,7 +52,13 @@ impl Split { fn at(&mut self, path: &[bool]) -> &mut Split { match (self, path.split_first()) { - (Split::Node { a, b, .. }, Some((side, rest))) => if *side { b.at(rest) } else { a.at(rest) }, + (Split::Node { a, b, .. }, Some((side, rest))) => { + if *side { + b.at(rest) + } else { + a.at(rest) + } + } (s, _) => s, } } @@ -124,20 +130,32 @@ fn divide(r: Rect, dir: Dir, ratio: f32) -> (Rect, Rect, Rect) { match dir { Dir::Right => { let x = r.left() + (r.width() - GAP) * ratio; - (Rect::from_min_max(r.min, pos2(x, r.bottom())), Rect::from_min_max(pos2(x + GAP, r.top()), r.max), Rect::from_min_max(pos2(x, r.top()), pos2(x + GAP, r.bottom()))) + ( + Rect::from_min_max(r.min, pos2(x, r.bottom())), + Rect::from_min_max(pos2(x + GAP, r.top()), r.max), + Rect::from_min_max(pos2(x, r.top()), pos2(x + GAP, r.bottom())), + ) } Dir::Down => { let y = r.top() + (r.height() - GAP) * ratio; - (Rect::from_min_max(r.min, pos2(r.right(), y)), Rect::from_min_max(pos2(r.left(), y + GAP), r.max), Rect::from_min_max(pos2(r.left(), y), pos2(r.right(), y + GAP))) + ( + Rect::from_min_max(r.min, pos2(r.right(), y)), + Rect::from_min_max(pos2(r.left(), y + GAP), r.max), + Rect::from_min_max(pos2(r.left(), y), pos2(r.right(), y + GAP)), + ) } } } /// Splits the focused pane of the active tab. pub fn split_focused(app: &mut App, dir: Dir) { - let Some(tab_id) = app.s.active.clone() else { return }; + let Some(tab_id) = app.s.active.clone() else { + return; + }; let focused = app.s.focused_session().unwrap_or(tab_id.clone()); - let Some(tab) = app.s.tabs.iter_mut().find(|t| t.id == tab_id) else { return }; + let Some(tab) = app.s.tabs.iter_mut().find(|t| t.id == tab_id) else { + return; + }; match &mut tab.split { None => tab.split = Some(Split::Node { dir, ratio: 0.5, a: Box::new(Split::Leaf(Some(tab_id.clone()))), b: Box::new(Split::Leaf(None)) }), Some(sp) => { @@ -152,7 +170,9 @@ pub fn split_focused(app: &mut App, dir: Dir) { /// removed too: the next pane takes over the tab. pub fn remove_session(s: &mut Shared, session: &str) { for i in 0..s.tabs.len() { - let Some(sp) = &mut s.tabs[i].split else { continue }; + let Some(sp) = &mut s.tabs[i].split else { + continue; + }; let mut path = vec![]; if !sp.find(session, &mut path) { continue; @@ -202,7 +222,8 @@ pub fn render(app: &mut App, ui: &mut Ui, tab_id: &str) { }; let min = (MIN_PANE / len).min(0.45); let ratio = ((pos - start) / len).clamp(min, 1.0 - min); - if let Some(Split::Node { ratio: r, .. }) = app.s.tabs.iter_mut().find(|tb| tb.id == tab_id).and_then(|tb| tb.split.as_mut()).map(|sp| sp.at(&path)) { + if let Some(Split::Node { ratio: r, .. }) = app.s.tabs.iter_mut().find(|tb| tb.id == tab_id).and_then(|tb| tb.split.as_mut()).map(|sp| sp.at(&path)) + { *r = ratio; } } @@ -213,9 +234,19 @@ pub fn render(app: &mut App, ui: &mut Ui, tab_id: &str) { let body = Rect::from_min_max(pos2(rect.left(), head.bottom()), rect.max); let focused = session.is_some() && app.s.focused == session; ui.painter().rect_filled(head, 0.0, if focused { t.card } else { t.tab_strip }); - let title = session.as_deref().and_then(|s| app.s.pane(s)).map(|p| (p.info.tab_name.clone(), protocol_color(p.info.protocol))).unwrap_or(("New pane".into(), t.text_faint)); + let title = session + .as_deref() + .and_then(|s| app.s.pane(s)) + .map(|p| (p.info.tab_name.clone(), protocol_color(p.info.protocol))) + .unwrap_or(("New pane".into(), t.text_faint)); ui.painter().circle_filled(head.left_center() + vec2(10.0, 0.0), 3.5, title.1); - ui.painter().with_clip_rect(head.shrink2(vec2(24.0, 0.0))).text(head.left_center() + vec2(20.0, 0.0), Align2::LEFT_CENTER, title.0, egui::FontId::proportional(11.5), if focused { t.text } else { t.text_dim }); + ui.painter().with_clip_rect(head.shrink2(vec2(24.0, 0.0))).text( + head.left_center() + vec2(20.0, 0.0), + Align2::LEFT_CENTER, + title.0, + egui::FontId::proportional(11.5), + if focused { t.text } else { t.text_dim }, + ); let xr = Rect::from_center_size(head.right_center() - vec2(12.0, 0.0), vec2(16.0, 16.0)); if widgets::icon_button(&mut ui.new_child(egui::UiBuilder::new().max_rect(xr)), icon::X, "Close pane").clicked() { close = Some((path.clone(), session.clone())); diff --git a/rust/app/src/ssh_keys.rs b/rust/app/src/ssh_keys.rs index 4d526de..6230966 100644 --- a/rust/app/src/ssh_keys.rs +++ b/rust/app/src/ssh_keys.rs @@ -90,7 +90,8 @@ impl SshKeys { } }); }); - ui.add(egui::Label::new(RichText::new(&k.public_key).font(theme::mono(11.0)).color(t.text_dim)).truncate()).on_hover_text(&k.public_key); + ui.add(egui::Label::new(RichText::new(&k.public_key).font(theme::mono(11.0)).color(t.text_dim)).truncate()) + .on_hover_text(&k.public_key); }); ui.add_space(2.0); } @@ -111,7 +112,8 @@ impl SshKeys { s.error("Passphrases do not match"); } else { let (svc, kind, bits, pass) = (s.svc.clone(), f.kind, f.bits, f.pass.clone()); - self.job = Some(Job::spawn(ctx, move || svc.generate_ssh_key(&name, kind, bits, &pass).map(|_| name).map_err(|e| e.to_string()))); + self.job = + Some(Job::spawn(ctx, move || svc.generate_ssh_key(&name, kind, bits, &pass).map(|_| name).map_err(|e| e.to_string()))); } } if self.job.is_none() && ui.button("Cancel").clicked() { @@ -125,7 +127,9 @@ impl SshKeys { } fn delete_dialog(&mut self, ctx: &egui::Context, s: &mut Shared) { - let Some(name) = self.confirm_delete.clone() else { return }; + let Some(name) = self.confirm_delete.clone() else { + return; + }; let t = Tokens::get(ctx); let mut done = false; let r = egui::Modal::new(egui::Id::new("delete-key")).show(ctx, |ui| { diff --git a/rust/app/src/state.rs b/rust/app/src/state.rs index 21af468..b218276 100644 --- a/rust/app/src/state.rs +++ b/rust/app/src/state.rs @@ -8,36 +8,27 @@ use std::collections::HashSet; use std::sync::Arc; /// Things a feature asks the main window to do (handled once per frame, in order). +#[allow(clippy::large_enum_variant)] // a per-frame queue; boxing buys nothing pub enum Request { /// Open the profile editor for a new profile (optionally pre-set protocol / folder). - NewProfile { protocol: Option, folder: String }, + NewProfile { + protocol: Option, + folder: String, + }, EditProfile(String), Connect(String), ConnectQuick(Profile), - ConnectLocal, /// Reload `Shared::profiles` from the database. ReloadProfiles, /// Re-read and apply preferences (theme, terminal theme, fonts…). ReloadPreferences, - QuickConnect, - Settings(SettingsTab), - SshKeys, ExportEncrypted(Vec), ImportEncrypted, - ExportJson, - ImportJson, - Recordings, - Snippets, - Triggers, Sftp(String), - PortForwards(String), /// Type text into the active session (snippets). SendToActive(String), /// Bytes to a specific session. Write(String, Vec), - Disconnect(String), - CommandPalette, - About, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] @@ -174,11 +165,6 @@ impl Shared { self.panes.get_mut(id) } - /// The tab that shows `session` (its own, or the one it's split into). - pub fn tab_of(&self, session: &str) -> Option<&Tab> { - self.tabs.iter().find(|t| t.id == session || t.split.as_ref().is_some_and(|sp| sp.sessions().iter().any(|x| x == session))) - } - /// The session keyboard actions apply to: the focused pane, else the active tab. pub fn focused_session(&self) -> Option { self.focused.clone().or_else(|| self.active.clone()) @@ -208,9 +194,5 @@ impl Shared { /// Formats a command-key shortcut for menus: "⌘K" on macOS, "Ctrl+K" elsewhere. pub fn keys(s: &str) -> String { - if cfg!(target_os = "macos") { - s.replace("Mod+Shift+", "⇧⌘").replace("Mod+", "⌘") - } else { - s.replace("Mod+", "Ctrl+") - } + if cfg!(target_os = "macos") { s.replace("Mod+Shift+", "⇧⌘").replace("Mod+", "⌘") } else { s.replace("Mod+", "Ctrl+") } } diff --git a/rust/app/src/tabs.rs b/rust/app/src/tabs.rs index c714461..2269960 100644 --- a/rust/app/src/tabs.rs +++ b/rust/app/src/tabs.rs @@ -141,7 +141,13 @@ fn strip(app: &mut App, ui: &mut Ui) { if sftp { let fr = Rect::from_center_size(pos2(body.right() - 33.0, body.center().y), vec2(16.0, 16.0)); let fresp = ui.interact(fr, ui.id().with(("sftp", id)), Sense::click()); - let c = if app.sftp.open_for() == Some(id.as_str()) { t.accent } else if fresp.hovered() { t.text } else { t.text_faint }; + let c = if app.sftp.open_for() == Some(id.as_str()) { + t.accent + } else if fresp.hovered() { + t.text + } else { + t.text_faint + }; ui.painter().text(fr.center(), Align2::CENTER_CENTER, icon::FOLDER_SIMPLE, egui::FontId::proportional(12.0), c); if fresp.on_hover_text("SFTP browser").clicked() { app.s.request(Request::Sftp(id.clone())); @@ -211,7 +217,11 @@ fn strip(app: &mut App, ui: &mut Ui) { if widgets::icon_button(&mut right, icon::PLUS, "New connection").clicked() { app.s.request(Request::NewProfile { protocol: None, folder: String::new() }); } - let b = widgets::icon_button(&mut right, icon::BROADCAST, if app.s.broadcast { "Broadcast ON — click to stop, right-click for targets" } else { "Broadcast input (right-click to pick targets)" }); + let b = widgets::icon_button( + &mut right, + icon::BROADCAST, + if app.s.broadcast { "Broadcast ON — click to stop, right-click for targets" } else { "Broadcast input (right-click to pick targets)" }, + ); if app.s.broadcast { right.painter().rect_stroke(b.rect, 4.0, Stroke::new(1.5, t.warning), egui::StrokeKind::Inside); } @@ -281,7 +291,9 @@ fn tab_action(app: &mut App, id: &str, a: &str) { /// Draws one session into `rect`. pub fn pane(app: &mut App, ui: &mut Ui, id: &str, rect: Rect) { - let Some(mut pane) = app.s.panes.remove(id) else { return }; + let Some(mut pane) = app.s.panes.remove(id) else { + return; + }; let t = Tokens::get(ui.ctx()); let theme_id = app.s.prefs.terminal_theme.clone(); let focused_tab = app.s.focused.as_deref() == Some(id) || (app.s.focused.is_none() && app.s.active.as_deref() == Some(id)); diff --git a/rust/app/src/terminal.rs b/rust/app/src/terminal.rs index d705318..e9fee00 100644 --- a/rust/app/src/terminal.rs +++ b/rust/app/src/terminal.rs @@ -143,14 +143,6 @@ impl TermView { self.focus_requested = true; } - pub fn alt_screen(&self) -> bool { - self.term.mode().contains(TermMode::ALT_SCREEN) - } - - pub fn has_selection(&self) -> bool { - self.term.selection.as_ref().is_some_and(|s| !s.is_empty()) - } - pub fn selection_text(&self) -> Option { self.term.selection_to_string().filter(|s| !s.is_empty()) } @@ -284,13 +276,13 @@ impl TermView { let focused = ui.memory(|m| m.has_focus(id)); out.focused = focused; if focused { - ui.memory_mut(|m| { - m.set_focus_lock_filter(id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }) - }); + ui.memory_mut(|m| m.set_focus_lock_filter(id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true })); self.keyboard(ui, &mut out); let cur = self.term.grid().cursor.point; let cursor_rect = Rect::from_min_size(origin + vec2(cur.column.0 as f32 * cw, cur.line.0 as f32 * ch), vec2(cw, ch)); - ui.output_mut(|o| o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Terminal, rect, cursor_rect, should_interrupt_composition: false })); + ui.output_mut(|o| { + o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Terminal, rect, cursor_rect, should_interrupt_composition: false }) + }); } self.mouse(ui, &resp, origin, cw, ch, &mut out); @@ -475,7 +467,9 @@ impl TermView { continue; } if *pressed { - if modifiers.command && let Some(url) = self.url_at(origin, cw, ch, *pos) { + if modifiers.command + && let Some(url) = self.url_at(origin, cw, ch, *pos) + { ui.ctx().open_url(egui::OpenUrl::new_tab(url)); continue; } @@ -560,11 +554,8 @@ impl TermView { let selection = self.term.selection.as_ref().and_then(|s| s.to_range(&self.term)); let sel_bg = rgb(theme.selection); self.refresh_find(); - let finds: Vec<(i32, usize, usize, bool)> = self - .find - .as_ref() - .map(|f| f.matches.iter().enumerate().map(|(i, &(l, a, b))| (l, a, b, i == f.current)).collect()) - .unwrap_or_default(); + let finds: Vec<(i32, usize, usize, bool)> = + self.find.as_ref().map(|f| f.matches.iter().enumerate().map(|(i, &(l, a, b))| (l, a, b, i == f.current)).collect()).unwrap_or_default(); let ncols = self.cols as usize; for vr in 0..self.rows as i32 { let line = Line(vr - offset); @@ -818,7 +809,7 @@ pub fn key_bytes(key: Key, m: Modifiers, mode: TermMode) -> Option> { Key::F10 => tilde(21), Key::F11 => tilde(23), Key::F12 => tilde(24), - _ if m.ctrl && !m.command_only() => { + _ if m.ctrl && !mac_cmd_only(m) => { let b = match key { Key::Space | Key::Num2 => 0, Key::OpenBracket | Key::Num3 => 0x1b, @@ -840,15 +831,9 @@ pub fn key_bytes(key: Key, m: Modifiers, mode: TermMode) -> Option> { }) } -trait CommandOnly { - fn command_only(&self) -> bool; -} - -impl CommandOnly for Modifiers { - /// On macOS, Cmd is the app's modifier; Ctrl still produces control codes. - fn command_only(&self) -> bool { - cfg!(target_os = "macos") && self.mac_cmd && !self.ctrl - } +/// On macOS Cmd is the app's modifier while Ctrl still produces control codes. +fn mac_cmd_only(m: Modifiers) -> bool { + cfg!(target_os = "macos") && m.mac_cmd && !m.ctrl } #[cfg(test)] @@ -862,6 +847,9 @@ mod tests { assert_eq!(key_bytes(Key::ArrowUp, none, TermMode::APP_CURSOR).unwrap(), b"\x1bOA"); assert_eq!(key_bytes(Key::ArrowLeft, Modifiers::CTRL, TermMode::empty()).unwrap(), b"\x1b[1;5D"); assert_eq!(key_bytes(Key::C, Modifiers::CTRL, TermMode::empty()).unwrap(), [3]); + // egui reports Ctrl as ctrl + command on Linux/Windows; it must still send a control code. + let ctrl_cmd = Modifiers { ctrl: true, command: true, ..Default::default() }; + assert_eq!(key_bytes(Key::D, ctrl_cmd, TermMode::empty()).unwrap(), [4]); assert_eq!(key_bytes(Key::F5, none, TermMode::empty()).unwrap(), b"\x1b[15~"); assert_eq!(key_bytes(Key::Backspace, none, TermMode::empty()).unwrap(), [0x7f]); assert!(key_bytes(Key::A, none, TermMode::empty()).is_none()); diff --git a/rust/app/src/terminal_themes.rs b/rust/app/src/terminal_themes.rs index f14367a..1c9d274 100644 --- a/rust/app/src/terminal_themes.rs +++ b/rust/app/src/terminal_themes.rs @@ -21,7 +21,24 @@ pub const THEMES: &[TermTheme] = &[ cursor: [78, 205, 196, 255], cursor_accent: [28, 28, 28, 255], selection: [78, 205, 196, 76], - ansi: [[28, 28, 28, 255], [239, 83, 80, 255], [102, 187, 106, 255], [255, 167, 38, 255], [66, 165, 245, 255], [171, 71, 188, 255], [78, 205, 196, 255], [228, 228, 228, 255], [85, 85, 85, 255], [255, 112, 67, 255], [129, 199, 132, 255], [255, 204, 2, 255], [100, 181, 246, 255], [206, 147, 216, 255], [128, 222, 234, 255], [255, 255, 255, 255]], + ansi: [ + [28, 28, 28, 255], + [239, 83, 80, 255], + [102, 187, 106, 255], + [255, 167, 38, 255], + [66, 165, 245, 255], + [171, 71, 188, 255], + [78, 205, 196, 255], + [228, 228, 228, 255], + [85, 85, 85, 255], + [255, 112, 67, 255], + [129, 199, 132, 255], + [255, 204, 2, 255], + [100, 181, 246, 255], + [206, 147, 216, 255], + [128, 222, 234, 255], + [255, 255, 255, 255], + ], }, TermTheme { id: "solarized-dark", @@ -31,7 +48,24 @@ pub const THEMES: &[TermTheme] = &[ cursor: [147, 161, 161, 255], cursor_accent: [0, 43, 54, 255], selection: [147, 161, 161, 76], - ansi: [[7, 54, 66, 255], [220, 50, 47, 255], [133, 153, 0, 255], [181, 137, 0, 255], [38, 139, 210, 255], [211, 54, 130, 255], [42, 161, 152, 255], [238, 232, 213, 255], [88, 110, 117, 255], [203, 75, 22, 255], [88, 110, 117, 255], [101, 123, 131, 255], [131, 148, 150, 255], [108, 113, 196, 255], [147, 161, 161, 255], [253, 246, 227, 255]], + ansi: [ + [7, 54, 66, 255], + [220, 50, 47, 255], + [133, 153, 0, 255], + [181, 137, 0, 255], + [38, 139, 210, 255], + [211, 54, 130, 255], + [42, 161, 152, 255], + [238, 232, 213, 255], + [88, 110, 117, 255], + [203, 75, 22, 255], + [88, 110, 117, 255], + [101, 123, 131, 255], + [131, 148, 150, 255], + [108, 113, 196, 255], + [147, 161, 161, 255], + [253, 246, 227, 255], + ], }, TermTheme { id: "monokai", @@ -41,7 +75,24 @@ pub const THEMES: &[TermTheme] = &[ cursor: [248, 248, 240, 255], cursor_accent: [39, 40, 34, 255], selection: [73, 72, 62, 153], - ansi: [[39, 40, 34, 255], [249, 38, 114, 255], [166, 226, 46, 255], [244, 191, 117, 255], [102, 217, 239, 255], [174, 129, 255, 255], [161, 239, 228, 255], [248, 248, 242, 255], [117, 113, 94, 255], [249, 38, 114, 255], [166, 226, 46, 255], [244, 191, 117, 255], [102, 217, 239, 255], [174, 129, 255, 255], [161, 239, 228, 255], [249, 248, 245, 255]], + ansi: [ + [39, 40, 34, 255], + [249, 38, 114, 255], + [166, 226, 46, 255], + [244, 191, 117, 255], + [102, 217, 239, 255], + [174, 129, 255, 255], + [161, 239, 228, 255], + [248, 248, 242, 255], + [117, 113, 94, 255], + [249, 38, 114, 255], + [166, 226, 46, 255], + [244, 191, 117, 255], + [102, 217, 239, 255], + [174, 129, 255, 255], + [161, 239, 228, 255], + [249, 248, 245, 255], + ], }, TermTheme { id: "nord", @@ -51,7 +102,24 @@ pub const THEMES: &[TermTheme] = &[ cursor: [216, 222, 233, 255], cursor_accent: [46, 52, 64, 255], selection: [136, 192, 208, 76], - ansi: [[59, 66, 82, 255], [191, 97, 106, 255], [163, 190, 140, 255], [235, 203, 139, 255], [129, 161, 193, 255], [180, 142, 173, 255], [136, 192, 208, 255], [229, 233, 240, 255], [76, 86, 106, 255], [191, 97, 106, 255], [163, 190, 140, 255], [235, 203, 139, 255], [129, 161, 193, 255], [180, 142, 173, 255], [143, 188, 187, 255], [236, 239, 244, 255]], + ansi: [ + [59, 66, 82, 255], + [191, 97, 106, 255], + [163, 190, 140, 255], + [235, 203, 139, 255], + [129, 161, 193, 255], + [180, 142, 173, 255], + [136, 192, 208, 255], + [229, 233, 240, 255], + [76, 86, 106, 255], + [191, 97, 106, 255], + [163, 190, 140, 255], + [235, 203, 139, 255], + [129, 161, 193, 255], + [180, 142, 173, 255], + [143, 188, 187, 255], + [236, 239, 244, 255], + ], }, TermTheme { id: "dracula", @@ -61,7 +129,24 @@ pub const THEMES: &[TermTheme] = &[ cursor: [248, 248, 242, 255], cursor_accent: [40, 42, 54, 255], selection: [68, 71, 90, 153], - ansi: [[33, 34, 44, 255], [255, 85, 85, 255], [80, 250, 123, 255], [241, 250, 140, 255], [189, 147, 249, 255], [255, 121, 198, 255], [139, 233, 253, 255], [248, 248, 242, 255], [98, 114, 164, 255], [255, 110, 110, 255], [105, 255, 148, 255], [255, 255, 165, 255], [214, 172, 255, 255], [255, 146, 223, 255], [164, 255, 255, 255], [255, 255, 255, 255]], + ansi: [ + [33, 34, 44, 255], + [255, 85, 85, 255], + [80, 250, 123, 255], + [241, 250, 140, 255], + [189, 147, 249, 255], + [255, 121, 198, 255], + [139, 233, 253, 255], + [248, 248, 242, 255], + [98, 114, 164, 255], + [255, 110, 110, 255], + [105, 255, 148, 255], + [255, 255, 165, 255], + [214, 172, 255, 255], + [255, 146, 223, 255], + [164, 255, 255, 255], + [255, 255, 255, 255], + ], }, TermTheme { id: "one-dark", @@ -71,7 +156,24 @@ pub const THEMES: &[TermTheme] = &[ cursor: [82, 139, 255, 255], cursor_accent: [40, 44, 52, 255], selection: [82, 139, 255, 76], - ansi: [[40, 44, 52, 255], [224, 108, 117, 255], [152, 195, 121, 255], [229, 192, 123, 255], [97, 175, 239, 255], [198, 120, 221, 255], [86, 182, 194, 255], [171, 178, 191, 255], [92, 99, 112, 255], [224, 108, 117, 255], [152, 195, 121, 255], [229, 192, 123, 255], [97, 175, 239, 255], [198, 120, 221, 255], [86, 182, 194, 255], [255, 255, 255, 255]], + ansi: [ + [40, 44, 52, 255], + [224, 108, 117, 255], + [152, 195, 121, 255], + [229, 192, 123, 255], + [97, 175, 239, 255], + [198, 120, 221, 255], + [86, 182, 194, 255], + [171, 178, 191, 255], + [92, 99, 112, 255], + [224, 108, 117, 255], + [152, 195, 121, 255], + [229, 192, 123, 255], + [97, 175, 239, 255], + [198, 120, 221, 255], + [86, 182, 194, 255], + [255, 255, 255, 255], + ], }, TermTheme { id: "gruvbox-dark", @@ -81,7 +183,24 @@ pub const THEMES: &[TermTheme] = &[ cursor: [235, 219, 178, 255], cursor_accent: [40, 40, 40, 255], selection: [214, 93, 14, 76], - ansi: [[40, 40, 40, 255], [204, 36, 29, 255], [152, 151, 26, 255], [215, 153, 33, 255], [69, 133, 136, 255], [177, 98, 134, 255], [104, 157, 106, 255], [168, 153, 132, 255], [146, 131, 116, 255], [251, 73, 52, 255], [184, 187, 38, 255], [250, 189, 47, 255], [131, 165, 152, 255], [211, 134, 155, 255], [142, 192, 124, 255], [235, 219, 178, 255]], + ansi: [ + [40, 40, 40, 255], + [204, 36, 29, 255], + [152, 151, 26, 255], + [215, 153, 33, 255], + [69, 133, 136, 255], + [177, 98, 134, 255], + [104, 157, 106, 255], + [168, 153, 132, 255], + [146, 131, 116, 255], + [251, 73, 52, 255], + [184, 187, 38, 255], + [250, 189, 47, 255], + [131, 165, 152, 255], + [211, 134, 155, 255], + [142, 192, 124, 255], + [235, 219, 178, 255], + ], }, TermTheme { id: "tokyo-night", @@ -91,7 +210,24 @@ pub const THEMES: &[TermTheme] = &[ cursor: [192, 202, 245, 255], cursor_accent: [26, 27, 38, 255], selection: [41, 46, 66, 230], - ansi: [[21, 22, 30, 255], [247, 118, 142, 255], [158, 206, 106, 255], [224, 175, 104, 255], [122, 162, 247, 255], [187, 154, 247, 255], [125, 207, 255, 255], [169, 177, 214, 255], [65, 72, 104, 255], [247, 118, 142, 255], [158, 206, 106, 255], [224, 175, 104, 255], [122, 162, 247, 255], [187, 154, 247, 255], [125, 207, 255, 255], [192, 202, 245, 255]], + ansi: [ + [21, 22, 30, 255], + [247, 118, 142, 255], + [158, 206, 106, 255], + [224, 175, 104, 255], + [122, 162, 247, 255], + [187, 154, 247, 255], + [125, 207, 255, 255], + [169, 177, 214, 255], + [65, 72, 104, 255], + [247, 118, 142, 255], + [158, 206, 106, 255], + [224, 175, 104, 255], + [122, 162, 247, 255], + [187, 154, 247, 255], + [125, 207, 255, 255], + [192, 202, 245, 255], + ], }, TermTheme { id: "catppuccin-mocha", @@ -101,7 +237,24 @@ pub const THEMES: &[TermTheme] = &[ cursor: [245, 224, 220, 255], cursor_accent: [30, 30, 46, 255], selection: [88, 91, 112, 128], - ansi: [[69, 71, 90, 255], [243, 139, 168, 255], [166, 227, 161, 255], [249, 226, 175, 255], [137, 180, 250, 255], [245, 194, 231, 255], [148, 226, 213, 255], [186, 194, 222, 255], [88, 91, 112, 255], [243, 139, 168, 255], [166, 227, 161, 255], [249, 226, 175, 255], [137, 180, 250, 255], [245, 194, 231, 255], [148, 226, 213, 255], [166, 173, 200, 255]], + ansi: [ + [69, 71, 90, 255], + [243, 139, 168, 255], + [166, 227, 161, 255], + [249, 226, 175, 255], + [137, 180, 250, 255], + [245, 194, 231, 255], + [148, 226, 213, 255], + [186, 194, 222, 255], + [88, 91, 112, 255], + [243, 139, 168, 255], + [166, 227, 161, 255], + [249, 226, 175, 255], + [137, 180, 250, 255], + [245, 194, 231, 255], + [148, 226, 213, 255], + [166, 173, 200, 255], + ], }, TermTheme { id: "rose-pine", @@ -111,7 +264,24 @@ pub const THEMES: &[TermTheme] = &[ cursor: [82, 79, 103, 255], cursor_accent: [224, 222, 244, 255], selection: [110, 106, 134, 102], - ansi: [[38, 35, 58, 255], [235, 111, 146, 255], [49, 116, 143, 255], [246, 193, 119, 255], [156, 207, 216, 255], [196, 167, 231, 255], [235, 188, 186, 255], [224, 222, 244, 255], [110, 106, 134, 255], [235, 111, 146, 255], [49, 116, 143, 255], [246, 193, 119, 255], [156, 207, 216, 255], [196, 167, 231, 255], [235, 188, 186, 255], [224, 222, 244, 255]], + ansi: [ + [38, 35, 58, 255], + [235, 111, 146, 255], + [49, 116, 143, 255], + [246, 193, 119, 255], + [156, 207, 216, 255], + [196, 167, 231, 255], + [235, 188, 186, 255], + [224, 222, 244, 255], + [110, 106, 134, 255], + [235, 111, 146, 255], + [49, 116, 143, 255], + [246, 193, 119, 255], + [156, 207, 216, 255], + [196, 167, 231, 255], + [235, 188, 186, 255], + [224, 222, 244, 255], + ], }, TermTheme { id: "solarized-light", @@ -121,7 +291,24 @@ pub const THEMES: &[TermTheme] = &[ cursor: [88, 110, 117, 255], cursor_accent: [253, 246, 227, 255], selection: [7, 54, 66, 51], - ansi: [[7, 54, 66, 255], [220, 50, 47, 255], [133, 153, 0, 255], [181, 137, 0, 255], [38, 139, 210, 255], [211, 54, 130, 255], [42, 161, 152, 255], [238, 232, 213, 255], [0, 43, 54, 255], [203, 75, 22, 255], [88, 110, 117, 255], [101, 123, 131, 255], [131, 148, 150, 255], [108, 113, 196, 255], [147, 161, 161, 255], [253, 246, 227, 255]], + ansi: [ + [7, 54, 66, 255], + [220, 50, 47, 255], + [133, 153, 0, 255], + [181, 137, 0, 255], + [38, 139, 210, 255], + [211, 54, 130, 255], + [42, 161, 152, 255], + [238, 232, 213, 255], + [0, 43, 54, 255], + [203, 75, 22, 255], + [88, 110, 117, 255], + [101, 123, 131, 255], + [131, 148, 150, 255], + [108, 113, 196, 255], + [147, 161, 161, 255], + [253, 246, 227, 255], + ], }, ]; diff --git a/rust/app/src/theme.rs b/rust/app/src/theme.rs index 48a9e5e..761cb23 100644 --- a/rust/app/src/theme.rs +++ b/rust/app/src/theme.rs @@ -212,7 +212,12 @@ pub fn install_fonts(ctx: &egui::Context) { /// The first wide-coverage (CJK) system font found on this machine. fn system_fallback_font() -> Option> { let candidates: &[&str] = if cfg!(target_os = "macos") { - &["/System/Library/Fonts/PingFang.ttc", "/System/Library/Fonts/Hiragino Sans GB.ttc", "/Library/Fonts/Arial Unicode.ttf", "/System/Library/Fonts/Supplemental/Arial Unicode.ttf"] + &[ + "/System/Library/Fonts/PingFang.ttc", + "/System/Library/Fonts/Hiragino Sans GB.ttc", + "/Library/Fonts/Arial Unicode.ttf", + "/System/Library/Fonts/Supplemental/Arial Unicode.ttf", + ] } else if cfg!(windows) { &["C:\\Windows\\Fonts\\msyh.ttc", "C:\\Windows\\Fonts\\YuGothM.ttc", "C:\\Windows\\Fonts\\msgothic.ttc", "C:\\Windows\\Fonts\\malgun.ttf"] } else { diff --git a/rust/app/src/transfer.rs b/rust/app/src/transfer.rs index 896529d..024d45b 100644 --- a/rust/app/src/transfer.rs +++ b/rust/app/src/transfer.rs @@ -14,10 +14,25 @@ const STRATEGIES: [(&str, &str); 3] = [("skip", "Skip existing"), ("overwrite", enum Mode { /// `selected` is filled from `preselect` (or every profile) on the first frame. - Export { preselect: Option>, selected: HashSet, pw: String, confirm: String }, - Import { path: PathBuf, pw: String, strategy: &'static str, status: String }, - ExportJson { include_secrets: bool }, - ImportJson { path: PathBuf, strategy: &'static str }, + Export { + preselect: Option>, + selected: HashSet, + pw: String, + confirm: String, + }, + Import { + path: PathBuf, + pw: String, + strategy: &'static str, + status: String, + }, + ExportJson { + include_secrets: bool, + }, + ImportJson { + path: PathBuf, + strategy: &'static str, + }, } enum Done { @@ -38,7 +53,9 @@ impl Transfer { } pub fn import_encrypted(&mut self) { - if let Some(path) = rfd::FileDialog::new().set_title("Import Profiles").add_filter("Nexus Export", &[EXTENSION, "link"]).add_filter("All files", &["*"]).pick_file() { + if let Some(path) = + rfd::FileDialog::new().set_title("Import Profiles").add_filter("Nexus Export", &[EXTENSION, "link"]).add_filter("All files", &["*"]).pick_file() + { self.mode = Some(Mode::Import { path, pw: String::new(), strategy: "skip", status: String::new() }); } } @@ -112,7 +129,11 @@ impl Transfer { let name = if p.display_name.is_empty() { &p.host } else { &p.display_name }; let label = format!("{name} ({})", p.protocol.id()); if ui.checkbox(&mut on, label).changed() { - if on { selected.insert(p.id.clone()) } else { selected.remove(&p.id) }; + if on { + selected.insert(p.id.clone()) + } else { + selected.remove(&p.id) + }; } } }); @@ -131,11 +152,18 @@ impl Transfer { s.error("Passwords do not match."); } else if selected.is_empty() { s.error("Please select at least one profile."); - } else if let Some(path) = rfd::FileDialog::new().set_title("Export Profiles").set_file_name(format!("nexus-profiles.{EXTENSION}")).add_filter("Nexus Export", &[EXTENSION]).save_file() { + } else if let Some(path) = rfd::FileDialog::new() + .set_title("Export Profiles") + .set_file_name(format!("nexus-profiles.{EXTENSION}")) + .add_filter("Nexus Export", &[EXTENSION]) + .save_file() + { // Keep the sidebar's order. let ids: Vec = s.profiles.iter().filter(|p| selected.contains(&p.id)).map(|p| p.id.clone()).collect(); let (svc, pw) = (s.svc.clone(), pw.clone()); - start = Some(Box::new(move || svc.export_encrypted(&ids, &pw, &path).map(|p| Done::Exported(ids.len(), p)).map_err(|e| format!("Export failed: {e}")))); + start = Some(Box::new(move || { + svc.export_encrypted(&ids, &pw, &path).map(|p| Done::Exported(ids.len(), p)).map_err(|e| format!("Export failed: {e}")) + })); } } } @@ -169,10 +197,18 @@ impl Transfer { ui.label(format!("Exports all {} profile(s) as plain JSON.", s.profiles.len())); ui.checkbox(include_secrets, "Include secrets (proxy passwords, TOTP secrets)"); if *include_secrets { - ui.label(RichText::new(format!("{} The file will contain secrets in plain text. Prefer the encrypted export.", icon::WARNING)).color(t.warning).size(12.0)); + ui.label( + RichText::new(format!("{} The file will contain secrets in plain text. Prefer the encrypted export.", icon::WARNING)) + .color(t.warning) + .size(12.0), + ); } if buttons(ui, false, icon::FILE_ARROW_UP, "Export", &mut close) - && let Some(path) = rfd::FileDialog::new().set_title("Export Profiles (JSON)").set_file_name("nexus-profiles.json").add_filter("JSON", &["json"]).save_file() + && let Some(path) = rfd::FileDialog::new() + .set_title("Export Profiles (JSON)") + .set_file_name("nexus-profiles.json") + .add_filter("JSON", &["json"]) + .save_file() { match s.svc.export_json(*include_secrets).map_err(|e| e.to_string()).and_then(|j| std::fs::write(&path, j).map_err(|e| e.to_string())) { Ok(()) => { @@ -189,7 +225,8 @@ impl Transfer { file_line(ui, path); strategy_combo(ui, strategy); if buttons(ui, false, icon::FILE_ARROW_DOWN, "Import", &mut close) { - match std::fs::read_to_string(&path).map_err(|e| e.to_string()).and_then(|j| s.svc.import_json(&j, strategy).map_err(|e| e.to_string())) { + match std::fs::read_to_string(&path).map_err(|e| e.to_string()).and_then(|j| s.svc.import_json(&j, strategy).map_err(|e| e.to_string())) + { Ok(n) => { s.success(format!("Imported {n} profile(s)")); s.request(Request::ReloadProfiles); diff --git a/rust/app/src/triggers.rs b/rust/app/src/triggers.rs index 864f404..eeac228 100644 --- a/rust/app/src/triggers.rs +++ b/rust/app/src/triggers.rs @@ -117,7 +117,10 @@ impl Triggers { } let lines = complete_lines(self.buffers.entry(session.to_owned()).or_default(), data); let mut highlight = false; - let label = s.pane(session).map(|p| if p.info.tab_name.is_empty() { p.info.profile_name.clone() } else { p.info.tab_name.clone() }).unwrap_or_else(|| session.to_owned()); + let label = s + .pane(session) + .map(|p| if p.info.tab_name.is_empty() { p.info.profile_name.clone() } else { p.info.tab_name.clone() }) + .unwrap_or_else(|| session.to_owned()); let mut fired: Vec<(String, String)> = vec![]; for line in &lines { let clean = strip_ansi(line); @@ -191,7 +194,11 @@ impl Triggers { ui.vertical(|ui| { ui.label(RichText::new(if r.name.is_empty() { &r.pattern } else { &r.name }).font(theme::semibold(13.0))); let action = ACTIONS.iter().find(|a| a.0 == r.action).map_or(r.action.as_str(), |a| a.1); - let meta = format!("{} · {action} · {}", if r.is_regex { "Regex" } else { "Text" }, if r.case_sensitive { "Case-sensitive" } else { "Case-insensitive" }); + let meta = format!( + "{} · {action} · {}", + if r.is_regex { "Regex" } else { "Text" }, + if r.case_sensitive { "Case-sensitive" } else { "Case-insensitive" } + ); ui.label(RichText::new(meta).size(11.5).color(t.text_dim)); }); ui.label(RichText::new(&r.pattern).font(theme::mono(12.0)).color(if r.is_regex && r.re.is_none() { t.danger } else { t.text })); diff --git a/rust/app/src/views.rs b/rust/app/src/views.rs index 57c46b4..5b718c2 100644 --- a/rust/app/src/views.rs +++ b/rust/app/src/views.rs @@ -14,15 +14,6 @@ pub enum View { Vnc(Box), } -impl View { - pub fn terminal(&mut self) -> Option<&mut TermView> { - match self { - View::Terminal(t) => Some(t), - _ => None, - } - } -} - /// Protocol badge colour (the web build's --protocol-* tokens). pub fn protocol_color(p: nexus_core::models::Protocol) -> egui::Color32 { use nexus_core::models::Protocol::*; diff --git a/rust/app/src/vnc_view.rs b/rust/app/src/vnc_view.rs index 8776cb5..e8a911f 100644 --- a/rust/app/src/vnc_view.rs +++ b/rust/app/src/vnc_view.rs @@ -254,7 +254,11 @@ impl VncView { } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.add_enabled_ui(self.ready, |ui| { - let (g, tip) = if self.scale_to_fit { (icon::CORNERS_IN, "Scaled to fit (click for 1:1)") } else { (icon::CORNERS_OUT, "1:1 (click to scale to fit)") }; + let (g, tip) = if self.scale_to_fit { + (icon::CORNERS_IN, "Scaled to fit (click for 1:1)") + } else { + (icon::CORNERS_OUT, "1:1 (click to scale to fit)") + }; if widgets::icon_button(ui, g, tip).clicked() { self.scale_to_fit = !self.scale_to_fit; } @@ -264,7 +268,10 @@ impl VncView { let _ = c.request_full_update(); } if !self.view_only { - if widgets::button(ui, icon::CLIPBOARD_TEXT, "Paste Clipboard").on_hover_text("Send the local clipboard to the remote desktop").clicked() { + if widgets::button(ui, icon::CLIPBOARD_TEXT, "Paste Clipboard") + .on_hover_text("Send the local clipboard to the remote desktop") + .clicked() + { match arboard::Clipboard::new().and_then(|mut c| c.get_text()) { Ok(text) if !text.is_empty() => { self.paste(s, &text); @@ -293,9 +300,7 @@ impl VncView { let focused = resp.has_focus(); if focused { ui.painter().rect_stroke(img, 0.0, egui::Stroke::new(1.0, Tokens::get(ui.ctx()).accent.gamma_multiply(0.6)), egui::StrokeKind::Outside); - ui.memory_mut(|m| { - m.set_focus_lock_filter(id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }) - }); + ui.memory_mut(|m| m.set_focus_lock_filter(id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true })); } else if self.had_focus { self.release_all(); } @@ -344,7 +349,8 @@ impl VncView { for e in events { match e { Event::Key { key, pressed, modifiers, .. } => { - let sym = named_keysym(key).or_else(|| (modifiers.ctrl || modifiers.alt || modifiers.mac_cmd).then(|| chord_keysym(key, modifiers.shift)).flatten()); + let sym = named_keysym(key) + .or_else(|| (modifiers.ctrl || modifiers.alt || modifiers.mac_cmd).then(|| chord_keysym(key, modifiers.shift)).flatten()); if let Some(sym) = sym { self.key(sym, pressed); } diff --git a/rust/core/src/clients/github.rs b/rust/core/src/clients/github.rs index 462e7a5..4c41275 100644 --- a/rust/core/src/clients/github.rs +++ b/rust/core/src/clients/github.rs @@ -70,11 +70,7 @@ struct ApiPerson { impl Client { /// `base_url` is the API root, e.g. `https://api.github.com` or `https://ghe.example.com/api/v3`. pub fn new(base_url: &str, token: &str) -> Client { - let agent = ureq::Agent::config_builder() - .timeout_global(Some(Duration::from_secs(30))) - .http_status_as_error(false) - .build() - .into(); + let agent = ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(30))).http_status_as_error(false).build().into(); Client { base_url: base_url.trim_end_matches('/').to_string(), token: token.to_string(), agent } } @@ -89,15 +85,12 @@ impl Client { /// Confirms the token works and returns the authenticated login. pub fn validate_token(&self) -> crate::Result { - let mut resp = self - .get(&format!("{}/user", self.base_url)) - .map_err(|e| format!("contacting GitHub API: {e}"))?; + let mut resp = self.get(&format!("{}/user", self.base_url)).map_err(|e| format!("contacting GitHub API: {e}"))?; let status = resp.status().as_u16(); if status != 200 { return Err(format!("token validation failed (HTTP {status})").into()); } - let body = read_limited(resp.body_mut().as_reader(), MAX_JSON_BODY_BYTES) - .map_err(|e| format!("reading user response: {e}"))?; + let body = read_limited(resp.body_mut().as_reader(), MAX_JSON_BODY_BYTES).map_err(|e| format!("reading user response: {e}"))?; #[derive(Deserialize)] struct User { #[serde(default)] @@ -114,35 +107,21 @@ impl Client { /// Like `fetch_repos`, reporting commit-enrichment progress as `(done, total)`; it is called /// from worker threads. - pub fn fetch_repos_with_progress( - &self, - progress: impl Fn(usize, usize) + Sync, - ) -> crate::Result> { + pub fn fetch_repos_with_progress(&self, progress: impl Fn(usize, usize) + Sync) -> crate::Result> { let mut all: Vec = Vec::new(); let mut page = 1; loop { - let url = format!( - "{}/user/repos?per_page=100&page={page}&sort=pushed&direction=desc", - self.base_url - ); + let url = format!("{}/user/repos?per_page=100&page={page}&sort=pushed&direction=desc", self.base_url); let mut resp = self.get(&url).map_err(|e| format!("fetching repos page {page}: {e}"))?; - let body = read_limited(resp.body_mut().as_reader(), MAX_JSON_BODY_BYTES) - .map_err(|e| format!("reading response body: {e}"))?; + let body = read_limited(resp.body_mut().as_reader(), MAX_JSON_BODY_BYTES).map_err(|e| format!("reading response body: {e}"))?; let status = resp.status().as_u16(); if status == 401 || status == 403 { - return Err( - format!("authentication failed (HTTP {status}) — check your access token").into() - ); + return Err(format!("authentication failed (HTTP {status}) — check your access token").into()); } if status != 200 { - return Err(format!( - "GitHub API returned HTTP {status}: {}", - String::from_utf8_lossy(&body) - ) - .into()); + return Err(format!("GitHub API returned HTTP {status}: {}", String::from_utf8_lossy(&body)).into()); } - let batch: Vec = - serde_json::from_slice(&body).map_err(|e| format!("decoding repos: {e}"))?; + let batch: Vec = serde_json::from_slice(&body).map_err(|e| format!("decoding repos: {e}"))?; if batch.is_empty() { break; } @@ -161,7 +140,7 @@ impl Client { let updated = parse_time(r.updated_at.as_deref()).map_err(|e| format!("decoding repos: {e}"))?; parsed.push((r, pushed, updated)); } - parsed.sort_by(|a, b| b.1.cmp(&a.1)); + parsed.sort_by_key(|p| std::cmp::Reverse(p.1)); let mut result: Vec = parsed .into_iter() @@ -234,14 +213,9 @@ impl Client { return Err(format!("authentication failed (HTTP {status})").into()); } if status != 200 { - return Err(format!( - "GitHub API returned HTTP {status}: {}", - read_error_body(resp.body_mut().as_reader()) - ) - .into()); + return Err(format!("GitHub API returned HTTP {status}: {}", read_error_body(resp.body_mut().as_reader())).into()); } - let body = read_limited(resp.body_mut().as_reader(), MAX_JSON_BODY_BYTES) - .map_err(|e| format!("reading commit response: {e}"))?; + let body = read_limited(resp.body_mut().as_reader(), MAX_JSON_BODY_BYTES).map_err(|e| format!("reading commit response: {e}"))?; #[derive(Deserialize)] struct Payload { @@ -284,25 +258,14 @@ impl Client { return Err(format!("authentication failed (HTTP {status}) — check your access token").into()); } if status != 200 { - return Err(format!( - "GitHub API returned HTTP {status}: {}", - read_error_body(resp.body_mut().as_reader()) - ) - .into()); + return Err(format!("GitHub API returned HTTP {status}: {}", read_error_body(resp.body_mut().as_reader())).into()); } - let length = resp - .headers() - .get("content-length") - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.trim().parse::().ok()); + let length = resp.headers().get("content-length").and_then(|v| v.to_str().ok()).and_then(|v| v.trim().parse::().ok()); if let Some(length) = length.filter(|&l| l > MAX_ARCHIVE_BYTES) { - return Err( - format!("archive is too large: {length} bytes exceeds {MAX_ARCHIVE_BYTES} bytes").into() - ); + return Err(format!("archive is too large: {length} bytes exceeds {MAX_ARCHIVE_BYTES} bytes").into()); } let mut file = std::fs::File::create(dest_path).map_err(|e| format!("creating file: {e}"))?; - copy_limited_archive(&mut file, resp.body_mut().as_reader(), MAX_ARCHIVE_BYTES) - .map_err(|e| format!("writing archive: {e}"))?; + copy_limited_archive(&mut file, resp.body_mut().as_reader(), MAX_ARCHIVE_BYTES).map_err(|e| format!("writing archive: {e}"))?; Ok(()) } @@ -446,9 +409,7 @@ mod tests { let log = seen.clone(); std::thread::spawn(move || { for req in server.incoming_requests() { - let header = |name: &'static str| { - req.headers().iter().find(|h| h.field.equiv(name)).map(|h| h.value.to_string()) - }; + let header = |name: &'static str| req.headers().iter().find(|h| h.field.equiv(name)).map(|h| h.value.to_string()); log.lock().unwrap().push((req.url().to_string(), header("Authorization"), header("User-Agent"))); let (status, body) = handler(req.url()); let _ = req.respond(tiny_http::Response::from_string(body).with_status_code(status)); @@ -475,7 +436,7 @@ mod tests { serde_json::json!({ "full_name": format!("me/r{n}"), "name": format!("r{n}"), "description": null, "html_url": format!("https://example/me/r{n}"), "default_branch": if n == 0 { "" } else { "main" }, - "private": n % 2 == 0, "fork": false, "archived": false, "language": null, + "private": n.is_multiple_of(2), "fork": false, "archived": false, "language": null, "stargazers_count": n, "pushed_at": pushed, "updated_at": "2024-01-01T00:00:00Z", "owner": {"login": "me"} }) @@ -485,9 +446,7 @@ mod tests { fn fetch_repos_paginates_sorts_and_enriches() { let (base, seen) = serve(|url| { if url.starts_with("/user/repos") && url.contains("page=1&") { - let repos: Vec<_> = (0..100) - .map(|n| repo_json(n, &format!("2024-02-{:02}T00:00:00Z", 1 + n % 28))) - .collect(); + let repos: Vec<_> = (0..100).map(|n| repo_json(n, &format!("2024-02-{:02}T00:00:00Z", 1 + n % 28))).collect(); (200, serde_json::to_string(&repos).unwrap()) } else if url.starts_with("/user/repos") { (200, serde_json::to_string(&[repo_json(100, "2025-06-01T12:00:00Z")]).unwrap()) @@ -500,9 +459,7 @@ mod tests { } }); let calls = Mutex::new(Vec::new()); - let repos = Client::new(&base, "t") - .fetch_repos_with_progress(|done, total| calls.lock().unwrap().push((done, total))) - .unwrap(); + let repos = Client::new(&base, "t").fetch_repos_with_progress(|done, total| calls.lock().unwrap().push((done, total))).unwrap(); assert_eq!(repos.len(), 101); assert_eq!(repos[0].full_name, "me/r100"); assert_eq!(repos[0].pushed_at, "2025-06-01T12:00:00Z"); diff --git a/rust/core/src/clients/irc.rs b/rust/core/src/clients/irc.rs index 3fdf209..833ac4b 100644 --- a/rust/core/src/clients/irc.rs +++ b/rust/core/src/clients/irc.rs @@ -478,14 +478,14 @@ impl Client { st.connected = false; r }; - if let Err(e) = result { - if !was_closing { - let msg = e.to_string(); - self.emit(Event { kind: "error".into(), text: msg.clone(), ..Default::default() }); - (self.cfg.on_error)(msg.clone()); - if let Some(ready) = ready { - let _ = ready.send(Err(msg)); - } + if let Err(e) = result + && !was_closing + { + let msg = e.to_string(); + self.emit(Event { kind: "error".into(), text: msg.clone(), ..Default::default() }); + (self.cfg.on_error)(msg.clone()); + if let Some(ready) = ready { + let _ = ready.send(Err(msg)); } } if was_connected { @@ -498,8 +498,7 @@ impl Client { Some(connector) => connector()?, None => { let addr = format!("{}:{}", self.cfg.server, self.cfg.port); - let tcp = dial(&self.cfg.server, self.cfg.port, self.cfg.connect_timeout) - .map_err(|e| format!("dial tcp {addr}: {e}"))?; + let tcp = dial(&self.cfg.server, self.cfg.port, self.cfg.connect_timeout).map_err(|e| format!("dial tcp {addr}: {e}"))?; tcp.set_read_timeout(Some(READ_POLL))?; tcp.set_write_timeout(Some(self.cfg.connect_timeout))?; let _ = tcp.set_nodelay(true); @@ -571,10 +570,7 @@ impl Client { if ping_sent && self.st().last_pong.elapsed() > PING_DELAY + PING_TIMEOUT { return Err("timed out waiting for a requested PING response".into()); } - let id = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); + let id = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos(); write(&mut stream, &encode("PING", &[&id.to_string()]))?; last_ping = Instant::now(); ping_sent = true; @@ -637,11 +633,7 @@ impl Client { } "322" => { if m.params.len() >= 4 { - let room = RoomInfo { - name: m.param(1).into(), - users: m.param(2).parse().unwrap_or(0), - topic: m.param(3).into(), - }; + let room = RoomInfo { name: m.param(1).into(), users: m.param(2).parse().unwrap_or(0), topic: m.param(3).into() }; let mut st = self.st(); if st.list_rooms.len() < MAX_ROOM_LIST_ENTRIES { st.list_rooms.push(room); @@ -668,11 +660,7 @@ impl Client { st.ready.take() }; let host = if self.cfg.server.contains(':') { format!("[{}]", self.cfg.server) } else { self.cfg.server.clone() }; - self.emit(Event { - kind: "system".into(), - text: format!("Connected to {host}:{}", self.cfg.port), - ..Default::default() - }); + self.emit(Event { kind: "system".into(), text: format!("Connected to {host}:{}", self.cfg.port), ..Default::default() }); self.identify_with_nickserv(); if !self.cfg.auto_join_rooms.is_empty() { let _ = self.send("JOIN", &[&self.cfg.auto_join_rooms.join(",")]); @@ -747,11 +735,7 @@ impl Client { let from = m.source.clone(); if !from.is_empty() { let mut st = self.st(); - let ch = st.channels.entry(fold(&room)).or_insert_with(|| Channel { - name: room.clone(), - topic: String::new(), - users: Vec::new(), - }); + let ch = st.channels.entry(fold(&room)).or_insert_with(|| Channel { name: room.clone(), topic: String::new(), users: Vec::new() }); add_user(ch, &from); } if self.is_me(&from) { @@ -856,14 +840,7 @@ impl Client { if let Some(ch) = self.st().channels.get_mut(&fold(room)) { ch.topic = topic.to_string(); } - self.emit(Event { - kind: "topic".into(), - target: room.into(), - from: m.source.clone(), - text: topic.into(), - topic: topic.into(), - ..Default::default() - }); + self.emit(Event { kind: "topic".into(), target: room.into(), from: m.source.clone(), text: topic.into(), topic: topic.into(), ..Default::default() }); self.emit_rooms(); } @@ -1060,10 +1037,7 @@ mod tests { fn remember_room_membership_tracks_confirmed_channels() { let updates = Arc::new(Mutex::new(Vec::new())); let log = updates.clone(); - let client = Client::new(Config { - on_rooms_changed: Arc::new(move |rooms| log.lock().unwrap().push(rooms.join(","))), - ..Default::default() - }); + let client = Client::new(Config { on_rooms_changed: Arc::new(move |rooms| log.lock().unwrap().push(rooms.join(","))), ..Default::default() }); client.remember_room_membership("#One", true); client.remember_room_membership("#two", true); client.remember_room_membership("#one", true); @@ -1095,11 +1069,7 @@ mod tests { fn event_serialises_like_go() { let e = Event { kind: "users".into(), target: "#a".into(), timestamp: 5, ..Default::default() }; assert_eq!(serde_json::to_string(&e).unwrap(), r##"{"type":"users","target":"#a","timestamp":5}"##); - let e = Event { - kind: "rooms".into(), - rooms: vec![RoomInfo { name: "#a".into(), topic: String::new(), users: 0 }], - ..Default::default() - }; + let e = Event { kind: "rooms".into(), rooms: vec![RoomInfo { name: "#a".into(), topic: String::new(), users: 0 }], ..Default::default() }; assert_eq!(serde_json::to_string(&e).unwrap(), r##"{"type":"rooms","timestamp":0,"rooms":[{"name":"#a"}]}"##); } @@ -1236,10 +1206,7 @@ mod tests { srv.expect("NAMES #a"); client.handle_input("#a", "/mode #a +i").unwrap(); srv.expect("MODE #a +i"); - assert_eq!( - client.handle_input(SERVER_TARGET, "hi").unwrap_err().to_string(), - "select a room or use /msg " - ); + assert_eq!(client.handle_input(SERVER_TARGET, "hi").unwrap_err().to_string(), "select a room or use /msg "); assert_eq!(client.handle_input("#a", "/msg bob").unwrap_err().to_string(), "/msg requires a target and message"); client.handle_input("", "/help").unwrap(); assert!(wait_for(&rx, |e| e.kind == "system").text.starts_with("Commands: /join")); @@ -1269,10 +1236,7 @@ mod tests { fn remote_close_reports_error_and_close() { let errors = Arc::new(Mutex::new(Vec::new())); let log = errors.clone(); - let (client, listener, rx, closes) = harness(Config { - on_error: Arc::new(move |e| log.lock().unwrap().push(e)), - ..Default::default() - }); + let (client, listener, rx, closes) = harness(Config { on_error: Arc::new(move |e| log.lock().unwrap().push(e)), ..Default::default() }); let c = client.clone(); let connecting = std::thread::spawn(move || c.connect()); let mut srv = accept(&listener); diff --git a/rust/core/src/clients/local.rs b/rust/core/src/clients/local.rs index eadbfed..74ae572 100644 --- a/rust/core/src/clients/local.rs +++ b/rust/core/src/clients/local.rs @@ -9,9 +9,7 @@ use std::sync::{Arc, Mutex}; pub fn default_shell() -> String { if cfg!(windows) { - let ps = std::env::var_os("SystemRoot") - .map(|r| PathBuf::from(r).join(r"System32\WindowsPowerShell\v1.0\powershell.exe")) - .filter(|p| p.exists()); + let ps = std::env::var_os("SystemRoot").map(|r| PathBuf::from(r).join(r"System32\WindowsPowerShell\v1.0\powershell.exe")).filter(|p| p.exists()); return ps.map_or_else(|| "cmd.exe".into(), |p| p.to_string_lossy().into()); } std::env::var("SHELL").ok().filter(|s| !s.is_empty()).unwrap_or_else(|| "/bin/sh".into()) diff --git a/rust/core/src/clients/mod.rs b/rust/core/src/clients/mod.rs index 1fac69d..837e41f 100644 --- a/rust/core/src/clients/mod.rs +++ b/rust/core/src/clients/mod.rs @@ -17,9 +17,7 @@ use std::sync::{Arc, OnceLock}; /// The shared tokio runtime for async protocol stacks (russh). Blocking clients use threads. pub fn rt() -> &'static tokio::runtime::Runtime { static RT: OnceLock = OnceLock::new(); - RT.get_or_init(|| { - tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().thread_name("nexus-net").build().expect("tokio runtime") - }) + RT.get_or_init(|| tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().thread_name("nexus-net").build().expect("tokio runtime")) } pub type DataFn = Arc; @@ -81,11 +79,7 @@ impl TcpConn { TcpConn { stream: std::sync::Mutex::new(None), on_error, on_close } } - pub(crate) fn connect( - self: &Arc, - stream: std::net::TcpStream, - mut on_read: impl FnMut(&[u8]) + Send + 'static, - ) -> crate::Result<()> { + pub(crate) fn connect(self: &Arc, stream: std::net::TcpStream, mut on_read: impl FnMut(&[u8]) + Send + 'static) -> crate::Result<()> { let mut reader = stream.try_clone()?; { let mut guard = self.stream.lock().unwrap(); @@ -148,10 +142,7 @@ pub(crate) fn is_latin1(encoding: &str) -> bool { /// UTF-8 → ISO 8859-1. Like Go's charmap encoder, input that isn't valid UTF-8 or holds a /// character above U+00FF is returned unchanged. pub(crate) fn utf8_to_latin1(data: &[u8]) -> Vec { - std::str::from_utf8(data) - .ok() - .and_then(|s| s.chars().map(|c| u8::try_from(c).ok()).collect()) - .unwrap_or_else(|| data.to_vec()) + std::str::from_utf8(data).ok().and_then(|s| s.chars().map(|c| u8::try_from(c).ok()).collect()).unwrap_or_else(|| data.to_vec()) } /// ISO 8859-1 → UTF-8 (every byte maps to the code point of the same value). diff --git a/rust/core/src/clients/raw.rs b/rust/core/src/clients/raw.rs index a003afa..f289c5d 100644 --- a/rust/core/src/clients/raw.rs +++ b/rust/core/src/clients/raw.rs @@ -262,10 +262,7 @@ pub fn parse_escaped(s: &str) -> crate::Result> { /// "latin1" / "latin-1", otherwise UTF-8 with invalid bytes replaced by U+FFFD. pub fn to_display_encoding(data: &[u8], encoding: &str) -> String { match encoding.trim().to_ascii_lowercase().as_str() { - "ascii" => data - .iter() - .map(|&b| if (0x20..=0x7e).contains(&b) || matches!(b, b'\n' | b'\r' | b'\t') { b as char } else { '.' }) - .collect(), + "ascii" => data.iter().map(|&b| if (0x20..=0x7e).contains(&b) || matches!(b, b'\n' | b'\r' | b'\t') { b as char } else { '.' }).collect(), e if super::is_latin1(e) => super::latin1_to_utf8(data), _ => String::from_utf8_lossy(data).into_owned(), } @@ -285,13 +282,8 @@ mod tests { #[test] fn parse_hex_string_cases() { - let ok: [(&str, &[u8]); 5] = [ - ("48656c6c6f", b"Hello"), - ("48 65 6c 6c 6f", b"Hello"), - ("0x48 0x65 0x6c 0x6c 0x6f", b"Hello"), - ("0000", &[0, 0]), - ("ff ff", &[0xff, 0xff]), - ]; + let ok: [(&str, &[u8]); 5] = + [("48656c6c6f", b"Hello"), ("48 65 6c 6c 6f", b"Hello"), ("0x48 0x65 0x6c 0x6c 0x6f", b"Hello"), ("0000", &[0, 0]), ("ff ff", &[0xff, 0xff])]; for (input, want) in ok { assert_eq!(parse_hex_string(input).unwrap(), want, "{input:?}"); } diff --git a/rust/core/src/clients/serial.rs b/rust/core/src/clients/serial.rs index 977f925..bee042f 100644 --- a/rust/core/src/clients/serial.rs +++ b/rust/core/src/clients/serial.rs @@ -38,12 +38,7 @@ pub fn list_ports() -> Vec { impl SerialClient { pub fn new(profile: &Profile, cb: Callbacks) -> SerialClient { SerialClient { - inner: Arc::new(Inner { - path: profile.host.trim().to_string(), - cfg: profile.serial.clone().unwrap_or_default(), - cb, - port: Mutex::new(None), - }), + inner: Arc::new(Inner { path: profile.host.trim().to_string(), cfg: profile.serial.clone().unwrap_or_default(), cb, port: Mutex::new(None) }), } } @@ -292,9 +287,15 @@ mod tests { let (etx, erx) = mpsc::channel(); let (ctx, crx) = mpsc::channel(); let cb = Callbacks { - on_data: Arc::new(move |d| { let _ = dtx.send(d.to_vec()); }), - on_error: Arc::new(move |e| { let _ = etx.send(e); }), - on_close: Arc::new(move || { let _ = ctx.send(()); }), + on_data: Arc::new(move |d| { + let _ = dtx.send(d.to_vec()); + }), + on_error: Arc::new(move |e| { + let _ = etx.send(e); + }), + on_close: Arc::new(move || { + let _ = ctx.send(()); + }), }; let profile = Profile { host: pty.path.clone(), serial: Some(cfg), ..Profile::new(models::Protocol::Serial) }; (SerialClient::new(&profile, cb), drx, erx, crx) diff --git a/rust/core/src/clients/sftp.rs b/rust/core/src/clients/sftp.rs index 5b856c9..886313a 100644 --- a/rust/core/src/clients/sftp.rs +++ b/rust/core/src/clients/sftp.rs @@ -31,19 +31,18 @@ pub struct Progress { } fn mode_string(m: &russh_sftp::protocol::FileAttributes) -> String { - let kind = if m.is_dir() { 'd' } else if m.is_symlink() { 'L' } else { '-' }; + let kind = if m.is_dir() { + 'd' + } else if m.is_symlink() { + 'L' + } else { + '-' + }; format!("{kind}{}", m.permissions()) } fn entry(name: String, path: String, m: &russh_sftp::protocol::FileAttributes) -> FileEntry { - FileEntry { - name, - path, - size: m.size.unwrap_or(0), - is_dir: m.is_dir(), - mode: mode_string(m), - modified_at: m.mtime.map_or(0, |t| t as i64 * 1000), - } + FileEntry { name, path, size: m.size.unwrap_or(0), is_dir: m.is_dir(), mode: mode_string(m), modified_at: m.mtime.map_or(0, |t| t as i64 * 1000) } } /// `path.Join` for remote (always '/') paths. @@ -60,10 +59,8 @@ pub fn join(dir: &str, name: &str) -> String { /// Directories first, then case-insensitive name order. pub fn list(s: &Arc, path: &str) -> Result> { let rd = rt().block_on(s.read_dir(path)).map_err(|e| format!("sftpclient: failed to list {path}: {e}"))?; - let mut out: Vec = rd - .filter(|e| e.file_name() != "." && e.file_name() != "..") - .map(|e| entry(e.file_name(), join(path, &e.file_name()), &e.metadata())) - .collect(); + let mut out: Vec = + rd.filter(|e| e.file_name() != "." && e.file_name() != "..").map(|e| entry(e.file_name(), join(path, &e.file_name()), &e.metadata())).collect(); out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))); Ok(out) } diff --git a/rust/core/src/clients/ssh.rs b/rust/core/src/clients/ssh.rs index 839ccb6..da05f8a 100644 --- a/rust/core/src/clients/ssh.rs +++ b/rust/core/src/clients/ssh.rs @@ -3,8 +3,8 @@ //! port forwards, agent forwarding and SFTP. A port of internal/sshclient + sftpclient. use super::{Callbacks, rt}; -use crate::models::{ForwardDirection, JumpHost, PortForward, Profile, SshConfig}; use crate::Result; +use crate::models::{ForwardDirection, JumpHost, PortForward, Profile, SshConfig}; use base64::Engine; use russh::client::{self, Handle, Msg}; use russh::keys::{HashAlg, PrivateKeyWithHashAlg, PublicKey}; @@ -160,12 +160,9 @@ async fn connect_agent() -> Result { fn is_totp_prompt(q: &str) -> bool { let q = q.to_lowercase(); - [ - "verification code", "otp", "one-time", "one time", "two-factor", "two factor", "2fa", "totp", "authenticator", - "security code", "6-digit", "6 digit", - ] - .iter() - .any(|k| q.contains(k)) + ["verification code", "otp", "one-time", "one time", "two-factor", "two factor", "2fa", "totp", "authenticator", "security code", "6-digit", "6 digit"] + .iter() + .any(|k| q.contains(k)) } /// RFC 6238 code (SHA-1, 6 digits, 30 s) for a base32 secret, normalised as pquerna/otp does. @@ -448,7 +445,9 @@ impl SshClient { let host = host.to_string(); let listener = std::net::TcpListener::bind("127.0.0.1:0")?; let addr = listener.local_addr()?; - let ch = rt().block_on(h.channel_open_direct_tcpip(host.clone(), port as u32, "127.0.0.1", 0)).map_err(|e| format!("SSH tunnel to {host}:{port} failed: {e}"))?; + let ch = rt() + .block_on(h.channel_open_direct_tcpip(host.clone(), port as u32, "127.0.0.1", 0)) + .map_err(|e| format!("SSH tunnel to {host}:{port} failed: {e}"))?; let client = std::net::TcpStream::connect(addr)?; let (server, _) = listener.accept()?; server.set_nonblocking(true)?; @@ -488,7 +487,11 @@ impl SshClient { let methods = if sc.auth_methods.is_empty() { SshConfig::defaults().auth_methods } else { sc.auth_methods.clone() }; let key = if methods.iter().any(|m| m == "publickey") && !sc.private_key_path.is_empty() { let (path, pp) = (sc.private_key_path.clone(), passphrase.clone()); - Some(blocking(move || load_private_key(&path, pp.as_ref())).await.map_err(|e| format!("failed to build auth methods: failed to parse private key {}: {e}", sc.private_key_path))?) + Some( + blocking(move || load_private_key(&path, pp.as_ref())) + .await + .map_err(|e| format!("failed to build auth methods: failed to parse private key {}: {e}", sc.private_key_path))?, + ) } else { None }; @@ -498,17 +501,16 @@ impl SshClient { self.handshake(tcp, &p.host, port, &check).await? } else { let (prev, h) = self.via_jumps(&sc, &check, passphrase.clone(), timeout).await?; - let ch = prev.channel_open_direct_tcpip(p.host.clone(), port as u32, "127.0.0.1", 0).await.map_err(|e| format!("failed to dial target {}:{port} through jump hosts: {e}", p.host))?; + let ch = prev + .channel_open_direct_tcpip(p.host.clone(), port as u32, "127.0.0.1", 0) + .await + .map_err(|e| format!("failed to dial target {}:{port} through jump hosts: {e}", p.host))?; self.hops.lock().unwrap().extend(h); self.hops.lock().unwrap().push(prev); self.handshake(ch.into_stream(), &p.host, port, &check).await? }; - authenticate( - &mut handle, - Auth { user: &p.username, methods, agent: sc.agent_auth, key, password, totp_secret: sc.totp_secret.clone() }, - ) - .await?; + authenticate(&mut handle, Auth { user: &p.username, methods, agent: sc.agent_auth, key, password, totp_secret: sc.totp_secret.clone() }).await?; let handle = Arc::new(handle); if !shell { *self.handle.lock().unwrap() = Some(handle); @@ -565,7 +567,13 @@ impl SshClient { } /// Authenticates through each jump host in turn; returns the last hop and the ones before it. - async fn via_jumps(&self, sc: &SshConfig, check: &HostKeyCheck, passphrase: Option, timeout: Duration) -> Result<(Arc>, Vec>>)> { + async fn via_jumps( + &self, + sc: &SshConfig, + check: &HostKeyCheck, + passphrase: Option, + timeout: Duration, + ) -> Result<(Arc>, Vec>>)> { let mut chain: Vec>> = vec![]; for (i, j) in sc.jump_hosts.iter().enumerate() { let JumpHost { host, port, username, private_key_path, .. } = j; @@ -585,13 +593,23 @@ impl SshClient { self.handshake(tcp, host, jport, check).await? } Some(prev) => { - let ch = prev.channel_open_direct_tcpip(host.clone(), jport as u32, "127.0.0.1", 0).await.map_err(|e| format!("failed to dial through jump host {i} ({host}:{jport}): {e}"))?; + let ch = prev + .channel_open_direct_tcpip(host.clone(), jport as u32, "127.0.0.1", 0) + .await + .map_err(|e| format!("failed to dial through jump host {i} ({host}:{jport}): {e}"))?; self.handshake(ch.into_stream(), host, jport, check).await? } }; authenticate( &mut h, - Auth { user: username, methods: vec!["publickey".into()], agent: key.is_none() || sc.agent_auth, key, password: None, totp_secret: String::new() }, + Auth { + user: username, + methods: vec!["publickey".into()], + agent: key.is_none() || sc.agent_auth, + key, + password: None, + totp_secret: String::new(), + }, ) .await .map_err(|e| format!("failed to establish SSH through jump host {i}: {e}"))?; @@ -678,9 +696,8 @@ impl SshClient { ForwardDirection::Local => { let bind = if rule.bind_host.is_empty() { "127.0.0.1".to_string() } else { rule.bind_host.clone() }; let addr = format!("{bind}:{}", rule.bind_port); - let listener = rt() - .block_on(tokio::net::TcpListener::bind(addr.clone())) - .map_err(|e| format!("local forward: listen on {addr} failed: {e}"))?; + let listener = + rt().block_on(tokio::net::TcpListener::bind(addr.clone())).map_err(|e| format!("local forward: listen on {addr} failed: {e}"))?; let (dhost, dport) = (rule.dest_host.clone(), rule.dest_port as u32); let t2 = tasks.clone(); let task = rt().spawn(async move { @@ -755,9 +772,8 @@ impl SshClient { let h = self.handle()?; let ch = h.channel_open_session().await?; ch.request_subsystem(true, "sftp").await?; - let s = Arc::new( - russh_sftp::client::SftpSession::new(ch.into_stream()).await.map_err(|e| format!("sftpclient: failed to start SFTP subsystem: {e}"))?, - ); + let s = + Arc::new(russh_sftp::client::SftpSession::new(ch.into_stream()).await.map_err(|e| format!("sftpclient: failed to start SFTP subsystem: {e}"))?); *slot = Some(s.clone()); Ok(s) }) diff --git a/rust/core/src/clients/telnet.rs b/rust/core/src/clients/telnet.rs index 5ef5cac..998b079 100644 --- a/rust/core/src/clients/telnet.rs +++ b/rust/core/src/clients/telnet.rs @@ -367,8 +367,7 @@ mod tests { let (mut srv, _) = listener.accept().unwrap(); srv.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); - srv.write_all(&[IAC, DO, OPT_TERM_TYPE, IAC, DO, OPT_NAWS, IAC, WILL, OPT_ECHO, IAC, WILL, OPT_SGA, IAC, DO, 99]) - .unwrap(); + srv.write_all(&[IAC, DO, OPT_TERM_TYPE, IAC, DO, OPT_NAWS, IAC, WILL, OPT_ECHO, IAC, WILL, OPT_SGA, IAC, DO, 99]).unwrap(); let mut want = vec![IAC, WILL, OPT_TERM_TYPE, IAC, WILL, OPT_NAWS, IAC, SB, OPT_NAWS, 0, 80, 0, 24, IAC, SE]; want.extend_from_slice(&[IAC, DO, OPT_ECHO, IAC, DO, OPT_SGA, IAC, WONT, 99]); assert_eq!(read_n(&mut srv, want.len()), want); diff --git a/rust/core/src/clients/vnc.rs b/rust/core/src/clients/vnc.rs index 1efa569..9233542 100644 --- a/rust/core/src/clients/vnc.rs +++ b/rust/core/src/clients/vnc.rs @@ -76,7 +76,7 @@ pub struct Framebuffer { impl Framebuffer { fn new(width: u16, height: u16) -> Framebuffer { let mut rgba = vec![0u8; width as usize * height as usize * 4]; - rgba.chunks_exact_mut(4).for_each(|p| p[3] = 255); + rgba.as_chunks_mut::<4>().0.iter_mut().for_each(|p| p[3] = 255); Framebuffer { width, height, rgba, dirty: true } } @@ -92,7 +92,7 @@ impl Framebuffer { let (w, stride) = (w as usize, self.width as usize); for row in 0..h as usize { let dst = ((y as usize + row) * stride + x as usize) * 4; - for (d, s) in self.rgba[dst..dst + w * 4].chunks_exact_mut(4).zip(px[row * w * bpp..].chunks_exact(bpp)) { + for (d, s) in self.rgba[dst..dst + w * 4].as_chunks_mut::<4>().0.iter_mut().zip(px[row * w * bpp..].chunks_exact(bpp)) { d[..3].copy_from_slice(&s[..3]); d[3] = 255; } @@ -103,7 +103,7 @@ impl Framebuffer { let stride = self.width as usize; for row in 0..h as usize { let dst = ((y as usize + row) * stride + x as usize) * 4; - for d in self.rgba[dst..dst + w as usize * 4].chunks_exact_mut(4) { + for d in self.rgba[dst..dst + w as usize * 4].as_chunks_mut::<4>().0 { d[..3].copy_from_slice(&rgb[..3]); d[3] = 255; } @@ -241,13 +241,13 @@ impl Client { st.connected = false; r }; - if let Err(e) = result { - if !was_closing { - let msg = e.to_string(); - (self.cfg.on_error)(msg.clone()); - if let Some(ready) = ready { - let _ = ready.send(Err(msg)); - } + if let Err(e) = result + && !was_closing + { + let msg = e.to_string(); + (self.cfg.on_error)(msg.clone()); + if let Some(ready) = ready { + let _ = ready.send(Err(msg)); } } if was_connected { @@ -334,7 +334,13 @@ impl Client { None => return Err(format!("not a VNC server (got {:?})", ver.trim_end()).into()), }; // 3.889 is Apple's 3.8; anything between 3.3 and 3.7 is treated as 3.3, as RFB specifies. - let minor = if minor >= 8 { 8 } else if minor == 7 { 7 } else { 3 }; + let minor = if minor >= 8 { + 8 + } else if minor == 7 { + 7 + } else { + 3 + }; r.get_mut().send(format!("RFB 003.00{minor}\n").as_bytes())?; let security = if minor == 3 { @@ -368,15 +374,13 @@ impl Client { } t => return Err(unsupported_security(&[t as u8]).into()), } - if minor == 8 || security == 2 { - if read_u32(r)? != 0 { - let reason = if minor == 8 { read_string(r)? } else { String::new() }; - return Err(if reason.is_empty() { - "VNC authentication failed (wrong password?)".into() - } else { - format!("VNC authentication failed: {reason}").into() - }); - } + if (minor == 8 || security == 2) && read_u32(r)? != 0 { + let reason = if minor == 8 { read_string(r)? } else { String::new() }; + return Err(if reason.is_empty() { + "VNC authentication failed (wrong password?)".into() + } else { + format!("VNC authentication failed: {reason}").into() + }); } r.get_mut().send(&[self.cfg.shared as u8])?; @@ -414,11 +418,11 @@ pub fn vnc_auth_response(password: &str, challenge: &[u8; 16]) -> [u8; 16] { } let cipher = des::Des::new(&key.into()); let mut out = *challenge; - for chunk in out.chunks_exact_mut(8) { - let mut block: [u8; 8] = chunk.try_into().unwrap(); + for chunk in out.as_chunks_mut::<8>().0 { + let mut block: [u8; 8] = *chunk; let b = (&mut block).into(); cipher.encrypt_block(b); - chunk.copy_from_slice(&block); + *chunk = block; } out } @@ -502,11 +506,7 @@ struct Decoders { impl Default for Decoders { fn default() -> Decoders { - Decoders { - zrle: Decompress::new(true), - tight: std::array::from_fn(|_| Decompress::new(true)), - buf: Vec::new(), - } + Decoders { zrle: Decompress::new(true), tight: std::array::from_fn(|_| Decompress::new(true)), buf: Vec::new() } } } @@ -726,9 +726,7 @@ fn tight(r: &mut impl Read, fb: &mut Framebuffer, dec: &mut Decoders, x: u16, y: 9 => { let len = compact_len(r)?; let jpeg = read_n(r, len)?; - let img = image::load_from_memory_with_format(&jpeg, image::ImageFormat::Jpeg) - .map_err(|e| format!("Tight JPEG: {e}"))? - .to_rgb8(); + let img = image::load_from_memory_with_format(&jpeg, image::ImageFormat::Jpeg).map_err(|e| format!("Tight JPEG: {e}"))?.to_rgb8(); if img.width() as usize != wu || img.height() as usize != hu { return Err("Tight JPEG size doesn't match its rectangle".into()); } diff --git a/rust/core/src/exportimport.rs b/rust/core/src/exportimport.rs index b640a50..774f09b 100644 --- a/rust/core/src/exportimport.rs +++ b/rust/core/src/exportimport.rs @@ -2,9 +2,9 @@ //! over a JSON bundle, with the vault's Argon2id parameters. `.link` files from Alchementary //! Link are the same format. +use crate::Result; use crate::models::Profile; use crate::secrets::{derive_key, random_bytes}; -use crate::Result; use aes_gcm::aead::{Aead, KeyInit}; use aes_gcm::{Aes256Gcm, Nonce}; use serde::{Deserialize, Serialize}; diff --git a/rust/core/src/ircformat.rs b/rust/core/src/ircformat.rs index 115888e..681bd82 100644 --- a/rust/core/src/ircformat.rs +++ b/rust/core/src/ircformat.rs @@ -409,10 +409,7 @@ mod tests { fn hex_colours() { assert_eq!( format_text("\x04ff8800orange\x04 default"), - vec![ - span("orange", Style { fg_rgb: Some([255, 136, 0]), ..Style::default() }), - span(" default", Style::default()) - ] + vec![span("orange", Style { fg_rgb: Some([255, 136, 0]), ..Style::default() }), span(" default", Style::default())] ); let s = format_text("\x0400ff00,0000FFx"); assert_eq!(s[0].style, Style { fg_rgb: Some([0, 255, 0]), bg_rgb: Some([0, 0, 255]), ..Style::default() }); @@ -444,10 +441,8 @@ mod tests { #[test] fn event_lines() { - let message = format_event( - &Event { kind: "message".into(), from: "alice".into(), text: "hello \x02world\x02".into(), ..Default::default() }, - "12:34:56", - ); + let message = + format_event(&Event { kind: "message".into(), from: "alice".into(), text: "hello \x02world\x02".into(), ..Default::default() }, "12:34:56"); assert_eq!(message[0], span("[12:34:56]", Style { dim: true, ..Style::fg(GREY) })); assert!(message.iter().any(|s| s.text == "world" && s.style.bold)); let nick = message.iter().find(|s| s.text == "alice").unwrap(); @@ -458,10 +453,7 @@ mod tests { let action = format_event(&Event { kind: "action".into(), from: "bob".into(), text: "waves".into(), ..Default::default() }, "01:02:03"); assert!(action.iter().any(|s| s.text.ends_with("waves") && s.style.italic && s.style.fg == Some(MAGENTA))); - let kick = format_event( - &Event { kind: "kick".into(), from: "op".into(), command: "eve".into(), text: "spam".into(), ..Default::default() }, - "t", - ); + let kick = format_event(&Event { kind: "kick".into(), from: "op".into(), command: "eve".into(), text: "spam".into(), ..Default::default() }, "t"); assert_eq!(kick.iter().map(|s| s.text.as_str()).collect::(), "[t] <-- eve was kicked by op (spam)"); let system = format_event(&Event { kind: "system".into(), text: "hi".into(), ..Default::default() }, "t"); assert_eq!(system.last().unwrap(), &span("hi", Style::fg(CYAN))); diff --git a/rust/core/src/keyfiles.rs b/rust/core/src/keyfiles.rs index fbe191f..9735b15 100644 --- a/rust/core/src/keyfiles.rs +++ b/rust/core/src/keyfiles.rs @@ -5,25 +5,11 @@ use crate::Result; use std::io::Read; use std::path::{Path, PathBuf}; -const PRIVATE_KEY_MARKERS: [&str; 6] = [ - "BEGIN OPENSSH PRIVATE KEY", - "BEGIN RSA PRIVATE KEY", - "BEGIN DSA PRIVATE KEY", - "BEGIN EC PRIVATE KEY", - "BEGIN ED25519 PRIVATE KEY", - "BEGIN PRIVATE KEY", -]; +const PRIVATE_KEY_MARKERS: [&str; 6] = + ["BEGIN OPENSSH PRIVATE KEY", "BEGIN RSA PRIVATE KEY", "BEGIN DSA PRIVATE KEY", "BEGIN EC PRIVATE KEY", "BEGIN ED25519 PRIVATE KEY", "BEGIN PRIVATE KEY"]; -const IGNORED: [&str; 8] = [ - "authorized_keys", - "authorized_keys2", - "config", - "known_hosts", - "known_hosts.old", - "ssh_config", - "ssh_known_hosts", - "ssh_known_hosts2", -]; +const IGNORED: [&str; 8] = + ["authorized_keys", "authorized_keys2", "config", "known_hosts", "known_hosts.old", "ssh_config", "ssh_known_hosts", "ssh_known_hosts2"]; /// Whether the first 512 bytes look like a private key. Unreadable or empty paths are `false` /// (this is also Go's `FileIsPrivateKey`). @@ -33,8 +19,7 @@ pub fn is_likely_private_key(path: &Path) -> bool { if f.take(512).read_to_end(&mut head).is_err() || head.is_empty() { return false; } - head.starts_with(b"openssh-key-v1") - || PRIVATE_KEY_MARKERS.iter().any(|m| head.windows(m.len()).any(|w| w == m.as_bytes())) + head.starts_with(b"openssh-key-v1") || PRIVATE_KEY_MARKERS.iter().any(|m| head.windows(m.len()).any(|w| w == m.as_bytes())) } /// Candidate private keys in `~/.ssh`, sorted. A missing home or `.ssh` gives an empty list. diff --git a/rust/core/src/models.rs b/rust/core/src/models.rs index a51a790..1892c4e 100644 --- a/rust/core/src/models.rs +++ b/rust/core/src/models.rs @@ -52,8 +52,7 @@ pub enum Protocol { } impl Protocol { - pub const SAVED: [Protocol; 7] = - [Protocol::Ssh, Protocol::Telnet, Protocol::Raw, Protocol::Github, Protocol::Vnc, Protocol::Irc, Protocol::Serial]; + pub const SAVED: [Protocol; 7] = [Protocol::Ssh, Protocol::Telnet, Protocol::Raw, Protocol::Github, Protocol::Vnc, Protocol::Irc, Protocol::Serial]; pub fn id(self) -> &'static str { match self { Protocol::Ssh => "ssh", diff --git a/rust/core/src/recording.rs b/rust/core/src/recording.rs index 29cc485..905869a 100644 --- a/rust/core/src/recording.rs +++ b/rust/core/src/recording.rs @@ -340,10 +340,7 @@ impl Store { } } let id = filename.trim_end_matches(".cast").to_string(); - let created_at = chrono::DateTime::from_timestamp(hdr.timestamp, 0) - .ok_or("recording: invalid timestamp")? - .with_timezone(&chrono::Local) - .fixed_offset(); + let created_at = chrono::DateTime::from_timestamp(hdr.timestamp, 0).ok_or("recording: invalid timestamp")?.with_timezone(&chrono::Local).fixed_offset(); Ok(RecordingMeta { session_id: id.clone(), // recording ID matches session ID by default id, diff --git a/rust/core/src/secrets.rs b/rust/core/src/secrets.rs index fea0c80..5bbde05 100644 --- a/rust/core/src/secrets.rs +++ b/rust/core/src/secrets.rs @@ -43,9 +43,7 @@ pub fn open(key: &[u8; 32], data: &[u8]) -> Result> { if data.len() < 12 { return Err("ciphertext too short".into()); } - Aes256Gcm::new(key.into()) - .decrypt(Nonce::from_slice(&data[..12]), &data[12..]) - .map_err(|_| "decryption failed".into()) + Aes256Gcm::new(key.into()).decrypt(Nonce::from_slice(&data[..12]), &data[12..]).map_err(|_| "decryption failed".into()) } #[derive(Serialize, Deserialize)] @@ -93,10 +91,7 @@ impl Vault { return Err("secrets: vault already initialised".into()); } let salt = random_bytes::<32>(); - let u = Unlocked { - key: derive_key(password, &salt)?, - vault: VaultFile { version: 1, salt: B64.encode(salt), secrets: BTreeMap::new() }, - }; + let u = Unlocked { key: derive_key(password, &salt)?, vault: VaultFile { version: 1, salt: B64.encode(salt), secrets: BTreeMap::new() } }; std::fs::create_dir_all(&self.dir)?; self.save(&u)?; *self.state.lock().unwrap() = Some(u); @@ -185,11 +180,7 @@ fn off_runtime(f: impl FnOnce() -> T + Send + 'static) -> T { fn entry(service: &str, key: &str) -> keyring::Result { // go-keyring's Windows target is "service:user"; keyring's default differs. - if cfg!(windows) { - keyring::Entry::new_with_target(&format!("{service}:{key}"), service, key) - } else { - keyring::Entry::new(service, key) - } + if cfg!(windows) { keyring::Entry::new_with_target(&format!("{service}:{key}"), service, key) } else { keyring::Entry::new(service, key) } } /// The OS keychain. Every call can fail (no secret service, locked keyring); callers fall back diff --git a/rust/core/src/service.rs b/rust/core/src/service.rs index 89df85f..71f73c1 100644 --- a/rust/core/src/service.rs +++ b/rust/core/src/service.rs @@ -229,7 +229,12 @@ impl Service { } } } - self.store.add_audit("export", "", "", &format!("Exported {} profile(s) {}", ps.len(), if include_secrets { "with secrets" } else { "without secrets" })); + self.store.add_audit( + "export", + "", + "", + &format!("Exported {} profile(s) {}", ps.len(), if include_secrets { "with secrets" } else { "without secrets" }), + ); Ok(serde_json::to_string_pretty(&ps)?) } @@ -238,7 +243,11 @@ impl Service { return Err(format!("import data is too large (max {MAX_IMPORT_JSON_BYTES} bytes)").into()); } let ps: Vec> = serde_json::from_str(json).map_err(|e| format!("invalid JSON: {e}"))?; - let ps = ps.into_iter().enumerate().map(|(i, p)| p.ok_or_else(|| format!("profile import entry {i} is null"))).collect::, _>>()?; + let ps = ps + .into_iter() + .enumerate() + .map(|(i, p)| p.ok_or_else(|| format!("profile import entry {i} is null"))) + .collect::, _>>()?; self.import(ps, strategy) } diff --git a/rust/core/src/sessions.rs b/rust/core/src/sessions.rs index 79eb11b..ded6d33 100644 --- a/rust/core/src/sessions.rs +++ b/rust/core/src/sessions.rs @@ -2,14 +2,14 @@ //! internal/sessions — same lifecycle, reconnect backoff, login scripts, recording hooks and //! credential resolution — reporting to the UI through `Event`s. -use crate::clients::{self, Callbacks, github, irc, raw, serial, telnet}; +use crate::Result; use crate::clients::local::LocalShell; use crate::clients::ssh::{HostKeyCheck, HostKeyInfo, SecretFn, SshClient}; +use crate::clients::{self, Callbacks, github, irc, raw, serial, telnet}; use crate::models::*; use crate::recording::{self, Recorder}; use crate::secrets::Secrets; use crate::storage::Store; -use crate::Result; use std::collections::HashMap; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; @@ -214,7 +214,9 @@ impl Manager { let id2 = id.clone(); std::thread::spawn(move || { let r = me.connect_protocol(&id2, epoch, &profile, prompt); - me.finish_connect(&id2, epoch, r, |me| me.store.add_audit("connect", &profile.id, &id2, &format!("Connected via {} to {}", profile.protocol.id(), profile.host))); + me.finish_connect(&id2, epoch, r, |me| { + me.store.add_audit("connect", &profile.id, &id2, &format!("Connected via {} to {}", profile.protocol.id(), profile.host)) + }); }); id } @@ -266,9 +268,8 @@ impl Manager { Protocol::Raw => { let cb = self.terminal_callbacks(id, epoch); let (me, sid) = (self.clone(), id.to_string()); - let on_data: raw::RawDataFn = Arc::new(move |d, dir, ts| { - me.emit(Event::RawData { session: sid.clone(), data: d.to_vec(), direction: dir.into(), timestamp: ts }) - }); + let on_data: raw::RawDataFn = + Arc::new(move |d, dir, ts| me.emit(Event::RawData { session: sid.clone(), data: d.to_vec(), direction: dir.into(), timestamp: ts })); let c = Arc::new(raw::RawClient::new(p, on_data, cb.on_error, cb.on_close)); c.connect()?; Ok(Client::Raw(c)) @@ -465,7 +466,20 @@ impl Manager { /// An SSH connection used only to tunnel another protocol (IRC, VNC). #[allow(clippy::too_many_arguments)] - fn tunnel(self: &Arc, id: &str, profile_id: &str, host: &str, port: i64, user: &str, method: &str, key: &str, agent: bool, mode: SecretMode, kind: &str, prompt: Prompt) -> Result> { + fn tunnel( + self: &Arc, + id: &str, + profile_id: &str, + host: &str, + port: i64, + user: &str, + method: &str, + key: &str, + agent: bool, + mode: SecretMode, + kind: &str, + prompt: Prompt, + ) -> Result> { let mut tp = Profile::new(Protocol::Ssh); tp.host = host.into(); tp.port = if port == 0 { 22 } else { port }; @@ -493,10 +507,7 @@ impl Manager { Err(e) => { let msg = e.to_string().to_lowercase(); let auth = ["token validation failed", "authentication failed", "http 401", "http 403"].iter().any(|k| msg.contains(k)); - let retry = auth - .then(|| prompt("GitHub token did not work. Please enter a valid token:").ok()) - .flatten() - .filter(|t| !t.is_empty()); + let retry = auth.then(|| prompt("GitHub token did not work. Please enter a valid token:").ok()).flatten().filter(|t| !t.is_empty()); let Some(t) = retry else { return err(format!("GitHub authentication failed: {e}")) }; if gh.secret_mode == SecretMode::Saved { self.secrets.set(&format!("profile:{}:github-token", p.id), &t); @@ -562,7 +573,19 @@ impl Manager { rooms = crate::storage::dedup_rooms(&[&rooms, &last]); } let tunnel = if ic.ssh_tunnel { - Some(self.tunnel(id, &p.id, &ic.ssh_host, ic.ssh_port, &ic.ssh_username, &ic.ssh_auth_method, &ic.ssh_private_key, ic.ssh_agent_auth, ic.ssh_secret_mode, "irc", prompt)?) + Some(self.tunnel( + id, + &p.id, + &ic.ssh_host, + ic.ssh_port, + &ic.ssh_username, + &ic.ssh_auth_method, + &ic.ssh_private_key, + ic.ssh_agent_auth, + ic.ssh_secret_mode, + "irc", + prompt, + )?) } else { None }; @@ -629,14 +652,35 @@ impl Manager { let host = if vc.vnc_host.is_empty() { p.host.clone() } else { vc.vnc_host.clone() }; let port = if vc.vnc_port == 0 { 5900 } else { vc.vnc_port } as u16; let tunnel = if vc.ssh_tunnel { - Some(self.tunnel(id, &p.id, &vc.ssh_host, vc.ssh_port, &vc.ssh_username, &vc.ssh_auth_method, &vc.ssh_private_key, vc.ssh_agent_auth, vc.ssh_secret_mode, "vnc", prompt.clone())?) + Some(self.tunnel( + id, + &p.id, + &vc.ssh_host, + vc.ssh_port, + &vc.ssh_username, + &vc.ssh_auth_method, + &vc.ssh_private_key, + vc.ssh_agent_auth, + vc.ssh_secret_mode, + "vnc", + prompt.clone(), + )?) } else { None }; let password = self.secret_fn(&p.id, "vnc-password", vc.secret_mode, prompt); let cb = self.terminal_callbacks(id, epoch); let (me, sid) = (self.clone(), id.to_string()); - let c = connect_vnc_client(&host, port, &vc, p.connect_timeout, password, tunnel.clone(), cb, Arc::new(move || me.emit(Event::VncFrame { session: sid.clone() }))); + let c = connect_vnc_client( + &host, + port, + &vc, + p.connect_timeout, + password, + tunnel.clone(), + cb, + Arc::new(move || me.emit(Event::VncFrame { session: sid.clone() })), + ); match c { Ok(c) => { self.emit(Event::VncReady { session: id.into() }); @@ -811,7 +855,12 @@ impl Manager { return; }; let delay = (1u64 << (attempt - 1).min(5)).min(30); - self.emit(Event::State { session: id.into(), state: SessionState::Reconnecting, error: String::new(), retry: Some(Retry { attempt, max, delay_secs: delay }) }); + self.emit(Event::State { + session: id.into(), + state: SessionState::Reconnecting, + error: String::new(), + retry: Some(Retry { attempt, max, delay_secs: delay }), + }); let (me, id) = (self.clone(), id.to_string()); std::thread::spawn(move || { let deadline = std::time::Instant::now() + Duration::from_secs(delay); @@ -903,7 +952,10 @@ impl Manager { "wait" => { let timeout = Duration::from_secs(if step.timeout <= 0 { 10 } else { step.timeout as u64 }); if !wait_for_output(&login, &step.value, timeout) { - me.emit(Event::Debug { session: id.clone(), message: format!("login script step {} timed out waiting for {:?}", i + 1, step.value) }); + me.emit(Event::Debug { + session: id.clone(), + message: format!("login script step {} timed out waiting for {:?}", i + 1, step.value), + }); break; } } @@ -1105,7 +1157,9 @@ impl Manager { if exists { return err(format!("sessions: session {id:?} is already being recorded")); } - let r = Arc::new(Recorder::new(self.recordings.dir(), id, &title, cols as i64, rows as i64).map_err(|e| format!("sessions: failed to start recording: {e}"))?); + let r = Arc::new( + Recorder::new(self.recordings.dir(), id, &title, cols as i64, rows as i64).map_err(|e| format!("sessions: failed to start recording: {e}"))?, + ); self.with(id, |s| s.recorder = Some(r))?; self.emit(Event::RecordingStarted { session: id.into() }); Ok(()) diff --git a/rust/core/src/sshconfig.rs b/rust/core/src/sshconfig.rs index f3cdcfb..3fda6ff 100644 --- a/rust/core/src/sshconfig.rs +++ b/rust/core/src/sshconfig.rs @@ -29,11 +29,7 @@ pub fn parse_file(path: &Path) -> Result> { return Err(format!("sshconfig: {p} is a directory").into()); } if meta.len() > MAX_CONFIG_FILE_BYTES { - return Err(format!( - "sshconfig: config file is too large: {} bytes exceeds {MAX_CONFIG_FILE_BYTES} bytes", - meta.len() - ) - .into()); + return Err(format!("sshconfig: config file is too large: {} bytes exceeds {MAX_CONFIG_FILE_BYTES} bytes", meta.len()).into()); } let raw = std::fs::read(path).map_err(|e| format!("sshconfig: failed to open {p}: {e}"))?; parse(&String::from_utf8_lossy(&raw)) @@ -138,8 +134,7 @@ fn parse_proxy_jump(value: &str) -> Option { if value.is_empty() || value == "none" { return None; } - let mut jh = - JumpHost { port: 22, auth_method: "publickey".into(), secret_mode: SecretMode::AskEveryTime, ..Default::default() }; + let mut jh = JumpHost { port: 22, auth_method: "publickey".into(), secret_mode: SecretMode::AskEveryTime, ..Default::default() }; if let Some((user, rest)) = value.split_once('@') { jh.username = user.into(); value = rest; diff --git a/rust/core/src/storage.rs b/rust/core/src/storage.rs index e9ae6f1..21a37f5 100644 --- a/rust/core/src/storage.rs +++ b/rust/core/src/storage.rs @@ -1,8 +1,8 @@ //! SQLite persistence: profiles, known hosts, preferences, audit log, snippets and IRC channel //! state. The schema and migration files are the Go app's, so existing databases open unchanged. -use crate::models::*; use crate::Result; +use crate::models::*; use chrono::{DateTime, SecondsFormat}; use rusqlite::{Connection, OptionalExtension, Row, params}; use std::path::Path; @@ -228,10 +228,7 @@ impl Store { } pub fn set_favourite(&self, id: &str, fav: bool) -> Result<()> { - let n = self.db.lock().unwrap().execute( - "UPDATE profiles SET favourite = ?, updated_at = ? WHERE id = ?", - params![fav as i64, fmt_time(&now()), id], - )?; + let n = self.db.lock().unwrap().execute("UPDATE profiles SET favourite = ?, updated_at = ? WHERE id = ?", params![fav as i64, fmt_time(&now()), id])?; if n == 0 { return Err(format!("storage: profile {id:?} not found").into()); } @@ -240,10 +237,7 @@ impl Store { pub fn update_last_connected(&self, id: &str) -> Result<()> { let now = fmt_time(&now()); - self.db - .lock() - .unwrap() - .execute("UPDATE profiles SET last_connected = ?, updated_at = ? WHERE id = ?", params![now, now, id])?; + self.db.lock().unwrap().execute("UPDATE profiles SET last_connected = ?, updated_at = ? WHERE id = ?", params![now, now, id])?; Ok(()) } @@ -337,9 +331,7 @@ impl Store { pub fn list_audit(&self, limit: i64) -> Result> { let db = self.db.lock().unwrap(); - let mut st = db.prepare( - "SELECT id, timestamp, action, profile_id, session_id, details FROM audit_log ORDER BY timestamp DESC LIMIT ?", - )?; + let mut st = db.prepare("SELECT id, timestamp, action, profile_id, session_id, details FROM audit_log ORDER BY timestamp DESC LIMIT ?")?; let rows = st .query_map([limit], |r| { Ok(AuditEntry { @@ -357,8 +349,7 @@ impl Store { pub fn irc_last_rooms(&self, profile_id: &str) -> Result> { let db = self.db.lock().unwrap(); - let mut st = - db.prepare("SELECT room_name FROM irc_channel_state WHERE profile_id = ? ORDER BY position, room_name")?; + let mut st = db.prepare("SELECT room_name FROM irc_channel_state WHERE profile_id = ? ORDER BY position, room_name")?; let rows = st.query_map([profile_id], |r| r.get(0))?.collect::>>()?; Ok(rows) } @@ -384,14 +375,8 @@ impl Store { } pub fn preferences(&self) -> AppPreferences { - let v: Option = self - .db - .lock() - .unwrap() - .query_row("SELECT value FROM preferences WHERE key = '_all'", [], |r| r.get(0)) - .optional() - .ok() - .flatten(); + let v: Option = + self.db.lock().unwrap().query_row("SELECT value FROM preferences WHERE key = '_all'", [], |r| r.get(0)).optional().ok().flatten(); let mut p = v.and_then(|v| serde_json::from_str::(&v).ok()).unwrap_or_default(); p.apply_defaults(); p @@ -407,9 +392,7 @@ impl Store { pub fn list_snippets(&self) -> Result> { let db = self.db.lock().unwrap(); - let mut st = db.prepare( - "SELECT id, name, command, description, folder, tags, created_at, updated_at FROM snippets ORDER BY name ASC", - )?; + let mut st = db.prepare("SELECT id, name, command, description, folder, tags, created_at, updated_at FROM snippets ORDER BY name ASC")?; let rows = st.query_map([], scan_snippet)?.collect::>>()?; Ok(rows) } diff --git a/rust/core/src/terminalenv.rs b/rust/core/src/terminalenv.rs index b43ee4e..e75203a 100644 --- a/rust/core/src/terminalenv.rs +++ b/rust/core/src/terminalenv.rs @@ -16,11 +16,7 @@ pub fn local_shell_env(env: Vec) -> Vec { /// Variables to request for an SSH session. A locale set by the profile is kept as is; otherwise /// a UTF-8 `LC_CTYPE` is added. Keys are trimmed and empty keys dropped. Iteration order is sorted. pub fn ssh_env_vars(profile_env: &BTreeMap) -> BTreeMap { - let mut out: BTreeMap = profile_env - .iter() - .map(|(k, v)| (k.trim().to_string(), v.clone())) - .filter(|(k, _)| !k.is_empty()) - .collect(); + let mut out: BTreeMap = profile_env.iter().map(|(k, v)| (k.trim().to_string(), v.clone())).filter(|(k, _)| !k.is_empty()).collect(); if ![LC_ALL, LC_CTYPE, LANG].iter().any(|k| out.contains_key(*k)) { out.insert(LC_CTYPE.into(), default_utf8_locale(std::env::consts::OS).into()); } diff --git a/rust/core/src/updater.rs b/rust/core/src/updater.rs index 4e64099..99e3d7f 100644 --- a/rust/core/src/updater.rs +++ b/rust/core/src/updater.rs @@ -70,17 +70,8 @@ pub fn expand_update_url(template: &str) -> String { /// GET with the updater headers; the body is read (bounded) only on 200. fn fetch(url: &str, accept: &str) -> Result<(u16, Vec)> { - let agent: ureq::Agent = ureq::Agent::config_builder() - .timeout_global(Some(REQUEST_TIMEOUT)) - .http_status_as_error(false) - .build() - .into(); - let resp = agent - .get(url) - .header("Accept", accept) - .header("User-Agent", USER_AGENT) - .call() - .map_err(|e| format!("updater: request failed: {e}"))?; + let agent: ureq::Agent = ureq::Agent::config_builder().timeout_global(Some(REQUEST_TIMEOUT)).http_status_as_error(false).build().into(); + let resp = agent.get(url).header("Accept", accept).header("User-Agent", USER_AGENT).call().map_err(|e| format!("updater: request failed: {e}"))?; let status = resp.status().as_u16(); if status != 200 { return Ok((status, vec![])); @@ -130,8 +121,7 @@ pub fn check_github(current_version: &str) -> Result { if status != 200 { return Err(format!("updater: GitHub API returned {status}").into()); } - let release: Release = - serde_json::from_slice(&body).map_err(|e| format!("updater: failed to decode response: {e}"))?; + let release: Release = serde_json::from_slice(&body).map_err(|e| format!("updater: failed to decode response: {e}"))?; let latest = normalise_version(&release.tag_name); if release.draft || release.prerelease { return Ok(UpdateInfo { current_version: current, latest_version: latest, ..Default::default() }); @@ -189,10 +179,7 @@ fn is_newer(latest: &str, current: &str) -> bool { /// Go's `fmt.Sscanf(s, "%d")`: optional sign then digits; anything else is 0. fn leading_int(s: Option<&&str>) -> i64 { let s = s.map_or("", |s| s.trim_start()); - let end = s - .char_indices() - .find(|&(i, c)| !(c.is_ascii_digit() || (i == 0 && (c == '+' || c == '-')))) - .map_or(s.len(), |(i, _)| i); + let end = s.char_indices().find(|&(i, c)| !(c.is_ascii_digit() || (i == 0 && (c == '+' || c == '-')))).map_or(s.len(), |(i, _)| i); s[..end].parse().unwrap_or(0) } @@ -228,10 +215,7 @@ mod tests { } req.extend(&buf[..n]); } - let head = format!( - "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - body.len() - ); + let head = format!("HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()); let _ = s.write_all(head.as_bytes()); let _ = s.write_all(&body); }); diff --git a/rust/core/src/validation.rs b/rust/core/src/validation.rs index f5866f1..7edec6c 100644 --- a/rust/core/src/validation.rs +++ b/rust/core/src/validation.rs @@ -380,8 +380,7 @@ pub fn validate_serial_config(cfg: &SerialConfig) -> Result<(), ValidationErrors fn is_valid_nick(nick: &str) -> bool { let b = nick.as_bytes(); let Some((&first, rest)) = b.split_first() else { return false }; - ((b'A'..=b'}').contains(&first) || first == b'?') - && rest.iter().all(|c| (b'A'..=b'}').contains(c) || c.is_ascii_digit() || *c == b'-') + ((b'A'..=b'}').contains(&first) || first == b'?') && rest.iter().all(|c| (b'A'..=b'}').contains(c) || c.is_ascii_digit() || *c == b'-') } /// girc's IsValidChannel. @@ -526,14 +525,8 @@ mod tests { assert!(validate_vnc_config("", &named).is_ok()); assert!(validate_vnc_config("desktop.local", &base).is_ok()); assert!(validate_vnc_config("", &base).is_err()); - let tunnel = VncConfig { - ssh_tunnel: true, - ssh_host: "jump.local".into(), - ssh_port: 22, - ssh_username: "admin".into(), - ssh_auth_method: "token".into(), - ..base - }; + let tunnel = + VncConfig { ssh_tunnel: true, ssh_host: "jump.local".into(), ssh_port: 22, ssh_username: "admin".into(), ssh_auth_method: "token".into(), ..base }; assert!(validate_vnc_config("desktop.local", &tunnel).is_err()); } diff --git a/rust/core/tests/ssh_integration.rs b/rust/core/tests/ssh_integration.rs index 7736d28..b489762 100644 --- a/rust/core/tests/ssh_integration.rs +++ b/rust/core/tests/ssh_integration.rs @@ -40,9 +40,8 @@ fn free_port() -> u16 { fn start(name: &str) -> Option { let sshd = which("sshd")?; let keygen = which("ssh-keygen")?; - let sftp_server = ["/usr/lib/openssh/sftp-server", "/usr/libexec/openssh/sftp-server", "/usr/libexec/sftp-server"] - .iter() - .find(|p| Path::new(p).exists())?; + let sftp_server = + ["/usr/lib/openssh/sftp-server", "/usr/libexec/openssh/sftp-server", "/usr/libexec/sftp-server"].iter().find(|p| Path::new(p).exists())?; let dir = std::env::temp_dir().join(format!("nexus-sshd-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); @@ -95,10 +94,13 @@ fn callbacks() -> (Callbacks, Seen) { fn accept_all() -> (HostKeyCheck, Arc>>) { let fps: Arc>> = Arc::default(); let f2 = fps.clone(); - (Arc::new(move |i| { - f2.lock().unwrap().push(format!("{} {}", i.key_type, i.fingerprint)); - Ok(()) - }), fps) + ( + Arc::new(move |i| { + f2.lock().unwrap().push(format!("{} {}", i.key_type, i.fingerprint)); + Ok(()) + }), + fps, + ) } fn wait_for(seen: &Seen, needle: &str) -> bool { @@ -151,7 +153,9 @@ fn echo_through(port: u16) -> String { #[test] fn shell_exec_forwards_sftp() { - let Some(s) = start("main") else { return eprintln!("sshd not available; skipping") }; + let Some(s) = start("main") else { + return eprintln!("sshd not available; skipping"); + }; let (cb, seen) = callbacks(); let (check, fps) = accept_all(); let c = SshClient::new(&profile(&s), cb); @@ -166,11 +170,28 @@ fn shell_exec_forwards_sftp() { let echo = echo_server(); let lport = free_port(); - let rule = PortForward { id: "l".into(), direction: ForwardDirection::Local, bind_port: lport as i64, dest_host: "127.0.0.1".into(), dest_port: echo as i64, enabled: true, ..Default::default() }; + let rule = PortForward { + id: "l".into(), + direction: ForwardDirection::Local, + bind_port: lport as i64, + dest_host: "127.0.0.1".into(), + dest_port: echo as i64, + enabled: true, + ..Default::default() + }; c.start_forward(rule).unwrap(); assert_eq!(echo_through(lport), "ping"); let rport = free_port(); - let rule = PortForward { id: "r".into(), direction: ForwardDirection::Remote, bind_host: "127.0.0.1".into(), bind_port: rport as i64, dest_host: "127.0.0.1".into(), dest_port: echo as i64, enabled: true, ..Default::default() }; + let rule = PortForward { + id: "r".into(), + direction: ForwardDirection::Remote, + bind_host: "127.0.0.1".into(), + bind_port: rport as i64, + dest_host: "127.0.0.1".into(), + dest_port: echo as i64, + enabled: true, + ..Default::default() + }; c.start_forward(rule).unwrap(); assert_eq!(echo_through(rport), "ping"); assert_eq!(c.active_forwards().len(), 2); @@ -226,7 +247,8 @@ fn jump_host_chain() { let Some(s) = start("jump") else { return }; let mut p = profile(&s); let key = p.ssh.as_ref().unwrap().private_key_path.clone(); - p.ssh.as_mut().unwrap().jump_hosts = vec![JumpHost { host: "127.0.0.1".into(), port: s.port as i64, username: p.username.clone(), private_key_path: key, ..Default::default() }]; + p.ssh.as_mut().unwrap().jump_hosts = + vec![JumpHost { host: "127.0.0.1".into(), port: s.port as i64, username: p.username.clone(), private_key_path: key, ..Default::default() }]; let (check, fps) = accept_all(); let c = SshClient::new(&p, Callbacks::noop()); c.connect(check, None, None).unwrap(); @@ -238,7 +260,9 @@ fn jump_host_chain() { #[test] fn agent_auth_and_forwarding() { let Some(mut s) = start("agent") else { return }; - let (Some(agent), Some(add)) = (which("ssh-agent"), which("ssh-add")) else { return }; + let (Some(agent), Some(add)) = (which("ssh-agent"), which("ssh-add")) else { + return; + }; let sock = s.dir.join("agent.sock"); s.agent = Some(Command::new(agent).arg("-D").arg("-a").arg(&sock).spawn().unwrap()); let start = Instant::now(); diff --git a/rust/rustfmt.toml b/rust/rustfmt.toml new file mode 100644 index 0000000..40d36bf --- /dev/null +++ b/rust/rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 160 +use_small_heuristics = "Max"