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