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.is_signing();
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.has_pending_fetches() {
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 .insert_status(h.alias.clone(), app::PingStatus::Skipped);
147 }
148 }
149 if !hosts_to_ping.is_empty() {
150 for (alias, _, _) in &hosts_to_ping {
151 app.ping
152 .insert_status(alias.clone(), app::PingStatus::Checking);
153 }
154 ping::ping_all(&hosts_to_ping, events_tx.clone(), app.ping.generation());
155 }
156 }
157
158 update::spawn_version_check(events_tx.clone());
159
160 let vault_aliases: Vec<(String, String)> = app
166 .hosts_state
167 .list
168 .iter()
169 .filter(|h| vault_ssh::has_purple_vault_context(h))
170 .filter(|h| !app.vault.is_cert_check_in_flight(&h.alias))
171 .filter(|h| !app.vault.has_cert(&h.alias))
172 .map(|h| (h.alias.clone(), h.certificate_file.clone()))
173 .collect();
174 for (alias, cert_file) in vault_aliases {
175 app.vault.mark_cert_check_started(alias.clone());
176 let tx = events_tx.clone();
177 std::thread::spawn(move || {
178 let check_path = match vault_ssh::resolve_cert_path(&alias, &cert_file) {
179 Ok(p) => p,
180 Err(e) => {
181 let _ = tx.send(event::AppEvent::CertCheckError {
182 alias,
183 message: e.to_string(),
184 });
185 return;
186 }
187 };
188 let status = vault_ssh::check_cert_validity(&check_path);
189 let _ = tx.send(event::AppEvent::CertCheckResult { alias, status });
190 });
191 }
192}
193
194#[allow(clippy::too_many_arguments)]
197fn dispatch_event(
198 app: &mut App,
199 event: Option<AppEvent>,
200 anim: &mut animation::AnimationState,
201 vault_signing: bool,
202 events_tx: &std::sync::mpsc::Sender<AppEvent>,
203 terminal: &mut tui::Tui,
204 last_config_check: &mut std::time::Instant,
205) -> Result<std::ops::ControlFlow<()>> {
206 match event {
207 Some(AppEvent::Key(key)) => {
208 handler::handle_key_event(app, key, events_tx)?;
209 }
210 Some(AppEvent::Tick) | None => {
211 handler::event_loop::handle_tick(app, anim, vault_signing, last_config_check);
212 }
213 Some(AppEvent::PingResult {
214 alias,
215 rtt_ms,
216 generation,
217 }) => {
218 handler::event_loop::handle_ping_result(app, alias, rtt_ms, generation);
219 }
220 Some(AppEvent::SyncProgress { provider, message }) => {
221 handler::event_loop::handle_sync_progress(app, provider, message);
222 }
223 Some(AppEvent::SyncComplete { provider, hosts }) => {
224 handler::event_loop::handle_sync_complete(app, provider, hosts, last_config_check);
225 }
226 Some(AppEvent::SyncPartial {
227 provider,
228 hosts,
229 failures,
230 total,
231 }) => {
232 handler::event_loop::handle_sync_partial(
233 app,
234 provider,
235 hosts,
236 failures,
237 total,
238 last_config_check,
239 );
240 }
241 Some(AppEvent::SyncError { provider, message }) => {
242 handler::event_loop::handle_sync_error(app, provider, message, last_config_check);
243 }
244 Some(AppEvent::UpdateAvailable { version, headline }) => {
245 handler::event_loop::handle_update_available(app, version, headline);
246 }
247 Some(AppEvent::FileBrowserListing {
248 alias,
249 path,
250 entries,
251 }) => {
252 handler::event_loop::handle_file_browser_listing(app, alias, path, entries, terminal);
253 }
254 Some(AppEvent::ScpComplete {
255 alias,
256 success,
257 message,
258 }) => {
259 handler::event_loop::handle_scp_complete(
260 app, alias, success, message, events_tx, terminal,
261 );
262 }
263 Some(AppEvent::SnippetHostDone {
264 run_id,
265 alias,
266 stdout,
267 stderr,
268 exit_code,
269 }) => {
270 handler::event_loop::handle_snippet_host_done(
271 app, run_id, alias, stdout, stderr, exit_code,
272 );
273 }
274 Some(AppEvent::SnippetProgress {
275 run_id,
276 completed,
277 total,
278 }) => {
279 handler::event_loop::handle_snippet_progress(app, run_id, completed, total);
280 }
281 Some(AppEvent::SnippetAllDone { run_id }) => {
282 handler::event_loop::handle_snippet_all_done(app, run_id);
283 }
284 Some(AppEvent::KeyPushResult { run_id, result }) => {
285 handler::event_loop::handle_key_push_result(app, run_id, result);
286 }
287 Some(AppEvent::ContainerListing { alias, result }) => {
288 handler::event_loop::handle_container_listing(app, alias, result, events_tx);
289 }
290 Some(AppEvent::ContainerActionComplete {
291 alias,
292 action,
293 result,
294 }) => {
295 handler::event_loop::handle_container_action_complete(
296 app, alias, action, result, events_tx,
297 );
298 }
299 Some(AppEvent::ContainerLogsComplete {
300 alias,
301 container_id,
302 container_name,
303 result,
304 }) => {
305 handler::event_loop::handle_container_logs_complete(
306 app,
307 alias,
308 container_id,
309 container_name,
310 result,
311 );
312 }
313 Some(AppEvent::ContainerInspectComplete {
314 alias,
315 container_id,
316 result,
317 }) => {
318 handler::event_loop::handle_container_inspect_complete(
319 app,
320 alias,
321 container_id,
322 *result,
323 );
324 }
325 Some(AppEvent::ContainerLogsTailComplete {
326 alias,
327 container_id,
328 result,
329 }) => {
330 handler::event_loop::handle_container_logs_tail_complete(
331 app,
332 alias,
333 container_id,
334 *result,
335 );
336 }
337 Some(AppEvent::VaultSignResult {
338 alias,
339 certificate_file: existing_cert_file,
340 success,
341 message,
342 }) => {
343 handler::event_loop::handle_vault_sign_result(
344 app,
345 alias,
346 existing_cert_file,
347 success,
348 message,
349 );
350 }
351 Some(AppEvent::VaultSignProgress { alias, done, total }) => {
352 handler::event_loop::handle_vault_sign_progress(
353 app,
354 alias,
355 done,
356 total,
357 anim.spinner_tick,
358 );
359 }
360 Some(AppEvent::VaultSignAllDone {
361 signed,
362 failed,
363 skipped,
364 cancelled,
365 aborted_message,
366 first_error,
367 }) => {
368 if handler::event_loop::handle_vault_sign_all_done(
369 app,
370 signed,
371 failed,
372 skipped,
373 cancelled,
374 aborted_message,
375 first_error,
376 )
377 .is_break()
378 {
379 return Ok(std::ops::ControlFlow::Break(()));
380 }
381 }
382 Some(AppEvent::CertCheckResult { alias, status }) => {
383 handler::event_loop::handle_cert_check_result(app, alias, status);
384 }
385 Some(AppEvent::CertCheckError { alias, message }) => {
386 handler::event_loop::handle_cert_check_error(app, alias, message);
387 }
388 Some(AppEvent::PollError) => {
389 app.running = false;
390 }
391 }
392 Ok(std::ops::ControlFlow::Continue(()))
393}
394
395fn lazy_cert_check(app: &mut App, events_tx: &std::sync::mpsc::Sender<AppEvent>) {
398 if let Some(selected) = app.selected_host() {
399 let has_vault_role = vault_ssh::resolve_vault_role(
400 selected.vault_ssh.as_deref(),
401 selected.provider.as_deref(),
402 selected.provider_label.as_deref(),
403 &app.providers.config,
404 )
405 .is_some();
406 let has_purple_cert_file = vault_ssh::cert_file_in_purple_dir(&selected.certificate_file);
411 if has_vault_role || has_purple_cert_file {
412 let current_mtime =
417 vault_ssh::resolve_cert_path(&selected.alias, &selected.certificate_file)
418 .ok()
419 .and_then(|p| std::fs::metadata(&p).ok())
420 .and_then(|m| m.modified().ok());
421 let cache_stale =
422 cache_entry_is_stale(app.vault.cert_entry(&selected.alias), current_mtime, |t| {
423 t.elapsed().as_secs()
424 });
425
426 let sign_in_flight = app
427 .vault
428 .sign_in_flight()
429 .lock()
430 .map(|g| g.contains(&selected.alias))
431 .unwrap_or(false);
432 if cache_stale && !app.vault.is_cert_check_in_flight(&selected.alias) && !sign_in_flight
433 {
434 let alias = selected.alias.clone();
435 let cert_file = selected.certificate_file.clone();
436 app.vault.mark_cert_check_started(alias.clone());
437 let tx = events_tx.clone();
438 std::thread::spawn(move || {
439 let check_path = match vault_ssh::resolve_cert_path(&alias, &cert_file) {
440 Ok(p) => p,
441 Err(e) => {
442 let _ = tx.send(event::AppEvent::CertCheckError {
443 alias,
444 message: e.to_string(),
445 });
446 return;
447 }
448 };
449 let status = vault_ssh::check_cert_validity(&check_path);
450 let _ = tx.send(event::AppEvent::CertCheckResult { alias, status });
451 });
452 }
453 }
454 }
455}
456
457fn handle_pending_connect(
462 app: &mut App,
463 terminal: &mut tui::Tui,
464 events: &EventHandler,
465 last_config_check: &mut std::time::Instant,
466) -> Result<()> {
467 let Some((alias, host_askpass)) = app.ui.pending_connect.take() else {
468 return Ok(());
469 };
470 let vault_host = app
471 .hosts_state
472 .list
473 .iter()
474 .find(|h| h.alias == alias)
475 .cloned();
476 let askpass = host_askpass.or_else(preferences::load_askpass_default);
477 let has_active_tunnel = app.tunnels.active_contains(&alias);
478 let use_tmux = connection::is_in_tmux() && askpass.is_none();
479
480 if use_tmux {
481 let vault_msg = if vault_host.is_some() {
487 let msg = ensure_vault_ssh_chain_if_needed(
488 &alias,
489 app.reload.config_path(),
490 &app.providers.config,
491 &mut app.hosts_state.ssh_config,
492 );
493 if msg.is_some() {
494 app.reload_hosts();
495 for hop in vault_ssh::resolve_proxy_chain(app.reload.config_path(), &alias) {
496 app.refresh_cert_cache(&hop);
497 }
498 }
499 msg
500 } else {
501 None
502 };
503
504 match connection::connect_tmux_window(&alias, app.reload.config_path(), has_active_tunnel) {
505 Ok(()) => {
506 app.record_key_use(&alias, key_activity::now_secs());
507 if let Some((ref msg, is_error)) = vault_msg {
508 if is_error {
509 app.notify_error(msg.clone());
510 } else {
511 app.notify(msg.clone());
512 }
513 } else {
514 app.notify(crate::messages::opened_in_tmux(&alias));
515 }
516 }
517 Err(e) => {
518 app.notify_error(crate::messages::tmux_error(&e));
519 }
520 }
521 return Ok(());
522 }
523
524 events.pause();
530 terminal.exit()?;
531 let vault_msg = if vault_host.is_some() {
532 let msg = ensure_vault_ssh_chain_if_needed(
533 &alias,
534 app.reload.config_path(),
535 &app.providers.config,
536 &mut app.hosts_state.ssh_config,
537 );
538 if msg.is_some() {
539 app.reload_hosts();
540 for hop in vault_ssh::resolve_proxy_chain(app.reload.config_path(), &alias) {
541 app.refresh_cert_cache(&hop);
542 }
543 }
544 msg
545 } else {
546 None
547 };
548 ensure_proton_login(askpass.as_deref());
549 if let Some(token) = ensure_bw_session(app.bw_session.as_deref(), askpass.as_deref()) {
550 app.bw_session = Some(token);
551 }
552 ensure_keychain_password(&alias, askpass.as_deref());
553 print!("{}", crate::messages::cli::beaming_up(&alias));
554 let result = connection::connect(
555 &alias,
556 app.reload.config_path(),
557 askpass.as_deref(),
558 app.bw_session.as_deref(),
559 has_active_tunnel,
560 );
561 println!();
562 match &result {
563 Ok(cr) => {
564 let code = cr.status.code().unwrap_or(1);
565 if code != 255 {
566 app.history.record(&alias);
567 app.record_key_use(&alias, key_activity::now_secs());
568 app.hosts_state.render_cache.invalidate();
569 }
570 if code != 0 {
571 if let Some((hostname, known_hosts_path)) =
572 connection::parse_host_key_error(&cr.stderr_output)
573 {
574 app.screen = app::Screen::ConfirmHostKeyReset {
575 alias: alias.clone(),
576 hostname,
577 known_hosts_path,
578 askpass,
579 };
580 } else {
581 if let Some((ref vmsg, true)) = vault_msg {
587 app.notify_error(vmsg.clone());
588 }
589 let reason = connection::stderr_summary(&cr.stderr_output);
590 let msg = if let Some(reason) = reason {
591 crate::messages::ssh_failed_with_reason(&alias, &reason)
592 } else {
593 crate::messages::ssh_exited_with_code(&alias, code)
594 };
595 app.notify_error(msg);
596 }
597 } else if let Some((ref msg, is_error)) = vault_msg {
598 if is_error {
599 app.notify_error(msg.clone());
600 } else {
601 app.notify(msg.clone());
602 }
603 }
604 }
605 Err(e) => {
606 log::error!("[external] ssh connect failed: alias={alias}: {e}");
607 eprintln!("{}", crate::messages::connection_spawn_failed(&e));
608 app.notify_error(crate::messages::connection_failed(&alias));
609 }
610 }
611 askpass::cleanup_marker(&alias);
612 terminal.enter()?;
613 events.resume();
614 *last_config_check = std::time::Instant::now();
615 app.hosts_state.ssh_config = SshConfigFile::parse(app.reload.config_path())?;
616 app.reload_hosts();
617 app.update_last_modified();
618 Ok(())
619}
620
621fn handle_pending_container_exec(
626 app: &mut App,
627 terminal: &mut tui::Tui,
628 events: &EventHandler,
629 last_config_check: &mut std::time::Instant,
630) -> Result<()> {
631 let Some(req) = app.container_state.take_pending_exec() else {
632 return Ok(());
633 };
634
635 if let Err(e) = crate::containers::validate_container_id(&req.container_id) {
641 log::warn!(
642 "[purple] container exec blocked on '{}': invalid container_id: {}",
643 req.alias,
644 e
645 );
646 app.notify(crate::messages::container_invalid_id(&e));
647 return Ok(());
648 }
649
650 let askpass = req.askpass.or_else(preferences::load_askpass_default);
651 let has_active_tunnel = app.tunnels.active_contains(&req.alias);
652 let use_tmux = connection::is_in_tmux() && askpass.is_none();
653
654 let remote_cmd = if let Some(ref user_cmd) = req.command {
655 let escaped = user_cmd.replace('\'', "'\\''");
660 format!(
661 "{} exec -it {} sh -c '{}'",
662 req.runtime.as_str(),
663 req.container_id,
664 escaped
665 )
666 } else {
667 format!(
668 "{} exec -it {} sh -c 'bash || sh'",
669 req.runtime.as_str(),
670 req.container_id
671 )
672 };
673
674 if use_tmux {
675 let label = format!("{}/{}", req.alias, req.container_name);
676 match connection::connect_tmux_window_with_remote_command(
677 &req.alias,
678 app.reload.config_path(),
679 has_active_tunnel,
680 &remote_cmd,
681 &label,
682 ) {
683 Ok(()) => {
684 app.record_key_use(&req.alias, key_activity::now_secs());
685 app.notify(crate::messages::container_exec_opened_in_tmux(
686 &req.container_name,
687 &req.alias,
688 ));
689 }
690 Err(e) => {
691 app.notify_error(crate::messages::tmux_error(&e));
692 }
693 }
694 return Ok(());
695 }
696
697 events.pause();
698 terminal.exit()?;
699
700 let result = connection::connect_with_remote_command(
701 &req.alias,
702 app.reload.config_path(),
703 askpass.as_deref(),
704 app.bw_session.as_deref(),
705 has_active_tunnel,
706 &remote_cmd,
707 );
708
709 match result {
710 Ok(cr) => {
711 let code = cr.status.code().unwrap_or(1);
712 if code != 255 {
718 app.history.record(&req.alias);
719 app.record_key_use(&req.alias, key_activity::now_secs());
720 app.hosts_state.render_cache.invalidate();
721 }
722 if code == 0 {
723 app.notify(crate::messages::container_exec_ended(&req.container_name));
724 } else if let Some((hostname, known_hosts_path)) =
725 connection::parse_host_key_error(&cr.stderr_output)
726 {
727 app.screen = app::Screen::ConfirmHostKeyReset {
731 alias: req.alias.clone(),
732 hostname,
733 known_hosts_path,
734 askpass: askpass.clone(),
735 };
736 } else {
737 let reason = connection::stderr_summary(&cr.stderr_output);
738 let msg = match reason {
739 Some(r) => {
740 crate::messages::container_exec_failed_with_reason(&req.container_name, &r)
741 }
742 None => {
743 crate::messages::container_exec_exited_with_code(&req.container_name, code)
744 }
745 };
746 app.notify_error(msg);
747 }
748 }
749 Err(e) => {
750 eprintln!("{}", crate::messages::connection_spawn_failed(&e));
751 app.notify_error(crate::messages::container_exec_spawn_failed(
752 &req.container_name,
753 ));
754 }
755 }
756 askpass::cleanup_marker(&req.alias);
757 terminal.enter()?;
758 events.resume();
759 *last_config_check = std::time::Instant::now();
760 Ok(())
761}
762
763fn handle_pending_container_logs(app: &mut App, events_tx: &std::sync::mpsc::Sender<AppEvent>) {
769 let Some(req) = app.container_state.take_pending_logs() else {
770 return;
771 };
772 let askpass = req.askpass.or_else(preferences::load_askpass_default);
773 let has_tunnel = app.tunnels.active_contains(&req.alias);
774 let ctx = crate::ssh_context::OwnedSshContext {
775 alias: req.alias,
776 config_path: app.reload.config_path().to_path_buf(),
777 askpass,
778 bw_session: app.bw_session.clone(),
779 has_tunnel,
780 };
781 let tx = events_tx.clone();
782 log::debug!(
783 "[purple] container_logs_fetch: spawning alias={} id={}",
784 ctx.alias,
785 req.container_id
786 );
787 crate::containers::spawn_container_logs_fetch(
788 ctx,
789 req.runtime,
790 req.container_id,
791 req.container_name,
792 crate::handler::container_logs::DEFAULT_TAIL,
793 move |alias, container_id, container_name, result| {
794 let _ = tx.send(AppEvent::ContainerLogsComplete {
795 alias,
796 container_id,
797 container_name,
798 result,
799 });
800 },
801 );
802}
803
804fn handle_pending_container_action(app: &mut App, events_tx: &std::sync::mpsc::Sender<AppEvent>) {
811 let Some(req) = app.container_state.pop_next_action() else {
815 return;
816 };
817 let askpass = req.askpass.or_else(preferences::load_askpass_default);
818 let has_tunnel = app.tunnels.active_contains(&req.alias);
819 let ctx = crate::ssh_context::OwnedSshContext {
820 alias: req.alias.clone(),
821 config_path: app.reload.config_path().to_path_buf(),
822 askpass,
823 bw_session: app.bw_session.clone(),
824 has_tunnel,
825 };
826 let tx = events_tx.clone();
827 log::info!(
828 "[purple] container_action_drain: spawning alias={} id={} action={:?} name={}",
829 req.alias,
830 req.container_id,
831 req.action,
832 req.container_name
833 );
834 crate::containers::spawn_container_action(
835 ctx,
836 req.runtime,
837 req.action,
838 req.container_id,
839 move |alias, action, result| {
840 let _ = tx.send(AppEvent::ContainerActionComplete {
841 alias,
842 action,
843 result,
844 });
845 },
846 );
847}
848
849fn handle_pending_snippet(
853 app: &mut App,
854 terminal: &mut tui::Tui,
855 events: &EventHandler,
856 last_config_check: &mut std::time::Instant,
857) -> Result<()> {
858 let Some((snip, aliases)) = app.snippets.take_pending() else {
859 return Ok(());
860 };
861 events.pause();
862 terminal.exit()?;
863
864 let multi = aliases.len() > 1;
865 for alias in &aliases {
866 let askpass = app
867 .hosts_state
868 .list
869 .iter()
870 .find(|h| h.alias == *alias)
871 .and_then(|h| h.askpass.clone())
872 .or_else(preferences::load_askpass_default);
873 ensure_proton_login(askpass.as_deref());
874 if let Some(token) = ensure_bw_session(app.bw_session.as_deref(), askpass.as_deref()) {
875 app.bw_session = Some(token);
876 }
877 ensure_keychain_password(alias, askpass.as_deref());
878
879 if multi {
880 println!("{}", crate::messages::cli::host_separator(alias));
881 } else {
882 print!(
883 "{}",
884 crate::messages::cli::running_snippet_on(&snip.name, alias)
885 );
886 }
887 let has_tunnel = app.tunnels.active_contains(alias);
888 match snippet::run_snippet(
889 alias,
890 app.reload.config_path(),
891 &snip.command,
892 askpass.as_deref(),
893 app.bw_session.as_deref(),
894 false,
895 has_tunnel,
896 ) {
897 Ok(r) => {
898 if r.status.success() {
899 app.history.record(alias);
900 app.record_key_use(alias, key_activity::now_secs());
901 app.hosts_state.render_cache.invalidate();
902 } else if multi {
903 eprintln!(
904 "{}",
905 crate::messages::cli::exited_with_code(r.status.code().unwrap_or(1))
906 );
907 } else {
908 println!(
909 "\n{}",
910 crate::messages::cli::exited_with_code(r.status.code().unwrap_or(1))
911 );
912 }
913 }
914 Err(e) => eprintln!("{}", crate::messages::cli::host_failed(alias, &e)),
915 }
916 if multi {
917 println!();
918 }
919 }
920
921 if !multi {
922 println!("\n{}", crate::messages::cli::DONE);
923 } else {
924 println!(
925 "{}",
926 crate::messages::cli::done_multi(&snip.name, aliases.len())
927 );
928 }
929 println!("\n{}", crate::messages::cli::PRESS_ENTER);
930 let _ = std::io::stdin().read_line(&mut String::new());
931 terminal.enter()?;
932 events.resume();
933 *last_config_check = std::time::Instant::now();
934 app.hosts_state.ssh_config = SshConfigFile::parse(app.reload.config_path())?;
936 app.reload_hosts();
937 app.update_last_modified();
938 Ok(())
939}
940
941fn tui_teardown(app: &mut App, terminal: &mut tui::Tui) -> Result<()> {
944 app.flush_pending_vault_write();
945
946 if let Some(handle) = app.vault.cancel_signing_run() {
947 let _ = handle.join();
948 }
949
950 for (_, mut tunnel) in app.tunnels.drain_active() {
951 let _ = tunnel.child.kill();
952 let _ = tunnel.child.wait();
953 }
954
955 terminal.exit()?;
956 Ok(())
957}
958
959pub(crate) fn current_cert_mtime(alias: &str, app: &app::App) -> Option<std::time::SystemTime> {
960 let host = app.hosts_state.list.iter().find(|h| h.alias == alias)?;
961 let cert_path = vault_ssh::resolve_cert_path(alias, &host.certificate_file).ok()?;
962 std::fs::metadata(&cert_path)
963 .ok()
964 .and_then(|m| m.modified().ok())
965}
966
967pub(crate) fn cache_entry_is_stale<F>(
980 entry: Option<&(
981 std::time::Instant,
982 vault_ssh::CertStatus,
983 Option<std::time::SystemTime>,
984 )>,
985 current_mtime: Option<std::time::SystemTime>,
986 elapsed_secs: F,
987) -> bool
988where
989 F: FnOnce(std::time::Instant) -> u64,
990{
991 let Some((checked_at, status, cached_mtime)) = entry else {
992 return true;
993 };
994 if current_mtime != *cached_mtime {
995 return true;
996 }
997 let ttl = if matches!(status, vault_ssh::CertStatus::Invalid(_)) {
998 vault_ssh::CERT_ERROR_BACKOFF_SECS
999 } else {
1000 vault_ssh::CERT_STATUS_CACHE_TTL_SECS
1001 };
1002 elapsed_secs(*checked_at) > ttl
1003}