1177 lines
43 KiB
Rust
1177 lines
43 KiB
Rust
use std::{
|
|
io,
|
|
sync::{
|
|
Arc, Mutex,
|
|
atomic::{AtomicU64, Ordering},
|
|
},
|
|
time::Instant,
|
|
};
|
|
|
|
use crossterm::event::{KeyCode, KeyModifiers};
|
|
use once_cell::sync::Lazy;
|
|
use ratatool::{
|
|
AsyncEventSource, Interaction, NotificationOverlay, Runtime, ScreenPresentation,
|
|
SessionOptions, TerminalSession, ThemeState,
|
|
};
|
|
use ratatui::layout::{Constraint, Layout, Rect};
|
|
use tokio::{
|
|
sync::{RwLock, mpsc},
|
|
task::JoinHandle,
|
|
};
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
use crate::{
|
|
controls::header::{HEADER_ITEMS, render_header},
|
|
help_overlay::HelpOverlay,
|
|
ipc_client::{DaemonStatus, IpcClient, IpcConnectionState},
|
|
screens::{
|
|
metrics::MetricsScreen,
|
|
overview::OverviewScreen,
|
|
screens::{
|
|
AppAction, AppEvent, HitMap, IotaEffect, IotaInteraction, IotaMessage, IotaScreen,
|
|
UiEvent,
|
|
},
|
|
settings::SettingsScreen,
|
|
users::{
|
|
UserEntry, UsersScreen, context::credential_status_label,
|
|
credential_export::write_private_export, model::UserAction,
|
|
},
|
|
},
|
|
theme::{self, ResolvedTheme, ThemeName},
|
|
};
|
|
|
|
pub static FPS: Lazy<RwLock<(f64, f64)>> = Lazy::new(|| RwLock::new((0.0, 0.0)));
|
|
|
|
const UI_EVENT_CAPACITY: usize = 128;
|
|
const UI_COMMAND_CAPACITY: usize = 32;
|
|
static NEXT_NOTIFICATION_ID: AtomicU64 = AtomicU64::new(1);
|
|
|
|
struct DriverThemeConfig {
|
|
selected: ThemeName,
|
|
color: theme::TerminalPolicy,
|
|
unicode: theme::TerminalPolicy,
|
|
}
|
|
|
|
pub struct DaemonViewState {
|
|
pub connection: tokio::sync::watch::Receiver<IpcConnectionState>,
|
|
pub status: tokio::sync::watch::Receiver<DaemonStatus>,
|
|
}
|
|
|
|
struct IotaScreenEntry {
|
|
screen: Box<dyn IotaScreen>,
|
|
presentation: ScreenPresentation,
|
|
}
|
|
|
|
pub struct IotaShellState {
|
|
screens: Vec<IotaScreenEntry>,
|
|
header_focus: Option<usize>,
|
|
notifications: NotificationOverlay,
|
|
daemon_view: Option<DaemonViewState>,
|
|
ipc: Option<Arc<IpcClient>>,
|
|
theme: ResolvedTheme,
|
|
shutdown_on_empty: bool,
|
|
}
|
|
|
|
pub type SharedShellState = Arc<Mutex<IotaShellState>>;
|
|
|
|
pub enum UiCommand {
|
|
Push(Box<dyn IotaScreen>, ScreenPresentation),
|
|
ReplaceRoot(Box<dyn IotaScreen>),
|
|
AttachDaemon(Arc<IpcClient>),
|
|
Notify(ratatool::Notification),
|
|
SetTheme(Box<ResolvedTheme>),
|
|
Shutdown,
|
|
Redraw,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct UiHandle {
|
|
commands: mpsc::Sender<UiCommand>,
|
|
event_tx: mpsc::Sender<IotaMessage>,
|
|
cancellation: CancellationToken,
|
|
ipc: Arc<RwLock<Option<Arc<IpcClient>>>>,
|
|
failure: Arc<Mutex<Option<String>>>,
|
|
}
|
|
|
|
pub struct TuiSession {
|
|
ui: Arc<UiHandle>,
|
|
driver: JoinHandle<io::Result<()>>,
|
|
}
|
|
|
|
pub fn start_tui(ipc: Arc<IpcClient>) -> io::Result<TuiSession> {
|
|
start_tui_with_theme(ipc, theme::resolve(ThemeName::Ansi))
|
|
}
|
|
|
|
pub fn start_tui_with_theme(
|
|
ipc: Arc<IpcClient>,
|
|
initial_theme: ResolvedTheme,
|
|
) -> io::Result<TuiSession> {
|
|
let config = theme::UiConfig::load_or_default();
|
|
start_session(Some(ipc), true, initial_theme, config.color, config.unicode)
|
|
}
|
|
|
|
pub fn start_bootstrap_tui() -> io::Result<TuiSession> {
|
|
start_bootstrap_tui_with_theme(theme::resolve(ThemeName::Ansi))
|
|
}
|
|
|
|
pub fn start_bootstrap_tui_with_theme(initial_theme: ResolvedTheme) -> io::Result<TuiSession> {
|
|
let config = theme::UiConfig::load_or_default();
|
|
start_session(None, false, initial_theme, config.color, config.unicode)
|
|
}
|
|
|
|
pub fn start_bootstrap_tui_with_config(
|
|
selected: ThemeName,
|
|
color: theme::TerminalPolicy,
|
|
unicode: theme::TerminalPolicy,
|
|
) -> io::Result<TuiSession> {
|
|
start_session(None, false, theme::resolve(selected), color, unicode)
|
|
}
|
|
|
|
fn start_session(
|
|
ipc: Option<Arc<IpcClient>>,
|
|
shutdown_on_empty: bool,
|
|
initial_theme: ResolvedTheme,
|
|
color_policy: theme::TerminalPolicy,
|
|
unicode_policy: theme::TerminalPolicy,
|
|
) -> io::Result<TuiSession> {
|
|
let session = TerminalSession::enter(SessionOptions {
|
|
mouse_capture: true,
|
|
..SessionOptions::default()
|
|
})?;
|
|
let (commands, command_rx) = mpsc::channel(UI_COMMAND_CAPACITY);
|
|
let (event_tx, event_source) = AsyncEventSource::channel(UI_EVENT_CAPACITY);
|
|
let cancellation = CancellationToken::new();
|
|
let selected_theme = initial_theme.name;
|
|
let shared_ipc = Arc::new(RwLock::new(ipc.clone()));
|
|
let failure = Arc::new(Mutex::new(None));
|
|
let shell = Arc::new(Mutex::new(IotaShellState {
|
|
screens: Vec::new(),
|
|
header_focus: None,
|
|
notifications: NotificationOverlay::default(),
|
|
daemon_view: ipc.as_ref().map(daemon_view),
|
|
ipc,
|
|
theme: initial_theme,
|
|
shutdown_on_empty,
|
|
}));
|
|
let ui = Arc::new(UiHandle {
|
|
commands: commands.clone(),
|
|
event_tx: event_tx.clone(),
|
|
cancellation: cancellation.clone(),
|
|
ipc: shared_ipc,
|
|
failure: failure.clone(),
|
|
});
|
|
let driver = tokio::spawn(async move {
|
|
let result = run_driver(
|
|
session,
|
|
shell,
|
|
command_rx,
|
|
event_source,
|
|
event_tx,
|
|
cancellation.clone(),
|
|
DriverThemeConfig {
|
|
selected: selected_theme,
|
|
color: color_policy,
|
|
unicode: unicode_policy,
|
|
},
|
|
)
|
|
.await;
|
|
if let Err(error) = &result {
|
|
if let Ok(mut slot) = failure.lock() {
|
|
*slot = Some(error.to_string());
|
|
}
|
|
cancellation.cancel();
|
|
}
|
|
result
|
|
});
|
|
Ok(TuiSession { ui, driver })
|
|
}
|
|
|
|
fn daemon_view(ipc: &Arc<IpcClient>) -> DaemonViewState {
|
|
DaemonViewState {
|
|
connection: ipc.connection_status(),
|
|
status: ipc.daemon_status(),
|
|
}
|
|
}
|
|
|
|
impl TuiSession {
|
|
pub fn ui(&self) -> Arc<UiHandle> {
|
|
self.ui.clone()
|
|
}
|
|
|
|
pub async fn shutdown(mut self) -> Option<String> {
|
|
self.ui.request_shutdown();
|
|
match tokio::time::timeout(std::time::Duration::from_secs(2), &mut self.driver).await {
|
|
Err(_) => {
|
|
self.driver.abort();
|
|
Some("UiHandle driver did not stop within 2 seconds".into())
|
|
}
|
|
Ok(Err(error)) => Some(format!("UiHandle driver task failed: {error}")),
|
|
Ok(Ok(Err(error))) => Some(error.to_string()),
|
|
Ok(Ok(Ok(()))) => self.ui.failure(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for TuiSession {
|
|
fn drop(&mut self) {
|
|
self.ui.request_shutdown();
|
|
self.driver.abort();
|
|
}
|
|
}
|
|
|
|
impl UiHandle {
|
|
pub async fn ipc(&self) -> Option<Arc<IpcClient>> {
|
|
self.ipc.read().await.clone()
|
|
}
|
|
|
|
pub async fn attach_daemon(&self, ipc: Arc<IpcClient>) {
|
|
*self.ipc.write().await = Some(ipc.clone());
|
|
let _ = self.commands.send(UiCommand::AttachDaemon(ipc)).await;
|
|
}
|
|
|
|
pub async fn set_theme(&self, theme: ResolvedTheme) {
|
|
let _ = self
|
|
.commands
|
|
.send(UiCommand::SetTheme(Box::new(theme)))
|
|
.await;
|
|
}
|
|
|
|
pub fn is_shutdown(&self) -> bool {
|
|
self.cancellation.is_cancelled()
|
|
}
|
|
|
|
pub fn request_shutdown(&self) {
|
|
self.cancellation.cancel();
|
|
let _ = self.commands.try_send(UiCommand::Shutdown);
|
|
}
|
|
|
|
pub fn invalidate(&self) {
|
|
let _ = self.event_tx.try_send(IotaMessage::Invalidate);
|
|
}
|
|
|
|
pub fn failure(&self) -> Option<String> {
|
|
self.failure.lock().ok().and_then(|failure| failure.clone())
|
|
}
|
|
|
|
pub async fn wait_for_shutdown(&self) {
|
|
self.cancellation.cancelled().await;
|
|
}
|
|
|
|
pub fn cancellation_token(&self) -> CancellationToken {
|
|
self.cancellation.clone()
|
|
}
|
|
|
|
pub async fn push_notification(&self, mut notification: ratatool::Notification) {
|
|
if notification.id == 0 {
|
|
notification.id = NEXT_NOTIFICATION_ID.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
let _ = self.commands.send(UiCommand::Notify(notification)).await;
|
|
}
|
|
|
|
pub async fn set_screen(&self, screen: Box<dyn IotaScreen>) {
|
|
let _ = self
|
|
.commands
|
|
.send(UiCommand::Push(screen, ScreenPresentation::Replace))
|
|
.await;
|
|
}
|
|
|
|
pub async fn replace_screen(&self, screen: Box<dyn IotaScreen>) {
|
|
self.set_root_screen(screen).await;
|
|
}
|
|
|
|
pub async fn set_root_screen(&self, screen: Box<dyn IotaScreen>) {
|
|
let _ = self.commands.send(UiCommand::ReplaceRoot(screen)).await;
|
|
}
|
|
|
|
pub async fn render(&self) -> io::Result<()> {
|
|
self.commands
|
|
.send(UiCommand::Redraw)
|
|
.await
|
|
.map_err(|_| io::Error::other("UiHandle driver is closed"))
|
|
}
|
|
}
|
|
|
|
struct IotaShell {
|
|
state: SharedShellState,
|
|
}
|
|
|
|
impl IotaShell {
|
|
fn new(state: SharedShellState) -> Self {
|
|
Self { state }
|
|
}
|
|
|
|
fn route_interaction(
|
|
&self,
|
|
state: &mut IotaShellState,
|
|
interaction: IotaInteraction,
|
|
) -> Interaction<IotaMessage> {
|
|
match interaction {
|
|
IotaInteraction::Unhandled => Interaction::Unhandled,
|
|
IotaInteraction::Changed => Interaction::changed(),
|
|
IotaInteraction::Push {
|
|
screen,
|
|
presentation,
|
|
} => {
|
|
state.screens.push(IotaScreenEntry {
|
|
screen,
|
|
presentation,
|
|
});
|
|
Interaction::changed()
|
|
}
|
|
IotaInteraction::Pop => {
|
|
state.screens.pop();
|
|
if state.screens.is_empty() && state.shutdown_on_empty {
|
|
Interaction::Exit
|
|
} else {
|
|
Interaction::changed()
|
|
}
|
|
}
|
|
IotaInteraction::Effect(effect) => Interaction::message(IotaMessage::Effect(effect)),
|
|
IotaInteraction::Exit => Interaction::Exit,
|
|
}
|
|
}
|
|
|
|
fn dispatch_action(
|
|
&self,
|
|
state: &mut IotaShellState,
|
|
action: AppAction,
|
|
) -> Interaction<IotaMessage> {
|
|
let interaction = match action {
|
|
AppAction::Quit => IotaInteraction::Exit,
|
|
AppAction::OpenMain => {
|
|
state.screens.truncate(1);
|
|
IotaInteraction::Changed
|
|
}
|
|
AppAction::OpenOverview => match &state.daemon_view {
|
|
Some(view) => IotaInteraction::Push {
|
|
screen: Box::new(OverviewScreen::new(
|
|
view.connection.clone(),
|
|
view.status.clone(),
|
|
)),
|
|
presentation: ScreenPresentation::Replace,
|
|
},
|
|
None => IotaInteraction::Unhandled,
|
|
},
|
|
AppAction::OpenUsers => IotaInteraction::Effect(IotaEffect::OpenUsers),
|
|
AppAction::OpenSettings => IotaInteraction::Push {
|
|
screen: Box::new(SettingsScreen::new(state.theme.name)),
|
|
presentation: ScreenPresentation::Replace,
|
|
},
|
|
AppAction::OpenMetrics => match &state.ipc {
|
|
Some(ipc) => IotaInteraction::Push {
|
|
screen: Box::new(MetricsScreen::from_state(ipc.state())),
|
|
presentation: ScreenPresentation::Replace,
|
|
},
|
|
None => IotaInteraction::Unhandled,
|
|
},
|
|
action => state
|
|
.screens
|
|
.last_mut()
|
|
.map_or(IotaInteraction::Unhandled, |entry| {
|
|
entry.screen.handle_action(action)
|
|
}),
|
|
};
|
|
self.route_interaction(state, interaction)
|
|
}
|
|
}
|
|
|
|
impl ratatool::Screen<IotaMessage> for IotaShell {
|
|
fn render(&mut self, ui: &mut ratatool::UiFrame<'_, '_, IotaMessage>, area: Rect) {
|
|
let Ok(mut state) = self.state.lock() else {
|
|
return;
|
|
};
|
|
let rows = Layout::vertical([
|
|
Constraint::Length(2),
|
|
Constraint::Min(1),
|
|
Constraint::Length(1),
|
|
])
|
|
.split(area);
|
|
let theme = state.theme.clone();
|
|
let (connection, daemon) = state
|
|
.daemon_view
|
|
.as_ref()
|
|
.map(|view| {
|
|
(
|
|
view.connection.borrow().clone(),
|
|
view.status.borrow().clone(),
|
|
)
|
|
})
|
|
.unwrap_or_else(|| (IpcConnectionState::Disconnected, DaemonStatus::default()));
|
|
|
|
let mut header_hits = HitMap::new();
|
|
render_header(
|
|
ui.frame(),
|
|
rows[0],
|
|
&connection,
|
|
&daemon,
|
|
&theme,
|
|
&mut header_hits,
|
|
state.header_focus,
|
|
);
|
|
for region in header_hits.regions() {
|
|
ui.hit_map().register(region.area, region.action.clone());
|
|
}
|
|
|
|
let first_visible = state
|
|
.screens
|
|
.iter()
|
|
.rposition(|entry| entry.presentation == ScreenPresentation::Replace)
|
|
.unwrap_or(0);
|
|
for entry in &mut state.screens[first_visible..] {
|
|
if entry.presentation == ScreenPresentation::ModalOverlay {
|
|
ui.hit_map().clear();
|
|
}
|
|
entry.screen.render(ui, rows[1], &theme);
|
|
}
|
|
|
|
let hints = if state.header_focus.is_some() {
|
|
" Left/Right: choose Enter: activate Esc/F6: screen".to_owned()
|
|
} else {
|
|
let mut hints = state
|
|
.screens
|
|
.last()
|
|
.map(|entry| entry.screen.key_hints())
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|hint| format!("{}: {}", hint.keys, hint.action))
|
|
.collect::<Vec<_>>();
|
|
if !hints.iter().any(|hint| hint.contains('?')) {
|
|
hints.push("?: Help".into());
|
|
}
|
|
format!(" {}", hints.join(" "))
|
|
};
|
|
let footer_style = ui.theme().surfaces.footer.patch(ui.theme().text.muted);
|
|
ui.frame().render_widget(
|
|
ratatui::widgets::Paragraph::new(hints).style(footer_style),
|
|
rows[2],
|
|
);
|
|
ui.notifications(rows[1], &state.notifications);
|
|
}
|
|
|
|
fn handle_event(&mut self, event: &ratatool::UiEvent<IotaMessage>) -> Interaction<IotaMessage> {
|
|
let Ok(mut state) = self.state.lock() else {
|
|
return Interaction::Unhandled;
|
|
};
|
|
match event {
|
|
ratatool::UiEvent::Message(IotaMessage::Action(action)) => {
|
|
self.dispatch_action(&mut state, *action)
|
|
}
|
|
ratatool::UiEvent::Message(IotaMessage::Effect(effect)) => {
|
|
Interaction::message(IotaMessage::Effect(effect.clone()))
|
|
}
|
|
ratatool::UiEvent::Message(IotaMessage::Event(event)) => {
|
|
let interaction =
|
|
state
|
|
.screens
|
|
.last_mut()
|
|
.map_or(IotaInteraction::Unhandled, |entry| {
|
|
entry
|
|
.screen
|
|
.handle_event(&UiEvent::Message(IotaMessage::Event(event.clone())))
|
|
});
|
|
self.route_interaction(&mut state, interaction)
|
|
}
|
|
ratatool::UiEvent::Message(IotaMessage::Invalidate) => Interaction::changed(),
|
|
ratatool::UiEvent::Tick => {
|
|
if state.notifications.expire(Instant::now()) {
|
|
Interaction::changed()
|
|
} else {
|
|
Interaction::unchanged()
|
|
}
|
|
}
|
|
ratatool::UiEvent::Key(key) => {
|
|
if key.modifiers.contains(KeyModifiers::CONTROL)
|
|
&& matches!(key.code, KeyCode::Char('q') | KeyCode::Char('c'))
|
|
{
|
|
return Interaction::Exit;
|
|
}
|
|
if key.code == KeyCode::F(6) {
|
|
state.header_focus = if state.header_focus.is_some() {
|
|
None
|
|
} else {
|
|
Some(0)
|
|
};
|
|
return Interaction::changed();
|
|
}
|
|
if key.code == KeyCode::Char('?') {
|
|
state.screens.push(IotaScreenEntry {
|
|
screen: Box::new(HelpOverlay::new()),
|
|
presentation: ScreenPresentation::ModalOverlay,
|
|
});
|
|
return Interaction::changed();
|
|
}
|
|
if let Some(index) = state.header_focus {
|
|
match key.code {
|
|
KeyCode::Left | KeyCode::BackTab => {
|
|
state.header_focus =
|
|
Some((index + HEADER_ITEMS.len() - 1) % HEADER_ITEMS.len())
|
|
}
|
|
KeyCode::Right | KeyCode::Tab => {
|
|
state.header_focus = Some((index + 1) % HEADER_ITEMS.len())
|
|
}
|
|
KeyCode::Enter | KeyCode::Char(' ') => {
|
|
let action = HEADER_ITEMS.get(index).map(|item| item.action);
|
|
state.header_focus = None;
|
|
if let Some(action) = action {
|
|
return self.dispatch_action(&mut state, action);
|
|
}
|
|
}
|
|
KeyCode::Esc => state.header_focus = None,
|
|
_ => {}
|
|
}
|
|
return Interaction::changed();
|
|
}
|
|
let interaction = state
|
|
.screens
|
|
.last_mut()
|
|
.map_or(IotaInteraction::Unhandled, |entry| {
|
|
entry.screen.handle_event(&UiEvent::Key(*key))
|
|
});
|
|
self.route_interaction(&mut state, interaction)
|
|
}
|
|
ratatool::UiEvent::Paste(text) => {
|
|
let interaction = state
|
|
.screens
|
|
.last_mut()
|
|
.map_or(IotaInteraction::Unhandled, |entry| {
|
|
entry.screen.handle_event(&UiEvent::Paste(text.clone()))
|
|
});
|
|
self.route_interaction(&mut state, interaction)
|
|
}
|
|
ratatool::UiEvent::Resize { width, height } => {
|
|
let interaction =
|
|
state
|
|
.screens
|
|
.last_mut()
|
|
.map_or(IotaInteraction::Unhandled, |entry| {
|
|
entry.screen.handle_event(&UiEvent::Resize {
|
|
width: *width,
|
|
height: *height,
|
|
})
|
|
});
|
|
self.route_interaction(&mut state, interaction)
|
|
}
|
|
ratatool::UiEvent::Pointer { event, hit } => {
|
|
let interaction =
|
|
state
|
|
.screens
|
|
.last_mut()
|
|
.map_or(IotaInteraction::Unhandled, |entry| {
|
|
entry.screen.handle_event(&UiEvent::Pointer {
|
|
event: *event,
|
|
hit: hit.clone(),
|
|
})
|
|
});
|
|
self.route_interaction(&mut state, interaction)
|
|
}
|
|
ratatool::UiEvent::Mouse(_) => Interaction::Unhandled,
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn run_driver(
|
|
mut session: TerminalSession,
|
|
shell: SharedShellState,
|
|
mut command_rx: mpsc::Receiver<UiCommand>,
|
|
mut event_source: AsyncEventSource<IotaMessage>,
|
|
event_tx: mpsc::Sender<IotaMessage>,
|
|
cancellation: CancellationToken,
|
|
theme_config: DriverThemeConfig,
|
|
) -> io::Result<()> {
|
|
let registry = theme::registry();
|
|
let profile = ratatool::detect_current(&theme::terminal_options(
|
|
theme_config.color,
|
|
theme_config.unicode,
|
|
));
|
|
let mut theme_state = ThemeState::new(theme_config.selected.ratatool_id(), profile, ®istry)
|
|
.map_err(|error| io::Error::other(error.to_string()))?;
|
|
let mut runtime = Runtime::new(IotaShell::new(shell.clone()));
|
|
|
|
while runtime.is_running() {
|
|
runtime.draw_if_due(session.terminal_mut(), &theme_state)?;
|
|
let deadline = runtime.next_redraw();
|
|
tokio::select! {
|
|
result = event_source.next(deadline) => {
|
|
let Some(event) = result? else { break };
|
|
if let Some(message) = runtime.dispatch(event) {
|
|
handle_runtime_message(message, &mut runtime, &shell, &event_tx, &mut theme_state, ®istry).await?;
|
|
}
|
|
}
|
|
command = command_rx.recv() => {
|
|
let Some(command) = command else { break };
|
|
apply_ui_command(command, &shell, &mut runtime, &event_tx, &mut theme_state, ®istry).await?;
|
|
}
|
|
_ = cancellation.cancelled() => runtime.request_exit(),
|
|
_ = ctrl_c() => runtime.request_exit(),
|
|
}
|
|
}
|
|
cancellation.cancel();
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
async fn ctrl_c() {
|
|
let _ = tokio::signal::ctrl_c().await;
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
async fn ctrl_c() {
|
|
std::future::pending::<()>().await;
|
|
}
|
|
|
|
async fn apply_ui_command(
|
|
command: UiCommand,
|
|
shell: &SharedShellState,
|
|
runtime: &mut Runtime<IotaMessage>,
|
|
event_tx: &mpsc::Sender<IotaMessage>,
|
|
theme_state: &mut ThemeState,
|
|
registry: &ratatool::ThemeRegistry,
|
|
) -> io::Result<()> {
|
|
match command {
|
|
UiCommand::Push(screen, presentation) => {
|
|
shell
|
|
.lock()
|
|
.map_err(|_| io::Error::other("shell state poisoned"))?
|
|
.screens
|
|
.push(IotaScreenEntry {
|
|
screen,
|
|
presentation,
|
|
});
|
|
runtime.request_redraw();
|
|
}
|
|
UiCommand::ReplaceRoot(screen) => {
|
|
let mut state = shell
|
|
.lock()
|
|
.map_err(|_| io::Error::other("shell state poisoned"))?;
|
|
state.screens.clear();
|
|
state.screens.push(IotaScreenEntry {
|
|
screen,
|
|
presentation: ScreenPresentation::Replace,
|
|
});
|
|
runtime.request_redraw();
|
|
}
|
|
UiCommand::AttachDaemon(ipc) => {
|
|
let mut state = shell
|
|
.lock()
|
|
.map_err(|_| io::Error::other("shell state poisoned"))?;
|
|
state.daemon_view = Some(daemon_view(&ipc));
|
|
state.ipc = Some(ipc.clone());
|
|
drop(state);
|
|
bridge_daemon_updates(ipc, event_tx.clone());
|
|
runtime.request_redraw();
|
|
}
|
|
UiCommand::Notify(notification) => {
|
|
let deadline = {
|
|
let mut state = shell
|
|
.lock()
|
|
.map_err(|_| io::Error::other("shell state poisoned"))?;
|
|
state.notifications.push(notification);
|
|
state.notifications.next_expiration()
|
|
};
|
|
if let Some(deadline) = deadline {
|
|
runtime.request_redraw_at(deadline);
|
|
}
|
|
runtime.request_redraw();
|
|
}
|
|
UiCommand::SetTheme(theme) => {
|
|
theme_state
|
|
.select_theme(theme.name.ratatool_id(), registry)
|
|
.map_err(|error| io::Error::other(error.to_string()))?;
|
|
shell
|
|
.lock()
|
|
.map_err(|_| io::Error::other("shell state poisoned"))?
|
|
.theme = *theme;
|
|
runtime.request_redraw();
|
|
}
|
|
UiCommand::Shutdown => runtime.request_exit(),
|
|
UiCommand::Redraw => runtime.request_redraw(),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn handle_runtime_message(
|
|
message: IotaMessage,
|
|
runtime: &mut Runtime<IotaMessage>,
|
|
shell: &SharedShellState,
|
|
event_tx: &mpsc::Sender<IotaMessage>,
|
|
theme_state: &mut ThemeState,
|
|
registry: &ratatool::ThemeRegistry,
|
|
) -> io::Result<()> {
|
|
match message {
|
|
IotaMessage::Action(action) => {
|
|
let interaction = {
|
|
let mut state = shell
|
|
.lock()
|
|
.map_err(|_| io::Error::other("shell state poisoned"))?;
|
|
let shell_screen = IotaShell::new(shell.clone());
|
|
shell_screen.dispatch_action(&mut state, action)
|
|
};
|
|
match interaction {
|
|
Interaction::Exit => runtime.request_exit(),
|
|
Interaction::Handled {
|
|
changed: true,
|
|
message,
|
|
} => {
|
|
runtime.request_redraw();
|
|
if let Some(message) = message {
|
|
Box::pin(handle_runtime_message(
|
|
message,
|
|
runtime,
|
|
shell,
|
|
event_tx,
|
|
theme_state,
|
|
registry,
|
|
))
|
|
.await?;
|
|
}
|
|
}
|
|
Interaction::Handled {
|
|
message: Some(message),
|
|
..
|
|
} => {
|
|
Box::pin(handle_runtime_message(
|
|
message,
|
|
runtime,
|
|
shell,
|
|
event_tx,
|
|
theme_state,
|
|
registry,
|
|
))
|
|
.await?
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
IotaMessage::Effect(effect) => {
|
|
run_effect(effect, runtime, shell, event_tx, theme_state, registry).await?
|
|
}
|
|
IotaMessage::Event(_) => {
|
|
let _ = event_tx.send(message).await;
|
|
}
|
|
IotaMessage::Invalidate => runtime.request_redraw(),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn run_effect(
|
|
effect: IotaEffect,
|
|
runtime: &mut Runtime<IotaMessage>,
|
|
shell: &SharedShellState,
|
|
event_tx: &mpsc::Sender<IotaMessage>,
|
|
theme_state: &mut ThemeState,
|
|
registry: &ratatool::ThemeRegistry,
|
|
) -> io::Result<()> {
|
|
match effect {
|
|
IotaEffect::OpenUsers => {
|
|
let ipc = shell
|
|
.lock()
|
|
.map_err(|_| io::Error::other("shell state poisoned"))?
|
|
.ipc
|
|
.clone();
|
|
if let Some(ipc) = ipc {
|
|
shell
|
|
.lock()
|
|
.map_err(|_| io::Error::other("shell state poisoned"))?
|
|
.screens
|
|
.push(IotaScreenEntry {
|
|
screen: Box::new(UsersScreen::loading(ipc.clone())),
|
|
presentation: ScreenPresentation::Replace,
|
|
});
|
|
runtime.request_redraw();
|
|
spawn_users_load(ipc, event_tx.clone());
|
|
}
|
|
}
|
|
IotaEffect::OpenMetrics => {
|
|
let ipc = shell
|
|
.lock()
|
|
.map_err(|_| io::Error::other("shell state poisoned"))?
|
|
.ipc
|
|
.clone();
|
|
if let Some(ipc) = ipc {
|
|
shell
|
|
.lock()
|
|
.map_err(|_| io::Error::other("shell state poisoned"))?
|
|
.screens
|
|
.push(IotaScreenEntry {
|
|
screen: Box::new(MetricsScreen::from_state(ipc.state())),
|
|
presentation: ScreenPresentation::Replace,
|
|
});
|
|
runtime.request_redraw();
|
|
}
|
|
}
|
|
IotaEffect::OpenOverview => {
|
|
let view = {
|
|
let state = shell
|
|
.lock()
|
|
.map_err(|_| io::Error::other("shell state poisoned"))?;
|
|
state
|
|
.daemon_view
|
|
.as_ref()
|
|
.map(|view| (view.connection.clone(), view.status.clone()))
|
|
};
|
|
if let Some((connection, status)) = view {
|
|
shell
|
|
.lock()
|
|
.map_err(|_| io::Error::other("shell state poisoned"))?
|
|
.screens
|
|
.push(IotaScreenEntry {
|
|
screen: Box::new(OverviewScreen::new(connection, status)),
|
|
presentation: ScreenPresentation::Replace,
|
|
});
|
|
runtime.request_redraw();
|
|
}
|
|
}
|
|
IotaEffect::ApplyTheme {
|
|
theme: name,
|
|
persist,
|
|
} => {
|
|
theme_state
|
|
.select_theme(name.ratatool_id(), registry)
|
|
.map_err(|error| io::Error::other(error.to_string()))?;
|
|
shell
|
|
.lock()
|
|
.map_err(|_| io::Error::other("shell state poisoned"))?
|
|
.theme = theme::resolve(name);
|
|
runtime.request_redraw();
|
|
if persist {
|
|
let tx = event_tx.clone();
|
|
tokio::spawn(async move {
|
|
let mut config = theme::UiConfig::load_or_default();
|
|
config.theme = name;
|
|
let result = config
|
|
.save()
|
|
.map_err(|error| format!("Could not save UiHandle settings: {error}"));
|
|
let _ = tx
|
|
.send(IotaMessage::Event(AppEvent::ThemeSaved(result)))
|
|
.await;
|
|
});
|
|
}
|
|
}
|
|
IotaEffect::SaveSettings {
|
|
theme: name,
|
|
color,
|
|
unicode,
|
|
cli_output,
|
|
cli_require_confirmation,
|
|
} => {
|
|
let options = theme::terminal_options(color, unicode);
|
|
let profile = ratatool::detect_current(&options);
|
|
theme_state
|
|
.select_theme(name.ratatool_id(), registry)
|
|
.map_err(|error| io::Error::other(error.to_string()))?;
|
|
theme_state
|
|
.set_profile(profile, registry)
|
|
.map_err(|error| io::Error::other(error.to_string()))?;
|
|
shell
|
|
.lock()
|
|
.map_err(|_| io::Error::other("shell state poisoned"))?
|
|
.theme = theme::resolve_with_capabilities(
|
|
name,
|
|
!matches!(profile.color_depth, ratatool::ColorDepth::None),
|
|
matches!(profile.charset, ratatool::Charset::Unicode),
|
|
);
|
|
runtime.request_redraw();
|
|
let tx = event_tx.clone();
|
|
tokio::spawn(async move {
|
|
let mut config = theme::UiConfig::load_or_default();
|
|
config.theme = name;
|
|
config.color = color;
|
|
config.unicode = unicode;
|
|
config.cli_output = cli_output;
|
|
config.cli_require_confirmation = cli_require_confirmation;
|
|
let result = config
|
|
.save()
|
|
.map_err(|error| format!("Could not save UiHandle settings: {error}"));
|
|
let _ = tx
|
|
.send(IotaMessage::Event(AppEvent::ThemeSaved(result)))
|
|
.await;
|
|
});
|
|
}
|
|
other => spawn_ipc_effect(other, shell, event_tx.clone())?,
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn spawn_users_load(ipc: Arc<IpcClient>, tx: mpsc::Sender<IotaMessage>) {
|
|
tokio::spawn(async move {
|
|
let result = list_users(&ipc).await;
|
|
let _ = tx
|
|
.send(IotaMessage::Event(AppEvent::UsersLoaded(result)))
|
|
.await;
|
|
});
|
|
}
|
|
|
|
async fn list_users(ipc: &IpcClient) -> Result<Vec<UserEntry>, String> {
|
|
match ipc.send_request(iota_ipc::LocalRequest::ListUsers).await {
|
|
Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::Users(users))) => Ok(users
|
|
.into_iter()
|
|
.map(|user| UserEntry {
|
|
user_id: user.user_id,
|
|
username: user.username,
|
|
state: user.state,
|
|
data_present: user.data_present,
|
|
credential_status: user.credential_status,
|
|
pending_operation: user.pending_operation,
|
|
})
|
|
.collect()),
|
|
Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot load users: {error}")),
|
|
Ok(_) => Err("Daemon returned an unexpected response while loading users.".into()),
|
|
Err(error) => Err(format!("Cannot load users: {error}")),
|
|
}
|
|
}
|
|
|
|
fn spawn_ipc_effect(
|
|
effect: IotaEffect,
|
|
shell: &SharedShellState,
|
|
tx: mpsc::Sender<IotaMessage>,
|
|
) -> io::Result<()> {
|
|
let ipc = shell
|
|
.lock()
|
|
.map_err(|_| io::Error::other("shell state poisoned"))?
|
|
.ipc
|
|
.clone();
|
|
let Some(ipc) = ipc else { return Ok(()) };
|
|
tokio::spawn(async move {
|
|
let event = match effect {
|
|
IotaEffect::RegenerateKeys => {
|
|
let result = match ipc
|
|
.send_request(iota_ipc::LocalRequest::RotateIotaIdentity)
|
|
.await
|
|
{
|
|
Ok(iota_ipc::ResponseResult::Ok(_)) => Ok(()),
|
|
Ok(iota_ipc::ResponseResult::Error(error)) => {
|
|
Err(format!("Cannot regenerate keys: {error}"))
|
|
}
|
|
Err(error) => Err(format!("Cannot regenerate keys: {error}")),
|
|
};
|
|
AppEvent::KeysRegenerated(result)
|
|
}
|
|
IotaEffect::RefreshUsers => AppEvent::UsersLoaded(list_users(&ipc).await),
|
|
IotaEffect::LoadNewestTerms { kind } => {
|
|
let result = iota_terms::get_terms(kind)
|
|
.await
|
|
.ok_or_else(|| "Could not fetch the legal document.".to_owned());
|
|
AppEvent::TermsLoaded { kind, result }
|
|
}
|
|
IotaEffect::InspectTu { credential } => {
|
|
let result = match ipc
|
|
.send_request(iota_ipc::LocalRequest::InspectTuCredential { credential })
|
|
.await
|
|
{
|
|
Ok(iota_ipc::ResponseResult::Ok(
|
|
iota_ipc::ResponsePayload::TuCredentialPreview(preview),
|
|
)) => Ok(preview),
|
|
Ok(iota_ipc::ResponseResult::Error(error)) => {
|
|
Err(format!("Credential inspection failed: {error}"))
|
|
}
|
|
Ok(_) => Err(
|
|
"Credential inspection failed: daemon returned an unexpected response."
|
|
.into(),
|
|
),
|
|
Err(error) => Err(format!("Credential inspection failed: {error}")),
|
|
};
|
|
AppEvent::TuInspected(result)
|
|
}
|
|
IotaEffect::AttachUserFromTu { credential } => {
|
|
let result = match ipc
|
|
.send_request(iota_ipc::LocalRequest::AttachUserFromTu { credential })
|
|
.await
|
|
{
|
|
Ok(iota_ipc::ResponseResult::Ok(_)) => Ok("Attached existing user.".into()),
|
|
Ok(iota_ipc::ResponseResult::Error(error)) => {
|
|
Err(format!("Attach existing user failed: {error}"))
|
|
}
|
|
Err(error) => Err(format!("Attach existing user failed: {error}")),
|
|
};
|
|
AppEvent::UserOperationFinished(result)
|
|
}
|
|
IotaEffect::RunUserOperation { action, user_id } => {
|
|
let response = match action {
|
|
UserAction::Release => {
|
|
ipc.send_request(iota_ipc::LocalRequest::ReleaseUser { user_id })
|
|
.await
|
|
}
|
|
UserAction::PurgeData => {
|
|
ipc.send_request(iota_ipc::LocalRequest::PurgeUserData { user_id })
|
|
.await
|
|
}
|
|
UserAction::ExportTu => {
|
|
let event = AppEvent::UserOperationFinished(Err(
|
|
"Credential export requires a destination path.".into(),
|
|
));
|
|
let _ = tx.send(IotaMessage::Event(event)).await;
|
|
return;
|
|
}
|
|
UserAction::DeleteAccount => {
|
|
ipc.send_request(iota_ipc::LocalRequest::CompleteDeleteUser {
|
|
user_id,
|
|
credential: None,
|
|
})
|
|
.await
|
|
}
|
|
UserAction::Reconcile => {
|
|
ipc.send_request(iota_ipc::LocalRequest::ReconcileUser { user_id })
|
|
.await
|
|
}
|
|
UserAction::Diagnostics => {
|
|
ipc.send_request(iota_ipc::LocalRequest::GetUserDiagnostics { user_id })
|
|
.await
|
|
}
|
|
UserAction::ForceDetach => {
|
|
ipc.send_request(iota_ipc::LocalRequest::ForceDetachUser { user_id })
|
|
.await
|
|
}
|
|
UserAction::ForgetResidency => {
|
|
ipc.send_request(iota_ipc::LocalRequest::ForgetReleasedUser { user_id })
|
|
.await
|
|
}
|
|
};
|
|
let result = match response {
|
|
Ok(iota_ipc::ResponseResult::Ok(
|
|
iota_ipc::ResponsePayload::UserDiagnostics(diagnostics),
|
|
)) => Ok(format!(
|
|
"Diagnostics: state={:?}, data={}, credential={}, trusted apps={}, pending={}",
|
|
diagnostics.local_state,
|
|
if diagnostics.data_present {
|
|
"present"
|
|
} else {
|
|
"empty"
|
|
},
|
|
credential_status_label(diagnostics.credential_status),
|
|
diagnostics.trusted_app_count,
|
|
diagnostics
|
|
.pending_operation
|
|
.unwrap_or_else(|| "none".into()),
|
|
)),
|
|
Ok(iota_ipc::ResponseResult::Ok(
|
|
iota_ipc::ResponsePayload::UserReconciled(result),
|
|
)) => Ok(format!("Reconciliation result: {:?}.", result.action)),
|
|
Ok(iota_ipc::ResponseResult::Ok(_)) => {
|
|
Ok(format!("{} completed.", action.label()))
|
|
}
|
|
Ok(iota_ipc::ResponseResult::Error(error)) => {
|
|
Err(format!("{} failed: {error}", action.label()))
|
|
}
|
|
Err(error) => Err(format!("{} failed: {error}", action.label())),
|
|
};
|
|
AppEvent::UserOperationFinished(result)
|
|
}
|
|
IotaEffect::ExportCredential {
|
|
user_id,
|
|
destination,
|
|
} => {
|
|
let result = match ipc
|
|
.send_request(iota_ipc::LocalRequest::ExportUserCredential { user_id })
|
|
.await
|
|
{
|
|
Ok(iota_ipc::ResponseResult::Ok(
|
|
iota_ipc::ResponsePayload::UserCredentialExport {
|
|
user_id: response_user_id,
|
|
username,
|
|
credential,
|
|
},
|
|
)) if response_user_id == user_id => {
|
|
write_private_export(&destination, credential.0.as_bytes())
|
|
.map(|()| {
|
|
format!(
|
|
"Exported credential for {username} to {}.",
|
|
destination.display()
|
|
)
|
|
})
|
|
.map_err(|error| {
|
|
format!(
|
|
"Credential export failed for {}: {error}",
|
|
destination.display()
|
|
)
|
|
})
|
|
}
|
|
Ok(iota_ipc::ResponseResult::Ok(_)) => Err(
|
|
"Credential export failed: daemon returned an unexpected response.".into(),
|
|
),
|
|
Ok(iota_ipc::ResponseResult::Error(error)) => {
|
|
Err(format!("Credential export failed: {error}"))
|
|
}
|
|
Err(error) => Err(format!("Credential export failed: {error}")),
|
|
};
|
|
AppEvent::CredentialExportFinished(result)
|
|
}
|
|
IotaEffect::CreateUser { username } => {
|
|
let result = match ipc
|
|
.send_request(iota_ipc::LocalRequest::CreateUser { username })
|
|
.await
|
|
{
|
|
Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserCreated {
|
|
username,
|
|
user_id,
|
|
})) => Ok(format!("Created user {username} ({user_id}).")),
|
|
Ok(iota_ipc::ResponseResult::Error(error)) => {
|
|
Err(format!("Create user failed: {error}"))
|
|
}
|
|
Ok(_) => {
|
|
Err("Create user failed: daemon returned an unexpected response.".into())
|
|
}
|
|
Err(error) => Err(format!("Create user failed: {error}")),
|
|
};
|
|
AppEvent::UserOperationFinished(result)
|
|
}
|
|
IotaEffect::CreateInvitation { authority } => {
|
|
let result = match ipc
|
|
.send_request(iota_ipc::LocalRequest::CreateInvitation {
|
|
authority,
|
|
lifetime_seconds: 7 * 24 * 60 * 60,
|
|
password: None,
|
|
label: None,
|
|
})
|
|
.await
|
|
{
|
|
Ok(iota_ipc::ResponseResult::Ok(
|
|
iota_ipc::ResponsePayload::InvitationCreated(invitation),
|
|
)) => Ok(format!(
|
|
"Invitation {} created. Token: {} Link: {}{}",
|
|
invitation.invitation_id,
|
|
invitation.raw_token.0,
|
|
invitation.short_url.as_deref().unwrap_or("unavailable"),
|
|
if invitation.mirror_synced {
|
|
""
|
|
} else {
|
|
" Local metadata is still synchronizing."
|
|
},
|
|
)),
|
|
Ok(iota_ipc::ResponseResult::Error(error)) => {
|
|
Err(format!("Create invitation failed: {error}"))
|
|
}
|
|
Ok(_) => Err(
|
|
"Create invitation failed: daemon returned an unexpected response.".into(),
|
|
),
|
|
Err(error) => Err(format!("Create invitation failed: {error}")),
|
|
};
|
|
AppEvent::UserOperationFinished(result)
|
|
}
|
|
IotaEffect::OpenUsers
|
|
| IotaEffect::OpenMetrics
|
|
| IotaEffect::OpenOverview
|
|
| IotaEffect::ApplyTheme { .. }
|
|
| IotaEffect::SaveSettings { .. } => return,
|
|
};
|
|
let _ = tx.send(IotaMessage::Event(event)).await;
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
fn bridge_daemon_updates(ipc: Arc<IpcClient>, tx: mpsc::Sender<IotaMessage>) {
|
|
let mut connection = ipc.connection_status();
|
|
let mut status = ipc.daemon_status();
|
|
let mut updates = ipc.ui_updates();
|
|
tokio::spawn(async move {
|
|
loop {
|
|
let changed = tokio::select! {
|
|
result = connection.changed() => result,
|
|
result = status.changed() => result,
|
|
result = updates.changed() => result,
|
|
};
|
|
if changed.is_err() || tx.send(IotaMessage::Invalidate).await.is_err() {
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
}
|