//! Crossterm terminal lifecycle and responsive application event loop. use std::io::{self, IsTerminal, Write}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::execute; use crossterm::terminal::{ EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, size, }; use ratatui::Terminal; use ratatui::backend::CrosstermBackend; use super::events::{ ApplicationWorker, CommandResult, FirstIssueStatus, LinuxStatisticsReader, SensitiveCommand, ShutdownProgress, StatisticsReader, SubmitError, UiCommand, WorkerConfig, WorkerEvent, }; use super::forms::{FormAction, FormController}; use super::render; use super::state::{Screen, SessionView, UiState}; use crate::app::Application; const MIN_WIDTH: u16 = 34; const MIN_HEIGHT: u16 = 8; const EVENT_POLL: Duration = Duration::from_millis(50); #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum TerminalError { NotInteractive, TooSmall, Unsupported, Setup, Io, Worker, Unexpected, } impl std::fmt::Display for TerminalError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter.write_str(match self { Self::NotInteractive => "interactive management requires terminal stdin and stdout", Self::TooSmall => "terminal must be at least 34 columns by 8 rows", Self::Unsupported => "terminal does not support the required interactive mode", Self::Setup => "could not initialize terminal mode", Self::Io => "terminal input or rendering failed", Self::Worker => "application worker stopped unexpectedly", Self::Unexpected => "an unexpected internal failure occurred", }) } } impl std::error::Error for TerminalError {} pub fn check_suitable() -> Result<(), TerminalError> { check_suitable_for( io::stdin().is_terminal(), io::stdout().is_terminal(), size(), ) } fn check_suitable_for( stdin_is_terminal: bool, stdout_is_terminal: bool, dimensions: io::Result<(u16, u16)>, ) -> Result<(), TerminalError> { if !stdin_is_terminal || !stdout_is_terminal { return Err(TerminalError::NotInteractive); } let (width, height) = dimensions.map_err(|_| TerminalError::Unsupported)?; if width < MIN_WIDTH || height < MIN_HEIGHT { return Err(TerminalError::TooSmall); } Ok(()) } struct Lifecycle { raw: AtomicBool, alternate: AtomicBool, hidden_cursor: AtomicBool, } impl Lifecycle { const fn new() -> Self { Self { raw: AtomicBool::new(false), alternate: AtomicBool::new(false), hidden_cursor: AtomicBool::new(false), } } fn restore(&self) { let mut stdout = io::stdout(); if self.hidden_cursor.swap(false, Ordering::AcqRel) { let _ = execute!(stdout, crossterm::cursor::Show); } if self.alternate.swap(false, Ordering::AcqRel) { let _ = execute!(stdout, LeaveAlternateScreen); } if self.raw.swap(false, Ordering::AcqRel) { let _ = disable_raw_mode(); } } fn enable_raw(&self) -> Result<(), TerminalError> { self.raw.store(true, Ordering::Release); enable_raw_mode().map_err(|_| TerminalError::Setup) } fn enter_alternate(&self, stdout: &mut io::Stdout) -> Result<(), TerminalError> { self.alternate.store(true, Ordering::Release); execute!(stdout, EnterAlternateScreen).map_err(|_| TerminalError::Setup) } fn hide_cursor(&self, stdout: &mut io::Stdout) -> Result<(), TerminalError> { self.hidden_cursor.store(true, Ordering::Release); execute!(stdout, crossterm::cursor::Hide).map_err(|_| TerminalError::Setup) } } struct TerminalGuard(Arc); impl Drop for TerminalGuard { fn drop(&mut self) { self.0.restore(); } } type PanicHook = Box) + Send + Sync + 'static>; struct PanicHookGuard(Option); impl PanicHookGuard { fn install(lifecycle: Arc) -> Self { let previous = std::panic::take_hook(); let owner = std::thread::current().id(); std::panic::set_hook(Box::new(move |_info| { if std::thread::current().id() == owner { lifecycle.restore(); let _ = writeln!( io::stderr().lock(), "wireguard-admin encountered an unexpected internal failure" ); } })); Self(Some(previous)) } } impl Drop for PanicHookGuard { fn drop(&mut self) { if let Some(previous) = self.0.take() { let _installed = std::panic::take_hook(); std::panic::set_hook(previous); } } } pub fn run(app: Application) -> Result<(), TerminalError> { run_with_statistics(app, LinuxStatisticsReader, WorkerConfig::default()) } /// Run the terminal loop with an injected statistics source. This also allows /// isolated PTY harnesses to exercise terminal cleanup without probing a host /// WireGuard interface. #[doc(hidden)] pub fn run_with_statistics( app: Application, statistics: impl StatisticsReader, config: WorkerConfig, ) -> Result<(), TerminalError> { check_suitable()?; with_terminal_lifecycle(|lifecycle| run_terminal_session(app, statistics, config, lifecycle)) } fn with_terminal_lifecycle( operation: impl FnOnce(Arc) -> Result<(), TerminalError>, ) -> Result<(), TerminalError> { let lifecycle = Arc::new(Lifecycle::new()); let _panic_hook_guard = PanicHookGuard::install(Arc::clone(&lifecycle)); let _guard = TerminalGuard(Arc::clone(&lifecycle)); match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { operation(Arc::clone(&lifecycle)) })) { Ok(result) => result, Err(_) => { lifecycle.restore(); Err(TerminalError::Unexpected) } } } fn run_terminal_session( app: Application, statistics: impl StatisticsReader, config: WorkerConfig, lifecycle: Arc, ) -> Result<(), TerminalError> { lifecycle.enable_raw()?; let mut stdout = io::stdout(); lifecycle.enter_alternate(&mut stdout)?; lifecycle.hide_cursor(&mut stdout)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend).map_err(|_| TerminalError::Setup)?; let mut worker = ApplicationWorker::spawn(app, statistics, config).map_err(|_| TerminalError::Worker)?; let mut state = UiState::default(); let mut forms = FormController::default(); let mut last_operation_outcome = None; let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { run_screen_loop( &mut terminal, &mut worker, &mut state, &mut forms, &mut last_operation_outcome, ) })) .unwrap_or(Err(TerminalError::Unexpected)); if let Err(error) = result { drop(terminal); lifecycle.restore(); let _ = writeln!( io::stderr().lock(), "Terminal interaction failed; waiting for submitted application work to finish." ); for outcome in await_worker_after_terminal_error(&mut worker, state.pending, last_operation_outcome) { let _ = writeln!(io::stderr().lock(), "{outcome}"); } return Err(error); } Ok(()) } fn run_screen_loop( terminal: &mut Terminal>, worker: &mut ApplicationWorker, state: &mut UiState, forms: &mut FormController, last_operation_outcome: &mut Option, ) -> Result<(), TerminalError> { let mut exiting = false; loop { drain_worker_events(worker, state, forms, last_operation_outcome); terminal .draw(|frame| render::draw(frame, state, forms)) .map_err(|_| TerminalError::Io)?; if exiting { match worker.shutdown(Duration::from_millis(20)) { ShutdownProgress::Stopped => return Ok(()), ShutdownProgress::Disconnected => return Err(TerminalError::Worker), ShutdownProgress::Pending => { state.screen = Screen::Progress; state.status_message = Some( "Waiting for the submitted operation or runtime read to finish. Press no further keys; outcome will be retained.".to_owned(), ); } } continue; } if event::poll(EVENT_POLL).map_err(|_| TerminalError::Io)? { match event::read().map_err(|_| TerminalError::Io)? { Event::Key(key) if key.kind == KeyEventKind::Press => { let code = if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { KeyCode::Char('q') } else { key.code }; if forms.is_active() { apply_form_action( forms.handle_key(code), worker, state, forms, &mut exiting, ); } else { let (terminal_width, terminal_height) = if state.screen == Screen::Help { let size = terminal.size().map_err(|_| TerminalError::Io)?; (size.width, size.height) } else { (0, 0) }; handle_key( code, worker, state, forms, &mut exiting, terminal_width, terminal_height, ); } } Event::Resize(_, _) => {} _ => {} } } } } trait WorkerLifecycle { fn shutdown(&mut self, timeout: Duration) -> ShutdownProgress; fn try_receive(&mut self) -> Option; } impl WorkerLifecycle for ApplicationWorker { fn shutdown(&mut self, timeout: Duration) -> ShutdownProgress { ApplicationWorker::shutdown(self, timeout) } fn try_receive(&mut self) -> Option { ApplicationWorker::try_receive(self) } } fn await_worker_after_terminal_error( worker: &mut impl WorkerLifecycle, initially_pending: Option, previous_outcome: Option, ) -> Vec { let mut outcomes = Vec::new(); if let Some(outcome) = previous_outcome { outcomes.push(format!( "Most recent submitted operation: {}", render::safe_text(&outcome) )); } let mut pending_operation = initially_pending; loop { match worker.shutdown(Duration::from_millis(100)) { ShutdownProgress::Stopped | ShutdownProgress::Disconnected => break, ShutdownProgress::Pending => {} } while let Some(event) = worker.try_receive() { record_shutdown_event(event, &mut pending_operation, &mut outcomes); } } while let Some(event) = worker.try_receive() { record_shutdown_event(event, &mut pending_operation, &mut outcomes); } if let Some(operation) = pending_operation { outcomes.push(format!( "Submitted operation {} ({:?}) has an uncertain outcome; the worker disconnected before reporting completion.", operation.request_id, operation.kind )); } if outcomes.is_empty() { outcomes.push("No submitted application operation remained pending.".to_owned()); } outcomes } fn record_shutdown_event( event: WorkerEvent, pending_operation: &mut Option, outcomes: &mut Vec, ) { match event { WorkerEvent::OperationStarted(operation) => *pending_operation = Some(operation), WorkerEvent::OperationFinished { operation, result, .. } => { let outcome = match result { Ok(_) => "completed".to_owned(), Err(error) => format!("failed: {}", render::safe_text(&error.message)), }; outcomes.push(format!( "Submitted operation {} ({:?}) {outcome}.", operation.request_id, operation.kind )); if *pending_operation == Some(operation) { *pending_operation = None; } } WorkerEvent::SessionReady(_) | WorkerEvent::Statistics { .. } | WorkerEvent::ShutdownComplete => {} } } fn drain_worker_events( worker: &mut ApplicationWorker, state: &mut UiState, forms: &mut FormController, last_operation_outcome: &mut Option, ) { while let Some(event) = worker.try_receive() { match event { WorkerEvent::SessionReady(view) => { state.generation = view.generation; let _ = state.apply_view(view); } WorkerEvent::OperationStarted(operation) => { let _ = state.begin_operation(operation); } WorkerEvent::OperationFinished { operation, result, view, } => { if operation.generation >= state.generation { state.generation = operation.generation; let _ = state.apply_view(*view); } let (message, outcome) = match result { Ok(result) => { let message = process_command_result(result, state, forms); (message, "completed".to_owned()) } Err(error) => ( error.message.clone(), format!("failed: {}", render::safe_text(&error.message)), ), }; *last_operation_outcome = Some(format!( "Submitted operation {} ({:?}) {outcome}.", operation.request_id, operation.kind )); let _ = state.finish_operation(operation); state.status_message = Some(super::render::safe_text(&message)); if !forms.is_active() { state.screen = Screen::Message; state.scroll_offset = 0; } } WorkerEvent::Statistics { generation, observation, } => { let _ = state.apply_statistics(generation, observation); } WorkerEvent::ShutdownComplete => {} } } } fn result_message(result: &CommandResult) -> String { match result { CommandResult::ClientCreated(key) => format!("Client {key} added to the draft."), CommandResult::ClientRenamed => "Alias updated in the draft.".to_owned(), CommandResult::ClientDeleted { record_removed } => { format!("Peer removed from the draft; vault record removed: {record_removed}.") } CommandResult::DraftDiscarded => "Draft discarded.".to_owned(), CommandResult::Committed(path) => format!( "Saved files committed with backup at {}.", render::safe_text(&path.display().to_string()) ), CommandResult::BackupCreated(path) => format!( "Backup created at {}.", render::safe_text(&path.display().to_string()) ), CommandResult::Backups(items) => format!( "{} archive(s) listed. Select an exact archive to preview restoration.", items.len() ), CommandResult::SelectedFileImported(report) => { let mut lines = vec![format!( "Selected source scanned; complete: {}. Original file preserved.", report.scan_complete )]; lines.extend(report.candidates.iter().map(|(name, status)| { format!("Candidate {}: {status:?}", render::safe_text(name)) })); lines.extend(report.issues.iter().map(|issue| { format!( "Anomaly: {} ({:?})", issue .file_name .as_deref() .map(render::safe_text) .unwrap_or_else(|| "selected entry".to_owned()), issue.kind ) })); lines.extend( report .server_only .iter() .map(|key| format!("Server-only peer: {key}")), ); lines.join("\n") } CommandResult::InitialSetupPrepared(report) => { let mut lines = vec![format!( "Vault setup staged; scan complete: {}. Review draft, then Commit to save.", report.scan_complete )]; lines.extend(report.issues.iter().map(|issue| { format!( "Anomaly: {} ({:?})", issue .file_name .as_deref() .map(render::safe_text) .unwrap_or_else(|| "source directory".to_owned()), issue.kind ) })); lines.extend(report.reconciliation.candidate_summaries().into_iter().map( |(name, status)| format!("Candidate {}: {status:?}", render::safe_text(name)), )); lines.extend( report .reconciliation .server_only() .iter() .map(|key| format!("Server-only peer: {key}")), ); lines.join("\n") } CommandResult::RestartPreview(_) => { "Restart preview ready; confirm the exact saved-state action.".to_owned() } CommandResult::Restarted(report) => { let mut message = format!( "Restart result: {:?}; failure: {:?}; submitted: {}; may still complete: {}; manual recovery required: {}.", report.stage, report.failure, report.restart_submitted, report.restart_may_still_complete, report.manual_recovery_required ); if let Some(failure) = report.service_failure { let phase = report .service_phase .map(restart_phase_label) .unwrap_or("service operation"); let unit = report .systemd_unit .as_deref() .map(render::safe_text) .unwrap_or_else(|| "configured systemd unit".to_owned()); message.push_str(&format!( " Systemd {phase} failed for {unit}: {}.", restart_failure_label(failure) )); } message } CommandResult::DeliveryDestination(destination) => format!( "Prepared destination {}. Overwrite confirmation required: {}.", render::safe_text(&destination.path.display().to_string()), destination.overwrite_required ), CommandResult::FirstIssue(summary) => match summary.status { FirstIssueStatus::Delivered => format!( "Client file delivered.{} QR result: {:?}.", if summary.activation_warning { " Access is not verified as active yet." } else { "" }, summary.qr ), FirstIssueStatus::PublishedButBookkeepingUncertain => format!( "Client file may have been published but delivery bookkeeping is uncertain. Inspect the destination; fresh password authorization is required.{} QR result: {:?}.", if summary.activation_warning { " Access is not verified as active yet." } else { "" }, summary.qr ), FirstIssueStatus::Failed(error) => format!( "Client file delivery failed: {error:?}. Retry state: {:?}.", summary.retry ), }, CommandResult::Reissued(summary) => format!( "Password-authorized file reissue completed.{} QR result: {:?}.", if summary.activation_warning { " Access is not verified as active yet." } else { "" }, summary.qr ), CommandResult::RestorePreview(preview) => format!( "Restore preview ready for exact archive {}.", render::safe_text(&preview.file_name) ), CommandResult::Restored(path) => format!( "Saved state restored from {}. Runtime state remains unchanged and unknown until explicit restart.", render::safe_text(&path.display().to_string()) ), CommandResult::Cancelled => "Prepared action cancelled.".to_owned(), } } fn restart_phase_label(phase: crate::service::RestartPhase) -> &'static str { match phase { crate::service::RestartPhase::Connect => "bus connection", crate::service::RestartPhase::Subscribe => "job subscription", crate::service::RestartPhase::ResolveUnit => "unit resolution", crate::service::RestartPhase::RequestRestart => "restart request", crate::service::RestartPhase::WaitForJob => "job wait", } } fn restart_failure_label(failure: crate::service::RestartFailure) -> &'static str { use crate::service::RestartFailure; match failure { RestartFailure::JobFailed => "job failed", RestartFailure::JobDependency => "job dependency failed", RestartFailure::JobTimedOut => "job timed out", RestartFailure::JobCanceled => "job was canceled", RestartFailure::JobSkipped => "job was skipped", RestartFailure::JobResultUnknown => "job returned an unknown result", RestartFailure::BusDisconnected => "D-Bus connection was lost", RestartFailure::ManagerOwnerChanged => "systemd manager owner changed", RestartFailure::SubscriptionFailed => "job subscription failed", RestartFailure::UnitResolutionFailed => "unit could not be loaded", RestartFailure::PermissionDenied => "permission denied", RestartFailure::RequestFailed => "systemd request failed", RestartFailure::WaitTimedOutMayStillComplete => { "operation timed out and may still complete" } RestartFailure::EarlyEventBufferFull => "job event buffer was full", RestartFailure::InvalidJobPath => "systemd returned an invalid job path", } } fn process_command_result( result: CommandResult, state: &mut UiState, forms: &mut FormController, ) -> String { let message = result_message(&result); match result { CommandResult::RestartPreview(preview) => forms.open_restart(preview), CommandResult::DeliveryDestination(summary) => forms.open_delivery_confirmation(summary), CommandResult::FirstIssue(mut summary) => { forms.note_first_issue(summary.identity, summary.retry); if let Some(qr) = summary.qr_output.take() { forms.open_disclosure(qr, message.clone()); } } CommandResult::Reissued(mut summary) => { if let Some(qr) = summary.qr_output.take() { forms.open_disclosure(qr, message.clone()); } } CommandResult::Backups(items) => forms.open_backups(items), CommandResult::RestorePreview(summary) => forms.open_restore_confirmation( summary, state.view.as_ref().is_some_and(|view| view.has_draft), ), CommandResult::Restored(_) => forms.clear_delivery_state(), _ => {} } state.status_message = Some(render::safe_text(&message)); if !forms.is_active() { state.screen = Screen::Message; } message } fn submit_command(worker: &ApplicationWorker, command: UiCommand, state: &mut UiState) { let result = worker.submit(command); match result { Ok(operation) => { let _ = state.begin_operation(operation); state.status_message = Some("Operation submitted; waiting for its result.".to_owned()); } Err(error) => state.status_message = Some(submit_error(error)), } } fn submit_sensitive(worker: &ApplicationWorker, command: SensitiveCommand, state: &mut UiState) { match worker.submit_sensitive(command) { Ok(operation) => { let _ = state.begin_operation(operation); state.status_message = Some("Setup submitted; scanning configured client files.".to_owned()); } Err(error) => state.status_message = Some(submit_error(error)), } } fn submit_error(error: SubmitError) -> String { match error { SubmitError::Busy => "An operation is already running.".to_owned(), SubmitError::QueueFull => "Application queue is full; try again shortly.".to_owned(), SubmitError::ShuttingDown => "Application worker is shutting down.".to_owned(), SubmitError::RequestIdsExhausted => { "No further operations can be submitted in this session.".to_owned() } } } fn apply_form_action( action: FormAction, worker: &ApplicationWorker, state: &mut UiState, forms: &mut FormController, exiting: &mut bool, ) { match action { FormAction::None => {} FormAction::Cancel => { state.cancel_dialog(); state.status_message = Some("Cancelled; draft preserved.".to_owned()); } FormAction::Submit(command) => submit_command(worker, command, state), FormAction::SubmitSensitive(command) => submit_sensitive(worker, command, state), FormAction::PreviewRestart => submit_command(worker, UiCommand::PreviewRestart, state), FormAction::ConfirmRename { identity, alias } => { forms.open_rename_confirmation(identity, alias); } FormAction::CancelRestorePreview => { submit_command(worker, UiCommand::CancelRestorePreview, state) } FormAction::CancelPreparedDelivery => { submit_command(worker, UiCommand::CancelPreparedDelivery, state) } FormAction::StartReissue { identity, overwrite_confirmed, prepared_generation, } => forms.open_reissue_password(identity, overwrite_confirmed, prepared_generation), FormAction::Exit => *exiting = true, FormAction::Feedback(message) => forms.feedback(message), } } fn handle_key( code: KeyCode, worker: &ApplicationWorker, state: &mut UiState, forms: &mut FormController, exiting: &mut bool, terminal_width: u16, terminal_height: u16, ) { if state.pending.is_some() { return; } if handle_help_navigation_key(code, state, terminal_width, terminal_height) { return; } if handle_navigation_key(code, state) { return; } match code { KeyCode::Char('q') => { if state.view.as_ref().is_some_and(|view| view.has_draft) { forms.open_exit_with_draft(); } else { *exiting = true; } } KeyCode::Char('s') if state .view .as_ref() .is_some_and(|view| !view.vault_initialized) => { forms.open_setup() } KeyCode::Char('n') if state.view.is_some() => forms.open_create(), KeyCode::Char('i') => forms.open_import(), KeyCode::Char('r') => { if state.selected_rename_available() { let selected = state.selected_identity.and_then(|id| { state .view .as_ref() .and_then(|v| v.clients.iter().find(|c| c.identity == Some(id))) }); if let (Some(id), Some(client)) = (state.selected_identity, selected) { forms.open_rename(id, &client.label); } } else { state.status_message = Some( "Select a supported peer with an unambiguous public key first.".to_owned(), ); } } KeyCode::Char('d') => { if state.selected_delete_available() { if let (Some(id), Some(view)) = (state.selected_identity, state.view.as_ref()) { let label = view .clients .iter() .find(|c| c.identity == Some(id)) .map_or("peer", |c| c.label.as_str()); forms.open_delete(id, label); } } else { state.status_message = Some( "Select a supported peer with an unambiguous public key first.".to_owned(), ); } } KeyCode::Char('p') => forms.open_draft_review(), KeyCode::Char('c') => forms.open_commit(), KeyCode::Char('x') => forms.open_discard(), KeyCode::Char('R') => { if state .view .as_ref() .is_some_and(|view| view.restart_eligibility().is_none_or(|item| item.available)) { submit_command(worker, UiCommand::PreviewRestart, state); } else { state.status_message = Some("Restart preview is unavailable for the saved configuration.".to_owned()); } } KeyCode::Char('o') | KeyCode::Char('O') => { let eligible = if code == KeyCode::Char('o') { state.selected_first_delivery_available() } else { state.selected_reissue_available() }; if eligible && let Some(identity) = state.selected_identity { forms.open_delivery(identity, code == KeyCode::Char('O')); } else { state.status_message = Some("Select a supported saved client by full public key first.".to_owned()); } } KeyCode::Char('b') => submit_command(worker, UiCommand::ListBackups, state), KeyCode::Char('B') => submit_command(worker, UiCommand::CreateBackup, state), KeyCode::Char('?') => { state.screen = Screen::Help; state.status_message = None; state.scroll_offset = 0; } KeyCode::Char('!') => { let Some(view) = &state.view else { state.status_message = Some("Client and issue data are still loading.".to_owned()); state.screen = Screen::Message; return; }; state.status_message = Some(issue_summary(view)); state.screen = Screen::Message; state.scroll_offset = 0; } KeyCode::Up | KeyCode::PageUp if state.screen == Screen::ClientDetails || state.screen == Screen::Help || state.screen == Screen::Message => { state.scroll_offset = state.scroll_offset.saturating_sub(1); } KeyCode::Down | KeyCode::PageDown if state.screen == Screen::ClientDetails || state.screen == Screen::Help || state.screen == Screen::Message => { state.scroll_offset = state.scroll_offset.saturating_add(1); } _ => {} } } fn handle_help_navigation_key( code: KeyCode, state: &mut UiState, terminal_width: u16, terminal_height: u16, ) -> bool { if state.screen != Screen::Help { return false; } let metrics = render::help_navigation_metrics(terminal_width, terminal_height); state.scroll_offset = state.scroll_offset.min(metrics.max_offset); match code { KeyCode::Up => state.scroll_offset = state.scroll_offset.saturating_sub(1), KeyCode::Down => { state.scroll_offset = state .scroll_offset .saturating_add(1) .min(metrics.max_offset) } KeyCode::PageUp => { state.scroll_offset = state.scroll_offset.saturating_sub(metrics.page_rows) } KeyCode::PageDown => { state.scroll_offset = state .scroll_offset .saturating_add(metrics.page_rows) .min(metrics.max_offset) } KeyCode::Char('?') => state.scroll_offset = 0, _ => return false, } true } fn issue_summary(view: &SessionView) -> String { let mut issues = Vec::new(); issues.extend( view.document_issues .iter() .map(|issue| format!("Document issue: {issue:?}")), ); for client in &view.clients { for issue in client.presented_issues() { issues.push(format!("Peer at line {}: {issue:?}", client.line)); } } if issues.is_empty() { "No document or peer issues were reported.".to_owned() } else { issues.join("\n") } } /// Handle list/detail navigation and return whether the key was consumed. /// Keeping this path independent of worker actions makes its state transitions /// directly testable without starting an application worker. fn handle_navigation_key(code: KeyCode, state: &mut UiState) -> bool { match code { KeyCode::Esc => { state.screen = Screen::ClientList; state.dialog = None; state.scroll_offset = 0; true } KeyCode::Enter if state.screen == Screen::ClientList => { let _ = state.open_selected_record(); state.scroll_offset = 0; true } KeyCode::Up | KeyCode::Down if state.screen == Screen::ClientList => { move_selection(state, matches!(code, KeyCode::Down)); true } _ => false, } } fn move_selection(state: &mut UiState, down: bool) { let Some(view) = &state.view else { return }; let records = view.visible_records(); if records.is_empty() { return; } let current = state.selected_visible_index(&records).unwrap_or(0); let next = if down { (current + 1) % records.len() } else { current.checked_sub(1).unwrap_or(records.len() - 1) }; let _ = state.select_visible_index(next); } #[cfg(test)] mod tests { use super::*; use std::collections::VecDeque; use std::process::Command; use std::sync::Mutex; use ratatui::Terminal; use ratatui::backend::TestBackend; use crate::app::ClientDisplay; use crate::domain::ApplyStatus; use crate::runtime::RuntimeObservation; use crate::tui::events::OperationError; use crate::tui::state::{OperationKind, SessionView}; use crate::wireguard::PeerIssue; struct FakeWorker { shutdown_calls: usize, events: VecDeque, } impl WorkerLifecycle for FakeWorker { fn shutdown(&mut self, _timeout: Duration) -> ShutdownProgress { self.shutdown_calls += 1; if self.shutdown_calls < 3 { ShutdownProgress::Pending } else { ShutdownProgress::Stopped } } fn try_receive(&mut self) -> Option { self.events.pop_front() } } #[test] fn suitability_rejects_non_tty_and_small_or_unsupported_terminal_before_setup() { assert_eq!( check_suitable_for(false, true, Ok((80, 24))), Err(TerminalError::NotInteractive) ); assert_eq!( check_suitable_for(true, true, Ok((20, 10))), Err(TerminalError::TooSmall) ); assert_eq!( check_suitable_for(true, true, Err(io::Error::other("fixture"))), Err(TerminalError::Unsupported) ); assert_eq!(check_suitable_for(true, true, Ok((80, 24))), Ok(())); } fn navigation_view() -> SessionView { let first = crate::domain::PublicKey::from_bytes([1; 32]); let unsupported = ClientDisplay { peer_index: 9, line: 10, identity: None, label: "unsupported peer".to_owned(), allowed_ips: Vec::new(), has_subnet_routes: false, issues: vec![PeerIssue::MissingIpv4HostRoute], mutable_identity: None, }; SessionView { generation: 0, clients: vec![ ClientDisplay { peer_index: 2, line: 3, identity: Some(first), label: "first".to_owned(), allowed_ips: vec!["10.0.0.2/32".to_owned()], has_subnet_routes: false, issues: Vec::new(), mutable_identity: Some(first), }, unsupported, ], client_states: Default::default(), saved_peer_count: Some(0), document_issues: Vec::new(), applied: ApplyStatus::Unknown, reserved_addresses: Default::default(), statistics: RuntimeObservation::unknown(), has_draft: false, vault_initialized: true, application: None, } } fn rendered_help(width: u16, height: u16, state: &UiState) -> String { let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); terminal .draw(|frame| render::draw(frame, state, &FormController::default())) .unwrap(); let buffer = terminal.backend().buffer(); (0..buffer.area.height) .map(|y| { (0..buffer.area.width) .map(|x| buffer[(x, y)].symbol()) .collect::() }) .collect::>() .join("\n") } #[test] fn help_navigation_uses_visual_rows_and_viewport_pages_with_bounded_edges() { for (width, height) in [(34, 8), (40, 9), (60, 16), (80, 24), (100, 30), (180, 50)] { let metrics = render::help_navigation_metrics(width, height); let mut state = UiState { screen: Screen::Help, ..UiState::default() }; assert!(handle_help_navigation_key( KeyCode::Up, &mut state, width, height )); assert_eq!(state.scroll_offset, 0, "top bound at {width}x{height}"); assert!(handle_help_navigation_key( KeyCode::Down, &mut state, width, height )); assert_eq!(state.scroll_offset, usize::from(metrics.max_offset > 0)); assert!(handle_help_navigation_key( KeyCode::PageUp, &mut state, width, height )); assert_eq!(state.scroll_offset, 0); for _ in 0..metrics.max_offset.div_ceil(metrics.page_rows) { assert!(handle_help_navigation_key( KeyCode::PageDown, &mut state, width, height )); } assert_eq!(state.scroll_offset, metrics.max_offset); assert!(handle_help_navigation_key( KeyCode::PageDown, &mut state, width, height )); assert_eq!(state.scroll_offset, metrics.max_offset); assert!(handle_help_navigation_key( KeyCode::Up, &mut state, width, height )); assert_eq!(state.scroll_offset, metrics.max_offset.saturating_sub(1)); } } #[test] fn help_navigation_reaches_final_section_and_resize_clamps_current_position() { let mut state = UiState { screen: Screen::Help, ..UiState::default() }; let (width, height) = (60, 16); let metrics = render::help_navigation_metrics(width, height); let mut reached_last_section = false; loop { let text = rendered_help(width, height, &state); reached_last_section |= text.contains("Navigation, cancellation"); if state.scroll_offset == metrics.max_offset { break; } handle_help_navigation_key(KeyCode::Down, &mut state, width, height); } assert_eq!(state.scroll_offset, metrics.max_offset); assert!( reached_last_section, "one-row scrolling reaches the last section" ); let text = rendered_help(width, height, &state); assert!(text.contains(&format!("Help {}-", metrics.max_offset + 1))); let small = render::help_navigation_metrics(34, 8); let large = render::help_navigation_metrics(180, 50); state.scroll_offset = small.max_offset; assert!(handle_help_navigation_key( KeyCode::PageDown, &mut state, 180, 50 )); assert_eq!(state.scroll_offset, large.max_offset); assert!(rendered_help(180, 50, &state).contains("Help ")); state.scroll_offset = large.max_offset; assert!(handle_help_navigation_key(KeyCode::Down, &mut state, 34, 8)); assert_eq!(state.scroll_offset, large.max_offset + 1); let small_text = rendered_help(34, 8, &state); assert!(small_text.contains("Help ")); assert!(!small_text.contains("Terminal too small")); assert!(state.scroll_offset <= small.max_offset); } #[test] fn help_reset_and_escape_preserve_selection_and_application_view_without_worker_actions() { let identity = crate::domain::PublicKey::from_bytes([1; 32]); let mut view = navigation_view(); view.has_draft = true; view.applied = ApplyStatus::Verified; let mut state = UiState { screen: Screen::Help, selected_identity: Some(identity), selected_peer_index: Some(2), scroll_offset: 42, view: Some(view), ..UiState::default() }; assert!(handle_help_navigation_key( KeyCode::Char('?'), &mut state, 80, 24 )); assert_eq!(state.screen, Screen::Help); assert_eq!(state.scroll_offset, 0); handle_help_navigation_key(KeyCode::PageDown, &mut state, 80, 24); let selected_identity = state.selected_identity; let selected_peer_index = state.selected_peer_index; let view_before = state.view.clone(); assert!(handle_navigation_key(KeyCode::Esc, &mut state)); assert_eq!(state.screen, Screen::ClientList); assert_eq!(state.scroll_offset, 0); assert_eq!(state.selected_identity, selected_identity); assert_eq!(state.selected_peer_index, selected_peer_index); assert_eq!(state.view, view_before); assert!(state.view.as_ref().unwrap().has_draft); assert_eq!(state.view.as_ref().unwrap().applied, ApplyStatus::Verified); // The scrolling helper accepts no worker or command sink, so Help input // cannot submit work or mutate saved/applied application state. assert!(!handle_help_navigation_key( KeyCode::Down, &mut state, 80, 24 )); } #[test] fn arrows_highlight_only_enter_opens_selected_peer_and_escape_returns_to_list() { let identity = crate::domain::PublicKey::from_bytes([1; 32]); let mut state = UiState { view: Some(navigation_view()), ..UiState::default() }; assert!(handle_navigation_key(KeyCode::Down, &mut state)); assert_eq!(state.screen, Screen::ClientList); assert_eq!(state.selected_peer_index, Some(9)); assert_eq!(state.selected_identity, None); assert!(handle_navigation_key(KeyCode::Up, &mut state)); assert_eq!(state.screen, Screen::ClientList); assert_eq!(state.selected_peer_index, Some(2)); assert_eq!(state.selected_identity, Some(identity)); state.selected_peer_index = None; state.selected_identity = None; assert!(handle_navigation_key(KeyCode::Up, &mut state)); assert_eq!(state.screen, Screen::ClientList); assert_eq!(state.selected_peer_index, Some(9)); assert_eq!(state.selected_identity, None); assert!(handle_navigation_key(KeyCode::Down, &mut state)); assert_eq!(state.selected_peer_index, Some(2)); state.scroll_offset = 3; assert!(handle_navigation_key(KeyCode::Enter, &mut state)); assert_eq!(state.screen, Screen::ClientDetails); assert_eq!(state.selected_peer_index, Some(2)); assert_eq!(state.scroll_offset, 0); state.scroll_offset = 5; assert!(handle_navigation_key(KeyCode::Esc, &mut state)); assert_eq!(state.screen, Screen::ClientList); assert_eq!(state.selected_peer_index, Some(2)); assert_eq!(state.scroll_offset, 0); } #[test] fn enter_opens_an_unsupported_peer_for_inspection_without_mutation_identity() { let mut state = UiState { view: Some(navigation_view()), ..UiState::default() }; assert!(handle_navigation_key(KeyCode::Down, &mut state)); assert_eq!(state.screen, Screen::ClientList); assert_eq!(state.selected_peer_index, Some(9)); assert!(!state.selected_client_mutable()); assert!(handle_navigation_key(KeyCode::Enter, &mut state)); assert_eq!(state.screen, Screen::ClientDetails); assert_eq!(state.selected_peer_index, Some(9)); assert_eq!(state.selected_identity, None); assert!(!state.selected_client_mutable()); } #[test] fn keyboard_navigation_reaches_draft_removed_rows_without_authorizing_them() { let removed_key = crate::domain::PublicKey::from_bytes([19; 32]); let mut view = navigation_view(); view.client_states.insert( removed_key, crate::tui::state::ClientStateRow { saved: Some( crate::domain::Client::new( removed_key, "removed", crate::domain::ClientAddresses { ipv4: "10.0.0.9".parse().unwrap(), ipv6: None, }, ) .unwrap(), ), draft: None, }, ); let mut state = UiState { view: Some(view), ..UiState::default() }; assert!(handle_navigation_key(KeyCode::Down, &mut state)); assert_eq!(state.selected_peer_index, Some(9)); assert!(handle_navigation_key(KeyCode::Down, &mut state)); assert_eq!(state.selected_peer_index, None); assert_eq!(state.selected_identity, Some(removed_key)); assert!(!state.selected_client_mutable()); assert!(handle_navigation_key(KeyCode::Enter, &mut state)); assert_eq!(state.screen, Screen::ClientDetails); assert!(matches!( state.selected_record(), Some(crate::tui::state::VisibleRecord::DraftRemoved { identity, .. }) if identity == removed_key )); assert!(handle_navigation_key(KeyCode::Esc, &mut state)); assert_eq!(state.screen, Screen::ClientList); assert_eq!(state.selected_identity, Some(removed_key)); let mut empty = UiState { view: Some(SessionView { generation: 0, clients: Vec::new(), client_states: Default::default(), saved_peer_count: Some(0), document_issues: vec![crate::wireguard::DocumentIssue::MalformedSectionHeader { line: 1, }], applied: ApplyStatus::Unknown, reserved_addresses: Default::default(), statistics: RuntimeObservation::unknown(), has_draft: false, vault_initialized: true, application: None, }), ..UiState::default() }; assert!(handle_navigation_key(KeyCode::Enter, &mut empty)); assert_eq!(empty.screen, Screen::ClientList); assert!(empty.selected_record().is_none()); } #[test] fn duplicate_public_keys_remain_inspectable_by_peer_position_but_not_mutable() { let key = crate::domain::PublicKey::from_bytes([20; 32]); let mut first = navigation_view().clients[0].clone(); first.peer_index = 3; first.identity = Some(key); first.mutable_identity = Some(key); first.label = "first duplicate".to_owned(); let mut second = first.clone(); second.peer_index = 7; second.label = "second duplicate".to_owned(); let mut state = UiState { view: Some(SessionView { generation: 0, clients: vec![first, second], client_states: Default::default(), saved_peer_count: Some(2), document_issues: Vec::new(), applied: ApplyStatus::Unknown, reserved_addresses: Default::default(), statistics: RuntimeObservation::unknown(), has_draft: false, vault_initialized: true, application: None, }), ..UiState::default() }; assert!(handle_navigation_key(KeyCode::Down, &mut state)); assert_eq!(state.selected_peer_index, Some(7)); assert_eq!(state.selected_identity, Some(key)); assert!(!state.selected_client_mutable()); assert!(handle_navigation_key(KeyCode::Enter, &mut state)); assert!(matches!( state.selected_record(), Some(crate::tui::state::VisibleRecord::Peer(client)) if client.peer_index == 7 && client.label == "second duplicate" )); assert!(!state.selected_client_mutable()); } #[test] fn issues_screen_hides_valid_address_shape_limits_but_keeps_genuine_errors() { let mut view = navigation_view(); view.clients.truncate(1); view.clients[0].issues = vec![PeerIssue::UnsupportedAllowedIpShape { field: 0, item: 1 }]; view.clients[0].has_subnet_routes = false; let additional_host_only = issue_summary(&view); assert_eq!( additional_host_only, "No document or peer issues were reported." ); assert_eq!( view.clients[0].issues, vec![PeerIssue::UnsupportedAllowedIpShape { field: 0, item: 1 }], "presentation must retain the parser issue for safety checks" ); view.clients[0].issues = vec![PeerIssue::MissingIpv4HostRoute]; assert_eq!( issue_summary(&view), "No document or peer issues were reported.", "valid IPv6-only address shape is a capability limit" ); view.clients[0] .issues .push(PeerIssue::MalformedAllowedIp { field: 0, item: 2 }); let mixed = issue_summary(&view); assert!(mixed.contains("MalformedAllowedIp")); assert!(!mixed.contains("UnsupportedAllowedIpShape")); } #[test] fn restart_results_render_cancel_success_and_uncertain_failure_honestly() { use crate::app::{RestartFailureKind, RestartReport, RestartStage}; use crate::service::{RestartFailure, RestartPhase}; let report = |stage, failure, restart_submitted, restart_may_still_complete| { CommandResult::Restarted(RestartReport { stage, failure, service_failure: None, service_phase: None, systemd_unit: None, backup_path: None, restart_submitted, restart_may_still_complete, manual_recovery_required: restart_may_still_complete, }) }; let cancelled = result_message(&report(RestartStage::Cancelled, None, false, false)); assert!(cancelled.contains("Cancelled")); assert!(cancelled.contains("submitted: false")); let success = result_message(&report(RestartStage::Complete, None, true, false)); assert!(success.contains("Complete")); assert!(success.contains("submitted: true")); let uncertain = result_message(&report( RestartStage::Service, Some(RestartFailureKind::ServiceFailed), true, true, )); assert!(uncertain.contains("ServiceFailed")); assert!(uncertain.contains("may still complete: true")); assert!(uncertain.contains("manual recovery required: true")); let detailed = CommandResult::Restarted(RestartReport { stage: RestartStage::Service, failure: Some(RestartFailureKind::ServiceFailed), service_failure: Some(RestartFailure::PermissionDenied), service_phase: Some(RestartPhase::ResolveUnit), systemd_unit: Some("wireguard-vpn.service".to_owned()), backup_path: None, restart_submitted: false, restart_may_still_complete: false, manual_recovery_required: false, }); let detailed = result_message(&detailed); assert!(detailed.contains("unit resolution")); assert!(detailed.contains("wireguard-vpn.service")); assert!(detailed.contains("permission denied")); assert!(!detailed.contains("org.freedesktop.DBus.Error")); } #[test] fn every_redacted_service_failure_has_a_readable_tui_category_and_phase() { use crate::app::{RestartFailureKind, RestartReport, RestartStage}; use crate::service::{RestartFailure, RestartPhase}; for (failure, phase, label) in [ ( RestartFailure::JobFailed, RestartPhase::WaitForJob, "job failed", ), ( RestartFailure::JobDependency, RestartPhase::WaitForJob, "job dependency failed", ), ( RestartFailure::JobTimedOut, RestartPhase::WaitForJob, "job timed out", ), ( RestartFailure::JobCanceled, RestartPhase::WaitForJob, "job was canceled", ), ( RestartFailure::JobSkipped, RestartPhase::WaitForJob, "job was skipped", ), ( RestartFailure::JobResultUnknown, RestartPhase::WaitForJob, "job returned an unknown result", ), ( RestartFailure::BusDisconnected, RestartPhase::Connect, "D-Bus connection was lost", ), ( RestartFailure::ManagerOwnerChanged, RestartPhase::WaitForJob, "systemd manager owner changed", ), ( RestartFailure::SubscriptionFailed, RestartPhase::Subscribe, "job subscription failed", ), ( RestartFailure::UnitResolutionFailed, RestartPhase::ResolveUnit, "unit could not be loaded", ), ( RestartFailure::PermissionDenied, RestartPhase::RequestRestart, "permission denied", ), ( RestartFailure::RequestFailed, RestartPhase::RequestRestart, "systemd request failed", ), ( RestartFailure::WaitTimedOutMayStillComplete, RestartPhase::WaitForJob, "operation timed out and may still complete", ), ( RestartFailure::EarlyEventBufferFull, RestartPhase::WaitForJob, "job event buffer was full", ), ( RestartFailure::InvalidJobPath, RestartPhase::WaitForJob, "systemd returned an invalid job path", ), ] { let report = CommandResult::Restarted(RestartReport { stage: RestartStage::Service, failure: Some(RestartFailureKind::ServiceFailed), service_failure: Some(failure), service_phase: Some(phase), systemd_unit: Some("wg-quick@wg0.service".to_owned()), backup_path: None, restart_submitted: true, restart_may_still_complete: true, manual_recovery_required: true, }); let message = result_message(&report); assert!(message.contains(label), "missing {failure:?}: {message}"); assert!(message.contains("wg-quick@wg0.service")); assert!(message.contains(restart_phase_label(phase))); assert!(!message.contains("org.freedesktop.DBus.Error")); } let hostile_unit = CommandResult::Restarted(RestartReport { stage: RestartStage::Service, failure: Some(RestartFailureKind::ServiceFailed), service_failure: Some(RestartFailure::PermissionDenied), service_phase: Some(RestartPhase::ResolveUnit), systemd_unit: Some("wg0\u{1b}[31m.service".to_owned()), backup_path: None, restart_submitted: false, restart_may_still_complete: false, manual_recovery_required: false, }); assert!(!result_message(&hostile_unit).contains('\u{1b}')); } #[test] fn terminal_error_waits_for_worker_and_records_its_submitted_result() { let operation = super::super::state::PendingOperation { request_id: 9, kind: OperationKind::Commit, generation: 1, }; let view = SessionView { generation: 1, clients: Vec::new(), client_states: Default::default(), saved_peer_count: Some(0), document_issues: Vec::new(), applied: ApplyStatus::Unknown, reserved_addresses: Default::default(), statistics: RuntimeObservation::unknown(), has_draft: false, vault_initialized: true, application: None, }; let mut worker = FakeWorker { shutdown_calls: 0, events: VecDeque::from([ WorkerEvent::OperationStarted(operation), WorkerEvent::OperationFinished { operation, result: Err(OperationError { message: "synthetic failure".to_owned(), }), view: Box::new(view), }, ]), }; let outcomes = await_worker_after_terminal_error(&mut worker, None, None); assert_eq!(worker.shutdown_calls, 3); assert_eq!( outcomes, ["Submitted operation 9 (Commit) failed: synthetic failure."] ); } #[test] fn panic_cleanup_hook_is_restored_when_tui_scope_ends() { static HOOK_TEST_LOCK: Mutex<()> = Mutex::new(()); let _lock = HOOK_TEST_LOCK.lock().unwrap(); let original = std::panic::take_hook(); let previous_called = Arc::new(AtomicBool::new(false)); let called = Arc::clone(&previous_called); std::panic::set_hook(Box::new(move |_| { called.store(true, Ordering::Release); })); let guard = PanicHookGuard::install(Arc::new(Lifecycle::new())); let result = std::panic::catch_unwind(|| panic!("synthetic marker")); assert!(result.is_err()); assert!(!previous_called.load(Ordering::Acquire)); drop(guard); let _ = std::panic::catch_unwind(|| panic!("verify prior hook")); assert!(previous_called.load(Ordering::Acquire)); let _temporary = std::panic::take_hook(); std::panic::set_hook(original); } #[test] fn lifecycle_failures_restore_terminal_modes_in_isolated_pty() { const CHILD: &str = "WG_ADMIN_LIFECYCLE_PROBE"; if let Some(scenario) = std::env::var_os(CHILD) { let scenario = scenario.to_string_lossy(); let result = with_terminal_lifecycle(|lifecycle| { lifecycle.enable_raw()?; if scenario == "partial-raw" { return Err(TerminalError::Setup); } let mut stdout = io::stdout(); lifecycle.enter_alternate(&mut stdout)?; if scenario == "partial-alternate" { return Err(TerminalError::Setup); } lifecycle.hide_cursor(&mut stdout)?; match scenario.as_ref() { "render-error" | "input-error" => Err(TerminalError::Io), "panic" => panic!("synthetic lifecycle panic marker"), _ => Err(TerminalError::Unexpected), } }); let expected = if scenario == "panic" { TerminalError::Unexpected } else if scenario == "render-error" || scenario == "input-error" { TerminalError::Io } else { TerminalError::Setup }; assert_eq!(result, Err(expected)); assert!(!crossterm::terminal::is_raw_mode_enabled().unwrap()); return; } if Command::new("script").arg("--version").output().is_err() { eprintln!("PTY lifecycle checks incomplete: `script` utility is unavailable"); return; } let executable = std::env::current_exe().expect("test executable path"); for (scenario, minimum_alt_enters, minimum_cursor_hides) in [ ("partial-raw", 0, 0), ("partial-alternate", 1, 0), ("render-error", 1, 1), ("input-error", 1, 1), ("panic", 1, 1), ] { let command = format!( "stty cols 80 rows 24; env {CHILD}={scenario} {} --exact tui::terminal::tests::lifecycle_failures_restore_terminal_modes_in_isolated_pty --nocapture", shell_quote(&executable.to_string_lossy()) ); let output = Command::new("script") .args(["-qefc", &command, "/dev/null"]) .output() .expect("run isolated lifecycle PTY probe"); assert!( output.status.success(), "PTY lifecycle probe {scenario} succeeded: {}", String::from_utf8_lossy(&output.stdout) ); let transcript = String::from_utf8_lossy(&output.stdout); assert_eq!( transcript.matches("\u{1b}[?1049h").count(), minimum_alt_enters, "alternate screen entered for {scenario}" ); assert_eq!( transcript.matches("\u{1b}[?1049l").count(), minimum_alt_enters, "alternate screen restored for {scenario}" ); assert_eq!( transcript.matches("\u{1b}[?25l").count(), minimum_cursor_hides, "cursor hidden for {scenario}" ); assert_eq!( transcript.matches("\u{1b}[?25h").count(), minimum_cursor_hides, "cursor shown for {scenario}" ); if scenario == "panic" { assert!( transcript .contains("wireguard-admin encountered an unexpected internal failure") ); assert!(!transcript.contains("synthetic lifecycle panic marker")); } } } fn shell_quote(value: &str) -> String { format!("'{}'", value.replace('\'', "'\\''")) } }