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.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.is_cert_check_in_flight(&h.alias))
173 .filter(|h| !app.vault.has_cert(&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 =
424 cache_entry_is_stale(app.vault.cert_entry(&selected.alias), current_mtime, |t| {
425 t.elapsed().as_secs()
426 });
427
428 let sign_in_flight = app
429 .vault
430 .sign_in_flight()
431 .lock()
432 .map(|g| g.contains(&selected.alias))
433 .unwrap_or(false);
434 if cache_stale && !app.vault.is_cert_check_in_flight(&selected.alias) && !sign_in_flight
435 {
436 let alias = selected.alias.clone();
437 let cert_file = selected.certificate_file.clone();
438 app.vault.mark_cert_check_started(alias.clone());
439 let tx = events_tx.clone();
440 std::thread::spawn(move || {
441 let check_path = match vault_ssh::resolve_cert_path(&alias, &cert_file) {
442 Ok(p) => p,
443 Err(e) => {
444 let _ = tx.send(event::AppEvent::CertCheckError {
445 alias,
446 message: e.to_string(),
447 });
448 return;
449 }
450 };
451 let status = vault_ssh::check_cert_validity(&check_path);
452 let _ = tx.send(event::AppEvent::CertCheckResult { alias, status });
453 });
454 }
455 }
456 }
457}
458
459fn handle_pending_connect(
464 app: &mut App,
465 terminal: &mut tui::Tui,
466 events: &EventHandler,
467 last_config_check: &mut std::time::Instant,
468) -> Result<()> {
469 let Some((alias, host_askpass)) = app.ui.pending_connect.take() else {
470 return Ok(());
471 };
472 let vault_host = app
473 .hosts_state
474 .list
475 .iter()
476 .find(|h| h.alias == alias)
477 .cloned();
478 let askpass = host_askpass.or_else(preferences::load_askpass_default);
479 let has_active_tunnel = app.tunnels.active.contains_key(&alias);
480 let use_tmux = connection::is_in_tmux() && askpass.is_none();
481
482 if use_tmux {
483 let vault_msg = if vault_host.is_some() {
489 let msg = ensure_vault_ssh_chain_if_needed(
490 &alias,
491 &app.reload.config_path,
492 &app.providers.config,
493 &mut app.hosts_state.ssh_config,
494 );
495 if msg.is_some() {
496 app.reload_hosts();
497 for hop in vault_ssh::resolve_proxy_chain(&app.reload.config_path, &alias) {
498 app.refresh_cert_cache(&hop);
499 }
500 }
501 msg
502 } else {
503 None
504 };
505
506 match connection::connect_tmux_window(&alias, &app.reload.config_path, has_active_tunnel) {
507 Ok(()) => {
508 app.record_key_use(&alias, key_activity::now_secs());
509 if let Some((ref msg, is_error)) = vault_msg {
510 if is_error {
511 app.notify_error(msg.clone());
512 } else {
513 app.notify(msg.clone());
514 }
515 } else {
516 app.notify(crate::messages::opened_in_tmux(&alias));
517 }
518 }
519 Err(e) => {
520 app.notify_error(crate::messages::tmux_error(&e));
521 }
522 }
523 return Ok(());
524 }
525
526 events.pause();
532 terminal.exit()?;
533 let vault_msg = if vault_host.is_some() {
534 let msg = ensure_vault_ssh_chain_if_needed(
535 &alias,
536 &app.reload.config_path,
537 &app.providers.config,
538 &mut app.hosts_state.ssh_config,
539 );
540 if msg.is_some() {
541 app.reload_hosts();
542 for hop in vault_ssh::resolve_proxy_chain(&app.reload.config_path, &alias) {
543 app.refresh_cert_cache(&hop);
544 }
545 }
546 msg
547 } else {
548 None
549 };
550 ensure_proton_login(askpass.as_deref());
551 if let Some(token) = ensure_bw_session(app.bw_session.as_deref(), askpass.as_deref()) {
552 app.bw_session = Some(token);
553 }
554 ensure_keychain_password(&alias, askpass.as_deref());
555 print!("{}", crate::messages::cli::beaming_up(&alias));
556 let result = connection::connect(
557 &alias,
558 &app.reload.config_path,
559 askpass.as_deref(),
560 app.bw_session.as_deref(),
561 has_active_tunnel,
562 );
563 println!();
564 match &result {
565 Ok(cr) => {
566 let code = cr.status.code().unwrap_or(1);
567 if code != 255 {
568 app.history.record(&alias);
569 app.record_key_use(&alias, key_activity::now_secs());
570 app.hosts_state.render_cache.invalidate();
571 }
572 if code != 0 {
573 if let Some((hostname, known_hosts_path)) =
574 connection::parse_host_key_error(&cr.stderr_output)
575 {
576 app.screen = app::Screen::ConfirmHostKeyReset {
577 alias: alias.clone(),
578 hostname,
579 known_hosts_path,
580 askpass,
581 };
582 } else {
583 if let Some((ref vmsg, true)) = vault_msg {
589 app.notify_error(vmsg.clone());
590 }
591 let reason = connection::stderr_summary(&cr.stderr_output);
592 let msg = if let Some(reason) = reason {
593 crate::messages::ssh_failed_with_reason(&alias, &reason)
594 } else {
595 crate::messages::ssh_exited_with_code(&alias, code)
596 };
597 app.notify_error(msg);
598 }
599 } else if let Some((ref msg, is_error)) = vault_msg {
600 if is_error {
601 app.notify_error(msg.clone());
602 } else {
603 app.notify(msg.clone());
604 }
605 }
606 }
607 Err(e) => {
608 log::error!("[external] ssh connect failed: alias={alias}: {e}");
609 eprintln!("{}", crate::messages::connection_spawn_failed(&e));
610 app.notify_error(crate::messages::connection_failed(&alias));
611 }
612 }
613 askpass::cleanup_marker(&alias);
614 terminal.enter()?;
615 events.resume();
616 *last_config_check = std::time::Instant::now();
617 app.hosts_state.ssh_config = SshConfigFile::parse(&app.reload.config_path)?;
618 app.reload_hosts();
619 app.update_last_modified();
620 Ok(())
621}
622
623fn handle_pending_container_exec(
628 app: &mut App,
629 terminal: &mut tui::Tui,
630 events: &EventHandler,
631 last_config_check: &mut std::time::Instant,
632) -> Result<()> {
633 let Some(req) = app.container_state.pending_exec.take() else {
634 return Ok(());
635 };
636
637 if let Err(e) = crate::containers::validate_container_id(&req.container_id) {
643 log::warn!(
644 "[purple] container exec blocked on '{}': invalid container_id: {}",
645 req.alias,
646 e
647 );
648 app.notify(crate::messages::container_invalid_id(&e));
649 return Ok(());
650 }
651
652 let askpass = req.askpass.or_else(preferences::load_askpass_default);
653 let has_active_tunnel = app.tunnels.active.contains_key(&req.alias);
654 let use_tmux = connection::is_in_tmux() && askpass.is_none();
655
656 let remote_cmd = if let Some(ref user_cmd) = req.command {
657 let escaped = user_cmd.replace('\'', "'\\''");
662 format!(
663 "{} exec -it {} sh -c '{}'",
664 req.runtime.as_str(),
665 req.container_id,
666 escaped
667 )
668 } else {
669 format!(
670 "{} exec -it {} sh -c 'bash || sh'",
671 req.runtime.as_str(),
672 req.container_id
673 )
674 };
675
676 if use_tmux {
677 let label = format!("{}/{}", req.alias, req.container_name);
678 match connection::connect_tmux_window_with_remote_command(
679 &req.alias,
680 &app.reload.config_path,
681 has_active_tunnel,
682 &remote_cmd,
683 &label,
684 ) {
685 Ok(()) => {
686 app.record_key_use(&req.alias, key_activity::now_secs());
687 app.notify(crate::messages::container_exec_opened_in_tmux(
688 &req.container_name,
689 &req.alias,
690 ));
691 }
692 Err(e) => {
693 app.notify_error(crate::messages::tmux_error(&e));
694 }
695 }
696 return Ok(());
697 }
698
699 events.pause();
700 terminal.exit()?;
701
702 let result = connection::connect_with_remote_command(
703 &req.alias,
704 &app.reload.config_path,
705 askpass.as_deref(),
706 app.bw_session.as_deref(),
707 has_active_tunnel,
708 &remote_cmd,
709 );
710
711 match result {
712 Ok(cr) => {
713 let code = cr.status.code().unwrap_or(1);
714 if code != 255 {
720 app.history.record(&req.alias);
721 app.record_key_use(&req.alias, key_activity::now_secs());
722 app.hosts_state.render_cache.invalidate();
723 }
724 if code == 0 {
725 app.notify(crate::messages::container_exec_ended(&req.container_name));
726 } else if let Some((hostname, known_hosts_path)) =
727 connection::parse_host_key_error(&cr.stderr_output)
728 {
729 app.screen = app::Screen::ConfirmHostKeyReset {
733 alias: req.alias.clone(),
734 hostname,
735 known_hosts_path,
736 askpass: askpass.clone(),
737 };
738 } else {
739 let reason = connection::stderr_summary(&cr.stderr_output);
740 let msg = match reason {
741 Some(r) => {
742 crate::messages::container_exec_failed_with_reason(&req.container_name, &r)
743 }
744 None => {
745 crate::messages::container_exec_exited_with_code(&req.container_name, code)
746 }
747 };
748 app.notify_error(msg);
749 }
750 }
751 Err(e) => {
752 eprintln!("{}", crate::messages::connection_spawn_failed(&e));
753 app.notify_error(crate::messages::container_exec_spawn_failed(
754 &req.container_name,
755 ));
756 }
757 }
758 askpass::cleanup_marker(&req.alias);
759 terminal.enter()?;
760 events.resume();
761 *last_config_check = std::time::Instant::now();
762 Ok(())
763}
764
765fn handle_pending_container_logs(app: &mut App, events_tx: &std::sync::mpsc::Sender<AppEvent>) {
771 let Some(req) = app.container_state.pending_logs.take() else {
772 return;
773 };
774 let askpass = req.askpass.or_else(preferences::load_askpass_default);
775 let has_tunnel = app.tunnels.active.contains_key(&req.alias);
776 let ctx = crate::ssh_context::OwnedSshContext {
777 alias: req.alias,
778 config_path: app.reload.config_path.clone(),
779 askpass,
780 bw_session: app.bw_session.clone(),
781 has_tunnel,
782 };
783 let tx = events_tx.clone();
784 log::debug!(
785 "[purple] container_logs_fetch: spawning alias={} id={}",
786 ctx.alias,
787 req.container_id
788 );
789 crate::containers::spawn_container_logs_fetch(
790 ctx,
791 req.runtime,
792 req.container_id,
793 req.container_name,
794 crate::handler::container_logs::DEFAULT_TAIL,
795 move |alias, container_id, container_name, result| {
796 let _ = tx.send(AppEvent::ContainerLogsComplete {
797 alias,
798 container_id,
799 container_name,
800 result,
801 });
802 },
803 );
804}
805
806fn handle_pending_container_action(app: &mut App, events_tx: &std::sync::mpsc::Sender<AppEvent>) {
813 let Some(req) = app.container_state.pending_actions.pop_front() else {
817 return;
818 };
819 let askpass = req.askpass.or_else(preferences::load_askpass_default);
820 let has_tunnel = app.tunnels.active.contains_key(&req.alias);
821 let ctx = crate::ssh_context::OwnedSshContext {
822 alias: req.alias.clone(),
823 config_path: app.reload.config_path.clone(),
824 askpass,
825 bw_session: app.bw_session.clone(),
826 has_tunnel,
827 };
828 let tx = events_tx.clone();
829 log::info!(
830 "[purple] container_action_drain: spawning alias={} id={} action={:?} name={}",
831 req.alias,
832 req.container_id,
833 req.action,
834 req.container_name
835 );
836 crate::containers::spawn_container_action(
837 ctx,
838 req.runtime,
839 req.action,
840 req.container_id,
841 move |alias, action, result| {
842 let _ = tx.send(AppEvent::ContainerActionComplete {
843 alias,
844 action,
845 result,
846 });
847 },
848 );
849}
850
851fn handle_pending_snippet(
855 app: &mut App,
856 terminal: &mut tui::Tui,
857 events: &EventHandler,
858 last_config_check: &mut std::time::Instant,
859) -> Result<()> {
860 let Some((snip, aliases)) = app.snippets.pending.take() else {
861 return Ok(());
862 };
863 events.pause();
864 terminal.exit()?;
865
866 let multi = aliases.len() > 1;
867 for alias in &aliases {
868 let askpass = app
869 .hosts_state
870 .list
871 .iter()
872 .find(|h| h.alias == *alias)
873 .and_then(|h| h.askpass.clone())
874 .or_else(preferences::load_askpass_default);
875 ensure_proton_login(askpass.as_deref());
876 if let Some(token) = ensure_bw_session(app.bw_session.as_deref(), askpass.as_deref()) {
877 app.bw_session = Some(token);
878 }
879 ensure_keychain_password(alias, askpass.as_deref());
880
881 if multi {
882 println!("{}", crate::messages::cli::host_separator(alias));
883 } else {
884 print!(
885 "{}",
886 crate::messages::cli::running_snippet_on(&snip.name, alias)
887 );
888 }
889 let has_tunnel = app.tunnels.active.contains_key(alias);
890 match snippet::run_snippet(
891 alias,
892 &app.reload.config_path,
893 &snip.command,
894 askpass.as_deref(),
895 app.bw_session.as_deref(),
896 false,
897 has_tunnel,
898 ) {
899 Ok(r) => {
900 if r.status.success() {
901 app.history.record(alias);
902 app.record_key_use(alias, key_activity::now_secs());
903 app.hosts_state.render_cache.invalidate();
904 } else if multi {
905 eprintln!(
906 "{}",
907 crate::messages::cli::exited_with_code(r.status.code().unwrap_or(1))
908 );
909 } else {
910 println!(
911 "\n{}",
912 crate::messages::cli::exited_with_code(r.status.code().unwrap_or(1))
913 );
914 }
915 }
916 Err(e) => eprintln!("{}", crate::messages::cli::host_failed(alias, &e)),
917 }
918 if multi {
919 println!();
920 }
921 }
922
923 if !multi {
924 println!("\n{}", crate::messages::cli::DONE);
925 } else {
926 println!(
927 "{}",
928 crate::messages::cli::done_multi(&snip.name, aliases.len())
929 );
930 }
931 println!("\n{}", crate::messages::cli::PRESS_ENTER);
932 let _ = std::io::stdin().read_line(&mut String::new());
933 terminal.enter()?;
934 events.resume();
935 *last_config_check = std::time::Instant::now();
936 app.hosts_state.ssh_config = SshConfigFile::parse(&app.reload.config_path)?;
938 app.reload_hosts();
939 app.update_last_modified();
940 Ok(())
941}
942
943fn tui_teardown(app: &mut App, terminal: &mut tui::Tui) -> Result<()> {
946 app.flush_pending_vault_write();
947
948 if let Some(handle) = app.vault.cancel_signing_run() {
949 let _ = handle.join();
950 }
951
952 for (_, mut tunnel) in app.tunnels.active.drain() {
953 let _ = tunnel.child.kill();
954 let _ = tunnel.child.wait();
955 }
956
957 terminal.exit()?;
958 Ok(())
959}
960
961pub(crate) fn current_cert_mtime(alias: &str, app: &app::App) -> Option<std::time::SystemTime> {
962 let host = app.hosts_state.list.iter().find(|h| h.alias == alias)?;
963 let cert_path = vault_ssh::resolve_cert_path(alias, &host.certificate_file).ok()?;
964 std::fs::metadata(&cert_path)
965 .ok()
966 .and_then(|m| m.modified().ok())
967}
968
969pub(crate) fn cache_entry_is_stale<F>(
982 entry: Option<&(
983 std::time::Instant,
984 vault_ssh::CertStatus,
985 Option<std::time::SystemTime>,
986 )>,
987 current_mtime: Option<std::time::SystemTime>,
988 elapsed_secs: F,
989) -> bool
990where
991 F: FnOnce(std::time::Instant) -> u64,
992{
993 let Some((checked_at, status, cached_mtime)) = entry else {
994 return true;
995 };
996 if current_mtime != *cached_mtime {
997 return true;
998 }
999 let ttl = if matches!(status, vault_ssh::CertStatus::Invalid(_)) {
1000 vault_ssh::CERT_ERROR_BACKOFF_SECS
1001 } else {
1002 vault_ssh::CERT_STATUS_CACHE_TTL_SECS
1003 };
1004 elapsed_secs(*checked_at) > ttl
1005}