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