Files
wireguard-admin/src/tui/terminal.rs
T

1739 lines
64 KiB
Rust

//! 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<Lifecycle>);
impl Drop for TerminalGuard {
fn drop(&mut self) {
self.0.restore();
}
}
type PanicHook = Box<dyn Fn(&std::panic::PanicHookInfo<'_>) + Send + Sync + 'static>;
struct PanicHookGuard(Option<PanicHook>);
impl PanicHookGuard {
fn install(lifecycle: Arc<Lifecycle>) -> 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<Lifecycle>) -> 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<Lifecycle>,
) -> 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<CrosstermBackend<io::Stdout>>,
worker: &mut ApplicationWorker,
state: &mut UiState,
forms: &mut FormController,
last_operation_outcome: &mut Option<String>,
) -> 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<WorkerEvent>;
}
impl WorkerLifecycle for ApplicationWorker {
fn shutdown(&mut self, timeout: Duration) -> ShutdownProgress {
ApplicationWorker::shutdown(self, timeout)
}
fn try_receive(&mut self) -> Option<WorkerEvent> {
ApplicationWorker::try_receive(self)
}
}
fn await_worker_after_terminal_error(
worker: &mut impl WorkerLifecycle,
initially_pending: Option<super::state::PendingOperation>,
previous_outcome: Option<String>,
) -> Vec<String> {
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<super::state::PendingOperation>,
outcomes: &mut Vec<String>,
) {
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<String>,
) {
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<WorkerEvent>,
}
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<WorkerEvent> {
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::<String>()
})
.collect::<Vec<_>>()
.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('\'', "'\\''"))
}
}