This commit is contained in:
Alex 2026-07-25 22:59:25 +02:00
commit 009173a97d
Signed by: alex
SSH key fingerprint: SHA256:D1+Ub8o0v4K5y1JNivW8IxEOelqLSvPmUzBbDIoZkRQ
49 changed files with 1788 additions and 389 deletions

2
Cargo.lock generated
View file

@ -2192,6 +2192,7 @@ dependencies = [
"iota-ipc", "iota-ipc",
"iota-paths", "iota-paths",
"iota-process-manager", "iota-process-manager",
"iota-terms",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
"tokio", "tokio",
@ -2350,6 +2351,7 @@ dependencies = [
"iota-paths", "iota-paths",
"iota-state", "iota-state",
"iota-storage", "iota-storage",
"iota-terms",
"iota-util", "iota-util",
"omikron-connector", "omikron-connector",
"tokio", "tokio",

View file

@ -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. 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 # Linux daemon installation
The system-managed daemon runs as the dedicated `iota` account and listens on The system-managed daemon runs as the dedicated `iota` account and listens on

View file

@ -250,6 +250,12 @@ impl ClientConnection {
return; return;
} }
if cv.is_type(CommunicationType::ClientStateAck) {
self.send_message(&message_handlers::handle_client_state_ack(&cv))
.await;
return;
}
// ************************************************ // // ************************************************ //
// Direct messages // // Direct messages //
// ************************************************ // // ************************************************ //

View file

@ -39,8 +39,15 @@ pub fn render_button(
} }
}; };
frame.render_widget( frame.render_widget(
Paragraph::new(Span::styled(if button.focused { format!(" {}", button.label) } else { button.label.to_owned() }, style)) Paragraph::new(Span::styled(
.alignment(Alignment::Center), if button.focused {
format!(" {}", button.label)
} else {
button.label.to_owned()
},
style,
))
.alignment(Alignment::Center),
area, area,
); );
} }

View file

@ -1,8 +1,8 @@
pub mod action; pub mod action;
pub mod button; pub mod button;
pub mod checkbox_group; pub mod checkbox_group;
pub mod header;
pub mod choice; pub mod choice;
pub mod header;
pub mod navigation; pub mod navigation;
pub mod panel; pub mod panel;
pub mod radio_group; pub mod radio_group;

View file

@ -1,23 +1,60 @@
use crate::theme::{ChromeMode, ResolvedTheme}; 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 /// Draw a conventional outlined panel or a filled surface from the same call
/// site. Screens can migrate without embedding theme branches in layouts. /// 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 { match theme.chrome {
ChromeMode::Bordered => { 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); let inner = block.inner(area);
frame.render_widget(block, area); frame.render_widget(block, area);
inner inner
} }
ChromeMode::Surfaces => { ChromeMode::Surfaces => {
frame.render_widget(Block::default().style(if focused { theme.surfaces.panel_focused } else { theme.surfaces.panel }), area); frame.render_widget(
let header = Rect { x: area.x, y: area.y, width: area.width, height: area.height.min(1) }; Block::default().style(if focused {
frame.render_widget(Paragraph::new(format!(" {title}")).style(theme.surfaces.panel_alternate), header); 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 // Surface panels use a single header row. A one-cell inset keeps
// compact controls such as the console usable at height three. // 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),
}
} }
} }
} }

View file

@ -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. /// Reusable viewport policy for long, vertically stacked terminal content.
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub struct ScrollOptions { pub show_scrollbar: bool, pub render_partial_components: bool } pub struct ScrollOptions {
impl Default for ScrollOptions { fn default() -> Self { Self { show_scrollbar: true, render_partial_components: true } } } 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)] #[derive(Clone, Debug, Default)]
pub struct ScrollField { pub offset: u16, pub options: ScrollOptions } pub struct ScrollField {
pub offset: u16,
pub options: ScrollOptions,
}
impl ScrollField { impl ScrollField {
pub fn up(&mut self, amount: u16) { self.offset = self.offset.saturating_sub(amount); } pub fn up(&mut self, amount: u16) {
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)); } self.offset = self.offset.saturating_sub(amount);
pub fn render(&self, frame: &mut Frame, area: Rect, content: Paragraph<'_>, content_height: u16) { }
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); frame.render_widget(content.scroll((self.offset, 0)), area);
if self.options.show_scrollbar && content_height > area.height { if self.options.show_scrollbar && content_height > area.height {
let mut state = ScrollbarState::new(content_height as usize).position(self.offset as usize); let mut state =
frame.render_stateful_widget(Scrollbar::new(ScrollbarOrientation::VerticalRight), area, &mut state); ScrollbarState::new(content_height as usize).position(self.offset as usize);
frame.render_stateful_widget(
Scrollbar::new(ScrollbarOrientation::VerticalRight),
area,
&mut state,
);
} }
} }
} }

View file

