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