1739 lines
64 KiB
Rust
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('\'', "'\\''"))
|
|
}
|
|
}
|