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