//! Secret-free navigation and session projections for the terminal interface. use std::collections::BTreeSet; use std::net::IpAddr; use crate::app::{Application, ClientDisplay}; use crate::domain::{Client, PublicKey, RuntimeObservation}; use crate::runtime::RuntimeObservation as Statistics; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Screen { ClientList, ClientDetails, Help, Progress, Message, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Focus { ClientList, Details, Dialog, Help, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum DialogKind { Rename, Delete, Create, Commit, Restart, Exit, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum AppliedState { Unknown, Unavailable, Verified, } /// Public client values for both states. No configuration bytes or secrets are /// retained in a view snapshot. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ClientStateRow { pub saved: Option, pub draft: Option, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct SessionView { pub generation: u64, pub clients: Vec, pub client_states: std::collections::BTreeMap, pub document_issues: Vec, pub applied: AppliedState, pub reserved_addresses: BTreeSet, pub statistics: Statistics, pub has_draft: bool, pub vault_initialized: bool, } impl SessionView { pub fn from_application(app: &Application, generation: u64) -> Self { let states = app.states(); let mut identities = BTreeSet::new(); identities.extend(states.saved().clients().map(|client| client.identity)); identities.extend(states.draft().clients().map(|client| client.identity)); let client_states = identities .into_iter() .map(|identity| { ( identity, ClientStateRow { saved: states.saved().get(&identity).cloned(), draft: states.draft().get(&identity).cloned(), }, ) }) .collect(); let applied = match states.applied() { RuntimeObservation::Unknown => AppliedState::Unknown, RuntimeObservation::Unavailable => AppliedState::Unavailable, RuntimeObservation::Verified => AppliedState::Verified, }; let projection = app.display_projection(); Self { generation, clients: projection.clients, client_states, document_issues: projection.document_issues, applied, reserved_addresses: states.reserved_addresses().clone(), statistics: Statistics::unknown(), has_draft: app.has_draft_changes(), vault_initialized: app.vault_initialized(), } } pub fn contains_identity(&self, identity: &PublicKey) -> bool { self.clients .iter() .any(|client| client.identity == Some(*identity)) } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum OperationKind { CreateClient, RenameClient, DeleteClient, DiscardDraft, Commit, CreateBackup, ListBackups, ImportSelectedFile, InitialSetup, PreviewRestart, Restart, DeliverClient, Restore, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct PendingOperation { pub request_id: u64, pub kind: OperationKind, pub generation: u64, } /// Mutable UI navigation and latest accepted worker projection. #[derive(Clone, Debug, Eq, PartialEq)] pub struct UiState { pub screen: Screen, pub focus: Focus, pub dialog: Option, pub selected_identity: Option, /// Authoritative document index used only to inspect malformed peers that /// have no public key. Mutating actions must always use selected_identity. pub selected_peer_index: Option, pub scroll_offset: usize, pub generation: u64, pub view: Option, pub pending: Option, pub status_message: Option, } impl Default for UiState { fn default() -> Self { Self { screen: Screen::ClientList, focus: Focus::ClientList, dialog: None, selected_identity: None, selected_peer_index: None, scroll_offset: 0, generation: 0, view: None, pending: None, status_message: None, } } } impl UiState { /// Replace the view only when it belongs to the current generation. The /// selected full public key remains stable through sorting and refreshes. pub fn apply_view(&mut self, view: SessionView) -> bool { if view.generation != self.generation { return false; } if self .selected_identity .is_some_and(|identity| !view.contains_identity(&identity)) { self.selected_identity = None; } if let Some(identity) = self.selected_identity { self.selected_peer_index = view .clients .iter() .find(|client| client.identity == Some(identity)) .map(|client| client.peer_index); } else if self .selected_peer_index .is_some_and(|index| !view.clients.iter().any(|client| client.peer_index == index)) { self.selected_peer_index = None; } self.view = Some(view); true } /// Apply only statistics sampled from the still-current saved/draft view. /// This never changes the applied-state marker. pub fn apply_statistics(&mut self, generation: u64, statistics: Statistics) -> bool { if generation != self.generation { return false; } let Some(view) = self.view.as_mut() else { return false; }; if view.generation != generation { return false; } view.statistics = statistics; true } pub fn begin_operation(&mut self, operation: PendingOperation) -> bool { if self.pending.is_some() || operation.generation < self.generation { return false; } self.generation = operation.generation; self.pending = Some(operation); self.screen = Screen::Progress; true } /// Close a form/dialog without cancelling work already submitted to the /// application worker. The pending progress state remains visible. pub fn cancel_dialog(&mut self) { self.dialog = None; if self.pending.is_some() { self.screen = Screen::Progress; } else { self.screen = if self.selected_identity.is_some() { Screen::ClientDetails } else { Screen::ClientList }; } } pub fn finish_operation(&mut self, operation: PendingOperation) -> bool { if self.pending != Some(operation) { return false; } self.pending = None; self.screen = if self.dialog.is_some() { Screen::Message } else if self.selected_identity.is_some() { Screen::ClientDetails } else { Screen::ClientList }; true } pub fn select(&mut self, identity: PublicKey) -> bool { if !self .view .as_ref() .is_some_and(|view| view.contains_identity(&identity)) { return false; } self.selected_identity = Some(identity); self.selected_peer_index = self.view.as_ref().and_then(|view| { view.clients .iter() .find(|client| client.identity == Some(identity)) .map(|client| client.peer_index) }); self.screen = Screen::ClientDetails; true } pub fn select_peer_index(&mut self, peer_index: usize) -> bool { let Some(client) = self.view.as_ref().and_then(|view| { view.clients .iter() .find(|client| client.peer_index == peer_index) }) else { return false; }; self.selected_peer_index = Some(peer_index); self.selected_identity = client.identity; self.screen = Screen::ClientDetails; true } pub fn selected_client_mutable(&self) -> bool { let Some(identity) = self.selected_identity else { return false; }; self.view.as_ref().is_some_and(|view| { view.clients.iter().any(|client| { client.identity == Some(identity) && client.mutable_identity == Some(identity) }) }) } } #[cfg(test)] mod tests { use super::*; use crate::domain::PublicKey; use crate::wireguard::PeerIssue; fn row(identity: PublicKey, label: &str, mutable: bool) -> ClientDisplay { ClientDisplay { peer_index: 0, line: 1, identity: Some(identity), label: label.to_owned(), allowed_ips: vec!["10.0.0.2/32".to_owned()], issues: if mutable { Vec::new() } else { vec![PeerIssue::MissingIpv4HostRoute] }, mutable_identity: mutable.then_some(identity), } } fn view(generation: u64, clients: Vec) -> SessionView { SessionView { generation, clients, client_states: Default::default(), document_issues: Vec::new(), applied: AppliedState::Unknown, reserved_addresses: BTreeSet::new(), statistics: Statistics::unknown(), has_draft: false, vault_initialized: true, } } #[test] fn refresh_preserves_selection_by_public_key_not_alias_or_row_position() { let a = PublicKey::from_bytes([1; 32]); let b = PublicKey::from_bytes([2; 32]); let mut state = UiState::default(); assert!(state.apply_view(view( 0, vec![row(a, "same alias", true), row(b, "same alias", true)] ))); assert!(state.select(b)); assert!(state.apply_view(view( 0, vec![row(b, "renamed", true), row(a, "same alias", true)] ))); assert_eq!(state.selected_identity, Some(b)); } #[test] fn stale_view_is_rejected_and_unsupported_selected_peer_is_not_mutable() { let key = PublicKey::from_bytes([3; 32]); let mut state = UiState::default(); assert!(state.apply_view(view(0, vec![row(key, "legacy", false)]))); assert!(state.select(key)); assert!(!state.selected_client_mutable()); state.generation = 1; assert!(!state.apply_view(view(0, vec![row(key, "stale", true)]))); assert_eq!(state.selected_identity, Some(key)); } #[test] fn malformed_peer_can_be_inspected_by_document_location_without_mutation_identity() { let mut malformed = row(PublicKey::from_bytes([8; 32]), "malformed", false); malformed.identity = None; malformed.mutable_identity = None; malformed.peer_index = 3; let mut state = UiState::default(); assert!(state.apply_view(view(0, vec![malformed]))); assert!(state.select_peer_index(3)); assert_eq!(state.selected_identity, None); assert_eq!(state.selected_peer_index, Some(3)); assert!(!state.selected_client_mutable()); } #[test] fn cancelling_a_dialog_does_not_claim_submitted_work_was_cancelled() { let mut state = UiState { dialog: Some(DialogKind::Commit), ..UiState::default() }; let operation = PendingOperation { request_id: 4, kind: OperationKind::Commit, generation: 1, }; assert!(state.begin_operation(operation)); state.cancel_dialog(); assert_eq!(state.pending, Some(operation)); assert_eq!(state.screen, Screen::Progress); } #[test] fn statistics_refresh_is_generation_bound_and_never_changes_applied_state() { let key = PublicKey::from_bytes([4; 32]); let mut state = UiState::default(); assert!(state.apply_view(view(0, vec![row(key, "client", true)]))); assert!(state.apply_statistics(0, Statistics::unknown())); assert_eq!(state.view.as_ref().unwrap().applied, AppliedState::Unknown); state.generation = 1; assert!(!state.apply_statistics(0, Statistics::unknown())); } }