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