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.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.is_cert_check_in_flight(&h.alias))
173        .filter(|h| !app.vault.has_cert(&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 =
424                cache_entry_is_stale(app.vault.cert_entry(&selected.alias), current_mtime, |t| {
425                    t.elapsed().as_secs()
426                });
427
428            let sign_in_flight = app
429                .vault
430                .sign_in_flight()
431                .lock()
432                .map(|g| g.contains(&selected.alias))
433                .unwrap_or(false);
434            if cache_stale && !app.vault.is_cert_check_in_flight(&selected.alias) && !sign_in_flight
435            {
436                let alias = selected.alias.clone();
437                let cert_file = selected.certificate_file.clone();
438                app.vault.mark_cert_check_started(alias.clone());
439                let tx = events_tx.clone();
440                std::thread::spawn(move || {
441                    let check_path = match vault_ssh::resolve_cert_path(&alias, &cert_file) {
442                        Ok(p) => p,
443                        Err(e) => {
444                            let _ = tx.send(event::AppEvent::CertCheckError {
445                                alias,
446                                message: e.to_string(),
447                            });
448                            return;
449                        }
450                    };
451                    let status = vault_ssh::check_cert_validity(&check_path);
452                    let _ = tx.send(event::AppEvent::CertCheckResult { alias, status });
453                });
454            }
455        }
456    }
457}
458
459/// Drain any queued SSH connection request. In tmux mode we open a new
460/// window and leave the TUI alive; otherwise we suspend the TUI, run ssh
461/// inline, then restore it. Vault SSH signing and askpass pre-flight
462/// (Bitwarden, keychain) run on the bare terminal to allow prompts.
463fn handle_pending_connect(
464    app: &mut App,
465    terminal: &mut tui::Tui,
466    events: &EventHandler,
467    last_config_check: &mut std::time::Instant,
468) -> Result<()> {
469    let Some((alias, host_askpass)) = app.ui.pending_connect.take() else {
470        return Ok(());
471    };
472    let vault_host = app
473        .hosts_state
474        .list
475        .iter()
476        .find(|h| h.alias == alias)
477        .cloned();
478    let askpass = host_askpass.or_else(preferences::load_askpass_default);
479    let has_active_tunnel = app.tunnels.active.contains_key(&alias);
480    let use_tmux = connection::is_in_tmux() && askpass.is_none();
481
482    if use_tmux {
483        // Tmux mode: open SSH in a new tmux window. TUI stays alive.
484        // Vault SSH cert signing runs first (eprintln warnings are harmless
485        // on the alternate screen. ratatui repaints over them on the next
486        // draw cycle). Sign the entire ProxyJump chain so the proxy hop's
487        // cert is in place before ssh tries to use it.
488        let vault_msg = if vault_host.is_some() {
489            let msg = ensure_vault_ssh_chain_if_needed(
490                &alias,
491                &app.reload.config_path,
492                &app.providers.config,
493                &mut app.hosts_state.ssh_config,
494            );
495            if msg.is_some() {
496                app.reload_hosts();
497                for hop in vault_ssh::resolve_proxy_chain(&app.reload.config_path, &alias) {
498                    app.refresh_cert_cache(&hop);
499                }
500            }
501            msg
502        } else {
503            None
504        };
505
506        match connection::connect_tmux_window(&alias, &app.reload.config_path, has_active_tunnel) {
507            Ok(()) => {
508                app.record_key_use(&alias, key_activity::now_secs());
509                if let Some((ref msg, is_error)) = vault_msg {
510                    if is_error {
511                        app.notify_error(msg.clone());
512                    } else {
513                        app.notify(msg.clone());
514                    }
515                } else {
516                    app.notify(crate::messages::opened_in_tmux(&alias));
517                }
518            }
519            Err(e) => {
520                app.notify_error(crate::messages::tmux_error(&e));
521            }
522        }
523        return Ok(());
524    }
525
526    // Standard mode: suspend TUI, run SSH inline, restore TUI.
527    // Order preserved: pause events, exit TUI, THEN run vault signing and
528    // password setup (which may eprintln or prompt for input on the real
529    // terminal). Sign the entire ProxyJump chain so the proxy hop's cert is
530    // in place before ssh tries to use it.
531    events.pause();
532    terminal.exit()?;
533    let vault_msg = if vault_host.is_some() {
534        let msg = ensure_vault_ssh_chain_if_needed(
535            &alias,
536            &app.reload.config_path,
537            &app.providers.config,
538            &mut app.hosts_state.ssh_config,
539        );
540        if msg.is_some() {
541            app.reload_hosts();
542            for hop in vault_ssh::resolve_proxy_chain(&app.reload.config_path, &alias) {
543                app.refresh_cert_cache(&hop);
544            }
545        }
546        msg
547    } else {
548        None
549    };
550    ensure_proton_login(askpass.as_deref());
551    if let Some(token) = ensure_bw_session(app.bw_session.as_deref(), askpass.as_deref()) {
552        app.bw_session = Some(token);
553    }
554    ensure_keychain_password(&alias, askpass.as_deref());
555    print!("{}", crate::messages::cli::beaming_up(&alias));
556    let result = connection::connect(
557        &alias,
558        &app.reload.config_path,
559        askpass.as_deref(),
560        app.bw_session.as_deref(),
561        has_active_tunnel,
562    );
563    println!();
564    match &result {
565        Ok(cr) => {
566            let code = cr.status.code().unwrap_or(1);
567            if code != 255 {
568                app.history.record(&alias);
569                app.record_key_use(&alias, key_activity::now_secs());
570                app.hosts_state.render_cache.invalidate();
571            }
572            if code != 0 {
573                if let Some((hostname, known_hosts_path)) =
574                    connection::parse_host_key_error(&cr.stderr_output)
575                {
576                    app.screen = app::Screen::ConfirmHostKeyReset {
577                        alias: alias.clone(),
578                        hostname,
579                        known_hosts_path,
580                        askpass,
581                    };
582                } else {
583                    // A failed Vault sign that came alongside a failed SSH
584                    // is almost always the CAUSE of the SSH failure (no cert
585                    // → permission denied). Surface the vault error first so
586                    // the user can fix the actual problem; otherwise they
587                    // chase the generic ssh error.
588                    if let Some((ref vmsg, true)) = vault_msg {
589                        app.notify_error(vmsg.clone());
590                    }
591                    let reason = connection::stderr_summary(&cr.stderr_output);
592                    let msg = if let Some(reason) = reason {
593                        crate::messages::ssh_failed_with_reason(&alias, &reason)
594                    } else {
595                        crate::messages::ssh_exited_with_code(&alias, code)
596                    };
597                    app.notify_error(msg);
598                }
599            } else if let Some((ref msg, is_error)) = vault_msg {
600                if is_error {
601                    app.notify_error(msg.clone());
602                } else {
603                    app.notify(msg.clone());
604                }
605            }
606        }
607        Err(e) => {
608            log::error!("[external] ssh connect failed: alias={alias}: {e}");
609            eprintln!("{}", crate::messages::connection_spawn_failed(&e));
610            app.notify_error(crate::messages::connection_failed(&alias));
611        }
612    }
613    askpass::cleanup_marker(&alias);
614    terminal.enter()?;
615    events.resume();
616    *last_config_check = std::time::Instant::now();
617    app.hosts_state.ssh_config = SshConfigFile::parse(&app.reload.config_path)?;
618    app.reload_hosts();
619    app.update_last_modified();
620    Ok(())
621}
622
623/// Drain any queued container-exec request. Same lifecycle as
624/// `handle_pending_connect` but the spawned command is
625/// `ssh -t <alias> <runtime> exec -it <id> sh -c 'bash || sh'` instead
626/// of a plain shell login.
627fn handle_pending_container_exec(
628    app: &mut App,
629    terminal: &mut tui::Tui,
630    events: &EventHandler,
631    last_config_check: &mut std::time::Instant,
632) -> Result<()> {
633    let Some(req) = app.container_state.pending_exec.take() else {
634        return Ok(());
635    };
636
637    // Defense-in-depth: container_id is currently gated by
638    // `selected_running_row_with_runtime` (which calls validate_container_id)
639    // before pending_container_exec is populated. This second validation
640    // covers any future entry point (MCP tool call, paste-via-jump, etc.)
641    // that might populate the request without going through that gate.
642    if let Err(e) = crate::containers::validate_container_id(&req.container_id) {
643        log::warn!(
644            "[purple] container exec blocked on '{}': invalid container_id: {}",
645            req.alias,
646            e
647        );
648        app.notify(crate::messages::container_invalid_id(&e));
649        return Ok(());
650    }
651
652    let askpass = req.askpass.or_else(preferences::load_askpass_default);
653    let has_active_tunnel = app.tunnels.active.contains_key(&req.alias);
654    let use_tmux = connection::is_in_tmux() && askpass.is_none();
655
656    let remote_cmd = if let Some(ref user_cmd) = req.command {
657        // User-typed exec command from the `e` prompt. The remote runs
658        // `sh -c '<cmd>'`; embedded single-quotes are escaped as the
659        // standard POSIX `'\''` so the wrapping quotes survive a token
660        // like `it's-fine`. The prompt handler already rejects newlines.
661        let escaped = user_cmd.replace('\'', "'\\''");
662        format!(
663            "{} exec -it {} sh -c '{}'",
664            req.runtime.as_str(),
665            req.container_id,
666            escaped
667        )
668    } else {
669        format!(
670            "{} exec -it {} sh -c 'bash || sh'",
671            req.runtime.as_str(),
672            req.container_id
673        )
674    };
675
676    if use_tmux {
677        let label = format!("{}/{}", req.alias, req.container_name);
678        match connection::connect_tmux_window_with_remote_command(
679            &req.alias,
680            &app.reload.config_path,
681            has_active_tunnel,
682            &remote_cmd,
683            &label,
684        ) {
685            Ok(()) => {
686                app.record_key_use(&req.alias, key_activity::now_secs());
687                app.notify(crate::messages::container_exec_opened_in_tmux(
688                    &req.container_name,
689                    &req.alias,
690                ));
691            }
692            Err(e) => {
693                app.notify_error(crate::messages::tmux_error(&e));
694            }
695        }
696        return Ok(());
697    }
698
699    events.pause();
700    terminal.exit()?;
701
702    let result = connection::connect_with_remote_command(
703        &req.alias,
704        &app.reload.config_path,
705        askpass.as_deref(),
706        app.bw_session.as_deref(),
707        has_active_tunnel,
708        &remote_cmd,
709    );
710
711    match result {
712        Ok(cr) => {
713            let code = cr.status.code().unwrap_or(1);
714            // SSH exit 255 = ssh itself failed (auth, network, host-key
715            // mismatch); anything else means ssh connected and the
716            // remote command exited with that code. Recording history
717            // for non-255 mirrors the host-list connect flow so a
718            // mid-shell crash still counts as a successful login.
719            if code != 255 {
720                app.history.record(&req.alias);
721                app.record_key_use(&req.alias, key_activity::now_secs());
722                app.hosts_state.render_cache.invalidate();
723            }
724            if code == 0 {
725                app.notify(crate::messages::container_exec_ended(&req.container_name));
726            } else if let Some((hostname, known_hosts_path)) =
727                connection::parse_host_key_error(&cr.stderr_output)
728            {
729                // Same recovery surface as the host-list `i` path:
730                // park the user on ConfirmHostKeyReset so they can
731                // delete the stale known_hosts entry and retry.
732                app.screen = app::Screen::ConfirmHostKeyReset {
733                    alias: req.alias.clone(),
734                    hostname,
735                    known_hosts_path,
736                    askpass: askpass.clone(),
737                };
738            } else {
739                let reason = connection::stderr_summary(&cr.stderr_output);
740                let msg = match reason {
741                    Some(r) => {
742                        crate::messages::container_exec_failed_with_reason(&req.container_name, &r)
743                    }
744                    None => {
745                        crate::messages::container_exec_exited_with_code(&req.container_name, code)
746                    }
747                };
748                app.notify_error(msg);
749            }
750        }
751        Err(e) => {
752            eprintln!("{}", crate::messages::connection_spawn_failed(&e));
753            app.notify_error(crate::messages::container_exec_spawn_failed(
754                &req.container_name,
755            ));
756        }
757    }
758    askpass::cleanup_marker(&req.alias);
759    terminal.enter()?;
760    events.resume();
761    *last_config_check = std::time::Instant::now();
762    Ok(())
763}
764
765/// Drain `pending_container_logs`. Spawns a background SSH
766/// thread that runs `<runtime> logs --tail N <id>` and emits an
767/// `AppEvent::ContainerLogsComplete` with the captured output. The
768/// receiving handler in `event_loop.rs` fills the open
769/// `Screen::ContainerLogs` overlay's body.
770fn handle_pending_container_logs(app: &mut App, events_tx: &std::sync::mpsc::Sender<AppEvent>) {
771    let Some(req) = app.container_state.pending_logs.take() else {
772        return;
773    };
774    let askpass = req.askpass.or_else(preferences::load_askpass_default);
775    let has_tunnel = app.tunnels.active.contains_key(&req.alias);
776    let ctx = crate::ssh_context::OwnedSshContext {
777        alias: req.alias,
778        config_path: app.reload.config_path.clone(),
779        askpass,
780        bw_session: app.bw_session.clone(),
781        has_tunnel,
782    };
783    let tx = events_tx.clone();
784    log::debug!(
785        "[purple] container_logs_fetch: spawning alias={} id={}",
786        ctx.alias,
787        req.container_id
788    );
789    crate::containers::spawn_container_logs_fetch(
790        ctx,
791        req.runtime,
792        req.container_id,
793        req.container_name,
794        crate::handler::container_logs::DEFAULT_TAIL,
795        move |alias, container_id, container_name, result| {
796            let _ = tx.send(AppEvent::ContainerLogsComplete {
797                alias,
798                container_id,
799                container_name,
800                result,
801            });
802        },
803    );
804}
805
806/// Drain `pending_container_action`. Reuses the existing
807/// `spawn_container_action` helper and `AppEvent::ContainerActionComplete`
808/// event so the result handler can stay one path. The action's
809/// container_id+name are logged here; the toast on completion uses
810/// the alias because the existing event payload does not carry the
811/// per-container labels.
812fn handle_pending_container_action(app: &mut App, events_tx: &std::sync::mpsc::Sender<AppEvent>) {
813    // Drain at most one action per tick. Stack-restart pushes N
814    // requests but the SSH workers should not all sprint off the
815    // same tick. staggering keeps load on the remote sshd lower.
816    let Some(req) = app.container_state.pending_actions.pop_front() else {
817        return;
818    };
819    let askpass = req.askpass.or_else(preferences::load_askpass_default);
820    let has_tunnel = app.tunnels.active.contains_key(&req.alias);
821    let ctx = crate::ssh_context::OwnedSshContext {
822        alias: req.alias.clone(),
823        config_path: app.reload.config_path.clone(),
824        askpass,
825        bw_session: app.bw_session.clone(),
826        has_tunnel,
827    };
828    let tx = events_tx.clone();
829    log::info!(
830        "[purple] container_action_drain: spawning alias={} id={} action={:?} name={}",
831        req.alias,
832        req.container_id,
833        req.action,
834        req.container_name
835    );
836    crate::containers::spawn_container_action(
837        ctx,
838        req.runtime,
839        req.action,
840        req.container_id,
841        move |alias, action, result| {
842            let _ = tx.send(AppEvent::ContainerActionComplete {
843                alias,
844                action,
845                result,
846            });
847        },
848    );
849}
850
851/// Drain any queued snippet-run request: suspend the TUI, run the command
852/// across all selected hosts, record history on success, wait for Enter,
853/// then restore the TUI and reload the SSH config.
854fn handle_pending_snippet(
855    app: &mut App,
856    terminal: &mut tui::Tui,
857    events: &EventHandler,
858    last_config_check: &mut std::time::Instant,
859) -> Result<()> {
860    let Some((snip, aliases)) = app.snippets.pending.take() else {
861        return Ok(());
862    };
863    events.pause();
864    terminal.exit()?;
865
866    let multi = aliases.len() > 1;
867    for alias in &aliases {
868        let askpass = app
869            .hosts_state
870            .list
871            .iter()
872            .find(|h| h.alias == *alias)
873            .and_then(|h| h.askpass.clone())
874            .or_else(preferences::load_askpass_default);
875        ensure_proton_login(askpass.as_deref());
876        if let Some(token) = ensure_bw_session(app.bw_session.as_deref(), askpass.as_deref()) {
877            app.bw_session = Some(token);
878        }
879        ensure_keychain_password(alias, askpass.as_deref());
880
881        if multi {
882            println!("{}", crate::messages::cli::host_separator(alias));
883        } else {
884            print!(
885                "{}",
886                crate::messages::cli::running_snippet_on(&snip.name, alias)
887            );
888        }
889        let has_tunnel = app.tunnels.active.contains_key(alias);
890        match snippet::run_snippet(
891            alias,
892            &app.reload.config_path,
893            &snip.command,
894            askpass.as_deref(),
895            app.bw_session.as_deref(),
896            false,
897            has_tunnel,
898        ) {
899            Ok(r) => {
900                if r.status.success() {
901                    app.history.record(alias);
902                    app.record_key_use(alias, key_activity::now_secs());
903                    app.hosts_state.render_cache.invalidate();
904                } else if multi {
905                    eprintln!(
906                        "{}",
907                        crate::messages::cli::exited_with_code(r.status.code().unwrap_or(1))
908                    );
909                } else {
910                    println!(
911                        "\n{}",
912                        crate::messages::cli::exited_with_code(r.status.code().unwrap_or(1))
913                    );
914                }
915            }
916            Err(e) => eprintln!("{}", crate::messages::cli::host_failed(alias, &e)),
917        }
918        if multi {
919            println!();
920        }
921    }
922
923    if !multi {
924        println!("\n{}", crate::messages::cli::DONE);
925    } else {
926        println!(
927            "{}",
928            crate::messages::cli::done_multi(&snip.name, aliases.len())
929        );
930    }
931    println!("\n{}", crate::messages::cli::PRESS_ENTER);
932    let _ = std::io::stdin().read_line(&mut String::new());
933    terminal.enter()?;
934    events.resume();
935    *last_config_check = std::time::Instant::now();
936    // Reload so sort order (e.g. most recent) reflects the new history.
937    app.hosts_state.ssh_config = SshConfigFile::parse(&app.reload.config_path)?;
938    app.reload_hosts();
939    app.update_last_modified();
940    Ok(())
941}
942
943/// Flush any deferred vault-config writes, join the background signing
944/// thread and kill active tunnels before leaving the TUI.
945fn tui_teardown(app: &mut App, terminal: &mut tui::Tui) -> Result<()> {
946    app.flush_pending_vault_write();
947
948    if let Some(handle) = app.vault.cancel_signing_run() {
949        let _ = handle.join();
950    }
951
952    for (_, mut tunnel) in app.tunnels.active.drain() {
953        let _ = tunnel.child.kill();
954        let _ = tunnel.child.wait();
955    }
956
957    terminal.exit()?;
958    Ok(())
959}
960
961pub(crate) fn current_cert_mtime(alias: &str, app: &app::App) -> Option<std::time::SystemTime> {
962    let host = app.hosts_state.list.iter().find(|h| h.alias == alias)?;
963    let cert_path = vault_ssh::resolve_cert_path(alias, &host.certificate_file).ok()?;
964    std::fs::metadata(&cert_path)
965        .ok()
966        .and_then(|m| m.modified().ok())
967}
968
969/// Decide whether a `vault.cert_cache` entry should be re-checked.
970///
971/// Returns true when:
972/// - there is no cached entry at all, or
973/// - the cert file's current mtime differs from the cached mtime
974///   (an external actor signed or deleted the cert behind our back), or
975/// - the entry's age exceeds its TTL. `CertStatus::Invalid` uses a shorter
976///   backoff so transient errors recover quickly without hammering the
977///   background check thread on every poll tick.
978///
979/// The `elapsed_secs` closure is taken as a parameter so tests can inject
980/// deterministic elapsed times instead of calling the real clock.
981pub(crate) fn cache_entry_is_stale<F>(
982    entry: Option<&(
983        std::time::Instant,
984        vault_ssh::CertStatus,
985        Option<std::time::SystemTime>,
986    )>,
987    current_mtime: Option<std::time::SystemTime>,
988    elapsed_secs: F,
989) -> bool
990where
991    F: FnOnce(std::time::Instant) -> u64,
992{
993    let Some((checked_at, status, cached_mtime)) = entry else {
994        return true;
995    };
996    if current_mtime != *cached_mtime {
997        return true;
998    }
999    let ttl = if matches!(status, vault_ssh::CertStatus::Invalid(_)) {
1000        vault_ssh::CERT_ERROR_BACKOFF_SECS
1001    } else {
1002        vault_ssh::CERT_STATUS_CACHE_TTL_SECS
1003    };
1004    elapsed_secs(*checked_at) > ttl
1005}