From ce9c5c6b44c006f842a41c32ee8e641efdf2b408 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Sun, 11 Oct 2026 19:41:30 +0100 Subject: [PATCH] Screenshot automation (--connect repeatable, --do, --answer, --shot-delay), SFTP Browser palette entry, centred dialogs, IRC opens the first joined room; demo_data example --- rust/app/src/app.rs | 43 ++++++-- rust/app/src/chrome.rs | 16 +++ rust/app/src/irc_view.rs | 5 + rust/app/src/main.rs | 22 +++- rust/app/src/recordings.rs | 4 + rust/app/src/snippets.rs | 2 + rust/app/src/triggers.rs | 2 + rust/core/examples/demo_data.rs | 181 ++++++++++++++++++++++++++++++++ 8 files changed, 262 insertions(+), 13 deletions(-) create mode 100644 rust/core/examples/demo_data.rs diff --git a/rust/app/src/app.rs b/rust/app/src/app.rs index 902a389..a5fea03 100644 --- a/rust/app/src/app.rs +++ b/rust/app/src/app.rs @@ -23,8 +23,14 @@ pub struct Options { /// Open a window at startup: editor, quick, settings[:tab], keys, snippets, triggers, /// recordings, palette, export, import (screenshot checks). pub open: Option, - /// Connect a saved profile (by name or id) at startup. - pub connect: Option, + /// Connect saved profiles (by name or id) at startup. + pub connect: Vec, + /// Command-palette actions (by label) to run once sessions have had time to connect. + pub actions: Vec, + /// Replies to the first credential prompts, in order (screenshots of token-protected sessions). + pub answers: Vec, + /// How long to wait before taking the screenshot. + pub shot_delay: std::time::Duration, } /// Errors from worker threads, drained into toasts each frame. @@ -108,8 +114,10 @@ pub struct App { pub tabs_ui: tabs::TabsUi, pub latency: Option<(String, Result)>, pub update: Option, - screenshot: Option<(PathBuf, u32)>, + screenshot: Option<(PathBuf, std::time::Instant, std::time::Duration)>, shot_settle: u32, + actions: Vec, + answers: Vec, run_local: Option<(String, Option)>, /// Apply `prefs.sidebar_width` to the panel on the next frame (after Settings changed it). resize_sidebar: bool, @@ -155,13 +163,15 @@ impl App { tabs_ui: Default::default(), latency: None, update: None, - screenshot: opts.screenshot.map(|p| (p, 0)), + screenshot: opts.screenshot.map(|p| (p, std::time::Instant::now(), opts.shot_delay)), shot_settle: 0, + actions: opts.actions, + answers: opts.answers, run_local: opts.run_local.map(|c| (c, None)), resize_sidebar: false, }; - if let Some(c) = opts.connect.as_deref() { - match app.s.profiles.iter().find(|p| p.id == c || p.display_name == c).map(|p| p.id.clone()) { + for c in &opts.connect { + match app.s.profiles.iter().find(|p| &p.id == c || &p.display_name == c).map(|p| p.id.clone()) { Some(id) => app.connect_profile(&id, true), None => eprintln!("--connect: no profile named {c:?}"), } @@ -479,6 +489,9 @@ impl App { self.s.success(format!("Recording saved ({:.0}s, {events} events)", duration)); } Event::SftpProgress { session, progress } => self.sftp.on_progress(&session, progress), + Event::SecretPrompt { session, title, message, reply } if !self.answers.is_empty() => { + let _ = (session, title, message, reply.send(self.answers.remove(0))); + } Event::SecretPrompt { session, title, message, reply } => { self.prompts.push_back(Prompt::Secret { session, title, message, value: String::new(), reply }); } @@ -807,11 +820,21 @@ impl App { // --- screenshot automation --------------------------------------------------------- fn screenshot(&mut self, ctx: &egui::Context) { - let Some((path, frames)) = &mut self.screenshot else { + let Some((path, started, delay)) = &mut self.screenshot else { return; }; - *frames += 1; - if *frames < 40 { + let (elapsed, delay) = (started.elapsed(), *delay); + if !self.actions.is_empty() && elapsed >= delay / 2 { + for a in std::mem::take(&mut self.actions) { + if !chrome::run_labelled(self, ctx, &a) { + eprintln!("--do: no palette command {a:?}"); + } + } + ctx.request_repaint(); + return; + } + let path = path.clone(); + if elapsed < delay { std::thread::sleep(std::time::Duration::from_millis(15)); ctx.request_repaint(); return; @@ -824,7 +847,7 @@ impl App { if let Some(img) = shot { let [w, h] = img.size; let raw: Vec = img.pixels.iter().flat_map(|p| p.to_array()).collect(); - if let Err(e) = image::save_buffer(&*path, &raw, w as u32, h as u32, image::ExtendedColorType::Rgba8) { + if let Err(e) = image::save_buffer(&path, &raw, w as u32, h as u32, image::ExtendedColorType::Rgba8) { eprintln!("screenshot: {e}"); } ctx.send_viewport_cmd(egui::ViewportCommand::Close); diff --git a/rust/app/src/chrome.rs b/rust/app/src/chrome.rs index 15033f1..7d38bb3 100644 --- a/rust/app/src/chrome.rs +++ b/rust/app/src/chrome.rs @@ -436,6 +436,7 @@ enum Cmd { Theme(ThemeKind), About, Fullscreen, + Sftp(String), } /// Fuzzy subsequence match with bonuses for consecutive and leading hits (utils.js fuzzyMatch). @@ -503,14 +504,29 @@ fn commands(app: &App) -> Vec<(Cmd, String, &'static str, &'static str, String)> if app.s.focused_session().and_then(|f| app.s.pane(&f)).is_some_and(|p| matches!(p.view, View::Terminal(_))) { v.push((Cmd::SplitRight, "Split Pane Right".into(), "Terminal", icon::SQUARE_SPLIT_HORIZONTAL, keys("Mod+Shift+D"))); v.push((Cmd::SplitDown, "Split Pane Down".into(), "Terminal", icon::SQUARE_SPLIT_VERTICAL, keys("Mod+Shift+E"))); + if let Some(id) = app.s.focused_session().filter(|f| app.s.pane(f).is_some_and(|p| p.info.protocol == Protocol::Ssh)) { + v.push((Cmd::Sftp(id), "SFTP Browser".into(), "Terminal", icon::FOLDER_OPEN, String::new())); + } v.push((Cmd::Clear, "Clear Terminal".into(), "Terminal", icon::ERASER, String::new())); v.push((Cmd::Reset, "Reset Terminal".into(), "Terminal", icon::ARROW_COUNTER_CLOCKWISE, String::new())); } v } +/// Runs the palette command with this label (case-insensitive); false when there is none. +pub fn run_labelled(app: &mut App, ctx: &egui::Context, label: &str) -> bool { + match commands(app).into_iter().find(|c| c.1.eq_ignore_ascii_case(label)) { + Some(c) => { + run(app, ctx, c.0); + true + } + None => false, + } +} + fn run(app: &mut App, ctx: &egui::Context, c: Cmd) { match c { + Cmd::Sftp(id) => app.s.request(Request::Sftp(id)), Cmd::Connect(id) => app.connect_profile(&id, false), Cmd::NewProfile(p) => app.s.request(Request::NewProfile { protocol: Some(p), folder: String::new() }), Cmd::Quick => app.quick.open(), diff --git a/rust/app/src/irc_view.rs b/rust/app/src/irc_view.rs index 9d75926..70107ff 100644 --- a/rust/app/src/irc_view.rs +++ b/rust/app/src/irc_view.rs @@ -191,7 +191,12 @@ impl IrcView { pub fn on_event(&mut self, event: irc::Event) { match route(&event) { Route::Rooms => { + // Show the first room once auto-join lands, rather than leaving the server log up. + let first = self.joined.is_empty() && self.active == SERVER; self.joined = event.rooms; + if first && let Some(r) = self.joined.first().map(|r| r.name.clone()) { + self.set_active(&r); + } for r in self.joined.clone() { self.target(&r.name); } diff --git a/rust/app/src/main.rs b/rust/app/src/main.rs index 5c8cd87..7c7066d 100644 --- a/rust/app/src/main.rs +++ b/rust/app/src/main.rs @@ -33,7 +33,16 @@ use nexus_core::service::Service; use std::sync::Arc; fn main() -> eframe::Result { - let mut opts = app::Options { screenshot: None, theme: None, run_local: None, open: None, connect: None }; + let mut opts = app::Options { + screenshot: None, + theme: None, + run_local: None, + open: None, + connect: vec![], + actions: vec![], + answers: vec![], + shot_delay: std::time::Duration::from_millis(600), + }; let mut size = [1360.0, 860.0]; let mut data_dir = None; let mut args = std::env::args().skip(1); @@ -42,7 +51,14 @@ fn main() -> eframe::Result { "--theme" => opts.theme = args.next(), "--run-local" => opts.run_local = args.next(), "--open" => opts.open = args.next(), - "--connect" => opts.connect = args.next(), + "--connect" => opts.connect.extend(args.next()), + "--do" => opts.actions.extend(args.next()), + "--answer" => opts.answers.extend(args.next()), + "--shot-delay" => { + if let Some(ms) = args.next().and_then(|s| s.parse().ok()) { + opts.shot_delay = std::time::Duration::from_millis(ms); + } + } "--screenshot" => opts.screenshot = args.next().map(Into::into), // Use another data directory (testing, screenshots, portable installs). "--data-dir" => data_dir = args.next(), @@ -57,7 +73,7 @@ fn main() -> eframe::Result { } _ => { println!( - "usage: nexus-terminal [--theme pro|midnight|light] [--data-dir DIR] [--connect NAME] [--open WINDOW] [--run-local CMD] [--screenshot out.png] [--size WxH] [--version]" + "usage: nexus-terminal [--theme pro|midnight|light] [--data-dir DIR] [--connect NAME]... [--open WINDOW] [--do PALETTE-COMMAND]... [--answer SECRET]... [--shot-delay MS] [--run-local CMD] [--screenshot out.png] [--size WxH] [--version]" ); return Ok(()); } diff --git a/rust/app/src/recordings.rs b/rust/app/src/recordings.rs index b0d8e74..55bdb4c 100644 --- a/rust/app/src/recordings.rs +++ b/rust/app/src/recordings.rs @@ -183,6 +183,8 @@ impl Recordings { let mut open = self.open; egui::Window::new(format!("{} Session Recordings", icon::RECORD)) .id(egui::Id::new("recordings")) + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.content_rect().center()) .open(&mut open) .collapsible(false) .default_size([640.0, 440.0]) @@ -298,6 +300,8 @@ impl Recordings { let mut open = true; egui::Window::new(format!("{} {}", icon::PLAY_CIRCLE, p.title)) .id(egui::Id::new("rec-playback")) + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.content_rect().center()) .open(&mut open) .collapsible(false) .default_size([820.0, 520.0]) diff --git a/rust/app/src/snippets.rs b/rust/app/src/snippets.rs index 598caea..e118817 100644 --- a/rust/app/src/snippets.rs +++ b/rust/app/src/snippets.rs @@ -104,6 +104,8 @@ impl Snippets { let mut open = self.open; egui::Window::new(format!("{} Snippets", icon::CODE)) .id(egui::Id::new("snippets")) + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.content_rect().center()) .open(&mut open) .collapsible(false) .default_size([720.0, 440.0]) diff --git a/rust/app/src/triggers.rs b/rust/app/src/triggers.rs index eeac228..831bbb4 100644 --- a/rust/app/src/triggers.rs +++ b/rust/app/src/triggers.rs @@ -159,6 +159,8 @@ impl Triggers { let mut open = self.open; egui::Window::new(format!("{} Trigger Rules", icon::LIGHTNING)) .id(egui::Id::new("triggers")) + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.content_rect().center()) .open(&mut open) .collapsible(false) .default_size([620.0, 380.0]) diff --git a/rust/core/examples/demo_data.rs b/rust/core/examples/demo_data.rs new file mode 100644 index 0000000..806b881 --- /dev/null +++ b/rust/core/examples/demo_data.rs @@ -0,0 +1,181 @@ +//! Fills a data directory with demo profiles, snippets, known hosts, trigger rules and recordings +//! for the user-guide screenshots (docs/guide/screenshots.sh). +//! +//! cargo run -p nexus-core --example demo_data -- DATA_DIR SSH_KEY + +use nexus_core::config::AppConfig; +use nexus_core::models::*; +use nexus_core::service::Service; + +fn main() { + let mut args = std::env::args().skip(1); + let (Some(dir), Some(key)) = (args.next(), args.next()) else { + eprintln!("usage: demo_data DATA_DIR SSH_KEY"); + std::process::exit(2); + }; + let config = AppConfig::in_dir(dir.into()); + std::fs::create_dir_all(&config.vault_dir).unwrap(); + std::fs::create_dir_all(&config.recordings_dir).unwrap(); + let svc = Service::open(config).unwrap(); + let user = std::env::var("USER").unwrap_or_else(|_| "andy".into()); + + let mk = |proto, name: &str, host: &str, port: i64, folder: &str, fav: bool| { + let mut p = Profile::new(proto); + p.display_name = name.into(); + p.host = host.into(); + if port != 0 { + p.port = port; + } + p.folder = folder.into(); + p.favourite = fav; + p + }; + let ssh = |name: &str, folder: &str, fav: bool, tags: &[&str]| { + let mut p = mk(Protocol::Ssh, name, "127.0.0.1", 42022, folder, fav); + p.username = user.clone(); + p.tags = tags.iter().map(|t| t.to_string()).collect(); + let s = p.ssh.as_mut().unwrap(); + s.private_key_path = key.clone(); + s.auth_methods = vec!["publickey".into()]; + s.host_key_policy = HostKeyPolicy::AcceptNew; + p + }; + + let mut profiles = vec![]; + let mut atlas = ssh("atlas", "Production", true, &["web", "eu-west"]); + atlas.description = "Primary web node".into(); + atlas.notes = "Deploys go out from here. Be nice to it.".into(); + profiles.push(atlas); + let mut db = ssh("db-primary", "Production", false, &["db"]); + db.description = "Postgres primary, via the bastion".into(); + db.ssh.as_mut().unwrap().jump_hosts = vec![JumpHost { + host: "bastion.example.net".into(), + port: 22, + username: user.clone(), + auth_method: "publickey".into(), + secret_mode: SecretMode::Saved, + private_key_path: key.clone(), + }]; + db.ssh.as_mut().unwrap().port_forwards = vec![PortForward { + id: new_id(), + direction: ForwardDirection::Local, + bind_host: "127.0.0.1".into(), + bind_port: 15432, + dest_host: "127.0.0.1".into(), + dest_port: 5432, + enabled: true, + label: "Postgres".into(), + }]; + profiles.push(db); + let mut hello = ssh("hello-ssh", "Production", false, &["demo"]); + hello.ssh.as_mut().unwrap().host_key_policy = HostKeyPolicy::Ask; + hello.description = "Same box, but asks about its host key".into(); + profiles.push(hello); + profiles.push(ssh("build-runner", "Lab", false, &["ci"])); + + let mut router = mk(Protocol::Telnet, "Core router", "192.0.2.1", 0, "Lab", false); + router.description = "Out-of-band console".into(); + profiles.push(router); + let mut console = mk(Protocol::Serial, "Switch console", "/dev/ttyUSB0", 0, "Lab", false); + console.serial.as_mut().unwrap().baud_rate = 9600; + profiles.push(console); + let mut sensor = mk(Protocol::Raw, "Sensor feed", "127.0.0.1", 42023, "Lab", false); + let raw = sensor.raw.as_mut().unwrap(); + raw.show_timestamps = true; + raw.show_direction = true; + raw.line_ending = LineEnding::Lf; + raw.payload_presets = vec![ + PayloadPreset { id: new_id(), name: "Status".into(), payload: "U1RBVFVT".into(), format: "text".into() }, + PayloadPreset { id: new_id(), name: "Reset".into(), payload: "UkVTRVQ=".into(), format: "text".into() }, + ]; + profiles.push(sensor); + + let mut irc = mk(Protocol::Irc, "Libera Chat", "127.0.0.1", 42667, "Home", true); + let ic = irc.irc.as_mut().unwrap(); + ic.nick = "andy".into(); + ic.real_name = "Andy Dixon".into(); + ic.use_tls = false; + ic.auto_join_rooms = vec!["#nexus".into(), "#rust".into()]; + profiles.push(irc); + let mut gh = mk(Protocol::Github, "GitHub", "github.com", 0, "Home", false); + gh.github.as_mut().unwrap().base_url = "http://127.0.0.1:42080".into(); + profiles.push(gh); + let mut vnc = mk(Protocol::Vnc, "Studio desktop", "127.0.0.1", 42900, "Home", false); + vnc.vnc.as_mut().unwrap().vnc_host = "127.0.0.1".into(); + vnc.vnc.as_mut().unwrap().vnc_port = 42900; + vnc.vnc.as_mut().unwrap().scale_to_fit = true; + profiles.push(vnc); + + for p in profiles { + svc.create_profile(p).unwrap(); + } + + for (name, cmd, desc, folder) in [ + ("Disk usage", "df -h | sort -k5 -r | head", "Fullest filesystems first", "Diagnostics"), + ("Who's hogging CPU", "ps aux --sort=-%cpu | head -n 8", "Top processes by CPU", "Diagnostics"), + ("Tail a service", "journalctl -fu {{service}}", "Asks which service", "Logs"), + ("Stamp the log", "echo '--- {{hostname}} {{date}} {{time}} ---'", "Drop a marker in the scrollback", "Logs"), + ("Restart nginx", "sudo systemctl restart nginx && systemctl status nginx --no-pager", "", "Deploy"), + ] { + svc.save_snippet(Snippet { + id: String::new(), + name: name.into(), + command: cmd.into(), + description: desc.into(), + folder: folder.into(), + tags: vec![], + created_at: now(), + updated_at: now(), + }) + .unwrap(); + } + + for (host, port, kind, fp) in [ + ("bastion.example.net", 22, "ssh-ed25519", "SHA256:x1rT0pV6c1m9QbHq4nWJcF5gk1Yt3uZ3w8sLrM2dE0Q"), + ("192.0.2.40", 22, "ecdsa-sha2-nistp256", "SHA256:9Hq0b2VtZr6y1oQwA7mXJ4sEo5kP8fN3cLdT6uB2iRg"), + ("git.example.org", 2222, "ssh-rsa", "SHA256:Qm7cV2pK9zR1tY5uW3xN8bL0aE6sD4fG2hJ1kM7nP5o"), + ] { + let n = now(); + svc.store + .save_known_host(&KnownHost { + id: String::new(), + host: host.into(), + port, + key_type: kind.into(), + fingerprint: fp.into(), + public_key: String::new(), + first_seen: n, + last_seen: n, + trusted: true, + }) + .unwrap(); + } + + std::fs::write( + svc.config.data_dir.join("triggers.json"), + r#"[{"name":"Errors","pattern":"(?i)\\b(error|fatal|panic)\\b","isRegex":true,"action":"both","caseSensitive":false,"enabled":true}, +{"name":"Build finished","pattern":"BUILD SUCCESSFUL","isRegex":false,"action":"sound","caseSensitive":true,"enabled":true}, +{"name":"Disk nearly full","pattern":"9[0-9]%","isRegex":true,"action":"highlight","caseSensitive":false,"enabled":false}]"#, + ) + .unwrap(); + + let start = 1_791_633_486u64; + for (i, (title, lines)) in [ + ("atlas — deploy", &["$ ./deploy.sh\r\n", "\x1b[32m✔\x1b[0m built in 41s\r\n", "\x1b[32m✔\x1b[0m rolled out to 4/4 nodes\r\n"][..]), + ("Switch console", &["Switch> enable\r\n", "Switch# show version\r\n", "Uptime is 212 days\r\n"][..]), + ("Local Machine", &["$ cargo test\r\n", "test result: \x1b[32mok\x1b[0m. 135 passed\r\n"][..]), + ] + .iter() + .enumerate() + { + let mut body = format!( + "{{\"version\":2,\"width\":100,\"height\":30,\"timestamp\":{},\"title\":\"{title}\",\"env\":{{\"TERM\":\"xterm-256color\"}}}}\n", + start - i as u64 * 86_400 * 3 + ); + for (j, l) in lines.iter().enumerate() { + body += &format!("[{}.5,\"o\",{}]\n", j * 2, serde_json::to_string(l).unwrap()); + } + std::fs::write(svc.config.recordings_dir.join(format!("demo-{i}.cast")), body).unwrap(); + } + println!("demo data written to {}", svc.config.data_dir.display()); +}