diff --git a/Cargo.lock b/Cargo.lock index 175c62e..b8631b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2192,6 +2192,7 @@ dependencies = [ "iota-ipc", "iota-paths", "iota-process-manager", + "iota-terms", "serde_json", "serde_yaml", "tokio", @@ -2350,6 +2351,7 @@ dependencies = [ "iota-paths", "iota-state", "iota-storage", + "iota-terms", "iota-util", "omikron-connector", "tokio", diff --git a/README.md b/README.md index 4a278f3..2f85cf6 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,27 @@ 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 b20dca3..da728d4 100644 --- a/client/src/client_connection.rs +++ b/client/src/client_connection.rs @@ -250,6 +250,12 @@ 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 b57833b..1ce27e4 100644 --- a/iota-cli/src/controls/button.rs +++ b/iota-cli/src/controls/button.rs @@ -39,8 +39,15 @@ 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( + if button.focused { + format!("› {}", button.label) + } else { + button.label.to_owned() + }, + style, + )) + .alignment(Alignment::Center), area, ); } diff --git a/iota-cli/src/controls/mod.rs b/iota-cli/src/controls/mod.rs index 7ce1cdd..fae2452 100644 --- a/iota-cli/src/controls/mod.rs +++ b/iota-cli/src/controls/mod.rs @@ -1,8 +1,8 @@ pub mod action; pub mod button; pub mod checkbox_group; -pub mod header; pub mod choice; +pub mod header; pub mod navigation; pub mod panel; pub mod radio_group; diff --git a/iota-cli/src/controls/panel.rs b/iota-cli/src/controls/panel.rs index 6e10e91..7307a03 100644 --- a/iota-cli/src/controls/panel.rs +++ b/iota-cli/src/controls/panel.rs @@ -1,23 +1,60 @@ use crate::theme::{ChromeMode, ResolvedTheme}; -use ratatui::{Frame, layout::Rect, widgets::{Block, Borders, Paragraph}}; +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 { +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 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); + 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) } + 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 index c3036c4..585bf3c 100644 --- a/iota-cli/src/controls/scroll.rs +++ b/iota-cli/src/controls/scroll.rs @@ -1,20 +1,53 @@ -use ratatui::{Frame, layout::Rect, widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState}}; +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 } } } +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 } +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) { + 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); + 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 e314f1d..db861e6 100644 --- a/iota-cli/src/elements/console_card.rs +++ b/iota-cli/src/elements/console_card.rs @@ -174,12 +174,7 @@ 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" | "identity rotate" ) || command .trim_start_matches('/') .trim_start() diff --git a/iota-cli/src/elements/graph_card.rs b/iota-cli/src/elements/graph_card.rs index a336772..f47aab6 100644 --- a/iota-cli/src/elements/graph_card.rs +++ b/iota-cli/src/elements/graph_card.rs @@ -40,9 +40,18 @@ impl GRAPHS { 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(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(), } } @@ -141,17 +150,32 @@ impl Element for GraphCard { }; 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 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, - ) }) + .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 }) .border_style(if self.focused { context.theme.graphs.focused_border @@ -186,17 +210,19 @@ 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 - }, - ); } + 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 + }, + ); + } } } diff --git a/iota-cli/src/elements/log_card.rs b/iota-cli/src/elements/log_card.rs index 3d5187b..a7d72eb 100755 --- a/iota-cli/src/elements/log_card.rs +++ b/iota-cli/src/elements/log_card.rs @@ -322,9 +322,22 @@ impl Element for LogCard { 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) + 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 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 @@ -363,7 +376,11 @@ 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 }; + let prefix = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { + "" + } else { + prefix + }; if !prefix.is_empty() { spans.push(Span::styled( @@ -404,17 +421,19 @@ 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 - }, - ); } + 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 + }, + ); + } } } diff --git a/iota-cli/src/ipc_client.rs b/iota-cli/src/ipc_client.rs index 904947c..3ffa8d1 100644 --- a/iota-cli/src/ipc_client.rs +++ b/iota-cli/src/ipc_client.rs @@ -459,14 +459,22 @@ impl IpcClient { if tasks.is_empty() { "No active tasks.".into() } else { - tasks.iter().map(|t| t.name.as_str()).collect::>().join(", ") + 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") + users + .iter() + .map(|u| format!("{} ({})", u.username, u.user_id)) + .collect::>() + .join("\n") } } ResponsePayload::UserCreated { user_id, username } => { @@ -489,14 +497,18 @@ impl IpcClient { 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") + 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) => { @@ -510,20 +522,31 @@ impl IpcClient { } msg } - ResponsePayload::LogEntries(logs) => { - logs.entries.iter().map(|e| { + 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") - } + }) + .collect::>() + .join("\n"), ResponsePayload::UpdateStatus(status) => { - if status.available { "Update available.".into() } else { "Up to date.".into() } + 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") + communities + .iter() + .map(|c| format!("{} ({})", c.title, c.name)) + .collect::>() + .join("\n") } } } diff --git a/iota-cli/src/screens/daemon_setup.rs b/iota-cli/src/screens/daemon_setup.rs index 627b1f2..b9cae99 100644 --- a/iota-cli/src/screens/daemon_setup.rs +++ b/iota-cli/src/screens/daemon_setup.rs @@ -29,7 +29,13 @@ 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<'_>, + _hits: &mut HitMap, + ) { let popup = crate::layout::fit::centered_rect( area, crate::layout::fit::RequiredSize { @@ -191,7 +197,13 @@ 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<'_>, + _hits: &mut HitMap, + ) { let popup = crate::layout::fit::centered_rect( area, crate::layout::fit::RequiredSize { @@ -267,7 +279,9 @@ impl Screen for DaemonSetupScreen { ); } fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - let UiEvent::Key(event) = event else { return InteractionResult::Unhandled; }; + let UiEvent::Key(event) = event else { + return InteractionResult::Unhandled; + }; 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 8676a76..40abb83 100644 --- a/iota-cli/src/screens/main_screen.rs +++ b/iota-cli/src/screens/main_screen.rs @@ -212,9 +212,7 @@ 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) - { + if x == 1 && (!self.graphs_open || self.layout_width.load(Ordering::Relaxed) < 70) { continue; } if elem_opt.is_some() && !seen.contains(elem_opt) { @@ -511,25 +509,64 @@ impl Screen for MainScreen { 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" }, + 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" }, + 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" }, + 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 c9c4518..8c46d3d 100644 --- a/iota-cli/src/screens/md_viewer.rs +++ b/iota-cli/src/screens/md_viewer.rs @@ -34,7 +34,9 @@ impl Screen for FileViewer { } fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - let UiEvent::Key(event) = event else { return InteractionResult::Unhandled; }; + let UiEvent::Key(event) = event else { + return InteractionResult::Unhandled; + }; 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 index 705560c..1243979 100644 --- a/iota-cli/src/screens/metrics.rs +++ b/iota-cli/src/screens/metrics.rs @@ -63,7 +63,13 @@ impl Screen for MetricsScreen { self } - fn render(&self, frame: &mut Frame, area: Rect, context: &RenderContext<'_>, hits: &mut HitMap) { + fn render( + &self, + frame: &mut Frame, + area: Rect, + context: &RenderContext<'_>, + hits: &mut HitMap, + ) { let block = Block::default() .title(" Metrics ") .borders(Borders::ALL) @@ -80,8 +86,7 @@ impl Screen for MetricsScreen { frame.render_widget( Paragraph::new(format!( "Range: {} ({} samples) Left/Right to change", - RANGES[self.range_index].1, - RANGES[self.range_index].0 + RANGES[self.range_index].1, RANGES[self.range_index].0 )) .style(context.theme.text.heading), rows[0], diff --git a/iota-cli/src/screens/overview.rs b/iota-cli/src/screens/overview.rs index 0c6feda..4d4aea6 100644 --- a/iota-cli/src/screens/overview.rs +++ b/iota-cli/src/screens/overview.rs @@ -53,17 +53,11 @@ impl OverviewScreen { let mut lines = Vec::new(); - lines.push(Line::from(Span::styled( - "Connection", - theme.text.heading, - ))); + 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(Span::styled("Daemon", theme.text.heading))); lines.push(Line::from(format!( " Version: {}", version_or_unknown(&daemon.version) @@ -113,10 +107,7 @@ impl OverviewScreen { if !daemon.components.is_empty() { lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - "Components", - theme.text.heading, - ))); + 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", @@ -187,7 +178,13 @@ impl Screen for OverviewScreen { .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 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), @@ -273,10 +270,22 @@ impl Screen for OverviewScreen { } 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" }, + 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/settings.rs b/iota-cli/src/screens/settings.rs index 474dfae..dd26878 100644 --- a/iota-cli/src/screens/settings.rs +++ b/iota-cli/src/screens/settings.rs @@ -49,8 +49,12 @@ impl SettingsScreen { 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(), + 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, @@ -64,14 +68,16 @@ impl SettingsScreen { fn apply(&self, persist: bool) -> InteractionResult { let theme = self.selected_theme(); InteractionResult::AppTask { - task: Box::pin(async move { - UiEvent::App(AppEvent::ApplyTheme { theme, persist }) - }), + 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 } + match policy { + TerminalPolicy::Auto => TerminalPolicy::Always, + TerminalPolicy::Always => TerminalPolicy::Never, + TerminalPolicy::Never => TerminalPolicy::Auto, + } } fn next_focus(&mut self) { @@ -100,9 +106,7 @@ impl SettingsScreen { self.pending = true; self.message = "Regenerating keys…".into(); return InteractionResult::AppTask { - task: Box::pin(async { - UiEvent::App(AppEvent::RegenerateKeysRequested) - }), + task: Box::pin(async { UiEvent::App(AppEvent::RegenerateKeysRequested) }), }; } } @@ -113,7 +117,15 @@ impl SettingsScreen { 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 }) }) } + InteractionResult::AppTask { + task: Box::pin(async move { + UiEvent::App(AppEvent::SaveSettings { + theme, + color, + unicode, + }) + }), + } } Focus::RegenerateKeys => { self.dialog = Some(Dialog::ConfirmRegenerateKeys); @@ -146,8 +158,7 @@ impl Screen for SettingsScreen { .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); + let rows = Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).split(inner); frame.render_widget( Paragraph::new(format!( "Theme: < {} >{}\nColor: {:?} (C) Unicode: {:?} (U)", @@ -156,7 +167,9 @@ impl Screen for SettingsScreen { " [saved]" } else { " [preview]" - }, self.color, self.unicode + }, + self.color, + self.unicode )) .style(context.theme.text.heading), rows[0], @@ -331,8 +344,14 @@ impl Screen for SettingsScreen { 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::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 } @@ -378,8 +397,14 @@ impl Screen for SettingsScreen { keys: "Enter", action: "Save/Activate", }, - KeyHint { keys: "Tab", action: "Move focus" }, - KeyHint { keys: "C/U", action: "Color/Unicode" }, + 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 99e28e3..628134f 100644 --- a/iota-cli/src/screens/terms_checker.rs +++ b/iota-cli/src/screens/terms_checker.rs @@ -2,7 +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::{HitMap, Screen, UiEvent}, + }, util::{buttons::draw_buttons, terms_focus::Focus}, }; use crossterm::event::KeyCode; @@ -271,7 +274,9 @@ impl Screen for TermsCheckerScreen { } fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - let UiEvent::Key(event) = event else { return InteractionResult::Unhandled; }; + let UiEvent::Key(event) = event else { + return InteractionResult::Unhandled; + }; 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 c298e9a..2dc502e 100644 --- a/iota-cli/src/screens/terms_updater.rs +++ b/iota-cli/src/screens/terms_updater.rs @@ -3,7 +3,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::{HitMap, Screen, UiEvent}, + }, util::{buttons::draw_buttons, terms_focus::Focus}, }; use chrono::{Local, TimeZone, Utc}; @@ -594,7 +597,9 @@ impl Screen for TermsUpdaterScreen { } fn handle_event(&mut self, event: UiEvent) -> InteractionResult { - let UiEvent::Key(event) = event else { return InteractionResult::Unhandled; }; + let UiEvent::Key(event) = event else { + return InteractionResult::Unhandled; + }; 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 index 14f1662..ac271a8 100644 --- a/iota-cli/src/screens/users.rs +++ b/iota-cli/src/screens/users.rs @@ -89,7 +89,12 @@ impl UsersScreen { let title = if self.filter.is_empty() { format!("Users ({})", self.users.len()) } else { - format!("Users ({}/{}) filter: {}", visible_indices.len(), self.users.len(), self.filter) + 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( @@ -124,14 +129,18 @@ impl UsersScreen { } let mut lines = Vec::new(); - self.viewport_height.store(inner.height as usize, Ordering::Relaxed); + 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)) + ( + *user_index, + format!("{:>6} {}", user.user_id, user.username), + ) }) .collect(); for (user_index, label) in &labels { @@ -291,7 +300,10 @@ impl UsersScreen { fn keep_focused_user_visible(&mut self) { let indices = self.filtered_indices(); - let Some(position) = indices.iter().position(|index| *index == self.focused_index) else { + let Some(position) = indices + .iter() + .position(|index| *index == self.focused_index) + else { self.scroll_offset = 0; return; }; diff --git a/iota-cli/src/theme/model.rs b/iota-cli/src/theme/model.rs index e107f39..5d4955e 100644 --- a/iota-cli/src/theme/model.rs +++ b/iota-cli/src/theme/model.rs @@ -25,11 +25,20 @@ pub struct BorderStyles { } #[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, + 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 } +pub enum ChromeMode { + Bordered, + Surfaces, +} #[derive(Clone, Debug)] pub struct ChoiceItemStyle { pub marker: Style, diff --git a/iota-cli/src/theme/presets.rs b/iota-cli/src/theme/presets.rs index 33b8ff0..301339a 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, ConsoleStyles, CursorPresentation, - ChromeMode, GraphStyles, LogStyles, MarkdownStyles, MarkerSet, ResolvedTheme, StatusStyles, SurfaceStyles, TextStyles, - ThemeName, + BorderStyles, ButtonStyles, ChoiceItemStyle, ChoiceStyles, ChromeMode, ConsoleStyles, + CursorPresentation, GraphStyles, LogStyles, MarkdownStyles, MarkerSet, ResolvedTheme, + StatusStyles, SurfaceStyles, TextStyles, ThemeName, }; use ratatui::style::{Color, Modifier, Style}; @@ -46,7 +46,16 @@ 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() }, + 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, @@ -300,11 +309,14 @@ pub fn resolve(name: ThemeName) -> ResolvedTheme { 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), + 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)), + 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 25f7405..90d0b0b 100644 --- a/iota-cli/src/ui.rs +++ b/iota-cli/src/ui.rs @@ -328,7 +328,12 @@ impl UI { } return; } - if let UiEvent::App(AppEvent::SaveSettings { theme, color, unicode }) = &event { + 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; @@ -337,7 +342,9 @@ impl UI { 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))); + let _ = self + .app_event_tx + .send(UiEvent::App(AppEvent::ThemeSaved(result))); return; } if matches!(&event, UiEvent::App(AppEvent::RegenerateKeysRequested)) { @@ -386,12 +393,14 @@ impl UI { 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]); + action = Some( + [ + AppAction::OpenOverview, + AppAction::OpenUsers, + AppAction::OpenSettings, + AppAction::Quit, + ][index], + ); *focus = None; } KeyCode::Esc => *focus = None, @@ -516,7 +525,8 @@ impl UI { AppAction::OpenUsers => self.open_users().await, AppAction::OpenSettings => { let current = self.theme_name().await; - self.set_screen(Box::new(SettingsScreen::new(current))).await; + self.set_screen(Box::new(SettingsScreen::new(current))) + .await; } AppAction::OpenMetrics => { if let Some(screen) = MetricsScreen::new(self.clone()).await { @@ -552,7 +562,9 @@ impl UI { }) .collect()) } - Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot load users: {error}")), + 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}")), }; @@ -611,8 +623,13 @@ impl UI { .join(" ") }; f.render_widget( - ratatui::widgets::Paragraph::new(format!(" {hints}")) - .style(context.theme.surfaces.footer.patch(context.theme.text.muted)), + 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); diff --git a/iota-cli/tests/settings_snapshot.rs b/iota-cli/tests/settings_snapshot.rs index 0161e2d..470e514 100644 --- a/iota-cli/tests/settings_snapshot.rs +++ b/iota-cli/tests/settings_snapshot.rs @@ -1,3 +1,4 @@ +use crossterm::event::{KeyCode, KeyEvent}; use iota_cli::{ interaction_result::InteractionResult, render_context::RenderContext, @@ -8,7 +9,6 @@ use iota_cli::{ theme::{ThemeName, resolve}, }; use ratatui::{Terminal, backend::TestBackend}; -use crossterm::event::{KeyCode, KeyEvent}; fn buffer_text(terminal: &Terminal) -> String { terminal diff --git a/iota-connection/src/message_handlers.rs b/iota-connection/src/message_handlers.rs index b3c08a9..079c3c5 100644 --- a/iota-connection/src/message_handlers.rs +++ b/iota-connection/src/message_handlers.rs @@ -108,6 +108,10 @@ 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), @@ -261,56 +265,160 @@ pub fn handle_delete_app(cv: &CommunicationValue) -> CommunicationValue { .with_receiver(sender_id as u64) } -pub fn handle_client_connected(cv: &CommunicationValue) -> CommunicationValue { - let user_id = cv.get_data(DataType::UserId).as_number().unwrap_or(0) as i64; - - 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)); +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) +} - CommunicationValue::new(CommunicationType::ClientConnected) +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()) - .add_typed_default(DataType::Contacts, DataValue::Array(contacts_array)) + .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())) +} + +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); + } + success_response(cv) + .add_typed_default( + DataType::SessionId, + DataValue::SignedNumber(session_id as i128), + ) + .add_typed_default( + DataType::VersionNumber, + DataValue::SignedNumber(version as i128), + ) } pub fn handle_message_state(cv: &CommunicationValue) { diff --git a/iota-daemon-lib/src/command_router.rs b/iota-daemon-lib/src/command_router.rs index f5d4d6d..ebc3e0e 100644 --- a/iota-daemon-lib/src/command_router.rs +++ b/iota-daemon-lib/src/command_router.rs @@ -1,8 +1,8 @@ -use crate::{DaemonRuntime, DaemonServices}; use crate::log_buffer::LogBuffer; +use crate::{DaemonRuntime, DaemonServices}; use iota_ipc::{ - ComponentStatusResponse, CommunitySummary, ConfigResponse, ExitIntent, IpcErrorCode, - LogEntriesResponse, LocalRequest, OmikronStatusResponse, ResponseEnvelope, ResponsePayload, + CommunitySummary, ComponentStatusResponse, ConfigResponse, ExitIntent, IpcErrorCode, + LocalRequest, LogEntriesResponse, OmikronStatusResponse, ResponseEnvelope, ResponsePayload, ResponseResult, StatusResponse, TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary, }; @@ -23,8 +23,16 @@ pub struct CommandRouter { } 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, + log_buffer: Arc>, + ) -> Self { + Self { + runtime, + services, + log_buffer, + } } pub async fn route(&self, request_id: u64, request: LocalRequest) -> ResponseEnvelope { @@ -34,6 +42,14 @@ impl CommandRouter { } async fn execute(&self, request: LocalRequest) -> ResponseResult { + if !self.services.active + && !matches!( + request, + LocalRequest::GetStatus | LocalRequest::GetDaemonStatus + ) + { + return ResponseResult::Error(IpcErrorCode::Unauthorized); + } let needs_omikron = matches!( request, LocalRequest::CreateUser { .. } @@ -96,12 +112,10 @@ impl CommandRouter { ) .await { - (Some(user), _) => { - ResponseResult::Ok(ResponsePayload::UserCreated { - user_id: user.user_id, - username: user.username, - }) - } + (Some(user), _) => ResponseResult::Ok(ResponsePayload::UserCreated { + user_id: user.user_id, + username: user.username, + }), _ => ResponseResult::Error(IpcErrorCode::StorageFailure), } } @@ -154,13 +168,11 @@ impl CommandRouter { message: "process exit accepted".into(), }) } - LocalRequest::GetDaemonStatus => { - ResponseResult::Ok(ResponsePayload::DaemonStatus( - iota_ipc::DaemonStatusResponse { - formatted: 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 { @@ -195,12 +207,10 @@ impl CommandRouter { 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, - }, - )) + ResponseResult::Ok(ResponsePayload::OmikronStatus(OmikronStatusResponse { + connected, + iota_id, + })) } LocalRequest::ListComponents => { let snapshot = self.runtime.snapshot(); @@ -215,20 +225,16 @@ impl CommandRouter { .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::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 { @@ -245,17 +251,22 @@ impl CommandRouter { }; 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::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 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 { diff --git a/iota-daemon-lib/src/ipc_server.rs b/iota-daemon-lib/src/ipc_server.rs index d138107..97b5dd6 100644 --- a/iota-daemon-lib/src/ipc_server.rs +++ b/iota-daemon-lib/src/ipc_server.rs @@ -1,5 +1,5 @@ -use crate::log_buffer::LogBuffer; 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, @@ -136,8 +136,16 @@ impl IpcServer { 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, + log_buffer, + state_rx, + instance_id, + ) + .await { eprintln!("IPC client error: {error}"); } diff --git a/iota-daemon-lib/src/log_broadcaster.rs b/iota-daemon-lib/src/log_broadcaster.rs index b00eb9d..9bb1d05 100644 --- a/iota-daemon-lib/src/log_broadcaster.rs +++ b/iota-daemon-lib/src/log_broadcaster.rs @@ -6,10 +6,7 @@ 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, buffer: Arc>) { let Some(mut logs) = subscribe() else { return; }; diff --git a/iota-daemon-lib/src/services.rs b/iota-daemon-lib/src/services.rs index a04002c..bd2ac1c 100644 --- a/iota-daemon-lib/src/services.rs +++ b/iota-daemon-lib/src/services.rs @@ -1,5 +1,8 @@ -use omikron_connector::{OmikronClient, OmikronConnection}; +use async_trait::async_trait; +use mtp::codec::CommunicationValue; +use omikron_connector::{OmikronClient, OmikronConnection, OmikronError}; use std::sync::Arc; +use std::time::Duration; #[derive(Default)] pub struct UserService; @@ -10,6 +13,7 @@ pub struct DaemonServices { pub omikron: Arc, pub users: Arc, pub config: Arc, + pub active: bool, } impl DaemonServices { @@ -18,6 +22,46 @@ 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 2a67cf1..3f80199 100644 --- a/iota-daemon-lib/tests/command_router.rs +++ b/iota-daemon-lib/tests/command_router.rs @@ -1,6 +1,6 @@ use async_trait::async_trait; -use iota_daemon_lib::{CommandRouter, DaemonRuntime, DaemonServices}; 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}; @@ -44,7 +44,11 @@ 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, + Arc::new(Mutex::new(LogBuffer::new(100))), + ); assert!(matches!( router.route(1, LocalRequest::ReconnectOmikron).await.result, ResponseResult::Ok(_) diff --git a/iota-daemon/Cargo.toml b/iota-daemon/Cargo.toml index 074f91b..29de9e1 100644 --- a/iota-daemon/Cargo.toml +++ b/iota-daemon/Cargo.toml @@ -11,6 +11,7 @@ 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 0204f7d..d971f91 100644 --- a/iota-daemon/src/main.rs +++ b/iota-daemon/src/main.rs @@ -22,6 +22,61 @@ 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; diff --git a/iota-ipc/src/lib.rs b/iota-ipc/src/lib.rs index ed5c3c1..0ff6284 100644 --- a/iota-ipc/src/lib.rs +++ b/iota-ipc/src/lib.rs @@ -3,12 +3,12 @@ pub mod text_commands; pub mod transport; pub use protocol::{ - ClientMessage, ComponentHealth, ComponentId, ComponentStatusResponse, ConfigResponse, - ConnectionStatus, DaemonMessage, DaemonStatusResponse, DeploymentMode, ExitIntent, - HealthStatus, HelloAck, IpcErrorCode, LifecycleEvent, LifecyclePhase, LocalRequest, LogEntry, - LogEntriesResponse, MetricSample, OmikronStatusResponse, RequestEnvelope, ResponseEnvelope, - ResponsePayload, ResponseResult, StartupPhase, StateSnapshot, StatusResponse, SupervisorKind, - TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary, CommunitySummary, + ClientMessage, CommunitySummary, ComponentHealth, ComponentId, ComponentStatusResponse, + ConfigResponse, ConnectionStatus, DaemonMessage, DaemonStatusResponse, DeploymentMode, + ExitIntent, HealthStatus, HelloAck, IpcErrorCode, LifecycleEvent, LifecyclePhase, LocalRequest, + LogEntriesResponse, LogEntry, MetricSample, OmikronStatusResponse, RequestEnvelope, + ResponseEnvelope, ResponsePayload, ResponseResult, StartupPhase, StateSnapshot, StatusResponse, + SupervisorKind, TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary, }; pub use transport::{read_msg, write_msg}; diff --git a/iota-ipc/src/protocol.rs b/iota-ipc/src/protocol.rs index 10b1d27..d31bee4 100644 --- a/iota-ipc/src/protocol.rs +++ b/iota-ipc/src/protocol.rs @@ -136,16 +136,9 @@ pub enum ResponsePayload { Status(StatusResponse), Tasks(Vec), Users(Vec), - UserCreated { - user_id: i64, - username: String, - }, - UserRemoved { - user_id: i64, - }, - Acknowledged { - message: String, - }, + UserCreated { user_id: i64, username: String }, + UserRemoved { user_id: i64 }, + Acknowledged { message: String }, DaemonStatus(DaemonStatusResponse), Config(ConfigResponse), OmikronStatus(OmikronStatusResponse), @@ -264,8 +257,15 @@ mod error_tests { #[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")); + assert_eq!( + IpcErrorCode::NotReady.to_string(), + "the daemon is not ready yet" + ); + assert!( + !IpcErrorCode::InternalFailure + .to_string() + .contains("InternalFailure") + ); } } diff --git a/iota-ipc/src/text_commands.rs b/iota-ipc/src/text_commands.rs index 1e824e3..f8ee611 100644 --- a/iota-ipc/src/text_commands.rs +++ b/iota-ipc/src/text_commands.rs @@ -37,7 +37,9 @@ pub fn validation_error(line: &str) -> Option { if normalized == "help" || parse(normalized).is_some() { None } else { - Some(format!("Unknown command `{normalized}`. Use /help or Tab completion.")) + Some(format!( + "Unknown command `{normalized}`. Use /help or Tab completion." + )) } } @@ -121,10 +123,7 @@ mod tests { #[test] fn accepts_the_headless_cli_user_vocabulary() { - assert!(matches!( - parse("users list"), - Some(LocalRequest::ListUsers) - )); + assert!(matches!(parse("users list"), Some(LocalRequest::ListUsers))); assert!(matches!( parse("users add alice"), Some(LocalRequest::CreateUser { .. }) @@ -203,10 +202,7 @@ mod tests { #[test] fn parses_config_get() { - assert!(matches!( - parse("config get"), - Some(LocalRequest::GetConfig) - )); + assert!(matches!(parse("config get"), Some(LocalRequest::GetConfig))); } #[test] @@ -319,6 +315,10 @@ mod tests { 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")); + 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 b09d750..da05aad 100644 --- a/iota-storage/src/util/chat_files.rs +++ b/iota-storage/src/util/chat_files.rs @@ -1,5 +1,6 @@ use crate::storage_error::StorageError; use crate::util::db; +use crate::util::sync::{self, EntityType, Operation}; use iota_logger::log; use rusqlite::params; @@ -46,6 +47,7 @@ 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, @@ -150,7 +152,8 @@ fn update_message_content( .unwrap() .as_millis() as i64; - conn.execute( + let tx = conn.unchecked_transaction()?; + tx.execute( r#" INSERT INTO message_edits (message_id, content_before, content_after, edited_at, edited_by) VALUES (?1, ?2, ?3, ?4, ?5) @@ -158,7 +161,7 @@ fn update_message_content( params![msg_id, old_content, new_content, now, editor_id], )?; - conn.execute( + tx.execute( r#" UPDATE messages SET content = ?1, edited_count = edited_count + 1 @@ -166,6 +169,14 @@ fn update_message_content( "#, params![new_content, msg_id], )?; + sync::record_event( + &tx, + storage_owner, + EntityType::Message, + msg_id, + Operation::Upsert, + )?; + tx.commit()?; Ok(()) }) @@ -187,15 +198,24 @@ pub fn hard_delete_message( |row| row.get(0), )?; - conn.execute( + let tx = conn.unchecked_transaction()?; + tx.execute( "DELETE FROM message_edits WHERE message_id = ?1", params![msg_id], )?; - conn.execute( + tx.execute( "DELETE FROM reactions WHERE message_id = ?1", params![msg_id], )?; - conn.execute("DELETE FROM messages WHERE 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()?; Ok(()) }) } @@ -261,7 +281,8 @@ pub fn flag_deleted_by_external( message_time: i64, ) -> Result<(), StorageError> { db::with_db(|conn| { - let affected = conn.execute( + let tx = conn.unchecked_transaction()?; + let affected = tx.execute( r#" UPDATE messages SET deleted_by_external = 1 @@ -272,6 +293,15 @@ 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(()) }) } @@ -297,10 +327,19 @@ pub fn delete_edit_history( |row| row.get(0), )?; - conn.execute( + let tx = conn.unchecked_transaction()?; + tx.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(()) }) } @@ -328,13 +367,22 @@ pub fn add_reaction( .unwrap() .as_millis() as i64; - conn.execute( + let tx = conn.unchecked_transaction()?; + tx.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(()) }) } @@ -357,10 +405,19 @@ pub fn remove_reaction( |row| row.get(0), )?; - conn.execute( + let tx = conn.unchecked_transaction()?; + tx.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(()) }) } @@ -383,7 +440,8 @@ pub fn add_message( }; if let Err(e) = db::with_db(|conn| { - conn.execute( + let tx = conn.unchecked_transaction()?; + tx.execute( r#" INSERT INTO messages ( storage_owner, external_user, message_time, content, @@ -405,6 +463,15 @@ 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); @@ -447,7 +514,8 @@ pub fn change_message_state( .as_str() .to_string(); - conn.execute( + let tx = conn.unchecked_transaction()?; + tx.execute( r#" UPDATE messages SET message_state = ?1 @@ -459,6 +527,13 @@ 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())) @@ -532,6 +607,7 @@ 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, @@ -568,6 +644,69 @@ 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 eb8f069..634b8f6 100644 --- a/iota-storage/src/util/chats_util.rs +++ b/iota-storage/src/util/chats_util.rs @@ -1,10 +1,12 @@ 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| { - conn.execute( + let tx = conn.unchecked_transaction()?; + tx.execute( r#" INSERT INTO contacts (storage_owner, user_id, user_name, last_message_at) VALUES (?1, ?2, ?3, ?4) @@ -19,12 +21,31 @@ 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/db.rs b/iota-storage/src/util/db.rs index 63debe3..1a4ca7e 100644 --- a/iota-storage/src/util/db.rs +++ b/iota-storage/src/util/db.rs @@ -220,6 +220,37 @@ 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(()) } @@ -289,7 +320,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, 5); + assert_eq!(version, 6); 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")?; @@ -298,4 +329,23 @@ 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 2fa8220..7412c7b 100644 --- a/iota-storage/src/util/mod.rs +++ b/iota-storage/src/util/mod.rs @@ -5,3 +5,4 @@ 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 new file mode 100644 index 0000000..b84c94a --- /dev/null +++ b/iota-storage/src/util/sync.rs @@ -0,0 +1,160 @@ +//! 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 new file mode 100644 index 0000000..ef968f9 --- /dev/null +++ b/iota-terms/src/consent.rs @@ -0,0 +1,89 @@ +//! 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 216997b..e878f9b 100644 --- a/iota-terms/src/lib.rs +++ b/iota-terms/src/lib.rs @@ -1,3 +1,4 @@ +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 cc4494b..3c38d65 100644 --- a/iota/Cargo.toml +++ b/iota/Cargo.toml @@ -10,6 +10,7 @@ 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" diff --git a/iota/src/cli_args.rs b/iota/src/cli_args.rs index 005d9fb..a90e106 100644 --- a/iota/src/cli_args.rs +++ b/iota/src/cli_args.rs @@ -1,5 +1,6 @@ use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum, error::ErrorKind}; use iota_cli::theme::ThemeName; +use iota_terms::TermsType; #[derive(Debug)] pub struct CliInvocation { @@ -25,61 +26,220 @@ pub enum OutputFormat { } #[derive(Debug, Clone, Copy, ValueEnum)] -enum CliTheme { Monospace, Binary, Ansi, Surface } +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 } + 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)] +#[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, + #[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), - RegenerateKeys { #[arg(long)] yes: bool }, + 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 }, + Logs { + #[arg(long, default_value_t = 100)] + limit: usize, + }, Update(UpdateArgs), Community(CommunityArgs), - Completions { shell: String }, + 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 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(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(Debug, PartialEq, Eq)] pub enum Command { Dashboard, Help, Version, - Completions { shell: String }, + Completions { + shell: String, + }, ManPage, Install { bundle: String, @@ -88,7 +248,9 @@ pub enum Command { Status, Tasks, UsersList, - UsersShow { user_id: i64 }, + UsersShow { + user_id: i64, + }, UsersAdd { username: String, }, @@ -96,7 +258,9 @@ pub enum Command { user_id: i64, confirmed: bool, }, - UsersImport { username: String }, + UsersImport { + username: String, + }, OmikronReconnect, IdentityRotate { confirmed: bool, @@ -117,16 +281,30 @@ pub enum Command { DaemonRestartService, DaemonStopService, ConfigGet, - ConfigSet { key: String, value: String }, + ConfigSet { + key: String, + value: String, + }, ConfigReload, OmikronStatus, RegenerateKeys { confirmed: bool, }, Components, - Logs { limit: usize }, + Logs { + limit: usize, + }, UpdateCheck, CommunityList, + TermsStatus { + system: bool, + }, + TermsShow { + document: TermsType, + }, + TermsAccept { + system: bool, + }, } impl CliInvocation { pub fn parse(args: impl IntoIterator) -> Result { @@ -134,13 +312,14 @@ impl CliInvocation { 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 = + 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)), @@ -154,33 +333,77 @@ impl CliInvocation { 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::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::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::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 } else { parsed.color }, + color: if parsed.no_color { + CapabilityPolicy::Never + } 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 } + Self { + theme_override: None, + output: OutputFormat::Text, + color: CapabilityPolicy::Auto, + unicode: CapabilityPolicy::Auto, + command, + } } pub fn help_text() -> String { @@ -237,12 +460,9 @@ mod tests { #[test] fn parses_terminal_capability_overrides() { - let invocation = CliInvocation::parse([ - "--color=never".into(), - "--unicode".into(), - "always".into(), - ]) - .unwrap(); + 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); diff --git a/iota/src/daemon_setup_flow.rs b/iota/src/daemon_setup_flow.rs index c88f875..1953e78 100644 --- a/iota/src/daemon_setup_flow.rs +++ b/iota/src/daemon_setup_flow.rs @@ -65,19 +65,23 @@ pub async fn run( endpoints: &DaemonEndpoints, caps: Capabilities, ) -> Result { - // Try connecting to an already-running daemon before starting a new one. + // 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. 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) { - let _ = show( - ui, - options, - "Daemon cannot be started. Correct the reported problem, then Retry, or Exit.", - ) - .await?; - return Err(StartupError::Cancelled); + return Err(StartupError::Other( + "No running daemon could be reached, and no daemon launch method is available.".into(), + )); } 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 0df1c0f..1f19d89 100644 --- a/iota/src/main.rs +++ b/iota/src/main.rs @@ -10,6 +10,7 @@ mod cli_args; mod daemon_setup_flow; mod local_daemon; mod startup_error; +mod terms; use cli_args::{CapabilityPolicy, CliInvocation, Command, OutputFormat}; use startup_error::StartupError; @@ -78,6 +79,9 @@ async fn run() -> Result<(), StartupError> { 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), @@ -98,11 +102,13 @@ async fn run() -> Result<(), StartupError> { return run_startup_command(command).await; } if !matches!(command, Command::Dashboard) { - 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 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())); } let ipc = tokio::select! { result = connect_available(&endpoints) => result?, @@ -242,8 +248,7 @@ async fn run_dashboard( CapabilityPolicy::Always => true, CapabilityPolicy::Never => false, CapabilityPolicy::Auto => { - std::env::var_os("NO_COLOR").is_none() - && std::env::var("TERM").as_deref() != Ok("dumb") + std::env::var_os("NO_COLOR").is_none() && std::env::var("TERM").as_deref() != Ok("dumb") } }; let unicode_enabled = match unicode_policy { @@ -278,6 +283,7 @@ 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), @@ -328,6 +334,20 @@ 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() { @@ -457,11 +477,21 @@ async fn run_command( "Refusing destructive command without --yes.".into(), )); } - Command::Dashboard | Command::Help | Command::Version | Command::Completions { .. } - | Command::ManPage | Command::Install { .. } - | Command::DaemonEnable { .. } | Command::DaemonDisableStartup - | Command::DaemonStartupStatus | Command::DaemonStart - | Command::DaemonRestartService | Command::DaemonStopService => { + 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(), )); diff --git a/iota/src/terms.rs b/iota/src/terms.rs new file mode 100644 index 0000000..b7cdbcb --- /dev/null +++ b/iota/src/terms.rs @@ -0,0 +1,118 @@ +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 6e745e6..bbf2bf2 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -889,6 +889,7 @@ 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); @@ -1166,6 +1167,12 @@ 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 ce3d5e6..53684cc 100644 --- a/type-maps.yaml +++ b/type-maps.yaml @@ -149,6 +149,9 @@ type_maps: MessageReactionRemove: 147 MessageReactionLive: 148 MessageDeleteLive: 150 + ClientStateSync: 151 + ClientStateAck: 152 + StateSubscribe: 153 DataTypes: ErrorType: 32 ErrorProtocol: 33 @@ -267,3 +270,9 @@ type_maps: Reactions: 156 Reaction: 157 ReplyId: 158 + CacheValid: 159 + CacheSchemaVersion: 160 + SyncMode: 161 + MessageId: 162 + DeletedMessageIds: 163 + DeletedContactIds: 164