@ -174,12 +174,7 @@ impl ConsoleCard {
fn is_destructive(command: &str) -> bool { fn is_destructive(command: &str) -> bool {
matches!( matches!(
command.trim_start_matches('/').trim(), command.trim_start_matches('/').trim(),
"restart" "restart" | "reload" | "stop" | "shutdown" | "regenerate keys" | "identity rotate"
| "reload"
| "stop"
| "shutdown"
| "regenerate keys"
| "identity rotate"
) || command ) || command
.trim_start_matches('/') .trim_start_matches('/')
.trim_start() .trim_start()

View file

@ -40,9 +40,18 @@ impl GRAPHS {
Err(_) => return Vec::new(), Err(_) => return Vec::new(),
}; };
match self { match self {
GRAPHS::Ram => state.with_width(sample_width.min(u16::MAX as usize) as u16).ram.clone(), GRAPHS::Ram => state
GRAPHS::Cpu => state.with_width(sample_width.min(u16::MAX as usize) as u16).cpu.clone(), .with_width(sample_width.min(u16::MAX as usize) as u16)
GRAPHS::Ping => state.with_width(sample_width.min(u16::MAX as usize) as u16).ping.clone(), .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 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 title = format!(
let plot_area = if surface { crate::controls::panel::render_panel(f, r, &title, self.focused, context.theme) } else { r }; "{}: {}{} {}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() let block = Block::default()
.title(if surface { String::new() } else { format!( .title(if surface {
"{}:─{}{}─{}min/{}max", String::new()
self.title, } else {
graph.last().unwrap_or(&(0.0, 0.0)).1 as i64, format!(
unit, "{}:─{}{}─{}min/{}max",
min_y as i64, self.title,
max_y as i64, 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 }) .borders(if surface { Borders::NONE } else { self.borders })
.border_style(if self.focused { .border_style(if self.focused {
context.theme.graphs.focused_border context.theme.graphs.focused_border
@ -186,17 +210,19 @@ impl Element for GraphCard {
}); });
f.render_widget(block, r); f.render_widget(block, r);
} }
if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { draw_block_joins( if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
f, draw_block_joins(
r, f,
self.borders, r,
self.joins, self.borders,
if self.focused { self.joins,
context.theme.borders.focused if self.focused {
} else { context.theme.borders.focused
context.theme.borders.normal } else {
}, context.theme.borders.normal
); } },
);
}
} }
} }

View file

@ -322,9 +322,22 @@ impl Element for LogCard {
let entries = self.get_logs(); let entries = self.get_logs();
let inner_area = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { 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 { } 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); let inner = block.inner(area);
f.render_widget(block, area); f.render_widget(block, area);
inner inner
@ -363,7 +376,11 @@ impl Element for LogCard {
let mut spans = Vec::new(); let mut spans = Vec::new();
let (prefix, rest) = Self::split_line_prefix(line); 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() { if !prefix.is_empty() {
spans.push(Span::styled( spans.push(Span::styled(
@ -404,17 +421,19 @@ impl Element for LogCard {
f.render_widget(Paragraph::new(line.clone()), line_area); f.render_widget(Paragraph::new(line.clone()), line_area);
} }
if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) { draw_block_joins( if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
f, draw_block_joins(
area, f,
self.borders, area,
self.joins, self.borders,
if self.focused { self.joins,
context.theme.borders.focused if self.focused {
} else { context.theme.borders.focused
context.theme.borders.normal } else {
}, context.theme.borders.normal
); } },
);
}
} }
} }

View file

@ -459,14 +459,22 @@ impl IpcClient {
if tasks.is_empty() { if tasks.is_empty() {
"No active tasks.".into() "No active tasks.".into()
} else { } else {
tasks.iter().map(|t| t.name.as_str()).collect::<Vec<_>>().join(", ") tasks
.iter()
.map(|t| t.name.as_str())
.collect::<Vec<_>>()
.join(", ")
} }
} }
ResponsePayload::Users(users) => { ResponsePayload::Users(users) => {
if users.is_empty() { if users.is_empty() {
"No users.".into() "No users.".into()
} else { } else {
users.iter().map(|u| format!("{} ({})", u.username, u.user_id)).collect::<Vec<_>>().join("\n") users
.iter()
.map(|u| format!("{} ({})", u.username, u.user_id))
.collect::<Vec<_>>()
.join("\n")
} }
} }
ResponsePayload::UserCreated { user_id, username } => { ResponsePayload::UserCreated { user_id, username } => {
@ -489,14 +497,18 @@ impl IpcClient {
if components.is_empty() { if components.is_empty() {
"No component health data available.".into() "No component health data available.".into()
} else { } else {
components.iter().map(|c| { components
let status_str = match c.status { .iter()
iota_ipc::HealthStatus::Healthy => "healthy", .map(|c| {
iota_ipc::HealthStatus::Degraded => "degraded", let status_str = match c.status {
iota_ipc::HealthStatus::Failed => "failed", iota_ipc::HealthStatus::Healthy => "healthy",
}; iota_ipc::HealthStatus::Degraded => "degraded",
format!("{:?}: {}", c.id, status_str) iota_ipc::HealthStatus::Failed => "failed",
}).collect::<Vec<_>>().join("\n") };
format!("{:?}: {}", c.id, status_str)
})
.collect::<Vec<_>>()
.join("\n")
} }
} }
ResponsePayload::UserDetail(user) => { ResponsePayload::UserDetail(user) => {
@ -510,20 +522,31 @@ impl IpcClient {
} }
msg msg
} }
ResponsePayload::LogEntries(logs) => { ResponsePayload::LogEntries(logs) => logs
logs.entries.iter().map(|e| { .entries
.iter()
.map(|e| {
let level = if e.is_error { "ERR" } else { "INF" }; let level = if e.is_error { "ERR" } else { "INF" };
format!("[{}] {level} {}: {}", e.timestamp_ms, e.sender, e.message) format!("[{}] {level} {}: {}", e.timestamp_ms, e.sender, e.message)
}).collect::<Vec<_>>().join("\n") })
} .collect::<Vec<_>>()
.join("\n"),
ResponsePayload::UpdateStatus(status) => { 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) => { ResponsePayload::Communities(communities) => {
if communities.is_empty() { if communities.is_empty() {
"No communities.".into() "No communities.".into()
} else { } else {
communities.iter().map(|c| format!("{} ({})", c.title, c.name)).collect::<Vec<_>>().join("\n") communities
.iter()
.map(|c| format!("{} ({})", c.title, c.name))
.collect::<Vec<_>>()
.join("\n")
} }
} }
} }

View file

@ -29,7 +29,13 @@ impl Screen for DaemonStartingScreen {
fn as_any_mut(&mut self) -> &mut dyn Any { fn as_any_mut(&mut self) -> &mut dyn Any {
self 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( let popup = crate::layout::fit::centered_rect(
area, area,
crate::layout::fit::RequiredSize { crate::layout::fit::RequiredSize {
@ -191,7 +197,13 @@ impl Screen for DaemonSetupScreen {
fn as_any_mut(&mut self) -> &mut dyn Any { fn as_any_mut(&mut self) -> &mut dyn Any {
self 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( let popup = crate::layout::fit::centered_rect(
area, area,
crate::layout::fit::RequiredSize { crate::layout::fit::RequiredSize {
@ -267,7 +279,9 @@ impl Screen for DaemonSetupScreen {
); );
} }
fn handle_event(&mut self, event: UiEvent) -> InteractionResult { 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 { match event.code {
KeyCode::Esc => { KeyCode::Esc => {
self.complete(DaemonSetupDecision::Exit); self.complete(DaemonSetupDecision::Exit);

View file

@ -212,9 +212,7 @@ impl MainScreen {
let mut seen: Vec<Option<usize>> = Vec::new(); let mut seen: Vec<Option<usize>> = Vec::new();
for (y, row) in self.nav_grid.iter().enumerate() { for (y, row) in self.nav_grid.iter().enumerate() {
for (x, elem_opt) in row.iter().enumerate() { for (x, elem_opt) in row.iter().enumerate() {
if x == 1 if x == 1 && (!self.graphs_open || self.layout_width.load(Ordering::Relaxed) < 70) {
&& (!self.graphs_open || self.layout_width.load(Ordering::Relaxed) < 70)
{
continue; continue;
} }
if elem_opt.is_some() && !seen.contains(elem_opt) { if elem_opt.is_some() && !seen.contains(elem_opt) {
@ -511,25 +509,64 @@ impl Screen for MainScreen {
fn key_hints(&self) -> Vec<KeyHint> { fn key_hints(&self) -> Vec<KeyHint> {
if self.selected_coords == (2, 0) { if self.selected_coords == (2, 0) {
vec![ vec![
KeyHint { keys: "Enter", action: "Send" }, KeyHint {
KeyHint { keys: "Up/Down", action: "History" }, keys: "Enter",
KeyHint { keys: "Tab", action: "Complete" }, action: "Send",
KeyHint { keys: "F6", action: "Header" }, },
KeyHint {
keys: "Up/Down",
action: "History",
},
KeyHint {
keys: "Tab",
action: "Complete",
},
KeyHint {
keys: "F6",
action: "Header",
},
] ]
} else if self.selected_coords == (0, 0) { } else if self.selected_coords == (0, 0) {
vec![ vec![
KeyHint { keys: "J/K", action: "Scroll logs" }, KeyHint {
KeyHint { keys: "Enter", action: "Lock scroll" }, keys: "J/K",
KeyHint { keys: "/", action: "Filter" }, action: "Scroll logs",
KeyHint { keys: "M", action: "Metrics screen" }, },
KeyHint { keys: "Tab", action: "Next panel" }, KeyHint {
KeyHint { keys: "F6", action: "Header" }, 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 { } else {
vec![ vec![
KeyHint { keys: "Enter", action: "Toggle metrics" }, KeyHint {
KeyHint { keys: "Tab", action: "Next panel" }, keys: "Enter",
KeyHint { keys: "F6", action: "Header" }, action: "Toggle metrics",
},
KeyHint {
keys: "Tab",
action: "Next panel",
},
KeyHint {
keys: "F6",
action: "Header",
},
] ]
} }
} }

View file

@ -34,7 +34,9 @@ impl Screen for FileViewer {
} }
fn handle_event(&mut self, event: UiEvent) -> InteractionResult { 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 { match event.code {
KeyCode::Char('q') | KeyCode::Esc => { KeyCode::Char('q') | KeyCode::Esc => {
return InteractionResult::CloseScreen; return InteractionResult::CloseScreen;

View file

@ -63,7 +63,13 @@ impl Screen for MetricsScreen {
self 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() let block = Block::default()
.title(" Metrics ") .title(" Metrics ")
.borders(Borders::ALL) .borders(Borders::ALL)
@ -80,8 +86,7 @@ impl Screen for MetricsScreen {
frame.render_widget( frame.render_widget(
Paragraph::new(format!( Paragraph::new(format!(
"Range: {} ({} samples) Left/Right to change", "Range: {} ({} samples) Left/Right to change",
RANGES[self.range_index].1, RANGES[self.range_index].1, RANGES[self.range_index].0
RANGES[self.range_index].0
)) ))
.style(context.theme.text.heading), .style(context.theme.text.heading),
rows[0], rows[0],

View file

@ -53,17 +53,11 @@ impl OverviewScreen {
let mut lines = Vec::new(); let mut lines = Vec::new();
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled("Connection", theme.text.heading)));
"Connection",
theme.text.heading,
)));
lines.push(Line::from(format!(" State: {}", connection_label(&conn)))); lines.push(Line::from(format!(" State: {}", connection_label(&conn))));
lines.push(Line::from("")); lines.push(Line::from(""));
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled("Daemon", theme.text.heading)));
"Daemon",
theme.text.heading,
)));
lines.push(Line::from(format!( lines.push(Line::from(format!(
" Version: {}", " Version: {}",
version_or_unknown(&daemon.version) version_or_unknown(&daemon.version)
@ -113,10 +107,7 @@ impl OverviewScreen {
if !daemon.components.is_empty() { if !daemon.components.is_empty() {
lines.push(Line::from("")); lines.push(Line::from(""));
lines.push(Line::from(Span::styled( lines.push(Line::from(Span::styled("Components", theme.text.heading)));
"Components",
theme.text.heading,
)));
for (id, health) in &daemon.components { for (id, health) in &daemon.components {
let status_str = match health.status { let status_str = match health.status {
iota_ipc::HealthStatus::Healthy => "[OK] healthy", iota_ipc::HealthStatus::Healthy => "[OK] healthy",
@ -187,7 +178,13 @@ impl Screen for OverviewScreen {
.borders(Borders::ALL) .borders(Borders::ALL)
.border_style(context.theme.borders.normal) .border_style(context.theme.borders.normal)
.title_style(context.theme.borders.title); .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([ let rows = ratatui::layout::Layout::vertical([
ratatui::layout::Constraint::Min(1), ratatui::layout::Constraint::Min(1),
ratatui::layout::Constraint::Length(1), ratatui::layout::Constraint::Length(1),
@ -273,10 +270,22 @@ impl Screen for OverviewScreen {
} }
fn key_hints(&self) -> Vec<KeyHint> { fn key_hints(&self) -> Vec<KeyHint> {
vec![ vec![
KeyHint { keys: "Up/Down", action: "Scroll" }, KeyHint {
KeyHint { keys: "PgUp/PgDn", action: "Page" }, keys: "Up/Down",
KeyHint { keys: "Esc/B", action: "Back" }, action: "Scroll",
KeyHint { keys: "F6", action: "Header" }, },
KeyHint {
keys: "PgUp/PgDn",
action: "Page",
},
KeyHint {
keys: "Esc/B",
action: "Back",
},
KeyHint {
keys: "F6",
action: "Header",
},
] ]
} }
} }

View file

@ -49,8 +49,12 @@ impl SettingsScreen {
selected, selected,
saved: current, saved: current,
message: "Left/Right previews. Enter saves.".into(), message: "Left/Right previews. Enter saves.".into(),
color: UiConfig::load().map(|config| config.color).unwrap_or_default(), color: UiConfig::load()
unicode: UiConfig::load().map(|config| config.unicode).unwrap_or_default(), .map(|config| config.color)
.unwrap_or_default(),
unicode: UiConfig::load()
.map(|config| config.unicode)
.unwrap_or_default(),
focus: Focus::Theme, focus: Focus::Theme,
dialog: None, dialog: None,
pending: false, pending: false,
@ -64,14 +68,16 @@ impl SettingsScreen {
fn apply(&self, persist: bool) -> InteractionResult { fn apply(&self, persist: bool) -> InteractionResult {
let theme = self.selected_theme(); let theme = self.selected_theme();
InteractionResult::AppTask { InteractionResult::AppTask {
task: Box::pin(async move { task: Box::pin(async move { UiEvent::App(AppEvent::ApplyTheme { theme, persist }) }),
UiEvent::App(AppEvent::ApplyTheme { theme, persist })
}),
} }
} }
fn next_policy(policy: TerminalPolicy) -> TerminalPolicy { 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) { fn next_focus(&mut self) {
@ -100,9 +106,7 @@ impl SettingsScreen {
self.pending = true; self.pending = true;
self.message = "Regenerating keys…".into(); self.message = "Regenerating keys…".into();
return InteractionResult::AppTask { return InteractionResult::AppTask {
task: Box::pin(async { task: Box::pin(async { UiEvent::App(AppEvent::RegenerateKeysRequested) }),
UiEvent::App(AppEvent::RegenerateKeysRequested)
}),
}; };
} }
} }
@ -113,7 +117,15 @@ impl SettingsScreen {
let theme = self.selected_theme(); let theme = self.selected_theme();
let color = self.color; let color = self.color;
let unicode = self.unicode; 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 => { Focus::RegenerateKeys => {
self.dialog = Some(Dialog::ConfirmRegenerateKeys); self.dialog = Some(Dialog::ConfirmRegenerateKeys);
@ -146,8 +158,7 @@ impl Screen for SettingsScreen {
.border_style(context.theme.borders.focused); .border_style(context.theme.borders.focused);
let inner = block.inner(area); let inner = block.inner(area);
frame.render_widget(block, area); frame.render_widget(block, area);
let rows = let rows = Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).split(inner);
Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).split(inner);
frame.render_widget( frame.render_widget(
Paragraph::new(format!( Paragraph::new(format!(
"Theme: < {} >{}\nColor: {:?} (C) Unicode: {:?} (U)", "Theme: < {} >{}\nColor: {:?} (C) Unicode: {:?} (U)",
@ -156,7 +167,9 @@ impl Screen for SettingsScreen {
" [saved]" " [saved]"
} else { } else {
" [preview]" " [preview]"
}, self.color, self.unicode },
self.color,
self.unicode
)) ))
.style(context.theme.text.heading), .style(context.theme.text.heading),
rows[0], rows[0],
@ -331,8 +344,14 @@ impl Screen for SettingsScreen {
self.prev_focus(); self.prev_focus();
InteractionResult::Handled InteractionResult::Handled
} }
KeyCode::Char('c') | KeyCode::Char('C') => { self.color = Self::next_policy(self.color); InteractionResult::Handled } KeyCode::Char('c') | KeyCode::Char('C') => {
KeyCode::Char('u') | KeyCode::Char('U') => { self.unicode = Self::next_policy(self.unicode); InteractionResult::Handled } 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') => { KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => {
InteractionResult::CloseScreen InteractionResult::CloseScreen
} }
@ -378,8 +397,14 @@ impl Screen for SettingsScreen {
keys: "Enter", keys: "Enter",
action: "Save/Activate", action: "Save/Activate",
}, },
KeyHint { keys: "Tab", action: "Move focus" }, KeyHint {
KeyHint { keys: "C/U", action: "Color/Unicode" }, keys: "Tab",
action: "Move focus",
},
KeyHint {
keys: "C/U",
action: "Color/Unicode",
},
KeyHint { KeyHint {
keys: "Esc/B", keys: "Esc/B",
action: "Back", action: "Back",

View file

@ -2,7 +2,10 @@ use crate::{
controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line}, controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line},
interaction_result::InteractionResult, interaction_result::InteractionResult,
render_context::RenderContext, 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}, util::{buttons::draw_buttons, terms_focus::Focus},
}; };
use crossterm::event::KeyCode; use crossterm::event::KeyCode;
@ -271,7 +274,9 @@ impl Screen for TermsCheckerScreen {
} }
fn handle_event(&mut self, event: UiEvent) -> InteractionResult { 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]; let mut possible_states = vec![Focus::Eula, Focus::Tos, Focus::Pp, Focus::Cancel];
if self.eula { if self.eula {

View file

@ -3,7 +3,10 @@ use crate::{
controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line}, controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line},
interaction_result::InteractionResult, interaction_result::InteractionResult,
render_context::RenderContext, 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}, util::{buttons::draw_buttons, terms_focus::Focus},
}; };
use chrono::{Local, TimeZone, Utc}; use chrono::{Local, TimeZone, Utc};
@ -594,7 +597,9 @@ impl Screen for TermsUpdaterScreen {
} }
fn handle_event(&mut self, event: UiEvent) -> InteractionResult { 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(); let mut possible_states = Vec::new();
if self.eula_needed { if self.eula_needed {

View file

@ -89,7 +89,12 @@ impl UsersScreen {
let title = if self.filter.is_empty() { let title = if self.filter.is_empty() {
format!("Users ({})", self.users.len()) format!("Users ({})", self.users.len())
} else { } 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) { let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
crate::controls::panel::render_panel( crate::controls::panel::render_panel(
@ -124,14 +129,18 @@ impl UsersScreen {
} }
let mut lines = Vec::new(); 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 let labels: Vec<(usize, String)> = visible_indices
.iter() .iter()
.skip(self.scroll_offset) .skip(self.scroll_offset)
.take(inner.height as usize) .take(inner.height as usize)
.map(|user_index| { .map(|user_index| {
let user = &self.users[*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(); .collect();
for (user_index, label) in &labels { for (user_index, label) in &labels {
@ -291,7 +300,10 @@ impl UsersScreen {
fn keep_focused_user_visible(&mut self) { fn keep_focused_user_visible(&mut self) {
let indices = self.filtered_indices(); 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; self.scroll_offset = 0;
return; return;
}; };

View file

@ -25,11 +25,20 @@ pub struct BorderStyles {
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct SurfaceStyles { pub struct SurfaceStyles {
pub canvas: Style, pub toolbar: Style, pub panel: Style, pub panel_alternate: Style, pub canvas: Style,
pub panel_focused: Style, pub panel_selected: Style, pub footer: Style, pub overlay: 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)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ChromeMode { Bordered, Surfaces } pub enum ChromeMode {
Bordered,
Surfaces,
}
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct ChoiceItemStyle { pub struct ChoiceItemStyle {
pub marker: Style, pub marker: Style,

View file

@ -1,7 +1,7 @@
use super::{ use super::{
BorderStyles, ButtonStyles, ChoiceItemStyle, ChoiceStyles, ConsoleStyles, CursorPresentation, BorderStyles, ButtonStyles, ChoiceItemStyle, ChoiceStyles, ChromeMode, ConsoleStyles,
ChromeMode, GraphStyles, LogStyles, MarkdownStyles, MarkerSet, ResolvedTheme, StatusStyles, SurfaceStyles, TextStyles, CursorPresentation, GraphStyles, LogStyles, MarkdownStyles, MarkerSet, ResolvedTheme,
ThemeName, StatusStyles, SurfaceStyles, TextStyles, ThemeName,
}; };
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
@ -46,7 +46,16 @@ fn base(
ResolvedTheme { ResolvedTheme {
name, name,
unicode: true, 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, chrome: ChromeMode::Bordered,
text: TextStyles { text: TextStyles {
normal, normal,
@ -300,11 +309,14 @@ pub fn resolve(name: ThemeName) -> ResolvedTheme {
CursorPresentation::StyledCell(plain.fg(Color::Black).bg(Color::Yellow)); CursorPresentation::StyledCell(plain.fg(Color::Black).bg(Color::Yellow));
theme.chrome = ChromeMode::Surfaces; theme.chrome = ChromeMode::Surfaces;
theme.surfaces = SurfaceStyles { 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: plain.fg(Color::White).bg(Color::Rgb(30, 35, 45)),
panel_alternate: plain.fg(Color::White).bg(Color::Rgb(45, 52, 66)), 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_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 theme
} }

View file

@ -328,7 +328,12 @@ impl UI {
} }
return; 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; self.set_theme(theme::resolve(*theme)).await;
let mut config = theme::UiConfig::load().unwrap_or_default(); let mut config = theme::UiConfig::load().unwrap_or_default();
config.theme = *theme; config.theme = *theme;
@ -337,7 +342,9 @@ impl UI {
let result = config let result = config
.save() .save()
.map_err(|error| format!("Could not save UI settings: {error}")); .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; return;
} }
if matches!(&event, UiEvent::App(AppEvent::RegenerateKeysRequested)) { if matches!(&event, UiEvent::App(AppEvent::RegenerateKeysRequested)) {
@ -386,12 +393,14 @@ impl UI {
KeyCode::Left | KeyCode::BackTab => *focus = Some((index + 3) % 4), KeyCode::Left | KeyCode::BackTab => *focus = Some((index + 3) % 4),
KeyCode::Right | KeyCode::Tab => *focus = Some((index + 1) % 4), KeyCode::Right | KeyCode::Tab => *focus = Some((index + 1) % 4),
KeyCode::Enter | KeyCode::Char(' ') => { KeyCode::Enter | KeyCode::Char(' ') => {
action = Some([ action = Some(
AppAction::OpenOverview, [
AppAction::OpenUsers, AppAction::OpenOverview,
AppAction::OpenSettings, AppAction::OpenUsers,
AppAction::Quit, AppAction::OpenSettings,
][index]); AppAction::Quit,
][index],
);
*focus = None; *focus = None;
} }
KeyCode::Esc => *focus = None, KeyCode::Esc => *focus = None,
@ -516,7 +525,8 @@ impl UI {
AppAction::OpenUsers => self.open_users().await, AppAction::OpenUsers => self.open_users().await,
AppAction::OpenSettings => { AppAction::OpenSettings => {
let current = self.theme_name().await; 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 => { AppAction::OpenMetrics => {
if let Some(screen) = MetricsScreen::new(self.clone()).await { if let Some(screen) = MetricsScreen::new(self.clone()).await {
@ -552,7 +562,9 @@ impl UI {
}) })
.collect()) .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()), Ok(_) => Err("Daemon returned an unexpected response while loading users.".into()),
Err(error) => Err(format!("Cannot load users: {error}")), Err(error) => Err(format!("Cannot load users: {error}")),
}; };
@ -611,8 +623,13 @@ impl UI {
.join(" ") .join(" ")
}; };
f.render_widget( f.render_widget(
ratatui::widgets::Paragraph::new(format!(" {hints}")) ratatui::widgets::Paragraph::new(format!(" {hints}")).style(
.style(context.theme.surfaces.footer.patch(context.theme.text.muted)), context
.theme
.surfaces
.footer
.patch(context.theme.text.muted),
),
rows[2], rows[2],
); );
screen.render(f, rows[1], &context, &mut hits); screen.render(f, rows[1], &context, &mut hits);

View file

@ -1,3 +1,4 @@
use crossterm::event::{KeyCode, KeyEvent};
use iota_cli::{ use iota_cli::{
interaction_result::InteractionResult, interaction_result::InteractionResult,
render_context::RenderContext, render_context::RenderContext,
@ -8,7 +9,6 @@ use iota_cli::{
theme::{ThemeName, resolve}, theme::{ThemeName, resolve},
}; };
use ratatui::{Terminal, backend::TestBackend}; use ratatui::{Terminal, backend::TestBackend};
use crossterm::event::{KeyCode, KeyEvent};
fn buffer_text(terminal: &Terminal<TestBackend>) -> String { fn buffer_text(terminal: &Terminal<TestBackend>) -> String {
terminal terminal

View file

@ -108,6 +108,10 @@ fn stored_message_value(
partner_id: i64, partner_id: i64,
) -> DataValue { ) -> DataValue {
let mut fields = vec![ let mut fields = vec![
(
DataType::MessageId,
DataValue::SignedNumber(message.id as i128),
),
( (
DataType::SendTime, DataType::SendTime,
DataValue::SignedNumber(message.message_time as i128), 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) .with_receiver(sender_id as u64)
} }
pub fn handle_client_connected(cv: &CommunicationValue) -> CommunicationValue { fn contact_value(contact: &iota_storage::users::contact::Contact) -> DataValue {
let user_id = cv.get_data(DataType::UserId).as_number().unwrap_or(0) as i64; let mut fields = vec![(
DataType::UserId,
let contacts = chats_util::get_users(user_id); DataValue::SignedNumber(contact.user_id as i128),
let mut contacts_array = Vec::new(); )];
if let Some(name) = &contact.user_name {
for (i, contact) in contacts.iter().enumerate() { fields.push((DataType::Username, DataValue::Str(name.clone())));
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));
} }
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()) .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) { pub fn handle_message_state(cv: &CommunicationValue) {

View file

@ -1,8 +1,8 @@
use crate::{DaemonRuntime, DaemonServices};
use crate::log_buffer::LogBuffer; use crate::log_buffer::LogBuffer;
use crate::{DaemonRuntime, DaemonServices};
use iota_ipc::{ use iota_ipc::{
ComponentStatusResponse, CommunitySummary, ConfigResponse, ExitIntent, IpcErrorCode, CommunitySummary, ComponentStatusResponse, ConfigResponse, ExitIntent, IpcErrorCode,
LogEntriesResponse, LocalRequest, OmikronStatusResponse, ResponseEnvelope, ResponsePayload, LocalRequest, LogEntriesResponse, OmikronStatusResponse, ResponseEnvelope, ResponsePayload,
ResponseResult, StatusResponse, TaskSummary, UpdateStatusResponse, UserDetailResponse, ResponseResult, StatusResponse, TaskSummary, UpdateStatusResponse, UserDetailResponse,
UserSummary, UserSummary,
}; };
@ -23,8 +23,16 @@ pub struct CommandRouter {
} }
impl CommandRouter { impl CommandRouter {
pub fn new(runtime: Arc<DaemonRuntime>, services: Arc<DaemonServices>, log_buffer: Arc<Mutex<LogBuffer>>) -> Self { pub fn new(
Self { runtime, services, log_buffer } runtime: Arc<DaemonRuntime>,
services: Arc<DaemonServices>,
log_buffer: Arc<Mutex<LogBuffer>>,
) -> Self {
Self {
runtime,
services,
log_buffer,
}
} }
pub async fn route(&self, request_id: u64, request: LocalRequest) -> ResponseEnvelope { pub async fn route(&self, request_id: u64, request: LocalRequest) -> ResponseEnvelope {
@ -34,6 +42,14 @@ impl CommandRouter {
} }
async fn execute(&self, request: LocalRequest) -> ResponseResult { 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!( let needs_omikron = matches!(
request, request,
LocalRequest::CreateUser { .. } LocalRequest::CreateUser { .. }
@ -96,12 +112,10 @@ impl CommandRouter {
) )
.await .await
{ {
(Some(user), _) => { (Some(user), _) => ResponseResult::Ok(ResponsePayload::UserCreated {
ResponseResult::Ok(ResponsePayload::UserCreated { user_id: user.user_id,
user_id: user.user_id, username: user.username,
username: user.username, }),
})
}
_ => ResponseResult::Error(IpcErrorCode::StorageFailure), _ => ResponseResult::Error(IpcErrorCode::StorageFailure),
} }
} }
@ -154,13 +168,11 @@ impl CommandRouter {
message: "process exit accepted".into(), message: "process exit accepted".into(),
}) })
} }
LocalRequest::GetDaemonStatus => { LocalRequest::GetDaemonStatus => ResponseResult::Ok(ResponsePayload::DaemonStatus(
ResponseResult::Ok(ResponsePayload::DaemonStatus( iota_ipc::DaemonStatusResponse {
iota_ipc::DaemonStatusResponse { formatted: format!("{:?}", self.runtime.snapshot()),
formatted: format!("{:?}", self.runtime.snapshot()), },
}, )),
))
}
LocalRequest::RestartDaemon => { LocalRequest::RestartDaemon => {
self.runtime.shutdown(ShutdownReason::Restart); self.runtime.shutdown(ShutdownReason::Restart);
ResponseResult::Ok(ResponsePayload::Acknowledged { ResponseResult::Ok(ResponsePayload::Acknowledged {
@ -195,12 +207,10 @@ impl CommandRouter {
LocalRequest::GetOmikronStatus => { LocalRequest::GetOmikronStatus => {
let connected = self.services.omikron.is_connected().await; let connected = self.services.omikron.is_connected().await;
let iota_id = config_util::CONFIG.load().iota_id; let iota_id = config_util::CONFIG.load().iota_id;
ResponseResult::Ok(ResponsePayload::OmikronStatus( ResponseResult::Ok(ResponsePayload::OmikronStatus(OmikronStatusResponse {
OmikronStatusResponse { connected,
connected, iota_id,
iota_id, }))
},
))
} }
LocalRequest::ListComponents => { LocalRequest::ListComponents => {
let snapshot = self.runtime.snapshot(); let snapshot = self.runtime.snapshot();
@ -215,20 +225,16 @@ impl CommandRouter {
.collect(); .collect();
ResponseResult::Ok(ResponsePayload::Components(components)) ResponseResult::Ok(ResponsePayload::Components(components))
} }
LocalRequest::GetUser { user_id } => { LocalRequest::GetUser { user_id } => match user_manager::get_user(user_id) {
match user_manager::get_user(user_id) { Some(user) => ResponseResult::Ok(ResponsePayload::UserDetail(UserDetailResponse {
Some(user) => ResponseResult::Ok(ResponsePayload::UserDetail( user_id: user.user_id,
UserDetailResponse { username: user.username,
user_id: user.user_id, display_name: user.display_name,
username: user.username, created_at: user.created_at,
display_name: user.display_name, trusted_apps: user.trusted_apps.keys().cloned().collect(),
created_at: user.created_at, })),
trusted_apps: user.trusted_apps.keys().cloned().collect(), None => ResponseResult::Error(IpcErrorCode::NotFound),
}, },
)),
None => ResponseResult::Error(IpcErrorCode::NotFound),
}
}
LocalRequest::ImportUser { username } => { LocalRequest::ImportUser { username } => {
match user_manager::load_from_tu(&username).await { match user_manager::load_from_tu(&username).await {
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged { Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged {
@ -245,17 +251,22 @@ impl CommandRouter {
}; };
ResponseResult::Ok(ResponsePayload::LogEntries(LogEntriesResponse { entries })) ResponseResult::Ok(ResponsePayload::LogEntries(LogEntriesResponse { entries }))
} }
LocalRequest::CheckUpdate => { LocalRequest::CheckUpdate => match iota_updater::check_update().await {
match iota_updater::check_update().await { Ok(available) => {
Ok(available) => ResponseResult::Ok(ResponsePayload::UpdateStatus( ResponseResult::Ok(ResponsePayload::UpdateStatus(UpdateStatusResponse {
UpdateStatusResponse { available }, available,
)), }))
Err(_e) => ResponseResult::Error(IpcErrorCode::InternalFailure),
} }
} Err(_e) => ResponseResult::Error(IpcErrorCode::InternalFailure),
},
LocalRequest::ListCommunities => { LocalRequest::ListCommunities => {
let iota_id = config_util::CONFIG.load().iota_id.map(|id| id as i64).unwrap_or(0); let iota_id = config_util::CONFIG
let stored = iota_storage::util::communities_util::CommunitiesUtil::get_communities(iota_id); .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<CommunitySummary> = stored let summaries: Vec<CommunitySummary> = stored
.into_iter() .into_iter()
.map(|c| CommunitySummary { .map(|c| CommunitySummary {

View file

@ -1,5 +1,5 @@
use crate::log_buffer::LogBuffer;
use crate::deployment::from_environment; use crate::deployment::from_environment;
use crate::log_buffer::LogBuffer;
use crate::{CommandRouter, DaemonRuntime, DaemonServices}; use crate::{CommandRouter, DaemonRuntime, DaemonServices};
use iota_ipc::{ use iota_ipc::{
ClientMessage, DaemonMessage, HelloAck, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION, read_msg, ClientMessage, DaemonMessage, HelloAck, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION, read_msg,
@ -136,8 +136,16 @@ impl IpcServer {
let state_rx = self.state_rx.clone(); let state_rx = self.state_rx.clone();
let instance_id = self.instance_id.clone(); let instance_id = self.instance_id.clone();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(error) = if let Err(error) = handle_client(
handle_client(stream, runtime, services, log_tx, log_buffer, state_rx, instance_id).await stream,
runtime,
services,
log_tx,
log_buffer,
state_rx,
instance_id,
)
.await
{ {
eprintln!("IPC client error: {error}"); eprintln!("IPC client error: {error}");
} }

View file

@ -6,10 +6,7 @@ use tokio::sync::broadcast;
/* The daemon adapts logger output to the wire protocol so the logger stays /* The daemon adapts logger output to the wire protocol so the logger stays
* independent from both the socket implementation and TUI state. */ * independent from both the socket implementation and TUI state. */
pub fn spawn( pub fn spawn(message_tx: broadcast::Sender<DaemonMessage>, buffer: Arc<Mutex<LogBuffer>>) {
message_tx: broadcast::Sender<DaemonMessage>,
buffer: Arc<Mutex<LogBuffer>>,
) {
let Some(mut logs) = subscribe() else { let Some(mut logs) = subscribe() else {
return; return;
}; };

View file

@ -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::sync::Arc;
use std::time::Duration;
#[derive(Default)] #[derive(Default)]
pub struct UserService; pub struct UserService;
@ -10,6 +13,7 @@ pub struct DaemonServices {
pub omikron: Arc<dyn OmikronClient>, pub omikron: Arc<dyn OmikronClient>,
pub users: Arc<UserService>, pub users: Arc<UserService>,
pub config: Arc<ConfigService>, pub config: Arc<ConfigService>,
pub active: bool,
} }
impl DaemonServices { impl DaemonServices {
@ -18,6 +22,46 @@ impl DaemonServices {
omikron, omikron,
users: Arc::new(UserService), users: Arc::new(UserService),
config: Arc::new(ConfigService), 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<Self> {
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<CommunicationValue, OmikronError> {
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
}
}

View file

@ -1,6 +1,6 @@
use async_trait::async_trait; use async_trait::async_trait;
use iota_daemon_lib::{CommandRouter, DaemonRuntime, DaemonServices};
use iota_daemon_lib::log_buffer::LogBuffer; use iota_daemon_lib::log_buffer::LogBuffer;
use iota_daemon_lib::{CommandRouter, DaemonRuntime, DaemonServices};
use iota_ipc::{LocalRequest, ResponseResult}; use iota_ipc::{LocalRequest, ResponseResult};
use mtp::codec::CommunicationValue; use mtp::codec::CommunicationValue;
use omikron_connector::{OmikronClient, OmikronError}; use omikron_connector::{OmikronClient, OmikronError};
@ -44,7 +44,11 @@ async fn reconnect_uses_the_injected_client() {
users: Default::default(), users: Default::default(),
config: 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!( assert!(matches!(
router.route(1, LocalRequest::ReconnectOmikron).await.result, router.route(1, LocalRequest::ReconnectOmikron).await.result,
ResponseResult::Ok(_) ResponseResult::Ok(_)

View file

@ -11,6 +11,7 @@ iota-state = { path = "../iota-state" }
iota-paths = { path = "../iota-paths" } iota-paths = { path = "../iota-paths" }
iota-storage = { path = "../iota-storage" } iota-storage = { path = "../iota-storage" }
iota-util = { path = "../iota-util" } iota-util = { path = "../iota-util" }
iota-terms = { path = "../iota-terms" }
omikron-connector = { path = "../omikron-connector" } omikron-connector = { path = "../omikron-connector" }
web-server = { path = "../web-server" } web-server = { path = "../web-server" }
tokio = { version = "1.50.0", features = ["full"] } tokio = { version = "1.50.0", features = ["full"] }

View file

@ -22,6 +22,61 @@ async fn main() -> ExitCode {
return ExitCode::FAILURE; 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() { if let Err(error) = paths.migrate_legacy_layout() {
eprintln!("Cannot migrate legacy Iota layout: {error}"); eprintln!("Cannot migrate legacy Iota layout: {error}");
return ExitCode::FAILURE; return ExitCode::FAILURE;

View file

@ -3,12 +3,12 @@ pub mod text_commands;
pub mod transport; pub mod transport;
pub use protocol::{ pub use protocol::{
ClientMessage, ComponentHealth, ComponentId, ComponentStatusResponse, ConfigResponse, ClientMessage, CommunitySummary, ComponentHealth, ComponentId, ComponentStatusResponse,
ConnectionStatus, DaemonMessage, DaemonStatusResponse, DeploymentMode, ExitIntent, ConfigResponse, ConnectionStatus, DaemonMessage, DaemonStatusResponse, DeploymentMode,
HealthStatus, HelloAck, IpcErrorCode, LifecycleEvent, LifecyclePhase, LocalRequest, LogEntry, ExitIntent, HealthStatus, HelloAck, IpcErrorCode, LifecycleEvent, LifecyclePhase, LocalRequest,
LogEntriesResponse, MetricSample, OmikronStatusResponse, RequestEnvelope, ResponseEnvelope, LogEntriesResponse, LogEntry, MetricSample, OmikronStatusResponse, RequestEnvelope,
ResponsePayload, ResponseResult, StartupPhase, StateSnapshot, StatusResponse, SupervisorKind, ResponseEnvelope, ResponsePayload, ResponseResult, StartupPhase, StateSnapshot, StatusResponse,
TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary, CommunitySummary, SupervisorKind, TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary,
}; };
pub use transport::{read_msg, write_msg}; pub use transport::{read_msg, write_msg};

View file

@ -136,16 +136,9 @@ pub enum ResponsePayload {
Status(StatusResponse), Status(StatusResponse),
Tasks(Vec<TaskSummary>), Tasks(Vec<TaskSummary>),
Users(Vec<UserSummary>), Users(Vec<UserSummary>),
UserCreated { UserCreated { user_id: i64, username: String },
user_id: i64, UserRemoved { user_id: i64 },
username: String, Acknowledged { message: String },
},
UserRemoved {
user_id: i64,
},
Acknowledged {
message: String,
},
DaemonStatus(DaemonStatusResponse), DaemonStatus(DaemonStatusResponse),
Config(ConfigResponse), Config(ConfigResponse),
OmikronStatus(OmikronStatusResponse), OmikronStatus(OmikronStatusResponse),
@ -264,8 +257,15 @@ mod error_tests {
#[test] #[test]
fn error_codes_have_operator_facing_messages() { fn error_codes_have_operator_facing_messages() {
assert_eq!(IpcErrorCode::NotReady.to_string(), "the daemon is not ready yet"); assert_eq!(
assert!(!IpcErrorCode::InternalFailure.to_string().contains("InternalFailure")); IpcErrorCode::NotReady.to_string(),
"the daemon is not ready yet"
);
assert!(
!IpcErrorCode::InternalFailure
.to_string()
.contains("InternalFailure")
);
} }
} }

View file

@ -37,7 +37,9 @@ pub fn validation_error(line: &str) -> Option<String> {
if normalized == "help" || parse(normalized).is_some() { if normalized == "help" || parse(normalized).is_some() {
None None
} else { } 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] #[test]
fn accepts_the_headless_cli_user_vocabulary() { fn accepts_the_headless_cli_user_vocabulary() {
assert!(matches!( assert!(matches!(parse("users list"), Some(LocalRequest::ListUsers)));
parse("users list"),
Some(LocalRequest::ListUsers)
));
assert!(matches!( assert!(matches!(
parse("users add alice"), parse("users add alice"),
Some(LocalRequest::CreateUser { .. }) Some(LocalRequest::CreateUser { .. })
@ -203,10 +202,7 @@ mod tests {
#[test] #[test]
fn parses_config_get() { fn parses_config_get() {
assert!(matches!( assert!(matches!(parse("config get"), Some(LocalRequest::GetConfig)));
parse("config get"),
Some(LocalRequest::GetConfig)
));
} }
#[test] #[test]
@ -319,6 +315,10 @@ mod tests {
fn validation_distinguishes_help_and_unknown_commands() { fn validation_distinguishes_help_and_unknown_commands() {
assert_eq!(validation_error("/help"), None); assert_eq!(validation_error("/help"), None);
assert!(validation_error("status").is_none()); assert!(validation_error("status").is_none());
assert!(validation_error("statuz").unwrap().contains("Unknown command")); assert!(
validation_error("statuz")
.unwrap()
.contains("Unknown command")
);
} }
} }

View file

@ -1,5 +1,6 @@
use crate::storage_error::StorageError; use crate::storage_error::StorageError;
use crate::util::db; use crate::util::db;
use crate::util::sync::{self, EntityType, Operation};
use iota_logger::log; use iota_logger::log;
use rusqlite::params; use rusqlite::params;
@ -46,6 +47,7 @@ impl MessageState {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct StoredMessage { pub struct StoredMessage {
pub id: i64, pub id: i64,
pub external_user: i64,
pub message_time: i64, pub message_time: i64,
pub content: String, pub content: String,
pub edited: bool, pub edited: bool,
@ -150,7 +152,8 @@ fn update_message_content(
.unwrap() .unwrap()
.as_millis() as i64; .as_millis() as i64;
conn.execute( let tx = conn.unchecked_transaction()?;
tx.execute(
r#" r#"
INSERT INTO message_edits (message_id, content_before, content_after, edited_at, edited_by) INSERT INTO message_edits (message_id, content_before, content_after, edited_at, edited_by)
VALUES (?1, ?2, ?3, ?4, ?5) VALUES (?1, ?2, ?3, ?4, ?5)
@ -158,7 +161,7 @@ fn update_message_content(
params![msg_id, old_content, new_content, now, editor_id], params![msg_id, old_content, new_content, now, editor_id],
)?; )?;
conn.execute( tx.execute(
r#" r#"
UPDATE messages UPDATE messages
SET content = ?1, edited_count = edited_count + 1 SET content = ?1, edited_count = edited_count + 1
@ -166,6 +169,14 @@ fn update_message_content(
"#, "#,
params![new_content, msg_id], params![new_content, msg_id],
)?; )?;
sync::record_event(
&tx,
storage_owner,
EntityType::Message,
msg_id,
Operation::Upsert,
)?;
tx.commit()?;
Ok(()) Ok(())
}) })
@ -187,15 +198,24 @@ pub fn hard_delete_message(
|row| row.get(0), |row| row.get(0),
)?; )?;
conn.execute( let tx = conn.unchecked_transaction()?;
tx.execute(
"DELETE FROM message_edits WHERE message_id = ?1", "DELETE FROM message_edits WHERE message_id = ?1",
params![msg_id], params![msg_id],
)?; )?;
conn.execute( tx.execute(
"DELETE FROM reactions WHERE message_id = ?1", "DELETE FROM reactions WHERE message_id = ?1",
params![msg_id], 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(()) Ok(())
}) })
} }
@ -261,7 +281,8 @@ pub fn flag_deleted_by_external(
message_time: i64, message_time: i64,
) -> Result<(), StorageError> { ) -> Result<(), StorageError> {
db::with_db(|conn| { db::with_db(|conn| {
let affected = conn.execute( let tx = conn.unchecked_transaction()?;
let affected = tx.execute(
r#" r#"
UPDATE messages UPDATE messages
SET deleted_by_external = 1 SET deleted_by_external = 1
@ -272,6 +293,15 @@ pub fn flag_deleted_by_external(
if affected == 0 { if affected == 0 {
return Err(StorageError::Other("Message not found".into())); 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(()) Ok(())
}) })
} }
@ -297,10 +327,19 @@ pub fn delete_edit_history(
|row| row.get(0), |row| row.get(0),
)?; )?;
conn.execute( let tx = conn.unchecked_transaction()?;
tx.execute(
"DELETE FROM message_edits WHERE message_id = ?1", "DELETE FROM message_edits WHERE message_id = ?1",
params![msg_id], params![msg_id],
)?; )?;
sync::record_event(
&tx,
storage_owner,
EntityType::Message,
msg_id,
Operation::Upsert,
)?;
tx.commit()?;
Ok(()) Ok(())
}) })
} }
@ -328,13 +367,22 @@ pub fn add_reaction(
.unwrap() .unwrap()
.as_millis() as i64; .as_millis() as i64;
conn.execute( let tx = conn.unchecked_transaction()?;
tx.execute(
r#" r#"
INSERT OR IGNORE INTO reactions (message_id, user_id, reaction, created_at) INSERT OR IGNORE INTO reactions (message_id, user_id, reaction, created_at)
VALUES (?1, ?2, ?3, ?4) VALUES (?1, ?2, ?3, ?4)
"#, "#,
params![msg_id, user_id, reaction, now], params![msg_id, user_id, reaction, now],
)?; )?;
sync::record_event(
&tx,
storage_owner,
EntityType::Message,
msg_id,
Operation::Upsert,
)?;
tx.commit()?;
Ok(()) Ok(())
}) })
} }
@ -357,10 +405,19 @@ pub fn remove_reaction(
|row| row.get(0), |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", "DELETE FROM reactions WHERE message_id = ?1 AND user_id = ?2 AND reaction = ?3",
params![msg_id, user_id, reaction], params![msg_id, user_id, reaction],
)?; )?;
sync::record_event(
&tx,
storage_owner,
EntityType::Message,
msg_id,
Operation::Upsert,
)?;
tx.commit()?;
Ok(()) Ok(())
}) })
} }
@ -383,7 +440,8 @@ pub fn add_message(
}; };
if let Err(e) = db::with_db(|conn| { if let Err(e) = db::with_db(|conn| {
conn.execute( let tx = conn.unchecked_transaction()?;
tx.execute(
r#" r#"
INSERT INTO messages ( INSERT INTO messages (
storage_owner, external_user, message_time, content, storage_owner, external_user, message_time, content,
@ -405,6 +463,15 @@ pub fn add_message(
reply_to, reply_to,
], ],
)?; )?;
let msg_id = tx.last_insert_rowid();
sync::record_event(
&tx,
storage_owner,
EntityType::Message,
msg_id,
Operation::Upsert,
)?;
tx.commit()?;
Ok(()) Ok(())
}) { }) {
log!("Failed to insert message into sqlite: {}", e); log!("Failed to insert message into sqlite: {}", e);
@ -447,7 +514,8 @@ pub fn change_message_state(
.as_str() .as_str()
.to_string(); .to_string();
conn.execute( let tx = conn.unchecked_transaction()?;
tx.execute(
r#" r#"
UPDATE messages UPDATE messages
SET message_state = ?1 SET message_state = ?1
@ -459,6 +527,13 @@ pub fn change_message_state(
"#, "#,
params![upgraded, storage_owner, external_user, timestamp], 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(()) Ok(())
}) })
.map_err(|e: StorageError| std::io::Error::new(std::io::ErrorKind::Other, e.to_string())) .map_err(|e: StorageError| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))
@ -532,6 +607,7 @@ pub fn get_messages(
|row| { |row| {
Ok(StoredMessage { Ok(StoredMessage {
id: row.get(0)?, id: row.get(0)?,
external_user,
message_time: row.get(1)?, message_time: row.get(1)?,
content: row.get(2)?, content: row.get(2)?,
sent_by_self: row.get::<_, i64>(3)? != 0, 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<StoredMessage> {
if ids.is_empty() {
return Vec::new();
}
let wanted: std::collections::HashSet<i64> = 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::<Vec<_>>());
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<StoredMessage> {
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::<Result<Vec<_>, _>>()?)
}) {
Ok(ids) => ids,
Err(e) => {
log!("Failed to query all messages: {}", e);
return Vec::new();
}
};
get_messages_by_ids(storage_owner, &ids)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::MessageState; use super::MessageState;

View file

@ -1,10 +1,12 @@
use crate::users::contact::Contact; use crate::users::contact::Contact;
use crate::util::db; use crate::util::db;
use crate::util::sync::{self, EntityType, Operation};
use rusqlite::params; use rusqlite::params;
pub fn mod_user(storage_owner: i64, contact: &Contact) { pub fn mod_user(storage_owner: i64, contact: &Contact) {
if let Err(e) = db::with_db(|conn| { if let Err(e) = db::with_db(|conn| {
conn.execute( let tx = conn.unchecked_transaction()?;
tx.execute(
r#" r#"
INSERT INTO contacts (storage_owner, user_id, user_name, last_message_at) INSERT INTO contacts (storage_owner, user_id, user_name, last_message_at)
VALUES (?1, ?2, ?3, ?4) VALUES (?1, ?2, ?3, ?4)
@ -19,12 +21,31 @@ pub fn mod_user(storage_owner: i64, contact: &Contact) {
contact.last_message_at, contact.last_message_at,
], ],
)?; )?;
sync::record_event(
&tx,
storage_owner,
EntityType::Contact,
contact.user_id,
Operation::Upsert,
)?;
tx.commit()?;
Ok(()) Ok(())
}) { }) {
eprintln!("Failed to mod_user: {}", e); eprintln!("Failed to mod_user: {}", e);
} }
} }
pub fn get_users_by_ids(storage_owner: i64, ids: &[i64]) -> Vec<Contact> {
if ids.is_empty() {
return Vec::new();
}
let wanted: std::collections::HashSet<i64> = 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<Contact> { pub fn get_user(storage_owner: i64, user_id: i64) -> Option<Contact> {
match db::with_db(|conn| { match db::with_db(|conn| {
match conn.query_row( match conn.query_row(

View file

@ -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(()) Ok(())
} }
@ -289,7 +320,7 @@ mod tests {
run_migrations_on_connection(&conn)?; run_migrations_on_connection(&conn)?;
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; 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"] { for column in ["height", "reply_to", "edited_count", "deleted_by_external"] {
let mut statement = let mut statement =
conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?; conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?;
@ -298,4 +329,23 @@ mod tests {
Ok(()) 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(())
}
} }

View file

@ -5,3 +5,4 @@ pub mod config_util;
pub mod db; pub mod db;
pub mod e2ee_storage; pub mod e2ee_storage;
pub mod settings; pub mod settings;
pub mod sync;

View file

@ -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<i64>,
pub deleted_message_ids: Vec<i64>,
pub contact_upserts: Vec<i64>,
pub deleted_contact_ids: Vec<i64>,
}
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<i64, StorageError> {
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<i64, StorageError> {
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<bool, StorageError> {
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<Delta, StorageError> {
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)
})
}

89
iota-terms/src/consent.rs Normal file
View file

@ -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)
}

View file

@ -1,3 +1,4 @@
pub mod consent;
pub mod terms_getter; pub mod terms_getter;
pub use terms_getter::Type as TermsType; pub use terms_getter::Type as TermsType;

View file

@ -10,6 +10,7 @@ iota-installer = { path = "../iota-installer" }
iota-core = { path = "../iota-core" } iota-core = { path = "../iota-core" }
iota-process-manager = { path = "../iota-process-manager" } iota-process-manager = { path = "../iota-process-manager" }
iota-paths = { path = "../iota-paths" } iota-paths = { path = "../iota-paths" }
iota-terms = { path = "../iota-terms" }
tokio = { version = "1.50.0", features = ["full"] } tokio = { version = "1.50.0", features = ["full"] }
tokio-util = { version = "0.7", features = ["rt"] } tokio-util = { version = "0.7", features = ["rt"] }
serde_json = "1" serde_json = "1"

View file

@ -1,5 +1,6 @@
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum, error::ErrorKind}; use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum, error::ErrorKind};
use iota_cli::theme::ThemeName; use iota_cli::theme::ThemeName;
use iota_terms::TermsType;
#[derive(Debug)] #[derive(Debug)]
pub struct CliInvocation { pub struct CliInvocation {
@ -25,61 +26,220 @@ pub enum OutputFormat {
} }
#[derive(Debug, Clone, Copy, ValueEnum)] #[derive(Debug, Clone, Copy, ValueEnum)]
enum CliTheme { Monospace, Binary, Ansi, Surface } enum CliTheme {
Monospace,
Binary,
Ansi,
Surface,
}
impl From<CliTheme> for ThemeName { impl From<CliTheme> for ThemeName {
fn from(value: CliTheme) -> Self { 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)] #[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 { struct Cli {
#[arg(long, global = true, value_enum)] theme: Option<CliTheme>, #[arg(long, global = true, value_enum)]
#[arg(long, global = true, value_enum, default_value_t = OutputFormat::Text)] output: OutputFormat, theme: Option<CliTheme>,
#[arg(long, global = true, value_enum, default_value_t = CapabilityPolicy::Auto)] color: CapabilityPolicy, #[arg(long, global = true, value_enum, default_value_t = OutputFormat::Text)]
#[arg(long, global = true, value_enum, default_value_t = CapabilityPolicy::Auto)] unicode: CapabilityPolicy, output: OutputFormat,
#[arg(long, global = true)] no_color: bool, #[arg(long, global = true, value_enum, default_value_t = CapabilityPolicy::Auto)]
#[command(subcommand)] command: Option<CliCommand>, 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<CliCommand>,
} }
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
enum CliCommand { enum CliCommand {
Status, Tasks, Status,
Users(UsersArgs), Omikron(OmikronArgs), Identity(IdentityArgs), Daemon(DaemonArgs), Config(ConfigArgs), Tasks,
RegenerateKeys { #[arg(long)] yes: bool }, Users(UsersArgs),
Omikron(OmikronArgs),
Identity(IdentityArgs),
Daemon(DaemonArgs),
Config(ConfigArgs),
Terms(TermsArgs),
RegenerateKeys {
#[arg(long)]
yes: bool,
},
Components, Components,
Logs { #[arg(long, default_value_t = 100)] limit: usize }, Logs {
#[arg(long, default_value_t = 100)]
limit: usize,
},
Update(UpdateArgs), Update(UpdateArgs),
Community(CommunityArgs), Community(CommunityArgs),
Completions { shell: String }, Completions {
shell: String,
},
Man, Man,
} }
#[derive(Args, Debug)] struct UsersArgs { #[command(subcommand)] action: UsersAction } #[derive(Args, Debug)]
#[derive(Subcommand, Debug)] enum UsersAction { List, Show { user_id: i64 }, Add { username: String }, Remove { user_id: i64, #[arg(long)] yes: bool }, Import { username: String } } struct UsersArgs {
#[derive(Args, Debug)] struct OmikronArgs { #[command(subcommand)] action: OmikronAction } #[command(subcommand)]
#[derive(Subcommand, Debug)] enum OmikronAction { Reconnect, Status } action: UsersAction,
#[derive(Args, Debug)] struct IdentityArgs { #[command(subcommand)] action: IdentityAction } }
#[derive(Subcommand, Debug)] enum IdentityAction { Rotate { #[arg(long)] yes: bool } } #[derive(Subcommand, Debug)]
#[derive(Args, Debug)] struct ConfigArgs { #[command(subcommand)] action: ConfigAction } enum UsersAction {
#[derive(Subcommand, Debug)] enum ConfigAction { Get, Set { key: String, value: String }, Reload } List,
#[derive(Args, Debug)] struct DaemonArgs { #[command(subcommand)] action: DaemonAction } Show {
#[derive(Subcommand, Debug)] enum DaemonAction { user_id: i64,
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, Add {
Install { #[arg(long)] bundle: String, #[arg(long)] operator: Option<String> }, 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<String>,
},
}
#[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<TermsDocument> 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)] #[derive(Debug, PartialEq, Eq)]
pub enum Command { pub enum Command {
Dashboard, Dashboard,
Help, Help,
Version, Version,
Completions { shell: String }, Completions {
shell: String,
},
ManPage, ManPage,
Install { Install {
bundle: String, bundle: String,
@ -88,7 +248,9 @@ pub enum Command {
Status, Status,
Tasks, Tasks,
UsersList, UsersList,
UsersShow { user_id: i64 }, UsersShow {
user_id: i64,
},
UsersAdd { UsersAdd {
username: String, username: String,
}, },
@ -96,7 +258,9 @@ pub enum Command {
user_id: i64, user_id: i64,
confirmed: bool, confirmed: bool,
}, },
UsersImport { username: String }, UsersImport {
username: String,
},
OmikronReconnect, OmikronReconnect,
IdentityRotate { IdentityRotate {
confirmed: bool, confirmed: bool,
@ -117,16 +281,30 @@ pub enum Command {
DaemonRestartService, DaemonRestartService,
DaemonStopService, DaemonStopService,
ConfigGet, ConfigGet,
ConfigSet { key: String, value: String }, ConfigSet {
key: String,
value: String,
},
ConfigReload, ConfigReload,
OmikronStatus, OmikronStatus,
RegenerateKeys { RegenerateKeys {
confirmed: bool, confirmed: bool,
}, },
Components, Components,
Logs { limit: usize }, Logs {
limit: usize,
},
UpdateCheck, UpdateCheck,
CommunityList, CommunityList,
TermsStatus {
system: bool,
},
TermsShow {
document: TermsType,
},
TermsAccept {
system: bool,
},
} }
impl CliInvocation { impl CliInvocation {
pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self, String> { pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self, String> {
@ -134,13 +312,14 @@ impl CliInvocation {
if args.as_slice() == ["help"] { if args.as_slice() == ["help"] {
return Ok(Self::special(Command::Help)); return Ok(Self::special(Command::Help));
} }
let parsed = Cli::try_parse_from(std::iter::once("iota".to_owned()).chain(args)).map_err(|error| { let parsed =
match error.kind() { Cli::try_parse_from(std::iter::once("iota".to_owned()).chain(args)).map_err(|error| {
ErrorKind::DisplayHelp => return "__help__".to_owned(), match error.kind() {
ErrorKind::DisplayVersion => return "__version__".to_owned(), ErrorKind::DisplayHelp => return "__help__".to_owned(),
_ => error.to_string(), ErrorKind::DisplayVersion => return "__version__".to_owned(),
} _ => error.to_string(),
}); }
});
let parsed = match parsed { let parsed = match parsed {
Ok(parsed) => parsed, Ok(parsed) => parsed,
Err(marker) if marker == "__help__" => return Ok(Self::special(Command::Help)), Err(marker) if marker == "__help__" => return Ok(Self::special(Command::Help)),
@ -154,33 +333,77 @@ impl CliInvocation {
Some(CliCommand::Components) => Command::Components, Some(CliCommand::Components) => Command::Components,
Some(CliCommand::Completions { shell }) => Command::Completions { shell }, Some(CliCommand::Completions { shell }) => Command::Completions { shell },
Some(CliCommand::Man) => Command::ManPage, 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::Users(users)) => match users.action {
Some(CliCommand::Omikron(omikron)) => match omikron.action { OmikronAction::Reconnect => Command::OmikronReconnect, OmikronAction::Status => Command::OmikronStatus }, UsersAction::List => Command::UsersList,
Some(CliCommand::Identity(identity)) => match identity.action { IdentityAction::Rotate { yes } => Command::IdentityRotate { confirmed: yes } }, UsersAction::Show { user_id } => Command::UsersShow { user_id },
Some(CliCommand::Config(config)) => match config.action { ConfigAction::Get => Command::ConfigGet, ConfigAction::Set { key, value } => Command::ConfigSet { key, value }, ConfigAction::Reload => Command::ConfigReload }, 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::RegenerateKeys { yes }) => Command::RegenerateKeys { confirmed: yes },
Some(CliCommand::Logs { limit }) => Command::Logs { limit }, Some(CliCommand::Logs { limit }) => Command::Logs { limit },
Some(CliCommand::Update(update)) => match update.action { UpdateAction::Check => Command::UpdateCheck }, Some(CliCommand::Update(update)) => match update.action {
Some(CliCommand::Community(community)) => match community.action { CommunityAction::List => Command::CommunityList }, 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 { Some(CliCommand::Daemon(daemon)) => match daemon.action {
DaemonAction::Restart { yes } => Command::DaemonRestart { confirmed: yes }, DaemonAction::Stop { yes } => Command::DaemonStop { confirmed: yes }, DaemonAction::Restart { yes } => Command::DaemonRestart { confirmed: yes },
DaemonAction::Enable { mode } => Command::DaemonEnable { mode }, DaemonAction::DisableStartup => Command::DaemonDisableStartup, DaemonAction::Stop { yes } => Command::DaemonStop { confirmed: yes },
DaemonAction::Status => Command::DaemonDaemonStatus, DaemonAction::StartupStatus => Command::DaemonStartupStatus, DaemonAction::Enable { mode } => Command::DaemonEnable { mode },
DaemonAction::Start => Command::DaemonStart, DaemonAction::RestartService => Command::DaemonRestartService, DaemonAction::StopService => Command::DaemonStopService, 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 }, DaemonAction::Install { bundle, operator } => Command::Install { bundle, operator },
}, },
}; };
Ok(Self { Ok(Self {
theme_override: parsed.theme.map(Into::into), theme_override: parsed.theme.map(Into::into),
output: parsed.output, 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, unicode: parsed.unicode,
command, command,
}) })
} }
fn special(command: Command) -> Self { 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 { pub fn help_text() -> String {
@ -237,12 +460,9 @@ mod tests {
#[test] #[test]
fn parses_terminal_capability_overrides() { fn parses_terminal_capability_overrides() {
let invocation = CliInvocation::parse([ let invocation =
"--color=never".into(), CliInvocation::parse(["--color=never".into(), "--unicode".into(), "always".into()])
"--unicode".into(), .unwrap();
"always".into(),
])
.unwrap();
assert_eq!(invocation.color, CapabilityPolicy::Never); assert_eq!(invocation.color, CapabilityPolicy::Never);
assert_eq!(invocation.unicode, CapabilityPolicy::Always); assert_eq!(invocation.unicode, CapabilityPolicy::Always);
assert_eq!(invocation.command, Command::Dashboard); assert_eq!(invocation.command, Command::Dashboard);

View file

@ -65,19 +65,23 @@ pub async fn run(
endpoints: &DaemonEndpoints, endpoints: &DaemonEndpoints,
caps: Capabilities, caps: Capabilities,
) -> Result<ConnectionContext, StartupError> { ) -> Result<ConnectionContext, StartupError> {
// 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 { if let Ok(ipc) = IpcClient::connect(&endpoints.local).await {
return Ok(ConnectionContext { ipc }); 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(); let options = caps.options();
if !options.iter().any(|o| o.enabled) { if !options.iter().any(|o| o.enabled) {
let _ = show( return Err(StartupError::Other(
ui, "No running daemon could be reached, and no daemon launch method is available.".into(),
options, ));
"Daemon cannot be started. Correct the reported problem, then Retry, or Exit.",
)
.await?;
return Err(StartupError::Cancelled);
} }
if UiConfig::load() if UiConfig::load()
.map(|c| c.daemon_start_policy == iota_cli::theme::DaemonStartPolicy::WithUi) .map(|c| c.daemon_start_policy == iota_cli::theme::DaemonStartPolicy::WithUi)

View file

@ -10,6 +10,7 @@ mod cli_args;
mod daemon_setup_flow; mod daemon_setup_flow;
mod local_daemon; mod local_daemon;
mod startup_error; mod startup_error;
mod terms;
use cli_args::{CapabilityPolicy, CliInvocation, Command, OutputFormat}; use cli_args::{CapabilityPolicy, CliInvocation, Command, OutputFormat};
use startup_error::StartupError; use startup_error::StartupError;
@ -78,6 +79,9 @@ async fn run() -> Result<(), StartupError> {
print_man_page(); print_man_page();
Ok(()) 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 } => { Command::Install { bundle, operator } => {
iota_installer::install_linux_bundle_with_operator( iota_installer::install_linux_bundle_with_operator(
Path::new(&bundle), Path::new(&bundle),
@ -98,11 +102,13 @@ async fn run() -> Result<(), StartupError> {
return run_startup_command(command).await; return run_startup_command(command).await;
} }
if !matches!(command, Command::Dashboard) { if !matches!(command, Command::Dashboard) {
match iota_core::consent_state::non_interactive_consent() { let state_dir = iota_paths::IotaPaths::resolve(iota_paths::Scope::User)
iota_core::consent_state::NonInteractiveConsent::Accepted => {} .map_err(|error| {
iota_core::consent_state::NonInteractiveConsent::RequiresInteractiveAcceptance => { StartupError::Other(format!("Cannot resolve consent storage: {error}"))
return Err(StartupError::Consent("Run `iota` in an interactive terminal to review and accept the required terms.".into())); })?
} .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! { let ipc = tokio::select! {
result = connect_available(&endpoints) => result?, result = connect_available(&endpoints) => result?,
@ -242,8 +248,7 @@ async fn run_dashboard(
CapabilityPolicy::Always => true, CapabilityPolicy::Always => true,
CapabilityPolicy::Never => false, CapabilityPolicy::Never => false,
CapabilityPolicy::Auto => { CapabilityPolicy::Auto => {
std::env::var_os("NO_COLOR").is_none() std::env::var_os("NO_COLOR").is_none() && std::env::var("TERM").as_deref() != Ok("dumb")
&& std::env::var("TERM").as_deref() != Ok("dumb")
} }
}; };
let unicode_enabled = match unicode_policy { let unicode_enabled = match unicode_policy {
@ -278,6 +283,7 @@ async fn run_dashboard(
if consent != (true, true) { if consent != (true, true) {
return Err(StartupError::Consent("Cannot continue until the required terms are accepted.".into())); return Err(StartupError::Consent("Cannot continue until the required terms are accepted.".into()));
} }
persist_dashboard_consent().await?;
let initial = tokio::select! { let initial = tokio::select! {
result = connect_available(&endpoints) => result, result = connect_available(&endpoints) => result,
_ = ui.wait_for_shutdown() => Err(StartupError::Cancelled), _ = 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 { fn map_process_manager_error(error: iota_process_manager::ProcessManagerError) -> StartupError {
use iota_process_manager::ProcessManagerErrorKind::*; use iota_process_manager::ProcessManagerErrorKind::*;
match error.kind() { match error.kind() {
@ -457,11 +477,21 @@ async fn run_command(
"Refusing destructive command without --yes.".into(), "Refusing destructive command without --yes.".into(),
)); ));
} }
Command::Dashboard | Command::Help | Command::Version | Command::Completions { .. } Command::Dashboard
| Command::ManPage | Command::Install { .. } | Command::Help
| Command::DaemonEnable { .. } | Command::DaemonDisableStartup | Command::Version
| Command::DaemonStartupStatus | Command::DaemonStart | Command::Completions { .. }
| Command::DaemonRestartService | Command::DaemonStopService => { | 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( return Err(StartupError::InvalidCommand(
"Command cannot be run headlessly.".into(), "Command cannot be run headlessly.".into(),
)); ));

118
iota/src/terms.rs Normal file
View file

@ -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<std::path::PathBuf, StartupError> {
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<bool, StartupError> {
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)
}

View file

@ -889,6 +889,7 @@ impl OmikronConnection {
dispatch!(CreateApp, handle_create_app); dispatch!(CreateApp, handle_create_app);
dispatch!(DeleteApp, handle_delete_app); dispatch!(DeleteApp, handle_delete_app);
dispatch!(ClientConnected, handle_client_connected); dispatch!(ClientConnected, handle_client_connected);
dispatch!(ClientStateAck, handle_client_state_ack);
dispatch!(MessageState, handle_message_state); dispatch!(MessageState, handle_message_state);
dispatch!(MessageSend, handle_message_send); dispatch!(MessageSend, handle_message_send);
dispatch!(MessageEdit, handle_message_edit); dispatch!(MessageEdit, handle_message_edit);
@ -1166,6 +1167,12 @@ impl OmikronConnection {
.await; .await;
} }
async fn handle_client_state_ack(self: Arc<Self>, cv: &CommunicationValue) {
let _ = self
.send_message(&message_handlers::handle_client_state_ack(cv))
.await;
}
async fn handle_message_state(self: Arc<Self>, cv: &CommunicationValue) { async fn handle_message_state(self: Arc<Self>, cv: &CommunicationValue) {
message_handlers::handle_message_state(cv); message_handlers::handle_message_state(cv);
} }

View file

@ -149,6 +149,9 @@ type_maps:
MessageReactionRemove: 147 MessageReactionRemove: 147
MessageReactionLive: 148 MessageReactionLive: 148
MessageDeleteLive: 150 MessageDeleteLive: 150
ClientStateSync: 151
ClientStateAck: 152
StateSubscribe: 153
DataTypes: DataTypes:
ErrorType: 32 ErrorType: 32
ErrorProtocol: 33 ErrorProtocol: 33
@ -267,3 +270,9 @@ type_maps:
Reactions: 156 Reactions: 156
Reaction: 157 Reaction: 157
ReplyId: 158 ReplyId: 158
CacheValid: 159
CacheSchemaVersion: 160
SyncMode: 161
MessageId: 162
DeletedMessageIds: 163
DeletedContactIds: 164