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 purple_dir = paths.purple_dir();
33 if let Some(has_backup) = first_launch_init(&purple_dir, app.reload.config_path()) {
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
50 let mut terminal = tui::Tui::new()?;
51 terminal.enter()?;
52 let events = EventHandler::new(50);
53 let events_tx = events.sender();
54 let mut last_config_check = std::time::Instant::now();
55
56 if !app.demo_mode {
58 spawn_startup_tasks(&mut app, &events_tx);
59 }
60
61 let mut anim = animation::AnimationState::new();
62
63 while app.running {
64 anim.detect_transitions(&mut app);
65 terminal.draw(&mut app, &mut anim)?;
66
67 let vault_signing = app.vault.is_signing();
71 let provider_syncing = !app.providers.syncing().is_empty();
72 let tunnels_anim_tick =
77 matches!(app.top_page, app::TopPage::Tunnels) && !app.tunnels.active().is_empty();
78 let event = if anim.is_animating(&app) || tunnels_anim_tick {
79 events.next_timeout(std::time::Duration::from_millis(16))?
80 } else if anim.has_checking_hosts(&app)
81 || vault_signing
82 || provider_syncing
83 || anim.has_reachable_hosts(&app)
84 {
85 events.next_timeout(std::time::Duration::from_millis(60))?
86 } else {
87 Some(events.next()?)
88 };
89
90 if dispatch_event(
91 &mut app,
92 event,
93 &mut anim,
94 vault_signing,
95 &events_tx,
96 &mut terminal,
97 &mut last_config_check,
98 )?
99 .is_break()
100 {
101 continue;
102 }
103
104 lazy_cert_check(&mut app, &events_tx);
105
106 handle_pending_connect(&mut app, &mut terminal, &events, &mut last_config_check)?;
107 handle_pending_container_exec(&mut app, &mut terminal, &events, &mut last_config_check)?;
108 handle_pending_container_logs(&mut app, &events_tx);
109 handle_pending_container_action(&mut app, &events_tx);
110 if app.container_state.has_pending_fetches() {
115 handler::containers_overview::auto_fetch_new_hosts(&mut app, &events_tx);
116 }
117 handle_pending_snippet(&mut app, &mut terminal, &events, &mut last_config_check)?;
118 }
119
120 tui_teardown(&mut app, &mut terminal)
121}
122
123fn spawn_startup_tasks(app: &mut App, events_tx: &std::sync::mpsc::Sender<AppEvent>) {
125 for section in app.providers.config().configured_providers().to_vec() {
126 if !section.auto_sync {
127 continue;
128 }
129 let key = section.id.to_string();
130 if !app.providers.syncing().contains_key(&key) {
131 app.providers.reset_batch_if_idle();
132 let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
133 app.providers.syncing_mut().insert(key, cancel.clone());
134 app.providers.bump_batch_total();
135 handler::spawn_provider_sync(
136 §ion,
137 events_tx.clone(),
138 cancel,
139 std::sync::Arc::clone(&app.env),
140 );
141 crate::set_sync_summary(app);
142 }
143 }
144
145 if app.ping.auto_ping() {
146 let hosts_to_ping: Vec<(String, String, u16)> = app
147 .hosts_state
148 .list()
149 .iter()
150 .filter(|h| !h.hostname.is_empty() && h.proxy_jump.is_empty())
151 .map(|h| (h.alias.clone(), h.hostname.clone(), h.port))
152 .collect();
153 for h in app.hosts_state.list() {
154 if !h.proxy_jump.is_empty() {
155 app.ping
156 .insert_status(h.alias.clone(), app::PingStatus::Skipped);
157 }
158 }
159 if !hosts_to_ping.is_empty() {
160 for (alias, _, _) in &hosts_to_ping {
161 app.ping
162 .insert_status(alias.clone(), app::PingStatus::Checking);
163 }
164 ping::ping_all(&hosts_to_ping, events_tx.clone(), app.ping.generation());
165 }
166 }
167
168 update::spawn_version_check(events_tx.clone(), std::sync::Arc::clone(&app.env));
169
170 let vault_aliases: Vec<(String, String)> = app
176 .hosts_state
177 .list()
178 .iter()
179 .filter(|h| vault_ssh::has_purple_vault_context(h))
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 = vault_ssh::cert_file_in_purple_dir(&s.certificate_file);
423 (s.alias.clone(), s.certificate_file.clone(), role, cert_file)
424 })
425 else {
426 return;
427 };
428 if !(has_vault_role || has_purple_cert_file) {
429 return;
430 }
431
432 let now = std::time::Instant::now();
438 let recently_stat = app
439 .vault
440 .last_cert_stat(&alias)
441 .is_some_and(|t| now.duration_since(t) < CERT_STAT_THROTTLE);
442 let current_mtime = if recently_stat {
443 app.vault
444 .cert_entry(&alias)
445 .and_then(|(_, _, mtime)| *mtime)
446 } else {
447 let m = vault_ssh::resolve_cert_path(app.env().paths(), &alias, &certificate_file)
448 .ok()
449 .and_then(|p| std::fs::metadata(&p).ok())
450 .and_then(|m| m.modified().ok());
451 app.vault.note_cert_stat(alias.clone(), now);
452 m
453 };
454 let cache_stale = cache_entry_is_stale(app.vault.cert_entry(&alias), current_mtime, |t| {
455 t.elapsed().as_secs()
456 });
457
458 let sign_in_flight = app
459 .vault
460 .sign_in_flight()
461 .lock()
462 .map(|g| g.contains(&alias))
463 .unwrap_or(false);
464 if cache_stale && !app.vault.is_cert_check_in_flight(&alias) && !sign_in_flight {
465 app.vault.mark_cert_check_started(alias.clone());
466 let tx = events_tx.clone();
467 let env = std::sync::Arc::clone(&app.env);
468 std::thread::spawn(move || {
469 let check_path =
470 match vault_ssh::resolve_cert_path(env.paths(), &alias, &certificate_file) {
471 Ok(p) => p,
472 Err(e) => {
473 let _ = tx.send(event::AppEvent::CertCheckError {
474 alias,
475 message: e.to_string(),
476 });
477 return;
478 }
479 };
480 let status = vault_ssh::check_cert_validity(&env, &check_path);
481 let _ = tx.send(event::AppEvent::CertCheckResult { alias, status });
482 });
483 }
484}
485
486fn handle_pending_connect(
491 app: &mut App,
492 terminal: &mut tui::Tui,
493 events: &EventHandler,
494 last_config_check: &mut std::time::Instant,
495) -> Result<()> {
496 let Some((alias, host_askpass)) = app.ui.take_pending_connect() else {
497 return Ok(());
498 };
499 let vault_host = app
500 .hosts_state
501 .list()
502 .iter()
503 .find(|h| h.alias == alias)
504 .cloned();
505 let askpass = host_askpass.or_else(|| preferences::load_askpass_default(app.env().paths()));
506 let has_active_tunnel = app.tunnels.active_contains(&alias);
507 let use_tmux = connection::is_in_tmux(app.env()) && askpass.is_none();
508
509 if use_tmux {
510 let vault_msg = if vault_host.is_some() {
516 let env = std::sync::Arc::clone(&app.env);
517 let msg = ensure_vault_ssh_chain_if_needed(
518 &env,
519 &alias,
520 app.reload.config_path(),
521 app.providers.config(),
522 app.hosts_state.ssh_config_mut(),
523 );
524 if msg.is_some() {
525 app.reload_hosts();
526 for hop in vault_ssh::resolve_proxy_chain(app.reload.config_path(), &alias) {
527 app.refresh_cert_cache(&hop);
528 }
529 }
530 msg
531 } else {
532 None
533 };
534
535 match connection::connect_tmux_window(&alias, app.reload.config_path(), has_active_tunnel) {
536 Ok(()) => {
537 app.record_key_use(&alias, key_activity::now_secs());
538 if let Some((ref msg, is_error)) = vault_msg {
539 if is_error {
540 app.notify_error(msg.clone());
541 } else {
542 app.notify(msg.clone());
543 }
544 } else {
545 app.notify(crate::messages::opened_in_tmux(&alias));
546 }
547 }
548 Err(e) => {
549 app.notify_error(crate::messages::tmux_error(&e));
550 }
551 }
552 return Ok(());
553 }
554
555 events.pause();
561 terminal.exit()?;
562 let vault_msg = if vault_host.is_some() {
563 let env = std::sync::Arc::clone(&app.env);
564 let msg = ensure_vault_ssh_chain_if_needed(
565 &env,
566 &alias,
567 app.reload.config_path(),
568 app.providers.config(),
569 app.hosts_state.ssh_config_mut(),
570 );
571 if msg.is_some() {
572 app.reload_hosts();
573 for hop in vault_ssh::resolve_proxy_chain(app.reload.config_path(), &alias) {
574 app.refresh_cert_cache(&hop);
575 }
576 }
577 msg
578 } else {
579 None
580 };
581 let env = std::sync::Arc::clone(&app.env);
582 ensure_proton_login(&env, askpass.as_deref());
583 if let Some(token) = ensure_bw_session(&env, app.bw_session.as_deref(), askpass.as_deref()) {
584 app.bw_session = Some(token);
585 }
586 ensure_keychain_password(&env, &alias, askpass.as_deref());
587 print!("{}", crate::messages::cli::beaming_up(&alias));
588 let result = connection::connect(
589 &alias,
590 app.reload.config_path(),
591 askpass.as_deref(),
592 app.bw_session.as_deref(),
593 has_active_tunnel,
594 );
595 println!();
596 match &result {
597 Ok(cr) => {
598 let code = cr.status.code().unwrap_or(1);
599 if code != 255 {
600 app.history.record(&alias);
601 app.record_key_use(&alias, key_activity::now_secs());
602 app.hosts_state.invalidate_render_cache();
603 }
604 if code != 0 {
605 if let Some((hostname, known_hosts_path)) =
606 connection::parse_host_key_error(&cr.stderr_output)
607 {
608 app.screen = app::Screen::ConfirmHostKeyReset {
609 alias: alias.clone(),
610 hostname,
611 known_hosts_path,
612 askpass,
613 };
614 } else {
615 if let Some((ref vmsg, true)) = vault_msg {
621 app.notify_error(vmsg.clone());
622 }
623 let reason = connection::stderr_summary(&cr.stderr_output);
624 let msg = if let Some(reason) = reason {
625 crate::messages::ssh_failed_with_reason(&alias, &reason)
626 } else {
627 crate::messages::ssh_exited_with_code(&alias, code)
628 };
629 app.notify_error(msg);
630 }
631 } else if let Some((ref msg, is_error)) = vault_msg {
632 if is_error {
633 app.notify_error(msg.clone());
634 } else {
635 app.notify(msg.clone());
636 }
637 }
638 }
639 Err(e) => {
640 log::error!("[external] ssh connect failed: alias={alias}: {e}");
641 eprintln!("{}", crate::messages::connection_spawn_failed(&e));
642 app.notify_error(crate::messages::connection_failed(&alias));
643 }
644 }
645 askpass::cleanup_marker(app.env.paths(), &alias);
646 terminal.enter()?;
647 events.resume();
648 *last_config_check = std::time::Instant::now();
649 let reloaded = SshConfigFile::parse_with_env(app.reload.config_path(), app.env())?;
650 app.hosts_state.set_ssh_config(reloaded);
651 app.reload_hosts();
652 app.update_last_modified();
653 Ok(())
654}
655
656fn handle_pending_container_exec(
661 app: &mut App,
662 terminal: &mut tui::Tui,
663 events: &EventHandler,
664 last_config_check: &mut std::time::Instant,
665) -> Result<()> {
666 let Some(req) = app.container_state.take_pending_exec() else {
667 return Ok(());
668 };
669
670 if let Err(e) = crate::containers::validate_container_id(&req.container_id) {
676 log::warn!(
677 "[purple] container exec blocked on '{}': invalid container_id: {}",
678 req.alias,
679 e
680 );
681 app.notify(crate::messages::container_invalid_id(&e));
682 return Ok(());
683 }
684
685 let askpass = req
686 .askpass
687 .or_else(|| preferences::load_askpass_default(app.env().paths()));
688 let has_active_tunnel = app.tunnels.active_contains(&req.alias);
689 let use_tmux = connection::is_in_tmux(app.env()) && askpass.is_none();
690
691 let remote_cmd = if let Some(ref user_cmd) = req.command {
692 let escaped = user_cmd.replace('\'', "'\\''");
697 format!(
698 "{} exec -it {} sh -c '{}'",
699 req.runtime.as_str(),
700 req.container_id,
701 escaped
702 )
703 } else {
704 format!(
705 "{} exec -it {} sh -c 'bash || sh'",
706 req.runtime.as_str(),
707 req.container_id
708 )
709 };
710
711 if use_tmux {
712 let label = format!("{}/{}", req.alias, req.container_name);
713 match connection::connect_tmux_window_with_remote_command(
714 &req.alias,
715 app.reload.config_path(),
716 app.env(),
717 has_active_tunnel,
718 &remote_cmd,
719 &label,
720 ) {
721 Ok(()) => {
722 app.record_key_use(&req.alias, key_activity::now_secs());
723 app.notify(crate::messages::container_exec_opened_in_tmux(
724 &req.container_name,
725 &req.alias,
726 ));
727 }
728 Err(e) => {
729 app.notify_error(crate::messages::tmux_error(&e));
730 }
731 }
732 return Ok(());
733 }
734
735 events.pause();
736 terminal.exit()?;
737
738 let result = connection::connect_with_remote_command(
739 &req.alias,
740 app.reload.config_path(),
741 app.env(),
742 askpass.as_deref(),
743 app.bw_session.as_deref(),
744 has_active_tunnel,
745 &remote_cmd,
746 );
747
748 match result {
749 Ok(cr) => {
750 let code = cr.status.code().unwrap_or(1);
751 if code != 255 {
757 app.history.record(&req.alias);
758 app.record_key_use(&req.alias, key_activity::now_secs());
759 app.hosts_state.invalidate_render_cache();
760 }
761 if code == 0 {
762 app.notify(crate::messages::container_exec_ended(&req.container_name));
763 } else if let Some((hostname, known_hosts_path)) =
764 connection::parse_host_key_error(&cr.stderr_output)
765 {
766 app.screen = app::Screen::ConfirmHostKeyReset {
770 alias: req.alias.clone(),
771 hostname,
772 known_hosts_path,
773 askpass: askpass.clone(),
774 };
775 } else {
776 let reason = connection::stderr_summary(&cr.stderr_output);
777 let msg = match reason {
778 Some(r) => {
779 crate::messages::container_exec_failed_with_reason(&req.container_name, &r)
780 }
781 None => {
782 crate::messages::container_exec_exited_with_code(&req.container_name, code)
783 }
784 };
785 app.notify_error(msg);
786 }
787 }
788 Err(e) => {
789 eprintln!("{}", crate::messages::connection_spawn_failed(&e));
790 app.notify_error(crate::messages::container_exec_spawn_failed(
791 &req.container_name,
792 ));
793 }
794 }
795 askpass::cleanup_marker(app.env.paths(), &req.alias);
796 terminal.enter()?;
797 events.resume();
798 *last_config_check = std::time::Instant::now();
799 Ok(())
800}
801
802fn handle_pending_container_logs(app: &mut App, events_tx: &std::sync::mpsc::Sender<AppEvent>) {
808 let Some(req) = app.container_state.take_pending_logs() else {
809 return;
810 };
811 let askpass = req
812 .askpass
813 .or_else(|| preferences::load_askpass_default(app.env().paths()));
814 let has_tunnel = app.tunnels.active_contains(&req.alias);
815 let ctx = crate::ssh_context::OwnedSshContext {
816 alias: req.alias,
817 config_path: app.reload.config_path().to_path_buf(),
818 askpass,
819 bw_session: app.bw_session.clone(),
820 has_tunnel,
821 env: std::sync::Arc::clone(&app.env),
822 };
823 let tx = events_tx.clone();
824 log::debug!(
825 "[purple] container_logs_fetch: spawning alias={} id={}",
826 ctx.alias,
827 req.container_id
828 );
829 crate::containers::spawn_container_logs_fetch(
830 ctx,
831 req.runtime,
832 req.container_id,
833 req.container_name,
834 crate::handler::container_logs::DEFAULT_TAIL,
835 move |alias, container_id, container_name, result| {
836 let _ = tx.send(AppEvent::ContainerLogsComplete {
837 alias,
838 container_id,
839 container_name,
840 result,
841 });
842 },
843 );
844}
845
846fn handle_pending_container_action(app: &mut App, events_tx: &std::sync::mpsc::Sender<AppEvent>) {
853 let Some(req) = app.container_state.pop_next_action() else {
857 return;
858 };
859 let askpass = req
860 .askpass
861 .or_else(|| preferences::load_askpass_default(app.env().paths()));
862 let has_tunnel = app.tunnels.active_contains(&req.alias);
863 let ctx = crate::ssh_context::OwnedSshContext {
864 alias: req.alias.clone(),
865 config_path: app.reload.config_path().to_path_buf(),
866 askpass,
867 bw_session: app.bw_session.clone(),
868 has_tunnel,
869 env: std::sync::Arc::clone(&app.env),
870 };
871 let tx = events_tx.clone();
872 log::info!(
873 "[purple] container_action_drain: spawning alias={} id={} action={:?} name={}",
874 req.alias,
875 req.container_id,
876 req.action,
877 req.container_name
878 );
879 crate::containers::spawn_container_action(
880 ctx,
881 req.runtime,
882 req.action,
883 req.container_id,
884 move |alias, action, result| {
885 let _ = tx.send(AppEvent::ContainerActionComplete {
886 alias,
887 action,
888 result,
889 });
890 },
891 );
892}
893
894fn handle_pending_snippet(
898 app: &mut App,
899 terminal: &mut tui::Tui,
900 events: &EventHandler,
901 last_config_check: &mut std::time::Instant,
902) -> Result<()> {
903 let Some((snip, aliases)) = app.snippets.take_pending() else {
904 return Ok(());
905 };
906 events.pause();
907 terminal.exit()?;
908
909 let multi = aliases.len() > 1;
910 for alias in &aliases {
911 let askpass = app
912 .hosts_state
913 .list()
914 .iter()
915 .find(|h| h.alias == *alias)
916 .and_then(|h| h.askpass.clone())
917 .or_else(|| preferences::load_askpass_default(app.env().paths()));
918 let env = std::sync::Arc::clone(&app.env);
919 ensure_proton_login(&env, askpass.as_deref());
920 if let Some(token) = ensure_bw_session(&env, app.bw_session.as_deref(), askpass.as_deref())
921 {
922 app.bw_session = Some(token);
923 }
924 ensure_keychain_password(&env, alias, askpass.as_deref());
925
926 if multi {
927 println!("{}", crate::messages::cli::host_separator(alias));
928 } else {
929 print!(
930 "{}",
931 crate::messages::cli::running_snippet_on(&snip.name, alias)
932 );
933 }
934 let has_tunnel = app.tunnels.active_contains(alias);
935 match snippet::run_snippet(
936 alias,
937 app.reload.config_path(),
938 &env,
939 &snip.command,
940 askpass.as_deref(),
941 app.bw_session.as_deref(),
942 false,
943 has_tunnel,
944 ) {
945 Ok(r) => {
946 if r.status.success() {
947 app.history.record(alias);
948 app.record_key_use(alias, key_activity::now_secs());
949 app.hosts_state.invalidate_render_cache();
950 } else if multi {
951 eprintln!(
952 "{}",
953 crate::messages::cli::exited_with_code(r.status.code().unwrap_or(1))
954 );
955 } else {
956 println!(
957 "\n{}",
958 crate::messages::cli::exited_with_code(r.status.code().unwrap_or(1))
959 );
960 }
961 }
962 Err(e) => eprintln!("{}", crate::messages::cli::host_failed(alias, &e)),
963 }
964 if multi {
965 println!();
966 }
967 }
968
969 if !multi {
970 println!("\n{}", crate::messages::cli::DONE);
971 } else {
972 println!(
973 "{}",
974 crate::messages::cli::done_multi(&snip.name, aliases.len())
975 );
976 }
977 println!("\n{}", crate::messages::cli::PRESS_ENTER);
978 let _ = std::io::stdin().read_line(&mut String::new());
979 terminal.enter()?;
980 events.resume();
981 *last_config_check = std::time::Instant::now();
982 let reloaded = SshConfigFile::parse_with_env(app.reload.config_path(), app.env())?;
984 app.hosts_state.set_ssh_config(reloaded);
985 app.reload_hosts();
986 app.update_last_modified();
987 Ok(())
988}
989
990fn tui_teardown(app: &mut App, terminal: &mut tui::Tui) -> Result<()> {
993 app.flush_pending_vault_write();
994
995 if let Some(handle) = app.vault.cancel_signing_run() {
996 let _ = handle.join();
997 }
998
999 for (_, mut tunnel) in app.tunnels.drain_active() {
1000 let _ = tunnel.child.kill();
1001 let _ = tunnel.child.wait();
1002 }
1003
1004 terminal.exit()?;
1005 Ok(())
1006}
1007
1008pub(crate) fn current_cert_mtime(alias: &str, app: &app::App) -> Option<std::time::SystemTime> {
1009 let host = app.hosts_state.list().iter().find(|h| h.alias == alias)?;
1010 let cert_path =
1011 vault_ssh::resolve_cert_path(app.env().paths(), alias, &host.certificate_file).ok()?;
1012 std::fs::metadata(&cert_path)
1013 .ok()
1014 .and_then(|m| m.modified().ok())
1015}
1016
1017pub(crate) fn cache_entry_is_stale<F>(
1030 entry: Option<&(
1031 std::time::Instant,
1032 vault_ssh::CertStatus,
1033 Option<std::time::SystemTime>,
1034 )>,
1035 current_mtime: Option<std::time::SystemTime>,
1036 elapsed_secs: F,
1037) -> bool
1038where
1039 F: FnOnce(std::time::Instant) -> u64,
1040{
1041 let Some((checked_at, status, cached_mtime)) = entry else {
1042 return true;
1043 };
1044 if current_mtime != *cached_mtime {
1045 return true;
1046 }
1047 let ttl = if matches!(status, vault_ssh::CertStatus::Invalid(_)) {
1048 vault_ssh::CERT_ERROR_BACKOFF_SECS
1049 } else {
1050 vault_ssh::CERT_STATUS_CACHE_TTL_SECS
1051 };
1052 elapsed_secs(*checked_at) > ttl
1053}