caching
This commit is contained in:
parent
6a535099bb
commit
009173a97d
49 changed files with 1788 additions and 389 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
21
README.md
21
README.md
|
|
@ -28,6 +28,27 @@ theme: surface
|
||||||
|
|
||||||
An invalid `ui.yaml` value is reported and Iota falls back to ANSI so the TUI can still start.
|
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
|
||||||
|
|
|
||||||
|
|
@ -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 //
|
||||||
// ************************************************ //
|
// ************************************************ //
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,14 @@ 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(
|
||||||
|
if button.focused {
|
||||||
|
format!("› {}", button.label)
|
||||||
|
} else {
|
||||||
|
button.label.to_owned()
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
))
|
||||||
.alignment(Alignment::Center),
|
.alignment(Alignment::Center),
|
||||||
area,
|
area,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
"{}:─{}{}─{}min/{}max",
|
"{}:─{}{}─{}min/{}max",
|
||||||
self.title,
|
self.title,
|
||||||
graph.last().unwrap_or(&(0.0, 0.0)).1 as i64,
|
graph.last().unwrap_or(&(0.0, 0.0)).1 as i64,
|
||||||
unit,
|
unit,
|
||||||
min_y as i64,
|
min_y as i64,
|
||||||
max_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,7 +210,8 @@ 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) {
|
||||||
|
draw_block_joins(
|
||||||
f,
|
f,
|
||||||
r,
|
r,
|
||||||
self.borders,
|
self.borders,
|
||||||
|
|
@ -196,7 +221,8 @@ impl Element for GraphCard {
|
||||||
} else {
|
} else {
|
||||||
context.theme.borders.normal
|
context.theme.borders.normal
|
||||||
},
|
},
|
||||||
); }
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,7 +421,8 @@ 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) {
|
||||||
|
draw_block_joins(
|
||||||
f,
|
f,
|
||||||
area,
|
area,
|
||||||
self.borders,
|
self.borders,
|
||||||
|
|
@ -414,7 +432,8 @@ impl Element for LogCard {
|
||||||
} else {
|
} else {
|
||||||
context.theme.borders.normal
|
context.theme.borders.normal
|
||||||
},
|
},
|
||||||
); }
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
.iter()
|
||||||
|
.map(|c| {
|
||||||
let status_str = match c.status {
|
let status_str = match c.status {
|
||||||
iota_ipc::HealthStatus::Healthy => "healthy",
|
iota_ipc::HealthStatus::Healthy => "healthy",
|
||||||
iota_ipc::HealthStatus::Degraded => "degraded",
|
iota_ipc::HealthStatus::Degraded => "degraded",
|
||||||
iota_ipc::HealthStatus::Failed => "failed",
|
iota_ipc::HealthStatus::Failed => "failed",
|
||||||
};
|
};
|
||||||
format!("{:?}: {}", c.id, status_str)
|
format!("{:?}: {}", c.id, status_str)
|
||||||
}).collect::<Vec<_>>().join("\n")
|
})
|
||||||
|
.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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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::OpenOverview,
|
||||||
AppAction::OpenUsers,
|
AppAction::OpenUsers,
|
||||||
AppAction::OpenSettings,
|
AppAction::OpenSettings,
|
||||||
AppAction::Quit,
|
AppAction::Quit,
|
||||||
][index]);
|
][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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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![(
|
||||||
|
|
||||||
let contacts = chats_util::get_users(user_id);
|
|
||||||
let mut contacts_array = Vec::new();
|
|
||||||
|
|
||||||
for (i, contact) in contacts.iter().enumerate() {
|
|
||||||
let mut contact_container = Vec::new();
|
|
||||||
contact_container.push((
|
|
||||||
DataType::UserId,
|
DataType::UserId,
|
||||||
DataValue::SignedNumber(contact.user_id as i128),
|
DataValue::SignedNumber(contact.user_id as i128),
|
||||||
));
|
)];
|
||||||
contact_container.push((
|
if let Some(name) = &contact.user_name {
|
||||||
|
fields.push((DataType::Username, DataValue::Str(name.clone())));
|
||||||
|
}
|
||||||
|
if let Some(last_message_at) = contact.last_message_at {
|
||||||
|
fields.push((
|
||||||
DataType::LastMessageAt,
|
DataType::LastMessageAt,
|
||||||
DataValue::SignedNumber(contact.last_message_at.unwrap_or(0) as i128),
|
DataValue::SignedNumber(last_message_at as i128),
|
||||||
));
|
));
|
||||||
|
|
||||||
if let Some(ref name) = contact.user_name {
|
|
||||||
contact_container.push((DataType::Username, DataValue::Str(name.clone())));
|
|
||||||
}
|
}
|
||||||
|
typed_container(fields)
|
||||||
|
}
|
||||||
|
|
||||||
let amount = if i < 10 { 20 } else { 1 };
|
fn sync_error(cv: &CommunicationValue) -> CommunicationValue {
|
||||||
let messages = chat_files::get_messages(user_id, contact.user_id, 0, amount);
|
error_response(cv, CommunicationType::ErrorInvalidData).add_typed_default(
|
||||||
|
DataType::SessionId,
|
||||||
|
cv.get_data(DataType::SessionId).clone(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
let mut msg_array = Vec::new();
|
/// The sender is authenticated by MTP; a UserId embedded by a client is never trusted here.
|
||||||
for m in &messages {
|
pub fn handle_client_connected(cv: &CommunicationValue) -> CommunicationValue {
|
||||||
msg_array.push(stored_message_value(m, user_id, contact.user_id));
|
use iota_storage::util::sync::{self, CACHE_SCHEMA_VERSION};
|
||||||
|
let user_id = match i64::try_from(cv.get_sender()) {
|
||||||
if msg_array.len() == 1 {
|
Ok(id) if id > 0 => id,
|
||||||
let sender_id = if m.sent_by_self {
|
_ => return sync_error(cv),
|
||||||
user_id
|
|
||||||
} else {
|
|
||||||
contact.user_id
|
|
||||||
};
|
};
|
||||||
let mut last_msg = Vec::new();
|
let session_id = match data_i64(cv, DataType::SessionId) {
|
||||||
last_msg.push((DataType::Content, DataValue::Str(m.content.clone())));
|
Some(id) if id > 0 => id,
|
||||||
last_msg.push((
|
_ => return sync_error(cv),
|
||||||
DataType::SenderId,
|
};
|
||||||
DataValue::SignedNumber(sender_id as i128),
|
let reported_version = match data_i64(cv, DataType::VersionNumber) {
|
||||||
));
|
Some(version) if version >= 0 => version,
|
||||||
contact_container.push((DataType::LastMessage, typed_container(last_msg)));
|
_ => 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",
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
contact_container.push((DataType::Messages, DataValue::Array(msg_array)));
|
let all_contact_ids = chats_util::get_users(user_id)
|
||||||
contacts_array.push(typed_container(contact_container));
|
.into_iter()
|
||||||
}
|
.map(|contact| DataValue::SignedNumber(contact.user_id as i128))
|
||||||
|
.collect();
|
||||||
CommunicationValue::new(CommunicationType::ClientConnected)
|
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) {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
|
||||||
UserDetailResponse {
|
|
||||||
user_id: user.user_id,
|
user_id: user.user_id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
display_name: user.display_name,
|
display_name: user.display_name,
|
||||||
created_at: user.created_at,
|
created_at: user.created_at,
|
||||||
trusted_apps: user.trusted_apps.keys().cloned().collect(),
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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}");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(_)
|
||||||
|
|
|
||||||
|
|
@ -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"] }
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
160
iota-storage/src/util/sync.rs
Normal file
160
iota-storage/src/util/sync.rs
Normal 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
89
iota-terms/src/consent.rs
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,7 +312,8 @@ 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 =
|
||||||
|
Cli::try_parse_from(std::iter::once("iota".to_owned()).chain(args)).map_err(|error| {
|
||||||
match error.kind() {
|
match error.kind() {
|
||||||
ErrorKind::DisplayHelp => return "__help__".to_owned(),
|
ErrorKind::DisplayHelp => return "__help__".to_owned(),
|
||||||
ErrorKind::DisplayVersion => return "__version__".to_owned(),
|
ErrorKind::DisplayVersion => return "__version__".to_owned(),
|
||||||
|
|
@ -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,11 +460,8 @@ 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(),
|
|
||||||
"always".into(),
|
|
||||||
])
|
|
||||||
.unwrap();
|
.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);
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
118
iota/src/terms.rs
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue