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