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