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