diff --git a/Cargo.lock b/Cargo.lock index b8631b7..c5d5a73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -327,56 +327,6 @@ dependencies = [ "libc", ] -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - [[package]] name = "anyhow" version = "1.0.104" @@ -732,46 +682,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "clap" -version = "4.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - [[package]] name = "client" version = "0.1.0" @@ -839,12 +749,6 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - [[package]] name = "combine" version = "4.6.7" @@ -2185,16 +2089,12 @@ dependencies = [ name = "iota" version = "0.1.0" dependencies = [ - "clap", "iota-cli", "iota-core", "iota-installer", "iota-ipc", "iota-paths", "iota-process-manager", - "iota-terms", - "serde_json", - "serde_yaml", "tokio", "tokio-util", ] @@ -2351,7 +2251,6 @@ dependencies = [ "iota-paths", "iota-state", "iota-storage", - "iota-terms", "iota-util", "omikron-connector", "tokio", @@ -2369,12 +2268,10 @@ dependencies = [ "iota-logger", "iota-state", "iota-storage", - "iota-updater", "iota-util", "libc", "mtp", "omikron-connector", - "serde_yaml", "sysinfo", "tempfile", "tokio", @@ -2568,12 +2465,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - [[package]] name = "itertools" version = "0.14.0" @@ -3284,12 +3175,6 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - [[package]] name = "opaque-debug" version = "0.3.1" diff --git a/README.md b/README.md index aee09ab..8739e1d 100644 --- a/README.md +++ b/README.md @@ -28,27 +28,6 @@ theme: surface An invalid `ui.yaml` value is reported and Iota falls back to ANSI so the TUI can still start. -## Accepting terms without the TUI - -Iota services do not start until the required agreements have been accepted for -the deployment. Use the terminal flow to read each current document and type -the document-specific acceptance phrase: - -```text -iota terms accept -``` - -For a system-managed daemon, accept its deployment-scoped terms as an account -that can write the system Iota state directory (normally via `sudo`): - -```text -sudo iota terms accept --system -``` - -`iota terms status` reports the stored state, and `iota terms show eula`, -`iota terms show tos`, or `iota terms show privacy` displays an individual -document without accepting it. - # Linux daemon installation The system-managed daemon runs as the dedicated `iota` account and listens on diff --git a/client/src/client_connection.rs b/client/src/client_connection.rs index da728d4..b20dca3 100644 --- a/client/src/client_connection.rs +++ b/client/src/client_connection.rs @@ -250,12 +250,6 @@ impl ClientConnection { return; } - if cv.is_type(CommunicationType::ClientStateAck) { - self.send_message(&message_handlers::handle_client_state_ack(&cv)) - .await; - return; - } - // ************************************************ // // Direct messages // // ************************************************ // diff --git a/iota-cli/src/controls/button.rs b/iota-cli/src/controls/button.rs index 1ce27e4..1f35bf5 100644 --- a/iota-cli/src/controls/button.rs +++ b/iota-cli/src/controls/button.rs @@ -3,7 +3,7 @@ use ratatui::{ Frame, layout::{Alignment, Rect}, text::Span, - widgets::Paragraph, + widgets::{Block, Borders, Paragraph}, }; use unicode_width::UnicodeWidthStr; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -39,15 +39,9 @@ pub fn render_button( } }; frame.render_widget( - Paragraph::new(Span::styled( - if button.focused { - format!("› {}", button.label) - } else { - button.label.to_owned() - }, - style, - )) - .alignment(Alignment::Center), + Paragraph::new(Span::styled(button.label, style)) + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::ALL)), area, ); } diff --git a/iota-cli/src/controls/header.rs b/iota-cli/src/controls/header.rs deleted file mode 100644 index 71af229..0000000 --- a/iota-cli/src/controls/header.rs +++ /dev/null @@ -1,62 +0,0 @@ -use crate::theme::ResolvedTheme; -use crate::{ - controls::button::{ActionButton, ButtonIntent, render_button}, - screens::screens::{AppAction, HitMap}, -}; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - text::Span, - widgets::Paragraph, -}; - -/// Shared application bar. The brand cell is deliberately an action so it is -/// a reliable way home from every screen. -pub fn render_header( - frame: &mut Frame, - area: Rect, - title: &str, - theme: &ResolvedTheme, - hits: &mut HitMap, - focused_action: Option, -) { - let cells = Layout::horizontal([ - Constraint::Min(28), - Constraint::Length(12), - Constraint::Length(12), - Constraint::Length(12), - Constraint::Length(8), - ]) - .split(area); - frame.render_widget( - Paragraph::new(Span::styled(format!(" {title}"), theme.surfaces.toolbar)), - cells[0], - ); - hits.register(cells[0], AppAction::OpenMain); - for (index, (area, label, action)) in [ - (cells[1], "Overview", AppAction::OpenOverview), - (cells[2], "Users", AppAction::OpenUsers), - (cells[3], "Settings", AppAction::OpenSettings), - (cells[4], "Quit", AppAction::Quit), - ] - .into_iter() - .enumerate() - { - render_button( - frame, - area, - ActionButton { - label, - intent: if action == AppAction::Quit { - ButtonIntent::Destructive - } else { - ButtonIntent::Neutral - }, - focused: focused_action == Some(index), - enabled: true, - }, - theme, - ); - hits.register(area, action); - } -} diff --git a/iota-cli/src/controls/mod.rs b/iota-cli/src/controls/mod.rs index fae2452..8af4977 100644 --- a/iota-cli/src/controls/mod.rs +++ b/iota-cli/src/controls/mod.rs @@ -2,8 +2,5 @@ pub mod action; pub mod button; pub mod checkbox_group; pub mod choice; -pub mod header; pub mod navigation; -pub mod panel; pub mod radio_group; -pub mod scroll; diff --git a/iota-cli/src/controls/panel.rs b/iota-cli/src/controls/panel.rs deleted file mode 100644 index 7307a03..0000000 --- a/iota-cli/src/controls/panel.rs +++ /dev/null @@ -1,60 +0,0 @@ -use crate::theme::{ChromeMode, ResolvedTheme}; -use ratatui::{ - Frame, - layout::Rect, - widgets::{Block, Borders, Paragraph}, -}; - -/// Draw a conventional outlined panel or a filled surface from the same call -/// site. Screens can migrate without embedding theme branches in layouts. -pub fn render_panel( - frame: &mut Frame, - area: Rect, - title: &str, - focused: bool, - theme: &ResolvedTheme, -) -> Rect { - match theme.chrome { - ChromeMode::Bordered => { - let block = Block::default() - .title(title) - .borders(Borders::ALL) - .border_style(if focused { - theme.borders.focused - } else { - theme.borders.normal - }); - let inner = block.inner(area); - frame.render_widget(block, area); - inner - } - ChromeMode::Surfaces => { - frame.render_widget( - Block::default().style(if focused { - theme.surfaces.panel_focused - } else { - theme.surfaces.panel - }), - area, - ); - let header = Rect { - x: area.x, - y: area.y, - width: area.width, - height: area.height.min(1), - }; - frame.render_widget( - Paragraph::new(format!(" {title}")).style(theme.surfaces.panel_alternate), - header, - ); - // Surface panels use a single header row. A one-cell inset keeps - // compact controls such as the console usable at height three. - Rect { - x: area.x.saturating_add(1), - y: area.y.saturating_add(1), - width: area.width.saturating_sub(2), - height: area.height.saturating_sub(1), - } - } - } -} diff --git a/iota-cli/src/controls/scroll.rs b/iota-cli/src/controls/scroll.rs deleted file mode 100644 index 585bf3c..0000000 --- a/iota-cli/src/controls/scroll.rs +++ /dev/null @@ -1,53 +0,0 @@ -use ratatui::{ - Frame, - layout::Rect, - widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}, -}; - -/// Reusable viewport policy for long, vertically stacked terminal content. -#[derive(Clone, Copy, Debug)] -pub struct ScrollOptions { - pub show_scrollbar: bool, - pub render_partial_components: bool, -} -impl Default for ScrollOptions { - fn default() -> Self { - Self { - show_scrollbar: true, - render_partial_components: true, - } - } -} - -#[derive(Clone, Debug, Default)] -pub struct ScrollField { - pub offset: u16, - pub options: ScrollOptions, -} -impl ScrollField { - pub fn up(&mut self, amount: u16) { - self.offset = self.offset.saturating_sub(amount); - } - pub fn down(&mut self, amount: u16, content_height: u16, viewport_height: u16) { - self.offset = (self.offset.saturating_add(amount)) - .min(content_height.saturating_sub(viewport_height)); - } - pub fn render( - &self, - frame: &mut Frame, - area: Rect, - content: Paragraph<'_>, - content_height: u16, - ) { - frame.render_widget(content.scroll((self.offset, 0)), area); - if self.options.show_scrollbar && content_height > area.height { - let mut state = - ScrollbarState::new(content_height as usize).position(self.offset as usize); - frame.render_stateful_widget( - Scrollbar::new(ScrollbarOrientation::VerticalRight), - area, - &mut state, - ); - } - } -} diff --git a/iota-cli/src/elements/console_card.rs b/iota-cli/src/elements/console_card.rs index db861e6..af69a24 100644 --- a/iota-cli/src/elements/console_card.rs +++ b/iota-cli/src/elements/console_card.rs @@ -1,4 +1,4 @@ -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ Frame, layout::Rect, @@ -35,10 +35,6 @@ pub struct ConsoleCard { last_swap: Arc>, pending_restore: Arc>>, pending_confirmation: Option, - history: Vec, - history_index: Option, - history_draft: String, - message: Option, } impl ConsoleCard { @@ -55,10 +51,6 @@ impl ConsoleCard { last_swap: Arc::new(Mutex::new(Instant::now())), pending_restore: Arc::new(Mutex::new(None)), pending_confirmation: None, - history: Vec::new(), - history_index: None, - history_draft: String::new(), - message: None, } } @@ -159,9 +151,6 @@ impl ConsoleCard { } fn render_cursor_spans(&self, theme: &crate::theme::ResolvedTheme) -> Vec> { - if let Some(message) = &self.message { - return vec![Span::styled(message.clone(), theme.console.error)]; - } if let Some(command) = &self.pending_confirmation { return vec![Span::styled( format!("Confirm `{command}`? [y/N]"), @@ -174,12 +163,11 @@ impl ConsoleCard { fn is_destructive(command: &str) -> bool { matches!( command.trim_start_matches('/').trim(), - "restart" | "reload" | "stop" | "shutdown" | "regenerate keys" | "identity rotate" + "restart" | "reload" | "stop" | "shutdown" | "regenerate keys" ) || command .trim_start_matches('/') .trim_start() - .split_once(" remove ") - .is_some_and(|(noun, _)| matches!(noun, "user" | "users")) + .starts_with("user remove ") } fn dispatch_command(&self, command: String) { @@ -226,69 +214,6 @@ impl ConsoleCard { self.content.insert(idx, c); self.cursor_position += 1; } - - pub fn handle_paste(&mut self, text: &str) { - let sanitized = text.replace(['\r', '\n'], " "); - let index = self.byte_index(); - self.content.insert_str(index, &sanitized); - self.cursor_position += sanitized.chars().count(); - self.message = None; - } - - fn set_editor(&mut self, value: String) { - self.content = value; - self.cursor_position = self.content.chars().count(); - } - - fn history_previous(&mut self) { - if self.history.is_empty() { - return; - } - let index = match self.history_index { - None => { - self.history_draft = self.content.clone(); - self.history.len() - 1 - } - Some(index) => index.saturating_sub(1), - }; - self.history_index = Some(index); - self.set_editor(self.history[index].clone()); - self.message = None; - } - - fn history_next(&mut self) { - let Some(index) = self.history_index else { - return; - }; - if index + 1 < self.history.len() { - self.history_index = Some(index + 1); - self.set_editor(self.history[index + 1].clone()); - } else { - self.history_index = None; - let draft = std::mem::take(&mut self.history_draft); - self.set_editor(draft); - } - self.message = None; - } - - fn complete(&mut self) -> bool { - let completions = iota_ipc::text_commands::completions(&self.content); - if completions.len() == 1 { - let leading_slash = self.content.starts_with('/'); - self.set_editor(format!( - "{}{}", - if leading_slash { "/" } else { "" }, - completions[0] - )); - self.message = None; - true - } else if completions.len() > 1 { - self.message = Some(format!("Matches: {}", completions.join(", "))); - true - } else { - false - } - } } impl Element for ConsoleCard { @@ -301,21 +226,6 @@ impl Element for ConsoleCard { } fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) { - if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { - let inner = crate::controls::panel::render_panel( - f, - r, - &self.title, - self.focused, - context.theme, - ); - f.render_widget( - Paragraph::new(Line::from(self.render_cursor_spans(context.theme))) - .style(context.theme.console.text), - inner, - ); - return; - } let block = Block::default() .borders(self.borders) .title(self.title.clone()) @@ -394,7 +304,6 @@ impl InteractableElement for ConsoleCard { if let Some(restored) = self.pending_restore.lock().unwrap().take() { self.content = restored; self.cursor_position = self.content.chars().count(); - self.message = Some("Command failed; restored for retry.".into()); } if let Some(command) = self.pending_confirmation.take() { @@ -411,22 +320,6 @@ impl InteractableElement for ConsoleCard { } let command = self.content.clone(); - if let Some(error) = iota_ipc::text_commands::validation_error(&command) { - self.message = Some(error); - return InteractionResult::Handled; - } - if command.trim_start_matches('/').trim() == "help" { - self.message = Some(format!( - "Commands: {}", - iota_ipc::text_commands::COMMANDS.join(", ") - )); - return InteractionResult::Handled; - } - if self.history.last() != Some(&command) { - self.history.push(command.clone()); - } - self.history_index = None; - self.history_draft.clear(); self.content.clear(); self.cursor_position = 0; if Self::is_destructive(&command) { @@ -437,12 +330,10 @@ impl InteractableElement for ConsoleCard { InteractionResult::Handled } KeyCode::Backspace => { - self.message = None; self.delete_at_cursor(); InteractionResult::Handled } KeyCode::Delete => { - self.message = None; let len = self.content.chars().count(); if self.cursor_position < len { let start = self.byte_index(); @@ -472,35 +363,14 @@ impl InteractableElement for ConsoleCard { self.cursor_position = self.content.chars().count(); InteractionResult::Handled } - KeyCode::Up => { - self.history_previous(); - InteractionResult::Handled - } - KeyCode::Down => { - self.history_next(); - InteractionResult::Handled - } - KeyCode::Tab if !self.content.is_empty() => { - if self.complete() { - InteractionResult::Handled - } else { - self.message = Some("No command completion.".into()); - InteractionResult::Handled - } - } KeyCode::Tab | KeyCode::BackTab => InteractionResult::Unhandled, _ => { - if !key - .modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) - { - if let Some(c) = key.code.as_char() { - self.insert_at_cursor(c); - self.message = None; - return InteractionResult::Handled; - } + if let Some(c) = key.code.as_char() { + self.insert_at_cursor(c); + InteractionResult::Handled + } else { + InteractionResult::Unhandled } - InteractionResult::Unhandled } } } diff --git a/iota-cli/src/elements/graph_card.rs b/iota-cli/src/elements/graph_card.rs index f47aab6..0902b6d 100644 --- a/iota-cli/src/elements/graph_card.rs +++ b/iota-cli/src/elements/graph_card.rs @@ -34,24 +34,15 @@ impl GRAPHS { } } - pub fn get_graph(&self, state: &ClientState, sample_width: usize) -> Vec<(f64, f64)> { + pub fn get_graph(&self, state: &ClientState) -> Vec<(f64, f64)> { let state = match state.app.try_lock() { Ok(state) => state, Err(_) => return Vec::new(), }; match self { - GRAPHS::Ram => state - .with_width(sample_width.min(u16::MAX as usize) as u16) - .ram - .clone(), - GRAPHS::Cpu => state - .with_width(sample_width.min(u16::MAX as usize) as u16) - .cpu - .clone(), - GRAPHS::Ping => state - .with_width(sample_width.min(u16::MAX as usize) as u16) - .ping - .clone(), + GRAPHS::Ram => state.with_width(28).ram.clone(), + GRAPHS::Cpu => state.with_width(28).cpu.clone(), + GRAPHS::Ping => state.with_width(28).ping.clone(), } } @@ -78,7 +69,6 @@ pub struct GraphCard { joins: Borders, open: bool, - sample_width: usize, } impl GraphCard { @@ -92,17 +82,12 @@ impl GraphCard { borders: Borders::ALL, joins: Borders::NONE, open: true, - sample_width: 28, } } pub fn set_open(&mut self, open: bool) { self.open = open; } - - pub fn set_sample_width(&mut self, sample_width: usize) { - self.sample_width = sample_width.max(1); - } } impl Element for GraphCard { fn as_any(&self) -> &dyn Any { @@ -115,24 +100,7 @@ impl Element for GraphCard { fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) { if self.open { - let graph = self.graph_type.get_graph(&self.state, self.sample_width); - if graph.is_empty() { - let block = Block::default() - .title(format!(" {} ", self.title)) - .borders(self.borders) - .border_style(if self.focused { - context.theme.graphs.focused_border - } else { - context.theme.graphs.border - }); - f.render_widget( - ratatui::widgets::Paragraph::new("No metric samples yet.") - .style(context.theme.text.muted) - .block(block), - r, - ); - return; - } + let graph = self.graph_type.get_graph(&self.state); let unit = self.graph_type.get_unit(); let min_x = graph.first().map(|(x, _)| *x).unwrap_or(0.0); let max_x = graph.last().map(|(x, _)| *x).unwrap_or(100.0); @@ -149,34 +117,16 @@ impl Element for GraphCard { GRAPHS::Ping => (max_y * 1.2).max(10.0), }; - let surface = matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces); - let title = format!( - "{}: {}{} {}min/{}max", - self.title, - graph.last().unwrap_or(&(0.0, 0.0)).1 as i64, - unit, - min_y as i64, - max_y as i64 - ); - let plot_area = if surface { - crate::controls::panel::render_panel(f, r, &title, self.focused, context.theme) - } else { - r - }; let block = Block::default() - .title(if surface { - String::new() - } else { - format!( - "{}:─{}{}─{}min/{}max", - self.title, - graph.last().unwrap_or(&(0.0, 0.0)).1 as i64, - unit, - min_y as i64, - max_y as i64, - ) - }) - .borders(if surface { Borders::NONE } else { self.borders }) + .title(format!( + "{}:─{}{}─{}min/{}max", + self.title, + graph.last().unwrap_or(&(0.0, 0.0)).1 as i64, + unit, + min_y as i64, + max_y as i64, + )) + .borders(self.borders) .border_style(if self.focused { context.theme.graphs.focused_border } else { @@ -198,7 +148,7 @@ impl Element for GraphCard { }); } }); - f.render_widget(canvas, plot_area); + f.render_widget(canvas, r); } else { let block = Block::default() .title("") @@ -210,19 +160,17 @@ impl Element for GraphCard { }); f.render_widget(block, r); } - if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { - draw_block_joins( - f, - r, - self.borders, - self.joins, - if self.focused { - context.theme.borders.focused - } else { - context.theme.borders.normal - }, - ); - } + draw_block_joins( + f, + r, + self.borders, + self.joins, + if self.focused { + context.theme.borders.focused + } else { + context.theme.borders.normal + }, + ); } } diff --git a/iota-cli/src/elements/log_card.rs b/iota-cli/src/elements/log_card.rs index a7d72eb..23c7107 100755 --- a/iota-cli/src/elements/log_card.rs +++ b/iota-cli/src/elements/log_card.rs @@ -1,7 +1,7 @@ use crate::elements::elements::{Element, InteractableElement, JoinableElement}; use crate::util::borders::draw_block_joins; use crate::{interaction_result::InteractionResult, render_context::RenderContext}; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crossterm::event::{KeyCode, KeyEvent}; use iota_state::{ClientState, UiLogEntry}; use ratatui::{ Frame, @@ -10,10 +10,7 @@ use ratatui::{ text::{Line, Span}, widgets::{Block, Borders, Paragraph}, }; -use std::{ - any::Any, - sync::atomic::{AtomicUsize, Ordering}, -}; +use std::any::Any; use unicode_width::UnicodeWidthChar; #[derive(Clone, Copy)] @@ -58,11 +55,8 @@ pub struct LogCard { focused: bool, selected: bool, scroll_offset: usize, - last_total_lines: AtomicUsize, - last_visible_height: AtomicUsize, - last_width: AtomicUsize, - filter: String, - filtering: bool, + last_total_lines: usize, + last_visible_height: usize, pub borders: Borders, pub joins: Borders, } @@ -74,11 +68,8 @@ impl LogCard { focused: false, selected: false, scroll_offset: 0, - last_total_lines: AtomicUsize::new(0), - last_visible_height: AtomicUsize::new(1), - last_width: AtomicUsize::new(1), - filter: String::new(), - filtering: false, + last_total_lines: 0, + last_visible_height: 10, borders: Borders::ALL, joins: Borders::NONE, } @@ -89,15 +80,9 @@ impl LogCard { Ok(state) => state, Err(_) => return Vec::new(), }; - let needle = self.filter.to_ascii_lowercase(); state .get_logs() .iter() - .filter(|entry| { - needle.is_empty() - || entry.sender.to_ascii_lowercase().contains(&needle) - || entry.message.to_ascii_lowercase().contains(&needle) - }) .map(|e| UiLogEntry { timestamp_ms: e.timestamp_ms, sender: e.sender.clone(), @@ -229,13 +214,11 @@ impl LogCard { } fn get_title_hints(&self) -> (bool, bool) { - let total_lines = self.last_total_lines.load(Ordering::Relaxed); - let visible_height = self.last_visible_height.load(Ordering::Relaxed); - if total_lines == 0 || total_lines <= visible_height { + if self.last_total_lines == 0 || self.last_total_lines <= self.last_visible_height { return (false, false); } - let max_offset = total_lines - visible_height; + let max_offset = self.last_total_lines - self.last_visible_height; let can_scroll_up = self.scroll_offset < max_offset; let can_scroll_down = self.scroll_offset > 0; @@ -243,12 +226,6 @@ impl LogCard { } fn build_title(&self) -> String { - if self.filtering { - return format!("Logs filter: {}_", self.filter); - } - if !self.filter.is_empty() { - return format!("Logs [filter: {}]", self.filter); - } if !self.focused { return "Logs".to_string(); } @@ -273,8 +250,7 @@ impl LogCard { fn scroll_up(&mut self) { let max_offset = self .last_total_lines - .load(Ordering::Relaxed) - .saturating_sub(self.last_visible_height.load(Ordering::Relaxed)); + .saturating_sub(self.last_visible_height); self.scroll_offset = (self.scroll_offset + 1).min(max_offset); } @@ -321,27 +297,17 @@ impl Element for LogCard { fn render(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) { let entries = self.get_logs(); - let inner_area = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { - crate::controls::panel::render_panel( - f, - area, - &self.build_title(), - self.focused, - context.theme, - ) - } else { - let block = Block::default() - .title(self.build_title()) - .borders(self.borders) - .border_style(if self.focused { - context.theme.logs.focused_border - } else { - context.theme.logs.border - }); - let inner = block.inner(area); - f.render_widget(block, area); - inner - }; + let block = Block::default() + .title(self.build_title()) + .borders(self.borders) + .border_style(if self.focused { + context.theme.logs.focused_border + } else { + context.theme.logs.border + }); + + let inner_area = block.inner(area); + f.render_widget(block, area); if inner_area.width == 0 || inner_area.height == 0 { draw_block_joins( @@ -361,11 +327,6 @@ impl Element for LogCard { let all_lines = self.build_all_lines(entries, inner_area.width as usize); let total_lines = all_lines.len(); let visible_height = inner_area.height as usize; - self.last_width - .store(inner_area.width as usize, Ordering::Relaxed); - self.last_total_lines.store(total_lines, Ordering::Relaxed); - self.last_visible_height - .store(visible_height, Ordering::Relaxed); let (start, end) = self.calculate_view_window(total_lines, visible_height); let visible_lines = &all_lines[start..end]; @@ -376,11 +337,6 @@ impl Element for LogCard { let mut spans = Vec::new(); let (prefix, rest) = Self::split_line_prefix(line); - let prefix = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { - "" - } else { - prefix - }; if !prefix.is_empty() { spans.push(Span::styled( @@ -421,19 +377,17 @@ impl Element for LogCard { f.render_widget(Paragraph::new(line.clone()), line_area); } - if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { - draw_block_joins( - f, - area, - self.borders, - self.joins, - if self.focused { - context.theme.borders.focused - } else { - context.theme.borders.normal - }, - ); - } + draw_block_joins( + f, + area, + self.borders, + self.joins, + if self.focused { + context.theme.borders.focused + } else { + context.theme.borders.normal + }, + ); } } @@ -481,44 +435,14 @@ impl InteractableElement for LogCard { } fn interact(&mut self, key: KeyEvent) -> InteractionResult { - if self.filtering { - match key.code { - KeyCode::Esc => { - self.filtering = false; - self.filter.clear(); - } - KeyCode::Enter => self.filtering = false, - KeyCode::Backspace => { - self.filter.pop(); - } - KeyCode::Char(c) - if !key - .modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => - { - self.filter.push(c); - } - _ => {} - } - self.scroll_offset = 0; - return InteractionResult::Handled; - } let entries = self.get_logs(); - let width = self.last_width.load(Ordering::Relaxed).max(1); - let all_lines = self.build_all_lines(entries, width); + let estimated_width = 80usize; + let all_lines = self.build_all_lines(entries, estimated_width); - self.last_total_lines - .store(all_lines.len(), Ordering::Relaxed); - let total_lines = all_lines.len(); - let visible_height = self.last_visible_height.load(Ordering::Relaxed).max(1); + self.last_total_lines = all_lines.len(); + let visible_height = self.last_visible_height.max(1); match key.code { - KeyCode::Char('/') => { - self.filtering = true; - self.filter.clear(); - self.scroll_offset = 0; - InteractionResult::Handled - } KeyCode::Enter | KeyCode::Char(' ') => { self.selected = !self.selected; InteractionResult::Handled @@ -552,8 +476,8 @@ impl InteractableElement for LogCard { InteractionResult::Handled } KeyCode::Home => { - if total_lines > visible_height { - self.scroll_offset = total_lines - visible_height; + if self.last_total_lines > visible_height { + self.scroll_offset = self.last_total_lines - visible_height; } InteractionResult::Handled } diff --git a/iota-cli/src/input_handler.rs b/iota-cli/src/input_handler.rs index 842f89c..3080480 100644 --- a/iota-cli/src/input_handler.rs +++ b/iota-cli/src/input_handler.rs @@ -1,4 +1,4 @@ -use crate::{screens::screens::UiEvent, ui::UI}; +use crate::ui::UI; use crossterm::event::{Event, KeyEvent, KeyEventKind, KeyModifiers, poll, read}; use std::sync::Arc; use std::time::Duration; @@ -27,9 +27,8 @@ pub fn setup_input_handler(ui: Arc) -> JoinHandle> { tokio::select! { event = rx.recv() => match event { Some(Event::Key(key)) if key.kind == KeyEventKind::Press => handle_input(key, ui.clone()).await, - Some(Event::Mouse(mouse)) => ui.clone().handle_event(UiEvent::Mouse(mouse)).await, - Some(Event::Resize(width, height)) => ui.clone().handle_event(UiEvent::Resize(width, height)).await, - Some(Event::Paste(text)) => ui.clone().handle_event(UiEvent::Paste(text)).await, + Some(Event::Resize(_, _)) => ui.invalidate(), + Some(Event::Paste(text)) => ui.handle_paste(text).await, Some(_) => {}, None => break, }, @@ -56,6 +55,6 @@ pub async fn handle_input(key: KeyEvent, ui: Arc) { { ui.request_shutdown(); } else { - ui.handle_event(UiEvent::Key(key)).await; + ui.handle_input(key).await; } } diff --git a/iota-cli/src/interaction_result.rs b/iota-cli/src/interaction_result.rs index 8afad00..f9ba7e0 100644 --- a/iota-cli/src/interaction_result.rs +++ b/iota-cli/src/interaction_result.rs @@ -2,7 +2,7 @@ use std::fmt::{Debug, Formatter}; use std::future::Future; use std::pin::Pin; -use crate::screens::screens::{Screen, UiEvent}; +use crate::screens::screens::Screen; #[allow(unused)] pub enum InteractionResult { @@ -13,9 +13,6 @@ pub enum InteractionResult { OpenFutureScreen { screen: Pin> + Send>>, }, - AppTask { - task: Pin + Send>>, - }, Handled, Unhandled, } @@ -25,7 +22,6 @@ impl Debug for InteractionResult { match self { InteractionResult::OpenScreen { screen: _ } => write!(f, "OpenScreen"), InteractionResult::OpenFutureScreen { screen: _ } => write!(f, "OpenFutureScreen"), - InteractionResult::AppTask { task: _ } => write!(f, "AppTask"), InteractionResult::CloseScreen => write!(f, "CloseScreen"), InteractionResult::Handled => write!(f, "Handled"), InteractionResult::Unhandled => write!(f, "Unhandled"), @@ -40,9 +36,6 @@ impl PartialEq for InteractionResult { InteractionResult::OpenScreen { screen: _ }, InteractionResult::OpenScreen { screen: _ }, ) => true, - (InteractionResult::AppTask { task: _ }, InteractionResult::AppTask { task: _ }) => { - true - } ( InteractionResult::OpenFutureScreen { screen: _ }, InteractionResult::OpenFutureScreen { screen: _ }, diff --git a/iota-cli/src/ipc_client.rs b/iota-cli/src/ipc_client.rs index 3ffa8d1..88a1eba 100644 --- a/iota-cli/src/ipc_client.rs +++ b/iota-cli/src/ipc_client.rs @@ -1,6 +1,6 @@ use iota_ipc::{ ClientMessage, DaemonMessage, HelloAck, LocalRequest, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION, - RequestEnvelope, ResponsePayload, ResponseResult, read_msg, write_msg, + RequestEnvelope, ResponseResult, read_msg, write_msg, }; use iota_state::{ClientState, UiLogEntry}; use std::collections::HashMap; @@ -443,115 +443,6 @@ impl IpcClient { }); } - fn format_payload(payload: &ResponsePayload) -> String { - match payload { - ResponsePayload::Status(status) => { - let mut msg = format!("Phase: {}", status.phase); - if !status.tasks.is_empty() { - msg.push_str(&format!(", Tasks: {}", status.tasks.join(", "))); - } - if let Some(reason) = &status.degraded_reason { - msg.push_str(&format!(", Degraded: {reason}")); - } - msg - } - ResponsePayload::Tasks(tasks) => { - if tasks.is_empty() { - "No active tasks.".into() - } else { - tasks - .iter() - .map(|t| t.name.as_str()) - .collect::>() - .join(", ") - } - } - ResponsePayload::Users(users) => { - if users.is_empty() { - "No users.".into() - } else { - users - .iter() - .map(|u| format!("{} ({})", u.username, u.user_id)) - .collect::>() - .join("\n") - } - } - ResponsePayload::UserCreated { user_id, username } => { - format!("Created user {} ({})", username, user_id) - } - ResponsePayload::UserRemoved { user_id } => { - format!("Removed user {}", user_id) - } - ResponsePayload::Acknowledged { message } => message.clone(), - ResponsePayload::DaemonStatus(status) => status.formatted.clone(), - ResponsePayload::Config(config) => config.yaml.clone(), - ResponsePayload::OmikronStatus(status) => { - let mut msg = format!("Connected: {}", status.connected); - if let Some(id) = status.iota_id { - msg.push_str(&format!("\nIota ID: {}", id)); - } - msg - } - ResponsePayload::Components(components) => { - if components.is_empty() { - "No component health data available.".into() - } else { - components - .iter() - .map(|c| { - let status_str = match c.status { - iota_ipc::HealthStatus::Healthy => "healthy", - iota_ipc::HealthStatus::Degraded => "degraded", - iota_ipc::HealthStatus::Failed => "failed", - }; - format!("{:?}: {}", c.id, status_str) - }) - .collect::>() - .join("\n") - } - } - ResponsePayload::UserDetail(user) => { - let mut msg = format!("User: {} ({})", user.username, user.user_id); - if let Some(ref name) = user.display_name { - msg.push_str(&format!("\nDisplay Name: {name}")); - } - msg.push_str(&format!("\nCreated At: {}", user.created_at)); - if !user.trusted_apps.is_empty() { - msg.push_str(&format!("\nTrusted Apps: {}", user.trusted_apps.join(", "))); - } - msg - } - ResponsePayload::LogEntries(logs) => logs - .entries - .iter() - .map(|e| { - let level = if e.is_error { "ERR" } else { "INF" }; - format!("[{}] {level} {}: {}", e.timestamp_ms, e.sender, e.message) - }) - .collect::>() - .join("\n"), - ResponsePayload::UpdateStatus(status) => { - if status.available { - "Update available.".into() - } else { - "Up to date.".into() - } - } - ResponsePayload::Communities(communities) => { - if communities.is_empty() { - "No communities.".into() - } else { - communities - .iter() - .map(|c| format!("{} ({})", c.title, c.name)) - .collect::>() - .join("\n") - } - } - } - } - fn format_error(code: &iota_ipc::IpcErrorCode) -> &'static str { match code { iota_ipc::IpcErrorCode::InvalidRequest => "The command is not valid.", @@ -605,9 +496,29 @@ impl IpcClient { } /// Parse a legacy console command string into a typed request. - /// Delegates to the shared parser in iota-ipc. pub fn parse_console_command(line: &str) -> Option { - iota_ipc::text_commands::parse(line) + let parts: Vec<&str> = line.trim_start_matches('/').split_whitespace().collect(); + match parts.as_slice() { + ["help"] => None, + ["tasks"] => Some(LocalRequest::ListTasks), + ["user", "add", username] => Some(LocalRequest::CreateUser { + username: username.to_string(), + }), + ["user", "remove", user_id_str] => { + let user_id = user_id_str.parse::().ok()?; + Some(LocalRequest::RemoveUser { user_id }) + } + ["user", "list"] => Some(LocalRequest::ListUsers), + ["reconnect"] => Some(LocalRequest::ReconnectOmikron), + ["regenerate", "keys"] => Some(LocalRequest::RotateIotaIdentity), + ["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit { + intent: iota_ipc::ExitIntent::Restart, + }), + ["shutdown"] | ["stop"] => Some(LocalRequest::RequestProcessExit { + intent: iota_ipc::ExitIntent::Stop, + }), + _ => None, + } } /// Legacy command interface: parse text command, send as typed request. @@ -648,7 +559,7 @@ impl IpcClient { Ok(result) => { let mut state = self.state.app.lock().await; let message = match &result { - ResponseResult::Ok(payload) => Self::format_payload(payload), + ResponseResult::Ok(msg) => msg.clone(), ResponseResult::Error(code) => Self::format_error(code).into(), }; state.push_log(UiLogEntry { @@ -684,8 +595,8 @@ impl IpcClient { .unwrap_or_default() .as_millis(), sender: "Console".into(), - message: if trimmed == "help" { - "Commands: status, tasks, ping, user add , user remove , user list, users, reconnect, regenerate keys, restart, stop, config get, config reload, omikron status, components" + message: if trimmed == "help" { + "Commands: status, tasks, ping, user add , user remove , user list, reconnect, regenerate keys, restart, stop" .into() } else { format!("Unknown command: {}", line) @@ -784,7 +695,7 @@ impl IpcClient { } else { let mut state = self.state.app.lock().await; let message = match &response.result { - ResponseResult::Ok(payload) => Self::format_payload(payload), + ResponseResult::Ok(msg) => msg.clone(), ResponseResult::Error(code) => Self::format_error(code).into(), }; state.push_log(UiLogEntry { diff --git a/iota-cli/src/lib.rs b/iota-cli/src/lib.rs index eee1487..f54720b 100644 --- a/iota-cli/src/lib.rs +++ b/iota-cli/src/lib.rs @@ -8,13 +8,9 @@ pub mod screens { pub mod daemon_setup; pub mod main_screen; pub mod md_viewer; - pub mod metrics; - pub mod overview; pub mod screens; - pub mod settings; pub mod terms_checker; pub mod terms_updater; - pub mod users; } pub mod util { pub mod borders; diff --git a/iota-cli/src/screens/daemon_setup.rs b/iota-cli/src/screens/daemon_setup.rs index b9cae99..2dad4ce 100644 --- a/iota-cli/src/screens/daemon_setup.rs +++ b/iota-cli/src/screens/daemon_setup.rs @@ -6,9 +6,9 @@ use crate::{ }, interaction_result::InteractionResult, render_context::RenderContext, - screens::screens::{HitMap, Screen, UiEvent}, + screens::screens::Screen, }; -use crossterm::event::KeyCode; +use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ Frame, layout::{Constraint, Layout, Rect}, @@ -29,13 +29,7 @@ impl Screen for DaemonStartingScreen { fn as_any_mut(&mut self) -> &mut dyn Any { self } - fn render( - &self, - frame: &mut Frame, - area: Rect, - context: &RenderContext<'_>, - _hits: &mut HitMap, - ) { + fn render(&self, frame: &mut Frame, area: Rect, context: &RenderContext<'_>) { let popup = crate::layout::fit::centered_rect( area, crate::layout::fit::RequiredSize { @@ -57,7 +51,7 @@ impl Screen for DaemonStartingScreen { popup, ); } - fn handle_event(&mut self, _: UiEvent) -> InteractionResult { + fn handle_input(&mut self, _: KeyEvent) -> InteractionResult { InteractionResult::Handled } } @@ -197,13 +191,7 @@ impl Screen for DaemonSetupScreen { fn as_any_mut(&mut self) -> &mut dyn Any { self } - fn render( - &self, - frame: &mut Frame, - area: Rect, - context: &RenderContext<'_>, - _hits: &mut HitMap, - ) { + fn render(&self, frame: &mut Frame, area: Rect, context: &RenderContext<'_>) { let popup = crate::layout::fit::centered_rect( area, crate::layout::fit::RequiredSize { @@ -278,10 +266,7 @@ impl Screen for DaemonSetupScreen { context.theme, ); } - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - let UiEvent::Key(event) = event else { - return InteractionResult::Unhandled; - }; + fn handle_input(&mut self, event: KeyEvent) -> InteractionResult { match event.code { KeyCode::Esc => { self.complete(DaemonSetupDecision::Exit); diff --git a/iota-cli/src/screens/main_screen.rs b/iota-cli/src/screens/main_screen.rs index 40abb83..19fa093 100644 --- a/iota-cli/src/screens/main_screen.rs +++ b/iota-cli/src/screens/main_screen.rs @@ -1,5 +1,4 @@ use crate::{ - controls::button::{ActionButton, ButtonIntent, render_button}, elements::{ console_card::ConsoleCard, elements::{InteractableElement, JoinableElement}, @@ -9,28 +8,19 @@ use crate::{ interaction_result::InteractionResult, ipc_client::{DaemonStatus, IpcConnectionState}, render_context::RenderContext, - screens::{ - overview::OverviewScreen, - screens::{AppAction, HitMap, KeyHint, NavDirection, Screen, UiEvent}, - }, + screens::screens::{NavDirection, Screen}, ui::UI, }; use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{ Frame, - layout::{Constraint, Layout, Rect}, - widgets::Borders, + layout::{Constraint, Layout, Margin, Rect}, + widgets::{Block, Borders}, }; use tokio::sync::watch; -use std::{ - any::Any, - sync::{ - Arc, - atomic::{AtomicU16, Ordering}, - }, -}; +use std::{any::Any, sync::Arc}; pub struct MainScreen { elements: Vec>, @@ -39,38 +29,9 @@ pub struct MainScreen { graphs_open: bool, connection_status_rx: watch::Receiver, daemon_status_rx: watch::Receiver, - layout_width: AtomicU16, } impl MainScreen { - pub fn connection_status(&self) -> watch::Receiver { - self.connection_status_rx.clone() - } - pub fn daemon_status(&self) -> watch::Receiver { - self.daemon_status_rx.clone() - } - fn status_summary(&self) -> String { - let connection = match self.connection_status_rx.borrow().clone() { - IpcConnectionState::Connected => "[OK] Connected".to_owned(), - IpcConnectionState::Connecting => "[..] Connecting".to_owned(), - IpcConnectionState::Reconnecting { .. } => "[WARN] Reconnecting".to_owned(), - IpcConnectionState::Failed { .. } | IpcConnectionState::Incompatible { .. } => { - "[FAIL] Failed".to_owned() - } - IpcConnectionState::Disconnected => "[WARN] Disconnected".to_owned(), - }; - let daemon = self.daemon_status_rx.borrow().clone(); - let version = if daemon.version.is_empty() { - String::new() - } else { - format!(" v{}", daemon.version) - }; - let ready = daemon - .startup_phase - .map(|phase| format!(" {:?}", phase)) - .unwrap_or_default(); - format!("IOTA{version} {connection}{ready}") - } pub async fn new(ui: Arc) -> Self { let mut elements: Vec> = Vec::new(); @@ -119,7 +80,6 @@ impl MainScreen { graphs_open, connection_status_rx, daemon_status_rx, - layout_width: AtomicU16::new(0), }; screen.focus_current(); screen @@ -212,9 +172,6 @@ impl MainScreen { let mut seen: Vec> = Vec::new(); for (y, row) in self.nav_grid.iter().enumerate() { for (x, elem_opt) in row.iter().enumerate() { - if x == 1 && (!self.graphs_open || self.layout_width.load(Ordering::Relaxed) < 70) { - continue; - } if elem_opt.is_some() && !seen.contains(elem_opt) { seen.push(*elem_opt); positions.push((y, x)); @@ -252,8 +209,7 @@ impl Screen for MainScreen { self } - fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap) { - self.layout_width.store(rect.width, Ordering::Relaxed); + fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>) { // A watch Ref blocks senders until it is dropped. Rendering may do // terminal I/O, so retain only owned snapshots for the whole frame. let status = self.connection_status_rx.borrow().clone(); @@ -286,15 +242,25 @@ impl Screen for MainScreen { } else { format!(" v{}", daemon.version) }; - let _ = (status_text, readiness, health, version); - f.render_widget( - ratatui::widgets::Block::default().style(context.theme.surfaces.canvas), - rect, - ); - let inner = rect; + let main_block = Block::default() + .title(format!( + "Iota{version} [{status_text}; {readiness}{health}]" + )) + .borders(Borders::ALL) + .border_style(context.theme.borders.normal) + .title_style(context.theme.borders.title); + f.render_widget(main_block, rect); - let metrics_visible = self.graphs_open && inner.width >= 70; - let graphs_width = if metrics_visible { 30 } else { 0 }; + let inner = rect.inner(Margin { + vertical: 1, + horizontal: 1, + }); + + let graphs_width = if self.graphs_open && inner.width >= 70 { + 30 + } else { + 2 + }; let main_width = inner.width.saturating_sub(graphs_width); let horizontal_chunks = Layout::default() @@ -307,39 +273,9 @@ impl Screen for MainScreen { let left_area = horizontal_chunks[0]; let right_area = horizontal_chunks[1]; - hits.register(left_area, AppAction::FocusLogs); - if metrics_visible { - hits.register(right_area, AppAction::FocusMetrics); - } - - if inner.width >= 70 { - let metrics_button = Rect { - x: right_area.x, - y: right_area.y, - width: right_area.width, - height: 1, - }; - render_button( - f, - metrics_button, - ActionButton { - label: if self.graphs_open { - "Hide metrics" - } else { - "Show metrics" - }, - intent: ButtonIntent::Neutral, - focused: false, - enabled: true, - }, - context.theme, - ); - hits.register(metrics_button, AppAction::ToggleMetrics); - } let left_rows = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(left_area); - hits.register(left_rows[1], AppAction::FocusConsole); if let Some(log) = self.elements.get(0) { log.as_element().render(f, left_rows[0], context); @@ -355,7 +291,7 @@ impl Screen for MainScreen { .filter(|el| el.as_any().is::()) .collect(); - if metrics_visible && !graph_elements.is_empty() { + if !graph_elements.is_empty() { let graph_chunks = Layout::vertical( graph_elements .iter() @@ -370,40 +306,7 @@ impl Screen for MainScreen { } } - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - if let UiEvent::Paste(text) = &event { - if self.selected_coords == (2, 0) { - if let Some(console) = self - .elements - .get_mut(1) - .and_then(|element| element.as_any_mut().downcast_mut::()) - { - console.handle_paste(text); - return InteractionResult::Handled; - } - } - return InteractionResult::Unhandled; - } - if let UiEvent::Resize(width, _) = &event { - self.layout_width.store(*width, Ordering::Relaxed); - if *width < 70 && self.selected_coords.1 == 1 { - self.unfocus_current(self.selected_coords.0, self.selected_coords.1); - self.selected_coords = (0, 0); - self.focus_current(); - } - return InteractionResult::Handled; - } - let UiEvent::Key(event) = event else { - return InteractionResult::Unhandled; - }; - // A focused console consumes text and cursor keys before dashboard - // shortcuts; commands such as `users` must remain typeable. - if self.selected_coords == (2, 0) && !matches!(event.code, KeyCode::Tab | KeyCode::BackTab) - { - if let Some(console) = self.elements.get_mut(1) { - return console.interact(event); - } - } + fn handle_input(&mut self, event: KeyEvent) -> InteractionResult { match event.code { KeyCode::Tab => { self.navigate_focus(true); @@ -413,27 +316,6 @@ impl Screen for MainScreen { self.navigate_focus(false); return InteractionResult::Handled; } - KeyCode::Char('o') | KeyCode::Char('O') => { - let conn_rx = self.connection_status_rx.clone(); - let daemon_rx = self.daemon_status_rx.clone(); - return InteractionResult::OpenScreen { - screen: Box::new(OverviewScreen::new(conn_rx, daemon_rx)), - }; - } - KeyCode::Char('u') | KeyCode::Char('U') => { - return InteractionResult::AppTask { - task: Box::pin(async { - UiEvent::App(crate::screens::screens::AppEvent::OpenUsers) - }), - }; - } - KeyCode::Char('m') | KeyCode::Char('M') => { - return InteractionResult::AppTask { - task: Box::pin(async { - UiEvent::App(crate::screens::screens::AppEvent::OpenMetrics) - }), - }; - } KeyCode::Enter | KeyCode::Char(' ') if self.selected_coords.1 == 1 => { self.graphs_open = !self.graphs_open; for element in self.elements.iter_mut() { @@ -465,109 +347,4 @@ impl Screen for MainScreen { InteractionResult::Handled } - fn handle_action(&mut self, action: AppAction) -> InteractionResult { - match action { - AppAction::ToggleMetrics => { - self.graphs_open = !self.graphs_open; - for element in &mut self.elements { - if let Some(graph) = element.as_any_mut().downcast_mut::() { - graph.set_open(self.graphs_open); - } - } - InteractionResult::Handled - } - AppAction::OpenOverview => { - self.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Char('o')))) - } - AppAction::OpenUsers => { - self.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Char('u')))) - } - AppAction::FocusLogs => { - self.unfocus_current(self.selected_coords.0, self.selected_coords.1); - self.selected_coords = (0, 0); - self.focus_current(); - InteractionResult::Handled - } - AppAction::FocusConsole => { - self.unfocus_current(self.selected_coords.0, self.selected_coords.1); - self.selected_coords = (2, 0); - self.focus_current(); - InteractionResult::Handled - } - AppAction::FocusMetrics => { - self.unfocus_current(self.selected_coords.0, self.selected_coords.1); - self.selected_coords = (0, 1); - self.focus_current(); - InteractionResult::Handled - } - _ => InteractionResult::Unhandled, - } - } - fn app_title(&self) -> String { - self.status_summary() - } - fn key_hints(&self) -> Vec { - if self.selected_coords == (2, 0) { - vec![ - KeyHint { - keys: "Enter", - action: "Send", - }, - KeyHint { - keys: "Up/Down", - action: "History", - }, - KeyHint { - keys: "Tab", - action: "Complete", - }, - KeyHint { - keys: "F6", - action: "Header", - }, - ] - } else if self.selected_coords == (0, 0) { - vec![ - KeyHint { - keys: "J/K", - action: "Scroll logs", - }, - KeyHint { - keys: "Enter", - action: "Lock scroll", - }, - KeyHint { - keys: "/", - action: "Filter", - }, - KeyHint { - keys: "M", - action: "Metrics screen", - }, - KeyHint { - keys: "Tab", - action: "Next panel", - }, - KeyHint { - keys: "F6", - action: "Header", - }, - ] - } else { - vec![ - KeyHint { - keys: "Enter", - action: "Toggle metrics", - }, - KeyHint { - keys: "Tab", - action: "Next panel", - }, - KeyHint { - keys: "F6", - action: "Header", - }, - ] - } - } } diff --git a/iota-cli/src/screens/md_viewer.rs b/iota-cli/src/screens/md_viewer.rs index 8c46d3d..aaf6b9d 100644 --- a/iota-cli/src/screens/md_viewer.rs +++ b/iota-cli/src/screens/md_viewer.rs @@ -1,4 +1,4 @@ -use crossterm::event::{self, Event, KeyCode}; +use crossterm::event::{self, Event, KeyCode, KeyEvent}; use ratatui::{ DefaultTerminal, prelude::*, @@ -10,7 +10,7 @@ use std::{any::Any, time::Duration}; use crate::{ interaction_result::InteractionResult, render_context::RenderContext, - screens::screens::{HitMap, Screen, UiEvent}, + screens::screens::Screen, theme::{ResolvedTheme, TextSemantics, ThemeName}, }; @@ -29,14 +29,11 @@ impl Screen for FileViewer { self } - fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { + fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>) { self.draw(f, rect, context.theme); } - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - let UiEvent::Key(event) = event else { - return InteractionResult::Unhandled; - }; + fn handle_input(&mut self, event: KeyEvent) -> InteractionResult { match event.code { KeyCode::Char('q') | KeyCode::Esc => { return InteractionResult::CloseScreen; diff --git a/iota-cli/src/screens/metrics.rs b/iota-cli/src/screens/metrics.rs deleted file mode 100644 index 1243979..0000000 --- a/iota-cli/src/screens/metrics.rs +++ /dev/null @@ -1,136 +0,0 @@ -use std::any::Any; - -use crossterm::event::KeyCode; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - widgets::{Block, Borders, Paragraph}, -}; - -use crate::{ - elements::{ - elements::Element, - graph_card::{GRAPHS, GraphCard}, - }, - interaction_result::InteractionResult, - render_context::RenderContext, - screens::screens::{AppAction, HitMap, KeyHint, Screen, UiEvent}, - ui::UI, -}; - -const RANGES: &[(usize, &str)] = &[(30, "Recent"), (120, "Medium"), (300, "Long")]; - -pub struct MetricsScreen { - graphs: Vec, - range_index: usize, -} - -impl MetricsScreen { - pub async fn new(ui: std::sync::Arc) -> Option { - let state = ui.client_state().await?; - let mut screen = Self { - graphs: vec![ - GraphCard::new(ui.clone(), state.clone(), GRAPHS::Ram, "RAM".into()), - GraphCard::new(ui.clone(), state.clone(), GRAPHS::Cpu, "CPU".into()), - GraphCard::new(ui, state, GRAPHS::Ping, "Ping".into()), - ], - range_index: 0, - }; - screen.apply_range(); - Some(screen) - } - - fn apply_range(&mut self) { - let width = RANGES[self.range_index].0; - for graph in &mut self.graphs { - graph.set_sample_width(width); - } - } - - fn change_range(&mut self, delta: isize) { - self.range_index = - (self.range_index as isize + delta).clamp(0, RANGES.len() as isize - 1) as usize; - self.apply_range(); - } -} - -impl Screen for MetricsScreen { - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - - fn render( - &self, - frame: &mut Frame, - area: Rect, - context: &RenderContext<'_>, - hits: &mut HitMap, - ) { - let block = Block::default() - .title(" Metrics ") - .borders(Borders::ALL) - .border_style(context.theme.borders.normal); - let inner = block.inner(area); - frame.render_widget(block, area); - let rows = Layout::vertical([ - Constraint::Length(1), - Constraint::Ratio(1, 3), - Constraint::Ratio(1, 3), - Constraint::Ratio(1, 3), - ]) - .split(inner); - frame.render_widget( - Paragraph::new(format!( - "Range: {} ({} samples) Left/Right to change", - RANGES[self.range_index].1, RANGES[self.range_index].0 - )) - .style(context.theme.text.heading), - rows[0], - ); - for (graph, graph_area) in self.graphs.iter().zip(rows[1..].iter()) { - graph.render(frame, *graph_area, context); - } - hits.register(rows[0], AppAction::OpenMetrics); - } - - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - let UiEvent::Key(key) = event else { - return InteractionResult::Unhandled; - }; - match key.code { - KeyCode::Left => { - self.change_range(-1); - InteractionResult::Handled - } - KeyCode::Right => { - self.change_range(1); - InteractionResult::Handled - } - KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => { - InteractionResult::CloseScreen - } - _ => InteractionResult::Unhandled, - } - } - - fn key_hints(&self) -> Vec { - vec![ - KeyHint { - keys: "Left/Right", - action: "Range", - }, - KeyHint { - keys: "Esc/B", - action: "Back", - }, - KeyHint { - keys: "F6", - action: "Header", - }, - ] - } -} diff --git a/iota-cli/src/screens/overview.rs b/iota-cli/src/screens/overview.rs deleted file mode 100644 index 4d4aea6..0000000 --- a/iota-cli/src/screens/overview.rs +++ /dev/null @@ -1,291 +0,0 @@ -use crate::{ - controls::button::{ActionButton, ButtonIntent, render_button}, - interaction_result::InteractionResult, - ipc_client::{DaemonStatus, IpcConnectionState}, - render_context::RenderContext, - screens::screens::{AppAction, HitMap, KeyHint, Screen, UiEvent}, -}; -use crossterm::event::KeyCode; -use ratatui::{ - Frame, - layout::Rect, - text::{Line, Span}, - widgets::{Block, Borders, Paragraph, Wrap}, -}; -use std::{ - any::Any, - sync::atomic::{AtomicUsize, Ordering}, -}; -use tokio::sync::watch; - -pub struct OverviewScreen { - connection_rx: watch::Receiver, - daemon_rx: watch::Receiver, - _focus: Focus, - scroll_offset: usize, - content_height: AtomicUsize, - viewport_height: AtomicUsize, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum Focus { - Back, -} - -impl OverviewScreen { - pub fn new( - connection_rx: watch::Receiver, - daemon_rx: watch::Receiver, - ) -> Self { - Self { - connection_rx, - daemon_rx, - _focus: Focus::Back, - scroll_offset: 0, - content_height: AtomicUsize::new(0), - viewport_height: AtomicUsize::new(1), - } - } - - fn build_lines(&self, theme: &crate::theme::ResolvedTheme) -> Vec> { - let conn = self.connection_rx.borrow().clone(); - let daemon = self.daemon_rx.borrow().clone(); - - let mut lines = Vec::new(); - - lines.push(Line::from(Span::styled("Connection", theme.text.heading))); - lines.push(Line::from(format!(" State: {}", connection_label(&conn)))); - lines.push(Line::from("")); - - lines.push(Line::from(Span::styled("Daemon", theme.text.heading))); - lines.push(Line::from(format!( - " Version: {}", - version_or_unknown(&daemon.version) - ))); - lines.push(Line::from(format!( - " Instance: {}", - truncate_id(&daemon.instance_id) - ))); - - let phase = daemon - .startup_phase - .map(|p| format!("{:?}", p)) - .unwrap_or_else(|| "Unknown".into()); - lines.push(Line::from(format!(" Phase: {}", phase))); - - let lifecycle = daemon - .lifecycle - .map(|l| format!("{:?}", l)) - .unwrap_or_else(|| "Unknown".into()); - lines.push(Line::from(format!(" Lifecycle: {}", lifecycle))); - - let health = match daemon.health { - iota_ipc::HealthStatus::Healthy => "[OK] Healthy", - iota_ipc::HealthStatus::Degraded => "[WARN] Degraded", - iota_ipc::HealthStatus::Failed => "[FAIL] Failed", - }; - lines.push(Line::from(format!(" Health: {health}"))); - - if let Some(ref reason) = daemon.degraded_reason { - lines.push(Line::from(Span::styled( - format!(" Degraded: {reason}"), - theme.status.warning, - ))); - } - - let mode = daemon - .deployment_mode - .map(|m| format!("{:?}", m)) - .unwrap_or_else(|| "Unknown".into()); - lines.push(Line::from(format!(" Deployment: {mode}"))); - - let supervisor = daemon - .supervisor - .map(|s| format!("{:?}", s)) - .unwrap_or_else(|| "Unknown".into()); - lines.push(Line::from(format!(" Supervisor: {supervisor}"))); - - if !daemon.components.is_empty() { - lines.push(Line::from("")); - lines.push(Line::from(Span::styled("Components", theme.text.heading))); - for (id, health) in &daemon.components { - let status_str = match health.status { - iota_ipc::HealthStatus::Healthy => "[OK] healthy", - iota_ipc::HealthStatus::Degraded => "[WARN] degraded", - iota_ipc::HealthStatus::Failed => "[FAIL] failed", - }; - let suffix = health - .message - .as_deref() - .map(|m| format!(" ({m})")) - .unwrap_or_default(); - lines.push(Line::from(format!(" {:?}: {}{}", id, status_str, suffix))); - } - } - - lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - "Press Esc or B to return to the dashboard", - theme.text.muted, - ))); - - lines - } -} - -fn connection_label(conn: &IpcConnectionState) -> String { - match conn { - IpcConnectionState::Connected => "Connected".into(), - IpcConnectionState::Connecting => "Connecting...".into(), - IpcConnectionState::Reconnecting { attempt } => { - format!("Reconnecting (attempt {attempt})...") - } - IpcConnectionState::Incompatible { message } => { - format!("Incompatible: {message}") - } - IpcConnectionState::Failed { message } => format!("Failed: {message}"), - IpcConnectionState::Disconnected => "Disconnected".into(), - } -} - -fn version_or_unknown(v: &str) -> String { - if v.is_empty() { - "Unknown".into() - } else { - v.into() - } -} - -fn truncate_id(id: &str) -> String { - if id.len() > 8 { - format!("{}…", &id[..8]) - } else { - id.into() - } -} - -impl Screen for OverviewScreen { - fn as_any(&self) -> &dyn Any { - self - } - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - - fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { - let block = Block::default() - .title(" Overview ") - .borders(Borders::ALL) - .border_style(context.theme.borders.normal) - .title_style(context.theme.borders.title); - let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { - crate::controls::panel::render_panel(f, rect, "Overview", false, context.theme) - } else { - let inner = block.inner(rect); - f.render_widget(block, rect); - inner - }; - let rows = ratatui::layout::Layout::vertical([ - ratatui::layout::Constraint::Min(1), - ratatui::layout::Constraint::Length(1), - ]) - .split(inner); - - let lines = self.build_lines(context.theme); - self.content_height.store(lines.len(), Ordering::Relaxed); - self.viewport_height - .store(rows[0].height as usize, Ordering::Relaxed); - let par = Paragraph::new(lines) - .wrap(Wrap { trim: true }) - .scroll((self.scroll_offset as u16, 0)); - f.render_widget(par, rows[0]); - render_button( - f, - rows[1], - ActionButton { - label: "Back", - intent: ButtonIntent::Cancel, - focused: self._focus == Focus::Back, - enabled: true, - }, - context.theme, - ); - _hits.register(rows[1], AppAction::Back); - } - - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - let UiEvent::Key(event) = event else { - return InteractionResult::Unhandled; - }; - match event.code { - KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => { - InteractionResult::CloseScreen - } - KeyCode::Down | KeyCode::Char('j') => { - let max = self - .content_height - .load(Ordering::Relaxed) - .saturating_sub(self.viewport_height.load(Ordering::Relaxed)); - self.scroll_offset = self.scroll_offset.saturating_add(1).min(max); - InteractionResult::Handled - } - KeyCode::Up | KeyCode::Char('k') => { - self.scroll_offset = self.scroll_offset.saturating_sub(1); - InteractionResult::Handled - } - KeyCode::PageDown => { - let page = self.viewport_height.load(Ordering::Relaxed).max(1); - let max = self - .content_height - .load(Ordering::Relaxed) - .saturating_sub(page); - self.scroll_offset = self.scroll_offset.saturating_add(page).min(max); - InteractionResult::Handled - } - KeyCode::PageUp => { - let page = self.viewport_height.load(Ordering::Relaxed).max(1); - self.scroll_offset = self.scroll_offset.saturating_sub(page); - InteractionResult::Handled - } - KeyCode::Home => { - self.scroll_offset = 0; - InteractionResult::Handled - } - KeyCode::End => { - self.scroll_offset = self - .content_height - .load(Ordering::Relaxed) - .saturating_sub(self.viewport_height.load(Ordering::Relaxed)); - InteractionResult::Handled - } - _ => InteractionResult::Unhandled, - } - } - fn handle_action(&mut self, action: AppAction) -> InteractionResult { - if action == AppAction::Back { - InteractionResult::CloseScreen - } else { - InteractionResult::Unhandled - } - } - fn key_hints(&self) -> Vec { - vec![ - KeyHint { - keys: "Up/Down", - action: "Scroll", - }, - KeyHint { - keys: "PgUp/PgDn", - action: "Page", - }, - KeyHint { - keys: "Esc/B", - action: "Back", - }, - KeyHint { - keys: "F6", - action: "Header", - }, - ] - } -} diff --git a/iota-cli/src/screens/screens.rs b/iota-cli/src/screens/screens.rs index f2e61f5..b69d980 100644 --- a/iota-cli/src/screens/screens.rs +++ b/iota-cli/src/screens/screens.rs @@ -1,103 +1,10 @@ use std::any::Any; -use crossterm::event::{KeyEvent, MouseEvent}; +use crossterm::event::KeyEvent; use ratatui::{Frame, layout::Rect}; use crate::{interaction_result::InteractionResult, render_context::RenderContext}; -/// All terminal input that can affect the UI. Keeping this as one type makes -/// it impossible for screens to accidentally ignore a newly supported event. -#[derive(Debug, Clone)] -pub enum UiEvent { - Key(KeyEvent), - Mouse(MouseEvent), - Paste(String), - Resize(u16, u16), - App(AppEvent), -} - -/// Completion of background UI work. Keeping it in the regular event stream -/// gives screens an explicit success/failure path instead of detached tasks. -#[derive(Debug, Clone)] -pub enum AppEvent { - OpenUsers, - OpenMetrics, - ApplyTheme { - theme: crate::theme::ThemeName, - persist: bool, - }, - SaveSettings { - theme: crate::theme::ThemeName, - color: crate::theme::TerminalPolicy, - unicode: crate::theme::TerminalPolicy, - }, - ThemeSaved(Result<(), String>), - UsersLoaded(Result, String>), - UserCreated(Result), - UserRemoved { - user_id: i64, - result: Result<(), String>, - }, - RegenerateKeysRequested, - KeysRegenerated(Result<(), String>), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AppAction { - OpenOverview, - OpenUsers, - OpenSettings, - OpenMetrics, - ToggleMetrics, - AddUser, - RemoveUser, - Back, - Quit, - FocusLogs, - FocusConsole, - FocusMetrics, - OpenMain, - SelectUser(usize), - ConfirmDialog, - CancelDialog, - RegenerateKeys, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct KeyHint { - pub keys: &'static str, - pub action: &'static str, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct HitRegion { - pub area: Rect, - pub action: AppAction, -} - -#[derive(Debug, Default, Clone)] -pub struct HitMap { - regions: Vec, -} - -impl HitMap { - pub fn register(&mut self, area: Rect, action: AppAction) { - self.regions.push(HitRegion { area, action }); - } - pub fn action_at(&self, column: u16, row: u16) -> Option { - self.regions - .iter() - .rev() - .find(|region| { - column >= region.area.x - && column < region.area.x.saturating_add(region.area.width) - && row >= region.area.y - && row < region.area.y.saturating_add(region.area.height) - }) - .map(|region| region.action) - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NavDirection { Up, @@ -113,32 +20,6 @@ pub trait Screen: Send + Sync + Any { fn as_any(&self) -> &dyn Any; fn as_any_mut(&mut self) -> &mut dyn Any; - fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap); - fn handle_event(&mut self, event: UiEvent) -> InteractionResult; - fn handle_action(&mut self, _action: AppAction) -> InteractionResult { - InteractionResult::Unhandled - } - fn app_title(&self) -> String { - "IOTA".to_owned() - } - fn key_hints(&self) -> Vec { - vec![ - KeyHint { - keys: "Tab", - action: "Move focus", - }, - KeyHint { - keys: "Enter", - action: "Activate", - }, - KeyHint { - keys: "Esc", - action: "Back", - }, - KeyHint { - keys: "F6", - action: "Header", - }, - ] - } + fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>); + fn handle_input(&mut self, event: KeyEvent) -> InteractionResult; } diff --git a/iota-cli/src/screens/settings.rs b/iota-cli/src/screens/settings.rs deleted file mode 100644 index dd26878..0000000 --- a/iota-cli/src/screens/settings.rs +++ /dev/null @@ -1,415 +0,0 @@ -use std::any::Any; - -use crossterm::event::KeyCode; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - text::{Line, Span}, - widgets::{Block, Borders, Paragraph}, -}; - -use crate::{ - controls::button::{ActionButton, ButtonIntent, render_button}, - interaction_result::InteractionResult, - render_context::RenderContext, - screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent}, - theme::{TerminalPolicy, ThemeName, UiConfig}, -}; - -#[derive(Clone, Copy, PartialEq, Eq)] -enum Focus { - Theme, - RegenerateKeys, - Back, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum Dialog { - ConfirmRegenerateKeys, -} - -pub struct SettingsScreen { - selected: usize, - saved: ThemeName, - message: String, - color: TerminalPolicy, - unicode: TerminalPolicy, - focus: Focus, - dialog: Option, - pending: bool, -} - -impl SettingsScreen { - pub fn new(current: ThemeName) -> Self { - let selected = ThemeName::ALL - .iter() - .position(|theme| *theme == current) - .unwrap_or(0); - Self { - selected, - saved: current, - message: "Left/Right previews. Enter saves.".into(), - color: UiConfig::load() - .map(|config| config.color) - .unwrap_or_default(), - unicode: UiConfig::load() - .map(|config| config.unicode) - .unwrap_or_default(), - focus: Focus::Theme, - dialog: None, - pending: false, - } - } - - fn selected_theme(&self) -> ThemeName { - ThemeName::ALL[self.selected] - } - - fn apply(&self, persist: bool) -> InteractionResult { - let theme = self.selected_theme(); - InteractionResult::AppTask { - task: Box::pin(async move { UiEvent::App(AppEvent::ApplyTheme { theme, persist }) }), - } - } - - fn next_policy(policy: TerminalPolicy) -> TerminalPolicy { - match policy { - TerminalPolicy::Auto => TerminalPolicy::Always, - TerminalPolicy::Always => TerminalPolicy::Never, - TerminalPolicy::Never => TerminalPolicy::Auto, - } - } - - fn next_focus(&mut self) { - self.focus = match self.focus { - Focus::Theme => Focus::RegenerateKeys, - Focus::RegenerateKeys => Focus::Back, - Focus::Back => Focus::Theme, - }; - } - - fn prev_focus(&mut self) { - self.focus = match self.focus { - Focus::Theme => Focus::Back, - Focus::Back => Focus::RegenerateKeys, - Focus::RegenerateKeys => Focus::Theme, - }; - } - - fn activate(&mut self) -> InteractionResult { - if self.pending { - return InteractionResult::Handled; - } - if let Some(dialog) = self.dialog.take() { - match dialog { - Dialog::ConfirmRegenerateKeys => { - self.pending = true; - self.message = "Regenerating keys…".into(); - return InteractionResult::AppTask { - task: Box::pin(async { UiEvent::App(AppEvent::RegenerateKeysRequested) }), - }; - } - } - } - match self.focus { - Focus::Theme => { - self.message = "Saving theme…".into(); - let theme = self.selected_theme(); - let color = self.color; - let unicode = self.unicode; - InteractionResult::AppTask { - task: Box::pin(async move { - UiEvent::App(AppEvent::SaveSettings { - theme, - color, - unicode, - }) - }), - } - } - Focus::RegenerateKeys => { - self.dialog = Some(Dialog::ConfirmRegenerateKeys); - InteractionResult::Handled - } - Focus::Back => InteractionResult::CloseScreen, - } - } -} - -impl Screen for SettingsScreen { - fn as_any(&self) -> &dyn Any { - self - } - - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - - fn render( - &self, - frame: &mut Frame, - area: Rect, - context: &RenderContext<'_>, - hits: &mut HitMap, - ) { - let block = Block::default() - .title(" Settings ") - .borders(Borders::ALL) - .border_style(context.theme.borders.focused); - let inner = block.inner(area); - frame.render_widget(block, area); - let rows = Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).split(inner); - frame.render_widget( - Paragraph::new(format!( - "Theme: < {} >{}\nColor: {:?} (C) Unicode: {:?} (U)", - self.selected_theme(), - if self.selected_theme() == self.saved { - " [saved]" - } else { - " [preview]" - }, - self.color, - self.unicode - )) - .style(context.theme.text.heading), - rows[0], - ); - - let bottom_rows = - Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(rows[1]); - - let lines = vec![ - Line::from(Span::styled(&self.message, context.theme.text.normal)), - Line::from(""), - Line::from("Preview"), - Line::from("[OK] Healthy"), - Line::from("[WARN] Degraded"), - Line::from("[FAIL] Failed"), - Line::from("> Focused action <"), - ]; - frame.render_widget( - Paragraph::new(lines).style(context.theme.text.normal), - bottom_rows[0], - ); - - let buttons_area = Layout::horizontal([ - Constraint::Percentage(33), - Constraint::Percentage(34), - Constraint::Percentage(33), - ]) - .split(bottom_rows[1]); - - render_button( - frame, - buttons_area[0], - ActionButton { - label: "Back", - intent: ButtonIntent::Cancel, - focused: self.focus == Focus::Back && self.dialog.is_none(), - enabled: true, - }, - context.theme, - ); - hits.register(buttons_area[0], AppAction::Back); - - render_button( - frame, - buttons_area[1], - ActionButton { - label: "Regenerate Keys", - intent: ButtonIntent::Destructive, - focused: self.focus == Focus::RegenerateKeys && self.dialog.is_none(), - enabled: !self.pending, - }, - context.theme, - ); - hits.register(buttons_area[1], AppAction::RegenerateKeys); - - if self.dialog.is_some() { - frame.render_widget(Block::default().style(context.theme.surfaces.overlay), area); - let popup = crate::layout::fit::centered_rect( - area, - crate::layout::fit::RequiredSize { - width: 42, - height: 7, - }, - ); - let block = Block::default() - .title(" Confirm ") - .borders(Borders::ALL) - .border_style(context.theme.borders.focused) - .style(context.theme.surfaces.overlay); - let popup_inner = block.inner(popup); - frame.render_widget(block, popup); - let dialog_rows = - Layout::vertical([Constraint::Min(2), Constraint::Length(1)]).split(popup_inner); - frame.render_widget( - Paragraph::new("Regenerate the identity key pair?\nThis will rotate keys and reconnect to Omikron.").style(context.theme.text.normal), - dialog_rows[0], - ); - let dialog_buttons = - Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(dialog_rows[1]); - render_button( - frame, - dialog_buttons[0], - ActionButton { - label: "Cancel", - intent: ButtonIntent::Cancel, - focused: false, - enabled: true, - }, - context.theme, - ); - render_button( - frame, - dialog_buttons[1], - ActionButton { - label: "Regenerate", - intent: ButtonIntent::Destructive, - focused: true, - enabled: true, - }, - context.theme, - ); - hits.register(dialog_buttons[0], AppAction::CancelDialog); - hits.register(dialog_buttons[1], AppAction::ConfirmDialog); - } - } - - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - let event = match event { - UiEvent::App(AppEvent::ThemeSaved(result)) => { - match result { - Ok(()) => { - self.saved = self.selected_theme(); - self.message = "Theme saved to ui.yaml.".into(); - } - Err(error) => self.message = error, - } - return InteractionResult::Handled; - } - UiEvent::App(AppEvent::KeysRegenerated(result)) => { - self.pending = false; - self.dialog = None; - match result { - Ok(()) => self.message = "Keys regenerated successfully.".into(), - Err(error) => self.message = error, - } - return InteractionResult::Handled; - } - event => event, - }; - - if self.dialog.is_some() { - let UiEvent::Key(key) = event else { - return InteractionResult::Unhandled; - }; - return match key.code { - KeyCode::Esc => { - self.dialog = None; - InteractionResult::Handled - } - KeyCode::Enter => self.activate(), - _ => InteractionResult::Handled, - }; - } - - let UiEvent::Key(key) = event else { - return InteractionResult::Unhandled; - }; - match key.code { - KeyCode::Left => { - if self.focus == Focus::Theme { - self.selected = self.selected.saturating_sub(1); - self.apply(false) - } else { - InteractionResult::Handled - } - } - KeyCode::Right => { - if self.focus == Focus::Theme { - self.selected = (self.selected + 1).min(ThemeName::ALL.len() - 1); - self.apply(false) - } else { - InteractionResult::Handled - } - } - KeyCode::Enter | KeyCode::Char(' ') => self.activate(), - KeyCode::Tab => { - self.next_focus(); - InteractionResult::Handled - } - KeyCode::BackTab => { - self.prev_focus(); - InteractionResult::Handled - } - KeyCode::Char('c') | KeyCode::Char('C') => { - self.color = Self::next_policy(self.color); - InteractionResult::Handled - } - KeyCode::Char('u') | KeyCode::Char('U') => { - self.unicode = Self::next_policy(self.unicode); - InteractionResult::Handled - } - KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => { - InteractionResult::CloseScreen - } - _ => InteractionResult::Unhandled, - } - } - - fn handle_action(&mut self, action: AppAction) -> InteractionResult { - match action { - AppAction::Back => InteractionResult::CloseScreen, - AppAction::RegenerateKeys => { - self.focus = Focus::RegenerateKeys; - self.activate() - } - AppAction::ConfirmDialog if self.dialog.is_some() => self.activate(), - AppAction::CancelDialog if self.dialog.is_some() => { - self.dialog = None; - InteractionResult::Handled - } - _ => InteractionResult::Unhandled, - } - } - - fn key_hints(&self) -> Vec { - if self.dialog.is_some() { - vec![ - KeyHint { - keys: "Enter", - action: "Confirm", - }, - KeyHint { - keys: "Esc", - action: "Cancel", - }, - ] - } else { - vec![ - KeyHint { - keys: "Left/Right", - action: "Preview theme", - }, - KeyHint { - keys: "Enter", - action: "Save/Activate", - }, - KeyHint { - keys: "Tab", - action: "Move focus", - }, - KeyHint { - keys: "C/U", - action: "Color/Unicode", - }, - KeyHint { - keys: "Esc/B", - action: "Back", - }, - ] - } - } -} diff --git a/iota-cli/src/screens/terms_checker.rs b/iota-cli/src/screens/terms_checker.rs index 628134f..c44cbcb 100644 --- a/iota-cli/src/screens/terms_checker.rs +++ b/iota-cli/src/screens/terms_checker.rs @@ -2,13 +2,10 @@ use crate::{ controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line}, interaction_result::InteractionResult, render_context::RenderContext, - screens::{ - md_viewer::FileViewer, - screens::{HitMap, Screen, UiEvent}, - }, + screens::{md_viewer::FileViewer, screens::Screen}, util::{buttons::draw_buttons, terms_focus::Focus}, }; -use crossterm::event::KeyCode; +use crossterm::event::{KeyCode, KeyEvent}; use iota_terms::{TermsType, get_link, get_terms}; use ratatui::{ Frame, @@ -56,7 +53,7 @@ impl Screen for TermsCheckerScreen { self } - fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { + fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>) { let mut needed_height = 5; if size.height < 6 || size.width < 27 { @@ -273,10 +270,7 @@ impl Screen for TermsCheckerScreen { ); } - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - let UiEvent::Key(event) = event else { - return InteractionResult::Unhandled; - }; + fn handle_input(&mut self, event: KeyEvent) -> InteractionResult { let mut possible_states = vec![Focus::Eula, Focus::Tos, Focus::Pp, Focus::Cancel]; if self.eula { diff --git a/iota-cli/src/screens/terms_updater.rs b/iota-cli/src/screens/terms_updater.rs index 2dc502e..f1701b5 100644 --- a/iota-cli/src/screens/terms_updater.rs +++ b/iota-cli/src/screens/terms_updater.rs @@ -3,14 +3,11 @@ use crate::{ controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line}, interaction_result::InteractionResult, render_context::RenderContext, - screens::{ - md_viewer::FileViewer, - screens::{HitMap, Screen, UiEvent}, - }, + screens::{md_viewer::FileViewer, screens::Screen}, util::{buttons::draw_buttons, terms_focus::Focus}, }; use chrono::{Local, TimeZone, Utc}; -use crossterm::event::KeyCode; +use crossterm::event::{KeyCode, KeyEvent}; use iota_terms::{Doc, TermsType, get_newest_link, get_terms}; use ratatui::{ Frame, @@ -124,7 +121,7 @@ impl Screen for TermsUpdaterScreen { self } - fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) { + fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>) { let checkbox = |label, selected, focused, enabled| { render_choice_line( label, @@ -596,10 +593,7 @@ impl Screen for TermsUpdaterScreen { ); } - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - let UiEvent::Key(event) = event else { - return InteractionResult::Unhandled; - }; + fn handle_input(&mut self, event: KeyEvent) -> InteractionResult { let mut possible_states = Vec::new(); if self.eula_needed { diff --git a/iota-cli/src/screens/users.rs b/iota-cli/src/screens/users.rs deleted file mode 100644 index ac271a8..0000000 --- a/iota-cli/src/screens/users.rs +++ /dev/null @@ -1,717 +0,0 @@ -use crate::{ - controls::{ - button::{ActionButton, ButtonIntent, render_button}, - choice::{ChoiceKind, render_choice_line}, - }, - interaction_result::InteractionResult, - ipc_client::IpcClient, - render_context::RenderContext, - screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent}, -}; -use crossterm::event::{KeyCode, KeyModifiers}; -use ratatui::{ - Frame, - layout::{Constraint, Layout, Rect}, - text::{Line, Span}, - widgets::{Block, Borders, Paragraph}, -}; -use std::{ - any::Any, - sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, - }, -}; - -#[derive(Clone, Debug)] -pub struct UserEntry { - pub user_id: i64, - pub username: String, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum Focus { - List, - AddButton, - RemoveButton, - Back, -} -#[derive(Clone, Debug)] -enum Dialog { - Add { username: String }, - Remove { user: UserEntry }, -} - -pub struct UsersScreen { - users: Vec, - focused_index: usize, - focus: Focus, - ipc: Arc, - message: Option, - dialog: Option, - pending_dialog: Option, - loading: bool, - pending: bool, - scroll_offset: usize, - viewport_height: AtomicUsize, - filter: String, - filtering: bool, -} - -impl UsersScreen { - pub fn new(ipc: Arc, users: Vec) -> Self { - Self { - users, - focused_index: 0, - focus: Focus::List, - ipc, - message: None, - dialog: None, - pending_dialog: None, - loading: false, - pending: false, - scroll_offset: 0, - viewport_height: AtomicUsize::new(1), - filter: String::new(), - filtering: false, - } - } - - pub fn loading(ipc: Arc) -> Self { - let mut screen = Self::new(ipc, Vec::new()); - screen.loading = true; - screen.message = Some("Loading users…".into()); - screen - } - - fn render_user_list(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) { - let visible_indices = self.filtered_indices(); - let title = if self.filter.is_empty() { - format!("Users ({})", self.users.len()) - } else { - format!( - "Users ({}/{}) filter: {}", - visible_indices.len(), - self.users.len(), - self.filter - ) - }; - let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { - crate::controls::panel::render_panel( - f, - area, - &title, - self.focus == Focus::List, - context.theme, - ) - } else { - let block = Block::default() - .title(format!(" {title} ")) - .borders(Borders::ALL) - .border_style(context.theme.borders.normal); - let inner = block.inner(area); - f.render_widget(block, area); - inner - }; - - if self.loading { - f.render_widget(Paragraph::new("Loading users…"), inner); - return; - } - if visible_indices.is_empty() { - let par = Paragraph::new(if self.users.is_empty() { - "No users found." - } else { - "No users match the filter." - }); - f.render_widget(par, inner); - return; - } - - let mut lines = Vec::new(); - self.viewport_height - .store(inner.height as usize, Ordering::Relaxed); - let labels: Vec<(usize, String)> = visible_indices - .iter() - .skip(self.scroll_offset) - .take(inner.height as usize) - .map(|user_index| { - let user = &self.users[*user_index]; - ( - *user_index, - format!("{:>6} {}", user.user_id, user.username), - ) - }) - .collect(); - for (user_index, label) in &labels { - let visual = crate::controls::choice::ChoiceVisualState { - selected: false, - focused: self.focus == Focus::List && *user_index == self.focused_index, - enabled: !self.loading && !self.pending, - }; - lines.push(render_choice_line( - &label, - ChoiceKind::Radio, - visual, - context.theme, - )); - } - let par = Paragraph::new(lines); - f.render_widget(par, inner); - } - - fn render_actions(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) { - let rows = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(area); - - if let Some(msg) = &self.message { - let par = Paragraph::new(Line::from(Span::styled( - msg.as_str(), - context.theme.text.muted, - ))); - f.render_widget(par, rows[0]); - } - - let buttons_area = Layout::horizontal([ - Constraint::Percentage(33), - Constraint::Percentage(33), - Constraint::Percentage(34), - ]) - .split(rows[1]); - - render_button( - f, - buttons_area[0], - ActionButton { - label: "Back", - intent: ButtonIntent::Cancel, - focused: self.focus == Focus::Back, - enabled: true, - }, - context.theme, - ); - render_button( - f, - buttons_area[1], - ActionButton { - label: "Add", - intent: ButtonIntent::Primary, - focused: self.focus == Focus::AddButton, - enabled: true, - }, - context.theme, - ); - render_button( - f, - buttons_area[2], - ActionButton { - label: "Remove", - intent: ButtonIntent::Destructive, - focused: self.focus == Focus::RemoveButton, - enabled: !self.loading && !self.pending && !self.users.is_empty(), - }, - context.theme, - ); - } - - fn activate(&mut self) -> InteractionResult { - if self.loading || self.pending { - return InteractionResult::Handled; - } - if let Some(dialog) = self.dialog.take() { - match dialog { - Dialog::Add { username } if !username.trim().is_empty() => { - let name = username.trim().to_owned(); - self.pending_dialog = Some(Dialog::Add { username }); - self.pending = true; - self.message = Some("Creating user…".into()); - let ipc = self.ipc.clone(); - return InteractionResult::AppTask { - task: Box::pin(async move { - let result = match ipc.send_request(iota_ipc::LocalRequest::CreateUser { username: name }).await { - Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserCreated { user_id, username })) => Ok(UserEntry { user_id, username }), - Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot create user: {error}")), - Ok(_) => Err("Daemon returned an unexpected response while creating the user.".into()), - Err(error) => Err(format!("Cannot create user: {error}")), - }; - UiEvent::App(AppEvent::UserCreated(result)) - }), - }; - } - Dialog::Remove { user } => { - self.pending_dialog = Some(Dialog::Remove { user: user.clone() }); - let ipc = self.ipc.clone(); - let id = user.user_id; - self.pending = true; - self.message = Some(format!("Removing {}…", user.username)); - return InteractionResult::AppTask { - task: Box::pin(async move { - let result = match ipc.send_request(iota_ipc::LocalRequest::RemoveUser { user_id: id }).await { - Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserRemoved { .. })) => Ok(()), - Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot remove user: {error}")), - Ok(_) => Err("Daemon returned an unexpected response while removing the user.".into()), - Err(error) => Err(format!("Cannot remove user: {error}")), - }; - UiEvent::App(AppEvent::UserRemoved { - user_id: id, - result, - }) - }), - }; - } - Dialog::Add { .. } => self.message = Some("A username is required.".into()), - } - return InteractionResult::Handled; - } - match self.focus { - Focus::Back => InteractionResult::CloseScreen, - Focus::AddButton => { - self.dialog = Some(Dialog::Add { - username: String::new(), - }); - InteractionResult::Handled - } - Focus::RemoveButton => { - if let Some(user) = self.users.get(self.focused_index) { - self.dialog = Some(Dialog::Remove { user: user.clone() }); - } - InteractionResult::Handled - } - Focus::List => InteractionResult::Handled, - } - } - - fn next_focus(&mut self) { - self.focus = match self.focus { - Focus::List => Focus::AddButton, - Focus::AddButton => Focus::RemoveButton, - Focus::RemoveButton => Focus::Back, - Focus::Back => Focus::List, - }; - } - - fn prev_focus(&mut self) { - self.focus = match self.focus { - Focus::List => Focus::Back, - Focus::Back => Focus::RemoveButton, - Focus::RemoveButton => Focus::AddButton, - Focus::AddButton => Focus::List, - }; - } - - fn keep_focused_user_visible(&mut self) { - let indices = self.filtered_indices(); - let Some(position) = indices - .iter() - .position(|index| *index == self.focused_index) - else { - self.scroll_offset = 0; - return; - }; - let height = self.viewport_height.load(Ordering::Relaxed).max(1); - if position < self.scroll_offset { - self.scroll_offset = position; - } else if position >= self.scroll_offset + height { - self.scroll_offset = position + 1 - height; - } - } - - fn move_user_focus(&mut self, index: usize) { - if !self.users.is_empty() { - self.focused_index = index.min(self.users.len() - 1); - self.keep_focused_user_visible(); - } - } - - fn filtered_indices(&self) -> Vec { - let needle = self.filter.to_ascii_lowercase(); - self.users - .iter() - .enumerate() - .filter(|(_, user)| { - needle.is_empty() - || user.username.to_ascii_lowercase().contains(&needle) - || user.user_id.to_string().contains(&needle) - }) - .map(|(index, _)| index) - .collect() - } - - fn move_visible(&mut self, delta: isize) { - let indices = self.filtered_indices(); - if indices.is_empty() { - return; - } - let current = indices - .iter() - .position(|index| *index == self.focused_index) - .unwrap_or(0); - let next = (current as isize + delta).clamp(0, indices.len() as isize - 1) as usize; - self.move_user_focus(indices[next]); - } - - fn reset_focus_to_filter(&mut self) { - self.scroll_offset = 0; - if let Some(index) = self.filtered_indices().first().copied() { - self.focused_index = index; - } - } -} - -impl Screen for UsersScreen { - fn as_any(&self) -> &dyn Any { - self - } - fn as_any_mut(&mut self) -> &mut dyn Any { - self - } - - fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap) { - let outer_block = Block::default() - .title(" Users ") - .borders(Borders::ALL) - .border_style(context.theme.borders.normal) - .title_style(context.theme.borders.title); - let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { - crate::controls::panel::render_panel(f, rect, "Users", false, context.theme) - } else { - let inner = outer_block.inner(rect); - f.render_widget(outer_block, rect); - inner - }; - - let chunks = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(inner); - - self.render_user_list(f, chunks[0], context); - self.render_actions(f, chunks[1], context); - if let Some(dialog) = &self.dialog { - f.render_widget(Block::default().style(context.theme.surfaces.overlay), rect); - let popup = crate::layout::fit::centered_rect( - rect, - crate::layout::fit::RequiredSize { - width: 42, - height: 7, - }, - ); - let text = match dialog { - Dialog::Add { username } => { - format!("Add user\nUsername: {username}") - } - Dialog::Remove { user } => format!( - "Remove user {} (ID {})?\nThis removes the local user record.", - user.username, user.user_id - ), - }; - let block = Block::default() - .title(" Confirm ") - .borders(Borders::ALL) - .border_style(context.theme.borders.focused) - .style(context.theme.surfaces.overlay); - let popup_inner = block.inner(popup); - f.render_widget(block, popup); - let dialog_rows = - Layout::vertical([Constraint::Min(2), Constraint::Length(1)]).split(popup_inner); - f.render_widget( - Paragraph::new(text).style(context.theme.text.normal), - dialog_rows[0], - ); - let dialog_buttons = - Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)]) - .split(dialog_rows[1]); - render_button( - f, - dialog_buttons[0], - ActionButton { - label: "Cancel", - intent: ButtonIntent::Cancel, - focused: false, - enabled: true, - }, - context.theme, - ); - render_button( - f, - dialog_buttons[1], - ActionButton { - label: match dialog { - Dialog::Add { .. } => "Create", - Dialog::Remove { .. } => "Remove", - }, - intent: match dialog { - Dialog::Add { .. } => ButtonIntent::Primary, - Dialog::Remove { .. } => ButtonIntent::Destructive, - }, - focused: true, - enabled: true, - }, - context.theme, - ); - hits.register(dialog_buttons[0], AppAction::CancelDialog); - hits.register(dialog_buttons[1], AppAction::ConfirmDialog); - } - let buttons = Layout::horizontal([ - Constraint::Percentage(33), - Constraint::Percentage(33), - Constraint::Percentage(34), - ]) - .split(chunks[1]); - if self.dialog.is_none() { - hits.register(buttons[0], AppAction::Back); - } - if self.dialog.is_none() && !self.loading && !self.pending { - hits.register(buttons[1], AppAction::AddUser); - } - if self.dialog.is_none() && !self.loading && !self.pending && !self.users.is_empty() { - hits.register(buttons[2], AppAction::RemoveUser); - } - if self.dialog.is_none() { - let list_height = chunks[0].height.saturating_sub(2) as usize; - let filtered_indices = self.filtered_indices(); - for visible in 0..list_height { - let position = self.scroll_offset + visible; - let Some(index) = filtered_indices.get(position).copied() else { - break; - }; - hits.register( - Rect { - x: chunks[0].x.saturating_add(1), - y: chunks[0].y.saturating_add(1 + visible as u16), - width: chunks[0].width.saturating_sub(2), - height: 1, - }, - AppAction::SelectUser(index), - ); - } - } - } - - fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - let event = match event { - UiEvent::App(AppEvent::UsersLoaded(result)) => { - self.loading = false; - match result { - Ok(users) => { - self.users = users; - self.message = None; - } - Err(error) => self.message = Some(error), - } - return InteractionResult::Handled; - } - UiEvent::App(AppEvent::UserCreated(result)) => { - self.pending = false; - match result { - Ok(user) => { - self.pending_dialog = None; - self.focused_index = self.users.len(); - self.users.push(user.clone()); - self.message = Some(format!( - "Created user {} ({}).", - user.username, user.user_id - )); - } - Err(error) => { - self.dialog = self.pending_dialog.take(); - self.message = Some(error); - } - } - return InteractionResult::Handled; - } - UiEvent::App(AppEvent::UserRemoved { user_id, result }) => { - self.pending = false; - match result { - Ok(()) => { - self.pending_dialog = None; - self.users.retain(|user| user.user_id != user_id); - self.focused_index = - self.focused_index.min(self.users.len().saturating_sub(1)); - self.message = Some(format!("Removed user {user_id}.")); - } - Err(error) => { - self.dialog = self.pending_dialog.take(); - self.message = Some(error); - } - } - return InteractionResult::Handled; - } - UiEvent::Paste(text) if matches!(self.dialog, Some(Dialog::Add { .. })) => { - if let Some(Dialog::Add { username }) = self.dialog.as_mut() { - username.push_str(&text.replace(['\r', '\n'], " ")); - } - return InteractionResult::Handled; - } - UiEvent::Key(event) => event, - _ => return InteractionResult::Unhandled, - }; - if self.filtering && self.dialog.is_none() { - match event.code { - KeyCode::Esc => { - self.filtering = false; - self.filter.clear(); - self.reset_focus_to_filter(); - } - KeyCode::Enter => self.filtering = false, - KeyCode::Backspace => { - self.filter.pop(); - self.reset_focus_to_filter(); - } - KeyCode::Char(c) - if !event - .modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => - { - self.filter.push(c); - self.reset_focus_to_filter(); - } - _ => {} - } - return InteractionResult::Handled; - } - if let Some(Dialog::Add { username }) = self.dialog.as_mut() { - match event.code { - KeyCode::Esc => { - self.dialog = None; - return InteractionResult::Handled; - } - KeyCode::Enter => return self.activate(), - KeyCode::Backspace => { - username.pop(); - return InteractionResult::Handled; - } - KeyCode::Char(c) - if !c.is_control() - && !event - .modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => - { - username.push(c); - return InteractionResult::Handled; - } - _ => return InteractionResult::Handled, - } - } - if self.dialog.is_some() { - return match event.code { - KeyCode::Esc => { - self.dialog = None; - InteractionResult::Handled - } - KeyCode::Enter => self.activate(), - _ => InteractionResult::Handled, - }; - } - match event.code { - KeyCode::Esc => InteractionResult::CloseScreen, - KeyCode::Char('/') if self.focus == Focus::List => { - self.filtering = true; - self.filter.clear(); - self.reset_focus_to_filter(); - InteractionResult::Handled - } - KeyCode::Tab => { - self.next_focus(); - InteractionResult::Handled - } - KeyCode::BackTab => { - self.prev_focus(); - InteractionResult::Handled - } - KeyCode::Down | KeyCode::Char('j') => { - if self.focus == Focus::List { - self.move_visible(1); - } - InteractionResult::Handled - } - KeyCode::Up | KeyCode::Char('k') => { - if self.focus == Focus::List { - self.move_visible(-1); - } - InteractionResult::Handled - } - KeyCode::PageDown if self.focus == Focus::List && !self.users.is_empty() => { - let page = self.viewport_height.load(Ordering::Relaxed).max(1); - self.move_visible(page as isize); - InteractionResult::Handled - } - KeyCode::PageUp if self.focus == Focus::List && !self.users.is_empty() => { - let page = self.viewport_height.load(Ordering::Relaxed).max(1); - self.move_visible(-(page as isize)); - InteractionResult::Handled - } - KeyCode::Home if self.focus == Focus::List => { - if let Some(index) = self.filtered_indices().first().copied() { - self.move_user_focus(index); - } - InteractionResult::Handled - } - KeyCode::End if self.focus == Focus::List && !self.users.is_empty() => { - if let Some(index) = self.filtered_indices().last().copied() { - self.move_user_focus(index); - } - InteractionResult::Handled - } - KeyCode::Enter | KeyCode::Char(' ') => self.activate(), - _ => InteractionResult::Unhandled, - } - } - fn handle_action(&mut self, action: AppAction) -> InteractionResult { - match action { - AppAction::Back => InteractionResult::CloseScreen, - AppAction::AddUser => { - self.focus = Focus::AddButton; - self.activate() - } - AppAction::RemoveUser => { - self.focus = Focus::RemoveButton; - self.activate() - } - AppAction::SelectUser(index) if self.dialog.is_none() => { - self.focus = Focus::List; - self.move_user_focus(index); - InteractionResult::Handled - } - AppAction::ConfirmDialog if self.dialog.is_some() => self.activate(), - AppAction::CancelDialog if self.dialog.is_some() => { - self.dialog = None; - InteractionResult::Handled - } - _ => InteractionResult::Unhandled, - } - } - fn key_hints(&self) -> Vec { - if self.dialog.is_some() { - vec![ - KeyHint { - keys: "Enter", - action: "Confirm", - }, - KeyHint { - keys: "Esc", - action: "Cancel", - }, - ] - } else { - vec![ - KeyHint { - keys: "Up/Down", - action: "Select user", - }, - KeyHint { - keys: "PgUp/PgDn", - action: "Page", - }, - KeyHint { - keys: "/", - action: "Filter", - }, - KeyHint { - keys: "Tab", - action: "Move focus", - }, - KeyHint { - keys: "F6", - action: "Header", - }, - ] - } - } -} diff --git a/iota-cli/src/theme/config.rs b/iota-cli/src/theme/config.rs index 79d1990..e77c7e6 100644 --- a/iota-cli/src/theme/config.rs +++ b/iota-cli/src/theme/config.rs @@ -13,19 +13,6 @@ pub struct UiConfig { /// Whether opening the interactive UI should launch a locally installed daemon. #[serde(default)] pub daemon_start_policy: DaemonStartPolicy, - #[serde(default)] - pub color: TerminalPolicy, - #[serde(default)] - pub unicode: TerminalPolicy, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum TerminalPolicy { - #[default] - Auto, - Always, - Never, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] diff --git a/iota-cli/src/theme/mod.rs b/iota-cli/src/theme/mod.rs index d185c6f..d103e58 100644 --- a/iota-cli/src/theme/mod.rs +++ b/iota-cli/src/theme/mod.rs @@ -3,81 +3,10 @@ mod model; mod name; mod presets; -pub use config::{DaemonStartPolicy, TerminalPolicy, UiConfig}; +pub use config::{DaemonStartPolicy, UiConfig}; pub use model::*; pub use name::ThemeName; pub fn resolve(name: ThemeName) -> ResolvedTheme { presets::resolve(name) } - -pub fn resolve_with_capabilities( - name: ThemeName, - color_enabled: bool, - unicode_enabled: bool, -) -> ResolvedTheme { - let mut theme = if color_enabled { - presets::resolve(name) - } else { - presets::resolve(ThemeName::Monospace) - }; - theme.name = name; - theme.unicode = unicode_enabled; - if !unicode_enabled { - if matches!(theme.console.cursor, CursorPresentation::Character { .. }) { - theme.console.cursor = CursorPresentation::Character { - glyph: "|", - style: theme.console.text, - }; - } - } - theme -} - -/// Resolve a theme against the terminal's color depth. Surface uses RGB -/// colors, so a portable ANSI preset is selected when truecolor is absent. -pub fn resolve_with_terminal_profile( - name: ThemeName, - color_enabled: bool, - unicode_enabled: bool, - truecolor_enabled: bool, -) -> ResolvedTheme { - let effective = if color_enabled && !truecolor_enabled && matches!(name, ThemeName::Surface) { - ThemeName::Ansi - } else { - name - }; - resolve_with_capabilities(effective, color_enabled, unicode_enabled) -} - -#[cfg(test)] -mod tests { - use super::*; - use ratatui::style::Color; - - #[test] - fn no_color_policy_removes_palette_dependencies() { - let theme = resolve_with_capabilities(ThemeName::Surface, false, true); - assert_eq!(theme.name, ThemeName::Surface); - assert_eq!(theme.status.error.fg, None); - assert_eq!(theme.surfaces.panel.bg, None); - } - - #[test] - fn ascii_policy_replaces_character_cursor() { - let theme = resolve_with_capabilities(ThemeName::Monospace, false, false); - assert!(!theme.unicode); - match theme.console.cursor { - CursorPresentation::Character { glyph, .. } => assert_eq!(glyph, "|"), - CursorPresentation::StyledCell(_) => panic!("expected an ASCII character cursor"), - } - assert_ne!(theme.graphs.ram, Color::Blue); - } - - #[test] - fn surface_uses_ansi_fallback_without_truecolor() { - let theme = resolve_with_terminal_profile(ThemeName::Surface, true, true, false); - assert_eq!(theme.name, ThemeName::Ansi); - assert_eq!(theme.surfaces.panel.bg, None); - } -} diff --git a/iota-cli/src/theme/model.rs b/iota-cli/src/theme/model.rs index 5d4955e..fd631dc 100644 --- a/iota-cli/src/theme/model.rs +++ b/iota-cli/src/theme/model.rs @@ -24,22 +24,6 @@ pub struct BorderStyles { pub title: Style, } #[derive(Clone, Debug)] -pub struct SurfaceStyles { - pub canvas: Style, - pub toolbar: Style, - pub panel: Style, - pub panel_alternate: Style, - pub panel_focused: Style, - pub panel_selected: Style, - pub footer: Style, - pub overlay: Style, -} -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum ChromeMode { - Bordered, - Surfaces, -} -#[derive(Clone, Debug)] pub struct ChoiceItemStyle { pub marker: Style, pub label: Style, @@ -135,9 +119,6 @@ pub struct TextSemantics { #[derive(Clone, Debug)] pub struct ResolvedTheme { pub name: ThemeName, - pub unicode: bool, - pub surfaces: SurfaceStyles, - pub chrome: ChromeMode, pub text: TextStyles, pub status: StatusStyles, pub choices: ChoiceStyles, diff --git a/iota-cli/src/theme/presets.rs b/iota-cli/src/theme/presets.rs index 301339a..333a221 100644 --- a/iota-cli/src/theme/presets.rs +++ b/iota-cli/src/theme/presets.rs @@ -1,7 +1,7 @@ use super::{ - BorderStyles, ButtonStyles, ChoiceItemStyle, ChoiceStyles, ChromeMode, ConsoleStyles, - CursorPresentation, GraphStyles, LogStyles, MarkdownStyles, MarkerSet, ResolvedTheme, - StatusStyles, SurfaceStyles, TextStyles, ThemeName, + BorderStyles, ButtonStyles, ChoiceItemStyle, ChoiceStyles, ConsoleStyles, CursorPresentation, + GraphStyles, LogStyles, MarkdownStyles, MarkerSet, ResolvedTheme, StatusStyles, TextStyles, + ThemeName, }; use ratatui::style::{Color, Modifier, Style}; @@ -45,18 +45,6 @@ fn base( }; ResolvedTheme { name, - unicode: true, - surfaces: SurfaceStyles { - canvas: Style::default(), - toolbar: Style::default(), - panel: Style::default(), - panel_alternate: Style::default(), - panel_focused: focused, - panel_selected: selected, - footer: Style::default(), - overlay: Style::default(), - }, - chrome: ChromeMode::Bordered, text: TextStyles { normal, muted, @@ -307,17 +295,6 @@ pub fn resolve(name: ThemeName) -> ResolvedTheme { ); theme.console.cursor = CursorPresentation::StyledCell(plain.fg(Color::Black).bg(Color::Yellow)); - theme.chrome = ChromeMode::Surfaces; - theme.surfaces = SurfaceStyles { - canvas: plain.bg(Color::Black), - toolbar: plain.fg(Color::White).bg(Color::DarkGray), - panel: plain.fg(Color::White).bg(Color::Rgb(30, 35, 45)), - panel_alternate: plain.fg(Color::White).bg(Color::Rgb(45, 52, 66)), - panel_focused: plain.fg(Color::White).bg(Color::Rgb(48, 58, 78)), - panel_selected: selected, - footer: plain.fg(Color::DarkGray).bg(Color::Black), - overlay: plain.fg(Color::White).bg(Color::Rgb(45, 52, 66)), - }; theme } } diff --git a/iota-cli/src/ui.rs b/iota-cli/src/ui.rs index 90d0b0b..21d8bd4 100644 --- a/iota-cli/src/ui.rs +++ b/iota-cli/src/ui.rs @@ -1,28 +1,14 @@ use crate::{ - controls::header::render_header, input_handler::setup_input_handler, interaction_result::InteractionResult, ipc_client::IpcClient, render_context::RenderContext, - screens::{ - main_screen::MainScreen, - metrics::MetricsScreen, - overview::OverviewScreen, - screens::{AppAction, AppEvent, HitMap, Screen, UiEvent}, - settings::SettingsScreen, - users::{UserEntry, UsersScreen}, - }, + screens::screens::Screen, theme::{self, ResolvedTheme, ThemeName}, }; -use crossterm::event::{ - DisableMouseCapture, EnableMouseCapture, KeyCode, KeyEvent, MouseEventKind, -}; +use crossterm::event::KeyEvent; use once_cell::sync::Lazy; -use ratatui::{ - Terminal, - backend::CrosstermBackend, - layout::{Constraint, Layout}, -}; +use ratatui::{Terminal, backend::CrosstermBackend}; use std::{ io, io::Stdout, @@ -32,7 +18,7 @@ use std::{ atomic::{AtomicBool, Ordering}, }, }; -use tokio::sync::{Notify, RwLock, mpsc}; +use tokio::sync::{Notify, RwLock}; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; @@ -49,10 +35,6 @@ pub struct UI { theme: RwLock>, pub(crate) invalidation: Notify, failure: Arc>>, - hits: Mutex, - app_event_tx: mpsc::UnboundedSender, - app_event_rx: Mutex>>, - header_focus: Mutex>, } pub fn start_tui(ipc: Arc) -> io::Result { @@ -73,24 +55,6 @@ pub fn start_bootstrap_tui_with_theme(theme: ResolvedTheme) -> io::Result io::Result { let ui = Arc::new(ui); - let mut app_event_rx = ui - .app_event_rx - .lock() - .map_err(|_| io::Error::other("application event queue poisoned"))? - .take() - .ok_or_else(|| io::Error::other("application event queue already started"))?; - let app_ui = ui.clone(); - let app_event_task = tokio::spawn(async move { - loop { - tokio::select! { - _ = app_ui.cancellation.cancelled() => break, - event = app_event_rx.recv() => match event { - Some(event) => app_ui.clone().handle_event(event).await, - None => break, - }, - } - } - }); let uic = ui.clone(); let renderer_task = tokio::spawn(async move { let cancellation = uic.cancellation_token(); @@ -98,6 +62,7 @@ fn start_session(ui: UI) -> io::Result { tokio::select! { _ = cancellation.cancelled() => break Ok(()), _ = uic.invalidation.notified() => { if !uic.is_shutdown() { uic.render().await?; } }, + _ = tokio::time::sleep(std::time::Duration::from_millis(250)) => { if !uic.is_shutdown() { uic.render().await?; } }, } }; if let Err(error) = &result { @@ -136,7 +101,6 @@ fn start_session(ui: UI) -> io::Result { ui, renderer_task, input_task, - app_event_task, signal_task, restored: AtomicBool::new(false), previous_hook, @@ -147,7 +111,6 @@ pub struct TuiSession { ui: Arc, renderer_task: JoinHandle>, input_task: JoinHandle>, - app_event_task: JoinHandle<()>, signal_task: Option>, restored: AtomicBool, previous_hook: Arc) + Send + Sync + 'static>>>>, @@ -166,7 +129,6 @@ impl TuiSession { tokio::time::timeout(std::time::Duration::from_secs(2), &mut self.renderer_task).await; let input = tokio::time::timeout(std::time::Duration::from_secs(2), &mut self.input_task).await; - self.app_event_task.abort(); if renderer.is_err() { self.renderer_task.abort(); } @@ -192,7 +154,6 @@ impl TuiSession { } fn restore_terminal_once(&self) { if !self.restored.swap(true, Ordering::AcqRel) { - let _ = crossterm::execute!(io::stdout(), DisableMouseCapture); ratatui::restore(); } } @@ -207,7 +168,6 @@ impl Drop for TuiSession { self.ui.request_shutdown(); self.renderer_task.abort(); self.input_task.abort(); - self.app_event_task.abort(); if let Some(task) = self.signal_task.as_ref() { task.abort(); } @@ -222,8 +182,6 @@ impl UI { theme: ResolvedTheme, ) -> io::Result { let terminal = ratatui::try_init()?; - crossterm::execute!(io::stdout(), EnableMouseCapture)?; - let (app_event_tx, app_event_rx) = mpsc::unbounded_channel(); Ok(Self { ipc: RwLock::new(ipc), shutdown_on_empty, @@ -233,10 +191,6 @@ impl UI { theme: RwLock::new(Arc::new(theme)), invalidation: Notify::new(), failure: Arc::new(Mutex::new(None)), - hits: Mutex::new(HitMap::default()), - app_event_tx, - app_event_rx: Mutex::new(Some(app_event_rx)), - header_focus: Mutex::new(None), }) } @@ -274,6 +228,10 @@ impl UI { pub fn failure(&self) -> Option { self.failure.lock().ok().and_then(|f| f.clone()) } + pub async fn handle_paste(&self, _text: String) { + self.invalidate(); + } + /// Lets bootstrap operations race their work against Ctrl+C without /// blocking the input task or leaving the terminal in raw mode. pub async fn wait_for_shutdown(&self) { @@ -301,166 +259,10 @@ impl UI { self.invalidate(); } pub async fn handle_input(self: Arc, key_event: KeyEvent) { - self.handle_event(UiEvent::Key(key_event)).await; - } - pub async fn handle_event(self: Arc, event: UiEvent) { - if matches!(&event, UiEvent::App(AppEvent::OpenUsers)) { - self.open_users().await; - return; - } - if matches!(&event, UiEvent::App(AppEvent::OpenMetrics)) { - if let Some(screen) = MetricsScreen::new(self.clone()).await { - self.set_screen(Box::new(screen)).await; - } - return; - } - if let UiEvent::App(AppEvent::ApplyTheme { theme, persist }) = &event { - self.set_theme(theme::resolve(*theme)).await; - if *persist { - let mut config = theme::UiConfig::load().unwrap_or_default(); - config.theme = *theme; - let result = config - .save() - .map_err(|error| format!("Could not save UI settings: {error}")); - let _ = self - .app_event_tx - .send(UiEvent::App(AppEvent::ThemeSaved(result))); - } - return; - } - if let UiEvent::App(AppEvent::SaveSettings { - theme, - color, - unicode, - }) = &event - { - self.set_theme(theme::resolve(*theme)).await; - let mut config = theme::UiConfig::load().unwrap_or_default(); - config.theme = *theme; - config.color = *color; - config.unicode = *unicode; - let result = config - .save() - .map_err(|error| format!("Could not save UI settings: {error}")); - let _ = self - .app_event_tx - .send(UiEvent::App(AppEvent::ThemeSaved(result))); - return; - } - if matches!(&event, UiEvent::App(AppEvent::RegenerateKeysRequested)) { - let Some(ipc) = self.ipc().await else { - let _ = self - .app_event_tx - .send(UiEvent::App(AppEvent::KeysRegenerated(Err( - "Not connected to daemon.".into(), - )))); - return; - }; - let sender = self.app_event_tx.clone(); - tokio::spawn(async move { - let result = match ipc - .send_request(iota_ipc::LocalRequest::RotateIotaIdentity) - .await - { - Ok(iota_ipc::ResponseResult::Ok(_)) => Ok(()), - Ok(iota_ipc::ResponseResult::Error(error)) => { - Err(format!("Cannot regenerate keys: {error}")) - } - Err(error) => Err(format!("Cannot regenerate keys: {error}")), - }; - let _ = sender.send(UiEvent::App(AppEvent::KeysRegenerated(result))); - }); - return; - } - if let UiEvent::Key(key) = &event { - let header_is_focused = self - .header_focus - .lock() - .map(|focus| focus.is_some()) - .unwrap_or(false); - if key.code == KeyCode::F(6) { - if let Ok(mut focus) = self.header_focus.lock() { - *focus = if focus.is_some() { None } else { Some(0) }; - } - self.invalidate(); - return; - } - if header_is_focused { - let mut action = None; - if let Ok(mut focus) = self.header_focus.lock() { - let index = focus.unwrap_or(0); - match key.code { - KeyCode::Left | KeyCode::BackTab => *focus = Some((index + 3) % 4), - KeyCode::Right | KeyCode::Tab => *focus = Some((index + 1) % 4), - KeyCode::Enter | KeyCode::Char(' ') => { - action = Some( - [ - AppAction::OpenOverview, - AppAction::OpenUsers, - AppAction::OpenSettings, - AppAction::Quit, - ][index], - ); - *focus = None; - } - KeyCode::Esc => *focus = None, - _ => {} - } - } - if let Some(action) = action { - self.dispatch_action(action).await; - } else { - self.invalidate(); - } - return; - } - } - if let UiEvent::Mouse(mouse) = &event { - if matches!( - mouse.kind, - MouseEventKind::ScrollUp | MouseEventKind::ScrollDown - ) { - let action = self - .hits - .lock() - .ok() - .and_then(|hits| hits.action_at(mouse.column, mouse.row)); - if action == Some(AppAction::FocusLogs) { - self.dispatch_action(AppAction::FocusLogs).await; - let key = if matches!(mouse.kind, MouseEventKind::ScrollUp) { - KeyCode::Up - } else { - KeyCode::Down - }; - // Log scrolling is a local, handled interaction; route it - // directly rather than recursively constructing another - // async UI event future. - if let Some(screen) = self.screen_stack.write().await.last_mut() { - let _ = screen.handle_event(UiEvent::Key(KeyEvent::from(key))); - } - self.invalidate(); - return; - } - } - if matches!( - mouse.kind, - MouseEventKind::Down(crossterm::event::MouseButton::Left) - ) { - if let Some(action) = self - .hits - .lock() - .ok() - .and_then(|hits| hits.action_at(mouse.column, mouse.row)) - { - self.dispatch_action(action).await; - return; - } - } - } let result = { let mut stack = self.screen_stack.write().await; if let Some(screen) = stack.last_mut() { - screen.handle_event(event) + screen.handle_input(key_event) } else { return; } @@ -476,13 +278,6 @@ impl UI { _ = ui.cancellation.cancelled() => return, } } - InteractionResult::AppTask { task } => { - let sender = self.app_event_tx.clone(); - tokio::spawn(async move { - let event = task.await; - let _ = sender.send(event); - }); - } InteractionResult::CloseScreen => { let mut stack = self.screen_stack.write().await; stack.pop(); @@ -497,81 +292,6 @@ impl UI { self.invalidate(); } - async fn dispatch_action(self: &Arc, action: AppAction) { - match action { - AppAction::Quit => self.request_shutdown(), - AppAction::OpenMain => { - let mut stack = self.screen_stack.write().await; - if stack.len() > 1 { - stack.truncate(1); - } - drop(stack); - self.invalidate(); - } - AppAction::OpenOverview => { - let status = { - let stack = self.screen_stack.read().await; - stack - .iter() - .rev() - .find_map(|s| s.as_any().downcast_ref::()) - .map(|main| (main.connection_status(), main.daemon_status())) - }; - if let Some((connection, daemon)) = status { - self.set_screen(Box::new(OverviewScreen::new(connection, daemon))) - .await; - } - } - AppAction::OpenUsers => self.open_users().await, - AppAction::OpenSettings => { - let current = self.theme_name().await; - self.set_screen(Box::new(SettingsScreen::new(current))) - .await; - } - AppAction::OpenMetrics => { - if let Some(screen) = MetricsScreen::new(self.clone()).await { - self.set_screen(Box::new(screen)).await; - } - } - action => { - let result = { - let mut stack = self.screen_stack.write().await; - stack.last_mut().map(|screen| screen.handle_action(action)) - }; - if matches!(result, Some(InteractionResult::CloseScreen)) { - let mut stack = self.screen_stack.write().await; - stack.pop(); - } - self.invalidate(); - } - } - } - async fn open_users(self: &Arc) { - let Some(ipc) = self.ipc().await else { return }; - self.set_screen(Box::new(UsersScreen::loading(ipc.clone()))) - .await; - let sender = self.app_event_tx.clone(); - tokio::spawn(async move { - let result = match ipc.send_request(iota_ipc::LocalRequest::ListUsers).await { - Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::Users(users))) => { - Ok(users - .into_iter() - .map(|u| UserEntry { - user_id: u.user_id, - username: u.username, - }) - .collect()) - } - Ok(iota_ipc::ResponseResult::Error(error)) => { - Err(format!("Cannot load users: {error}")) - } - Ok(_) => Err("Daemon returned an unexpected response while loading users.".into()), - Err(error) => Err(format!("Cannot load users: {error}")), - }; - let _ = sender.send(UiEvent::App(AppEvent::UsersLoaded(result))); - }); - } - pub async fn render(&self) -> io::Result<()> { let theme = self.theme.read().await.clone(); let context = RenderContext { @@ -584,59 +304,9 @@ impl UI { .terminal .lock() .map_err(|_| io::Error::other("terminal mutex poisoned"))?; - let mut hits = HitMap::default(); terminal.draw(|f| { - let rows = Layout::vertical([ - Constraint::Length(2), - Constraint::Min(1), - Constraint::Length(1), - ]) - .split(f.area()); - let header_title = self - .screen_stack - .try_read() - .ok() - .and_then(|stack| { - stack - .iter() - .find_map(|item| item.as_any().downcast_ref::()) - .map(|main| main.app_title()) - }) - .unwrap_or_else(|| screen.app_title()); - let header_focus = self.header_focus.lock().ok().and_then(|focus| *focus); - render_header( - f, - rows[0], - &header_title, - context.theme, - &mut hits, - header_focus, - ); - let hints = if header_focus.is_some() { - " Left/Right: choose Enter: activate Esc/F6: screen".to_owned() - } else { - screen - .key_hints() - .into_iter() - .map(|hint| format!("{}: {}", hint.keys, hint.action)) - .collect::>() - .join(" ") - }; - f.render_widget( - ratatui::widgets::Paragraph::new(format!(" {hints}")).style( - context - .theme - .surfaces - .footer - .patch(context.theme.text.muted), - ), - rows[2], - ); - screen.render(f, rows[1], &context, &mut hits); + screen.render(f, f.area(), &context); })?; - if let Ok(mut current) = self.hits.lock() { - *current = hits; - } } Ok(()) } diff --git a/iota-cli/tests/settings_snapshot.rs b/iota-cli/tests/settings_snapshot.rs deleted file mode 100644 index 470e514..0000000 --- a/iota-cli/tests/settings_snapshot.rs +++ /dev/null @@ -1,76 +0,0 @@ -use crossterm::event::{KeyCode, KeyEvent}; -use iota_cli::{ - interaction_result::InteractionResult, - render_context::RenderContext, - screens::{ - screens::{AppEvent, HitMap, Screen, UiEvent}, - settings::SettingsScreen, - }, - theme::{ThemeName, resolve}, -}; -use ratatui::{Terminal, backend::TestBackend}; - -fn buffer_text(terminal: &Terminal) -> String { - terminal - .backend() - .buffer() - .content() - .iter() - .map(|cell| cell.symbol()) - .collect() -} - -#[tokio::test] -async fn settings_preview_and_save_emit_typed_application_events() { - let mut screen = SettingsScreen::new(ThemeName::Ansi); - let preview = screen.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Right))); - let InteractionResult::AppTask { task } = preview else { - panic!("theme preview should emit an application task"); - }; - assert!(matches!( - task.await, - UiEvent::App(AppEvent::ApplyTheme { - theme: ThemeName::Surface, - persist: false - }) - )); - - let save = screen.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Enter))); - let InteractionResult::AppTask { task } = save else { - panic!("theme save should emit an application task"); - }; - assert!(matches!( - task.await, - UiEvent::App(AppEvent::SaveSettings { - theme: ThemeName::Surface, - color: _, - unicode: _ - }) - )); -} - -#[test] -fn settings_is_readable_in_every_theme_and_layout() { - for theme_name in ThemeName::ALL { - for (width, height) in [(42, 12), (72, 20), (100, 28)] { - let theme = resolve(theme_name); - let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); - let screen = SettingsScreen::new(theme_name); - terminal - .draw(|frame| { - screen.render( - frame, - frame.area(), - &RenderContext { theme: &theme }, - &mut HitMap::default(), - ); - }) - .unwrap(); - let rendered = buffer_text(&terminal); - assert!(rendered.contains("Settings")); - assert!(rendered.contains("Theme:")); - assert!(rendered.contains("[OK] Healthy")); - assert!(rendered.contains("[FAIL] Failed")); - } - } -} diff --git a/iota-connection/src/message_handlers.rs b/iota-connection/src/message_handlers.rs index 079c3c5..b3c08a9 100644 --- a/iota-connection/src/message_handlers.rs +++ b/iota-connection/src/message_handlers.rs @@ -108,10 +108,6 @@ fn stored_message_value( partner_id: i64, ) -> DataValue { let mut fields = vec![ - ( - DataType::MessageId, - DataValue::SignedNumber(message.id as i128), - ), ( DataType::SendTime, DataValue::SignedNumber(message.message_time as i128), @@ -265,160 +261,56 @@ pub fn handle_delete_app(cv: &CommunicationValue) -> CommunicationValue { .with_receiver(sender_id as u64) } -fn contact_value(contact: &iota_storage::users::contact::Contact) -> DataValue { - let mut fields = vec![( - DataType::UserId, - DataValue::SignedNumber(contact.user_id as i128), - )]; - if let Some(name) = &contact.user_name { - fields.push((DataType::Username, DataValue::Str(name.clone()))); - } - if let Some(last_message_at) = contact.last_message_at { - fields.push(( - DataType::LastMessageAt, - DataValue::SignedNumber(last_message_at as i128), - )); - } - typed_container(fields) -} - -fn sync_error(cv: &CommunicationValue) -> CommunicationValue { - error_response(cv, CommunicationType::ErrorInvalidData).add_typed_default( - DataType::SessionId, - cv.get_data(DataType::SessionId).clone(), - ) -} - -/// The sender is authenticated by MTP; a UserId embedded by a client is never trusted here. pub fn handle_client_connected(cv: &CommunicationValue) -> CommunicationValue { - use iota_storage::util::sync::{self, CACHE_SCHEMA_VERSION}; - let user_id = match i64::try_from(cv.get_sender()) { - Ok(id) if id > 0 => id, - _ => return sync_error(cv), - }; - let session_id = match data_i64(cv, DataType::SessionId) { - Some(id) if id > 0 => id, - _ => return sync_error(cv), - }; - let reported_version = match data_i64(cv, DataType::VersionNumber) { - Some(version) if version >= 0 => version, - _ => return sync_error(cv), - }; - let cache_valid = cv.get_data(DataType::CacheValid).as_bool().unwrap_or(false); - let schema = data_i64(cv, DataType::CacheSchemaVersion).unwrap_or(0); - let head = match sync::head(user_id) { - Ok(version) => version, - Err(_) => return sync_error(cv), - }; - let known_session = sync::has_session(user_id, session_id).unwrap_or(false); - let full = !cache_valid - || reported_version == 0 - || !known_session - || reported_version > head - || schema != CACHE_SCHEMA_VERSION; - let (contacts, messages, deleted_messages, deleted_contacts, mode) = if full { - ( - chats_util::get_users(user_id), - chat_files::get_all_messages(user_id), - Vec::new(), - Vec::new(), - "full", - ) - } else { - match sync::delta(user_id, reported_version, head) { - Ok(delta) => ( - chats_util::get_users_by_ids(user_id, &delta.contact_upserts), - chat_files::get_messages_by_ids(user_id, &delta.message_upserts), - delta.deleted_message_ids, - delta.deleted_contact_ids, - "delta", - ), - Err(_) => ( - chats_util::get_users(user_id), - chat_files::get_all_messages(user_id), - Vec::new(), - Vec::new(), - "full", - ), - } - }; - let all_contact_ids = chats_util::get_users(user_id) - .into_iter() - .map(|contact| DataValue::SignedNumber(contact.user_id as i128)) - .collect(); - let message_values = messages - .iter() - .map(|message| stored_message_value(message, user_id, message.external_user)) - .collect(); - CommunicationValue::new(CommunicationType::ClientStateSync) - .with_id(cv.get_id()) - .with_receiver(cv.get_sender()) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ) - .add_typed_default( - DataType::VersionNumber, - DataValue::SignedNumber(head as i128), - ) - .add_typed_default( - DataType::CacheSchemaVersion, - DataValue::SignedNumber(CACHE_SCHEMA_VERSION as i128), - ) - .add_typed_default(DataType::SyncMode, DataValue::Str(mode.into())) - .add_typed_default( - DataType::Contacts, - DataValue::Array(contacts.iter().map(contact_value).collect()), - ) - .add_typed_default(DataType::Messages, DataValue::Array(message_values)) - .add_typed_default( - DataType::DeletedMessageIds, - DataValue::Array( - deleted_messages - .into_iter() - .map(|id| DataValue::SignedNumber(id as i128)) - .collect(), - ), - ) - .add_typed_default( - DataType::DeletedContactIds, - DataValue::Array( - deleted_contacts - .into_iter() - .map(|id| DataValue::SignedNumber(id as i128)) - .collect(), - ), - ) - .add_typed_default(DataType::UserIds, DataValue::Array(all_contact_ids)) - .add_typed_default(DataType::Calls, DataValue::Array(Vec::new())) -} + let user_id = cv.get_data(DataType::UserId).as_number().unwrap_or(0) as i64; -pub fn handle_client_state_ack(cv: &CommunicationValue) -> CommunicationValue { - use iota_storage::util::sync::{self, CACHE_SCHEMA_VERSION}; - let user_id = match i64::try_from(cv.get_sender()) { - Ok(id) if id > 0 => id, - _ => return sync_error(cv), - }; - let session_id = match data_i64(cv, DataType::SessionId) { - Some(id) if id > 0 => id, - _ => return sync_error(cv), - }; - let version = match data_i64(cv, DataType::VersionNumber) { - Some(version) if version >= 0 => version, - _ => return sync_error(cv), - }; - if sync::acknowledge(user_id, session_id, version, CACHE_SCHEMA_VERSION).is_err() { - return sync_error(cv); + let contacts = chats_util::get_users(user_id); + let mut contacts_array = Vec::new(); + + for (i, contact) in contacts.iter().enumerate() { + let mut contact_container = Vec::new(); + contact_container.push(( + DataType::UserId, + DataValue::SignedNumber(contact.user_id as i128), + )); + contact_container.push(( + DataType::LastMessageAt, + DataValue::SignedNumber(contact.last_message_at.unwrap_or(0) as i128), + )); + + if let Some(ref name) = contact.user_name { + contact_container.push((DataType::Username, DataValue::Str(name.clone()))); + } + + let amount = if i < 10 { 20 } else { 1 }; + let messages = chat_files::get_messages(user_id, contact.user_id, 0, amount); + + let mut msg_array = Vec::new(); + for m in &messages { + msg_array.push(stored_message_value(m, user_id, contact.user_id)); + + if msg_array.len() == 1 { + let sender_id = if m.sent_by_self { + user_id + } else { + contact.user_id + }; + let mut last_msg = Vec::new(); + last_msg.push((DataType::Content, DataValue::Str(m.content.clone()))); + last_msg.push(( + DataType::SenderId, + DataValue::SignedNumber(sender_id as i128), + )); + contact_container.push((DataType::LastMessage, typed_container(last_msg))); + } + } + contact_container.push((DataType::Messages, DataValue::Array(msg_array))); + contacts_array.push(typed_container(contact_container)); } - success_response(cv) - .add_typed_default( - DataType::SessionId, - DataValue::SignedNumber(session_id as i128), - ) - .add_typed_default( - DataType::VersionNumber, - DataValue::SignedNumber(version as i128), - ) + + CommunicationValue::new(CommunicationType::ClientConnected) + .with_id(cv.get_id()) + .add_typed_default(DataType::Contacts, DataValue::Array(contacts_array)) } pub fn handle_message_state(cv: &CommunicationValue) { diff --git a/iota-daemon-lib/Cargo.toml b/iota-daemon-lib/Cargo.toml index 5e0c67c..f039440 100644 --- a/iota-daemon-lib/Cargo.toml +++ b/iota-daemon-lib/Cargo.toml @@ -9,14 +9,12 @@ iota-ipc = { path = "../iota-ipc" } iota-logger = { path = "../iota-logger" } iota-state = { path = "../iota-state" } iota-storage = { path = "../iota-storage" } -iota-updater = { path = "../iota-updater" } iota-util = { path = "../iota-util" } omikron-connector = { path = "../omikron-connector" } mtp = { git = "https://git.methanium.net/Methanium/mtp.git" } dashmap = "6.1.0" libc = "0.2" sysinfo = "0.38.3" -serde_yaml = "0.9" tokio = { version = "1.50.0", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } uuid = { version = "*", features = ["v4"] } diff --git a/iota-daemon-lib/src/command_router.rs b/iota-daemon-lib/src/command_router.rs index ebc3e0e..6fe4c5f 100644 --- a/iota-daemon-lib/src/command_router.rs +++ b/iota-daemon-lib/src/command_router.rs @@ -1,16 +1,10 @@ -use crate::log_buffer::LogBuffer; use crate::{DaemonRuntime, DaemonServices}; -use iota_ipc::{ - CommunitySummary, ComponentStatusResponse, ConfigResponse, ExitIntent, IpcErrorCode, - LocalRequest, LogEntriesResponse, OmikronStatusResponse, ResponseEnvelope, ResponsePayload, - ResponseResult, StatusResponse, TaskSummary, UpdateStatusResponse, UserDetailResponse, - UserSummary, -}; +use iota_ipc::{ExitIntent, IpcErrorCode, LocalRequest, ResponseEnvelope, ResponseResult}; use iota_logger::{log, log_command}; use iota_storage::users::user_manager; -use iota_storage::util::config_util::{self, modify_config}; +use iota_storage::util::config_util::modify_config; use mtp::codec::{CommunicationType, CommunicationValue}; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use std::time::Duration; use crate::daemon_state::{ShutdownReason, StartupPhase}; @@ -19,20 +13,11 @@ use crate::daemon_state::{ShutdownReason, StartupPhase}; pub struct CommandRouter { runtime: Arc, services: Arc, - log_buffer: Arc>, } impl CommandRouter { - pub fn new( - runtime: Arc, - services: Arc, - log_buffer: Arc>, - ) -> Self { - Self { - runtime, - services, - log_buffer, - } + pub fn new(runtime: Arc, services: Arc) -> Self { + Self { runtime, services } } pub async fn route(&self, request_id: u64, request: LocalRequest) -> ResponseEnvelope { @@ -41,15 +26,36 @@ impl CommandRouter { ResponseEnvelope { request_id, result } } - async fn execute(&self, request: LocalRequest) -> ResponseResult { - if !self.services.active - && !matches!( - request, - LocalRequest::GetStatus | LocalRequest::GetDaemonStatus - ) - { - return ResponseResult::Error(IpcErrorCode::Unauthorized); + /// Parse a legacy console command string into a typed request. + pub fn parse_console_command(line: &str) -> Option { + let parts: Vec<&str> = line.trim_start_matches('/').split_whitespace().collect(); + match parts.as_slice() { + ["help"] => None, + ["tasks"] => Some(LocalRequest::ListTasks), + ["ping", _] | ["ping"] => None, + ["user", "add", username] => Some(LocalRequest::CreateUser { + username: username.to_string(), + }), + ["user", "remove", username] => { + let user = user_manager::get_user_by_username(username)?; + Some(LocalRequest::RemoveUser { + user_id: user.user_id, + }) + } + ["user", "list"] => Some(LocalRequest::ListUsers), + ["reconnect"] => Some(LocalRequest::ReconnectOmikron), + ["regenerate", "keys"] => Some(LocalRequest::RotateIotaIdentity), + ["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit { + intent: ExitIntent::Restart, + }), + ["shutdown"] | ["stop"] => Some(LocalRequest::RequestProcessExit { + intent: ExitIntent::Stop, + }), + _ => None, } + } + + async fn execute(&self, request: LocalRequest) -> ResponseResult { let needs_omikron = matches!( request, LocalRequest::CreateUser { .. } @@ -77,33 +83,28 @@ impl CommandRouter { .iter() .map(|task| task.to_string()) .collect(); - ResponseResult::Ok(ResponsePayload::Status(StatusResponse { - phase: format!("{:?}", phase), - tasks: tasks.clone(), - degraded_reason: degraded, - })) + let mut info = format!("Phase: {:?}, Tasks: {}", phase, tasks.join(", ")); + if let Some(reason) = degraded { + info.push_str(&format!(", Degraded: {}", reason)); + } + ResponseResult::Ok(info) } LocalRequest::ListTasks => { - let tasks: Vec = self + let tasks: Vec = self .runtime .state .active_tasks .iter() - .map(|task| TaskSummary { - name: task.to_string(), - }) + .map(|task| task.to_string()) .collect(); - ResponseResult::Ok(ResponsePayload::Tasks(tasks)) + ResponseResult::Ok(tasks.join(", ")) } LocalRequest::ListUsers => { - let users: Vec = user_manager::get_users() + let users: Vec = user_manager::get_users() .into_iter() - .map(|user| UserSummary { - user_id: user.user_id, - username: user.username, - }) + .map(|user| format!("{} ({})", user.username, user.user_id)) .collect(); - ResponseResult::Ok(ResponsePayload::Users(users)) + ResponseResult::Ok(users.join("\n")) } LocalRequest::CreateUser { username } => { match omikron_connector::user_ops::create_user( @@ -112,10 +113,7 @@ impl CommandRouter { ) .await { - (Some(user), _) => ResponseResult::Ok(ResponsePayload::UserCreated { - user_id: user.user_id, - username: user.username, - }), + (Some(user), _) => ResponseResult::Ok(format!("Created user {}", user.user_id)), _ => ResponseResult::Error(IpcErrorCode::StorageFailure), } } @@ -130,12 +128,10 @@ impl CommandRouter { return ResponseResult::Error(IpcErrorCode::OmikronUnavailable); } user_manager::remove_user(user.user_id); - ResponseResult::Ok(ResponsePayload::UserRemoved { user_id }) + ResponseResult::Ok(format!("Removed user {}", user.user_id)) } LocalRequest::ReconnectOmikron => match self.services.omikron.reconnect().await { - Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { - message: "Reconnected to Omikron server".into(), - }), + Ok(()) => ResponseResult::Ok("Reconnected to Omikron server".into()), Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable), }, LocalRequest::RotateIotaIdentity => { @@ -145,9 +141,9 @@ impl CommandRouter { config.iota_id = None; }); match self.services.omikron.reconnect().await { - Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { - message: "Key pair regenerated and Omikron reconnection requested".into(), - }), + Ok(()) => ResponseResult::Ok( + "Key pair regenerated and Omikron reconnection requested".into(), + ), Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable), } } @@ -164,117 +160,18 @@ impl CommandRouter { ExitIntent::Stop => ShutdownReason::Stop, ExitIntent::Restart => ShutdownReason::Restart, }); - ResponseResult::Ok(ResponsePayload::Acknowledged { - message: "process exit accepted".into(), - }) + ResponseResult::Ok("process exit accepted".into()) + } + LocalRequest::GetDaemonStatus => { + ResponseResult::Ok(format!("{:?}", self.runtime.snapshot())) } - LocalRequest::GetDaemonStatus => ResponseResult::Ok(ResponsePayload::DaemonStatus( - iota_ipc::DaemonStatusResponse { - formatted: format!("{:?}", self.runtime.snapshot()), - }, - )), LocalRequest::RestartDaemon => { self.runtime.shutdown(ShutdownReason::Restart); - ResponseResult::Ok(ResponsePayload::Acknowledged { - message: "Daemon restart requested".into(), - }) + ResponseResult::Ok("Daemon restart requested".into()) } LocalRequest::StopDaemon => { self.runtime.shutdown(ShutdownReason::Stop); - ResponseResult::Ok(ResponsePayload::Acknowledged { - message: "Daemon shutdown requested".into(), - }) - } - LocalRequest::GetConfig => { - let cfg = config_util::CONFIG.load(); - let yaml = serde_yaml::to_string(&**cfg).unwrap_or_default(); - ResponseResult::Ok(ResponsePayload::Config(ConfigResponse { yaml })) - } - LocalRequest::SetConfig { key, value } => { - match config_util::modify_config_value(&key, &value) { - Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { - message: format!("Set {key} = {value}"), - }), - Err(_e) => ResponseResult::Error(IpcErrorCode::InvalidRequest), - } - } - LocalRequest::ReloadConfig => { - config_util::load_config(); - ResponseResult::Ok(ResponsePayload::Acknowledged { - message: "Configuration reloaded".into(), - }) - } - LocalRequest::GetOmikronStatus => { - let connected = self.services.omikron.is_connected().await; - let iota_id = config_util::CONFIG.load().iota_id; - ResponseResult::Ok(ResponsePayload::OmikronStatus(OmikronStatusResponse { - connected, - iota_id, - })) - } - LocalRequest::ListComponents => { - let snapshot = self.runtime.snapshot(); - let components: Vec = snapshot - .components - .into_iter() - .map(|(id, health)| ComponentStatusResponse { - id, - status: health.status, - message: health.message, - }) - .collect(); - ResponseResult::Ok(ResponsePayload::Components(components)) - } - LocalRequest::GetUser { user_id } => match user_manager::get_user(user_id) { - Some(user) => ResponseResult::Ok(ResponsePayload::UserDetail(UserDetailResponse { - user_id: user.user_id, - username: user.username, - display_name: user.display_name, - created_at: user.created_at, - trusted_apps: user.trusted_apps.keys().cloned().collect(), - })), - None => ResponseResult::Error(IpcErrorCode::NotFound), - }, - LocalRequest::ImportUser { username } => { - match user_manager::load_from_tu(&username).await { - Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { - message: format!("Imported user {username}"), - }), - Err(()) => ResponseResult::Error(IpcErrorCode::StorageFailure), - } - } - LocalRequest::GetLogs { limit } => { - let entries = if let Ok(buf) = self.log_buffer.lock() { - buf.recent(limit) - } else { - Vec::new() - }; - ResponseResult::Ok(ResponsePayload::LogEntries(LogEntriesResponse { entries })) - } - LocalRequest::CheckUpdate => match iota_updater::check_update().await { - Ok(available) => { - ResponseResult::Ok(ResponsePayload::UpdateStatus(UpdateStatusResponse { - available, - })) - } - Err(_e) => ResponseResult::Error(IpcErrorCode::InternalFailure), - }, - LocalRequest::ListCommunities => { - let iota_id = config_util::CONFIG - .load() - .iota_id - .map(|id| id as i64) - .unwrap_or(0); - let stored = - iota_storage::util::communities_util::CommunitiesUtil::get_communities(iota_id); - let summaries: Vec = stored - .into_iter() - .map(|c| CommunitySummary { - name: c.address, - title: c.title, - }) - .collect(); - ResponseResult::Ok(ResponsePayload::Communities(summaries)) + ResponseResult::Ok("Daemon shutdown requested".into()) } } } diff --git a/iota-daemon-lib/src/ipc_server.rs b/iota-daemon-lib/src/ipc_server.rs index 97b5dd6..b6ea1cb 100644 --- a/iota-daemon-lib/src/ipc_server.rs +++ b/iota-daemon-lib/src/ipc_server.rs @@ -1,5 +1,4 @@ use crate::deployment::from_environment; -use crate::log_buffer::LogBuffer; use crate::{CommandRouter, DaemonRuntime, DaemonServices}; use iota_ipc::{ ClientMessage, DaemonMessage, HelloAck, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION, read_msg, @@ -9,7 +8,7 @@ use iota_logger::log; use std::io::Result; use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt}; use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use std::{env, fs::File, os::fd::FromRawFd, os::unix::net::UnixListener as StdUnixListener}; use tokio::net::{UnixListener, UnixStream}; use tokio::sync::{broadcast, mpsc, watch}; @@ -23,25 +22,11 @@ const CLIENT_CHANNEL_SIZE: usize = 256; const MAX_HANDSHAKE_RETRIES: u32 = 1; const CLIENT_IO_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); -/// Minimum metric subscription interval to prevent excessive update rates. -const MIN_METRIC_INTERVAL_MS: u64 = 100; -/// Maximum metric subscription interval. -const MAX_METRIC_INTERVAL_MS: u64 = 60_000; -/// Default metric interval if the client does not specify one. -const DEFAULT_METRIC_INTERVAL_MS: u64 = 500; - -/// Per-client subscription state. -struct ClientSubscription { - log_classes: Vec, - metric_interval_ms: u64, -} - pub struct IpcServer { listener: UnixListener, runtime: Arc, services: Arc, log_tx: broadcast::Sender, - log_buffer: Arc>, state_rx: watch::Receiver, instance_id: String, _instance_lock: File, @@ -53,7 +38,6 @@ impl IpcServer { runtime: Arc, services: Arc, log_tx: broadcast::Sender, - log_buffer: Arc>, state_rx: watch::Receiver, ) -> Result { let path = path.into(); @@ -106,7 +90,6 @@ impl IpcServer { runtime, services, log_tx, - log_buffer, state_rx, instance_id: Uuid::new_v4().to_string(), _instance_lock: lock, @@ -118,7 +101,6 @@ impl IpcServer { runtime, services, log_tx, - log_buffer, state_rx, instance_id: Uuid::new_v4().to_string(), _instance_lock: File::options().read(true).open("/dev/null")?, @@ -132,20 +114,11 @@ impl IpcServer { let runtime = self.runtime.clone(); let services = self.services.clone(); let log_tx = self.log_tx.clone(); - let log_buffer = self.log_buffer.clone(); let state_rx = self.state_rx.clone(); let instance_id = self.instance_id.clone(); tokio::spawn(async move { - if let Err(error) = handle_client( - stream, - runtime, - services, - log_tx, - log_buffer, - state_rx, - instance_id, - ) - .await + if let Err(error) = + handle_client(stream, runtime, services, log_tx, state_rx, instance_id).await { eprintln!("IPC client error: {error}"); } @@ -279,7 +252,6 @@ async fn handle_client( runtime: Arc, services: Arc, log_tx: broadcast::Sender, - log_buffer: Arc>, mut state_rx: watch::Receiver, instance_id: String, ) -> Result<()> { @@ -365,18 +337,12 @@ async fn handle_client( // --- Writer task: merge directed responses + shared log events --- let mut log_rx = log_tx.subscribe(); - let (sub_tx, mut sub_rx) = tokio::sync::watch::channel(ClientSubscription { - log_classes: Vec::new(), - metric_interval_ms: DEFAULT_METRIC_INTERVAL_MS, - }); let writer_task = { let runtime = runtime.clone(); let session_cancellation = session_cancellation.clone(); tokio::spawn(async move { let mut directed_rx = directed_rx; - let mut last_metric_sent = tokio::time::Instant::now(); loop { - let metric_interval = sub_rx.borrow().metric_interval_ms; tokio::select! { // Directed messages (responses to this client's requests) msg = directed_rx.recv() => { @@ -394,35 +360,9 @@ async fn handle_client( // Shared log events result = log_rx.recv() => { match result { - Ok(DaemonMessage::LogEntry(entry)) => { - // Filter by subscribed log classes - let log_classes = sub_rx.borrow().log_classes.clone(); - if log_classes.is_empty() - || log_classes.iter().any(|c| entry.sender == *c) - { - if let Err(error) = write_client_message(&mut writer, &DaemonMessage::LogEntry(entry)).await { - eprintln!("IPC client writer stopped while sending log message: {error}"); - session_cancellation.cancel(); - break; - } - } - } - Ok(DaemonMessage::MetricSample(sample)) => { - // Rate-limit metric samples based on subscription interval - let now = tokio::time::Instant::now(); - if now.duration_since(last_metric_sent) >= std::time::Duration::from_millis(metric_interval) { - last_metric_sent = now; - if let Err(error) = write_client_message(&mut writer, &DaemonMessage::MetricSample(sample)).await { - eprintln!("IPC client writer stopped while sending metric sample: {error}"); - session_cancellation.cancel(); - break; - } - } - } Ok(message) => { - // Forward other broadcast messages as-is if let Err(error) = write_client_message(&mut writer, &message).await { - eprintln!("IPC client writer stopped while sending broadcast message: {error}"); + eprintln!("IPC client writer stopped while sending log message: {error}"); session_cancellation.cancel(); break; } @@ -449,14 +389,13 @@ async fn handle_client( break; } } - _ = sub_rx.changed() => {} } } }) }; // --- Reader loop --- - let router = CommandRouter::new(runtime.clone(), services, log_buffer); + let router = CommandRouter::new(runtime.clone(), services); loop { let message = tokio::select! { _ = session_cancellation.cancelled() => break, @@ -503,17 +442,7 @@ async fn handle_client( break; } } - Ok(ClientMessage::Subscribe { - log_classes, - metric_interval_ms, - }) => { - let interval = metric_interval_ms - .unwrap_or(DEFAULT_METRIC_INTERVAL_MS) - .clamp(MIN_METRIC_INTERVAL_MS, MAX_METRIC_INTERVAL_MS); - let _ = sub_tx.send(ClientSubscription { - log_classes, - metric_interval_ms: interval, - }); + Ok(ClientMessage::Subscribe { .. }) => { let snapshot = DaemonMessage::StateUpdate(runtime.snapshot()); let _ = directed_tx.send(snapshot).await; let _ = directed_tx.send(DaemonMessage::Subscribed).await; diff --git a/iota-daemon-lib/src/lib.rs b/iota-daemon-lib/src/lib.rs index 6ccd977..5a40295 100644 --- a/iota-daemon-lib/src/lib.rs +++ b/iota-daemon-lib/src/lib.rs @@ -3,7 +3,6 @@ pub mod daemon_state; pub mod deployment; pub mod ipc_server; pub mod log_broadcaster; -pub mod log_buffer; pub mod services; pub mod task_registry; diff --git a/iota-daemon-lib/src/log_broadcaster.rs b/iota-daemon-lib/src/log_broadcaster.rs index 9bb1d05..1ce3c88 100644 --- a/iota-daemon-lib/src/log_broadcaster.rs +++ b/iota-daemon-lib/src/log_broadcaster.rs @@ -1,27 +1,21 @@ -use crate::log_buffer::LogBuffer; use iota_ipc::{DaemonMessage, LogEntry}; use iota_logger::subscribe; -use std::sync::{Arc, Mutex}; use tokio::sync::broadcast; /* The daemon adapts logger output to the wire protocol so the logger stays * independent from both the socket implementation and TUI state. */ -pub fn spawn(message_tx: broadcast::Sender, buffer: Arc>) { +pub fn spawn(message_tx: broadcast::Sender) { let Some(mut logs) = subscribe() else { return; }; tokio::spawn(async move { while let Ok(entry) = logs.recv().await { - let entry = LogEntry { + let _ = message_tx.send(DaemonMessage::LogEntry(LogEntry { timestamp_ms: entry.timestamp_ms, sender: entry.sender, message: entry.message, is_error: entry.is_error, - }; - if let Ok(mut buf) = buffer.lock() { - buf.push(entry.clone()); - } - let _ = message_tx.send(DaemonMessage::LogEntry(entry)); + })); } }); } diff --git a/iota-daemon-lib/src/log_buffer.rs b/iota-daemon-lib/src/log_buffer.rs deleted file mode 100644 index bb2cdc6..0000000 --- a/iota-daemon-lib/src/log_buffer.rs +++ /dev/null @@ -1,36 +0,0 @@ -use iota_ipc::LogEntry; -use std::collections::VecDeque; - -pub struct LogBuffer { - entries: VecDeque, - capacity: usize, -} - -impl LogBuffer { - pub fn new(capacity: usize) -> Self { - Self { - entries: VecDeque::with_capacity(capacity), - capacity, - } - } - - pub fn push(&mut self, entry: LogEntry) { - if self.entries.len() == self.capacity { - self.entries.pop_front(); - } - self.entries.push_back(entry); - } - - pub fn recent(&self, limit: usize) -> Vec { - let _len = self.entries.len(); - self.entries - .iter() - .rev() - .take(limit) - .cloned() - .collect::>() - .into_iter() - .rev() - .collect() - } -} diff --git a/iota-daemon-lib/src/services.rs b/iota-daemon-lib/src/services.rs index bd2ac1c..a04002c 100644 --- a/iota-daemon-lib/src/services.rs +++ b/iota-daemon-lib/src/services.rs @@ -1,8 +1,5 @@ -use async_trait::async_trait; -use mtp::codec::CommunicationValue; -use omikron_connector::{OmikronClient, OmikronConnection, OmikronError}; +use omikron_connector::{OmikronClient, OmikronConnection}; use std::sync::Arc; -use std::time::Duration; #[derive(Default)] pub struct UserService; @@ -13,7 +10,6 @@ pub struct DaemonServices { pub omikron: Arc, pub users: Arc, pub config: Arc, - pub active: bool, } impl DaemonServices { @@ -22,46 +18,6 @@ impl DaemonServices { omikron, users: Arc::new(UserService), config: Arc::new(ConfigService), - active: true, - }) - } - - /// Services used while the daemon is awaiting terms acceptance. They can - /// never initiate a connection; the command router exposes status only. - pub fn inactive() -> Arc { - Arc::new(Self { - omikron: Arc::new(InactiveOmikron), - users: Arc::new(UserService), - config: Arc::new(ConfigService), - active: false, }) } } - -struct InactiveOmikron; - -#[async_trait] -impl OmikronClient for InactiveOmikron { - async fn send_message(&self, _: &CommunicationValue) -> Result<(), OmikronError> { - Err(OmikronError::Disconnected( - "terms have not been accepted".into(), - )) - } - async fn await_response( - &self, - _: &CommunicationValue, - _: Duration, - ) -> Result { - Err(OmikronError::Disconnected( - "terms have not been accepted".into(), - )) - } - async fn reconnect(&self) -> Result<(), OmikronError> { - Err(OmikronError::Disconnected( - "terms have not been accepted".into(), - )) - } - async fn is_connected(&self) -> bool { - false - } -} diff --git a/iota-daemon-lib/tests/command_router.rs b/iota-daemon-lib/tests/command_router.rs index 3f80199..525bca1 100644 --- a/iota-daemon-lib/tests/command_router.rs +++ b/iota-daemon-lib/tests/command_router.rs @@ -1,11 +1,10 @@ use async_trait::async_trait; -use iota_daemon_lib::log_buffer::LogBuffer; use iota_daemon_lib::{CommandRouter, DaemonRuntime, DaemonServices}; use iota_ipc::{LocalRequest, ResponseResult}; use mtp::codec::CommunicationValue; use omikron_connector::{OmikronClient, OmikronError}; use std::sync::{ - Arc, Mutex, + Arc, atomic::{AtomicUsize, Ordering}, }; use std::time::Duration; @@ -44,11 +43,7 @@ async fn reconnect_uses_the_injected_client() { users: Default::default(), config: Default::default(), }); - let router = CommandRouter::new( - Arc::new(DaemonRuntime::new()), - services, - Arc::new(Mutex::new(LogBuffer::new(100))), - ); + let router = CommandRouter::new(Arc::new(DaemonRuntime::new()), services); assert!(matches!( router.route(1, LocalRequest::ReconnectOmikron).await.result, ResponseResult::Ok(_) diff --git a/iota-daemon/Cargo.toml b/iota-daemon/Cargo.toml index 29de9e1..074f91b 100644 --- a/iota-daemon/Cargo.toml +++ b/iota-daemon/Cargo.toml @@ -11,7 +11,6 @@ iota-state = { path = "../iota-state" } iota-paths = { path = "../iota-paths" } iota-storage = { path = "../iota-storage" } iota-util = { path = "../iota-util" } -iota-terms = { path = "../iota-terms" } omikron-connector = { path = "../omikron-connector" } web-server = { path = "../web-server" } tokio = { version = "1.50.0", features = ["full"] } diff --git a/iota-daemon/src/main.rs b/iota-daemon/src/main.rs index d971f91..12d24fd 100644 --- a/iota-daemon/src/main.rs +++ b/iota-daemon/src/main.rs @@ -1,12 +1,11 @@ use iota_daemon_lib::{ DaemonRuntime, DaemonServices, IpcServer, ShutdownReason, StartupPhase, log_broadcaster, - log_buffer::LogBuffer, }; use iota_logger::{self as logger, log}; use iota_storage::users::user_manager; use iota_storage::util::config_util::CONFIG; use std::process::ExitCode; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use std::time::Duration; use tokio::sync::{broadcast, watch}; #[tokio::main(flavor = "multi_thread")] @@ -22,61 +21,6 @@ async fn main() -> ExitCode { return ExitCode::FAILURE; } }; - // Bind a deliberately dormant IPC daemon before terms are accepted. This - // makes socket activation and `iota terms accept --system` usable, while - // the router exposes status only and the inactive service cannot connect. - if !iota_terms::consent::load(&paths.state_dir).has_all_required() { - let socket = match &paths.ipc_endpoint { - iota_paths::IpcEndpoint::UnixSocket(path) => path.clone(), - iota_paths::IpcEndpoint::WindowsPipe(_) => { - eprintln!("Windows named-pipe daemon transport is not implemented yet"); - return ExitCode::FAILURE; - } - }; - let runtime = Arc::new(DaemonRuntime::new()); - let (log_tx, _) = broadcast::channel(64); - let log_buffer = Arc::new(Mutex::new(LogBuffer::new(64))); - let (_, state_rx) = watch::channel(runtime.snapshot()); - let server = match IpcServer::bind( - socket, - runtime.clone(), - DaemonServices::inactive(), - log_tx, - log_buffer, - state_rx, - ) - .await - { - Ok(server) => server, - Err(error) => { - eprintln!("Cannot bind dormant daemon IPC socket: {error}"); - return ExitCode::FAILURE; - } - }; - tokio::spawn(async move { - let _ = server.serve().await; - }); - eprintln!( - "Iota daemon is awaiting terms acceptance. Run `iota terms accept{}` in an interactive terminal.", - if paths.scope == iota_paths::Scope::System { - " --system" - } else { - "" - } - ); - loop { - tokio::select! { - _ = tokio::time::sleep(Duration::from_secs(1)) => { - if iota_terms::consent::load(&paths.state_dir).has_all_required() { - // systemd restarts this daemon; a locally-launched daemon can - // simply be started again after accepting the documents. - return ExitCode::from(75); - } - } - _ = tokio::signal::ctrl_c() => return ExitCode::SUCCESS, - } - } - } if let Err(error) = paths.migrate_legacy_layout() { eprintln!("Cannot migrate legacy Iota layout: {error}"); return ExitCode::FAILURE; @@ -97,8 +41,7 @@ async fn main() -> ExitCode { let runtime = Arc::new(DaemonRuntime::new()); // --- IPC infrastructure --- let (log_tx, _) = broadcast::channel(512); - let log_buffer = Arc::new(Mutex::new(LogBuffer::new(1024))); - log_broadcaster::spawn(log_tx.clone(), log_buffer.clone()); + log_broadcaster::spawn(log_tx.clone()); let (state_tx, state_rx) = watch::channel(runtime.snapshot()); runtime.set_startup_phase(StartupPhase::LoadingUsers); @@ -158,7 +101,6 @@ async fn main() -> ExitCode { runtime.clone(), services, log_tx.clone(), - log_buffer.clone(), state_rx, ) .await @@ -186,21 +128,6 @@ async fn main() -> ExitCode { .await; log!("iota-daemon IPC server ready"); - log!( - "iota-daemon paths (scope={:?}): config={} state={} storage={} identity={} cache={} log={} asset={} ipc={}", - paths.scope, - paths.config_file.display(), - paths.state_dir.display(), - paths.storage_dir.display(), - paths.identity_dir.display(), - paths.cache_dir.display(), - paths.log_dir.display(), - paths.asset_dir.display(), - match &paths.ipc_endpoint { - iota_paths::IpcEndpoint::UnixSocket(p) => p.display().to_string(), - iota_paths::IpcEndpoint::WindowsPipe(n) => n.clone(), - }, - ); runtime.set_startup_phase(StartupPhase::StartingServices); // --- System monitor --- diff --git a/iota-ipc/src/lib.rs b/iota-ipc/src/lib.rs index 0ff6284..1118fc2 100644 --- a/iota-ipc/src/lib.rs +++ b/iota-ipc/src/lib.rs @@ -1,14 +1,11 @@ pub mod protocol; -pub mod text_commands; pub mod transport; pub use protocol::{ - ClientMessage, CommunitySummary, ComponentHealth, ComponentId, ComponentStatusResponse, - ConfigResponse, ConnectionStatus, DaemonMessage, DaemonStatusResponse, DeploymentMode, + ClientMessage, ComponentHealth, ComponentId, ConnectionStatus, DaemonMessage, DeploymentMode, ExitIntent, HealthStatus, HelloAck, IpcErrorCode, LifecycleEvent, LifecyclePhase, LocalRequest, - LogEntriesResponse, LogEntry, MetricSample, OmikronStatusResponse, RequestEnvelope, - ResponseEnvelope, ResponsePayload, ResponseResult, StartupPhase, StateSnapshot, StatusResponse, - SupervisorKind, TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary, + LogEntry, MetricSample, RequestEnvelope, ResponseEnvelope, ResponseResult, StartupPhase, + StateSnapshot, SupervisorKind, }; pub use transport::{read_msg, write_msg}; diff --git a/iota-ipc/src/protocol.rs b/iota-ipc/src/protocol.rs index d31bee4..7a95a3d 100644 --- a/iota-ipc/src/protocol.rs +++ b/iota-ipc/src/protocol.rs @@ -49,25 +49,6 @@ pub enum LocalRequest { RestartDaemon, #[serde(skip)] StopDaemon, - GetConfig, - SetConfig { - key: String, - value: String, - }, - ReloadConfig, - GetOmikronStatus, - ListComponents, - GetUser { - user_id: i64, - }, - ImportUser { - username: String, - }, - GetLogs { - limit: usize, - }, - CheckUpdate, - ListCommunities, } #[derive(Clone, Copy, Debug, Deserialize, Serialize)] @@ -126,95 +107,10 @@ pub struct ResponseEnvelope { #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(tag = "type", content = "data", rename_all = "snake_case")] pub enum ResponseResult { - Ok(ResponsePayload), + Ok(String), Error(IpcErrorCode), } -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(tag = "type", content = "data", rename_all = "snake_case")] -pub enum ResponsePayload { - Status(StatusResponse), - Tasks(Vec), - Users(Vec), - UserCreated { user_id: i64, username: String }, - UserRemoved { user_id: i64 }, - Acknowledged { message: String }, - DaemonStatus(DaemonStatusResponse), - Config(ConfigResponse), - OmikronStatus(OmikronStatusResponse), - Components(Vec), - UserDetail(UserDetailResponse), - LogEntries(LogEntriesResponse), - UpdateStatus(UpdateStatusResponse), - Communities(Vec), -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct ConfigResponse { - pub yaml: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct OmikronStatusResponse { - pub connected: bool, - pub iota_id: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct ComponentStatusResponse { - pub id: ComponentId, - pub status: HealthStatus, - pub message: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct UserDetailResponse { - pub user_id: i64, - pub username: String, - pub display_name: Option, - pub created_at: i64, - pub trusted_apps: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct LogEntriesResponse { - pub entries: Vec, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct UpdateStatusResponse { - pub available: bool, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct CommunitySummary { - pub name: String, - pub title: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct StatusResponse { - pub phase: String, - pub tasks: Vec, - pub degraded_reason: Option, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct TaskSummary { - pub name: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct UserSummary { - pub user_id: i64, - pub username: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct DaemonStatusResponse { - pub formatted: String, -} - #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "snake_case")] pub enum IpcErrorCode { @@ -232,43 +128,6 @@ pub enum IpcErrorCode { InternalFailure, } -impl std::fmt::Display for IpcErrorCode { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - Self::InvalidRequest => "the daemon rejected the request", - Self::NotFound => "the requested resource was not found", - Self::Conflict => "the request conflicts with current daemon state", - Self::StorageFailure => "the daemon could not access local storage", - Self::OmikronUnavailable => "Omikron is unavailable", - Self::UnsupportedVersion => "the client and daemon protocol versions are incompatible", - Self::NotReady => "the daemon is not ready yet", - Self::Disconnected => "the daemon connection was lost", - Self::Timeout => "the daemon did not respond in time", - Self::Cancelled => "the daemon cancelled the request", - Self::Unauthorized => "the daemon denied this operation", - Self::InternalFailure => "the daemon encountered an internal failure", - }) - } -} - -#[cfg(test)] -mod error_tests { - use super::IpcErrorCode; - - #[test] - fn error_codes_have_operator_facing_messages() { - assert_eq!( - IpcErrorCode::NotReady.to_string(), - "the daemon is not ready yet" - ); - assert!( - !IpcErrorCode::InternalFailure - .to_string() - .contains("InternalFailure") - ); - } -} - #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(tag = "type", content = "data", rename_all = "snake_case")] pub enum LifecycleEvent { diff --git a/iota-ipc/src/text_commands.rs b/iota-ipc/src/text_commands.rs deleted file mode 100644 index f8ee611..0000000 --- a/iota-ipc/src/text_commands.rs +++ /dev/null @@ -1,324 +0,0 @@ -use crate::LocalRequest; - -pub const COMMANDS: &[&str] = &[ - "status", - "tasks", - "users list", - "users show ", - "users add ", - "users remove ", - "users import ", - "omikron status", - "reconnect", - "identity rotate", - "daemon status", - "config get", - "config set ", - "config reload", - "components", - "logs", - "update check", - "community list", - "restart", - "stop", -]; - -pub fn completions(prefix: &str) -> Vec<&'static str> { - let normalized = prefix.trim_start_matches('/'); - COMMANDS - .iter() - .copied() - .filter(|command| command.starts_with(normalized)) - .collect() -} - -pub fn validation_error(line: &str) -> Option { - let normalized = line.trim_start_matches('/').trim(); - if normalized == "help" || parse(normalized).is_some() { - None - } else { - Some(format!( - "Unknown command `{normalized}`. Use /help or Tab completion." - )) - } -} - -/// Parse a text command string into a typed IPC request. -/// -/// Both the CLI console and the TUI command palette use this single parser. -/// Commands are case-insensitive and support an optional leading `/`. -pub fn parse(line: &str) -> Option { - let parts: Vec<&str> = line.trim_start_matches('/').split_whitespace().collect(); - match parts.as_slice() { - ["help"] => None, - ["status"] => Some(LocalRequest::GetStatus), - ["tasks"] => Some(LocalRequest::ListTasks), - ["users"] | ["user", "list"] | ["users", "list"] => Some(LocalRequest::ListUsers), - ["user" | "users", "show", id_str] => { - let user_id = id_str.parse::().ok()?; - Some(LocalRequest::GetUser { user_id }) - } - ["user" | "users", "add", username] => Some(LocalRequest::CreateUser { - username: username.to_string(), - }), - ["user" | "users", "remove", id_str] => { - let user_id = id_str.parse::().ok()?; - Some(LocalRequest::RemoveUser { user_id }) - } - ["user" | "users", "import", username] => Some(LocalRequest::ImportUser { - username: username.to_string(), - }), - ["reconnect"] => Some(LocalRequest::ReconnectOmikron), - ["regenerate", "keys"] | ["identity", "rotate"] => Some(LocalRequest::RotateIotaIdentity), - ["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit { - intent: crate::ExitIntent::Restart, - }), - ["shutdown"] | ["stop"] => Some(LocalRequest::RequestProcessExit { - intent: crate::ExitIntent::Stop, - }), - ["daemon", "status"] => Some(LocalRequest::GetDaemonStatus), - ["config", "get"] => Some(LocalRequest::GetConfig), - ["config", "set", key, value] => Some(LocalRequest::SetConfig { - key: key.to_string(), - value: value.to_string(), - }), - ["config", "reload"] => Some(LocalRequest::ReloadConfig), - ["omikron", "status"] => Some(LocalRequest::GetOmikronStatus), - ["components"] => Some(LocalRequest::ListComponents), - ["logs"] => Some(LocalRequest::GetLogs { limit: 100 }), - ["update", "check"] => Some(LocalRequest::CheckUpdate), - ["community", "list"] | ["communities"] => Some(LocalRequest::ListCommunities), - _ => None, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_status() { - assert!(matches!(parse("status"), Some(LocalRequest::GetStatus))); - } - - #[test] - fn parses_tasks() { - assert!(matches!(parse("tasks"), Some(LocalRequest::ListTasks))); - } - - #[test] - fn parses_user_list_shortcuts() { - assert!(matches!(parse("users"), Some(LocalRequest::ListUsers))); - assert!(matches!(parse("user list"), Some(LocalRequest::ListUsers))); - } - - #[test] - fn parses_user_add() { - let req = parse("user add alice").unwrap(); - match req { - LocalRequest::CreateUser { username } => assert_eq!(username, "alice"), - _ => panic!("expected CreateUser"), - } - } - - #[test] - fn accepts_the_headless_cli_user_vocabulary() { - assert!(matches!(parse("users list"), Some(LocalRequest::ListUsers))); - assert!(matches!( - parse("users add alice"), - Some(LocalRequest::CreateUser { .. }) - )); - assert!(matches!( - parse("users remove 42"), - Some(LocalRequest::RemoveUser { user_id: 42 }) - )); - assert!(matches!( - parse("identity rotate"), - Some(LocalRequest::RotateIotaIdentity) - )); - } - - #[test] - fn parses_user_remove_by_id() { - let req = parse("user remove 42").unwrap(); - match req { - LocalRequest::RemoveUser { user_id } => assert_eq!(user_id, 42), - _ => panic!("expected RemoveUser"), - } - } - - #[test] - fn user_remove_requires_numeric_id() { - assert!(parse("user remove alice").is_none()); - } - - #[test] - fn parses_reconnect() { - assert!(matches!( - parse("reconnect"), - Some(LocalRequest::ReconnectOmikron) - )); - } - - #[test] - fn parses_regenerate_keys() { - assert!(matches!( - parse("regenerate keys"), - Some(LocalRequest::RotateIotaIdentity) - )); - } - - #[test] - fn parses_restart_aliases() { - assert!(matches!( - parse("restart"), - Some(LocalRequest::RequestProcessExit { .. }) - )); - assert!(matches!( - parse("reload"), - Some(LocalRequest::RequestProcessExit { .. }) - )); - } - - #[test] - fn parses_stop_aliases() { - assert!(matches!( - parse("stop"), - Some(LocalRequest::RequestProcessExit { .. }) - )); - assert!(matches!( - parse("shutdown"), - Some(LocalRequest::RequestProcessExit { .. }) - )); - } - - #[test] - fn parses_daemon_status() { - assert!(matches!( - parse("daemon status"), - Some(LocalRequest::GetDaemonStatus) - )); - } - - #[test] - fn parses_config_get() { - assert!(matches!(parse("config get"), Some(LocalRequest::GetConfig))); - } - - #[test] - fn parses_config_reload() { - assert!(matches!( - parse("config reload"), - Some(LocalRequest::ReloadConfig) - )); - } - - #[test] - fn parses_omikron_status() { - assert!(matches!( - parse("omikron status"), - Some(LocalRequest::GetOmikronStatus) - )); - } - - #[test] - fn parses_components() { - assert!(matches!( - parse("components"), - Some(LocalRequest::ListComponents) - )); - } - - #[test] - fn parses_users_show() { - let req = parse("users show 42").unwrap(); - match req { - LocalRequest::GetUser { user_id } => assert_eq!(user_id, 42), - _ => panic!("expected GetUser"), - } - } - - #[test] - fn user_show_requires_numeric_id() { - assert!(parse("users show alice").is_none()); - } - - #[test] - fn parses_config_set() { - let req = parse("config set port 8080").unwrap(); - match req { - LocalRequest::SetConfig { key, value } => { - assert_eq!(key, "port"); - assert_eq!(value, "8080"); - } - _ => panic!("expected SetConfig"), - } - } - - #[test] - fn parses_logs() { - assert!(matches!(parse("logs"), Some(LocalRequest::GetLogs { .. }))); - } - - #[test] - fn parses_update_check() { - assert!(matches!( - parse("update check"), - Some(LocalRequest::CheckUpdate) - )); - } - - #[test] - fn parses_community_list() { - assert!(matches!( - parse("community list"), - Some(LocalRequest::ListCommunities) - )); - } - - #[test] - fn parses_communities_alias() { - assert!(matches!( - parse("communities"), - Some(LocalRequest::ListCommunities) - )); - } - - #[test] - fn parses_users_import() { - let req = parse("users import alice").unwrap(); - match req { - LocalRequest::ImportUser { username } => assert_eq!(username, "alice"), - _ => panic!("expected ImportUser"), - } - } - - #[test] - fn parses_with_slash_prefix() { - assert!(matches!(parse("/status"), Some(LocalRequest::GetStatus))); - assert!(matches!(parse("/tasks"), Some(LocalRequest::ListTasks))); - } - - #[test] - fn unknown_returns_none() { - assert!(parse("nonexistent").is_none()); - } - - #[test] - fn completion_is_prefix_based_and_deterministic() { - assert_eq!(completions("identity r"), vec!["identity rotate"]); - assert_eq!(completions("/users a"), vec!["users add "]); - assert!(completions("definitely-unknown").is_empty()); - } - - #[test] - fn validation_distinguishes_help_and_unknown_commands() { - assert_eq!(validation_error("/help"), None); - assert!(validation_error("status").is_none()); - assert!( - validation_error("statuz") - .unwrap() - .contains("Unknown command") - ); - } -} diff --git a/iota-storage/src/util/chat_files.rs b/iota-storage/src/util/chat_files.rs index da05aad..b09d750 100644 --- a/iota-storage/src/util/chat_files.rs +++ b/iota-storage/src/util/chat_files.rs @@ -1,6 +1,5 @@ use crate::storage_error::StorageError; use crate::util::db; -use crate::util::sync::{self, EntityType, Operation}; use iota_logger::log; use rusqlite::params; @@ -47,7 +46,6 @@ impl MessageState { #[derive(Debug, Clone)] pub struct StoredMessage { pub id: i64, - pub external_user: i64, pub message_time: i64, pub content: String, pub edited: bool, @@ -152,8 +150,7 @@ fn update_message_content( .unwrap() .as_millis() as i64; - let tx = conn.unchecked_transaction()?; - tx.execute( + conn.execute( r#" INSERT INTO message_edits (message_id, content_before, content_after, edited_at, edited_by) VALUES (?1, ?2, ?3, ?4, ?5) @@ -161,7 +158,7 @@ fn update_message_content( params![msg_id, old_content, new_content, now, editor_id], )?; - tx.execute( + conn.execute( r#" UPDATE messages SET content = ?1, edited_count = edited_count + 1 @@ -169,14 +166,6 @@ fn update_message_content( "#, params![new_content, msg_id], )?; - sync::record_event( - &tx, - storage_owner, - EntityType::Message, - msg_id, - Operation::Upsert, - )?; - tx.commit()?; Ok(()) }) @@ -198,24 +187,15 @@ pub fn hard_delete_message( |row| row.get(0), )?; - let tx = conn.unchecked_transaction()?; - tx.execute( + conn.execute( "DELETE FROM message_edits WHERE message_id = ?1", params![msg_id], )?; - tx.execute( + conn.execute( "DELETE FROM reactions WHERE message_id = ?1", params![msg_id], )?; - tx.execute("DELETE FROM messages WHERE id = ?1", params![msg_id])?; - sync::record_event( - &tx, - storage_owner, - EntityType::Message, - msg_id, - Operation::Delete, - )?; - tx.commit()?; + conn.execute("DELETE FROM messages WHERE id = ?1", params![msg_id])?; Ok(()) }) } @@ -281,8 +261,7 @@ pub fn flag_deleted_by_external( message_time: i64, ) -> Result<(), StorageError> { db::with_db(|conn| { - let tx = conn.unchecked_transaction()?; - let affected = tx.execute( + let affected = conn.execute( r#" UPDATE messages SET deleted_by_external = 1 @@ -293,15 +272,6 @@ pub fn flag_deleted_by_external( if affected == 0 { return Err(StorageError::Other("Message not found".into())); } - let msg_id: i64 = tx.query_row("SELECT id FROM messages WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 ORDER BY id DESC LIMIT 1", params![storage_owner, external_user, message_time], |r| r.get(0))?; - sync::record_event( - &tx, - storage_owner, - EntityType::Message, - msg_id, - Operation::Delete, - )?; - tx.commit()?; Ok(()) }) } @@ -327,19 +297,10 @@ pub fn delete_edit_history( |row| row.get(0), )?; - let tx = conn.unchecked_transaction()?; - tx.execute( + conn.execute( "DELETE FROM message_edits WHERE message_id = ?1", params![msg_id], )?; - sync::record_event( - &tx, - storage_owner, - EntityType::Message, - msg_id, - Operation::Upsert, - )?; - tx.commit()?; Ok(()) }) } @@ -367,22 +328,13 @@ pub fn add_reaction( .unwrap() .as_millis() as i64; - let tx = conn.unchecked_transaction()?; - tx.execute( + conn.execute( r#" INSERT OR IGNORE INTO reactions (message_id, user_id, reaction, created_at) VALUES (?1, ?2, ?3, ?4) "#, params![msg_id, user_id, reaction, now], )?; - sync::record_event( - &tx, - storage_owner, - EntityType::Message, - msg_id, - Operation::Upsert, - )?; - tx.commit()?; Ok(()) }) } @@ -405,19 +357,10 @@ pub fn remove_reaction( |row| row.get(0), )?; - let tx = conn.unchecked_transaction()?; - tx.execute( + conn.execute( "DELETE FROM reactions WHERE message_id = ?1 AND user_id = ?2 AND reaction = ?3", params![msg_id, user_id, reaction], )?; - sync::record_event( - &tx, - storage_owner, - EntityType::Message, - msg_id, - Operation::Upsert, - )?; - tx.commit()?; Ok(()) }) } @@ -440,8 +383,7 @@ pub fn add_message( }; if let Err(e) = db::with_db(|conn| { - let tx = conn.unchecked_transaction()?; - tx.execute( + conn.execute( r#" INSERT INTO messages ( storage_owner, external_user, message_time, content, @@ -463,15 +405,6 @@ pub fn add_message( reply_to, ], )?; - let msg_id = tx.last_insert_rowid(); - sync::record_event( - &tx, - storage_owner, - EntityType::Message, - msg_id, - Operation::Upsert, - )?; - tx.commit()?; Ok(()) }) { log!("Failed to insert message into sqlite: {}", e); @@ -514,8 +447,7 @@ pub fn change_message_state( .as_str() .to_string(); - let tx = conn.unchecked_transaction()?; - tx.execute( + conn.execute( r#" UPDATE messages SET message_state = ?1 @@ -527,13 +459,6 @@ pub fn change_message_state( "#, params![upgraded, storage_owner, external_user, timestamp], )?; - let msg_id: i64 = tx.query_row( - "SELECT id FROM messages WHERE storage_owner = ?1 AND external_user = ?2 AND message_time = ?3 ORDER BY id DESC LIMIT 1", - params![storage_owner, external_user, timestamp], - |row| row.get(0), - )?; - sync::record_event(&tx, storage_owner, EntityType::Message, msg_id, Operation::Upsert)?; - tx.commit()?; Ok(()) }) .map_err(|e: StorageError| std::io::Error::new(std::io::ErrorKind::Other, e.to_string())) @@ -607,7 +532,6 @@ pub fn get_messages( |row| { Ok(StoredMessage { id: row.get(0)?, - external_user, message_time: row.get(1)?, content: row.get(2)?, sent_by_self: row.get::<_, i64>(3)? != 0, @@ -644,69 +568,6 @@ pub fn get_messages( } } -pub fn get_messages_by_ids(storage_owner: i64, ids: &[i64]) -> Vec { - if ids.is_empty() { - return Vec::new(); - } - let wanted: std::collections::HashSet = ids.iter().copied().collect(); - // A journal id uniquely identifies a row. Load all messages for this owner and retain only - // those ids; this keeps reaction hydration identical to normal message loading. - match db::with_db(|conn| { - let mut stmt = conn.prepare("SELECT id, message_time, content, sent_by_self, message_state, height, reply_to, edited_count, external_user FROM messages WHERE storage_owner = ?1 AND deleted_by_external = 0")?; - let rows = stmt.query_map([storage_owner], |row| { - let external_user: i64 = row.get(8)?; - Ok(StoredMessage { - id: row.get(0)?, - external_user, - message_time: row.get(1)?, - content: row.get(2)?, - sent_by_self: row.get::<_, i64>(3)? != 0, - message_state: row.get(4)?, - height: row.get(5).unwrap_or(0), - reply_to: row.get(6).ok().flatten(), - edited: row.get::<_, i64>(7).unwrap_or(0) > 0, - reactions: Vec::new(), - }) - })?; - let mut messages = Vec::new(); - for row in rows { - let message = row?; - if wanted.contains(&message.id) { - messages.push(message); - } - } - let reaction_map = load_reactions(conn, &messages.iter().map(|m| m.id).collect::>()); - for message in &mut messages { - message.reactions = reaction_map.get(&message.id).cloned().unwrap_or_default(); - } - Ok(messages) - }) { - Ok(messages) => messages, - Err(e) => { - log!("Failed to query messages by id: {}", e); - Vec::new() - } - } -} - -pub fn get_all_messages(storage_owner: i64) -> Vec { - let ids = match db::with_db(|conn| { - let mut stmt = conn.prepare( - "SELECT id FROM messages WHERE storage_owner = ?1 AND deleted_by_external = 0", - )?; - Ok(stmt - .query_map([storage_owner], |row| row.get::<_, i64>(0))? - .collect::, _>>()?) - }) { - Ok(ids) => ids, - Err(e) => { - log!("Failed to query all messages: {}", e); - return Vec::new(); - } - }; - get_messages_by_ids(storage_owner, &ids) -} - #[cfg(test)] mod tests { use super::MessageState; diff --git a/iota-storage/src/util/chats_util.rs b/iota-storage/src/util/chats_util.rs index 634b8f6..eb8f069 100644 --- a/iota-storage/src/util/chats_util.rs +++ b/iota-storage/src/util/chats_util.rs @@ -1,12 +1,10 @@ use crate::users::contact::Contact; use crate::util::db; -use crate::util::sync::{self, EntityType, Operation}; use rusqlite::params; pub fn mod_user(storage_owner: i64, contact: &Contact) { if let Err(e) = db::with_db(|conn| { - let tx = conn.unchecked_transaction()?; - tx.execute( + conn.execute( r#" INSERT INTO contacts (storage_owner, user_id, user_name, last_message_at) VALUES (?1, ?2, ?3, ?4) @@ -21,31 +19,12 @@ pub fn mod_user(storage_owner: i64, contact: &Contact) { contact.last_message_at, ], )?; - sync::record_event( - &tx, - storage_owner, - EntityType::Contact, - contact.user_id, - Operation::Upsert, - )?; - tx.commit()?; Ok(()) }) { eprintln!("Failed to mod_user: {}", e); } } -pub fn get_users_by_ids(storage_owner: i64, ids: &[i64]) -> Vec { - if ids.is_empty() { - return Vec::new(); - } - let wanted: std::collections::HashSet = ids.iter().copied().collect(); - get_users(storage_owner) - .into_iter() - .filter(|contact| wanted.contains(&contact.user_id)) - .collect() -} - pub fn get_user(storage_owner: i64, user_id: i64) -> Option { match db::with_db(|conn| { match conn.query_row( diff --git a/iota-storage/src/util/config_util.rs b/iota-storage/src/util/config_util.rs index 60b1cda..380ba6c 100644 --- a/iota-storage/src/util/config_util.rs +++ b/iota-storage/src/util/config_util.rs @@ -172,57 +172,6 @@ pub fn modify_config(f: impl FnOnce(&mut IotaConfig)) { CONFIG.store(Arc::new(cfg)); save_config(); } - -pub fn modify_config_value(key: &str, value: &str) -> Result<(), &'static str> { - match key { - "iota_id" => { - let parsed: u64 = value.parse().map_err(|_| "invalid iota_id")?; - modify_config(|cfg| cfg.iota_id = Some(parsed)); - Ok(()) - } - "port" => { - let parsed: u16 = value.parse().map_err(|_| "invalid port")?; - modify_config(|cfg| cfg.port = parsed); - Ok(()) - } - "omikron_host" => { - let host = value.to_string(); - modify_config(|cfg| cfg.omikron_host = Some(host)); - Ok(()) - } - "omikron_port" => { - let parsed: u16 = value.parse().map_err(|_| "invalid omikron_port")?; - modify_config(|cfg| cfg.omikron_port = Some(parsed)); - Ok(()) - } - "read_receipts_enabled" => { - let parsed: bool = value.parse().map_err(|_| "invalid boolean")?; - modify_config(|cfg| cfg.read_receipts_enabled = parsed); - Ok(()) - } - "web.mode" => { - let mode = match value { - "disabled" => WebMode::Disabled, - "loopback" => WebMode::Loopback, - "network" => WebMode::Network, - _ => return Err("invalid web.mode; use disabled, loopback, or network"), - }; - modify_config(|cfg| cfg.web.mode = mode); - Ok(()) - } - "web.port" => { - let parsed: u16 = value.parse().map_err(|_| "invalid web.port")?; - modify_config(|cfg| cfg.web.port = parsed); - Ok(()) - } - "web.bind" => { - let bind = value.to_string(); - modify_config(|cfg| cfg.web.bind = bind); - Ok(()) - } - _ => Err("unknown config key"), - } -} static CONFIG_PATH: OnceLock = OnceLock::new(); pub fn configure_config_path(path: PathBuf) { diff --git a/iota-storage/src/util/db.rs b/iota-storage/src/util/db.rs index 1a4ca7e..63debe3 100644 --- a/iota-storage/src/util/db.rs +++ b/iota-storage/src/util/db.rs @@ -220,37 +220,6 @@ fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> { )?; } - if current_version < 6 { - conn.execute_batch( - r#" - CREATE TABLE IF NOT EXISTS sync_heads ( - user_id INTEGER PRIMARY KEY, - version INTEGER NOT NULL - ); - CREATE TABLE IF NOT EXISTS sync_events ( - user_id INTEGER NOT NULL, - version INTEGER NOT NULL, - entity_type TEXT NOT NULL, - entity_id INTEGER NOT NULL, - operation TEXT NOT NULL, - created_at INTEGER NOT NULL, - PRIMARY KEY (user_id, version) - ); - CREATE INDEX IF NOT EXISTS idx_sync_events_user_version - ON sync_events (user_id, version); - CREATE TABLE IF NOT EXISTS client_sync_state ( - user_id INTEGER NOT NULL, - session_id INTEGER NOT NULL, - acknowledged_version INTEGER NOT NULL, - cache_schema_version INTEGER NOT NULL, - updated_at INTEGER NOT NULL, - PRIMARY KEY (user_id, session_id) - ); - PRAGMA user_version = 6; - "#, - )?; - } - Ok(()) } @@ -320,7 +289,7 @@ mod tests { run_migrations_on_connection(&conn)?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 6); + assert_eq!(version, 5); for column in ["height", "reply_to", "edited_count", "deleted_by_external"] { let mut statement = conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?; @@ -329,23 +298,4 @@ mod tests { Ok(()) } - - #[test] - fn migrates_version_five_once_and_is_idempotent() -> Result<(), StorageError> { - let conn = Connection::open_in_memory()?; - conn.execute_batch("PRAGMA user_version = 5;")?; - run_migrations_on_connection(&conn)?; - run_migrations_on_connection(&conn)?; - let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 6); - for table in ["sync_heads", "sync_events", "client_sync_state"] { - let exists: i64 = conn.query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1", - [table], - |row| row.get(0), - )?; - assert_eq!(exists, 1); - } - Ok(()) - } } diff --git a/iota-storage/src/util/mod.rs b/iota-storage/src/util/mod.rs index 7412c7b..2fa8220 100644 --- a/iota-storage/src/util/mod.rs +++ b/iota-storage/src/util/mod.rs @@ -5,4 +5,3 @@ pub mod config_util; pub mod db; pub mod e2ee_storage; pub mod settings; -pub mod sync; diff --git a/iota-storage/src/util/sync.rs b/iota-storage/src/util/sync.rs deleted file mode 100644 index b84c94a..0000000 --- a/iota-storage/src/util/sync.rs +++ /dev/null @@ -1,160 +0,0 @@ -//! Durable per-user state journal used by device cache synchronization. -use crate::storage_error::StorageError; -use crate::util::db; -use rusqlite::{Transaction, params}; -use std::collections::BTreeMap; - -pub const CACHE_SCHEMA_VERSION: i64 = 1; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum EntityType { - Message, - Contact, -} -impl EntityType { - fn as_str(self) -> &'static str { - match self { - Self::Message => "message", - Self::Contact => "contact", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Operation { - Upsert, - Delete, -} -impl Operation { - fn as_str(self) -> &'static str { - match self { - Self::Upsert => "upsert", - Self::Delete => "delete", - } - } -} - -#[derive(Debug, Default, Clone)] -pub struct Delta { - pub message_upserts: Vec, - pub deleted_message_ids: Vec, - pub contact_upserts: Vec, - pub deleted_contact_ids: Vec, -} - -pub fn now_millis() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64 -} - -pub fn record_event( - tx: &Transaction<'_>, - user_id: i64, - entity: EntityType, - entity_id: i64, - operation: Operation, -) -> Result { - tx.execute( - "INSERT INTO sync_heads (user_id, version) VALUES (?1, 0) ON CONFLICT(user_id) DO NOTHING", - [user_id], - )?; - let previous: i64 = tx.query_row( - "SELECT version FROM sync_heads WHERE user_id = ?1", - [user_id], - |r| r.get(0), - )?; - let version = previous - .checked_add(1) - .ok_or_else(|| StorageError::Other("sync version overflow".into()))?; - tx.execute( - "UPDATE sync_heads SET version = ?2 WHERE user_id = ?1", - params![user_id, version], - )?; - tx.execute("INSERT INTO sync_events (user_id, version, entity_type, entity_id, operation, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![user_id, version, entity.as_str(), entity_id, operation.as_str(), now_millis()])?; - Ok(version) -} - -pub fn head(user_id: i64) -> Result { - db::with_db(|conn| { - Ok(conn - .query_row( - "SELECT version FROM sync_heads WHERE user_id = ?1", - [user_id], - |r| r.get(0), - ) - .unwrap_or(0)) - }) -} - -pub fn has_session(user_id: i64, session_id: i64) -> Result { - db::with_db(|conn| { - Ok(conn - .query_row( - "SELECT 1 FROM client_sync_state WHERE user_id = ?1 AND session_id = ?2", - params![user_id, session_id], - |_| Ok(()), - ) - .is_ok()) - }) -} - -pub fn acknowledge( - user_id: i64, - session_id: i64, - version: i64, - cache_schema_version: i64, -) -> Result<(), StorageError> { - if user_id <= 0 || session_id <= 0 || version < 0 { - return Err(StorageError::Other("invalid sync acknowledgement".into())); - } - db::with_db(|conn| { - let head = conn - .query_row( - "SELECT version FROM sync_heads WHERE user_id = ?1", - [user_id], - |r| r.get(0), - ) - .unwrap_or(0); - if version > head { - return Err(StorageError::Other( - "acknowledgement is ahead of head".into(), - )); - } - conn.execute("INSERT INTO client_sync_state (user_id, session_id, acknowledged_version, cache_schema_version, updated_at) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(user_id, session_id) DO UPDATE SET acknowledged_version = MAX(acknowledged_version, excluded.acknowledged_version), cache_schema_version = excluded.cache_schema_version, updated_at = excluded.updated_at", params![user_id, session_id, version, cache_schema_version, now_millis()])?; - Ok(()) - }) -} - -/// Returns the final operation for each entity after `from_version`. -pub fn delta(user_id: i64, from_version: i64, captured_head: i64) -> Result { - if from_version < 0 || from_version > captured_head { - return Err(StorageError::Other("invalid sync cursor".into())); - } - db::with_db(|conn| { - let mut stmt = conn.prepare("SELECT entity_type, entity_id, operation FROM sync_events WHERE user_id = ?1 AND version > ?2 AND version <= ?3 ORDER BY version ASC")?; - let mut final_events = BTreeMap::<(String, i64), String>::new(); - for row in stmt.query_map(params![user_id, from_version, captured_head], |r| { - Ok(( - r.get::<_, String>(0)?, - r.get::<_, i64>(1)?, - r.get::<_, String>(2)?, - )) - })? { - let (kind, id, operation) = row?; - final_events.insert((kind, id), operation); - } - let mut out = Delta::default(); - for ((kind, id), operation) in final_events { - match (kind.as_str(), operation.as_str()) { - ("message", "delete") => out.deleted_message_ids.push(id), - ("message", _) => out.message_upserts.push(id), - ("contact", "delete") => out.deleted_contact_ids.push(id), - ("contact", _) => out.contact_upserts.push(id), - _ => {} - } - } - Ok(out) - }) -} diff --git a/iota-terms/src/consent.rs b/iota-terms/src/consent.rs deleted file mode 100644 index ef968f9..0000000 --- a/iota-terms/src/consent.rs +++ /dev/null @@ -1,89 +0,0 @@ -//! Durable, deployment-scoped consent records. -//! -//! This deliberately contains no UI code. Both the terminal client and the -//! daemon use the same record so a UI-local decision can never start services. - -use crate::{Doc, TermsType as Type}; -use std::fs; -use std::io; -use std::path::Path; -use std::time::{SystemTime, UNIX_EPOCH}; - -const FILE_NAME: &str = "terms-consent-v1"; - -#[derive(Clone, Debug, Default)] -pub struct ConsentRecord { - pub eula: Option<(String, String)>, - pub tos: Option<(String, String)>, - pub privacy: Option<(String, String)>, -} - -impl ConsentRecord { - pub fn has_all_required(&self) -> bool { - self.eula.is_some() && self.tos.is_some() && self.privacy.is_some() - } - - pub fn accepts(&self, eula: &Doc, tos: &Doc, privacy: &Doc) -> bool { - matches_doc(&self.eula, eula) - && matches_doc(&self.tos, tos) - && matches_doc(&self.privacy, privacy) - } - - pub fn accept(&mut self, doc: &Doc) { - let value = Some((doc.get_version(), doc.get_hash())); - match doc.doc_type { - Type::EULA => self.eula = value, - Type::TOS => self.tos = value, - Type::PP => self.privacy = value, - } - } -} - -fn matches_doc(value: &Option<(String, String)>, doc: &Doc) -> bool { - matches!(value, Some((version, hash)) if version == &doc.get_version() && hash == &doc.get_hash()) -} - -pub fn load(state_dir: &Path) -> ConsentRecord { - let Ok(text) = fs::read_to_string(state_dir.join(FILE_NAME)) else { - return ConsentRecord::default(); - }; - let mut record = ConsentRecord::default(); - for line in text.lines() { - let Some((key, value)) = line.split_once('=') else { - continue; - }; - let Some((version, hash)) = value.split_once(':') else { - continue; - }; - let value = Some((version.to_owned(), hash.to_owned())); - match key { - "eula" => record.eula = value, - "tos" => record.tos = value, - "privacy" => record.privacy = value, - _ => {} - } - } - record -} - -pub fn save(state_dir: &Path, record: &ConsentRecord) -> io::Result<()> { - fs::create_dir_all(state_dir)?; - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let mut text = format!("# Iota terms consent record; accepted_at_unix={timestamp}\n"); - for (name, value) in [ - ("eula", &record.eula), - ("tos", &record.tos), - ("privacy", &record.privacy), - ] { - if let Some((version, hash)) = value { - text.push_str(&format!("{name}={version}:{hash}\n")); - } - } - let path = state_dir.join(FILE_NAME); - let temporary = state_dir.join(format!(".{FILE_NAME}.{}.tmp", std::process::id())); - fs::write(&temporary, text)?; - fs::rename(temporary, path) -} diff --git a/iota-terms/src/lib.rs b/iota-terms/src/lib.rs index e878f9b..216997b 100644 --- a/iota-terms/src/lib.rs +++ b/iota-terms/src/lib.rs @@ -1,4 +1,3 @@ -pub mod consent; pub mod terms_getter; pub use terms_getter::Type as TermsType; diff --git a/iota/Cargo.toml b/iota/Cargo.toml index 3c38d65..64de533 100644 --- a/iota/Cargo.toml +++ b/iota/Cargo.toml @@ -10,9 +10,5 @@ iota-installer = { path = "../iota-installer" } iota-core = { path = "../iota-core" } iota-process-manager = { path = "../iota-process-manager" } iota-paths = { path = "../iota-paths" } -iota-terms = { path = "../iota-terms" } tokio = { version = "1.50.0", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } -serde_json = "1" -serde_yaml = "0.9" -clap = { version = "4.5", features = ["derive"] } diff --git a/iota/src/cli_args.rs b/iota/src/cli_args.rs index a90e106..cedefb0 100644 --- a/iota/src/cli_args.rs +++ b/iota/src/cli_args.rs @@ -1,246 +1,15 @@ -use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum, error::ErrorKind}; use iota_cli::theme::ThemeName; -use iota_terms::TermsType; #[derive(Debug)] pub struct CliInvocation { pub theme_override: Option, - pub output: OutputFormat, - pub color: CapabilityPolicy, - pub unicode: CapabilityPolicy, pub command: Command, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub enum CapabilityPolicy { - Auto, - Always, - Never, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub enum OutputFormat { - Text, - Json, - Yaml, -} - -#[derive(Debug, Clone, Copy, ValueEnum)] -enum CliTheme { - Monospace, - Binary, - Ansi, - Surface, -} -impl From for ThemeName { - fn from(value: CliTheme) -> Self { - match value { - CliTheme::Monospace => Self::Monospace, - CliTheme::Binary => Self::Binary, - CliTheme::Ansi => Self::Ansi, - CliTheme::Surface => Self::Surface, - } - } -} - -#[derive(Parser, Debug)] -#[command( - name = "iota", - version, - about = "Iota operator console", - arg_required_else_help = false -)] -struct Cli { - #[arg(long, global = true, value_enum)] - theme: Option, - #[arg(long, global = true, value_enum, default_value_t = OutputFormat::Text)] - output: OutputFormat, - #[arg(long, global = true, value_enum, default_value_t = CapabilityPolicy::Auto)] - color: CapabilityPolicy, - #[arg(long, global = true, value_enum, default_value_t = CapabilityPolicy::Auto)] - unicode: CapabilityPolicy, - #[arg(long, global = true)] - no_color: bool, - #[command(subcommand)] - command: Option, -} - -#[derive(Subcommand, Debug)] -enum CliCommand { - Status, - Tasks, - Users(UsersArgs), - Omikron(OmikronArgs), - Identity(IdentityArgs), - Daemon(DaemonArgs), - Config(ConfigArgs), - Terms(TermsArgs), - RegenerateKeys { - #[arg(long)] - yes: bool, - }, - Components, - Logs { - #[arg(long, default_value_t = 100)] - limit: usize, - }, - Update(UpdateArgs), - Community(CommunityArgs), - Completions { - shell: String, - }, - Man, -} -#[derive(Args, Debug)] -struct UsersArgs { - #[command(subcommand)] - action: UsersAction, -} -#[derive(Subcommand, Debug)] -enum UsersAction { - List, - Show { - user_id: i64, - }, - Add { - username: String, - }, - Remove { - user_id: i64, - #[arg(long)] - yes: bool, - }, - Import { - username: String, - }, -} -#[derive(Args, Debug)] -struct OmikronArgs { - #[command(subcommand)] - action: OmikronAction, -} -#[derive(Subcommand, Debug)] -enum OmikronAction { - Reconnect, - Status, -} -#[derive(Args, Debug)] -struct IdentityArgs { - #[command(subcommand)] - action: IdentityAction, -} -#[derive(Subcommand, Debug)] -enum IdentityAction { - Rotate { - #[arg(long)] - yes: bool, - }, -} -#[derive(Args, Debug)] -struct ConfigArgs { - #[command(subcommand)] - action: ConfigAction, -} -#[derive(Subcommand, Debug)] -enum ConfigAction { - Get, - Set { key: String, value: String }, - Reload, -} -#[derive(Args, Debug)] -struct DaemonArgs { - #[command(subcommand)] - action: DaemonAction, -} -#[derive(Subcommand, Debug)] -enum DaemonAction { - Restart { - #[arg(long)] - yes: bool, - }, - Stop { - #[arg(long)] - yes: bool, - }, - Enable { - #[arg(long, value_parser = ["socket", "always-on"])] - mode: String, - }, - DisableStartup, - Status, - StartupStatus, - Start, - RestartService, - StopService, - Install { - #[arg(long)] - bundle: String, - #[arg(long)] - operator: Option, - }, -} -#[derive(Args, Debug)] -struct UpdateArgs { - #[command(subcommand)] - action: UpdateAction, -} -#[derive(Subcommand, Debug)] -enum UpdateAction { - Check, -} -#[derive(Args, Debug)] -struct CommunityArgs { - #[command(subcommand)] - action: CommunityAction, -} -#[derive(Subcommand, Debug)] -enum CommunityAction { - List, -} -#[derive(Args, Debug)] -struct TermsArgs { - #[command(subcommand)] - action: TermsAction, -} -#[derive(Subcommand, Debug)] -enum TermsAction { - Status { - #[arg(long)] - system: bool, - }, - Show { - document: TermsDocument, - }, - Accept { - #[arg(long)] - system: bool, - }, -} -#[derive(Clone, Copy, Debug, ValueEnum)] -enum TermsDocument { - Eula, - Tos, - Privacy, -} -impl From for TermsType { - fn from(value: TermsDocument) -> Self { - match value { - TermsDocument::Eula => TermsType::EULA, - TermsDocument::Tos => TermsType::TOS, - TermsDocument::Privacy => TermsType::PP, - } - } -} - #[derive(Debug, PartialEq, Eq)] pub enum Command { Dashboard, Help, - Version, - Completions { - shell: String, - }, - ManPage, Install { bundle: String, operator: Option, @@ -248,187 +17,94 @@ pub enum Command { Status, Tasks, UsersList, - UsersShow { - user_id: i64, - }, - UsersAdd { - username: String, - }, - UsersRemove { - user_id: i64, - confirmed: bool, - }, - UsersImport { - username: String, - }, - OmikronReconnect, - IdentityRotate { - confirmed: bool, - }, DaemonRestart { confirmed: bool, }, DaemonStop { confirmed: bool, }, + DaemonStopProcess, DaemonEnable { mode: String, }, DaemonDisableStartup, DaemonDaemonStatus, - DaemonStartupStatus, - DaemonStart, - DaemonRestartService, - DaemonStopService, - ConfigGet, - ConfigSet { - key: String, - value: String, - }, - ConfigReload, - OmikronStatus, - RegenerateKeys { - confirmed: bool, - }, - Components, - Logs { - limit: usize, - }, - UpdateCheck, - CommunityList, - TermsStatus { - system: bool, - }, - TermsShow { - document: TermsType, - }, - TermsAccept { - system: bool, - }, } impl CliInvocation { pub fn parse(args: impl IntoIterator) -> Result { - let args = args.into_iter().collect::>(); - if args.as_slice() == ["help"] { - return Ok(Self::special(Command::Help)); - } - let parsed = - Cli::try_parse_from(std::iter::once("iota".to_owned()).chain(args)).map_err(|error| { - match error.kind() { - ErrorKind::DisplayHelp => return "__help__".to_owned(), - ErrorKind::DisplayVersion => return "__version__".to_owned(), - _ => error.to_string(), - } - }); - let parsed = match parsed { - Ok(parsed) => parsed, - Err(marker) if marker == "__help__" => return Ok(Self::special(Command::Help)), - Err(marker) if marker == "__version__" => return Ok(Self::special(Command::Version)), - Err(error) => return Err(error), - }; - let command = match parsed.command { - None => Command::Dashboard, - Some(CliCommand::Status) => Command::Status, - Some(CliCommand::Tasks) => Command::Tasks, - Some(CliCommand::Components) => Command::Components, - Some(CliCommand::Completions { shell }) => Command::Completions { shell }, - Some(CliCommand::Man) => Command::ManPage, - Some(CliCommand::Users(users)) => match users.action { - UsersAction::List => Command::UsersList, - UsersAction::Show { user_id } => Command::UsersShow { user_id }, - UsersAction::Add { username } => Command::UsersAdd { username }, - UsersAction::Remove { user_id, yes } => Command::UsersRemove { - user_id, - confirmed: yes, - }, - UsersAction::Import { username } => Command::UsersImport { username }, - }, - Some(CliCommand::Omikron(omikron)) => match omikron.action { - OmikronAction::Reconnect => Command::OmikronReconnect, - OmikronAction::Status => Command::OmikronStatus, - }, - Some(CliCommand::Identity(identity)) => match identity.action { - IdentityAction::Rotate { yes } => Command::IdentityRotate { confirmed: yes }, - }, - Some(CliCommand::Config(config)) => match config.action { - ConfigAction::Get => Command::ConfigGet, - ConfigAction::Set { key, value } => Command::ConfigSet { key, value }, - ConfigAction::Reload => Command::ConfigReload, - }, - Some(CliCommand::RegenerateKeys { yes }) => Command::RegenerateKeys { confirmed: yes }, - Some(CliCommand::Logs { limit }) => Command::Logs { limit }, - Some(CliCommand::Update(update)) => match update.action { - UpdateAction::Check => Command::UpdateCheck, - }, - Some(CliCommand::Community(community)) => match community.action { - CommunityAction::List => Command::CommunityList, - }, - Some(CliCommand::Terms(terms)) => match terms.action { - TermsAction::Status { system } => Command::TermsStatus { system }, - TermsAction::Show { document } => Command::TermsShow { - document: document.into(), - }, - TermsAction::Accept { system } => Command::TermsAccept { system }, - }, - Some(CliCommand::Daemon(daemon)) => match daemon.action { - DaemonAction::Restart { yes } => Command::DaemonRestart { confirmed: yes }, - DaemonAction::Stop { yes } => Command::DaemonStop { confirmed: yes }, - DaemonAction::Enable { mode } => Command::DaemonEnable { mode }, - DaemonAction::DisableStartup => Command::DaemonDisableStartup, - DaemonAction::Status => Command::DaemonDaemonStatus, - DaemonAction::StartupStatus => Command::DaemonStartupStatus, - DaemonAction::Start => Command::DaemonStart, - DaemonAction::RestartService => Command::DaemonRestartService, - DaemonAction::StopService => Command::DaemonStopService, - DaemonAction::Install { bundle, operator } => Command::Install { bundle, operator }, - }, - }; - Ok(Self { - theme_override: parsed.theme.map(Into::into), - output: parsed.output, - color: if parsed.no_color { - CapabilityPolicy::Never + let mut theme_override = None; + let mut command = Vec::new(); + let mut args = args.into_iter(); + while let Some(argument) = args.next() { + if argument == "--theme" { + let value = args.next().ok_or_else(|| { + format!( + "--theme requires a value ({})", + ThemeName::supported_names() + ) + })?; + theme_override = Some(value.parse()?); + } else if let Some(value) = argument.strip_prefix("--theme=") { + theme_override = Some(value.parse()?); } else { - parsed.color - }, - unicode: parsed.unicode, - command, - }) - } - - fn special(command: Command) -> Self { - Self { - theme_override: None, - output: OutputFormat::Text, - color: CapabilityPolicy::Auto, - unicode: CapabilityPolicy::Auto, - command, - } - } - - pub fn help_text() -> String { - Cli::command().render_long_help().to_string() - } - - pub fn command_paths() -> Vec { - fn collect(command: &clap::Command, prefix: &str, paths: &mut Vec) { - for subcommand in command.get_subcommands() { - let path = if prefix.is_empty() { - subcommand.get_name().to_owned() - } else { - format!("{prefix} {}", subcommand.get_name()) - }; - if subcommand.get_subcommands().next().is_some() { - collect(subcommand, &path, paths); - } else { - paths.push(path); - } + command.push(argument); } } - let command = Cli::command(); - let mut paths = Vec::new(); - collect(&command, "", &mut paths); - paths + let command = match command.as_slice() { + [] => Command::Dashboard, + [help] if help == "help" || help == "--help" => Command::Help, + [status] if status == "status" => Command::Status, + [tasks] if tasks == "tasks" => Command::Tasks, + [noun, verb] if noun == "users" && verb == "list" => Command::UsersList, + [noun, verb, flag] if noun == "daemon" && verb == "restart" => Command::DaemonRestart { + confirmed: flag == "--yes", + }, + [noun, verb] if noun == "daemon" && verb == "restart" => { + Command::DaemonRestart { confirmed: false } + } + [noun, verb, flag] if noun == "daemon" && verb == "stop" => Command::DaemonStop { + confirmed: flag == "--yes", + }, + [noun, verb] if noun == "daemon" && verb == "stop" => { + Command::DaemonStop { confirmed: false } + } + [noun, verb] if noun == "daemon" && verb == "stop-process" => { + Command::DaemonStopProcess + } + [noun, verb] if noun == "daemon" && verb == "disable-startup" => { + Command::DaemonDisableStartup + } + [noun, verb] if noun == "daemon" && verb == "status" => Command::DaemonDaemonStatus, + [noun, verb, flag, mode] + if noun == "daemon" && verb == "enable" && flag == "--mode" => + { + Command::DaemonEnable { mode: mode.clone() } + } + [noun, verb, bundle_flag, bundle] + if noun == "daemon" && verb == "install" && bundle_flag == "--bundle" => + { + Command::Install { + bundle: bundle.clone(), + operator: None, + } + } + [noun, verb, bundle_flag, bundle, operator_flag, operator] + if noun == "daemon" + && verb == "install" + && bundle_flag == "--bundle" + && operator_flag == "--operator" => + { + Command::Install { + bundle: bundle.clone(), + operator: Some(operator.clone()), + } + } + _ => return Err("Unknown command. Run `iota --help`.".into()), + }; + Ok(Self { + theme_override, + command, + }) } } @@ -450,52 +126,6 @@ mod tests { assert!(error.contains(ThemeName::supported_names())); } - #[test] - fn parses_structured_output_as_a_global_option() { - let invocation = - CliInvocation::parse(["users".into(), "list".into(), "--output=json".into()]).unwrap(); - assert_eq!(invocation.output, OutputFormat::Json); - assert_eq!(invocation.command, Command::UsersList); - } - - #[test] - fn parses_terminal_capability_overrides() { - let invocation = - CliInvocation::parse(["--color=never".into(), "--unicode".into(), "always".into()]) - .unwrap(); - assert_eq!(invocation.color, CapabilityPolicy::Never); - assert_eq!(invocation.unicode, CapabilityPolicy::Always); - assert_eq!(invocation.command, Command::Dashboard); - } - - #[test] - fn no_color_is_a_compatible_alias() { - let invocation = CliInvocation::parse(["--no-color".into()]).unwrap(); - assert_eq!(invocation.color, CapabilityPolicy::Never); - } - - #[test] - fn supports_standard_help_and_version_flags() { - assert_eq!( - CliInvocation::parse(["-h".into()]).unwrap().command, - Command::Help - ); - assert_eq!( - CliInvocation::parse(["--version".into()]).unwrap().command, - Command::Version - ); - } - - #[test] - fn command_schema_drives_help_and_completion_paths() { - let paths = CliInvocation::command_paths(); - assert!(paths.contains(&"users remove".to_owned())); - assert!(paths.contains(&"daemon install".to_owned())); - let help = CliInvocation::help_text(); - assert!(help.contains("users")); - assert!(help.contains("--output")); - } - #[test] fn parses_install_operator_without_raw_slice_matching() { let invocation = CliInvocation::parse([ @@ -521,92 +151,4 @@ mod tests { let invocation = CliInvocation::parse(["daemon".into(), "stop".into()]).unwrap(); assert_eq!(invocation.command, Command::DaemonStop { confirmed: false }); } - - #[test] - fn parses_users_add() { - let invocation = - CliInvocation::parse(["users".into(), "add".into(), "alice".into()]).unwrap(); - assert_eq!( - invocation.command, - Command::UsersAdd { - username: "alice".into() - } - ); - } - - #[test] - fn rejects_malformed_users_add_shape() { - assert!( - CliInvocation::parse([ - "users".into(), - "incorrect".into(), - "add".into(), - "alice".into() - ]) - .is_err() - ); - } - - #[test] - fn rejects_unknown_destructive_option() { - assert!(CliInvocation::parse(["daemon".into(), "stop".into(), "--later".into()]).is_err()); - } - - #[test] - fn parses_users_remove_without_confirmation() { - let invocation = - CliInvocation::parse(["users".into(), "remove".into(), "42".into()]).unwrap(); - assert_eq!( - invocation.command, - Command::UsersRemove { - user_id: 42, - confirmed: false, - } - ); - } - - #[test] - fn parses_users_remove_with_confirmation() { - let invocation = - CliInvocation::parse(["users".into(), "remove".into(), "42".into(), "--yes".into()]) - .unwrap(); - assert_eq!( - invocation.command, - Command::UsersRemove { - user_id: 42, - confirmed: true, - } - ); - } - - #[test] - fn parses_omikron_reconnect() { - let invocation = CliInvocation::parse(["omikron".into(), "reconnect".into()]).unwrap(); - assert_eq!(invocation.command, Command::OmikronReconnect); - } - - #[test] - fn parses_identity_rotate_requires_yes() { - let invocation = CliInvocation::parse(["identity".into(), "rotate".into()]).unwrap(); - assert_eq!( - invocation.command, - Command::IdentityRotate { confirmed: false } - ); - let invocation = - CliInvocation::parse(["identity".into(), "rotate".into(), "--yes".into()]).unwrap(); - assert_eq!( - invocation.command, - Command::IdentityRotate { confirmed: true } - ); - } - - #[test] - fn rejects_daemon_ping_until_protocol_supports_a_ping_contract() { - assert!(CliInvocation::parse(["daemon".into(), "ping".into()]).is_err()); - } - - #[test] - fn rejects_daemon_diagnostics_until_protocol_supports_diagnostics() { - assert!(CliInvocation::parse(["daemon".into(), "diagnostics".into()]).is_err()); - } } diff --git a/iota/src/daemon_setup_flow.rs b/iota/src/daemon_setup_flow.rs index 1953e78..c88f875 100644 --- a/iota/src/daemon_setup_flow.rs +++ b/iota/src/daemon_setup_flow.rs @@ -65,23 +65,19 @@ pub async fn run( endpoints: &DaemonEndpoints, caps: Capabilities, ) -> Result { - // The initial dashboard probe may have raced a daemon that was still - // accepting connections. Re-check both endpoints before offering setup: - // a system-managed daemon normally listens on a different socket from a - // locally launched one. + // Try connecting to an already-running daemon before starting a new one. if let Ok(ipc) = IpcClient::connect(&endpoints.local).await { return Ok(ConnectionContext { ipc }); } - if endpoints.system != endpoints.local { - if let Ok(ipc) = IpcClient::connect(&endpoints.system).await { - return Ok(ConnectionContext { ipc }); - } - } let options = caps.options(); if !options.iter().any(|o| o.enabled) { - return Err(StartupError::Other( - "No running daemon could be reached, and no daemon launch method is available.".into(), - )); + let _ = show( + ui, + options, + "Daemon cannot be started. Correct the reported problem, then Retry, or Exit.", + ) + .await?; + return Err(StartupError::Cancelled); } if UiConfig::load() .map(|c| c.daemon_start_policy == iota_cli::theme::DaemonStartPolicy::WithUi) diff --git a/iota/src/main.rs b/iota/src/main.rs index 1f19d89..0e85d36 100644 --- a/iota/src/main.rs +++ b/iota/src/main.rs @@ -2,7 +2,7 @@ use iota_cli::{ ipc_client::IpcClient, screens::main_screen::MainScreen, theme, ui::start_bootstrap_tui_with_theme, }; -use iota_ipc::{LocalRequest, ResponsePayload, ResponseResult}; +use iota_ipc::{LocalRequest, ResponseResult}; use iota_process_manager::detect; use std::{path::Path, process::ExitCode, sync::Arc}; @@ -10,9 +10,8 @@ mod cli_args; mod daemon_setup_flow; mod local_daemon; mod startup_error; -mod terms; -use cli_args::{CapabilityPolicy, CliInvocation, Command, OutputFormat}; +use cli_args::{CliInvocation, Command}; use startup_error::StartupError; #[tokio::main(flavor = "multi_thread")] @@ -31,13 +30,6 @@ async fn main() -> ExitCode { async fn run() -> Result<(), StartupError> { let invocation = CliInvocation::parse(std::env::args().skip(1)).map_err(StartupError::InvalidCommand)?; - let CliInvocation { - theme_override, - output, - color, - unicode, - command, - } = invocation; let local_endpoint = match iota_paths::IotaPaths::resolve(iota_paths::Scope::User) .map_err(|error| StartupError::Other(format!("Cannot resolve user IPC path: {error}")))? .ipc_endpoint @@ -65,23 +57,11 @@ async fn run() -> Result<(), StartupError> { system: system_endpoint, }; - match command { + match invocation.command { Command::Help => { print_help(); Ok(()) } - Command::Version => { - println!("iota {}", env!("CARGO_PKG_VERSION")); - Ok(()) - } - Command::Completions { shell } => print_completions(&shell), - Command::ManPage => { - print_man_page(); - Ok(()) - } - Command::TermsStatus { system } => terms::run(terms::TermsCommand::Status { system }).await, - Command::TermsShow { document } => terms::run(terms::TermsCommand::Show { document }).await, - Command::TermsAccept { system } => terms::run(terms::TermsCommand::Accept { system }).await, Command::Install { bundle, operator } => { iota_installer::install_linux_bundle_with_operator( Path::new(&bundle), @@ -92,31 +72,24 @@ async fn run() -> Result<(), StartupError> { command => { if matches!( command, - Command::DaemonEnable { .. } - | Command::DaemonDisableStartup - | Command::DaemonStartupStatus - | Command::DaemonStart - | Command::DaemonRestartService - | Command::DaemonStopService + Command::DaemonEnable { .. } | Command::DaemonDisableStartup ) { return run_startup_command(command).await; } if !matches!(command, Command::Dashboard) { - let state_dir = iota_paths::IotaPaths::resolve(iota_paths::Scope::User) - .map_err(|error| { - StartupError::Other(format!("Cannot resolve consent storage: {error}")) - })? - .state_dir; - if !iota_terms::consent::load(&state_dir).has_all_required() { - return Err(StartupError::Consent("Run `iota terms accept` in an interactive terminal to review and accept the required terms.".into())); + match iota_core::consent_state::non_interactive_consent() { + iota_core::consent_state::NonInteractiveConsent::Accepted => {} + iota_core::consent_state::NonInteractiveConsent::RequiresInteractiveAcceptance => { + return Err(StartupError::Consent("Run `iota` in an interactive terminal to review and accept the required terms.".into())); + } } let ipc = tokio::select! { result = connect_available(&endpoints) => result?, _ = tokio::signal::ctrl_c() => return Err(StartupError::Cancelled), }; - return run_command(ipc, command, output).await; + return run_command(ipc, command).await; } - run_dashboard(theme_override, color, unicode, endpoints).await + run_dashboard(invocation.theme_override, endpoints).await } } } @@ -145,42 +118,6 @@ async fn run_startup_command(command: Command) -> Result<(), StartupError> { .disable_startup() .await .map_err(|e| StartupError::Other(e.to_string()))?, - Command::DaemonStartupStatus => { - let status = manager - .iota_startup_status() - .await - .map_err(|e| StartupError::Other(e.to_string()))?; - println!("service active: {}", status.service.active); - println!("service enabled: {}", status.service.enabled); - println!("socket active: {}", status.socket.active); - println!("socket enabled: {}", status.socket.enabled); - println!("detected mode: {:?}", status.detected); - return Ok(()); - } - Command::DaemonStart => { - let status = manager - .process_action(iota_process_manager::ProcessAction::Start) - .await - .map_err(|e| StartupError::Other(e.to_string()))?; - println!("Daemon started. detected mode: {:?}", status.detected); - return Ok(()); - } - Command::DaemonRestartService => { - let status = manager - .process_action(iota_process_manager::ProcessAction::Restart) - .await - .map_err(|e| StartupError::Other(e.to_string()))?; - println!("Daemon restarted. detected mode: {:?}", status.detected); - return Ok(()); - } - Command::DaemonStopService => { - let status = manager - .process_action(iota_process_manager::ProcessAction::Stop) - .await - .map_err(|e| StartupError::Other(e.to_string()))?; - println!("Daemon stopped. detected mode: {:?}", status.detected); - return Ok(()); - } _ => unreachable!(), }; println!("deployment status: {:?}", status.detected); @@ -212,8 +149,6 @@ async fn connect_available( async fn run_dashboard( theme_override: Option, - color_policy: CapabilityPolicy, - unicode_policy: CapabilityPolicy, endpoints: daemon_setup_flow::DaemonEndpoints, ) -> Result<(), StartupError> { use std::io::IsTerminal; @@ -227,54 +162,9 @@ async fn run_dashboard( "TERM=dumb does not support the interactive dashboard".into(), )); } - let stored_terminal = theme::UiConfig::load().unwrap_or_default(); - let color_policy = match color_policy { - CapabilityPolicy::Auto => match stored_terminal.color { - theme::TerminalPolicy::Auto => CapabilityPolicy::Auto, - theme::TerminalPolicy::Always => CapabilityPolicy::Always, - theme::TerminalPolicy::Never => CapabilityPolicy::Never, - }, - policy => policy, - }; - let unicode_policy = match unicode_policy { - CapabilityPolicy::Auto => match stored_terminal.unicode { - theme::TerminalPolicy::Auto => CapabilityPolicy::Auto, - theme::TerminalPolicy::Always => CapabilityPolicy::Always, - theme::TerminalPolicy::Never => CapabilityPolicy::Never, - }, - policy => policy, - }; - let color_enabled = match color_policy { - CapabilityPolicy::Always => true, - CapabilityPolicy::Never => false, - CapabilityPolicy::Auto => { - std::env::var_os("NO_COLOR").is_none() && std::env::var("TERM").as_deref() != Ok("dumb") - } - }; - let unicode_enabled = match unicode_policy { - CapabilityPolicy::Always => true, - CapabilityPolicy::Never => false, - CapabilityPolicy::Auto => std::env::var("LC_ALL") - .or_else(|_| std::env::var("LC_CTYPE")) - .or_else(|_| std::env::var("LANG")) - .map(|locale| { - let locale = locale.to_ascii_lowercase(); - locale.contains("utf-8") || locale.contains("utf8") - }) - .unwrap_or(false), - }; - let truecolor_enabled = std::env::var("COLORTERM") - .map(|value| { - let value = value.to_ascii_lowercase(); - value.contains("truecolor") || value.contains("24bit") - }) - .unwrap_or(false); - let session = start_bootstrap_tui_with_theme(theme::resolve_with_terminal_profile( - theme::UiConfig::resolve_theme(theme_override), - color_enabled, - unicode_enabled, - truecolor_enabled, - )) + let session = start_bootstrap_tui_with_theme(theme::resolve(theme::UiConfig::resolve_theme( + theme_override, + ))) .map_err(|error| StartupError::Terminal(error.to_string()))?; let ui = session.ui(); let result = async { @@ -283,7 +173,6 @@ async fn run_dashboard( if consent != (true, true) { return Err(StartupError::Consent("Cannot continue until the required terms are accepted.".into())); } - persist_dashboard_consent().await?; let initial = tokio::select! { result = connect_available(&endpoints) => result, _ = ui.wait_for_shutdown() => Err(StartupError::Cancelled), @@ -334,20 +223,6 @@ async fn run_dashboard( } } -async fn persist_dashboard_consent() -> Result<(), StartupError> { - let docs = iota_terms::get_current_docs().await.ok_or_else(|| { - StartupError::Consent("Could not verify the current agreements after acceptance.".into()) - })?; - let paths = iota_paths::IotaPaths::resolve(iota_paths::Scope::User) - .map_err(|error| StartupError::Other(format!("Cannot resolve consent storage: {error}")))?; - let mut record = iota_terms::consent::load(&paths.state_dir); - for document in [&docs.0, &docs.1, &docs.2] { - record.accept(document); - } - iota_terms::consent::save(&paths.state_dir, &record) - .map_err(|error| StartupError::Consent(format!("Could not save consent: {error}"))) -} - fn map_process_manager_error(error: iota_process_manager::ProcessManagerError) -> StartupError { use iota_process_manager::ProcessManagerErrorKind::*; match error.kind() { @@ -383,115 +258,32 @@ fn writable_socket_path(path: &Path) -> Result<(), StartupError> { } fn print_help() { - println!("{}", CliInvocation::help_text()); + println!( + "Iota operator console\n\nUsage:\n iota [--theme ] Open the dashboard\n iota daemon install --bundle [--operator USER]\n iota status Print daemon readiness and tasks\n iota tasks Print active tasks\n iota users list List users\n iota daemon restart --yes\n iota daemon stop --yes\n\nRun the dashboard in an interactive terminal to review required terms." + ); } -fn print_completions(shell: &str) -> Result<(), StartupError> { - let command_paths = CliInvocation::command_paths(); - let words = command_paths - .iter() - .flat_map(|command| command.split_whitespace()) - .collect::>() - .into_iter() - .collect::>() - .join(" "); - match shell { - "bash" => println!( - "_iota() {{ local words='{} --help --version --theme --output --color --unicode --yes --mode --bundle --operator'; COMPREPLY=( $(compgen -W \"$words\" -- \"${{COMP_WORDS[COMP_CWORD]}}\") ); }}\ncomplete -F _iota iota", - words - ), - "zsh" => println!( - "#compdef iota\n_arguments '1:command:({})' '*::argument:->args'", - words - ), - "fish" => { - for command in words.split_whitespace() { - println!("complete -c iota -f -a '{command}'"); - } - } - _ => { - return Err(StartupError::InvalidCommand( - "completion shell must be bash, zsh, or fish".into(), - )); - } - } - Ok(()) -} - -fn print_man_page() { - println!(".TH IOTA 1"); - println!(".SH NAME\n iota \\- Iota operator console"); - println!(".SH SYNOPSIS\n.B iota\n[global options] [command]"); - println!(".SH COMMANDS"); - for command in CliInvocation::command_paths() { - println!(".TP\n.B {command}"); - } - println!(".SH GLOBAL OPTIONS"); - println!(".TP\n.B --output text|json|yaml"); - println!(".TP\n.B --color auto|always|never"); - println!(".TP\n.B --unicode auto|always|never"); -} - -async fn run_command( - ipc: Arc, - command: Command, - output: OutputFormat, -) -> Result<(), StartupError> { +async fn run_command(ipc: Arc, command: Command) -> Result<(), StartupError> { let request = match command { Command::Status => LocalRequest::GetStatus, Command::Tasks => LocalRequest::ListTasks, Command::UsersList => LocalRequest::ListUsers, - Command::UsersShow { user_id } => LocalRequest::GetUser { user_id }, - Command::UsersAdd { username } => LocalRequest::CreateUser { username }, - Command::UsersRemove { - user_id, - confirmed: true, - } => LocalRequest::RemoveUser { user_id }, - Command::UsersImport { username } => LocalRequest::ImportUser { username }, - Command::OmikronReconnect => LocalRequest::ReconnectOmikron, - Command::IdentityRotate { confirmed: true } => LocalRequest::RotateIotaIdentity, - Command::RegenerateKeys { confirmed: true } => LocalRequest::RotateIotaIdentity, Command::DaemonRestart { confirmed: true } => LocalRequest::RequestProcessExit { intent: iota_ipc::ExitIntent::Restart, }, Command::DaemonStop { confirmed: true } => LocalRequest::RequestProcessExit { intent: iota_ipc::ExitIntent::Stop, }, + Command::DaemonStopProcess => LocalRequest::RequestProcessExit { + intent: iota_ipc::ExitIntent::Stop, + }, Command::DaemonDaemonStatus => LocalRequest::GetDaemonStatus, - Command::OmikronStatus => LocalRequest::GetOmikronStatus, - Command::ConfigGet => LocalRequest::GetConfig, - Command::ConfigSet { key, value } => LocalRequest::SetConfig { key, value }, - Command::ConfigReload => LocalRequest::ReloadConfig, - Command::Components => LocalRequest::ListComponents, - Command::Logs { limit } => LocalRequest::GetLogs { limit }, - Command::UpdateCheck => LocalRequest::CheckUpdate, - Command::CommunityList => LocalRequest::ListCommunities, - Command::UsersRemove { - confirmed: false, .. - } - | Command::IdentityRotate { confirmed: false } - | Command::RegenerateKeys { confirmed: false } - | Command::DaemonRestart { confirmed: false } - | Command::DaemonStop { confirmed: false } => { + Command::DaemonRestart { confirmed: false } | Command::DaemonStop { confirmed: false } => { return Err(StartupError::InvalidCommand( "Refusing destructive command without --yes.".into(), )); } - Command::Dashboard - | Command::Help - | Command::Version - | Command::Completions { .. } - | Command::ManPage - | Command::Install { .. } - | Command::TermsStatus { .. } - | Command::TermsShow { .. } - | Command::TermsAccept { .. } - | Command::DaemonEnable { .. } - | Command::DaemonDisableStartup - | Command::DaemonStartupStatus - | Command::DaemonStart - | Command::DaemonRestartService - | Command::DaemonStopService => { + _ => { return Err(StartupError::InvalidCommand( "Command cannot be run headlessly.".into(), )); @@ -502,139 +294,12 @@ async fn run_command( .await .map_err(|e| StartupError::Other(e.to_string()))? { - ResponseResult::Ok(payload) => { - if !matches!(output, OutputFormat::Text) { - return render_structured(&payload, output); - } - match payload { - ResponsePayload::Status(status) => { - print!("Phase: {}", status.phase); - if !status.tasks.is_empty() { - print!(", Tasks: {}", status.tasks.join(", ")); - } - if let Some(reason) = status.degraded_reason { - print!(", Degraded: {}", reason); - } - println!(); - } - ResponsePayload::Tasks(tasks) => { - if tasks.is_empty() { - println!("No active tasks."); - } else { - for task in &tasks { - println!("{}", task.name); - } - } - } - ResponsePayload::Users(users) => { - if users.is_empty() { - println!("No users."); - } else { - for user in &users { - println!("{} ({})", user.username, user.user_id); - } - } - } - ResponsePayload::UserCreated { user_id, username } => { - println!("Created user {} ({})", username, user_id); - } - ResponsePayload::UserRemoved { user_id } => { - println!("Removed user {}", user_id); - } - ResponsePayload::Acknowledged { message } => { - println!("{}", message); - } - ResponsePayload::DaemonStatus(status) => { - println!("{}", status.formatted); - } - ResponsePayload::Config(config) => { - println!("{}", config.yaml); - } - ResponsePayload::OmikronStatus(status) => { - println!("Connected: {}", status.connected); - if let Some(id) = status.iota_id { - println!("Iota ID: {}", id); - } - } - ResponsePayload::Components(components) => { - if components.is_empty() { - println!("No component health data available."); - } else { - for comp in &components { - let status_str = match comp.status { - iota_ipc::HealthStatus::Healthy => "healthy", - iota_ipc::HealthStatus::Degraded => "degraded", - iota_ipc::HealthStatus::Failed => "failed", - }; - let suffix = comp - .message - .as_deref() - .map(|m| format!(" ({m})")) - .unwrap_or_default(); - println!("{:?}: {}{}", comp.id, status_str, suffix); - } - } - } - ResponsePayload::UserDetail(user) => { - println!("User: {} ({})", user.username, user.user_id); - if let Some(ref name) = user.display_name { - println!("Display Name: {name}"); - } - println!("Created At: {}", user.created_at); - if !user.trusted_apps.is_empty() { - println!("Trusted Apps: {}", user.trusted_apps.join(", ")); - } - } - ResponsePayload::LogEntries(logs) => { - for entry in &logs.entries { - let ts = entry.timestamp_ms; - let level = if entry.is_error { "ERR" } else { "INF" }; - println!("[{ts}] {level} {}: {}", entry.sender, entry.message); - } - } - ResponsePayload::UpdateStatus(status) => { - if status.available { - println!("Update available."); - } else { - println!("Up to date."); - } - } - ResponsePayload::Communities(communities) => { - if communities.is_empty() { - println!("No communities."); - } else { - for c in &communities { - println!("{} ({})", c.title, c.name); - } - } - } - } + ResponseResult::Ok(message) => { + println!("{message}"); Ok(()) } ResponseResult::Error(code) => Err(StartupError::Other(format!( - "Daemon request failed: {code}" + "Daemon request failed: {code:?}" ))), } } - -/// The IPC payload is the versioned, tagged schema used by headless clients. -/// Text remains an operator-oriented presentation; JSON and YAML must never -/// require consumers to parse it. -fn render_structured(payload: &ResponsePayload, output: OutputFormat) -> Result<(), StartupError> { - match output { - OutputFormat::Json => println!( - "{}", - serde_json::to_string_pretty(payload).map_err(|error| StartupError::Other(format!( - "Cannot encode JSON output: {error}" - )))? - ), - OutputFormat::Yaml => print!( - "{}", - serde_yaml::to_string(payload).map_err(|error| StartupError::Other(format!( - "Cannot encode YAML output: {error}" - )))? - ), - OutputFormat::Text => unreachable!(), - } - Ok(()) -} diff --git a/iota/src/terms.rs b/iota/src/terms.rs deleted file mode 100644 index b7cdbcb..0000000 --- a/iota/src/terms.rs +++ /dev/null @@ -1,118 +0,0 @@ -use crate::startup_error::StartupError; -use iota_terms::{Doc, TermsType, consent, get_current_docs, get_terms}; -use std::io::{self, IsTerminal, Write}; - -pub enum TermsCommand { - Status { system: bool }, - Show { document: TermsType }, - Accept { system: bool }, -} - -fn state_dir(system: bool) -> Result { - let scope = if system { - iota_paths::Scope::System - } else { - iota_paths::Scope::User - }; - iota_paths::IotaPaths::resolve(scope) - .map(|paths| paths.state_dir) - .map_err(|error| StartupError::Other(format!("Cannot resolve consent storage: {error}"))) -} - -pub async fn run(command: TermsCommand) -> Result<(), StartupError> { - match command { - TermsCommand::Status { system } => { - let record = consent::load(&state_dir(system)?); - println!( - "EULA: {}", - if record.eula.is_some() { - "accepted" - } else { - "not accepted" - } - ); - println!( - "Terms of Service: {}", - if record.tos.is_some() { - "accepted" - } else { - "not accepted" - } - ); - println!( - "Privacy Policy: {}", - if record.privacy.is_some() { - "accepted" - } else { - "not accepted" - } - ); - Ok(()) - } - TermsCommand::Show { document } => { - let text = get_terms(document).await.ok_or_else(|| { - StartupError::Consent("Could not fetch the requested terms document.".into()) - })?; - print!("{text}"); - Ok(()) - } - TermsCommand::Accept { system } => accept(system).await, - } -} - -async fn accept(system: bool) -> Result<(), StartupError> { - if !io::stdin().is_terminal() || !io::stdout().is_terminal() { - return Err(StartupError::Consent("`iota terms accept` requires an interactive terminal so the documents can be reviewed.".into())); - } - let documents = get_current_docs().await.ok_or_else(|| { - StartupError::Consent( - "Could not fetch the current agreements from the legal endpoint.".into(), - ) - })?; - let mut record = consent::load(&state_dir(system)?); - for document in [&documents.0, &documents.1, &documents.2] { - let text = get_terms(document.doc_type).await.ok_or_else(|| { - StartupError::Consent(format!( - "Could not fetch {}.", - document.doc_type.to_string() - )) - })?; - println!( - "\n===== {} =====\nVersion: {}\nDocument hash: {}\n", - document.doc_type.to_string(), - document.get_version(), - document.get_hash() - ); - print!("{text}\n"); - if !confirm(document)? { - return Err(StartupError::Consent( - "No terms were accepted. Iota remains inactive.".into(), - )); - } - record.accept(document); - } - consent::save(&state_dir(system)?, &record) - .map_err(|error| StartupError::Consent(format!("Could not save consent: {error}")))?; - println!("Terms accepted. Start Iota again to enable services."); - Ok(()) -} - -fn confirm(document: &Doc) -> Result { - let hash = document.get_hash(); - let prefix = hash.get(..10).unwrap_or(&hash); - let expected = format!( - "ACCEPT {} {} {}", - document.doc_type.to_str().to_ascii_uppercase(), - document.get_version(), - prefix - ); - print!("To accept this exact document, type:\n{expected}\n> "); - io::stdout() - .flush() - .map_err(|error| StartupError::Consent(error.to_string()))?; - let mut response = String::new(); - io::stdin() - .read_line(&mut response) - .map_err(|error| StartupError::Consent(error.to_string()))?; - Ok(response.trim() == expected) -} diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index bbf2bf2..6e745e6 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -889,7 +889,6 @@ impl OmikronConnection { dispatch!(CreateApp, handle_create_app); dispatch!(DeleteApp, handle_delete_app); dispatch!(ClientConnected, handle_client_connected); - dispatch!(ClientStateAck, handle_client_state_ack); dispatch!(MessageState, handle_message_state); dispatch!(MessageSend, handle_message_send); dispatch!(MessageEdit, handle_message_edit); @@ -1167,12 +1166,6 @@ impl OmikronConnection { .await; } - async fn handle_client_state_ack(self: Arc, cv: &CommunicationValue) { - let _ = self - .send_message(&message_handlers::handle_client_state_ack(cv)) - .await; - } - async fn handle_message_state(self: Arc, cv: &CommunicationValue) { message_handlers::handle_message_state(cv); } diff --git a/type-maps.yaml b/type-maps.yaml index 53684cc..ce3d5e6 100644 --- a/type-maps.yaml +++ b/type-maps.yaml @@ -149,9 +149,6 @@ type_maps: MessageReactionRemove: 147 MessageReactionLive: 148 MessageDeleteLive: 150 - ClientStateSync: 151 - ClientStateAck: 152 - StateSubscribe: 153 DataTypes: ErrorType: 32 ErrorProtocol: 33 @@ -270,9 +267,3 @@ type_maps: Reactions: 156 Reaction: 157 ReplyId: 158 - CacheValid: 159 - CacheSchemaVersion: 160 - SyncMode: 161 - MessageId: 162 - DeletedMessageIds: 163 - DeletedContactIds: 164