1use 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 if app.status_center.status.is_none() && !app.demo_mode {
23 if let Some(home) = dirs::home_dir() {
24 let purple_dir = home.join(".purple");
25 if let Some(has_backup) = first_launch_init(&purple_dir, &app.reload.config_path) {
26 let host_count = app.hosts_state.list.len();
27 let known_hosts_count = if host_count == 0 {
28 import::count_known_hosts_candidates()
29 } else {
30 0
31 };
32 app.ui.known_hosts_count = known_hosts_count;
33 app.screen = app::Screen::Welcome {
34 has_backup,
35 host_count,
36 known_hosts_count,
37 };
38 }
39 }
40 }
41
42 let mut terminal = tui::Tui::new()?;
43 terminal.enter()?;
44 let events = EventHandler::new(50);
45 let events_tx = events.sender();
46 let mut last_config_check = std::time::Instant::now();
47
48 if !app.demo_mode {
50 spawn_startup_tasks(&mut app, &events_tx);
51 }
52
53 let mut anim = animation::AnimationState::new();
54
55 while app.running {
56 anim.detect_transitions(&mut app);
57 terminal.draw(&mut app, &mut anim)?;
58
59 let vault_signing = app.vault.signing_cancel.is_some();
63 let provider_syncing = !app.providers.syncing.is_empty();
64 let tunnels_anim_tick =
69 matches!(app.top_page, app::TopPage::Tunnels) && !app.tunnels.active.is_empty();
70 let event = if anim.is_animating(&app) || tunnels_anim_tick {
71 events.next_timeout(std::time::Duration::from_millis(16))?
72 } else if anim.has_checking_hosts(&app)
73 || vault_signing
74 || provider_syncing
75 || anim.has_reachable_hosts(&app)
76 {
77 events.next_timeout(std::time::Duration::from_millis(60))?
78 } else {
79 Some(events.next()?)
80 };
81
82 if dispatch_event(
83 &mut app,
84 event,
85 &mut anim,
86 vault_signing,
87 &events_tx,
88 &mut terminal,
89 &mut last_config_check,
90 )?
91 .is_break()
92 {
93 continue;
94 }
95
96 lazy_cert_check(&mut app, &events_tx);
97
98 handle_pending_connect(&mut app, &mut terminal, &events, &mut last_config_check)?;
99 handle_pending_container_exec(&mut app, &mut terminal, &events, &mut last_config_check)?;
100 handle_pending_container_logs(&mut app, &events_tx);
101 handle_pending_container_action(&mut app, &events_tx);
102 if !app.container_state.pending_fetch_aliases.is_empty() {
107 handler::containers_overview::auto_fetch_new_hosts(&mut app, &events_tx);
108 }
109 handle_pending_snippet(&mut app, &mut terminal, &events, &mut last_config_check)?;
110 }
111
112 tui_teardown(&mut app, &mut terminal)
113}
114
115fn spawn_startup_tasks(app: &mut App, events_tx: &std::sync::mpsc::Sender<AppEvent>) {
117 for section in app.providers.config.configured_providers().to_vec() {
118 if !section.auto_sync {
119 continue;
120 }
121 let key = section.id.to_string();
122 if !app.providers.syncing.contains_key(&key) {
123 app.providers.reset_batch_if_idle();
124 let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
125 app.providers.syncing.insert(key, cancel.clone());
126 app.providers.batch_total = app
127 .providers
128 .batch_total
129 .max(app.providers.sync_done.len() + app.providers.syncing.len());
130 handler::spawn_provider_sync(§ion, events_tx.clone(), cancel);
131 crate::set_sync_summary(app);
132 }
133 }
134
135 if app.ping.auto_ping {
136 let hosts_to_ping: Vec<(String, String, u16)> = app
137 .hosts_state
138 .list
139 .iter()
140 .filter(|h| !h.hostname.is_empty() && h.proxy_jump.is_empty())
141 .map(|h| (h.alias.clone(), h.hostname.clone(), h.port))
142 .collect();
143 for h in &app.hosts_state.list {
144 if !h.proxy_jump.is_empty() {
145 app.ping
146 .status
147 .insert(h.alias.clone(), app::PingStatus::Skipped);
148 }
149 }
150 if !hosts_to_ping.is_empty() {
151 for (alias, _, _) in &hosts_to_ping {
152 app.ping
153 .status
154 .insert(alias.clone(), app::PingStatus::Checking);
155 }
156 ping::ping_all(&hosts_to_ping, events_tx.clone(), app.ping.generation);
157 }
158 }
159
160 update::spawn_version_check(events_tx.clone());
161
162 let vault_aliases: Vec<(String, String)> = app
168 .hosts_state
169 .list
170 .iter()
171 .filter(|h| vault_ssh::has_purple_vault_context(h))
172 .filter(|h| !app.vault.cert_checks_in_flight.contains(&h.alias))
173 .filter(|h| !app.vault.cert_cache.contains_key(&h.alias))
174 .map(|h| (h.alias.clone(), h.certificate_file.clone()))
175 .collect();
176 for (alias, cert_file) in vault_aliases {
177 app.vault.mark_cert_check_started(alias.clone());
178 let tx = events_tx.clone();
179 std::thread::spawn(move || {
180 let check_path = match vault_ssh::resolve_cert_path(&alias, &cert_file) {
181 Ok(p) => p,
182 Err(e) => {
183 let _ = tx.send(event::AppEvent::CertCheckError {
184 alias,
185 message: e.to_string(),
186 });
187 return;
188 }
189 };
190 let status = vault_ssh::check_cert_validity(&check_path);
191 let _ = tx.send(event::AppEvent::CertCheckResult { alias, status });
192 });
193 }
194}
195
196#[allow(clippy::too_many_arguments)]
199fn dispatch_event(
200 app: &mut App,
201 event: Option<AppEvent>,
202 anim: &mut animation::AnimationState,
203 vault_signing: bool,
204 events_tx: &std::sync::mpsc::Sender<AppEvent>,
205 terminal: &mut tui::Tui,
206 last_config_check: &mut std::time::Instant,
207) -> Result<std::ops::ControlFlow<()>> {
208 match event {
209 Some(AppEvent::Key(key)) => {
210 handler::handle_key_event(app, key, events_tx)?;
211 }
212 Some(AppEvent::Tick) | None => {
213 handler::event_loop::handle_tick(app, anim, vault_signing, last_config_check);
214 }
215 Some(AppEvent::PingResult {
216 alias,
217 rtt_ms,
218 generation,
219 }) => {
220 handler::event_loop::handle_ping_result(app, alias, rtt_ms, generation);
221 }
222 Some(AppEvent::SyncProgress { provider, message }) => {
223 handler::event_loop::handle_sync_progress(app, provider, message);
224 }
225 Some(AppEvent::SyncComplete { provider, hosts }) => {
226 handler::event_loop::handle_sync_complete(app, provider, hosts, last_config_check);
227 }
228 Some(AppEvent::SyncPartial {
229 provider,
230 hosts,
231 failures,
232 total,
233 }) => {
234 handler::event_loop::handle_sync_partial(
235 app,
236 provider,
237 hosts,
238 failures,
239 total,
240 last_config_check,
241 );
242 }
243 Some(AppEvent::SyncError { provider, message }) => {
244 handler::event_loop::handle_sync_error(app, provider, message, last_config_check);
245 }
246 Some(AppEvent::UpdateAvailable { version, headline }) => {
247 handler::event_loop::handle_update_available(app, version, headline);
248 }
249 Some(AppEvent::FileBrowserListing {
250 alias,
251 path,
252 entries,
253 }) => {
254 handler::event_loop::handle_file_browser_listing(app, alias, path, entries, terminal);
255 }
256 Some(AppEvent::ScpComplete {
257 alias,
258 success,
259 message,
260 }) => {
261 handler::event_loop::handle_scp_complete(
262 app, alias, success, message, events_tx, terminal,
263 );
264 }
265 Some(AppEvent::SnippetHostDone {
266 run_id,
267 alias,
268 stdout,
269 stderr,
270 exit_code,
271 }) => {
272 handler::event_loop::handle_snippet_host_done(
273 app, run_id, alias, stdout, stderr, exit_code,
274 );
275 }
276 Some(AppEvent::SnippetProgress {
277 run_id,
278 completed,
279 total,
280 }) => {
281 handler::event_loop::handle_snippet_progress(app, run_id, completed, total);
282 }
283 Some(AppEvent::SnippetAllDone { run_id }) => {
284 handler::event_loop::handle_snippet_all_done(app, run_id);
285 }
286 Some(AppEvent::KeyPushResult { run_id, result }) => {
287 handler::event_loop::handle_key_push_result(app, run_id, result);
288 }
289 Some(AppEvent::ContainerListing { alias, result }) => {
290 handler::event_loop::handle_container_listing(app, alias, result, events_tx);
291 }
292 Some(AppEvent::ContainerActionComplete {
293 alias,
294 action,
295 result,
296 }) => {
297 handler::event_loop::handle_container_action_complete(
298 app, alias, action, result, events_tx,
299 );
300 }
301 Some(AppEvent::ContainerLogsComplete {
302 alias,
303 container_id,
304 container_name,
305 result,
306 }) => {
307 handler::event_loop::handle_container_logs_complete(
308 app,
309 alias,
310 container_id,
311 container_name,
312 result,
313 );
314 }
315 Some(AppEvent::ContainerInspectComplete {
316 alias,
317 container_id,
318 result,
319 }) => {
320 handler::event_loop::handle_container_inspect_complete(
321 app,
322 alias,
323 container_id,
324 *result,
325 );
326 }
327 Some(AppEvent::ContainerLogsTailComplete {
328 alias,
329 container_id,
330 result,
331 }) => {
332 handler::event_loop::handle_container_logs_tail_complete(
333 app,
334 alias,
335 container_id,
336 *result,
337 );
338 }
339 Some(AppEvent::VaultSignResult {
340 alias,
341 certificate_file: existing_cert_file,
342 success,
343 message,
344 }) => {
345 handler::event_loop::handle_vault_sign_result(
346 app,
347 alias,
348 existing_cert_file,
349 success,
350 message,
351 );
352 }
353 Some(AppEvent::VaultSignProgress { alias, done, total }) => {
354 handler::event_loop::handle_vault_sign_progress(
355 app,
356 alias,
357 done,
358 total,
359 anim.spinner_tick,
360 );
361 }
362 Some(AppEvent::VaultSignAllDone {
363 signed,
364 failed,
365 skipped,
366 cancelled,
367 aborted_message,
368 first_error,
369 }) => {
370 if handler::event_loop::handle_vault_sign_all_done(
371 app,
372 signed,
373 failed,
374 skipped,
375 cancelled,
376 aborted_message,
377 first_error,
378 )
379 .is_break()
380 {
381 return Ok(std::ops::ControlFlow::Break(()));
382 }
383 }
384 Some(AppEvent::CertCheckResult { alias, status }) => {
385 handler::event_loop::handle_cert_check_result(app, alias, status);
386 }
387 Some(AppEvent::CertCheckError { alias, message }) => {
388 handler::event_loop::handle_cert_check_error(app, alias, message);
389 }
390 Some(AppEvent::PollError) => {
391 app.running = false;
392 }
393 }
394 Ok(std::ops::ControlFlow::Continue(()))
395}
396
397fn lazy_cert_check(app: &mut App, events_tx: &std::sync::mpsc::Sender<AppEvent>) {
400 if let Some(selected) = app.selected_host() {
401 let has_vault_role = vault_ssh::resolve_vault_role(
402 selected.vault_ssh.as_deref(),
403 selected.provider.as_deref(),
404 selected.provider_label.as_deref(),
405 &app.providers.config,
406 )
407 .is_some();
408 let has_purple_cert_file = vault_ssh::cert_file_in_purple_dir(&selected.certificate_file);
413 if has_vault_role || has_purple_cert_file {
414 let current_mtime =
419 vault_ssh::resolve_cert_path(&selected.alias, &selected.certificate_file)
420 .ok()
421 .and_then(|p| std::fs::metadata(&p).ok())
422 .and_then(|m| m.modified().ok());
423 let cache_stale = cache_entry_is_stale(
424 app.vault.cert_cache.get(&selected.alias),
425 current_mtime,
426 |t| t.elapsed().as_secs(),
427 );
428
429 let sign_in_flight = app
430 .vault
431 .sign_in_flight
432 .lock()
433 .map(|g| g.contains(&selected.alias))
434 .unwrap_or(false);
435 if cache_stale
436 && !app.vault.cert_checks_in_flight.contains(&selected.alias)
437 && !sign_in_flight
438 {
439 let alias = selected.alias.clone();
440 let cert_file = selected.certificate_file.clone();
441 app.vault.mark_cert_check_started(alias.clone());
442 let tx = events_tx.clone();
443 std::thread::spawn(move || {
444 let check_path = match vault_ssh::resolve_cert_path(&alias, &cert_file) {
445 Ok(p) => p,
446 Err(e) => {
447 let _ = tx.send(event::AppEvent::CertCheckError {
448 alias,
449 message: e.to_string(),
450 });
451 return;
452 }
453 };
454 let status = vault_ssh::check_cert_validity(&check_path);
455 let _ = tx.send(event::AppEvent::CertCheckResult { alias, status });
456 });
457 }
458 }
459 }
460}
461
462fn handle_pending_connect(
467 app: &mut App,
468 terminal: &mut tui::Tui,
469 events: &EventHandler,
470 last_config_check: &mut std::time::Instant,
471) -> Result<()> {
472 let Some((alias, host_askpass)) = app.ui.pending_connect.take() else {
473 return Ok(());
474 };
475 let vault_host = app
476 .hosts_state
477 .list
478 .iter()
479 .find(|h| h.alias == alias)
480 .cloned();
481 let askpass = host_askpass.or_else(preferences::load_askpass_default);
482 let has_active_tunnel = app.tunnels.active.contains_key(&alias);
483 let use_tmux = connection::is_in_tmux() && askpass.is_none();
484
485 if use_tmux {
486 let vault_msg = if vault_host.is_some() {
492 let msg = ensure_vault_ssh_chain_if_needed(
493 &alias,
494 &app.reload.config_path,
495 &app.providers.config,
496 &mut app.hosts_state.ssh_config,
497 );
498 if msg.is_some() {
499 app.reload_hosts();
500 for hop in vault_ssh::resolve_proxy_chain(&app.reload.config_path, &alias) {
501 app.refresh_cert_cache(&hop);
502 }
503 }
504 msg
505 } else {
506 None
507 };
508
509 match connection::connect_tmux_window(&alias, &app.reload.config_path, has_active_tunnel) {
510 Ok(()) => {
511 app.record_key_use(&alias, key_activity::now_secs());
512 if let Some((ref msg, is_error)) = vault_msg {
513 if is_error {
514 app.notify_error(msg.clone());
515 } else {
516 app.notify(msg.clone());
517 }
518 } else {
519 app.notify(crate::messages::opened_in_tmux(&alias));
520 }
521 }
522 Err(e) => {
523 app.notify_error(crate::messages::tmux_error(&e));
524 }
525 }
526 return Ok(());
527 }
528
529 events.pause();
535 terminal.exit()?;
536 let vault_msg = if vault_host.is_some() {
537 let msg = ensure_vault_ssh_chain_if_needed(
538 &alias,
539 &app.reload.config_path,
540 &app.providers.config,
541 &mut app.hosts_state.ssh_config,
542 );
543 if msg.is_some() {
544 app.reload_hosts();
545 for hop in vault_ssh::resolve_proxy_chain(&app.reload.config_path, &alias) {
546 app.refresh_cert_cache(&hop);
547 }
548 }
549 msg
550 } else {
551 None
552 };
553 ensure_proton_login(askpass.as_deref());
554 if let Some(token) = ensure_bw_session(app.bw_session.as_deref(), askpass.as_deref()) {
555 app.bw_session = Some(token);
556 }
557 ensure_keychain_password(&alias, askpass.as_deref());
558 print!("{}", crate::messages::cli::beaming_up(&alias));
559 let result = connection::connect(
560 &alias,
561 &app.reload.config_path,
562 askpass.as_deref(),
563 app.bw_session.as_deref(),
564 has_active_tunnel,
565 );
566 println!();
567 match &result {
568 Ok(cr) => {
569 let code = cr.status.code().unwrap_or(1);
570 if code != 255 {
571 app.history.record(&alias);
572 app.record_key_use(&alias, key_activity::now_secs());
573 app.hosts_state.render_cache.invalidate();
574 }
575 if code != 0 {
576 if let Some((hostname, known_hosts_path)) =
577 connection::parse_host_key_error(&cr.stderr_output)
578 {
579 app.screen = app::Screen::ConfirmHostKeyReset {
580 alias: alias.clone(),
581 hostname,
582 known_hosts_path,
583 askpass,
584 };
585 } else {
586 if let Some((ref vmsg, true)) = vault_msg {
592 app.notify_error(vmsg.clone());
593 }
594 let reason = connection::stderr_summary(&cr.stderr_output);
595 let msg = if let Some(reason) = reason {
596 crate::messages::ssh_failed_with_reason(&alias, &reason)
597 } else {
598 crate::messages::ssh_exited_with_code(&alias, code)
599 };
600 app.notify_error(msg);
601 }
602 } else if let Some((ref msg, is_error)) = vault_msg {
603 if is_error {
604 app.notify_error(msg.clone());
605 } else {
606 app.notify(msg.clone());
607 }
608 }
609 }
610 Err(e) => {
611 log::error!("[external] ssh connect failed: alias={alias}: {e}");
612 eprintln!("{}", crate::messages::connection_spawn_failed(&e));
613 app.notify_error(crate::messages::connection_failed(&alias));
614 }
615 }
616 askpass::cleanup_marker(&alias);
617 terminal.enter()?;
618 events.resume();
619 *last_config_check = std::time::Instant::now();
620 app.hosts_state.ssh_config = SshConfigFile::parse(&app.reload.config_path)?;
621 app.reload_hosts();
622 app.update_last_modified();
623 Ok(())
624}
625
626fn handle_pending_container_exec(
631 app: &mut App,
632 terminal: &mut tui::Tui,
633 events: &EventHandler,
634 last_config_check: &mut std::time::Instant,
635) -> Result<()> {
636 let Some(req) = app.container_state.pending_exec.take() else {
637 return Ok(());
638 };
639
640 if let Err(e) = crate::containers::validate_container_id(&req.container_id) {
646 log::warn!(
647 "[purple] container exec blocked on '{}': invalid container_id: {}",
648 req.alias,
649 e
650 );
651 app.notify(crate::messages::container_invalid_id(&e));
652 return Ok(());
653 }
654
655 let askpass = req.askpass.or_else(preferences::load_askpass_default);
656 let has_active_tunnel = app.tunnels.active.contains_key(&req.alias);
657 let use_tmux = connection::is_in_tmux() && askpass.is_none();
658
659 let remote_cmd = if let Some(ref user_cmd) = req.command {
660 let escaped = user_cmd.replace('\'', "'\\''");
665 format!(
666 "{} exec -it {} sh -c '{}'",
667 req.runtime.as_str(),
668 req.container_id,
669 escaped
670 )
671 } else {
672 format!(
673 "{} exec -it {} sh -c 'bash || sh'",
674 req.runtime.as_str(),
675 req.container_id
676 )
677 };
678
679 if use_tmux {
680 let label = format!("{}/{}", req.alias, req.container_name);
681 match connection::connect_tmux_window_with_remote_command(
682 &req.alias,
683 &app.reload.config_path,
684 has_active_tunnel,
685 &remote_cmd,
686 &label,
687 ) {
688 Ok(()) => {
689 app.record_key_use(&req.alias, key_activity::now_secs());
690 app.notify(crate::messages::container_exec_opened_in_tmux(
691 &req.container_name,
692 &req.alias,
693 ));
694 }
695 Err(e) => {
696 app.notify_error(crate::messages::tmux_error(&e));
697 }
698 }
699 return Ok(());
700 }
701
702 events.pause();
703 terminal.exit()?;
704
705 let result = connection::connect_with_remote_command(
706 &req.alias,
707 &app.reload.config_path,
708 askpass.as_deref(),
709 app.bw_session.as_deref(),
710 has_active_tunnel,
711 &remote_cmd,
712 );
713
714 match result {
715 Ok(cr) => {
716 let code = cr.status.code().unwrap_or(1);
717 if code != 255 {
723 app.history.record(&req.alias);
724 app.record_key_use(&req.alias, key_activity::now_secs());
725 app.hosts_state.render_cache.invalidate();
726 }
727 if code == 0 {
728 app.notify(crate::messages::container_exec_ended(&req.container_name));
729 } else if let Some((hostname, known_hosts_path)) =
730 connection::parse_host_key_error(&cr.stderr_output)
731 {
732 app.screen = app::Screen::ConfirmHostKeyReset {
736 alias: req.alias.clone(),
737 hostname,
738 known_hosts_path,
739 askpass: askpass.clone(),
740 };
741 } else {
742 let reason = connection::stderr_summary(&cr.stderr_output);
743 let msg = match reason {
744 Some(r) => {
745 crate::messages::container_exec_failed_with_reason(&req.container_name, &r)
746 }
747 None => {
748 crate::messages::container_exec_exited_with_code(&req.container_name, code)
749 }
750 };
751 app.notify_error(msg);
752 }
753 }
754 Err(e) => {
755 eprintln!("{}", crate::messages::connection_spawn_failed(&e));
756 app.notify_error(crate::messages::container_exec_spawn_failed(
757 &req.container_name,
758 ));
759 }
760 }
761 askpass::cleanup_marker(&req.alias);
762 terminal.enter()?;
763 events.resume();
764 *last_config_check = std::time::Instant::now();
765 Ok(())
766}
767
768fn handle_pending_container_logs(app: &mut App, events_tx: &std::sync::mpsc::Sender<AppEvent>) {
774 let Some(req) = app.container_state.pending_logs.take() else {
775 return;
776 };
777 let askpass = req.askpass.or_else(preferences::load_askpass_default);
778 let has_tunnel = app.tunnels.active.contains_key(&req.alias);
779 let ctx = crate::ssh_context::OwnedSshContext {
780 alias: req.alias,
781 config_path: app.reload.config_path.clone(),
782 askpass,
783 bw_session: app.bw_session.clone(),
784 has_tunnel,
785 };
786 let tx = events_tx.clone();
787 log::debug!(
788 "[purple] container_logs_fetch: spawning alias={} id={}",
789 ctx.alias,
790 req.container_id
791 );
792 crate::containers::spawn_container_logs_fetch(
793 ctx,
794 req.runtime,
795 req.container_id,
796 req.container_name,
797 crate::handler::container_logs::DEFAULT_TAIL,
798 move |alias, container_id, container_name, result| {
799 let _ = tx.send(AppEvent::ContainerLogsComplete {
800 alias,
801 container_id,
802 container_name,
803 result,
804 });
805 },
806 );
807}
808
809fn handle_pending_container_action(app: &mut App, events_tx: &std::sync::mpsc::Sender<AppEvent>) {
816 let Some(req) = app.container_state.pending_actions.pop_front() else {
820 return;
821 };
822 let askpass = req.askpass.or_else(preferences::load_askpass_default);
823 let has_tunnel = app.tunnels.active.contains_key(&req.alias);
824 let ctx = crate::ssh_context::OwnedSshContext {
825 alias: req.alias.clone(),
826 config_path: app.reload.config_path.clone(),
827 askpass,
828 bw_session: app.bw_session.clone(),
829 has_tunnel,
830 };
831 let tx = events_tx.clone();
832 log::info!(
833 "[purple] container_action_drain: spawning alias={} id={} action={:?} name={}",
834 req.alias,
835 req.container_id,
836 req.action,
837 req.container_name
838 );
839 crate::containers::spawn_container_action(
840 ctx,
841 req.runtime,
842 req.action,
843 req.container_id,
844 move |alias, action, result| {
845 let _ = tx.send(AppEvent::ContainerActionComplete {
846 alias,
847 action,
848 result,
849 });
850 },
851 );
852}
853
854fn handle_pending_snippet(
858 app: &mut App,
859 terminal: &mut tui::Tui,
860 events: &EventHandler,
861 last_config_check: &mut std::time::Instant,
862) -> Result<()> {
863 let Some((snip, aliases)) = app.snippets.pending.take() else {
864 return Ok(());
865 };
866 events.pause();
867 terminal.exit()?;
868
869 let multi = aliases.len() > 1;
870 for alias in &aliases {
871 let askpass = app
872 .hosts_state
873 .list
874 .iter()
875 .find(|h| h.alias == *alias)
876 .and_then(|h| h.askpass.clone())
877 .or_else(preferences::load_askpass_default);
878 ensure_proton_login(askpass.as_deref());
879 if let Some(token) = ensure_bw_session(app.bw_session.as_deref(), askpass.as_deref()) {
880 app.bw_session = Some(token);
881 }
882 ensure_keychain_password(alias, askpass.as_deref());
883
884 if multi {
885 println!("{}", crate::messages::cli::host_separator(alias));
886 } else {
887 print!(
888 "{}",
889 crate::messages::cli::running_snippet_on(&snip.name, alias)
890 );
891 }
892 let has_tunnel = app.tunnels.active.contains_key(alias);
893 match snippet::run_snippet(
894 alias,
895 &app.reload.config_path,
896 &snip.command,
897 askpass.as_deref(),
898 app.bw_session.as_deref(),
899 false,
900 has_tunnel,
901 ) {
902 Ok(r) => {
903 if r.status.success() {
904 app.history.record(alias);
905 app.record_key_use(alias, key_activity::now_secs());
906 app.hosts_state.render_cache.invalidate();
907 } else if multi {
908 eprintln!(
909 "{}",
910 crate::messages::cli::exited_with_code(r.status.code().unwrap_or(1))
911 );
912 } else {
913 println!(
914 "\n{}",
915 crate::messages::cli::exited_with_code(r.status.code().unwrap_or(1))
916 );
917 }
918 }
919 Err(e) => eprintln!("{}", crate::messages::cli::host_failed(alias, &e)),
920 }
921 if multi {
922 println!();
923 }
924 }
925
926 if !multi {
927 println!("\n{}", crate::messages::cli::DONE);
928 } else {
929 println!(
930 "{}",
931 crate::messages::cli::done_multi(&snip.name, aliases.len())
932 );
933 }
934 println!("\n{}", crate::messages::cli::PRESS_ENTER);
935 let _ = std::io::stdin().read_line(&mut String::new());
936 terminal.enter()?;
937 events.resume();
938 *last_config_check = std::time::Instant::now();
939 app.hosts_state.ssh_config = SshConfigFile::parse(&app.reload.config_path)?;
941 app.reload_hosts();
942 app.update_last_modified();
943 Ok(())
944}
945
946fn tui_teardown(app: &mut App, terminal: &mut tui::Tui) -> Result<()> {
949 app.flush_pending_vault_write();
950
951 if let Some(handle) = app.vault.cancel_signing_run() {
952 let _ = handle.join();
953 }
954
955 for (_, mut tunnel) in app.tunnels.active.drain() {
956 let _ = tunnel.child.kill();
957 let _ = tunnel.child.wait();
958 }
959
960 terminal.exit()?;
961 Ok(())
962}
963
964pub(crate) fn current_cert_mtime(alias: &str, app: &app::App) -> Option<std::time::SystemTime> {
965 let host = app.hosts_state.list.iter().find(|h| h.alias == alias)?;
966 let cert_path = vault_ssh::resolve_cert_path(alias, &host.certificate_file).ok()?;
967 std::fs::metadata(&cert_path)
968 .ok()
969 .and_then(|m| m.modified().ok())
970}
971
972pub(crate) fn cache_entry_is_stale<F>(
985 entry: Option<&(
986 std::time::Instant,
987 vault_ssh::CertStatus,
988 Option<std::time::SystemTime>,
989 )>,
990 current_mtime: Option<std::time::SystemTime>,
991 elapsed_secs: F,
992) -> bool
993where
994 F: FnOnce(std::time::Instant) -> u64,
995{
996 let Some((checked_at, status, cached_mtime)) = entry else {
997 return true;
998 };
999 if current_mtime != *cached_mtime {
1000 return true;
1001 }
1002 let ttl = if matches!(status, vault_ssh::CertStatus::Invalid(_)) {
1003 vault_ssh::CERT_ERROR_BACKOFF_SECS
1004 } else {
1005 vault_ssh::CERT_STATUS_CACHE_TTL_SECS
1006 };
1007 elapsed_secs(*checked_at) > ttl
1008}