Skip to main content

purple_ssh/
tui_loop.rs

1//! TUI event loop and the per-iteration helpers that drive it.
2//!
3//! Everything that runs while the TUI is on the alternate screen lives
4//! here: the main `run_tui` orchestrator, its six tick-scoped helpers
5//! (startup tasks, event dispatch, lazy cert check, pending SSH connect,
6//! pending snippet run, teardown), plus Vault cert-cache helpers used by
7//! the dispatch logic.
8
9use anyhow::Result;
10
11use crate::app::{self, App};
12use crate::event::{self, AppEvent, EventHandler};
13use crate::ssh_config::model::SshConfigFile;
14use crate::{
15    animation, askpass, connection, ensure_bw_session, ensure_keychain_password,
16    ensure_proton_login, ensure_vault_ssh_chain_if_needed, first_launch_init, handler, import,
17    key_activity, ping, preferences, snippet, tui, update, vault_ssh,
18};
19
20pub fn run_tui(mut app: App) -> Result<()> {
21    // First-launch welcome hint (one-shot: creates .purple/ so it won't show again)
22    if app.status_center.status().is_none() && !app.demo_mode {
23        let paths = app.env().paths().cloned();
24        if let Some(paths) = paths {
25            let purple_dir = paths.purple_dir();
26            if let Some(has_backup) = first_launch_init(&purple_dir, app.reload.config_path()) {
27                let host_count = app.hosts_state.list().len();
28                let known_hosts_count = if host_count == 0 {
29                    import::count_known_hosts_candidates(Some(&paths))
30                } else {
31                    0
32                };
33                app.ui.set_known_hosts_count(known_hosts_count);
34                app.screen = app::Screen::Welcome {
35                    has_backup,
36                    host_count,
37                    known_hosts_count,
38                };
39            }
40        }
41    }
42
43    let mut terminal = tui::Tui::new()?;
44    terminal.enter()?;
45    let events = EventHandler::new(50);
46    let events_tx = events.sender();
47    let mut last_config_check = std::time::Instant::now();
48
49    // Skip background tasks in demo mode (ping status is pre-populated).
50    if !app.demo_mode {
51        spawn_startup_tasks(&mut app, &events_tx);
52    }
53
54    let mut anim = animation::AnimationState::new();
55
56    while app.running {
57        anim.detect_transitions(&mut app);
58        terminal.draw(&mut app, &mut anim)?;
59
60        // During animation, use a short timeout for smooth frames (~60fps).
61        // During ping checking, use 80ms timeout for spinner.
62        // Otherwise, block until the next event arrives.
63        let vault_signing = app.vault.is_signing();
64        let provider_syncing = !app.providers.syncing().is_empty();
65        // Tunnels tab drives the live chart animation. While at least
66        // one tunnel is running we tick at 16ms (~60 fps) so the
67        // swimlane bars and sparklines drift smoothly. The tick also
68        // refreshes the uptime readout every frame.
69        let tunnels_anim_tick =
70            matches!(app.top_page, app::TopPage::Tunnels) && !app.tunnels.active().is_empty();
71        let event = if anim.is_animating(&app) || tunnels_anim_tick {
72            events.next_timeout(std::time::Duration::from_millis(16))?
73        } else if anim.has_checking_hosts(&app)
74            || vault_signing
75            || provider_syncing
76            || anim.has_reachable_hosts(&app)
77        {
78            events.next_timeout(std::time::Duration::from_millis(60))?
79        } else {
80            Some(events.next()?)
81        };
82
83        if dispatch_event(
84            &mut app,
85            event,
86            &mut anim,
87            vault_signing,
88            &events_tx,
89            &mut terminal,
90            &mut last_config_check,
91        )?
92        .is_break()
93        {
94            continue;
95        }
96
97        lazy_cert_check(&mut app, &events_tx);
98
99        handle_pending_connect(&mut app, &mut terminal, &events, &mut last_config_check)?;
100        handle_pending_container_exec(&mut app, &mut terminal, &events, &mut last_config_check)?;
101        handle_pending_container_logs(&mut app, &events_tx);
102        handle_pending_container_action(&mut app, &events_tx);
103        // Drain any aliases queued for an initial container-cache
104        // fetch (form save, sync, external edit, restore). The
105        // helper drains the queue itself and routes the items into
106        // the existing `RefreshBatch` driver.
107        if app.container_state.has_pending_fetches() {
108            handler::containers_overview::auto_fetch_new_hosts(&mut app, &events_tx);
109        }
110        handle_pending_snippet(&mut app, &mut terminal, &events, &mut last_config_check)?;
111    }
112
113    tui_teardown(&mut app, &mut terminal)
114}
115
116/// Spawn auto-sync, auto-ping and the background version check on TUI startup.
117fn spawn_startup_tasks(app: &mut App, events_tx: &std::sync::mpsc::Sender<AppEvent>) {
118    for section in app.providers.config().configured_providers().to_vec() {
119        if !section.auto_sync {
120            continue;
121        }
122        let key = section.id.to_string();
123        if !app.providers.syncing().contains_key(&key) {
124            app.providers.reset_batch_if_idle();
125            let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
126            app.providers.syncing_mut().insert(key, cancel.clone());
127            app.providers.bump_batch_total();
128            handler::spawn_provider_sync(
129                &section,
130                events_tx.clone(),
131                cancel,
132                std::sync::Arc::clone(&app.env),
133            );
134            crate::set_sync_summary(app);
135        }
136    }
137
138    if app.ping.auto_ping() {
139        let hosts_to_ping: Vec<(String, String, u16)> = app
140            .hosts_state
141            .list()
142            .iter()
143            .filter(|h| !h.hostname.is_empty() && h.proxy_jump.is_empty())
144            .map(|h| (h.alias.clone(), h.hostname.clone(), h.port))
145            .collect();
146        for h in app.hosts_state.list() {
147            if !h.proxy_jump.is_empty() {
148                app.ping
149                    .insert_status(h.alias.clone(), app::PingStatus::Skipped);
150            }
151        }
152        if !hosts_to_ping.is_empty() {
153            for (alias, _, _) in &hosts_to_ping {
154                app.ping
155                    .insert_status(alias.clone(), app::PingStatus::Checking);
156            }
157            ping::ping_all(&hosts_to_ping, events_tx.clone(), app.ping.generation());
158        }
159    }
160
161    update::spawn_version_check(events_tx.clone(), std::sync::Arc::clone(&app.env));
162
163    // Kick off a one-shot cert check for every vault-managed host so the
164    // Keys-tab strip populates on startup without the user having to
165    // navigate through each host first (or hit R). The actual validation
166    // is `ssh-keygen -L`, cheap, runs off-thread, and reuses the same
167    // `CertCheckResult` event path as the lazy selection-driven check.
168    let vault_aliases: Vec<(String, String)> = app
169        .hosts_state
170        .list()
171        .iter()
172        .filter(|h| vault_ssh::has_purple_vault_context(h))
173        .filter(|h| !app.vault.is_cert_check_in_flight(&h.alias))
174        .filter(|h| !app.vault.has_cert(&h.alias))
175        .map(|h| (h.alias.clone(), h.certificate_file.clone()))
176        .collect();
177    for (alias, cert_file) in vault_aliases {
178        app.vault.mark_cert_check_started(alias.clone());
179        let tx = events_tx.clone();
180        let env = std::sync::Arc::clone(&app.env);
181        std::thread::spawn(move || {
182            let check_path = match vault_ssh::resolve_cert_path(env.paths(), &alias, &cert_file) {
183                Ok(p) => p,
184                Err(e) => {
185                    let _ = tx.send(event::AppEvent::CertCheckError {
186                        alias,
187                        message: e.to_string(),
188                    });
189                    return;
190                }
191            };
192            let status = vault_ssh::check_cert_validity(&env, &check_path);
193            let _ = tx.send(event::AppEvent::CertCheckResult { alias, status });
194        });
195    }
196}
197
198/// Dispatch a single tick's event. Returns `Break` when the outer loop
199/// should `continue` without running the post-dispatch helpers.
200#[allow(clippy::too_many_arguments)]
201fn dispatch_event(
202    app: &mut App,
203    event: Option<AppEvent>,
204    anim: &mut animation::AnimationState,
205    vault_signing: bool,
206    events_tx: &std::sync::mpsc::Sender<AppEvent>,
207    terminal: &mut tui::Tui,
208    last_config_check: &mut std::time::Instant,
209) -> Result<std::ops::ControlFlow<()>> {
210    match event {
211        Some(AppEvent::Key(key)) => {
212            handler::handle_key_event(app, key, events_tx)?;
213        }
214        Some(AppEvent::Tick) | None => {
215            handler::event_loop::handle_tick(app, anim, vault_signing, last_config_check);
216        }
217        Some(AppEvent::PingResult {
218            alias,
219            rtt_ms,
220            generation,
221        }) => {
222            handler::event_loop::handle_ping_result(app, alias, rtt_ms, generation);
223        }
224        Some(AppEvent::SyncProgress { provider, message }) => {
225            handler::event_loop::handle_sync_progress(app, provider, message);
226        }
227        Some(AppEvent::SyncComplete { provider, hosts }) => {
228            handler::event_loop::handle_sync_complete(app, provider, hosts, last_config_check);
229        }
230        Some(AppEvent::SyncPartial {
231            provider,
232            hosts,
233            failures,
234            total,
235        }) => {
236            handler::event_loop::handle_sync_partial(
237                app,
238                provider,
239                hosts,
240                failures,
241                total,
242                last_config_check,
243            );
244        }
245        Some(AppEvent::SyncError { provider, message }) => {
246            handler::event_loop::handle_sync_error(app, provider, message, last_config_check);
247        }
248        Some(AppEvent::UpdateAvailable { version, headline }) => {
249            handler::event_loop::handle_update_available(app, version, headline);
250        }
251        Some(AppEvent::FileBrowserListing {
252            alias,
253            path,
254            entries,
255        }) => {
256            handler::event_loop::handle_file_browser_listing(app, alias, path, entries, terminal);
257        }
258        Some(AppEvent::ScpComplete {
259            alias,
260            success,
261            message,
262        }) => {
263            handler::event_loop::handle_scp_complete(
264                app, alias, success, message, events_tx, terminal,
265            );
266        }
267        Some(AppEvent::SnippetHostDone {
268            run_id,
269            alias,
270            stdout,
271            stderr,
272            exit_code,
273        }) => {
274            handler::event_loop::handle_snippet_host_done(
275                app, run_id, alias, stdout, stderr, exit_code,
276            );
277        }
278        Some(AppEvent::SnippetProgress {
279            run_id,
280            completed,
281            total,
282        }) => {
283            handler::event_loop::handle_snippet_progress(app, run_id, completed, total);
284        }
285        Some(AppEvent::SnippetAllDone { run_id }) => {
286            handler::event_loop::handle_snippet_all_done(app, run_id);
287        }
288        Some(AppEvent::KeyPushResult { run_id, result }) => {
289            handler::event_loop::handle_key_push_result(app, run_id, result);
290        }
291        Some(AppEvent::ContainerListing { alias, result }) => {
292            handler::event_loop::handle_container_listing(app, alias, result, events_tx);
293        }
294        Some(AppEvent::ContainerActionComplete {
295            alias,
296            action,
297            result,
298        }) => {
299            handler::event_loop::handle_container_action_complete(
300                app, alias, action, result, events_tx,
301            );
302        }
303        Some(AppEvent::ContainerLogsComplete {
304            alias,
305            container_id,
306            container_name,
307            result,
308        }) => {
309            handler::event_loop::handle_container_logs_complete(
310                app,
311                alias,
312                container_id,
313                container_name,
314                result,
315            );
316        }
317        Some(AppEvent::ContainerInspectComplete {
318            alias,
319            container_id,
320            result,
321        }) => {
322            handler::event_loop::handle_container_inspect_complete(
323                app,
324                alias,
325                container_id,
326                *result,
327            );
328        }
329        Some(AppEvent::ContainerLogsTailComplete {
330            alias,
331            container_id,
332            result,
333        }) => {
334            handler::event_loop::handle_container_logs_tail_complete(
335                app,
336                alias,
337                container_id,
338                *result,
339            );
340        }
341        Some(AppEvent::VaultSignResult {
342            alias,
343            certificate_file: existing_cert_file,
344            success,
345            message,
346        }) => {
347            handler::event_loop::handle_vault_sign_result(
348                app,
349                alias,
350                existing_cert_file,
351                success,
352                message,
353            );
354        }
355        Some(AppEvent::VaultSignProgress { alias, done, total }) => {
356            handler::event_loop::handle_vault_sign_progress(
357                app,
358                alias,
359                done,
360                total,
361                anim.spinner_tick,
362            );
363        }
364        Some(AppEvent::VaultSignAllDone {
365            signed,
366            failed,
367            skipped,
368            cancelled,
369            aborted_message,
370            first_error,
371        }) => {
372            if handler::event_loop::handle_vault_sign_all_done(
373                app,
374                signed,
375                failed,
376                skipped,
377                cancelled,
378                aborted_message,
379                first_error,
380            )
381            .is_break()
382            {
383                return Ok(std::ops::ControlFlow::Break(()));
384            }
385        }
386        Some(AppEvent::CertCheckResult { alias, status }) => {
387            handler::event_loop::handle_cert_check_result(app, alias, status);
388        }
389        Some(AppEvent::CertCheckError { alias, message }) => {
390            handler::event_loop::handle_cert_check_error(app, alias, message);
391        }
392        Some(AppEvent::PollError) => {
393            app.running = false;
394        }
395    }
396    Ok(std::ops::ControlFlow::Continue(()))
397}
398
399/// When the selected host has a vault role and the cached cert status is
400/// missing, stale or has been touched externally, spawn a background check.
401fn lazy_cert_check(app: &mut App, events_tx: &std::sync::mpsc::Sender<AppEvent>) {
402    if let Some(selected) = app.selected_host() {
403        let has_vault_role = vault_ssh::resolve_vault_role(
404            selected.vault_ssh.as_deref(),
405            selected.provider.as_deref(),
406            selected.provider_label.as_deref(),
407            app.providers.config(),
408        )
409        .is_some();
410        // Also trigger a check when the host wires in a purple-managed cert
411        // via `CertificateFile` without setting the role marker (the user
412        // signed with the `vault` CLI directly). Without this branch the
413        // TTL gauge would stay empty for CLI-signed certs.
414        let has_purple_cert_file = vault_ssh::cert_file_in_purple_dir(&selected.certificate_file);
415        if has_vault_role || has_purple_cert_file {
416            // Stat the cert file once per iteration to detect external writes
417            // (CLI sign, another purple instance) within one frame. Compared
418            // against the mtime recorded when the cache entry was populated;
419            // any mismatch forces a re-check, no matter the TTL.
420            let current_mtime = vault_ssh::resolve_cert_path(
421                app.env().paths(),
422                &selected.alias,
423                &selected.certificate_file,
424            )
425            .ok()
426            .and_then(|p| std::fs::metadata(&p).ok())
427            .and_then(|m| m.modified().ok());
428            let cache_stale =
429                cache_entry_is_stale(app.vault.cert_entry(&selected.alias), current_mtime, |t| {
430                    t.elapsed().as_secs()
431                });
432
433            let sign_in_flight = app
434                .vault
435                .sign_in_flight()
436                .lock()
437                .map(|g| g.contains(&selected.alias))
438                .unwrap_or(false);
439            if cache_stale && !app.vault.is_cert_check_in_flight(&selected.alias) && !sign_in_flight
440            {
441                let alias = selected.alias.clone();
442                let cert_file = selected.certificate_file.clone();
443                app.vault.mark_cert_check_started(alias.clone());
444                let tx = events_tx.clone();
445                let env = std::sync::Arc::clone(&app.env);
446                std::thread::spawn(move || {
447                    let check_path =
448                        match vault_ssh::resolve_cert_path(env.paths(), &alias, &cert_file) {
449                            Ok(p) => p,
450                            Err(e) => {
451                                let _ = tx.send(event::AppEvent::CertCheckError {
452                                    alias,
453                                    message: e.to_string(),
454                                });
455                                return;
456                            }
457                        };
458                    let status = vault_ssh::check_cert_validity(&env, &check_path);
459                    let _ = tx.send(event::AppEvent::CertCheckResult { alias, status });
460                });
461            }
462        }
463    }
464}
465
466/// Drain any queued SSH connection request. In tmux mode we open a new
467/// window and leave the TUI alive; otherwise we suspend the TUI, run ssh
468/// inline, then restore it. Vault SSH signing and askpass pre-flight
469/// (Bitwarden, keychain) run on the bare terminal to allow prompts.
470fn handle_pending_connect(
471    app: &mut App,
472    terminal: &mut tui::Tui,
473    events: &EventHandler,
474    last_config_check: &mut std::time::Instant,
475) -> Result<()> {
476    let Some((alias, host_askpass)) = app.ui.take_pending_connect() else {
477        return Ok(());
478    };
479    let vault_host = app
480        .hosts_state
481        .list()
482        .iter()
483        .find(|h| h.alias == alias)
484        .cloned();
485    let askpass = host_askpass.or_else(|| preferences::load_askpass_default(app.env().paths()));
486    let has_active_tunnel = app.tunnels.active_contains(&alias);
487    let use_tmux = connection::is_in_tmux(app.env()) && askpass.is_none();
488
489    if use_tmux {
490        // Tmux mode: open SSH in a new tmux window. TUI stays alive.
491        // Vault SSH cert signing runs first (eprintln warnings are harmless
492        // on the alternate screen. ratatui repaints over them on the next
493        // draw cycle). Sign the entire ProxyJump chain so the proxy hop's
494        // cert is in place before ssh tries to use it.
495        let vault_msg = if vault_host.is_some() {
496            let env = std::sync::Arc::clone(&app.env);
497            let msg = ensure_vault_ssh_chain_if_needed(
498                &env,
499                &alias,
500                app.reload.config_path(),
501                app.providers.config(),
502                app.hosts_state.ssh_config_mut(),
503            );
504            if msg.is_some() {
505                app.reload_hosts();
506                for hop in vault_ssh::resolve_proxy_chain(app.reload.config_path(), &alias) {
507                    app.refresh_cert_cache(&hop);
508                }
509            }
510            msg
511        } else {
512            None
513        };
514
515        match connection::connect_tmux_window(&alias, app.reload.config_path(), has_active_tunnel) {
516            Ok(()) => {
517                app.record_key_use(&alias, key_activity::now_secs());
518                if let Some((ref msg, is_error)) = vault_msg {
519                    if is_error {
520                        app.notify_error(msg.clone());
521                    } else {
522                        app.notify(msg.clone());
523                    }
524                } else {
525                    app.notify(crate::messages::opened_in_tmux(&alias));
526                }
527            }
528            Err(e) => {
529                app.notify_error(crate::messages::tmux_error(&e));
530            }
531        }
532        return Ok(());
533    }
534
535    // Standard mode: suspend TUI, run SSH inline, restore TUI.
536    // Order preserved: pause events, exit TUI, THEN run vault signing and
537    // password setup (which may eprintln or prompt for input on the real
538    // terminal). Sign the entire ProxyJump chain so the proxy hop's cert is
539    // in place before ssh tries to use it.
540    events.pause();
541    terminal.exit()?;
542    let vault_msg = if vault_host.is_some() {
543        let env = std::sync::Arc::clone(&app.env);
544        let msg = ensure_vault_ssh_chain_if_needed(
545            &env,
546            &alias,
547            app.reload.config_path(),
548            app.providers.config(),
549            app.hosts_state.ssh_config_mut(),
550        );
551        if msg.is_some() {
552            app.reload_hosts();
553            for hop in vault_ssh::resolve_proxy_chain(app.reload.config_path(), &alias) {
554                app.refresh_cert_cache(&hop);
555            }
556        }
557        msg
558    } else {
559        None
560    };
561    let env = std::sync::Arc::clone(&app.env);
562    ensure_proton_login(&env, askpass.as_deref());
563    if let Some(token) = ensure_bw_session(&env, app.bw_session.as_deref(), askpass.as_deref()) {
564        app.bw_session = Some(token);
565    }
566    ensure_keychain_password(&env, &alias, askpass.as_deref());
567    print!("{}", crate::messages::cli::beaming_up(&alias));
568    let result = connection::connect(
569        &alias,
570        app.reload.config_path(),
571        askpass.as_deref(),
572        app.bw_session.as_deref(),
573        has_active_tunnel,
574    );
575    println!();
576    match &result {
577        Ok(cr) => {
578            let code = cr.status.code().unwrap_or(1);
579            if code != 255 {
580                app.history.record(&alias);
581                app.record_key_use(&alias, key_activity::now_secs());
582                app.hosts_state.invalidate_render_cache();
583            }
584            if code != 0 {
585                if let Some((hostname, known_hosts_path)) =
586                    connection::parse_host_key_error(&cr.stderr_output)
587                {
588                    app.screen = app::Screen::ConfirmHostKeyReset {
589                        alias: alias.clone(),
590                        hostname,
591                        known_hosts_path,
592                        askpass,
593                    };
594                } else {
595                    // A failed Vault sign that came alongside a failed SSH
596                    // is almost always the CAUSE of the SSH failure (no cert
597                    // → permission denied). Surface the vault error first so
598                    // the user can fix the actual problem; otherwise they
599                    // chase the generic ssh error.
600                    if let Some((ref vmsg, true)) = vault_msg {
601                        app.notify_error(vmsg.clone());
602                    }
603                    let reason = connection::stderr_summary(&cr.stderr_output);
604                    let msg = if let Some(reason) = reason {
605                        crate::messages::ssh_failed_with_reason(&alias, &reason)
606                    } else {
607                        crate::messages::ssh_exited_with_code(&alias, code)
608                    };
609                    app.notify_error(msg);
610                }
611            } else if let Some((ref msg, is_error)) = vault_msg {
612                if is_error {
613                    app.notify_error(msg.clone());
614                } else {
615                    app.notify(msg.clone());
616                }
617            }
618        }
619        Err(e) => {
620            log::error!("[external] ssh connect failed: alias={alias}: {e}");
621            eprintln!("{}", crate::messages::connection_spawn_failed(&e));
622            app.notify_error(crate::messages::connection_failed(&alias));
623        }
624    }
625    askpass::cleanup_marker(app.env.paths(), &alias);
626    terminal.enter()?;
627    events.resume();
628    *last_config_check = std::time::Instant::now();
629    let reloaded = SshConfigFile::parse_with_env(app.reload.config_path(), app.env())?;
630    app.hosts_state.set_ssh_config(reloaded);
631    app.reload_hosts();
632    app.update_last_modified();
633    Ok(())
634}
635
636/// Drain any queued container-exec request. Same lifecycle as
637/// `handle_pending_connect` but the spawned command is
638/// `ssh -t <alias> <runtime> exec -it <id> sh -c 'bash || sh'` instead
639/// of a plain shell login.
640fn handle_pending_container_exec(
641    app: &mut App,
642    terminal: &mut tui::Tui,
643    events: &EventHandler,
644    last_config_check: &mut std::time::Instant,
645) -> Result<()> {
646    let Some(req) = app.container_state.take_pending_exec() else {
647        return Ok(());
648    };
649
650    // Defense-in-depth: container_id is currently gated by
651    // `validate_running_row` (which calls validate_container_id)
652    // before pending_container_exec is populated. This second validation
653    // covers any future entry point (MCP tool call, paste-via-jump, etc.)
654    // that might populate the request without going through that gate.
655    if let Err(e) = crate::containers::validate_container_id(&req.container_id) {
656        log::warn!(
657            "[purple] container exec blocked on '{}': invalid container_id: {}",
658            req.alias,
659            e
660        );
661        app.notify(crate::messages::container_invalid_id(&e));
662        return Ok(());
663    }
664
665    let askpass = req
666        .askpass
667        .or_else(|| preferences::load_askpass_default(app.env().paths()));
668    let has_active_tunnel = app.tunnels.active_contains(&req.alias);
669    let use_tmux = connection::is_in_tmux(app.env()) && askpass.is_none();
670
671    let remote_cmd = if let Some(ref user_cmd) = req.command {
672        // User-typed exec command from the `e` prompt. The remote runs
673        // `sh -c '<cmd>'`; embedded single-quotes are escaped as the
674        // standard POSIX `'\''` so the wrapping quotes survive a token
675        // like `it's-fine`. The prompt handler already rejects newlines.
676        let escaped = user_cmd.replace('\'', "'\\''");
677        format!(
678            "{} exec -it {} sh -c '{}'",
679            req.runtime.as_str(),
680            req.container_id,
681            escaped
682        )
683    } else {
684        format!(
685            "{} exec -it {} sh -c 'bash || sh'",
686            req.runtime.as_str(),
687            req.container_id
688        )
689    };
690
691    if use_tmux {
692        let label = format!("{}/{}", req.alias, req.container_name);
693        match connection::connect_tmux_window_with_remote_command(
694            &req.alias,
695            app.reload.config_path(),
696            app.env(),
697            has_active_tunnel,
698            &remote_cmd,
699            &label,
700        ) {
701            Ok(()) => {
702                app.record_key_use(&req.alias, key_activity::now_secs());
703                app.notify(crate::messages::container_exec_opened_in_tmux(
704                    &req.container_name,
705                    &req.alias,
706                ));
707            }
708            Err(e) => {
709                app.notify_error(crate::messages::tmux_error(&e));
710            }
711        }
712        return Ok(());
713    }
714
715    events.pause();
716    terminal.exit()?;
717
718    let result = connection::connect_with_remote_command(
719        &req.alias,
720        app.reload.config_path(),
721        app.env(),
722        askpass.as_deref(),
723        app.bw_session.as_deref(),
724        has_active_tunnel,
725        &remote_cmd,
726    );
727
728    match result {
729        Ok(cr) => {
730            let code = cr.status.code().unwrap_or(1);
731            // SSH exit 255 = ssh itself failed (auth, network, host-key
732            // mismatch); anything else means ssh connected and the
733            // remote command exited with that code. Recording history
734            // for non-255 mirrors the host-list connect flow so a
735            // mid-shell crash still counts as a successful login.
736            if code != 255 {
737                app.history.record(&req.alias);
738                app.record_key_use(&req.alias, key_activity::now_secs());
739                app.hosts_state.invalidate_render_cache();
740            }
741            if code == 0 {
742                app.notify(crate::messages::container_exec_ended(&req.container_name));
743            } else if let Some((hostname, known_hosts_path)) =
744                connection::parse_host_key_error(&cr.stderr_output)
745            {
746                // Same recovery surface as the host-list `i` path:
747                // park the user on ConfirmHostKeyReset so they can
748                // delete the stale known_hosts entry and retry.
749                app.screen = app::Screen::ConfirmHostKeyReset {
750                    alias: req.alias.clone(),
751                    hostname,
752                    known_hosts_path,
753                    askpass: askpass.clone(),
754                };
755            } else {
756                let reason = connection::stderr_summary(&cr.stderr_output);
757                let msg = match reason {
758                    Some(r) => {
759                        crate::messages::container_exec_failed_with_reason(&req.container_name, &r)
760                    }
761                    None => {
762                        crate::messages::container_exec_exited_with_code(&req.container_name, code)
763                    }
764                };
765                app.notify_error(msg);
766            }
767        }
768        Err(e) => {
769            eprintln!("{}", crate::messages::connection_spawn_failed(&e));
770            app.notify_error(crate::messages::container_exec_spawn_failed(
771                &req.container_name,
772            ));
773        }
774    }
775    askpass::cleanup_marker(app.env.paths(), &req.alias);
776    terminal.enter()?;
777    events.resume();
778    *last_config_check = std::time::Instant::now();
779    Ok(())
780}
781
782/// Drain `pending_container_logs`. Spawns a background SSH
783/// thread that runs `<runtime> logs --tail N <id>` and emits an
784/// `AppEvent::ContainerLogsComplete` with the captured output. The
785/// receiving handler in `event_loop.rs` fills the open
786/// `Screen::ContainerLogs` overlay's body.
787fn handle_pending_container_logs(app: &mut App, events_tx: &std::sync::mpsc::Sender<AppEvent>) {
788    let Some(req) = app.container_state.take_pending_logs() else {
789        return;
790    };
791    let askpass = req
792        .askpass
793        .or_else(|| preferences::load_askpass_default(app.env().paths()));
794    let has_tunnel = app.tunnels.active_contains(&req.alias);
795    let ctx = crate::ssh_context::OwnedSshContext {
796        alias: req.alias,
797        config_path: app.reload.config_path().to_path_buf(),
798        askpass,
799        bw_session: app.bw_session.clone(),
800        has_tunnel,
801        env: std::sync::Arc::clone(&app.env),
802    };
803    let tx = events_tx.clone();
804    log::debug!(
805        "[purple] container_logs_fetch: spawning alias={} id={}",
806        ctx.alias,
807        req.container_id
808    );
809    crate::containers::spawn_container_logs_fetch(
810        ctx,
811        req.runtime,
812        req.container_id,
813        req.container_name,
814        crate::handler::container_logs::DEFAULT_TAIL,
815        move |alias, container_id, container_name, result| {
816            let _ = tx.send(AppEvent::ContainerLogsComplete {
817                alias,
818                container_id,
819                container_name,
820                result,
821            });
822        },
823    );
824}
825
826/// Drain `pending_container_action`. Reuses the existing
827/// `spawn_container_action` helper and `AppEvent::ContainerActionComplete`
828/// event so the result handler can stay one path. The action's
829/// container_id+name are logged here; the toast on completion uses
830/// the alias because the existing event payload does not carry the
831/// per-container labels.
832fn handle_pending_container_action(app: &mut App, events_tx: &std::sync::mpsc::Sender<AppEvent>) {
833    // Drain at most one action per tick. Stack-restart pushes N
834    // requests but the SSH workers should not all sprint off the
835    // same tick. staggering keeps load on the remote sshd lower.
836    let Some(req) = app.container_state.pop_next_action() else {
837        return;
838    };
839    let askpass = req
840        .askpass
841        .or_else(|| preferences::load_askpass_default(app.env().paths()));
842    let has_tunnel = app.tunnels.active_contains(&req.alias);
843    let ctx = crate::ssh_context::OwnedSshContext {
844        alias: req.alias.clone(),
845        config_path: app.reload.config_path().to_path_buf(),
846        askpass,
847        bw_session: app.bw_session.clone(),
848        has_tunnel,
849        env: std::sync::Arc::clone(&app.env),
850    };
851    let tx = events_tx.clone();
852    log::info!(
853        "[purple] container_action_drain: spawning alias={} id={} action={:?} name={}",
854        req.alias,
855        req.container_id,
856        req.action,
857        req.container_name
858    );
859    crate::containers::spawn_container_action(
860        ctx,
861        req.runtime,
862        req.action,
863        req.container_id,
864        move |alias, action, result| {
865            let _ = tx.send(AppEvent::ContainerActionComplete {
866                alias,
867                action,
868                result,
869            });
870        },
871    );
872}
873
874/// Drain any queued snippet-run request: suspend the TUI, run the command
875/// across all selected hosts, record history on success, wait for Enter,
876/// then restore the TUI and reload the SSH config.
877fn handle_pending_snippet(
878    app: &mut App,
879    terminal: &mut tui::Tui,
880    events: &EventHandler,
881    last_config_check: &mut std::time::Instant,
882) -> Result<()> {
883    let Some((snip, aliases)) = app.snippets.take_pending() else {
884        return Ok(());
885    };
886    events.pause();
887    terminal.exit()?;
888
889    let multi = aliases.len() > 1;
890    for alias in &aliases {
891        let askpass = app
892            .hosts_state
893            .list()
894            .iter()
895            .find(|h| h.alias == *alias)
896            .and_then(|h| h.askpass.clone())
897            .or_else(|| preferences::load_askpass_default(app.env().paths()));
898        let env = std::sync::Arc::clone(&app.env);
899        ensure_proton_login(&env, askpass.as_deref());
900        if let Some(token) = ensure_bw_session(&env, app.bw_session.as_deref(), askpass.as_deref())
901        {
902            app.bw_session = Some(token);
903        }
904        ensure_keychain_password(&env, alias, askpass.as_deref());
905
906        if multi {
907            println!("{}", crate::messages::cli::host_separator(alias));
908        } else {
909            print!(
910                "{}",
911                crate::messages::cli::running_snippet_on(&snip.name, alias)
912            );
913        }
914        let has_tunnel = app.tunnels.active_contains(alias);
915        match snippet::run_snippet(
916            alias,
917            app.reload.config_path(),
918            &env,
919            &snip.command,
920            askpass.as_deref(),
921            app.bw_session.as_deref(),
922            false,
923            has_tunnel,
924        ) {
925            Ok(r) => {
926                if r.status.success() {
927                    app.history.record(alias);
928                    app.record_key_use(alias, key_activity::now_secs());
929                    app.hosts_state.invalidate_render_cache();
930                } else if multi {
931                    eprintln!(
932                        "{}",
933                        crate::messages::cli::exited_with_code(r.status.code().unwrap_or(1))
934                    );
935                } else {
936                    println!(
937                        "\n{}",
938                        crate::messages::cli::exited_with_code(r.status.code().unwrap_or(1))
939                    );
940                }
941            }
942            Err(e) => eprintln!("{}", crate::messages::cli::host_failed(alias, &e)),
943        }
944        if multi {
945            println!();
946        }
947    }
948
949    if !multi {
950        println!("\n{}", crate::messages::cli::DONE);
951    } else {
952        println!(
953            "{}",
954            crate::messages::cli::done_multi(&snip.name, aliases.len())
955        );
956    }
957    println!("\n{}", crate::messages::cli::PRESS_ENTER);
958    let _ = std::io::stdin().read_line(&mut String::new());
959    terminal.enter()?;
960    events.resume();
961    *last_config_check = std::time::Instant::now();
962    // Reload so sort order (e.g. most recent) reflects the new history.
963    let reloaded = SshConfigFile::parse_with_env(app.reload.config_path(), app.env())?;
964    app.hosts_state.set_ssh_config(reloaded);
965    app.reload_hosts();
966    app.update_last_modified();
967    Ok(())
968}
969
970/// Flush any deferred vault-config writes, join the background signing
971/// thread and kill active tunnels before leaving the TUI.
972fn tui_teardown(app: &mut App, terminal: &mut tui::Tui) -> Result<()> {
973    app.flush_pending_vault_write();
974
975    if let Some(handle) = app.vault.cancel_signing_run() {
976        let _ = handle.join();
977    }
978
979    for (_, mut tunnel) in app.tunnels.drain_active() {
980        let _ = tunnel.child.kill();
981        let _ = tunnel.child.wait();
982    }
983
984    terminal.exit()?;
985    Ok(())
986}
987
988pub(crate) fn current_cert_mtime(alias: &str, app: &app::App) -> Option<std::time::SystemTime> {
989    let host = app.hosts_state.list().iter().find(|h| h.alias == alias)?;
990    let cert_path =
991        vault_ssh::resolve_cert_path(app.env().paths(), alias, &host.certificate_file).ok()?;
992    std::fs::metadata(&cert_path)
993        .ok()
994        .and_then(|m| m.modified().ok())
995}
996
997/// Decide whether a `vault.cert_cache` entry should be re-checked.
998///
999/// Returns true when:
1000/// - there is no cached entry at all, or
1001/// - the cert file's current mtime differs from the cached mtime
1002///   (an external actor signed or deleted the cert behind our back), or
1003/// - the entry's age exceeds its TTL. `CertStatus::Invalid` uses a shorter
1004///   backoff so transient errors recover quickly without hammering the
1005///   background check thread on every poll tick.
1006///
1007/// The `elapsed_secs` closure is taken as a parameter so tests can inject
1008/// deterministic elapsed times instead of calling the real clock.
1009pub(crate) fn cache_entry_is_stale<F>(
1010    entry: Option<&(
1011        std::time::Instant,
1012        vault_ssh::CertStatus,
1013        Option<std::time::SystemTime>,
1014    )>,
1015    current_mtime: Option<std::time::SystemTime>,
1016    elapsed_secs: F,
1017) -> bool
1018where
1019    F: FnOnce(std::time::Instant) -> u64,
1020{
1021    let Some((checked_at, status, cached_mtime)) = entry else {
1022        return true;
1023    };
1024    if current_mtime != *cached_mtime {
1025        return true;
1026    }
1027    let ttl = if matches!(status, vault_ssh::CertStatus::Invalid(_)) {
1028        vault_ssh::CERT_ERROR_BACKOFF_SECS
1029    } else {
1030        vault_ssh::CERT_STATUS_CACHE_TTL_SECS
1031    };
1032    elapsed_secs(*checked_at) > ttl
1033}