Skip to main content

purple_ssh/
tui_loop.rs

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