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: creates .purple/ 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 purple_dir = paths.purple_dir();
33            if let Some(has_backup) = first_launch_init(&purple_dir, app.reload.config_path()) {
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
50    let mut terminal = tui::Tui::new()?;
51    terminal.enter()?;
52    let events = EventHandler::new(50);
53    let events_tx = events.sender();
54    let mut last_config_check = std::time::Instant::now();
55
56    // Skip background tasks in demo mode (ping status is pre-populated).
57    if !app.demo_mode {
58        spawn_startup_tasks(&mut app, &events_tx);
59    }
60
61    let mut anim = animation::AnimationState::new();
62
63    while app.running {
64        anim.detect_transitions(&mut app);
65        terminal.draw(&mut app, &mut anim)?;
66
67        // During animation, use a short timeout for smooth frames (~60fps).
68        // During ping checking, use 80ms timeout for spinner.
69        // Otherwise, block until the next event arrives.
70        let vault_signing = app.vault.is_signing();
71        let provider_syncing = !app.providers.syncing().is_empty();
72        // Tunnels tab drives the live chart animation. While at least
73        // one tunnel is running we tick at 16ms (~60 fps) so the
74        // swimlane bars and sparklines drift smoothly. The tick also
75        // refreshes the uptime readout every frame.
76        let tunnels_anim_tick =
77            matches!(app.top_page, app::TopPage::Tunnels) && !app.tunnels.active().is_empty();
78        let event = if anim.is_animating(&app) || tunnels_anim_tick {
79            events.next_timeout(std::time::Duration::from_millis(16))?
80        } else if anim.has_checking_hosts(&app)
81            || vault_signing
82            || provider_syncing
83            || anim.has_reachable_hosts(&app)
84        {
85            events.next_timeout(std::time::Duration::from_millis(60))?
86        } else {
87            Some(events.next()?)
88        };
89
90        if dispatch_event(
91            &mut app,
92            event,
93            &mut anim,
94            vault_signing,
95            &events_tx,
96            &mut terminal,
97            &mut last_config_check,
98        )?
99        .is_break()
100        {
101            continue;
102        }
103
104        lazy_cert_check(&mut app, &events_tx);
105
106        handle_pending_connect(&mut app, &mut terminal, &events, &mut last_config_check)?;
107        handle_pending_container_exec(&mut app, &mut terminal, &events, &mut last_config_check)?;
108        handle_pending_container_logs(&mut app, &events_tx);
109        handle_pending_container_action(&mut app, &events_tx);
110        // Drain any aliases queued for an initial container-cache
111        // fetch (form save, sync, external edit, restore). The
112        // helper drains the queue itself and routes the items into
113        // the existing `RefreshBatch` driver.
114        if app.container_state.has_pending_fetches() {
115            handler::containers_overview::auto_fetch_new_hosts(&mut app, &events_tx);
116        }
117        handle_pending_snippet(&mut app, &mut terminal, &events, &mut last_config_check)?;
118    }
119
120    tui_teardown(&mut app, &mut terminal)
121}
122
123/// Spawn auto-sync, auto-ping and the background version check on TUI startup.
124fn spawn_startup_tasks(app: &mut App, events_tx: &std::sync::mpsc::Sender<AppEvent>) {
125    for section in app.providers.config().configured_providers().to_vec() {
126        if !section.auto_sync {
127            continue;
128        }
129        let key = section.id.to_string();
130        if !app.providers.syncing().contains_key(&key) {
131            app.providers.reset_batch_if_idle();
132            let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
133            app.providers.syncing_mut().insert(key, cancel.clone());
134            app.providers.bump_batch_total();
135            handler::spawn_provider_sync(
136                &section,
137                events_tx.clone(),
138                cancel,
139                std::sync::Arc::clone(&app.env),
140            );
141            crate::set_sync_summary(app);
142        }
143    }
144
145    if app.ping.auto_ping() {
146        let hosts_to_ping: Vec<(String, String, u16)> = app
147            .hosts_state
148            .list()
149            .iter()
150            .filter(|h| !h.hostname.is_empty() && h.proxy_jump.is_empty())
151            .map(|h| (h.alias.clone(), h.hostname.clone(), h.port))
152            .collect();
153        for h in app.hosts_state.list() {
154            if !h.proxy_jump.is_empty() {
155                app.ping
156                    .insert_status(h.alias.clone(), app::PingStatus::Skipped);
157            }
158        }
159        if !hosts_to_ping.is_empty() {
160            for (alias, _, _) in &hosts_to_ping {
161                app.ping
162                    .insert_status(alias.clone(), app::PingStatus::Checking);
163            }
164            ping::ping_all(&hosts_to_ping, events_tx.clone(), app.ping.generation());
165        }
166    }
167
168    update::spawn_version_check(events_tx.clone(), std::sync::Arc::clone(&app.env));
169
170    // Kick off a one-shot cert check for every vault-managed host so the
171    // Keys-tab strip populates on startup without the user having to
172    // navigate through each host first (or hit R). The actual validation
173    // is `ssh-keygen -L`, cheap, runs off-thread, and reuses the same
174    // `CertCheckResult` event path as the lazy selection-driven check.
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))
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 = vault_ssh::cert_file_in_purple_dir(&s.certificate_file);
423            (s.alias.clone(), s.certificate_file.clone(), role, cert_file)
424        })
425    else {
426        return;
427    };
428    if !(has_vault_role || has_purple_cert_file) {
429        return;
430    }
431
432    // Stat the cert file at most once per `CERT_STAT_THROTTLE` so the
433    // per-frame freshness probe detects external writes (CLI sign,
434    // another purple instance) without a syscall on every 16ms tick.
435    // Compared against the mtime recorded when the cache entry was
436    // populated; any mismatch forces a re-check, no matter the TTL.
437    let now = std::time::Instant::now();
438    let recently_stat = app
439        .vault
440        .last_cert_stat(&alias)
441        .is_some_and(|t| now.duration_since(t) < CERT_STAT_THROTTLE);
442    let current_mtime = if recently_stat {
443        app.vault
444            .cert_entry(&alias)
445            .and_then(|(_, _, mtime)| *mtime)
446    } else {
447        let m = vault_ssh::resolve_cert_path(app.env().paths(), &alias, &certificate_file)
448            .ok()
449            .and_then(|p| std::fs::metadata(&p).ok())
450            .and_then(|m| m.modified().ok());
451        app.vault.note_cert_stat(alias.clone(), now);
452        m
453    };
454    let cache_stale = cache_entry_is_stale(app.vault.cert_entry(&alias), current_mtime, |t| {
455        t.elapsed().as_secs()
456    });
457
458    let sign_in_flight = app
459        .vault
460        .sign_in_flight()
461        .lock()
462        .map(|g| g.contains(&alias))
463        .unwrap_or(false);
464    if cache_stale && !app.vault.is_cert_check_in_flight(&alias) && !sign_in_flight {
465        app.vault.mark_cert_check_started(alias.clone());
466        let tx = events_tx.clone();
467        let env = std::sync::Arc::clone(&app.env);
468        std::thread::spawn(move || {
469            let check_path =
470                match vault_ssh::resolve_cert_path(env.paths(), &alias, &certificate_file) {
471                    Ok(p) => p,
472                    Err(e) => {
473                        let _ = tx.send(event::AppEvent::CertCheckError {
474                            alias,
475                            message: e.to_string(),
476                        });
477                        return;
478                    }
479                };
480            let status = vault_ssh::check_cert_validity(&env, &check_path);
481            let _ = tx.send(event::AppEvent::CertCheckResult { alias, status });
482        });
483    }
484}
485
486/// Drain any queued SSH connection request. In tmux mode we open a new
487/// window and leave the TUI alive; otherwise we suspend the TUI, run ssh
488/// inline, then restore it. Vault SSH signing and askpass pre-flight
489/// (Bitwarden, keychain) run on the bare terminal to allow prompts.
490fn handle_pending_connect(
491    app: &mut App,
492    terminal: &mut tui::Tui,
493    events: &EventHandler,
494    last_config_check: &mut std::time::Instant,
495) -> Result<()> {
496    let Some((alias, host_askpass)) = app.ui.take_pending_connect() else {
497        return Ok(());
498    };
499    let vault_host = app
500        .hosts_state
501        .list()
502        .iter()
503        .find(|h| h.alias == alias)
504        .cloned();
505    let askpass = host_askpass.or_else(|| preferences::load_askpass_default(app.env().paths()));
506    let has_active_tunnel = app.tunnels.active_contains(&alias);
507    let use_tmux = connection::is_in_tmux(app.env()) && askpass.is_none();
508
509    if use_tmux {
510        // Tmux mode: open SSH in a new tmux window. TUI stays alive.
511        // Vault SSH cert signing runs first (eprintln warnings are harmless
512        // on the alternate screen. ratatui repaints over them on the next
513        // draw cycle). Sign the entire ProxyJump chain so the proxy hop's
514        // cert is in place before ssh tries to use it.
515        let vault_msg = if vault_host.is_some() {
516            let env = std::sync::Arc::clone(&app.env);
517            let msg = ensure_vault_ssh_chain_if_needed(
518                &env,
519                &alias,
520                app.reload.config_path(),
521                app.providers.config(),
522                app.hosts_state.ssh_config_mut(),
523            );
524            if msg.is_some() {
525                app.reload_hosts();
526                for hop in vault_ssh::resolve_proxy_chain(app.reload.config_path(), &alias) {
527                    app.refresh_cert_cache(&hop);
528                }
529            }
530            msg
531        } else {
532            None
533        };
534
535        match connection::connect_tmux_window(&alias, app.reload.config_path(), has_active_tunnel) {
536            Ok(()) => {
537                app.record_key_use(&alias, key_activity::now_secs());
538                if let Some((ref msg, is_error)) = vault_msg {
539                    if is_error {
540                        app.notify_error(msg.clone());
541                    } else {
542                        app.notify(msg.clone());
543                    }
544                } else {
545                    app.notify(crate::messages::opened_in_tmux(&alias));
546                }
547            }
548            Err(e) => {
549                app.notify_error(crate::messages::tmux_error(&e));
550            }
551        }
552        return Ok(());
553    }
554
555    // Standard mode: suspend TUI, run SSH inline, restore TUI.
556    // Order preserved: pause events, exit TUI, THEN run vault signing and
557    // password setup (which may eprintln or prompt for input on the real
558    // terminal). Sign the entire ProxyJump chain so the proxy hop's cert is
559    // in place before ssh tries to use it.
560    events.pause();
561    terminal.exit()?;
562    let vault_msg = if vault_host.is_some() {
563        let env = std::sync::Arc::clone(&app.env);
564        let msg = ensure_vault_ssh_chain_if_needed(
565            &env,
566            &alias,
567            app.reload.config_path(),
568            app.providers.config(),
569            app.hosts_state.ssh_config_mut(),
570        );
571        if msg.is_some() {
572            app.reload_hosts();
573            for hop in vault_ssh::resolve_proxy_chain(app.reload.config_path(), &alias) {
574                app.refresh_cert_cache(&hop);
575            }
576        }
577        msg
578    } else {
579        None
580    };
581    let env = std::sync::Arc::clone(&app.env);
582    ensure_proton_login(&env, askpass.as_deref());
583    if let Some(token) = ensure_bw_session(&env, app.bw_session.as_deref(), askpass.as_deref()) {
584        app.bw_session = Some(token);
585    }
586    ensure_keychain_password(&env, &alias, askpass.as_deref());
587    print!("{}", crate::messages::cli::beaming_up(&alias));
588    let result = connection::connect(
589        &alias,
590        app.reload.config_path(),
591        askpass.as_deref(),
592        app.bw_session.as_deref(),
593        has_active_tunnel,
594    );
595    println!();
596    match &result {
597        Ok(cr) => {
598            let code = cr.status.code().unwrap_or(1);
599            if code != 255 {
600                app.history.record(&alias);
601                app.record_key_use(&alias, key_activity::now_secs());
602                app.hosts_state.invalidate_render_cache();
603            }
604            if code != 0 {
605                if let Some((hostname, known_hosts_path)) =
606                    connection::parse_host_key_error(&cr.stderr_output)
607                {
608                    app.screen = app::Screen::ConfirmHostKeyReset {
609                        alias: alias.clone(),
610                        hostname,
611                        known_hosts_path,
612                        askpass,
613                    };
614                } else {
615                    // A failed Vault sign that came alongside a failed SSH
616                    // is almost always the CAUSE of the SSH failure (no cert
617                    // → permission denied). Surface the vault error first so
618                    // the user can fix the actual problem; otherwise they
619                    // chase the generic ssh error.
620                    if let Some((ref vmsg, true)) = vault_msg {
621                        app.notify_error(vmsg.clone());
622                    }
623                    let reason = connection::stderr_summary(&cr.stderr_output);
624                    let msg = if let Some(reason) = reason {
625                        crate::messages::ssh_failed_with_reason(&alias, &reason)
626                    } else {
627                        crate::messages::ssh_exited_with_code(&alias, code)
628                    };
629                    app.notify_error(msg);
630                }
631            } else if let Some((ref msg, is_error)) = vault_msg {
632                if is_error {
633                    app.notify_error(msg.clone());
634                } else {
635                    app.notify(msg.clone());
636                }
637            }
638        }
639        Err(e) => {
640            log::error!("[external] ssh connect failed: alias={alias}: {e}");
641            eprintln!("{}", crate::messages::connection_spawn_failed(&e));
642            app.notify_error(crate::messages::connection_failed(&alias));
643        }
644    }
645    askpass::cleanup_marker(app.env.paths(), &alias);
646    terminal.enter()?;
647    events.resume();
648    *last_config_check = std::time::Instant::now();
649    let reloaded = SshConfigFile::parse_with_env(app.reload.config_path(), app.env())?;
650    app.hosts_state.set_ssh_config(reloaded);
651    app.reload_hosts();
652    app.update_last_modified();
653    Ok(())
654}
655
656/// Drain any queued container-exec request. Same lifecycle as
657/// `handle_pending_connect` but the spawned command is
658/// `ssh -t <alias> <runtime> exec -it <id> sh -c 'bash || sh'` instead
659/// of a plain shell login.
660fn handle_pending_container_exec(
661    app: &mut App,
662    terminal: &mut tui::Tui,
663    events: &EventHandler,
664    last_config_check: &mut std::time::Instant,
665) -> Result<()> {
666    let Some(req) = app.container_state.take_pending_exec() else {
667        return Ok(());
668    };
669
670    // Defense-in-depth: container_id is currently gated by
671    // `validate_running_row` (which calls validate_container_id)
672    // before pending_container_exec is populated. This second validation
673    // covers any future entry point (MCP tool call, paste-via-jump, etc.)
674    // that might populate the request without going through that gate.
675    if let Err(e) = crate::containers::validate_container_id(&req.container_id) {
676        log::warn!(
677            "[purple] container exec blocked on '{}': invalid container_id: {}",
678            req.alias,
679            e
680        );
681        app.notify(crate::messages::container_invalid_id(&e));
682        return Ok(());
683    }
684
685    let askpass = req
686        .askpass
687        .or_else(|| preferences::load_askpass_default(app.env().paths()));
688    let has_active_tunnel = app.tunnels.active_contains(&req.alias);
689    let use_tmux = connection::is_in_tmux(app.env()) && askpass.is_none();
690
691    let remote_cmd = if let Some(ref user_cmd) = req.command {
692        // User-typed exec command from the `e` prompt. The remote runs
693        // `sh -c '<cmd>'`; embedded single-quotes are escaped as the
694        // standard POSIX `'\''` so the wrapping quotes survive a token
695        // like `it's-fine`. The prompt handler already rejects newlines.
696        let escaped = user_cmd.replace('\'', "'\\''");
697        format!(
698            "{} exec -it {} sh -c '{}'",
699            req.runtime.as_str(),
700            req.container_id,
701            escaped
702        )
703    } else {
704        format!(
705            "{} exec -it {} sh -c 'bash || sh'",
706            req.runtime.as_str(),
707            req.container_id
708        )
709    };
710
711    if use_tmux {
712        let label = format!("{}/{}", req.alias, req.container_name);
713        match connection::connect_tmux_window_with_remote_command(
714            &req.alias,
715            app.reload.config_path(),
716            app.env(),
717            has_active_tunnel,
718            &remote_cmd,
719            &label,
720        ) {
721            Ok(()) => {
722                app.record_key_use(&req.alias, key_activity::now_secs());
723                app.notify(crate::messages::container_exec_opened_in_tmux(
724                    &req.container_name,
725                    &req.alias,
726                ));
727            }
728            Err(e) => {
729                app.notify_error(crate::messages::tmux_error(&e));
730            }
731        }
732        return Ok(());
733    }
734
735    events.pause();
736    terminal.exit()?;
737
738    let result = connection::connect_with_remote_command(
739        &req.alias,
740        app.reload.config_path(),
741        app.env(),
742        askpass.as_deref(),
743        app.bw_session.as_deref(),
744        has_active_tunnel,
745        &remote_cmd,
746    );
747
748    match result {
749        Ok(cr) => {
750            let code = cr.status.code().unwrap_or(1);
751            // SSH exit 255 = ssh itself failed (auth, network, host-key
752            // mismatch); anything else means ssh connected and the
753            // remote command exited with that code. Recording history
754            // for non-255 mirrors the host-list connect flow so a
755            // mid-shell crash still counts as a successful login.
756            if code != 255 {
757                app.history.record(&req.alias);
758                app.record_key_use(&req.alias, key_activity::now_secs());
759                app.hosts_state.invalidate_render_cache();
760            }
761            if code == 0 {
762                app.notify(crate::messages::container_exec_ended(&req.container_name));
763            } else if let Some((hostname, known_hosts_path)) =
764                connection::parse_host_key_error(&cr.stderr_output)
765            {
766                // Same recovery surface as the host-list `i` path:
767                // park the user on ConfirmHostKeyReset so they can
768                // delete the stale known_hosts entry and retry.
769                app.screen = app::Screen::ConfirmHostKeyReset {
770                    alias: req.alias.clone(),
771                    hostname,
772                    known_hosts_path,
773                    askpass: askpass.clone(),
774                };
775            } else {
776                let reason = connection::stderr_summary(&cr.stderr_output);
777                let msg = match reason {
778                    Some(r) => {
779                        crate::messages::container_exec_failed_with_reason(&req.container_name, &r)
780                    }
781                    None => {
782                        crate::messages::container_exec_exited_with_code(&req.container_name, code)
783                    }
784                };
785                app.notify_error(msg);
786            }
787        }
788        Err(e) => {
789            eprintln!("{}", crate::messages::connection_spawn_failed(&e));
790            app.notify_error(crate::messages::container_exec_spawn_failed(
791                &req.container_name,
792            ));
793        }
794    }
795    askpass::cleanup_marker(app.env.paths(), &req.alias);
796    terminal.enter()?;
797    events.resume();
798    *last_config_check = std::time::Instant::now();
799    Ok(())
800}
801
802/// Drain `pending_container_logs`. Spawns a background SSH
803/// thread that runs `<runtime> logs --tail N <id>` and emits an
804/// `AppEvent::ContainerLogsComplete` with the captured output. The
805/// receiving handler in `event_loop.rs` fills the open
806/// `Screen::ContainerLogs` overlay's body.
807fn handle_pending_container_logs(app: &mut App, events_tx: &std::sync::mpsc::Sender<AppEvent>) {
808    let Some(req) = app.container_state.take_pending_logs() else {
809        return;
810    };
811    let askpass = req
812        .askpass
813        .or_else(|| preferences::load_askpass_default(app.env().paths()));
814    let has_tunnel = app.tunnels.active_contains(&req.alias);
815    let ctx = crate::ssh_context::OwnedSshContext {
816        alias: req.alias,
817        config_path: app.reload.config_path().to_path_buf(),
818        askpass,
819        bw_session: app.bw_session.clone(),
820        has_tunnel,
821        env: std::sync::Arc::clone(&app.env),
822    };
823    let tx = events_tx.clone();
824    log::debug!(
825        "[purple] container_logs_fetch: spawning alias={} id={}",
826        ctx.alias,
827        req.container_id
828    );
829    crate::containers::spawn_container_logs_fetch(
830        ctx,
831        req.runtime,
832        req.container_id,
833        req.container_name,
834        crate::handler::container_logs::DEFAULT_TAIL,
835        move |alias, container_id, container_name, result| {
836            let _ = tx.send(AppEvent::ContainerLogsComplete {
837                alias,
838                container_id,
839                container_name,
840                result,
841            });
842        },
843    );
844}
845
846/// Drain `pending_container_action`. Reuses the existing
847/// `spawn_container_action` helper and `AppEvent::ContainerActionComplete`
848/// event so the result handler can stay one path. The action's
849/// container_id+name are logged here; the toast on completion uses
850/// the alias because the existing event payload does not carry the
851/// per-container labels.
852fn handle_pending_container_action(app: &mut App, events_tx: &std::sync::mpsc::Sender<AppEvent>) {
853    // Drain at most one action per tick. Stack-restart pushes N
854    // requests but the SSH workers should not all sprint off the
855    // same tick. staggering keeps load on the remote sshd lower.
856    let Some(req) = app.container_state.pop_next_action() else {
857        return;
858    };
859    let askpass = req
860        .askpass
861        .or_else(|| preferences::load_askpass_default(app.env().paths()));
862    let has_tunnel = app.tunnels.active_contains(&req.alias);
863    let ctx = crate::ssh_context::OwnedSshContext {
864        alias: req.alias.clone(),
865        config_path: app.reload.config_path().to_path_buf(),
866        askpass,
867        bw_session: app.bw_session.clone(),
868        has_tunnel,
869        env: std::sync::Arc::clone(&app.env),
870    };
871    let tx = events_tx.clone();
872    log::info!(
873        "[purple] container_action_drain: spawning alias={} id={} action={:?} name={}",
874        req.alias,
875        req.container_id,
876        req.action,
877        req.container_name
878    );
879    crate::containers::spawn_container_action(
880        ctx,
881        req.runtime,
882        req.action,
883        req.container_id,
884        move |alias, action, result| {
885            let _ = tx.send(AppEvent::ContainerActionComplete {
886                alias,
887                action,
888                result,
889            });
890        },
891    );
892}
893
894/// Drain any queued snippet-run request: suspend the TUI, run the command
895/// across all selected hosts, record history on success, wait for Enter,
896/// then restore the TUI and reload the SSH config.
897fn handle_pending_snippet(
898    app: &mut App,
899    terminal: &mut tui::Tui,
900    events: &EventHandler,
901    last_config_check: &mut std::time::Instant,
902) -> Result<()> {
903    let Some((snip, aliases)) = app.snippets.take_pending() else {
904        return Ok(());
905    };
906    events.pause();
907    terminal.exit()?;
908
909    let multi = aliases.len() > 1;
910    for alias in &aliases {
911        let askpass = app
912            .hosts_state
913            .list()
914            .iter()
915            .find(|h| h.alias == *alias)
916            .and_then(|h| h.askpass.clone())
917            .or_else(|| preferences::load_askpass_default(app.env().paths()));
918        let env = std::sync::Arc::clone(&app.env);
919        ensure_proton_login(&env, askpass.as_deref());
920        if let Some(token) = ensure_bw_session(&env, app.bw_session.as_deref(), askpass.as_deref())
921        {
922            app.bw_session = Some(token);
923        }
924        ensure_keychain_password(&env, alias, askpass.as_deref());
925
926        if multi {
927            println!("{}", crate::messages::cli::host_separator(alias));
928        } else {
929            print!(
930                "{}",
931                crate::messages::cli::running_snippet_on(&snip.name, alias)
932            );
933        }
934        let has_tunnel = app.tunnels.active_contains(alias);
935        match snippet::run_snippet(
936            alias,
937            app.reload.config_path(),
938            &env,
939            &snip.command,
940            askpass.as_deref(),
941            app.bw_session.as_deref(),
942            false,
943            has_tunnel,
944        ) {
945            Ok(r) => {
946                if r.status.success() {
947                    app.history.record(alias);
948                    app.record_key_use(alias, key_activity::now_secs());
949                    app.hosts_state.invalidate_render_cache();
950                } else if multi {
951                    eprintln!(
952                        "{}",
953                        crate::messages::cli::exited_with_code(r.status.code().unwrap_or(1))
954                    );
955                } else {
956                    println!(
957                        "\n{}",
958                        crate::messages::cli::exited_with_code(r.status.code().unwrap_or(1))
959                    );
960                }
961            }
962            Err(e) => eprintln!("{}", crate::messages::cli::host_failed(alias, &e)),
963        }
964        if multi {
965            println!();
966        }
967    }
968
969    if !multi {
970        println!("\n{}", crate::messages::cli::DONE);
971    } else {
972        println!(
973            "{}",
974            crate::messages::cli::done_multi(&snip.name, aliases.len())
975        );
976    }
977    println!("\n{}", crate::messages::cli::PRESS_ENTER);
978    let _ = std::io::stdin().read_line(&mut String::new());
979    terminal.enter()?;
980    events.resume();
981    *last_config_check = std::time::Instant::now();
982    // Reload so sort order (e.g. most recent) reflects the new history.
983    let reloaded = SshConfigFile::parse_with_env(app.reload.config_path(), app.env())?;
984    app.hosts_state.set_ssh_config(reloaded);
985    app.reload_hosts();
986    app.update_last_modified();
987    Ok(())
988}
989
990/// Flush any deferred vault-config writes, join the background signing
991/// thread and kill active tunnels before leaving the TUI.
992fn tui_teardown(app: &mut App, terminal: &mut tui::Tui) -> Result<()> {
993    app.flush_pending_vault_write();
994
995    if let Some(handle) = app.vault.cancel_signing_run() {
996        let _ = handle.join();
997    }
998
999    for (_, mut tunnel) in app.tunnels.drain_active() {
1000        let _ = tunnel.child.kill();
1001        let _ = tunnel.child.wait();
1002    }
1003
1004    terminal.exit()?;
1005    Ok(())
1006}
1007
1008pub(crate) fn current_cert_mtime(alias: &str, app: &app::App) -> Option<std::time::SystemTime> {
1009    let host = app.hosts_state.list().iter().find(|h| h.alias == alias)?;
1010    let cert_path =
1011        vault_ssh::resolve_cert_path(app.env().paths(), alias, &host.certificate_file).ok()?;
1012    std::fs::metadata(&cert_path)
1013        .ok()
1014        .and_then(|m| m.modified().ok())
1015}
1016
1017/// Decide whether a `vault.cert_cache` entry should be re-checked.
1018///
1019/// Returns true when:
1020/// - there is no cached entry at all, or
1021/// - the cert file's current mtime differs from the cached mtime
1022///   (an external actor signed or deleted the cert behind our back), or
1023/// - the entry's age exceeds its TTL. `CertStatus::Invalid` uses a shorter
1024///   backoff so transient errors recover quickly without hammering the
1025///   background check thread on every poll tick.
1026///
1027/// The `elapsed_secs` closure is taken as a parameter so tests can inject
1028/// deterministic elapsed times instead of calling the real clock.
1029pub(crate) fn cache_entry_is_stale<F>(
1030    entry: Option<&(
1031        std::time::Instant,
1032        vault_ssh::CertStatus,
1033        Option<std::time::SystemTime>,
1034    )>,
1035    current_mtime: Option<std::time::SystemTime>,
1036    elapsed_secs: F,
1037) -> bool
1038where
1039    F: FnOnce(std::time::Instant) -> u64,
1040{
1041    let Some((checked_at, status, cached_mtime)) = entry else {
1042        return true;
1043    };
1044    if current_mtime != *cached_mtime {
1045        return true;
1046    }
1047    let ttl = if matches!(status, vault_ssh::CertStatus::Invalid(_)) {
1048        vault_ssh::CERT_ERROR_BACKOFF_SECS
1049    } else {
1050        vault_ssh::CERT_STATUS_CACHE_TTL_SECS
1051    };
1052    elapsed_secs(*checked_at) > ttl
1053}