Compare commits

..
232 changed files with 7152 additions and 40515 deletions

View file

@ -1,2 +0,0 @@
[env]
MTP_TYPE_MAPS = { value = "mtp-type-maps/type-maps.yaml", relative = true }

View file

@ -1,130 +0,0 @@
name: Build & Publish Release
env:
NIX_CONFIG: experimental-features = nix-command flakes
on:
workflow_dispatch:
inputs:
release_type:
description: "Release type: 'dev' or 'stable'"
required: true
default: "dev"
type: choice
options:
- dev
- stable
description:
description: "Release description"
required: true
type: string
jobs:
build:
name: Build & Publish Release
runs-on: host
steps:
- name: Set up repository
uses: actions/checkout@v4
- name: Login to Docker Hub
env:
DOCKER_USER: ${{ secrets.DOCKER_USER }}
DOCKER_PASSWD: ${{ secrets.DOCKER_PASSWD }}
run: |
set -eu
DOCKER_USER="$(printf '%s' "$DOCKER_USER" | tr -d '\r\n')"
DOCKER_PASSWD="$(printf '%s' "$DOCKER_PASSWD" | tr -d '\r\n')"
printf '%s' "$DOCKER_PASSWD" | nix-shell -p docker --run "docker login docker.io --username \"$DOCKER_USER\" --password-stdin"
- name: Build & Push Docker image
run: |
nix-shell -p docker --run "docker build -f dockerfile -t tensamin/iota:latest . && docker push tensamin/iota:latest"
- name: Build release binaries
run: |
set -eu
nix build .#iota-daemon --print-build-logs
install -Dm755 result/bin/iota-daemon dist/iota-daemon
nix build .#iota-ui --print-build-logs
install -Dm755 result/bin/iota-ui dist/iota-ui
- name: Read release metadata
id: version
env:
RELEASE_TYPE: ${{ inputs.release_type }}
run: |
set -eu
VERSION="$(nix eval --raw .#iota-daemon.version)"
SHORT_SHA="$(git rev-parse --short=7 HEAD)"
case "$RELEASE_TYPE" in
dev)
TAG="${VERSION}-dev-${SHORT_SHA}"
PRERELEASE="true"
;;
stable)
TAG="$VERSION"
PRERELEASE="false"
;;
*)
echo "release_type must be either 'dev' or 'stable'"
exit 1
;;
esac
echo "version=$VERSION" >> "$FORGEJO_OUTPUT"
echo "tag=$TAG" >> "$FORGEJO_OUTPUT"
echo "title=$TAG" >> "$FORGEJO_OUTPUT"
echo "prerelease=$PRERELEASE" >> "$FORGEJO_OUTPUT"
test -x "dist/iota-daemon"
test -x "dist/iota-ui"
- name: Create release and upload binaries
env:
TOKEN: ${{ forgejo.token }}
API: ${{ forgejo.api_url }}
REPO: ${{ forgejo.repository }}
SHA: ${{ forgejo.sha }}
TAG: ${{ steps.version.outputs.tag }}
TITLE: ${{ steps.version.outputs.title }}
PRERELEASE: ${{ steps.version.outputs.prerelease }}
DESCRIPTION: ${{ inputs.description }}
run: |
nix-shell -p curl jq --run '
set -eu
HTTP_STATUS=$(curl -s -w "%{http_code}" -o release_out.json \
-H "Authorization: token $TOKEN" \
"$API/repos/$REPO/releases/tags/$TAG")
if [ "$HTTP_STATUS" = "200" ]; then
echo "Release $TAG already exists."
RELEASE_ID="$(jq -r .id release_out.json)"
else
echo "Creating release for $TAG"
RELEASE_JSON="$(curl -f -sS -X POST "$API/repos/$REPO/releases" \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg tag "$TAG" \
--arg name "$TITLE" \
--arg body "$DESCRIPTION" \
--arg target "$SHA" \
--argjson prerelease "$PRERELEASE" \
'"'"'{ tag_name: $tag, name: $name, body: $body, target_commitish: $target, draft: false, prerelease: $prerelease }'"'"')")"
RELEASE_ID="$(echo "$RELEASE_JSON" | jq -r .id)"
fi
curl -fsS -X POST "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=iota-daemon" \
-H "Authorization: token $TOKEN" \
-F "attachment=@dist/iota-daemon"
curl -fsS -X POST "$API/repos/$REPO/releases/$RELEASE_ID/assets?name=iota-ui" \
-H "Authorization: token $TOKEN" \
-F "attachment=@dist/iota-ui"
'

23
.github/workflows/build.yml vendored Normal file
View file

@ -0,0 +1,23 @@
name: Build & Publish Docker Image
on:
workflow_dispatch:
jobs:
build:
name: Build & Publish Docker Image
runs-on: ubuntu-latest
steps:
- name: Set up repository
uses: actions/checkout@v4
- name: Login
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASSWD }}
- name: Build & Push
run: |
docker build -t tensamin/iota:latest .
docker push tensamin/iota:latest

7
.gitignore vendored
View file

@ -1,9 +1,2 @@
target
logs
agreements
languages/
.envrc
.direnv
config.json
*.mk
*.sqlite*

3
.gitmodules vendored
View file

@ -1,3 +0,0 @@
[submodule "mtp-type-maps"]
path = mtp-type-maps
url = ssh://git@git.methanium.net/tensamin/mtp-type-maps

3020
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,26 +1,56 @@
[workspace]
members = [
"iota-storage",
"iota-connection",
[package]
name = "iota"
version = "0.1.0"
edition = "2024"
[dependencies]
ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" }
ttp-native = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-native" }
actix-web = { version = "4", features = ["rustls-0_23"] }
actix-web-actors = "4"
aes-gcm = "0.10.3"
base64 = "0.22.1"
crossterm = "*"
futures = "*"
futures-util = "*"
hex = "*"
hkdf = "0.12.4"
hyper-util = { version = "*" }
hyper = { version = "1.8.1", features = [
"capi",
"client",
"iota-auth",
"other-iota",
"iota-updater",
"iota-terms",
"iota-state",
"iota-cli",
"iota",
"iota-daemon",
"iota-daemon-lib",
"iota-ipc",
"omikron-connector",
"web-server",
"web-ui",
"iota-logger",
"iota-util",
"iota-process-manager",
"iota-paths",
"iota-installer",
"iota-core",
]
resolver = "3"
"full",
"http1",
"http2",
"nightly",
"server",
] }
json = "*"
once_cell = "1.21.3"
rand = "0.8"
rand_core = { version = "0.6", features = ["getrandom", "std"] }
reqwest = "0.13.2"
rustls = { version = "0.23.37", features = ["aws-lc-rs"] }
sha2 = "0.10.9"
sysinfo = "0.38.3"
tokio = { version = "1.50.0", features = ["full"] }
tokio-tungstenite = { version = "*", features = ["native-tls"] }
tungstenite = "*"
uuid = { version = "*", features = ["v4"] }
walkdir = "2.5.0"
warp = "*"
x448 = { version = "*" }
rustls-pemfile = "2.2.0"
async-trait = "0.1.89"
zip = "6.0.0"
pnet = "0.35.0"
dashmap = "6.1.0"
strum = "0.27.2"
strum_macros = "0.27.2"
ratatui = "0.30.0"
open = "5.3.3"
chrono = "0.4.43"
serde_json = "1.0.149"
rusqlite = "0.39.0"
lazy_static = "1.5.0"

25
LICENSE
View file

@ -1,15 +1,16 @@
Copyright (c) 2025 Methanium
Copyright (c) [2025] [Methanium]
All rights reserved.
No part of this software, source code, documentation, or
associated materials may be copied, reproduced, modified,
distributed, published, sublicensed, sold, or used to create
derivative works without prior written permission from the
copyright holder.
This software is protected by copyright. Copying, editing,
distributing, publicly performing, or any other use of this software
or its components, in source or binary form, is strictly prohibited without the express
written permission of the copyright holder.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY
OF ANY KIND, EXPRESS OR IMPLIED. TO THE MAXIMUM
EXTENT PERMITTED BY LAW, THE COPYRIGHT HOLDER SHALL
NOT BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
LIABILITY ARISING FROM THE SOFTWARE OR ITS USE.
FUTURE LICENSE ACCEPTANCE:
It is the copyright holder's intention to release this software in the future
under a license yet to be defined, which will, among other things,
allow private, non-commercial use. This statement does not constitute
a current license grant and does not alter the above
prohibition on use, copying, or modification. Until the formal
publication of such a future license, all rights remain
reserved.

View file

@ -4,65 +4,3 @@ A lightweight, Rust-based TUI and service orchestrator for Tensamin IOTA.
Iota manages users and stores their messages and communities. It can be run in a centralised, decentralised or hybrid mode.
The Iota is a work in progress.
## Terminal themes
The TUI defaults to the ANSI theme. Select a theme for one invocation with `--theme`:
```text
iota --theme monospace
iota --theme binary status
```
The available names are `monospace`, `binary`, `ansi`, and `surface`. Theme selection uses this precedence: `--theme`, `IOTA_THEME`, then `ui.yaml` in Iota's configuration directory. For example:
```text
IOTA_THEME=surface iota
```
On Linux, the configuration file defaults to `~/.config/iota/ui.yaml` (or `$XDG_CONFIG_HOME/iota/ui.yaml` when set):
```yaml
theme: surface
```
An invalid `ui.yaml` value is reported and Iota falls back to ANSI so the TUI can still start.
## Accepting terms without the TUI
Iota services do not start until the required agreements have been accepted for
the deployment. Use the terminal flow to read each current document and type
the document-specific acceptance phrase:
```text
iota terms accept
```
For a system-managed daemon, accept its deployment-scoped terms as an account
that can write the system Iota state directory (normally via `sudo`):
```text
sudo iota terms accept --system
```
`iota terms status` reports the stored state, and `iota terms show eula`,
`iota terms show tos`, or `iota terms show privacy` displays an individual
document without accepting it.
# Linux daemon installation
The system-managed daemon runs as the dedicated `iota` account and listens on
`/run/iota/iota.sock` through socket activation. The system IPC socket is the
privilege boundary. Operator access is granted through the `iota-operators`
group, and every account admitted through that socket is authorized for the
full operator-console role, including user management, identity rotation,
configuration, and daemon lifecycle commands. After installing, add an
account with:
```text
usermod -aG iota-operators USER
```
The user must start a new login session before supplementary group membership
is visible. Unix per-user deployments must set `IOTA_SOCKET` to an absolute
path; Iota does not derive its IPC socket from `XDG_RUNTIME_DIR`.

View file

@ -1,14 +0,0 @@
[package]
name = "client"
version = "0.1.0"
edition = "2024"
[dependencies]
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = ["client", "crypto"] }
iota-connection = { path = "../iota-connection" }
iota-logger = { path = "../iota-logger" }
iota-util = { path = "../iota-util" }
iota-storage = { path = "../iota-storage" }
dashmap = "6.1.0"
tokio = { version = "1.50.0", features = ["full"] }
uuid = { version = "*", features = ["v4"] }

View file

@ -1,497 +0,0 @@
use dashmap::DashMap;
use iota_connection::message_common::*;
use iota_connection::message_handlers;
use iota_connection::relay::message_security_class;
use iota_logger::{log_cv_in, log_cv_out, log_t};
use iota_storage::util::config_util::CONFIG;
use iota_util::crypto_helper::keyring_from_base64;
use iota_util::crypto_util::{self};
use mtp::client::{Receiver, Sender};
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
use mtp::crypto::Keyring;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{Mutex, RwLock, mpsc, watch};
use tokio::task::JoinHandle;
use uuid::Uuid;
// ============================================================================
// Waiting Task System
// ============================================================================
#[allow(dead_code)]
pub struct ClientConnection {
sender: Arc<RwLock<Option<Arc<Sender>>>>,
receiver: Receiver,
connection_loop_handle: Arc<Mutex<Option<JoinHandle<()>>>>,
pub connection_id: Uuid,
shutdown_tx: Arc<Mutex<Option<watch::Sender<bool>>>>,
pub waiting_tasks:
DashMap<u32, Box<dyn Fn(Arc<ClientConnection>, CommunicationValue) -> bool + Send + Sync>>,
shutdown: Arc<RwLock<bool>>,
keyring: Arc<RwLock<Option<Arc<Keyring>>>>,
}
impl ClientConnection {
pub fn new(
sender: Arc<RwLock<Option<Arc<Sender>>>>,
receiver: Receiver,
connection_loop_handle: Arc<Mutex<Option<JoinHandle<()>>>>,
connection_id: Uuid,
shutdown_tx: Arc<Mutex<Option<watch::Sender<bool>>>>,
waiting_tasks: DashMap<
u32,
Box<dyn Fn(Arc<ClientConnection>, CommunicationValue) -> bool + Send + Sync>,
>,
shutdown: Arc<RwLock<bool>>,
) -> Self {
Self {
sender,
receiver,
connection_loop_handle,
connection_id,
shutdown_tx,
waiting_tasks,
shutdown,
keyring: Arc::new(RwLock::new(None)),
}
}
pub async fn set_keyring(&self, keyring: Arc<Keyring>) {
*self.keyring.write().await = Some(keyring);
}
async fn local_keyring(&self) -> Result<Arc<Keyring>, String> {
if let Some(keyring) = self.keyring.read().await.as_ref().cloned() {
return Ok(keyring);
}
let keyring_data = CONFIG
.load()
.keyring
.clone()
.ok_or_else(|| "Iota keyring is not configured".to_string())?;
let keyring = keyring_from_base64(&keyring_data)
.ok_or_else(|| "Iota keyring is invalid".to_string())?;
let keyring = Arc::new(keyring);
*self.keyring.write().await = Some(keyring.clone());
Ok(keyring)
}
pub fn start(self: Arc<Self>) {
let self_clone = self.clone();
tokio::spawn(async move {
while let Ok(cv) = self_clone.receiver.receive().await {
if *self_clone.shutdown.read().await {
return;
}
self.clone().handle_message(cv).await;
if !self_clone.receiver.is_open() {
break;
}
}
// Handle Close
});
}
pub async fn stop(&self) {
if let Some(tx) = self.shutdown_tx.lock().await.take() {
let _ = tx.send(true);
}
if let Some(handle) = self.connection_loop_handle.lock().await.take() {
handle.abort();
}
if let Some(sender) = self.sender.read().await.as_ref() {
sender.close().await;
}
*self.sender.write().await = None;
}
// -------------------------------------------------------------------------
// Message Handling
// -------------------------------------------------------------------------
pub async fn handle_message(self: Arc<Self>, cv: CommunicationValue) {
log_cv_in!(&cv);
if cv.is_type(CommunicationType::Relay) {
log_t!(
"relay_from_client_rejected",
"legacy client path has no Relay router".to_string()
);
let _ = self
.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData))
.await;
return;
}
if matches!(
message_security_class(&cv),
iota_connection::relay::MessageSecurityClass::RelayOnly
) {
let _ = self
.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData))
.await;
return;
}
if cv.require_id().is_err() {
self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData))
.await;
return;
}
if cv.is_type(CommunicationType::Challenge) {
self.handle_challenge(&cv).await;
return;
}
if cv.is_type(CommunicationType::GetChatSecret) {
self.send_message(&message_handlers::handle_get_chat_secret(&cv))
.await;
return;
}
if cv.is_type(CommunicationType::SaveAppData) {
let sender_id = match cv.require_sender() {
Ok(sender_id) => sender_id,
Err(_) => {
self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData))
.await;
return;
}
};
let _app_data = cv
.get_data(DataType::AppData)
.as_str()
.unwrap_or("")
.to_string();
let res = CommunicationValue::new(CommunicationType::SaveAppData)
.with_request_id(&cv)
.with_receiver(sender_id);
self.send_message(&res).await;
return;
}
if cv.is_type(CommunicationType::LoadAppData) {
let sender_id = match cv.require_sender() {
Ok(sender_id) => sender_id,
Err(_) => {
self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData))
.await;
return;
}
};
let app_data = String::new();
let res = CommunicationValue::new(CommunicationType::LoadAppData)
.with_request_id(&cv)
.with_receiver(sender_id)
.add_typed_default(DataType::AppData, DataValue::Str(app_data));
self.send_message(&res).await;
return;
}
if cv.is_type(CommunicationType::CreateApp) {
self.send_message(&message_handlers::handle_create_app(&cv))
.await;
return;
}
if cv.is_type(CommunicationType::DeleteApp) {
self.send_message(&message_handlers::handle_delete_app(&cv))
.await;
return;
}
if cv.is_type(CommunicationType::ClientConnected) {
self.send_message(&message_handlers::handle_client_connected(&cv))
.await;
return;
}
if cv.is_type(CommunicationType::ClientStateAck) {
self.send_message(&message_handlers::handle_client_state_ack(&cv))
.await;
return;
}
// ************************************************ //
// Direct messages //
// ************************************************ //
if cv.is_type(CommunicationType::MessageEdit) {
self.send_message(&message_handlers::handle_message_edit(&cv))
.await;
return;
}
if cv.is_type(CommunicationType::MessageReactionAdd) {
self.send_message(&message_handlers::handle_message_reaction(&cv, true))
.await;
return;
}
if cv.is_type(CommunicationType::MessageReactionRemove) {
self.send_message(&message_handlers::handle_message_reaction(&cv, false))
.await;
return;
}
if cv.is_type(CommunicationType::MessageDeleteLive) {
self.send_message(&message_handlers::handle_message_delete(&cv))
.await;
return;
}
// Incoming storsed message: store for the recipient, attempt local delivery, notify sender.
if cv.is_type(CommunicationType::MessageSend) {
self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData))
.await;
return;
}
if cv.is_type(CommunicationType::MessagesGet) {
self.send_message(&message_handlers::handle_messages_get(&cv))
.await;
return;
}
if cv.is_type(CommunicationType::MessageGet) {
self.send_message(&message_handlers::handle_message_get(&cv))
.await;
return;
}
if cv.is_type(CommunicationType::GetChats) {
self.send_message(&message_handlers::handle_get_chats(&cv))
.await;
return;
}
if cv.is_type(CommunicationType::AddCommunity) {
self.send_message(&message_handlers::handle_add_community(&cv))
.await;
return;
}
if cv.is_type(CommunicationType::GetCommunities) {
self.send_message(&message_handlers::handle_get_communities(&cv))
.await;
return;
}
if cv.is_type(CommunicationType::RemoveCommunity) {
self.send_message(&message_handlers::handle_remove_community(&cv))
.await;
return;
}
if cv.is_type(CommunicationType::SettingsSave) {
let my_id = match cv.require_sender() {
Ok(my_id) => my_id,
Err(_) => {
self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData))
.await;
return;
}
};
let Ok(my_id_i64) = i64::try_from(my_id) else {
self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData))
.await;
return;
};
let Some(settings_name) = cv.get_data(DataType::SettingsName).as_str() else {
self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData))
.await;
return;
};
let Some(settings_value) = cv.get_data(DataType::Payload).as_str() else {
self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData))
.await;
return;
};
let _ = iota_storage::util::settings::save(
my_id_i64,
iota_storage::util::settings::GLOBAL_SESSION_ID,
settings_name,
settings_value,
);
let response = CommunicationValue::new(CommunicationType::SettingsSave)
.with_receiver(my_id)
.with_request_id(&cv);
self.send_message(&response).await;
return;
}
if cv.is_type(CommunicationType::SettingsLoad) {
let my_id = match cv.require_sender() {
Ok(my_id) => my_id,
Err(_) => {
self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData))
.await;
return;
}
};
let Ok(my_id_i64) = i64::try_from(my_id) else {
self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData))
.await;
return;
};
let Some(settings_name) = cv.get_data(DataType::SettingsName).as_string() else {
self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData))
.await;
return;
};
let settings_value_str = iota_storage::util::settings::load(
my_id_i64,
iota_storage::util::settings::GLOBAL_SESSION_ID,
&settings_name,
)
.ok()
.flatten()
.unwrap_or_default();
let response = CommunicationValue::new(CommunicationType::SettingsLoad)
.with_request_id(&cv)
.with_receiver(my_id)
.add_typed_default(DataType::Payload, DataValue::Str(settings_value_str))
.add_typed_default(DataType::SettingsName, DataValue::Str(settings_name));
self.send_message(&response).await;
return;
}
if cv.is_type(CommunicationType::SettingsList) {
let my_id = match cv.require_sender() {
Ok(my_id) => my_id,
Err(_) => {
self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData))
.await;
return;
}
};
let Ok(my_id_i64) = i64::try_from(my_id) else {
self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData))
.await;
return;
};
let settings = iota_storage::util::settings::list(
my_id_i64,
iota_storage::util::settings::GLOBAL_SESSION_ID,
)
.unwrap_or_default();
let settings_json = settings.into_iter().map(DataValue::Str).collect();
let response = CommunicationValue::new(CommunicationType::SettingsList)
.with_request_id(&cv)
.with_receiver(my_id)
.add_typed_default(DataType::Settings, DataValue::Array(settings_json));
self.send_message(&response).await;
return;
}
}
async fn handle_challenge(&self, cv: &CommunicationValue) {
let Ok(keyring) = self.local_keyring().await else {
return;
};
let Some(encrypted_challenge) = cv.get_data(DataType::Challenge).as_str() else {
return;
};
let solved = crypto_util::decrypt_challenge(encrypted_challenge, &keyring).ok();
if let Some(solved) = solved {
let response = CommunicationValue::new(CommunicationType::ChallengeResponse)
.with_request_id(&cv)
.add_typed_default(DataType::Challenge, DataValue::Str(solved));
self.send_message(&response).await;
}
}
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
pub async fn send_message(&self, cv: &CommunicationValue) {
if let Err(err) = self.send_message_result(cv).await {
log_t!("send_message_failed", err);
}
}
async fn send_message_result(&self, cv: &CommunicationValue) -> Result<(), String> {
let sender_guard = self.sender.read().await;
if let Some(sender) = sender_guard.as_ref() {
if !sender.is_open() {
drop(sender_guard);
if let Some(sender) = self.sender.write().await.take() {
sender.close().await;
}
return Err("connection closed".to_string());
}
let sender_clone = Arc::clone(sender);
drop(sender_guard);
log_cv_out!(&cv);
if let Err(e) = sender_clone.send(cv).await {
return Err(e.to_string());
}
Ok(())
} else {
Err("not connected".to_string())
}
}
pub async fn await_response(
self: Arc<ClientConnection>,
cv: &CommunicationValue,
timeout_duration: Option<Duration>,
) -> Result<CommunicationValue, String> {
let (tx, mut rx) = mpsc::channel(1);
let msg_id = cv
.require_id()
.map_err(|error| format!("cannot await response without a message id: {error}"))?;
let task_tx = tx.clone();
self.waiting_tasks.insert(
msg_id,
Box::new(move |_, response_cv| {
let inner_tx = task_tx.clone();
tokio::spawn(async move {
let _ = inner_tx.send(response_cv).await;
});
true
}),
);
self.send_message(cv).await;
let timeout = timeout_duration.unwrap_or(Duration::from_secs(10));
match tokio::time::timeout(timeout, rx.recv()).await {
Ok(Some(response_cv)) => Ok(response_cv),
Ok(_) => Err("Failed to receive response, channel was closed.".to_string()),
Err(_) => {
self.waiting_tasks.remove(&msg_id);
Err(format!(
"Request timed out after {} seconds.",
timeout.as_secs()
))
}
}
}
}

View file

@ -1,2 +0,0 @@
mod client_connection;
pub use client_connection::ClientConnection;

5236
communities/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,16 +0,0 @@
[package]
name = "communities"
version = "0.1.0"
edition = "2024"
[dependencies]
mtp = { git = "https://git.methanium.net/Methanium/mtp.git" }
iota-util = { path = "../iota-util" }
futures = "*"
rand = "0.8"
rand_core = { version = "0.6", features = ["getrandom", "std"] }
sha2 = "0.11.0"
tokio = { version = "1.50.0", features = ["full"] }
uuid = { version = "*", features = ["v4"] }
x448 = { version = "*" }

View file

@ -4,7 +4,7 @@ FROM rust:latest AS builder
WORKDIR /app
COPY . .
RUN cargo build --release -p iota-daemon
RUN cargo build --release
# Runtime stage
FROM debian:sid
@ -13,10 +13,8 @@ WORKDIR /app
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/iota-daemon .
RUN useradd -r -s /bin/false iota && mkdir -p /run/iota && chown iota:iota /run/iota
COPY --from=builder /app/target/release/iota .
EXPOSE 1984
CMD ["./iota-daemon"]
CMD ["./iota"]

82
flake.lock generated
View file

@ -1,82 +0,0 @@
{
"nodes": {
"flake-parts": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1782949081,
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1784497964,
"narHash": "sha256-vlHUuqAcbcH2RKmHbPiuQzbv1pnzzavXnI62RD0bqCU=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "241313f4e8e508cb9b13278c2b0fa25b9ca27163",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1782614948,
"narHash": "sha256-ePjCwr1sNm9NYUqywL7QfK3JnlS015msC+eBu2zKlp8=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "db3f255737b94216eb71cce308e2912cf6bc2d7c",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixpkgs.lib",
"type": "github"
}
},
"root": {
"inputs": {
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1784526465,
"narHash": "sha256-L37teKC6oINWG4PGZLIqbphMWvSQ0PEz+aWxAk+rIDw=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "58c6334db52d51fc5dd8877c90b01f00cf8a696b",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

275
flake.nix
View file

@ -1,275 +0,0 @@
{
description = "Iota";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = inputs @ {
self,
nixpkgs,
flake-parts,
rust-overlay,
...
}:
flake-parts.lib.mkFlake {inherit inputs;} {
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
perSystem = {
self',
pkgs,
system,
...
}: let
rustPkgs = import nixpkgs {
inherit system;
overlays = [(import rust-overlay)];
};
rustToolchain = rustPkgs.rust-bin.stable.latest.default.override {
extensions = ["rust-src" "rust-analyzer" "clippy" "rustfmt"];
};
commonBuildInputs = with pkgs; [openssl sqlite];
commonNativeBuildInputs = with pkgs; [cmake perl pkg-config];
in {
packages = {
default = pkgs.rustPlatform.buildRustPackage {
pname = "iota";
version = "0.1.0";
src = ./.;
cargoBuildFlags = ["-p" "iota" "-p" "iota-daemon"];
cargoLock = {
lockFile = ./Cargo.lock;
allowBuiltinFetchGit = true;
};
nativeBuildInputs = commonNativeBuildInputs;
buildInputs = commonBuildInputs;
dontUseCmakeConfigure = true;
passthru.dataDir = "/var/lib/iota";
};
iota-daemon = self'.packages.default.overrideAttrs (old: {
pname = "iota-daemon";
cargoBuildFlags = ["-p" "iota-daemon"];
postInstall = ''
for f in $out/bin/*; do
if [ "$(basename "$f")" != "iota-daemon" ]; then
rm "$f"
fi
done
'';
});
iota-ui = self'.packages.default.overrideAttrs (old: {
pname = "iota-ui";
cargoBuildFlags = ["-p" "iota"];
postInstall = ''
for f in $out/bin/*; do
if [ "$(basename "$f")" != "iota" ]; then
rm "$f"
fi
done
if [ -f "$out/bin/iota" ]; then
mv "$out/bin/iota" "$out/bin/iota-ui"
fi
'';
});
};
devShells.default = pkgs.mkShell {
nativeBuildInputs = with pkgs; [rustToolchain git cmake perl pkg-config];
buildInputs = commonBuildInputs;
};
};
flake = {
nixosModules.default = {
config,
pkgs,
lib,
...
}: let
cfg = config.services.iota;
defaultPackage = self.packages.${pkgs.stdenv.hostPlatform.system}.default or (throw "iota: no pre-built package for system ${pkgs.stdenv.hostPlatform.system}");
configFormat = pkgs.formats.yaml {};
configFile =
if cfg.settingsFile != null
then cfg.settingsFile
else configFormat.generate "iota-config.yaml" cfg.settings;
descriptionText = "Tensamin Iota daemon";
in {
options.services.iota = {
enable = lib.mkEnableOption "Enable the Iota service.";
stateDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/iota";
description = "Persistent mutable Iota state.";
};
cacheDir = lib.mkOption { type = lib.types.str; default = "/var/cache/iota"; };
runtimeDir = lib.mkOption { type = lib.types.str; default = "/run/iota"; };
logDir = lib.mkOption { type = lib.types.str; default = "/var/log/iota"; };
assetDir = lib.mkOption { type = lib.types.str; default = "${cfg.package}/share/iota/web"; };
certFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "Path to the SSL certificate file (cert.pem).";
};
keyFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "Path to the SSL private key file (cert.key).";
};
environmentFiles = lib.mkOption {
type = lib.types.listOf lib.types.path;
default = [];
description = "Environment files to load for the Iota service.";
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to open the firewall for ports used by Iota.";
};
bindAddress = lib.mkOption {
type = lib.types.str;
default = "0.0.0.0";
description = "IP address to bind the HTTP server to.";
};
package = lib.mkOption {
type = lib.types.package;
default = defaultPackage;
description = "The Iota package to use.";
};
settings = lib.mkOption {
type = lib.types.attrs;
default = {};
description = "Configuration attributes for Iota, written to YAML.";
};
settingsFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "Path to an existing YAML file to use instead of generating from settings.";
};
};
config = lib.mkIf cfg.enable {
users.users.iota = {
isSystemUser = true;
group = "iota";
home = cfg.stateDir;
createHome = true;
description = "Iota service user";
shell = pkgs.bash;
};
users.groups.iota = {};
systemd.sockets.iota = {
description = "${descriptionText} IPC socket";
wantedBy = ["sockets.target"];
socketConfig = {
ListenStream = "/run/iota/iota.sock";
SocketMode = "0660";
SocketUser = "iota";
SocketGroup = "iota";
DirectoryMode = "0750";
Backlog = 5;
RemoveOnStop = "true";
NonBlocking = true;
};
};
systemd.services.iota = {
description = descriptionText;
wantedBy = ["multi-user.target"];
after = ["network.target" "iota.socket"];
requires = ["iota.socket"];
serviceConfig =
{
Type = "simple";
User = "iota";
Group = "iota";
ExecStart = "${cfg.package}/bin/iota-daemon";
Restart = "on-failure";
RestartSec = "5s";
RuntimeDirectory = "iota";
RuntimeDirectoryMode = "0750";
StateDirectory = "iota";
StateDirectoryMode = "0750";
CacheDirectory = "iota";
CacheDirectoryMode = "0750";
LogsDirectory = "iota";
LogsDirectoryMode = "0750";
# Exit code 75 = restart requested
RestartPreventExitStatus = "0";
RestartForceExitStatus = "75";
TimeoutStopSec = "10";
KillMode = "mixed";
KillSignal = "SIGTERM";
AmbientCapabilities = ["CAP_NET_BIND_SERVICE"];
CapabilityBoundingSet = ["CAP_NET_BIND_SERVICE"];
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
NoNewPrivileges = true;
ReadWritePaths = [cfg.stateDir cfg.cacheDir cfg.runtimeDir cfg.logDir];
ReadOnlyPaths = [configFile cfg.assetDir];
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
LockPersonality = true;
MemoryDenyWriteExecute = true;
Environment = [
"BIND_ADDRESS=${cfg.bindAddress}"
"IOTA_SOCKET=/run/iota/iota.sock"
"IOTA_CONFIG_FILE=${configFile}"
"IOTA_STATE_DIR=${cfg.stateDir}"
"IOTA_CACHE_DIR=${cfg.cacheDir}"
"IOTA_RUNTIME_DIR=${cfg.runtimeDir}"
"IOTA_LOG_DIR=${cfg.logDir}"
"IOTA_ASSET_DIR=${cfg.assetDir}"
"IOTA_DEPLOYMENT_MODE=system_socket_activated"
"IOTA_SUPERVISOR=systemd"
];
}
// lib.optionalAttrs (cfg.environmentFiles != []) {
EnvironmentFile = cfg.environmentFiles;
};
};
networking.firewall = lib.mkIf cfg.openFirewall {
allowedTCPPorts = [1984];
allowedUDPPorts = [1984];
};
};
};
};
};
}

View file

@ -1,7 +0,0 @@
[package]
name = "iota-auth"
version = "0.1.0"
edition = "2024"
[dependencies]
json = "*"

View file

@ -1 +0,0 @@

View file

@ -1,29 +0,0 @@
[package]
name = "iota-cli"
version = "0.1.0"
edition = "2024"
[features]
legacy-commands = [
]
[dependencies]
iota-state = { path = "../iota-state" }
iota-terms = { path = "../iota-terms" }
iota-ipc = { path = "../iota-ipc" }
iota-paths = { path = "../iota-paths" }
chrono = "0.4.43"
crossterm = "*"
once_cell = "1.21.3"
open = "5.3.3"
ratatui = "0.30.0"
serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"
tokio = { version = "1.50.0", features = ["full"] }
tokio-util = { version = "0.7", features = ["rt"] }
unicode-width = "0.2"
[dev-dependencies]
tempfile = "3"

View file

@ -1 +0,0 @@
pub use iota_state::*;

View file

@ -1,7 +0,0 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ControlAction {
FocusNext,
FocusPrevious,
Select,
Activate,
}

View file

@ -1,78 +0,0 @@
use crate::theme::ResolvedTheme;
use ratatui::{
Frame,
layout::{Alignment, Rect},
text::Span,
widgets::Paragraph,
};
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ButtonIntent {
Primary,
Neutral,
Cancel,
Destructive,
}
pub struct ActionButton<'a> {
pub label: &'a str,
pub intent: ButtonIntent,
pub focused: bool,
pub enabled: bool,
}
pub fn render_button(
frame: &mut Frame,
area: Rect,
button: ActionButton<'_>,
theme: &ResolvedTheme,
) {
let style = if !button.enabled {
theme.buttons.disabled
} else {
match (button.intent, button.focused) {
(ButtonIntent::Primary, true) => theme.buttons.primary_focused,
(ButtonIntent::Primary, false) => theme.buttons.primary,
(ButtonIntent::Neutral, true) => theme.buttons.neutral_focused,
(ButtonIntent::Neutral, false) => theme.buttons.neutral,
(ButtonIntent::Cancel, true) => theme.buttons.cancel_focused,
(ButtonIntent::Cancel, false) => theme.buttons.cancel,
(ButtonIntent::Destructive, _) => theme.buttons.destructive,
}
};
frame.render_widget(
Paragraph::new(Span::styled(
if button.focused {
format!(" {}", button.label)
} else {
button.label.to_owned()
},
style,
))
.alignment(Alignment::Center),
area,
);
}
pub fn horizontal_button_widths(available: u16, minimums: &[u16]) -> Option<Vec<u16>> {
let required = minimums
.iter()
.try_fold(0u16, |total, width| total.checked_add(*width))?;
if required > available {
return None;
}
if minimums.is_empty() {
return Some(Vec::new());
}
let extra = available - required;
let count = minimums.len() as u16;
Some(
minimums
.iter()
.enumerate()
.map(|(index, width)| width + extra / count + u16::from((index as u16) < extra % count))
.collect(),
)
}
pub fn button_minimum_width(label: &str) -> u16 {
UnicodeWidthStr::width(label)
.saturating_add(2)
.min(u16::MAX as usize) as u16
}

View file

@ -1,135 +0,0 @@
use super::{choice::ChoiceVisualState, navigation::DisabledFocusPolicy};
use std::{collections::HashSet, hash::Hash};
pub struct CheckboxItem<T> {
pub value: T,
pub label: String,
pub description: Option<String>,
pub enabled: bool,
pub disabled_reason: Option<String>,
}
pub struct CheckboxGroup<T: Clone + Eq + Hash> {
items: Vec<CheckboxItem<T>>,
selected: HashSet<T>,
focused_index: usize,
focus_policy: DisabledFocusPolicy,
wrap_navigation: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CheckboxGroupError {
Empty,
DuplicateValue,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CheckboxChange<T> {
Selected(T),
Deselected(T),
IgnoredDisabled(T),
NoItem,
}
impl<T: Clone + Eq + Hash> CheckboxGroup<T> {
pub fn new(
items: Vec<CheckboxItem<T>>,
selected: impl IntoIterator<Item = T>,
) -> Result<Self, CheckboxGroupError> {
let mut values = HashSet::new();
if items.iter().any(|item| !values.insert(item.value.clone())) {
return Err(CheckboxGroupError::DuplicateValue);
}
let selected = selected
.into_iter()
.filter(|value| values.contains(value))
.collect();
let focused_index = items.iter().position(|item| item.enabled).unwrap_or(0);
Ok(Self {
items,
selected,
focused_index,
focus_policy: DisabledFocusPolicy::Skip,
wrap_navigation: true,
})
}
pub fn items(&self) -> &[CheckboxItem<T>] {
&self.items
}
pub fn selected(&self) -> &HashSet<T> {
&self.selected
}
pub fn focused_item(&self) -> Option<&CheckboxItem<T>> {
self.items.get(self.focused_index)
}
pub fn set_focus_policy(&mut self, policy: DisabledFocusPolicy) {
self.focus_policy = policy;
}
pub fn set_wrap_navigation(&mut self, wrap: bool) {
self.wrap_navigation = wrap;
}
pub fn focus_next(&mut self) {
self.move_focus(true);
}
pub fn focus_previous(&mut self) {
self.move_focus(false);
}
fn move_focus(&mut self, forward: bool) {
if self.items.is_empty() {
return;
}
for step in 1..=self.items.len() {
let current = self.focused_index as isize;
let delta = if forward {
step as isize
} else {
-(step as isize)
};
let raw = current + delta;
let next = if self.wrap_navigation {
raw.rem_euclid(self.items.len() as isize) as usize
} else if raw < 0 || raw >= self.items.len() as isize {
return;
} else {
raw as usize
};
if self.focus_policy == DisabledFocusPolicy::Include || self.items[next].enabled {
self.focused_index = next;
return;
}
}
}
pub fn toggle_focused(&mut self) -> CheckboxChange<T> {
let Some(item) = self.items.get(self.focused_index) else {
return CheckboxChange::NoItem;
};
let value = item.value.clone();
if !item.enabled {
return CheckboxChange::IgnoredDisabled(value);
}
if self.selected.remove(&value) {
CheckboxChange::Deselected(value)
} else {
self.selected.insert(value.clone());
CheckboxChange::Selected(value)
}
}
pub fn set_enabled(&mut self, value: &T, enabled: bool) {
if let Some(item) = self.items.iter_mut().find(|item| &item.value == value) {
item.enabled = enabled;
}
}
pub fn set_selected(&mut self, value: T, selected: bool) {
if selected {
self.selected.insert(value);
} else {
self.selected.remove(&value);
}
}
pub fn visual_state(&self, value: &T) -> ChoiceVisualState {
let item = self.items.iter().position(|item| &item.value == value);
ChoiceVisualState {
selected: self.selected.contains(value),
focused: item == Some(self.focused_index),
enabled: item
.and_then(|index| self.items.get(index))
.is_some_and(|item| item.enabled),
}
}
}

View file

@ -1,42 +0,0 @@
use crate::theme::ResolvedTheme;
use ratatui::text::{Line, Span};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChoiceKind {
Checkbox,
Radio,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ChoiceVisualState {
pub selected: bool,
pub focused: bool,
pub enabled: bool,
}
pub fn render_choice_line<'a>(
label: &'a str,
kind: ChoiceKind,
state: ChoiceVisualState,
theme: &'a ResolvedTheme,
) -> Line<'a> {
let item = match (state.selected, state.focused, state.enabled) {
(_, true, false) => &theme.choices.focused_disabled,
(true, false, false) => &theme.choices.selected_disabled,
(false, false, false) => &theme.choices.disabled,
(true, true, true) => &theme.choices.focused_selected,
(true, false, true) => &theme.choices.selected,
(false, true, true) => &theme.choices.focused,
(false, false, true) => &theme.choices.normal,
};
let marker = match (kind, state.selected) {
(ChoiceKind::Checkbox, false) => theme.markers.checkbox_unselected,
(ChoiceKind::Checkbox, true) => theme.markers.checkbox_selected,
(ChoiceKind::Radio, false) => theme.markers.radio_unselected,
(ChoiceKind::Radio, true) => theme.markers.radio_selected,
};
Line::from(vec![
Span::styled(item.prefix, item.label),
Span::styled(marker, item.marker),
Span::raw(" "),
Span::styled(label, item.label),
Span::styled(item.suffix, item.label),
])
}

View file

@ -1,267 +0,0 @@
use crossterm::event::KeyCode;
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
};
use crate::{
controls::button::{ActionButton, ButtonIntent, render_button},
interaction_result::InteractionResult,
render_context::RenderContext,
screens::screens::{HitMap, KeyHint, Screen, UiEvent},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DialogButton {
Cancel,
Confirm,
Custom(usize),
}
pub struct ConfirmDialog {
title: String,
message: Vec<String>,
buttons: Vec<DialogButtonConfig>,
focused_button: usize,
on_confirm: Option<Box<dyn Fn() -> InteractionResult + Send + Sync>>,
on_cancel: Option<Box<dyn Fn() -> InteractionResult + Send + Sync>>,
}
struct DialogButtonConfig {
label: String,
intent: ButtonIntent,
enabled: bool,
}
impl ConfirmDialog {
pub fn new(title: impl Into<String>, message: impl Into<String>) -> Self {
Self {
title: title.into(),
message: vec![message.into()],
buttons: vec![
DialogButtonConfig {
label: "Cancel".to_owned(),
intent: ButtonIntent::Cancel,
enabled: true,
},
DialogButtonConfig {
label: "Confirm".to_owned(),
intent: ButtonIntent::Primary,
enabled: true,
},
],
focused_button: 0,
on_confirm: None,
on_cancel: None,
}
}
pub fn destructive(title: impl Into<String>, message: impl Into<String>) -> Self {
Self {
title: title.into(),
message: vec![message.into()],
buttons: vec![
DialogButtonConfig {
label: "Cancel".to_owned(),
intent: ButtonIntent::Cancel,
enabled: true,
},
DialogButtonConfig {
label: "Delete".to_owned(),
intent: ButtonIntent::Destructive,
enabled: true,
},
],
focused_button: 0,
on_confirm: None,
on_cancel: None,
}
}
pub fn with_message_line(mut self, line: impl Into<String>) -> Self {
self.message.push(line.into());
self
}
pub fn with_button(mut self, label: impl Into<String>, intent: ButtonIntent) -> Self {
self.buttons.push(DialogButtonConfig {
label: label.into(),
intent,
enabled: true,
});
self
}
pub fn with_confirm_action<F: Fn() -> InteractionResult + Send + Sync + 'static>(
mut self,
action: F,
) -> Self {
self.on_confirm = Some(Box::new(action));
self
}
pub fn with_cancel_action<F: Fn() -> InteractionResult + Send + Sync + 'static>(
mut self,
action: F,
) -> Self {
self.on_cancel = Some(Box::new(action));
self
}
fn activate(&self) -> InteractionResult {
match self.focused_button {
0 => {
if let Some(action) = &self.on_cancel {
action()
} else {
InteractionResult::CloseScreen
}
}
1 => {
if let Some(action) = &self.on_confirm {
action()
} else {
InteractionResult::CloseScreen
}
}
_ => InteractionResult::CloseScreen,
}
}
fn next_button(&mut self) {
self.focused_button = (self.focused_button + 1) % self.buttons.len();
}
fn prev_button(&mut self) {
if self.focused_button == 0 {
self.focused_button = self.buttons.len() - 1;
} else {
self.focused_button -= 1;
}
}
}
impl Screen for ConfirmDialog {
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
let area = crate::layout::fit::centered_rect(
rect,
crate::layout::fit::RequiredSize {
width: 50,
height: (self.message.len() + 8) as u16,
},
);
f.render_widget(Clear, area);
let block = Block::default()
.title(format!(" {} ", self.title))
.borders(Borders::ALL)
.border_style(context.theme.borders.focused)
.style(context.theme.surfaces.overlay);
let inner = block.inner(area);
f.render_widget(block, area);
let rows = Layout::vertical([
Constraint::Min(self.message.len() as u16),
Constraint::Length(1),
Constraint::Length(1),
])
.split(inner);
let lines: Vec<Line> = self
.message
.iter()
.map(|line| Line::from(Span::styled(line.as_str(), context.theme.text.normal)))
.collect();
f.render_widget(Paragraph::new(lines), rows[0]);
let buttons_area = rows[2];
let button_widths: Vec<u16> = self
.buttons
.iter()
.map(|b| crate::controls::button::button_minimum_width(&b.label))
.collect();
let total_width: u16 = button_widths.iter().sum();
let spacing = self.buttons.len().saturating_sub(1) as u16;
let available = buttons_area.width;
let start_x = buttons_area.x + available.saturating_sub(total_width + spacing) / 2;
let mut x = start_x;
for (i, (button_config, &width)) in self.buttons.iter().zip(&button_widths).enumerate() {
let button_area = Rect {
x,
y: buttons_area.y,
width,
height: 1,
};
x = x.saturating_add(width + 1);
render_button(
f,
button_area,
ActionButton {
label: &button_config.label,
intent: button_config.intent,
focused: self.focused_button == i,
enabled: button_config.enabled,
},
context.theme,
);
}
}
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
let UiEvent::Key(key) = event else {
return InteractionResult::Unhandled;
};
match key.code {
KeyCode::Esc => InteractionResult::CloseScreen,
KeyCode::Tab => {
self.next_button();
InteractionResult::Handled
}
KeyCode::BackTab => {
self.prev_button();
InteractionResult::Handled
}
KeyCode::Left => {
self.prev_button();
InteractionResult::Handled
}
KeyCode::Right => {
self.next_button();
InteractionResult::Handled
}
KeyCode::Enter | KeyCode::Char(' ') => self.activate(),
_ => InteractionResult::Unhandled,
}
}
fn key_hints(&self) -> Vec<KeyHint> {
vec![
KeyHint {
keys: "Tab",
action: "Switch button",
},
KeyHint {
keys: "Enter",
action: "Confirm",
},
KeyHint {
keys: "Esc",
action: "Cancel",
},
]
}
}

View file

@ -1,164 +0,0 @@
use crate::ipc_client::{DaemonStatus, IpcConnectionState};
use crate::theme::ResolvedTheme;
use crate::{
controls::button::ButtonIntent,
screens::screens::{AppAction, HitMap},
};
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
text::{Line, Span},
widgets::Paragraph,
};
fn connection_badge(
state: &IpcConnectionState,
theme: &ResolvedTheme,
) -> (&'static str, ratatui::style::Style) {
match state {
IpcConnectionState::Connected => ("OK", theme.status.success),
IpcConnectionState::Connecting => ("..", theme.status.warning),
IpcConnectionState::Reconnecting { .. } => ("WARN", theme.status.warning),
IpcConnectionState::Failed { .. } | IpcConnectionState::Incompatible { .. } => {
("FAIL", theme.status.error)
}
IpcConnectionState::Disconnected => ("WARN", theme.status.warning),
}
}
fn omikron_badge(daemon: &DaemonStatus, theme: &ResolvedTheme) -> (String, ratatui::style::Style) {
use iota_ipc::ComponentId;
let health = daemon.components.get(&ComponentId::Omikron);
let (label, style) = match health.map(|h| h.status) {
Some(iota_ipc::HealthStatus::Healthy) => ("OK", theme.status.success),
Some(iota_ipc::HealthStatus::Degraded) => ("WARN", theme.status.warning),
Some(iota_ipc::HealthStatus::Failed) => ("FAIL", theme.status.error),
None => ("--", theme.text.muted),
};
let detail = health
.and_then(|h| h.message.as_deref())
.map(|m| format!(" {m}"))
.unwrap_or_default();
(format!("{label}{detail}"), style)
}
pub fn render_header(
frame: &mut Frame,
area: Rect,
connection: &IpcConnectionState,
daemon: &DaemonStatus,
theme: &ResolvedTheme,
hits: &mut HitMap,
focused_action: Option<usize>,
) {
let version = if daemon.version.is_empty() {
String::new()
} else {
format!(" v{}", daemon.version)
};
let rows =
Layout::vertical([Constraint::Percentage(50), Constraint::Percentage(50)]).split(area);
let cells = Layout::horizontal([
Constraint::Min(28),
Constraint::Length(12),
Constraint::Length(12),
Constraint::Length(12),
Constraint::Length(8),
])
.split(rows[0]);
let cells2 = Layout::horizontal([
Constraint::Min(28),
Constraint::Length(12),
Constraint::Length(12),
Constraint::Length(12),
Constraint::Length(8),
])
.split(rows[1]);
let (ipc_label, ipc_style) = connection_badge(connection, theme);
let (omikron_text, omikron_style) = omikron_badge(daemon, theme);
let brand_line1 = Line::from(vec![
Span::styled(format!(" IOTA{version}"), theme.surfaces.toolbar),
Span::styled(format!(" IPC:[{ipc_label}]"), ipc_style),
]);
let brand_line2 = Line::from(vec![
Span::styled(" Omikron: ", theme.surfaces.toolbar),
Span::styled(format!("[{omikron_text}]"), omikron_style),
]);
let brand_area = Rect {
x: area.x,
y: area.y,
width: cells[0].width,
height: area.height,
};
frame.render_widget(
Paragraph::new(vec![brand_line1, brand_line2]).style(theme.surfaces.toolbar),
brand_area,
);
hits.register(brand_area, AppAction::OpenMain);
for (index, (top, _bottom, label, intent, action)) in [
(
cells[1],
cells2[1],
"Overview",
ButtonIntent::Primary,
AppAction::OpenOverview,
),
(
cells[2],
cells2[2],
"Users",
ButtonIntent::Neutral,
AppAction::OpenUsers,
),
(
cells[3],
cells2[3],
"Settings",
ButtonIntent::Neutral,
AppAction::OpenSettings,
),
(
cells[4],
cells2[4],
"Quit",
ButtonIntent::Destructive,
AppAction::Quit,
),
]
.into_iter()
.enumerate()
{
let button_area = Rect {
x: top.x,
y: top.y,
width: top.width,
height: area.height,
};
let style = match (intent, focused_action == Some(index)) {
(ButtonIntent::Primary, true) => theme.buttons.primary_focused,
(ButtonIntent::Primary, false) => theme.buttons.primary,
(ButtonIntent::Neutral, true) => theme.buttons.neutral_focused,
(ButtonIntent::Neutral, false) => theme.buttons.neutral,
(ButtonIntent::Cancel, true) => theme.buttons.cancel_focused,
(ButtonIntent::Cancel, false) => theme.buttons.cancel,
(ButtonIntent::Destructive, _) => theme.buttons.destructive,
};
let display_label = if focused_action == Some(index) {
format!(" {label}")
} else {
label.to_owned()
};
frame.render_widget(
Paragraph::new(vec![
Line::from(Span::styled(display_label, style)),
Line::from(""),
]),
button_area,
);
hits.register(button_area, action);
}
}

View file

@ -1,10 +0,0 @@
pub mod action;
pub mod button;
pub mod checkbox_group;
pub mod choice;
pub mod dialog;
pub mod header;
pub mod navigation;
pub mod panel;
pub mod radio_group;
pub mod scroll;

View file

@ -1,6 +0,0 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DisabledFocusPolicy {
Include,
#[default]
Skip,
}

View file

@ -1,60 +0,0 @@
use crate::theme::{ChromeMode, ResolvedTheme};
use ratatui::{
Frame,
layout::Rect,
widgets::{Block, Borders, Paragraph},
};
/// Draw a conventional outlined panel or a filled surface from the same call
/// site. Screens can migrate without embedding theme branches in layouts.
pub fn render_panel(
frame: &mut Frame,
area: Rect,
title: &str,
focused: bool,
theme: &ResolvedTheme,
) -> Rect {
match theme.chrome {
ChromeMode::Bordered => {
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(if focused {
theme.borders.focused
} else {
theme.borders.normal
});
let inner = block.inner(area);
frame.render_widget(block, area);
inner
}
ChromeMode::Surfaces => {
frame.render_widget(
Block::default().style(if focused {
theme.surfaces.panel_focused
} else {
theme.surfaces.panel
}),
area,
);
let header = Rect {
x: area.x,
y: area.y,
width: area.width,
height: area.height.min(1),
};
frame.render_widget(
Paragraph::new(format!(" {title}")).style(theme.surfaces.panel_alternate),
header,
);
// Surface panels use a single header row. A one-cell inset keeps
// compact controls such as the console usable at height three.
Rect {
x: area.x.saturating_add(1),
y: area.y.saturating_add(1),
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(1),
}
}
}
}

View file

@ -1,194 +0,0 @@
use super::{choice::ChoiceVisualState, navigation::DisabledFocusPolicy};
pub struct RadioItem<T> {
pub value: T,
pub label: String,
pub description: Option<String>,
pub enabled: bool,
pub disabled_reason: Option<String>,
}
pub struct RadioGroup<T: Clone + Eq> {
items: Vec<RadioItem<T>>,
selected: T,
default: T,
focused_index: usize,
focus_policy: DisabledFocusPolicy,
wrap_navigation: bool,
disabled_selection_policy: DisabledSelectionPolicy,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RadioGroupError {
Empty,
DefaultMissing,
DefaultDisabled,
NoEnabledItems,
SelectedItemDisabled,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RadioChange<T> {
Changed { previous: T, selected: T },
Unchanged(T),
IgnoredDisabled(T),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DisabledSelectionPolicy {
UseConfiguredDefault,
UseFirstEnabled,
ReturnError,
}
impl<T: Clone + Eq> RadioGroup<T> {
pub fn new(
items: Vec<RadioItem<T>>,
observed: Option<T>,
default: T,
) -> Result<Self, RadioGroupError> {
if items.is_empty() {
return Err(RadioGroupError::Empty);
}
let default_item = items
.iter()
.find(|item| item.value == default)
.ok_or(RadioGroupError::DefaultMissing)?;
if !default_item.enabled {
return Err(RadioGroupError::DefaultDisabled);
}
let focused_index = items
.iter()
.position(|item| item.enabled)
.ok_or(RadioGroupError::NoEnabledItems)?;
let selected = observed
.filter(|value| {
items
.iter()
.any(|item| item.enabled && item.value == *value)
})
.unwrap_or_else(|| default.clone());
Ok(Self {
items,
selected,
default,
focused_index,
focus_policy: DisabledFocusPolicy::Skip,
wrap_navigation: true,
disabled_selection_policy: DisabledSelectionPolicy::UseConfiguredDefault,
})
}
pub fn items(&self) -> &[RadioItem<T>] {
&self.items
}
pub fn selected(&self) -> &T {
&self.selected
}
pub fn focused_item(&self) -> &RadioItem<T> {
&self.items[self.focused_index]
}
pub fn focus_next(&mut self) {
self.move_focus(true);
}
pub fn focus_previous(&mut self) {
self.move_focus(false);
}
fn move_focus(&mut self, forward: bool) {
for step in 1..=self.items.len() {
let raw = self.focused_index as isize
+ if forward {
step as isize
} else {
-(step as isize)
};
let next = if self.wrap_navigation {
raw.rem_euclid(self.items.len() as isize) as usize
} else if raw < 0 || raw >= self.items.len() as isize {
return;
} else {
raw as usize
};
if self.focus_policy == DisabledFocusPolicy::Include || self.items[next].enabled {
self.focused_index = next;
return;
}
}
}
pub fn select_focused(&mut self) -> RadioChange<T> {
let item = self.focused_item();
let enabled = item.enabled;
let value = item.value.clone();
if !enabled {
return RadioChange::IgnoredDisabled(value);
}
if value == self.selected {
RadioChange::Unchanged(self.selected.clone())
} else {
let previous = std::mem::replace(&mut self.selected, value);
RadioChange::Changed {
previous,
selected: self.selected.clone(),
}
}
}
pub fn visual_state(&self, value: &T) -> ChoiceVisualState {
let item = self.items.iter().position(|item| &item.value == value);
ChoiceVisualState {
selected: &self.selected == value,
focused: item == Some(self.focused_index),
enabled: item
.and_then(|index| self.items.get(index))
.is_some_and(|item| item.enabled),
}
}
pub fn set_disabled_selection_policy(&mut self, policy: DisabledSelectionPolicy) {
self.disabled_selection_policy = policy;
}
pub fn set_focus_policy(&mut self, policy: DisabledFocusPolicy) {
self.focus_policy = policy;
}
pub fn set_wrap_navigation(&mut self, wrap: bool) {
self.wrap_navigation = wrap;
}
pub fn set_enabled(&mut self, value: &T, enabled: bool) -> Result<(), RadioGroupError> {
let Some(index) = self.items.iter().position(|item| &item.value == value) else {
return Ok(());
};
if self.items[index].enabled == enabled {
return Ok(());
}
if !enabled
&& self
.items
.iter()
.enumerate()
.all(|(other, item)| other == index || !item.enabled)
{
return Err(RadioGroupError::NoEnabledItems);
}
if !enabled && self.selected == *value {
let replacement = match self.disabled_selection_policy {
DisabledSelectionPolicy::UseConfiguredDefault if self.default != *value => self
.items
.iter()
.find(|item| item.enabled && item.value == self.default)
.map(|item| item.value.clone()),
DisabledSelectionPolicy::UseConfiguredDefault => None,
DisabledSelectionPolicy::UseFirstEnabled => self
.items
.iter()
.enumerate()
.find(|(other, item)| *other != index && item.enabled)
.map(|(_, item)| item.value.clone()),
DisabledSelectionPolicy::ReturnError => {
return Err(RadioGroupError::SelectedItemDisabled);
}
};
self.selected = replacement.ok_or(RadioGroupError::SelectedItemDisabled)?;
}
self.items[index].enabled = enabled;
if !enabled && self.focused_index == index && self.focus_policy == DisabledFocusPolicy::Skip
{
self.focus_next();
}
Ok(())
}
pub fn default(&self) -> &T {
&self.default
}
}

View file

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

View file

@ -1,519 +0,0 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
Frame,
layout::Rect,
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use std::{
any::Any,
sync::{Arc, Mutex},
time::Duration,
};
use tokio::time::Instant;
use crate::{
elements::elements::{Element, InteractableElement, JoinableElement},
interaction_result::InteractionResult,
ipc_client::IpcClient,
render_context::RenderContext,
util::borders::draw_block_joins,
};
pub struct ConsoleCard {
ipc: Arc<IpcClient>,
focused: bool,
pub title: String,
pub content: String,
pub cursor_position: usize,
borders: Borders,
joins: Borders,
cursor: Arc<Mutex<bool>>,
last_swap: Arc<Mutex<Instant>>,
pending_restore: Arc<Mutex<Option<String>>>,
pending_confirmation: Option<String>,
history: Vec<String>,
history_index: Option<usize>,
history_draft: String,
message: Option<String>,
}
impl ConsoleCard {
pub fn new(title: &str, content: &str, ipc: Arc<IpcClient>) -> Self {
ConsoleCard {
ipc,
focused: false,
title: title.to_string(),
content: content.to_string(),
cursor_position: content.chars().count(),
borders: Borders::ALL,
joins: Borders::NONE,
cursor: Arc::new(Mutex::new(true)),
last_swap: Arc::new(Mutex::new(Instant::now())),
pending_restore: Arc::new(Mutex::new(None)),
pending_confirmation: None,
history: Vec::new(),
history_index: None,
history_draft: String::new(),
message: None,
}
}
fn byte_index(&self) -> usize {
self.content
.char_indices()
.nth(self.cursor_position)
.map(|(i, _)| i)
.unwrap_or(self.content.len())
}
fn cursor_visible(&self) -> bool {
if !self.focused {
return false;
}
let mut visible = self.cursor.lock().unwrap();
let mut last = self.last_swap.lock().unwrap();
let now = Instant::now();
if now.duration_since(*last) >= Duration::from_millis(500) {
*visible = !*visible;
*last = now;
}
*visible
}
fn current_prefix(&self) -> Option<&str> {
if self.content.starts_with('/') {
Some("/")
} else {
None
}
}
fn cursor_spans(&self, theme: &crate::theme::ResolvedTheme) -> Vec<Span<'static>> {
let cursor_visible = self.cursor_visible();
let mut spans = Vec::new();
if self.content.is_empty() {
if self.focused {
if cursor_visible {
Self::push_cursor(&mut spans, theme);
} else {
spans.push(Span::styled(" ", theme.console.text));
}
spans.push(Span::styled(
"send command (/help for info)",
theme.console.hint,
));
} else {
spans.push(Span::styled(
" send command (/help for info)",
theme.console.hint,
));
}
return spans;
}
let byte_index = self.byte_index();
let before = self.content[..byte_index].to_string();
let after = self.content[byte_index..].to_string();
let prefix_len = self.current_prefix().map(|s| s.len()).unwrap_or(0);
if prefix_len > 0 && before.len() >= prefix_len {
let prefix = &before[..prefix_len];
let rest = &before[prefix_len..];
spans.push(Span::styled(prefix.to_string(), theme.console.prefix));
if !rest.is_empty() {
spans.push(Span::styled(rest.to_string(), theme.console.text));
}
} else if !before.is_empty() {
spans.push(Span::styled(before.clone(), theme.console.text));
}
if cursor_visible {
Self::push_cursor(&mut spans, theme);
}
if !after.is_empty() {
spans.push(Span::styled(after, theme.console.text));
}
spans
}
fn push_cursor(spans: &mut Vec<Span<'static>>, theme: &crate::theme::ResolvedTheme) {
match &theme.console.cursor {
crate::theme::CursorPresentation::StyledCell(style) => {
spans.push(Span::styled(" ", *style))
}
crate::theme::CursorPresentation::Character { glyph, style } => {
spans.push(Span::styled(*glyph, *style))
}
}
}
fn render_cursor_spans(&self, theme: &crate::theme::ResolvedTheme) -> Vec<Span<'static>> {
if let Some(message) = &self.message {
return vec![Span::styled(message.clone(), theme.console.error)];
}
if let Some(command) = &self.pending_confirmation {
return vec![Span::styled(
format!("Confirm `{command}`? [y/N]"),
theme.console.confirmation,
)];
}
self.cursor_spans(theme)
}
fn is_destructive(command: &str) -> bool {
matches!(
command.trim_start_matches('/').trim(),
"restart" | "reload" | "stop" | "shutdown" | "regenerate keys" | "identity rotate"
) || command
.trim_start_matches('/')
.trim_start()
.split_once(" remove ")
.is_some_and(|(noun, _)| matches!(noun, "user" | "users"))
}
fn dispatch_command(&self, command: String) {
let ipc = self.ipc.clone();
let restore = self.pending_restore.clone();
tokio::spawn(async move {
if ipc.send_command(0, command.clone()).await.is_err() {
*restore.lock().unwrap() = Some(command);
}
});
}
fn move_cursor_left(&mut self) {
if self.cursor_position > 0 {
self.cursor_position -= 1;
}
}
fn move_cursor_right(&mut self) {
let len = self.content.chars().count();
if self.cursor_position < len {
self.cursor_position += 1;
}
}
fn delete_at_cursor(&mut self) {
if self.content.is_empty() || self.cursor_position == 0 {
return;
}
let start = self
.content
.char_indices()
.nth(self.cursor_position.saturating_sub(1))
.map(|(i, _)| i)
.unwrap_or(0);
let end = self.byte_index();
self.content.replace_range(start..end, "");
self.cursor_position -= 1;
}
fn insert_at_cursor(&mut self, c: char) {
let idx = self.byte_index();
self.content.insert(idx, c);
self.cursor_position += 1;
}
pub fn handle_paste(&mut self, text: &str) {
let sanitized = text.replace(['\r', '\n'], " ");
let index = self.byte_index();
self.content.insert_str(index, &sanitized);
self.cursor_position += sanitized.chars().count();
self.message = None;
}
fn set_editor(&mut self, value: String) {
self.content = value;
self.cursor_position = self.content.chars().count();
}
fn history_previous(&mut self) {
if self.history.is_empty() {
return;
}
let index = match self.history_index {
None => {
self.history_draft = self.content.clone();
self.history.len() - 1
}
Some(index) => index.saturating_sub(1),
};
self.history_index = Some(index);
self.set_editor(self.history[index].clone());
self.message = None;
}
fn history_next(&mut self) {
let Some(index) = self.history_index else {
return;
};
if index + 1 < self.history.len() {
self.history_index = Some(index + 1);
self.set_editor(self.history[index + 1].clone());
} else {
self.history_index = None;
let draft = std::mem::take(&mut self.history_draft);
self.set_editor(draft);
}
self.message = None;
}
fn complete(&mut self) -> bool {
let completions = iota_ipc::text_commands::completions(&self.content);
if completions.len() == 1 {
let leading_slash = self.content.starts_with('/');
self.set_editor(format!(
"{}{}",
if leading_slash { "/" } else { "" },
completions[0]
));
self.message = None;
true
} else if completions.len() > 1 {
self.message = Some(format!("Matches: {}", completions.join(", ")));
true
} else {
false
}
}
}
impl Element for ConsoleCard {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) {
if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
let inner = crate::controls::panel::render_panel(
f,
r,
&self.title,
self.focused,
context.theme,
);
f.render_widget(
Paragraph::new(Line::from(self.render_cursor_spans(context.theme)))
.style(context.theme.console.text),
inner,
);
return;
}
let block = Block::default()
.borders(self.borders)
.title(self.title.clone())
.title_style(context.theme.console.title)
.border_style(if self.focused {
context.theme.console.focused_border
} else {
context.theme.console.border
})
.style(context.theme.console.text);
let spans = self.render_cursor_spans(context.theme);
let par = Paragraph::new(Line::from(spans))
.block(block)
.scroll((0, 0));
f.render_widget(par, r);
draw_block_joins(
f,
r,
self.borders,
self.joins,
if self.focused {
context.theme.borders.focused
} else {
context.theme.borders.normal
},
);
}
}
impl JoinableElement for ConsoleCard {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn as_element(&self) -> &(dyn Element + 'static) {
self
}
fn as_element_mut(&mut self) -> &mut (dyn Element + 'static) {
self
}
fn set_borders(&mut self, borders: Borders) {
self.borders = borders;
}
fn set_joins(&mut self, joins: Borders) {
self.joins = joins;
}
}
impl InteractableElement for ConsoleCard {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn as_element(&self) -> &(dyn Element + 'static) {
self
}
fn as_element_mut(&mut self) -> &mut (dyn Element + 'static) {
self
}
fn interact(&mut self, key: KeyEvent) -> InteractionResult {
// Check if a previously failed command should be restored.
if let Some(restored) = self.pending_restore.lock().unwrap().take() {
self.content = restored;
self.cursor_position = self.content.chars().count();
self.message = Some("Command failed; restored for retry.".into());
}
if let Some(command) = self.pending_confirmation.take() {
if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y')) {
self.dispatch_command(command);
}
return InteractionResult::Handled;
}
match key.code {
KeyCode::Enter => {
if self.content.is_empty() {
return InteractionResult::Handled;
}
let command = self.content.clone();
if let Some(error) = iota_ipc::text_commands::validation_error(&command) {
self.message = Some(error);
return InteractionResult::Handled;
}
if command.trim_start_matches('/').trim() == "help" {
self.message = Some(format!(
"Commands: {}",
iota_ipc::text_commands::COMMANDS.join(", ")
));
return InteractionResult::Handled;
}
if self.history.last() != Some(&command) {
self.history.push(command.clone());
}
self.history_index = None;
self.history_draft.clear();
self.content.clear();
self.cursor_position = 0;
if Self::is_destructive(&command) {
self.pending_confirmation = Some(command);
} else {
self.dispatch_command(command);
}
InteractionResult::Handled
}
KeyCode::Backspace => {
self.message = None;
self.delete_at_cursor();
InteractionResult::Handled
}
KeyCode::Delete => {
self.message = None;
let len = self.content.chars().count();
if self.cursor_position < len {
let start = self.byte_index();
let end = self
.content
.char_indices()
.nth(self.cursor_position + 1)
.map(|(i, _)| i)
.unwrap_or(self.content.len());
self.content.replace_range(start..end, "");
}
InteractionResult::Handled
}
KeyCode::Left => {
self.move_cursor_left();
InteractionResult::Handled
}
KeyCode::Right => {
self.move_cursor_right();
InteractionResult::Handled
}
KeyCode::Home => {
self.cursor_position = 0;
InteractionResult::Handled
}
KeyCode::End => {
self.cursor_position = self.content.chars().count();
InteractionResult::Handled
}
KeyCode::Up => {
self.history_previous();
InteractionResult::Handled
}
KeyCode::Down => {
self.history_next();
InteractionResult::Handled
}
KeyCode::Tab if !self.content.is_empty() => {
if self.complete() {
InteractionResult::Handled
} else {
self.message = Some("No command completion.".into());
InteractionResult::Handled
}
}
KeyCode::Tab | KeyCode::BackTab => InteractionResult::Unhandled,
_ => {
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
{
if let Some(c) = key.code.as_char() {
self.insert_at_cursor(c);
self.message = None;
return InteractionResult::Handled;
}
}
InteractionResult::Unhandled
}
}
}
fn can_focus(&self) -> bool {
true
}
fn is_focused(&self) -> bool {
self.focused
}
fn focus(&mut self, f: bool) {
self.focused = f;
}
}

View file

@ -1,287 +0,0 @@
use std::{any::Any, sync::Arc};
use crossterm::event::KeyEvent;
use iota_state::ClientState;
use ratatui::{
Frame,
layout::Rect,
widgets::{
Block, Borders,
canvas::{Canvas, Line},
},
};
use crate::{
elements::elements::{Element, InteractableElement, JoinableElement},
interaction_result::InteractionResult,
render_context::RenderContext,
ui::UI,
util::borders::draw_block_joins,
};
pub enum GRAPHS {
Ram,
Cpu,
Ping,
}
impl GRAPHS {
pub fn get_color(&self, theme: &crate::theme::ResolvedTheme) -> ratatui::style::Color {
match self {
GRAPHS::Ram => theme.graphs.ram,
GRAPHS::Cpu => theme.graphs.cpu,
GRAPHS::Ping => theme.graphs.ping,
}
}
pub fn get_graph(&self, state: &ClientState, sample_width: usize) -> Vec<(f64, f64)> {
let state = match state.app.try_lock() {
Ok(state) => state,
Err(_) => return Vec::new(),
};
match self {
GRAPHS::Ram => state
.with_width(sample_width.min(u16::MAX as usize) as u16)
.ram
.clone(),
GRAPHS::Cpu => state
.with_width(sample_width.min(u16::MAX as usize) as u16)
.cpu
.clone(),
GRAPHS::Ping => state
.with_width(sample_width.min(u16::MAX as usize) as u16)
.ping
.clone(),
}
}
pub fn get_unit(&self) -> String {
match self {
// Memory is collected as a percentage of total RAM, not MiB.
GRAPHS::Ram => "%".to_string(),
GRAPHS::Cpu => "%".to_string(),
GRAPHS::Ping => "ms".to_string(),
}
}
}
#[allow(unused)]
pub struct GraphCard {
ui: Arc<UI>,
state: ClientState,
graph_type: GRAPHS,
focused: bool,
pub title: String,
borders: Borders,
joins: Borders,
open: bool,
sample_width: usize,
}
impl GraphCard {
pub fn new(ui: Arc<UI>, state: ClientState, graph_type: GRAPHS, title: String) -> Self {
Self {
ui,
state,
graph_type,
focused: false,
title,
borders: Borders::ALL,
joins: Borders::NONE,
open: true,
sample_width: 28,
}
}
pub fn set_open(&mut self, open: bool) {
self.open = open;
}
pub fn set_sample_width(&mut self, sample_width: usize) {
self.sample_width = sample_width.max(1);
}
}
impl Element for GraphCard {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) {
if self.open {
let graph = self.graph_type.get_graph(&self.state, self.sample_width);
if graph.is_empty() {
let block = Block::default()
.title(format!(" {} ", self.title))
.borders(self.borders)
.border_style(if self.focused {
context.theme.graphs.focused_border
} else {
context.theme.graphs.border
});
f.render_widget(
ratatui::widgets::Paragraph::new("No metric samples yet.")
.style(context.theme.text.muted)
.block(block),
r,
);
return;
}
let unit = self.graph_type.get_unit();
let min_x = graph.first().map(|(x, _)| *x).unwrap_or(0.0);
let max_x = graph.last().map(|(x, _)| *x).unwrap_or(100.0);
let max_x = if max_x <= min_x { min_x + 1.0 } else { max_x };
let min_y = graph
.iter()
.map(|(_, y)| *y)
.filter(|y| *y > 0.0)
.min_by(|a, b| a.total_cmp(b))
.unwrap_or(0.0);
let max_y = graph.iter().map(|(_, y)| *y).fold(0.0, f64::max);
let y_upper = match self.graph_type {
GRAPHS::Cpu | GRAPHS::Ram => 100.0,
GRAPHS::Ping => (max_y * 1.2).max(10.0),
};
let surface = matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces);
let title = format!(
"{}: {}{} {}min/{}max",
self.title,
graph.last().unwrap_or(&(0.0, 0.0)).1 as i64,
unit,
min_y as i64,
max_y as i64
);
let plot_area = if surface {
crate::controls::panel::render_panel(f, r, &title, self.focused, context.theme)
} else {
r
};
let block = Block::default()
.title(if surface {
String::new()
} else {
format!(
"{}:─{}{}─{}min/{}max",
self.title,
graph.last().unwrap_or(&(0.0, 0.0)).1 as i64,
unit,
min_y as i64,
max_y as i64,
)
})
.borders(if surface { Borders::NONE } else { self.borders })
.border_style(if self.focused {
context.theme.graphs.focused_border
} else {
context.theme.graphs.border
});
let canvas = Canvas::default()
.block(block)
.x_bounds([min_x, max_x])
.y_bounds([0.0, y_upper])
.paint(|ctx| {
for (x, y) in &graph {
ctx.draw(&Line {
x1: *x,
y1: 0.0,
x2: *x,
y2: *y,
color: self.graph_type.get_color(context.theme),
});
}
});
f.render_widget(canvas, plot_area);
} else {
let block = Block::default()
.title("")
.borders(self.borders)
.border_style(if self.focused {
context.theme.graphs.focused_border
} else {
context.theme.graphs.border
});
f.render_widget(block, r);
}
if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
draw_block_joins(
f,
r,
self.borders,
self.joins,
if self.focused {
context.theme.borders.focused
} else {
context.theme.borders.normal
},
);
}
}
}
impl JoinableElement for GraphCard {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn as_element(&self) -> &dyn Element {
self
}
fn as_element_mut(&mut self) -> &mut dyn Element {
self
}
fn set_borders(&mut self, borders: Borders) {
self.borders = borders;
}
fn set_joins(&mut self, joins: Borders) {
self.joins = joins;
}
}
impl InteractableElement for GraphCard {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn as_element(&self) -> &dyn Element {
self
}
fn as_element_mut(&mut self) -> &mut dyn Element {
self
}
fn interact(&mut self, _key: KeyEvent) -> InteractionResult {
InteractionResult::Handled
}
fn can_focus(&self) -> bool {
true
}
fn is_focused(&self) -> bool {
self.focused
}
fn focus(&mut self, f: bool) {
self.focused = f;
}
}

View file

@ -1,219 +0,0 @@
use crossterm::event::KeyCode;
use ratatui::{
Frame,
layout::Rect,
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
};
use crate::{
interaction_result::InteractionResult,
render_context::RenderContext,
screens::screens::{HitMap, Screen, UiEvent},
theme::ResolvedTheme,
};
pub struct HelpOverlay {
scroll: usize,
}
impl HelpOverlay {
pub fn new() -> Self {
Self { scroll: 0 }
}
fn build_lines(&self, theme: &ResolvedTheme) -> Vec<Line<'static>> {
vec![
Line::from(""),
Line::from(Span::styled(
"Global Keyboard Shortcuts",
theme.text.heading,
)),
Line::from(""),
Line::from(vec![
Span::styled(" F6", theme.text.link),
Span::styled(" Toggle header navigation", theme.text.normal),
]),
Line::from(vec![
Span::styled(" Tab", theme.text.link),
Span::styled(" Move focus to next panel", theme.text.normal),
]),
Line::from(vec![
Span::styled(" Shift+Tab", theme.text.link),
Span::styled(" Move focus to previous panel", theme.text.normal),
]),
Line::from(vec![
Span::styled(" Esc", theme.text.link),
Span::styled(" Go back / Close dialog", theme.text.normal),
]),
Line::from(vec![
Span::styled(" Ctrl+C", theme.text.link),
Span::styled(" Quit the application", theme.text.normal),
]),
Line::from(""),
Line::from(Span::styled("Dashboard Navigation", theme.text.heading)),
Line::from(""),
Line::from(vec![
Span::styled(" o/O", theme.text.link),
Span::styled(" Open Overview screen", theme.text.normal),
]),
Line::from(vec![
Span::styled(" u/U", theme.text.link),
Span::styled(" Open Users screen", theme.text.normal),
]),
Line::from(vec![
Span::styled(" m/M", theme.text.link),
Span::styled(" Open Metrics screen", theme.text.normal),
]),
Line::from(""),
Line::from(Span::styled("Log Panel", theme.text.heading)),
Line::from(""),
Line::from(vec![
Span::styled(" j/Down", theme.text.link),
Span::styled(" Scroll down", theme.text.normal),
]),
Line::from(vec![
Span::styled(" k/Up", theme.text.link),
Span::styled(" Scroll up", theme.text.normal),
]),
Line::from(vec![
Span::styled(" Enter", theme.text.link),
Span::styled(" Lock/unlock scroll", theme.text.normal),
]),
Line::from(vec![
Span::styled(" /", theme.text.link),
Span::styled(" Filter logs", theme.text.normal),
]),
Line::from(""),
Line::from(Span::styled("Console Panel", theme.text.heading)),
Line::from(""),
Line::from(vec![
Span::styled(" Enter", theme.text.link),
Span::styled(" Send command", theme.text.normal),
]),
Line::from(vec![
Span::styled(" Up/Down", theme.text.link),
Span::styled(" Command history", theme.text.normal),
]),
Line::from(vec![
Span::styled(" Tab", theme.text.link),
Span::styled(" Auto-complete", theme.text.normal),
]),
Line::from(vec![
Span::styled(" /help", theme.text.link),
Span::styled(" List available commands", theme.text.normal),
]),
Line::from(""),
Line::from(Span::styled("List Navigation", theme.text.heading)),
Line::from(""),
Line::from(vec![
Span::styled(" j/Down", theme.text.link),
Span::styled(" Next item", theme.text.normal),
]),
Line::from(vec![
Span::styled(" k/Up", theme.text.link),
Span::styled(" Previous item", theme.text.normal),
]),
Line::from(vec![
Span::styled(" PgUp/PgDn", theme.text.link),
Span::styled(" Page up/down", theme.text.normal),
]),
Line::from(vec![
Span::styled(" Home", theme.text.link),
Span::styled(" First item", theme.text.normal),
]),
Line::from(vec![
Span::styled(" End", theme.text.link),
Span::styled(" Last item", theme.text.normal),
]),
Line::from(vec![
Span::styled(" /", theme.text.link),
Span::styled(" Filter list", theme.text.normal),
]),
Line::from(""),
Line::from(Span::styled(
"Press ? or Esc to close this overlay",
theme.text.muted,
)),
]
}
}
impl Screen for HelpOverlay {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
let area = crate::layout::fit::centered_rect(
rect,
crate::layout::fit::RequiredSize {
width: 52,
height: 40,
},
);
f.render_widget(Clear, area);
let block = Block::default()
.title(" Keyboard Shortcuts (?) ")
.borders(Borders::ALL)
.border_style(context.theme.borders.focused)
.style(context.theme.surfaces.overlay);
let inner = block.inner(area);
f.render_widget(block, area);
let lines = self.build_lines(context.theme);
let paragraph = Paragraph::new(lines)
.scroll((self.scroll as u16, 0))
.style(context.theme.text.normal);
f.render_widget(paragraph, inner);
}
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
let UiEvent::Key(key) = event else {
return InteractionResult::Unhandled;
};
match key.code {
KeyCode::Esc | KeyCode::Char('?') | KeyCode::Char('q') => {
InteractionResult::CloseScreen
}
KeyCode::Down | KeyCode::Char('j') => {
self.scroll = self.scroll.saturating_add(1);
InteractionResult::Handled
}
KeyCode::Up | KeyCode::Char('k') => {
self.scroll = self.scroll.saturating_sub(1);
InteractionResult::Handled
}
KeyCode::PageDown => {
self.scroll = self.scroll.saturating_add(10);
InteractionResult::Handled
}
KeyCode::PageUp => {
self.scroll = self.scroll.saturating_sub(10);
InteractionResult::Handled
}
_ => InteractionResult::Unhandled,
}
}
fn key_hints(&self) -> Vec<crate::screens::screens::KeyHint> {
vec![
crate::screens::screens::KeyHint {
keys: "Up/Down",
action: "Scroll",
},
crate::screens::screens::KeyHint {
keys: "Esc/?",
action: "Close",
},
]
}
}
use std::any::Any;

View file

@ -1,61 +0,0 @@
use crate::{screens::screens::UiEvent, ui::UI};
use crossterm::event::{Event, KeyEvent, KeyEventKind, KeyModifiers, poll, read};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
pub fn setup_input_handler(ui: Arc<UI>) -> JoinHandle<Result<(), String>> {
tokio::spawn(async move {
let cancellation = ui.cancellation_token();
let (tx, mut rx) = mpsc::unbounded_channel();
let worker_cancellation = cancellation.clone();
let worker = tokio::task::spawn_blocking(move || -> Result<(), String> {
while !worker_cancellation.is_cancelled() {
if poll(Duration::from_millis(100)).map_err(|e| e.to_string())? {
tx.send(read().map_err(|e| e.to_string())?)
.map_err(|_| "input session closed".to_string())?;
}
}
Ok(())
});
loop {
if ui.is_shutdown() {
break;
}
tokio::select! {
event = rx.recv() => match event {
Some(Event::Key(key)) if key.kind == KeyEventKind::Press => handle_input(key, ui.clone()).await,
Some(Event::Mouse(mouse)) => ui.clone().handle_event(UiEvent::Mouse(mouse)).await,
Some(Event::Resize(width, height)) => ui.clone().handle_event(UiEvent::Resize(width, height)).await,
Some(Event::Paste(text)) => ui.clone().handle_event(UiEvent::Paste(text)).await,
Some(_) => {},
None => break,
},
_ = cancellation.cancelled() => break,
}
}
let result = match worker.await {
Ok(result) => result,
Err(error) if error.is_cancelled() => Ok(()),
Err(error) => Err(format!("input worker failed: {error}")),
};
if result.is_err() {
ui.request_shutdown();
}
result
})
}
pub async fn handle_input(key: KeyEvent, ui: Arc<UI>) {
if matches!(
key.code,
crossterm::event::KeyCode::Char('q') | crossterm::event::KeyCode::Char('c')
) && key.modifiers.contains(KeyModifiers::CONTROL)
{
ui.request_shutdown();
} else {
ui.handle_event(UiEvent::Key(key)).await;
}
}

View file

@ -1,845 +0,0 @@
use iota_ipc::{
ClientMessage, DaemonMessage, HelloAck, LocalRequest, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION,
RequestEnvelope, ResponsePayload, ResponseResult, read_msg, write_msg,
};
use iota_state::{ClientState, UiLogEntry};
use std::collections::HashMap;
use std::io::Result;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex as StdMutex};
use std::time::Duration;
use tokio::net::UnixStream;
use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf};
use tokio::sync::{Mutex, oneshot, watch};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
const INITIAL_BACKOFF: Duration = Duration::from_millis(200);
const MAX_BACKOFF: Duration = Duration::from_secs(10);
const MAX_RECONNECT_ATTEMPTS: u32 = 50;
const STARTUP_CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
const CONNECT_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(2);
/// Connection state exposed to the UI.
#[derive(Clone, Debug)]
pub enum IpcConnectionState {
Connecting,
Connected,
Reconnecting { attempt: u32 },
Incompatible { message: String },
Failed { message: String },
Disconnected,
}
/// Daemon information shown by the UI. This is separate from socket connectivity:
/// a connected daemon may still be starting or degraded.
#[derive(Clone, Debug, Default)]
pub struct DaemonStatus {
pub version: String,
pub instance_id: String,
pub startup_phase: Option<iota_ipc::StartupPhase>,
pub degraded_reason: Option<String>,
pub lifecycle: Option<iota_ipc::LifecyclePhase>,
pub health: iota_ipc::HealthStatus,
pub deployment_mode: Option<iota_ipc::DeploymentMode>,
pub supervisor: Option<iota_ipc::SupervisorKind>,
pub components: std::collections::BTreeMap<iota_ipc::ComponentId, iota_ipc::ComponentHealth>,
}
/// Pending request awaiting a response.
struct PendingRequest {
response_tx: oneshot::Sender<ResponseResult>,
}
struct ActiveWriter {
generation: u64,
writer: OwnedWriteHalf,
}
struct NegotiatedConnection {
reader: OwnedReadHalf,
writer: OwnedWriteHalf,
ack: HelloAck,
buffered_messages: Vec<DaemonMessage>,
}
/* The TUI owns this cache. IPC updates replace daemon snapshots and append
* logs, so rendering never reaches into daemon-owned storage or connections. */
pub struct IpcClient {
state: ClientState,
writer: Mutex<Option<ActiveWriter>>,
next_generation: AtomicU64,
next_request_id: AtomicU64,
pending: Mutex<HashMap<u64, PendingRequest>>,
connection_state: watch::Sender<IpcConnectionState>,
daemon_status: watch::Sender<DaemonStatus>,
path: PathBuf,
reconnector_started: AtomicBool,
cancellation: CancellationToken,
background_tasks: StdMutex<Vec<JoinHandle<()>>>,
}
impl IpcClient {
pub async fn connect(path: impl AsRef<Path>) -> Result<Arc<Self>> {
let path = path.as_ref().to_path_buf();
let deadline = tokio::time::Instant::now() + STARTUP_CONNECT_TIMEOUT;
Self::connect_until(&path, deadline).await
}
async fn connect_until(path: &Path, deadline: tokio::time::Instant) -> Result<Arc<Self>> {
let path = path.to_path_buf();
let stream = Self::connect_stream(&path, deadline).await?;
let negotiated = Self::negotiate_stream(stream, deadline).await?;
// These values are visible before MainScreen subscribes. Do not
// publish the handshake into a channel with no retained receiver.
let initial_status = DaemonStatus {
version: negotiated.ack.daemon_version.clone(),
instance_id: negotiated.ack.instance_id.clone(),
startup_phase: Some(negotiated.ack.startup_phase),
degraded_reason: None,
lifecycle: Some(negotiated.ack.lifecycle),
health: negotiated.ack.health,
deployment_mode: Some(negotiated.ack.deployment_mode),
supervisor: Some(negotiated.ack.supervisor),
components: std::collections::BTreeMap::new(),
};
let (conn_state_tx, _) = watch::channel(IpcConnectionState::Connected);
let (daemon_status_tx, _) = watch::channel(initial_status);
let client = Arc::new(Self {
state: ClientState::new(),
writer: Mutex::new(Some(ActiveWriter {
generation: 1,
writer: negotiated.writer,
})),
next_generation: AtomicU64::new(2),
next_request_id: AtomicU64::new(1),
pending: Mutex::new(HashMap::new()),
connection_state: conn_state_tx,
daemon_status: daemon_status_tx,
path: path.clone(),
reconnector_started: AtomicBool::new(false),
cancellation: CancellationToken::new(),
background_tasks: StdMutex::new(Vec::new()),
});
// Apply messages received while waiting for subscription confirmation
// before exposing the connection to the UI.
for message in negotiated.buffered_messages {
client.apply(message).await;
}
// Start reader task (continues reading after handshake)
let reader_client = client.clone();
let task = tokio::spawn(async move {
reader_client.read_loop(negotiated.reader, 1).await;
});
client.background_tasks.lock().unwrap().push(task);
Ok(client)
}
/// Try to connect with retries for socket activation.
pub async fn connect_or_activate(path: impl AsRef<Path>) -> Result<Arc<Self>> {
let path = path.as_ref().to_path_buf();
let deadline = tokio::time::Instant::now() + STARTUP_CONNECT_TIMEOUT;
let mut last_error = None;
while tokio::time::Instant::now() < deadline {
match Self::connect_until(&path, deadline).await {
Ok(client) => return Ok(client),
Err(error) => {
last_error = Some(error);
tokio::time::sleep(Duration::from_millis(250)).await;
}
}
}
Err(last_error.unwrap_or_else(|| {
std::io::Error::new(std::io::ErrorKind::TimedOut, "Timed out waiting for daemon")
}))
}
async fn connect_stream(path: &Path, deadline: tokio::time::Instant) -> Result<UnixStream> {
tokio::time::timeout_at(deadline, UnixStream::connect(path))
.await
.map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::TimedOut,
"Timed out connecting to daemon",
)
})?
}
async fn negotiate_stream(
stream: UnixStream,
deadline: tokio::time::Instant,
) -> Result<NegotiatedConnection> {
let (mut reader, mut writer) = stream.into_split();
tokio::time::timeout_at(
deadline,
write_msg(
&mut writer,
&ClientMessage::Hello {
supported_versions: vec![MIN_PROTOCOL_VERSION, PROTOCOL_VERSION],
},
),
)
.await
.map_err(|_| {
std::io::Error::new(std::io::ErrorKind::TimedOut, "Timed out sending IPC Hello")
})??;
let ack = match tokio::time::timeout_at(deadline, read_msg::<_, DaemonMessage>(&mut reader))
.await
{
Err(_) => {
return Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"Timed out waiting for IPC HelloAck",
));
}
Ok(Ok(DaemonMessage::HelloAck(ack))) => ack,
Ok(Ok(_)) => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Expected HelloAck as the first daemon message",
));
}
Ok(Err(error)) => return Err(error),
};
if !Self::is_compatible_version(ack.protocol_version) {
return Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
format!(
"Unsupported daemon protocol version {}",
ack.protocol_version
),
));
}
tokio::time::timeout_at(
deadline,
write_msg(
&mut writer,
&ClientMessage::Subscribe {
log_classes: vec![],
metric_interval_ms: Some(500),
},
),
)
.await
.map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::TimedOut,
"Timed out sending IPC subscription",
)
})??;
// The daemon may send its initial StateUpdate before the acknowledgement.
// Keep draining until the subscription itself is confirmed, otherwise a
// UI can report Connected while no state stream exists yet.
let mut buffered_messages = Vec::new();
loop {
match tokio::time::timeout_at(deadline, read_msg::<_, DaemonMessage>(&mut reader)).await
{
Err(_) => {
return Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"Timed out waiting for IPC subscription acknowledgement",
));
}
Ok(Ok(DaemonMessage::Subscribed)) => break,
Ok(Ok(message)) => buffered_messages.push(message),
Ok(Err(error)) => return Err(error),
}
}
Ok(NegotiatedConnection {
reader,
writer,
ack,
buffered_messages,
})
}
/// Start the reconnection actor.
pub fn spawn_reconnector(self: &Arc<Self>) {
if self.reconnector_started.swap(true, Ordering::AcqRel) {
return;
}
let client = self.clone();
let task = tokio::spawn(async move {
client.reconnection_loop().await;
});
self.background_tasks.lock().unwrap().push(task);
}
async fn reconnection_loop(self: Arc<Self>) {
let mut rx = self.connection_status();
loop {
while !matches!(*rx.borrow(), IpcConnectionState::Disconnected) {
if tokio::select! {
changed = rx.changed() => changed.is_err(),
_ = self.cancellation.cancelled() => true,
} {
return;
}
}
let mut backoff = INITIAL_BACKOFF;
for attempt in 1..=MAX_RECONNECT_ATTEMPTS {
if self.cancellation.is_cancelled() {
return;
}
let _ = self
.connection_state
.send(IpcConnectionState::Reconnecting { attempt });
tokio::select! {
_ = tokio::time::sleep(backoff) => {},
_ = self.cancellation.cancelled() => return,
}
let deadline = tokio::time::Instant::now() + CONNECT_ATTEMPT_TIMEOUT;
let result = async {
let stream = Self::connect_stream(&self.path, deadline).await?;
Self::negotiate_stream(stream, deadline).await
}
.await;
match result {
Ok(connection) => {
self.install_connection(connection).await;
break;
}
Err(error) if error.kind() == std::io::ErrorKind::Unsupported => {
let _ = self
.connection_state
.send(IpcConnectionState::Incompatible {
message: error.to_string(),
});
return;
}
Err(error) if attempt == MAX_RECONNECT_ATTEMPTS => {
let _ = self.connection_state.send(IpcConnectionState::Failed {
message: format!("Reconnect failed after {attempt} attempts: {error}"),
});
return;
}
Err(error) => {
eprintln!(
"IPC reconnect attempt {attempt} to {} failed: kind={:?}, error={error}",
self.path.display(),
error.kind()
);
backoff = std::cmp::min(backoff * 2, MAX_BACKOFF);
}
}
}
}
}
async fn install_connection(self: &Arc<Self>, connection: NegotiatedConnection) {
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
*self.writer.lock().await = Some(ActiveWriter {
generation,
writer: connection.writer,
});
self.update_hello_ack(connection.ack);
for message in connection.buffered_messages {
self.apply(message).await;
}
let _ = self.connection_state.send(IpcConnectionState::Connected);
let client = self.clone();
let task = tokio::spawn(async move {
client.read_loop(connection.reader, generation).await;
});
self.background_tasks.lock().unwrap().push(task);
}
async fn fail_pending_requests(&self) {
let mut pending = self.pending.lock().await;
for (_, request) in pending.drain() {
let _ = request
.response_tx
.send(ResponseResult::Error(iota_ipc::IpcErrorCode::Disconnected));
}
}
async fn mark_disconnected(&self, generation: u64) {
let removed = {
let mut writer = self.writer.lock().await;
match writer.as_ref() {
Some(active) if active.generation == generation => {
writer.take();
true
}
_ => false,
}
};
if removed {
let _ = self.connection_state.send(IpcConnectionState::Disconnected);
self.fail_pending_requests().await;
}
}
async fn read_loop(self: Arc<Self>, mut reader: OwnedReadHalf, generation: u64) {
loop {
let result = tokio::select! {
result = read_msg::<_, DaemonMessage>(&mut reader) => result,
_ = self.cancellation.cancelled() => break,
};
match result {
Ok(message) => self.apply(message).await,
Err(_) => {
self.mark_disconnected(generation).await;
break;
}
}
}
}
pub fn state(&self) -> ClientState {
self.state.clone()
}
pub fn connection_status(&self) -> watch::Receiver<IpcConnectionState> {
self.connection_state.subscribe()
}
pub fn connection_status_snapshot(&self) -> IpcConnectionState {
self.connection_state.borrow().clone()
}
pub fn daemon_status(&self) -> watch::Receiver<DaemonStatus> {
self.daemon_status.subscribe()
}
/// Stop the IPC reader/reconnector and release the socket writer. This
/// is deliberately bounded so UI shutdown cannot hang on a peer.
pub async fn shutdown(&self) {
self.cancellation.cancel();
self.writer.lock().await.take();
let tasks = std::mem::take(&mut *self.background_tasks.lock().unwrap());
for mut task in tasks {
if tokio::time::timeout(Duration::from_secs(2), &mut task)
.await
.is_err()
{
task.abort();
}
}
}
fn is_compatible_version(version: u16) -> bool {
(MIN_PROTOCOL_VERSION..=PROTOCOL_VERSION).contains(&version)
}
fn update_hello_ack(&self, ack: HelloAck) {
self.daemon_status.send_modify(|status| {
status.version = ack.daemon_version;
status.instance_id = ack.instance_id;
status.startup_phase = Some(ack.startup_phase);
status.lifecycle = Some(ack.lifecycle);
status.health = ack.health;
status.deployment_mode = Some(ack.deployment_mode);
status.supervisor = Some(ack.supervisor);
});
}
fn format_payload(payload: &ResponsePayload) -> String {
match payload {
ResponsePayload::Status(status) => {
let mut msg = format!("Phase: {}", status.phase);
if !status.tasks.is_empty() {
msg.push_str(&format!(", Tasks: {}", status.tasks.join(", ")));
}
if let Some(reason) = &status.degraded_reason {
msg.push_str(&format!(", Degraded: {reason}"));
}
msg
}
ResponsePayload::Tasks(tasks) => {
if tasks.is_empty() {
"No active tasks.".into()
} else {
tasks
.iter()
.map(|t| t.name.as_str())
.collect::<Vec<_>>()
.join(", ")
}
}
ResponsePayload::Users(users) => {
if users.is_empty() {
"No users.".into()
} else {
users
.iter()
.map(|u| format!("{} ({})", u.username, u.user_id))
.collect::<Vec<_>>()
.join("\n")
}
}
ResponsePayload::UserCreated { user_id, username } => {
format!("Created user {} ({})", username, user_id)
}
ResponsePayload::UserRemoved { user_id } => {
format!("Removed user {}", user_id)
}
ResponsePayload::UserDataPurged { user_id } => {
format!("Purged hosted data for {}", user_id)
}
ResponsePayload::Acknowledged { message } => message.clone(),
ResponsePayload::DaemonStatus(status) => status.formatted.clone(),
ResponsePayload::Config(config) => config.yaml.clone(),
ResponsePayload::OmikronStatus(status) => {
let mut msg = format!("Connected: {}", status.connected);
if let Some(id) = status.iota_id {
msg.push_str(&format!("\nIota ID: {}", id));
}
msg
}
ResponsePayload::Components(components) => {
if components.is_empty() {
"No component health data available.".into()
} else {
components
.iter()
.map(|c| {
let status_str = match c.status {
iota_ipc::HealthStatus::Healthy => "healthy",
iota_ipc::HealthStatus::Degraded => "degraded",
iota_ipc::HealthStatus::Failed => "failed",
};
format!("{:?}: {}", c.id, status_str)
})
.collect::<Vec<_>>()
.join("\n")
}
}
ResponsePayload::UserDetail(user) => {
let mut msg = format!("User: {} ({})", user.username, user.user_id);
if let Some(ref name) = user.display_name {
msg.push_str(&format!("\nDisplay Name: {name}"));
}
msg.push_str(&format!("\nCreated At: {}", user.created_at));
if !user.trusted_apps.is_empty() {
msg.push_str(&format!("\nTrusted Apps: {}", user.trusted_apps.join(", ")));
}
msg
}
ResponsePayload::LogEntries(logs) => logs
.entries
.iter()
.map(|e| {
let level = if e.is_error { "ERR" } else { "INF" };
format!("[{}] {level} {}: {}", e.timestamp_ms, e.sender, e.message)
})
.collect::<Vec<_>>()
.join("\n"),
ResponsePayload::UpdateStatus(status) => {
if status.available {
"Update available.".into()
} else {
"Up to date.".into()
}
}
ResponsePayload::Communities(communities) => {
if communities.is_empty() {
"No communities.".into()
} else {
communities
.iter()
.map(|c| format!("{} ({})", c.title, c.name))
.collect::<Vec<_>>()
.join("\n")
}
}
}
}
fn format_error(code: &iota_ipc::IpcErrorCode) -> &'static str {
match code {
iota_ipc::IpcErrorCode::InvalidRequest => "The command is not valid.",
iota_ipc::IpcErrorCode::NotFound => "The requested user or resource was not found.",
iota_ipc::IpcErrorCode::Conflict => "The request conflicts with existing state.",
iota_ipc::IpcErrorCode::StorageFailure => "The daemon could not update its storage.",
iota_ipc::IpcErrorCode::OmikronUnavailable => {
"Omikron is unavailable; try reconnecting."
}
iota_ipc::IpcErrorCode::UnsupportedVersion => {
"CLI and daemon versions are incompatible."
}
iota_ipc::IpcErrorCode::NotReady => "The daemon is still starting; try again shortly.",
iota_ipc::IpcErrorCode::Disconnected => "The daemon connection was lost.",
iota_ipc::IpcErrorCode::Timeout => "The daemon request timed out.",
iota_ipc::IpcErrorCode::Cancelled => "The daemon request was cancelled.",
iota_ipc::IpcErrorCode::Unauthorized => {
"The daemon rejected this operation: the connected IPC account lacks the required role. Use the configured operator socket or ask an administrator to grant access."
}
iota_ipc::IpcErrorCode::InternalFailure => "The daemon reported an internal failure.",
}
}
pub async fn send_request(&self, request: LocalRequest) -> Result<ResponseResult> {
let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed);
let (response_tx, response_rx) = oneshot::channel();
{
let mut pending = self.pending.lock().await;
pending.insert(request_id, PendingRequest { response_tx });
}
let envelope = RequestEnvelope {
request_id,
protocol_version: PROTOCOL_VERSION,
request,
};
if let Err(error) = self.send(ClientMessage::Request(envelope)).await {
self.pending.lock().await.remove(&request_id);
return Err(error);
}
match tokio::time::timeout(Duration::from_secs(45), response_rx).await {
Ok(Ok(result)) => Ok(result),
Ok(Err(_)) => Ok(ResponseResult::Error(iota_ipc::IpcErrorCode::Disconnected)),
Err(_) => {
self.pending.lock().await.remove(&request_id);
Ok(ResponseResult::Error(iota_ipc::IpcErrorCode::Timeout))
}
}
}
/// Parse a legacy console command string into a typed request.
/// Delegates to the shared parser in iota-ipc.
pub fn parse_console_command(line: &str) -> Option<LocalRequest> {
iota_ipc::text_commands::parse(line)
}
/// Legacy command interface: parse text command, send as typed request.
pub async fn send_command(&self, _seq: u64, line: String) -> Result<()> {
let trimmed = line.trim_start_matches('/').trim();
// Handle ping as a direct Ping message (not a LocalRequest).
if trimmed == "ping" || trimmed.starts_with("ping ") {
let seq = self.next_request_id.fetch_add(1, Ordering::Relaxed);
if let Err(e) = self.send(ClientMessage::Ping { seq }).await {
let mut state = self.state.app.lock().await;
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
sender: "Console".into(),
message: format!("Failed to send ping: {}", e),
is_error: true,
});
return Err(e);
}
let mut state = self.state.app.lock().await;
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
sender: "Console".into(),
message: "Ping sent".into(),
is_error: false,
});
return Ok(());
}
if let Some(request) = Self::parse_console_command(&line) {
match self.send_request(request).await {
Ok(result) => {
let mut state = self.state.app.lock().await;
let message = match &result {
ResponseResult::Ok(payload) => Self::format_payload(payload),
ResponseResult::Error(code) => Self::format_error(code).into(),
};
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
sender: "Command".into(),
message,
is_error: matches!(&result, ResponseResult::Error(_)),
});
Ok(())
}
Err(e) => {
let mut state = self.state.app.lock().await;
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
sender: "Console".into(),
message: format!("Failed to send: {}", e),
is_error: true,
});
Err(e)
}
}
} else {
let mut state = self.state.app.lock().await;
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
sender: "Console".into(),
message: if trimmed == "help" {
"Commands: status, tasks, ping, user add <name>, user remove <id>, user list, users, reconnect, regenerate keys, restart, stop, config get, config reload, omikron status, components"
.into()
} else {
format!("Unknown command: {}", line)
},
is_error: false,
});
Ok(())
}
}
async fn send(&self, message: ClientMessage) -> Result<()> {
let deadline = tokio::time::Instant::now() + CONNECT_ATTEMPT_TIMEOUT;
let mut writer_guard = tokio::time::timeout_at(deadline, self.writer.lock())
.await
.map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::TimedOut,
"Timed out acquiring IPC writer",
)
})?;
let active = writer_guard.as_mut().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotConnected,
"IPC connection is not active",
)
})?;
let generation = active.generation;
let write_result =
tokio::time::timeout_at(deadline, write_msg(&mut active.writer, &message))
.await
.map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::TimedOut,
"Timed out writing IPC message",
)
})
.and_then(|result| result);
drop(writer_guard);
if write_result.is_err() {
self.mark_disconnected(generation).await;
}
write_result
}
async fn apply(&self, message: DaemonMessage) {
match message {
DaemonMessage::LogEntry(entry) => {
let mut state = self.state.app.lock().await;
state.push_log(UiLogEntry {
timestamp_ms: entry.timestamp_ms,
sender: entry.sender,
message: entry.message,
is_error: entry.is_error,
});
}
DaemonMessage::StateUpdate(snapshot) => {
// Never hold a watch borrow while sending to that same
// channel: send waits for outstanding Ref guards.
self.daemon_status.send_modify(|status| {
status.startup_phase = Some(snapshot.startup_phase);
status.degraded_reason = snapshot.degraded_reason.clone();
status.lifecycle = Some(snapshot.lifecycle);
status.health = snapshot.overall_health;
status.components = snapshot.components.clone();
});
let mut state = self.state.app.lock().await;
state.cpu = snapshot.cpu;
state.ram = snapshot.ram;
state.ping = snapshot.ping;
state.net_up = snapshot.net_up;
state.net_down = snapshot.net_down;
state.sys_info = snapshot.sys_info;
}
DaemonMessage::MetricSample(sample) => {
let mut state = self.state.app.lock().await;
if let Some(cpu) = sample.cpu {
state.push_cpu((0.0, cpu));
}
if let Some(ram) = sample.ram {
state.push_ram((0.0, ram));
}
if let Some(ping) = sample.ping {
state.push_ping_val(ping);
}
if let Some(net_up) = sample.net_up {
state.push_net_up((0.0, net_up));
}
if let Some(net_down) = sample.net_down {
state.push_net_down((0.0, net_down));
}
}
DaemonMessage::Response(response) => {
let mut pending = self.pending.lock().await;
if let Some(request) = pending.remove(&response.request_id) {
let _ = request.response_tx.send(response.result);
} else {
let mut state = self.state.app.lock().await;
let message = match &response.result {
ResponseResult::Ok(payload) => Self::format_payload(payload),
ResponseResult::Error(code) => Self::format_error(code).into(),
};
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
sender: "Command".into(),
message,
is_error: matches!(&response.result, ResponseResult::Error(_)),
});
}
}
DaemonMessage::HelloAck(ack) => self.update_hello_ack(ack),
DaemonMessage::Subscribed => {}
DaemonMessage::Pong { .. } => {}
DaemonMessage::LifecycleEvent(event) => match event {
iota_ipc::LifecycleEvent::Shutdown { reason } => {
let mut state = self.state.app.lock().await;
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
sender: "Daemon".into(),
message: format!("Daemon shutting down: {}", reason),
is_error: true,
});
}
iota_ipc::LifecycleEvent::StateChanged(status) => {
self.daemon_status.send_modify(|daemon_status| {
daemon_status.degraded_reason = match status {
iota_ipc::ConnectionStatus::Degraded => {
Some("A daemon dependency is degraded".into())
}
_ => None,
};
});
}
},
DaemonMessage::Gap { skipped } => {
let mut state = self.state.app.lock().await;
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
sender: "System".into(),
message: format!("Skipped {} messages, resynchronizing", skipped),
is_error: false,
});
}
}
}
}

View file

@ -1,52 +0,0 @@
use ratatui::layout::Rect;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RequiredSize {
pub width: u16,
pub height: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FitLevel {
Preferred,
Compact,
Fallback,
}
pub fn select_fit_level(area: Rect, preferred: RequiredSize, compact: RequiredSize) -> FitLevel {
if area.width >= preferred.width && area.height >= preferred.height {
FitLevel::Preferred
} else if area.width >= compact.width && area.height >= compact.height {
FitLevel::Compact
} else {
FitLevel::Fallback
}
}
pub fn centered_rect(area: Rect, maximum: RequiredSize) -> Rect {
let width = area.width.min(maximum.width);
let height = area.height.min(maximum.height);
Rect {
x: area.x.saturating_add(area.width.saturating_sub(width) / 2),
y: area
.y
.saturating_add(area.height.saturating_sub(height) / 2),
width,
height,
}
}
pub fn reserve_vertical(area: Rect, top: u16, bottom: u16) -> Option<Rect> {
let height = area.height.checked_sub(top)?.checked_sub(bottom)?;
Some(Rect {
x: area.x,
y: area.y.checked_add(top)?,
width: area.width,
height,
})
}
pub fn inset_checked(area: Rect, horizontal: u16, vertical: u16) -> Option<Rect> {
let width = area.width.checked_sub(horizontal.checked_mul(2)?)?;
let height = area.height.checked_sub(vertical.checked_mul(2)?)?;
Some(Rect {
x: area.x.checked_add(horizontal)?,
y: area.y.checked_add(vertical)?,
width,
height,
})
}

View file

@ -1,2 +0,0 @@
pub mod fit;
pub mod text_measure;

View file

@ -1,10 +0,0 @@
use unicode_width::UnicodeWidthStr;
pub fn wrapped_line_count(text: &str, width: u16) -> u16 {
if width == 0 {
return 0;
}
text.split('\n')
.map(|line| (UnicodeWidthStr::width(line).max(1) + width as usize - 1) / width as usize)
.sum::<usize>()
.min(u16::MAX as usize) as u16
}

View file

@ -1,128 +0,0 @@
use std::time::{Duration, Instant};
use ratatui::{
Frame,
layout::Rect,
text::{Line, Span},
widgets::Paragraph,
};
use crate::theme::ResolvedTheme;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NotificationKind {
Success,
Warning,
Error,
Info,
}
#[derive(Clone)]
pub struct Notification {
pub message: String,
pub kind: NotificationKind,
pub created_at: Instant,
pub duration: Duration,
}
impl Notification {
pub fn success(message: impl Into<String>) -> Self {
Self::new(message, NotificationKind::Success, Duration::from_secs(3))
}
pub fn warning(message: impl Into<String>) -> Self {
Self::new(message, NotificationKind::Warning, Duration::from_secs(4))
}
pub fn error(message: impl Into<String>) -> Self {
Self::new(message, NotificationKind::Error, Duration::from_secs(5))
}
pub fn info(message: impl Into<String>) -> Self {
Self::new(message, NotificationKind::Info, Duration::from_secs(3))
}
fn new(message: impl Into<String>, kind: NotificationKind, duration: Duration) -> Self {
Self {
message: message.into(),
kind,
created_at: Instant::now(),
duration,
}
}
pub fn is_expired(&self) -> bool {
self.created_at.elapsed() >= self.duration
}
pub fn remaining(&self) -> Duration {
self.duration.saturating_sub(self.created_at.elapsed())
}
pub fn progress(&self) -> f64 {
let elapsed = self.created_at.elapsed().as_secs_f64();
let total = self.duration.as_secs_f64();
(elapsed / total).min(1.0)
}
}
pub fn render_notification(
frame: &mut Frame,
area: Rect,
notification: &Notification,
theme: &ResolvedTheme,
) {
let (prefix, style) = match notification.kind {
NotificationKind::Success => ("", theme.status.success),
NotificationKind::Warning => ("", theme.status.warning),
NotificationKind::Error => ("", theme.status.error),
NotificationKind::Info => (" ", theme.status.info),
};
let remaining = notification.remaining().as_secs();
let progress = notification.progress();
let mut spans = vec![
Span::styled(prefix, style),
Span::styled(&notification.message, theme.text.normal),
];
if remaining > 0 {
let bar_width = 10;
let filled = ((1.0 - progress) * bar_width as f64) as usize;
let empty = bar_width - filled;
let bar: String = "".repeat(filled) + &"".repeat(empty);
spans.push(Span::styled(
format!(" [{bar}] {remaining}s"),
theme.text.muted,
));
}
let paragraph = Paragraph::new(Line::from(spans));
frame.render_widget(paragraph, area);
}
pub fn render_notification_area(
frame: &mut Frame,
area: Rect,
notifications: &[Notification],
theme: &ResolvedTheme,
) {
if notifications.is_empty() {
return;
}
let visible_height = area.height as usize;
let start = notifications.len().saturating_sub(visible_height);
let visible = &notifications[start..];
for (i, notification) in visible.iter().enumerate() {
let row = Rect {
x: area.x,
y: area.y + i as u16,
width: area.width,
height: 1,
};
render_notification(frame, row, notification, theme);
}
}

View file

@ -1,6 +0,0 @@
use crate::theme::ResolvedTheme;
/// Immutable state shared by every component during one render pass.
pub struct RenderContext<'a> {
pub theme: &'a ResolvedTheme,
}

View file

@ -1,302 +0,0 @@
use crate::{
controls::{
button::{ActionButton, ButtonIntent, render_button},
choice::{ChoiceKind, render_choice_line},
radio_group::{RadioGroup, RadioItem},
},
interaction_result::InteractionResult,
render_context::RenderContext,
screens::screens::{HitMap, Screen, UiEvent},
};
use crossterm::event::KeyCode;
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
text::{Line, Text},
widgets::{Block, Borders, Paragraph, Wrap},
};
use std::any::Any;
use tokio::sync::oneshot;
/// Kept on screen while the launcher waits for the daemon's IPC hello. The
/// setup choice screen is intentionally closed before its decision is sent,
/// so without this the terminal would otherwise be blank during startup.
pub struct DaemonStartingScreen;
impl Screen for DaemonStartingScreen {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(
&self,
frame: &mut Frame,
area: Rect,
context: &RenderContext<'_>,
_hits: &mut HitMap,
) {
let popup = crate::layout::fit::centered_rect(
area,
crate::layout::fit::RequiredSize {
width: 48,
height: 5,
},
);
frame.render_widget(
Paragraph::new(
"Starting iota-daemon…\nWaiting for its IPC handshake.\nPress Ctrl+C to cancel.",
)
.wrap(Wrap { trim: true })
.block(
Block::default()
.title(" Iota daemon ")
.borders(Borders::ALL)
.border_style(context.theme.borders.normal),
),
popup,
);
}
fn handle_event(&mut self, _: UiEvent) -> InteractionResult {
InteractionResult::Handled
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DaemonLaunchMode {
Once,
WithUi,
WithSystem,
}
#[derive(Debug, Clone)]
pub struct LaunchOption {
pub mode: DaemonLaunchMode,
pub enabled: bool,
pub reason: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DaemonSetupDecision {
Start(DaemonLaunchMode),
Exit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Focus {
Options,
Exit,
Action,
}
/// The launcher owns the actual side effects. This screen only presents the
/// capabilities discovered for this machine, keeping disabled choices visible.
pub struct DaemonSetupScreen {
choices: RadioGroup<DaemonLaunchMode>,
focus: Focus,
sender: Option<oneshot::Sender<DaemonSetupDecision>>,
message: String,
}
impl DaemonSetupScreen {
pub fn new(
options: Vec<LaunchOption>,
message: impl Into<String>,
sender: oneshot::Sender<DaemonSetupDecision>,
) -> Result<Self, crate::controls::radio_group::RadioGroupError> {
let items: Vec<RadioItem<DaemonLaunchMode>> = options
.into_iter()
.map(|o| RadioItem {
value: o.mode,
label: match o.mode {
DaemonLaunchMode::Once => "Start once",
DaemonLaunchMode::WithUi => "Start with Iota UI",
DaemonLaunchMode::WithSystem => "Start with the system",
}
.into(),
description: o.reason,
enabled: o.enabled,
disabled_reason: None,
})
.collect();
let default = items
.iter()
.find(|item| item.enabled)
.map(|item| item.value)
.ok_or(crate::controls::radio_group::RadioGroupError::NoEnabledItems)?;
let mut choices = RadioGroup::new(items, None, default)?;
choices.set_focus_policy(crate::controls::navigation::DisabledFocusPolicy::Include);
Ok(Self {
choices,
focus: Focus::Options,
sender: Some(sender),
message: message.into(),
})
}
fn complete(&mut self, d: DaemonSetupDecision) {
if let Some(tx) = self.sender.take() {
let _ = tx.send(d);
}
}
fn activate(&mut self) -> InteractionResult {
match self.focus {
Focus::Options => {
self.choices.select_focused();
InteractionResult::Handled
}
Focus::Exit => {
self.complete(DaemonSetupDecision::Exit);
InteractionResult::CloseScreen
}
Focus::Action => {
let choice = *self.choices.selected();
if self
.choices
.items()
.iter()
.find(|i| i.value == choice)
.is_some_and(|i| i.enabled)
{
self.complete(DaemonSetupDecision::Start(choice));
InteractionResult::CloseScreen
} else {
InteractionResult::Handled
}
}
}
}
fn next(&mut self) {
self.focus = match self.focus {
Focus::Options => {
self.choices.focus_next();
if self.choices.focused_item().value == DaemonLaunchMode::Once {
Focus::Exit
} else {
Focus::Options
}
}
Focus::Exit => Focus::Action,
Focus::Action => Focus::Options,
};
}
fn previous(&mut self) {
self.focus = match self.focus {
Focus::Options => {
self.choices.focus_previous();
if self.choices.focused_item().value == DaemonLaunchMode::WithSystem {
Focus::Action
} else {
Focus::Options
}
}
Focus::Exit => Focus::Options,
Focus::Action => Focus::Exit,
};
}
}
impl Screen for DaemonSetupScreen {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(
&self,
frame: &mut Frame,
area: Rect,
context: &RenderContext<'_>,
_hits: &mut HitMap,
) {
let popup = crate::layout::fit::centered_rect(
area,
crate::layout::fit::RequiredSize {
width: 68,
height: 16,
},
);
let mut lines = vec![Line::from(self.message.as_str()), Line::from("")];
for item in self.choices.items() {
lines.push(render_choice_line(
&item.label,
ChoiceKind::Radio,
self.choices.visual_state(&item.value),
context.theme,
));
if let Some(reason) = &item.description {
lines.push(Line::styled(
format!(" {reason}"),
context.theme.text.muted,
));
}
}
let rows = Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(popup);
frame.render_widget(
Paragraph::new(Text::from(lines))
.wrap(Wrap { trim: true })
.block(
Block::default()
.title(" Iota daemon setup ")
.borders(Borders::ALL),
),
rows[0],
);
let b = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(rows[1]);
render_button(
frame,
b[0],
ActionButton {
label: "Exit",
intent: ButtonIntent::Cancel,
focused: self.focus == Focus::Exit,
enabled: true,
},
context.theme,
);
let selected = *self.choices.selected();
let enabled = self
.choices
.items()
.iter()
.find(|i| i.value == selected)
.is_some_and(|i| i.enabled);
let label = match selected {
DaemonLaunchMode::Once => "Start once",
DaemonLaunchMode::WithUi => "Save and start",
DaemonLaunchMode::WithSystem => "Configure and start",
};
render_button(
frame,
b[1],
ActionButton {
label,
intent: if enabled {
ButtonIntent::Primary
} else {
ButtonIntent::Destructive
},
focused: self.focus == Focus::Action,
enabled,
},
context.theme,
);
}
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
let UiEvent::Key(event) = event else {
return InteractionResult::Unhandled;
};
match event.code {
KeyCode::Esc => {
self.complete(DaemonSetupDecision::Exit);
InteractionResult::CloseScreen
}
KeyCode::Down | KeyCode::Right | KeyCode::Tab => {
self.next();
InteractionResult::Handled
}
KeyCode::Up | KeyCode::Left | KeyCode::BackTab => {
self.previous();
InteractionResult::Handled
}
KeyCode::Enter | KeyCode::Char(' ') => self.activate(),
_ => InteractionResult::Unhandled,
}
}
}

View file

@ -1,515 +0,0 @@
use crate::{
controls::button::{ActionButton, ButtonIntent, render_button},
elements::{
console_card::ConsoleCard,
elements::{InteractableElement, JoinableElement},
graph_card::{GRAPHS, GraphCard},
log_card::LogCard,
},
interaction_result::InteractionResult,
ipc_client::{DaemonStatus, IpcConnectionState},
render_context::RenderContext,
screens::{
overview::OverviewScreen,
screens::{AppAction, HitMap, KeyHint, NavDirection, Screen, UiEvent},
},
ui::UI,
};
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
widgets::Borders,
};
use tokio::sync::watch;
use std::{
any::Any,
sync::{
Arc,
atomic::{AtomicU16, Ordering},
},
};
pub struct MainScreen {
elements: Vec<Box<dyn InteractableElement>>,
nav_grid: Vec<Vec<Option<usize>>>,
selected_coords: (usize, usize),
graphs_open: bool,
connection_status_rx: watch::Receiver<IpcConnectionState>,
daemon_status_rx: watch::Receiver<DaemonStatus>,
layout_width: AtomicU16,
}
impl MainScreen {
pub fn connection_status(&self) -> watch::Receiver<IpcConnectionState> {
self.connection_status_rx.clone()
}
pub fn daemon_status(&self) -> watch::Receiver<DaemonStatus> {
self.daemon_status_rx.clone()
}
pub async fn new(ui: Arc<UI>) -> Self {
let mut elements: Vec<Box<dyn InteractableElement>> = Vec::new();
let nav_grid = vec![
vec![Some(0), Some(2)],
vec![Some(0), Some(3)],
vec![Some(1), Some(4)],
];
let state = ui
.client_state()
.await
.expect("MainScreen requires an attached daemon");
let mut log_card = LogCard::new(state.clone());
log_card.set_borders(Borders::TOP.union(Borders::RIGHT).union(Borders::LEFT));
let ipc = ui
.ipc()
.await
.expect("MainScreen requires an attached daemon");
let mut console_card = ConsoleCard::new("Console", "", ipc.clone());
console_card.set_joins(Borders::TOP);
elements.push(Box::new(log_card));
elements.push(Box::new(console_card));
let mut ram_graph = GraphCard::new(ui.clone(), state.clone(), GRAPHS::Ram, "RAM".into());
ram_graph.set_borders(Borders::TOP.union(Borders::LEFT).union(Borders::RIGHT));
elements.push(Box::new(ram_graph));
let mut cpu_graph = GraphCard::new(ui.clone(), state.clone(), GRAPHS::Cpu, "CPU".into());
cpu_graph.set_borders(Borders::TOP.union(Borders::LEFT).union(Borders::RIGHT));
cpu_graph.set_joins(Borders::TOP);
elements.push(Box::new(cpu_graph));
let mut ping_graph = GraphCard::new(ui.clone(), state, GRAPHS::Ping, "Ping".into());
ping_graph.set_joins(Borders::TOP);
elements.push(Box::new(ping_graph));
let graphs_open = true;
let connection_status_rx = ipc.connection_status();
let daemon_status_rx = ipc.daemon_status();
let mut screen = MainScreen {
elements,
nav_grid,
selected_coords: (1, 0),
graphs_open,
connection_status_rx,
daemon_status_rx,
layout_width: AtomicU16::new(0),
};
screen.focus_current();
screen
}
fn focus_current(&mut self) {
let (y, x) = self.selected_coords;
if let Some(Some(index)) = self.nav_grid.get(y).and_then(|row| row.get(x)) {
if let Some(element) = self.elements.get_mut(*index) {
if element.can_focus() {
element.focus(true);
}
}
}
}
fn unfocus_current(&mut self, y: usize, x: usize) {
if let Some(Some(index)) = self.nav_grid.get(y).and_then(|row| row.get(x)) {
if let Some(element) = self.elements.get_mut(*index) {
element.focus(false);
}
}
}
fn navigate(&mut self, direction: NavDirection) {
let (current_row, current_col) = self.selected_coords;
let current_element = self.nav_grid[current_row][current_col];
self.unfocus_current(current_row, current_col);
let (delta_row, delta_col) = match direction {
NavDirection::Up => (-1isize, 0),
NavDirection::Down => (1, 0),
NavDirection::Left => (0, -1),
NavDirection::Right => (0, 1),
_ => (0, 0),
};
let mut next_row = current_row as isize;
let mut next_col = current_col as isize;
loop {
next_row += delta_row;
next_col += delta_col;
if next_row < 0 || next_col < 0 {
self.selected_coords = (
(next_row - delta_row) as usize,
(next_col - delta_col) as usize,
);
break;
}
let next_row_u = next_row as usize;
let next_col_u = next_col as usize;
if next_row_u >= self.nav_grid.len() {
self.selected_coords = (
(next_row - delta_row) as usize,
(next_col - delta_col) as usize,
);
break;
}
if let Some(row) = self.nav_grid.get(next_row_u) {
if next_col_u >= row.len() {
self.selected_coords = (
(next_row - delta_row) as usize,
(next_col - delta_col) as usize,
);
break;
}
if let Some(next_element) = row[next_col_u] {
if Some(next_element) != current_element {
self.selected_coords = (next_row_u, next_col_u);
self.focus_current();
return;
}
}
}
}
self.focus_current();
}
/// Cycle focus between unique elements in the navigation grid.
fn navigate_focus(&mut self, forward: bool) {
// Collect unique elements in grid order.
let mut positions: Vec<(usize, usize)> = Vec::new(); // (row, col)
let mut seen: Vec<Option<usize>> = Vec::new();
for (y, row) in self.nav_grid.iter().enumerate() {
for (x, elem_opt) in row.iter().enumerate() {
if x == 1 && (!self.graphs_open || self.layout_width.load(Ordering::Relaxed) < 70) {
continue;
}
if elem_opt.is_some() && !seen.contains(elem_opt) {
seen.push(*elem_opt);
positions.push((y, x));
}
}
}
let current = self.selected_coords;
let current_pos = positions
.iter()
.position(|&(r, c)| r == current.0 && c == current.1);
let next_pos = if let Some(idx) = current_pos {
if forward {
(idx + 1) % positions.len()
} else {
(idx + positions.len() - 1) % positions.len()
}
} else {
0
};
self.unfocus_current(self.selected_coords.0, self.selected_coords.1);
self.selected_coords = positions[next_pos];
self.focus_current();
}
}
impl Screen for MainScreen {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap) {
self.layout_width.store(rect.width, Ordering::Relaxed);
f.render_widget(
ratatui::widgets::Block::default().style(context.theme.surfaces.canvas),
rect,
);
let inner = rect;
let metrics_visible = self.graphs_open && inner.width >= 70;
let graphs_width = if metrics_visible { 30 } else { 0 };
let main_width = inner.width.saturating_sub(graphs_width);
let horizontal_chunks = Layout::default()
.direction(ratatui::layout::Direction::Horizontal)
.constraints([
Constraint::Length(main_width),
Constraint::Length(graphs_width),
])
.split(inner);
let left_area = horizontal_chunks[0];
let right_area = horizontal_chunks[1];
hits.register(left_area, AppAction::FocusLogs);
if metrics_visible {
hits.register(right_area, AppAction::FocusMetrics);
}
if inner.width >= 70 {
let metrics_button = Rect {
x: right_area.x,
y: right_area.y,
width: right_area.width,
height: 1,
};
render_button(
f,
metrics_button,
ActionButton {
label: if self.graphs_open {
"Hide metrics"
} else {
"Show metrics"
},
intent: ButtonIntent::Neutral,
focused: false,
enabled: true,
},
context.theme,
);
hits.register(metrics_button, AppAction::ToggleMetrics);
}
let left_rows =
Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(left_area);
hits.register(left_rows[1], AppAction::FocusConsole);
if let Some(log) = self.elements.get(0) {
log.as_element().render(f, left_rows[0], context);
}
if let Some(console) = self.elements.get(1) {
console.as_element().render(f, left_rows[1], context);
}
let graph_elements: Vec<_> = self
.elements
.iter()
.filter(|el| el.as_any().is::<GraphCard>())
.collect();
if metrics_visible && !graph_elements.is_empty() {
let graph_chunks = Layout::vertical(
graph_elements
.iter()
.map(|_| Constraint::Ratio(1, graph_elements.len() as u32))
.collect::<Vec<_>>(),
)
.split(right_area);
for (el, area) in graph_elements.iter().zip(graph_chunks.iter()) {
el.as_element().render(f, *area, context);
}
}
}
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
if let UiEvent::Paste(text) = &event {
if self.selected_coords == (2, 0) {
if let Some(console) = self
.elements
.get_mut(1)
.and_then(|element| element.as_any_mut().downcast_mut::<ConsoleCard>())
{
console.handle_paste(text);
return InteractionResult::Handled;
}
}
return InteractionResult::Unhandled;
}
if let UiEvent::Resize(width, _) = &event {
self.layout_width.store(*width, Ordering::Relaxed);
if *width < 70 && self.selected_coords.1 == 1 {
self.unfocus_current(self.selected_coords.0, self.selected_coords.1);
self.selected_coords = (0, 0);
self.focus_current();
}
return InteractionResult::Handled;
}
let UiEvent::Key(event) = event else {
return InteractionResult::Unhandled;
};
// A focused console consumes text and cursor keys before dashboard
// shortcuts; commands such as `users` must remain typeable.
if self.selected_coords == (2, 0) && !matches!(event.code, KeyCode::Tab | KeyCode::BackTab)
{
if let Some(console) = self.elements.get_mut(1) {
return console.interact(event);
}
}
match event.code {
KeyCode::Tab => {
self.navigate_focus(true);
return InteractionResult::Handled;
}
KeyCode::BackTab => {
self.navigate_focus(false);
return InteractionResult::Handled;
}
KeyCode::Char('o') | KeyCode::Char('O') => {
let conn_rx = self.connection_status_rx.clone();
let daemon_rx = self.daemon_status_rx.clone();
return InteractionResult::OpenScreen {
screen: Box::new(OverviewScreen::new(conn_rx, daemon_rx)),
};
}
KeyCode::Char('u') | KeyCode::Char('U') => {
return InteractionResult::AppTask {
task: Box::pin(async {
UiEvent::App(crate::screens::screens::AppEvent::OpenUsers)
}),
};
}
KeyCode::Char('m') | KeyCode::Char('M') => {
return InteractionResult::AppTask {
task: Box::pin(async {
UiEvent::App(crate::screens::screens::AppEvent::OpenMetrics)
}),
};
}
KeyCode::Enter | KeyCode::Char(' ') if self.selected_coords.1 == 1 => {
self.graphs_open = !self.graphs_open;
for element in self.elements.iter_mut() {
if let Some(graph) = element.as_any_mut().downcast_mut::<GraphCard>() {
graph.set_open(self.graphs_open);
}
}
return InteractionResult::Handled;
}
_ => {
let (y, x) = self.selected_coords;
if let Some(Some(index)) = self.nav_grid.get(y).and_then(|r| r.get(x)) {
if let Some(el) = self.elements.get_mut(*index) {
let result = el.interact(event);
if matches!(result, InteractionResult::Unhandled) {
match event.code {
KeyCode::Up => self.navigate(NavDirection::Up),
KeyCode::Down => self.navigate(NavDirection::Down),
KeyCode::Left => self.navigate(NavDirection::Left),
KeyCode::Right => self.navigate(NavDirection::Right),
_ => {}
}
}
return result;
}
}
}
}
InteractionResult::Handled
}
fn handle_action(&mut self, action: AppAction) -> InteractionResult {
match action {
AppAction::ToggleMetrics => {
self.graphs_open = !self.graphs_open;
for element in &mut self.elements {
if let Some(graph) = element.as_any_mut().downcast_mut::<GraphCard>() {
graph.set_open(self.graphs_open);
}
}
InteractionResult::Handled
}
AppAction::OpenOverview => {
self.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Char('o'))))
}
AppAction::OpenUsers => {
self.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Char('u'))))
}
AppAction::FocusLogs => {
self.unfocus_current(self.selected_coords.0, self.selected_coords.1);
self.selected_coords = (0, 0);
self.focus_current();
InteractionResult::Handled
}
AppAction::FocusConsole => {
self.unfocus_current(self.selected_coords.0, self.selected_coords.1);
self.selected_coords = (2, 0);
self.focus_current();
InteractionResult::Handled
}
AppAction::FocusMetrics => {
self.unfocus_current(self.selected_coords.0, self.selected_coords.1);
self.selected_coords = (0, 1);
self.focus_current();
InteractionResult::Handled
}
_ => InteractionResult::Unhandled,
}
}
fn key_hints(&self) -> Vec<KeyHint> {
if self.selected_coords == (2, 0) {
vec![
KeyHint {
keys: "Enter",
action: "Send",
},
KeyHint {
keys: "Up/Down",
action: "History",
},
KeyHint {
keys: "Tab",
action: "Complete",
},
KeyHint {
keys: "F6",
action: "Header",
},
]
} else if self.selected_coords == (0, 0) {
vec![
KeyHint {
keys: "J/K",
action: "Scroll logs",
},
KeyHint {
keys: "Enter",
action: "Lock scroll",
},
KeyHint {
keys: "/",
action: "Filter",
},
KeyHint {
keys: "M",
action: "Metrics screen",
},
KeyHint {
keys: "Tab",
action: "Next panel",
},
KeyHint {
keys: "F6",
action: "Header",
},
]
} else {
vec![
KeyHint {
keys: "Enter",
action: "Toggle metrics",
},
KeyHint {
keys: "Tab",
action: "Next panel",
},
KeyHint {
keys: "F6",
action: "Header",
},
]
}
}
}

View file

@ -1,136 +0,0 @@
use std::any::Any;
use crossterm::event::KeyCode;
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
widgets::{Block, Borders, Paragraph},
};
use crate::{
elements::{
elements::Element,
graph_card::{GRAPHS, GraphCard},
},
interaction_result::InteractionResult,
render_context::RenderContext,
screens::screens::{AppAction, HitMap, KeyHint, Screen, UiEvent},
ui::UI,
};
const RANGES: &[(usize, &str)] = &[(30, "Recent"), (120, "Medium"), (300, "Long")];
pub struct MetricsScreen {
graphs: Vec<GraphCard>,
range_index: usize,
}
impl MetricsScreen {
pub async fn new(ui: std::sync::Arc<UI>) -> Option<Self> {
let state = ui.client_state().await?;
let mut screen = Self {
graphs: vec![
GraphCard::new(ui.clone(), state.clone(), GRAPHS::Ram, "RAM".into()),
GraphCard::new(ui.clone(), state.clone(), GRAPHS::Cpu, "CPU".into()),
GraphCard::new(ui, state, GRAPHS::Ping, "Ping".into()),
],
range_index: 0,
};
screen.apply_range();
Some(screen)
}
fn apply_range(&mut self) {
let width = RANGES[self.range_index].0;
for graph in &mut self.graphs {
graph.set_sample_width(width);
}
}
fn change_range(&mut self, delta: isize) {
self.range_index =
(self.range_index as isize + delta).clamp(0, RANGES.len() as isize - 1) as usize;
self.apply_range();
}
}
impl Screen for MetricsScreen {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(
&self,
frame: &mut Frame,
area: Rect,
context: &RenderContext<'_>,
hits: &mut HitMap,
) {
let block = Block::default()
.title(" Metrics ")
.borders(Borders::ALL)
.border_style(context.theme.borders.normal);
let inner = block.inner(area);
frame.render_widget(block, area);
let rows = Layout::vertical([
Constraint::Length(1),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
Constraint::Ratio(1, 3),
])
.split(inner);
frame.render_widget(
Paragraph::new(format!(
"Range: {} ({} samples) Left/Right to change",
RANGES[self.range_index].1, RANGES[self.range_index].0
))
.style(context.theme.text.heading),
rows[0],
);
for (graph, graph_area) in self.graphs.iter().zip(rows[1..].iter()) {
graph.render(frame, *graph_area, context);
}
hits.register(rows[0], AppAction::OpenMetrics);
}
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
let UiEvent::Key(key) = event else {
return InteractionResult::Unhandled;
};
match key.code {
KeyCode::Left => {
self.change_range(-1);
InteractionResult::Handled
}
KeyCode::Right => {
self.change_range(1);
InteractionResult::Handled
}
KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => {
InteractionResult::CloseScreen
}
_ => InteractionResult::Unhandled,
}
}
fn key_hints(&self) -> Vec<KeyHint> {
vec![
KeyHint {
keys: "Left/Right",
action: "Range",
},
KeyHint {
keys: "Esc/B",
action: "Back",
},
KeyHint {
keys: "F6",
action: "Header",
},
]
}
}

View file

@ -1,302 +0,0 @@
use crate::{
controls::button::{ActionButton, ButtonIntent, render_button},
interaction_result::InteractionResult,
ipc_client::{DaemonStatus, IpcConnectionState},
render_context::RenderContext,
screens::screens::{AppAction, HitMap, KeyHint, Screen, UiEvent},
};
use crossterm::event::KeyCode;
use ratatui::{
Frame,
layout::Rect,
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
};
use std::{
any::Any,
sync::atomic::{AtomicUsize, Ordering},
};
use tokio::sync::watch;
pub struct OverviewScreen {
connection_rx: watch::Receiver<IpcConnectionState>,
daemon_rx: watch::Receiver<DaemonStatus>,
_focus: Focus,
scroll_offset: usize,
content_height: AtomicUsize,
viewport_height: AtomicUsize,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Focus {
Back,
}
impl OverviewScreen {
pub fn new(
connection_rx: watch::Receiver<IpcConnectionState>,
daemon_rx: watch::Receiver<DaemonStatus>,
) -> Self {
Self {
connection_rx,
daemon_rx,
_focus: Focus::Back,
scroll_offset: 0,
content_height: AtomicUsize::new(0),
viewport_height: AtomicUsize::new(1),
}
}
fn build_lines(&self, theme: &crate::theme::ResolvedTheme) -> Vec<Line<'static>> {
let conn = self.connection_rx.borrow().clone();
let daemon = self.daemon_rx.borrow().clone();
let mut lines = Vec::new();
lines.push(Line::from(Span::styled("Connection", theme.text.heading)));
lines.push(Line::from(format!(" State: {}", connection_label(&conn))));
let omikron = daemon.components.get(&iota_ipc::ComponentId::Omikron);
let omikron_label = match omikron.map(|health| health.status) {
Some(iota_ipc::HealthStatus::Healthy) => "[OK] Connected",
Some(iota_ipc::HealthStatus::Degraded) => "[WARN] Connecting or unavailable",
Some(iota_ipc::HealthStatus::Failed) => "[FAIL] Authentication failed",
None => "Unknown",
};
lines.push(Line::from(format!(" Omikron: {omikron_label}")));
if let Some(message) = omikron.and_then(|health| health.message.as_deref()) {
lines.push(Line::from(format!(" Omikron detail: {message}")));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("Daemon", theme.text.heading)));
lines.push(Line::from(format!(
" Version: {}",
version_or_unknown(&daemon.version)
)));
lines.push(Line::from(format!(
" Instance: {}",
truncate_id(&daemon.instance_id)
)));
let phase = daemon
.startup_phase
.map(|p| format!("{:?}", p))
.unwrap_or_else(|| "Unknown".into());
lines.push(Line::from(format!(" Phase: {}", phase)));
let lifecycle = daemon
.lifecycle
.map(|l| format!("{:?}", l))
.unwrap_or_else(|| "Unknown".into());
lines.push(Line::from(format!(" Lifecycle: {}", lifecycle)));
let health = match daemon.health {
iota_ipc::HealthStatus::Healthy => "[OK] Healthy",
iota_ipc::HealthStatus::Degraded => "[WARN] Degraded",
iota_ipc::HealthStatus::Failed => "[FAIL] Failed",
};
lines.push(Line::from(format!(" Health: {health}")));
if let Some(ref reason) = daemon.degraded_reason {
lines.push(Line::from(Span::styled(
format!(" Degraded: {reason}"),
theme.status.warning,
)));
}
let mode = daemon
.deployment_mode
.map(|m| format!("{:?}", m))
.unwrap_or_else(|| "Unknown".into());
lines.push(Line::from(format!(" Deployment: {mode}")));
let supervisor = daemon
.supervisor
.map(|s| format!("{:?}", s))
.unwrap_or_else(|| "Unknown".into());
lines.push(Line::from(format!(" Supervisor: {supervisor}")));
if !daemon.components.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("Components", theme.text.heading)));
for (id, health) in &daemon.components {
let status_str = match health.status {
iota_ipc::HealthStatus::Healthy => "[OK] healthy",
iota_ipc::HealthStatus::Degraded => "[WARN] degraded",
iota_ipc::HealthStatus::Failed => "[FAIL] failed",
};
let suffix = health
.message
.as_deref()
.map(|m| format!(" ({m})"))
.unwrap_or_default();
lines.push(Line::from(format!(" {:?}: {}{}", id, status_str, suffix)));
}
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Press Esc or B to return to the dashboard",
theme.text.muted,
)));
lines
}
}
fn connection_label(conn: &IpcConnectionState) -> String {
match conn {
IpcConnectionState::Connected => "Connected".into(),
IpcConnectionState::Connecting => "Connecting...".into(),
IpcConnectionState::Reconnecting { attempt } => {
format!("Reconnecting (attempt {attempt})...")
}
IpcConnectionState::Incompatible { message } => {
format!("Incompatible: {message}")
}
IpcConnectionState::Failed { message } => format!("Failed: {message}"),
IpcConnectionState::Disconnected => "Disconnected".into(),
}
}
fn version_or_unknown(v: &str) -> String {
if v.is_empty() {
"Unknown".into()
} else {
v.into()
}
}
fn truncate_id(id: &str) -> String {
if id.len() > 8 {
format!("{}", &id[..8])
} else {
id.into()
}
}
impl Screen for OverviewScreen {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
let block = Block::default()
.title(" Overview ")
.borders(Borders::ALL)
.border_style(context.theme.borders.normal)
.title_style(context.theme.borders.title);
let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
crate::controls::panel::render_panel(f, rect, "Overview", false, context.theme)
} else {
let inner = block.inner(rect);
f.render_widget(block, rect);
inner
};
let rows = ratatui::layout::Layout::vertical([
ratatui::layout::Constraint::Min(1),
ratatui::layout::Constraint::Length(1),
])
.split(inner);
let lines = self.build_lines(context.theme);
self.content_height.store(lines.len(), Ordering::Relaxed);
self.viewport_height
.store(rows[0].height as usize, Ordering::Relaxed);
let par = Paragraph::new(lines)
.wrap(Wrap { trim: true })
.scroll((self.scroll_offset as u16, 0));
f.render_widget(par, rows[0]);
render_button(
f,
rows[1],
ActionButton {
label: "Back",
intent: ButtonIntent::Cancel,
focused: self._focus == Focus::Back,
enabled: true,
},
context.theme,
);
_hits.register(rows[1], AppAction::Back);
}
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
let UiEvent::Key(event) = event else {
return InteractionResult::Unhandled;
};
match event.code {
KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => {
InteractionResult::CloseScreen
}
KeyCode::Down | KeyCode::Char('j') => {
let max = self
.content_height
.load(Ordering::Relaxed)
.saturating_sub(self.viewport_height.load(Ordering::Relaxed));
self.scroll_offset = self.scroll_offset.saturating_add(1).min(max);
InteractionResult::Handled
}
KeyCode::Up | KeyCode::Char('k') => {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
InteractionResult::Handled
}
KeyCode::PageDown => {
let page = self.viewport_height.load(Ordering::Relaxed).max(1);
let max = self
.content_height
.load(Ordering::Relaxed)
.saturating_sub(page);
self.scroll_offset = self.scroll_offset.saturating_add(page).min(max);
InteractionResult::Handled
}
KeyCode::PageUp => {
let page = self.viewport_height.load(Ordering::Relaxed).max(1);
self.scroll_offset = self.scroll_offset.saturating_sub(page);
InteractionResult::Handled
}
KeyCode::Home => {
self.scroll_offset = 0;
InteractionResult::Handled
}
KeyCode::End => {
self.scroll_offset = self
.content_height
.load(Ordering::Relaxed)
.saturating_sub(self.viewport_height.load(Ordering::Relaxed));
InteractionResult::Handled
}
_ => InteractionResult::Unhandled,
}
}
fn handle_action(&mut self, action: AppAction) -> InteractionResult {
if action == AppAction::Back {
InteractionResult::CloseScreen
} else {
InteractionResult::Unhandled
}
}
fn key_hints(&self) -> Vec<KeyHint> {
vec![
KeyHint {
keys: "Up/Down",
action: "Scroll",
},
KeyHint {
keys: "PgUp/PgDn",
action: "Page",
},
KeyHint {
keys: "Esc/B",
action: "Back",
},
KeyHint {
keys: "F6",
action: "Header",
},
]
}
}

View file

@ -1,143 +0,0 @@
use std::any::Any;
use crossterm::event::{KeyEvent, MouseEvent};
use ratatui::{Frame, layout::Rect};
use crate::{interaction_result::InteractionResult, render_context::RenderContext};
/// All terminal input that can affect the UI. Keeping this as one type makes
/// it impossible for screens to accidentally ignore a newly supported event.
#[derive(Debug, Clone)]
pub enum UiEvent {
Key(KeyEvent),
Mouse(MouseEvent),
Paste(String),
Resize(u16, u16),
App(AppEvent),
}
/// Completion of background UI work. Keeping it in the regular event stream
/// gives screens an explicit success/failure path instead of detached tasks.
#[derive(Debug, Clone)]
pub enum AppEvent {
OpenUsers,
OpenMetrics,
ApplyTheme {
theme: crate::theme::ThemeName,
persist: bool,
},
SaveSettings {
theme: crate::theme::ThemeName,
color: crate::theme::TerminalPolicy,
unicode: crate::theme::TerminalPolicy,
cli_output: crate::theme::CliOutputFormat,
cli_require_confirmation: bool,
},
ThemeSaved(Result<(), String>),
UsersLoaded(Result<Vec<crate::screens::users::UserEntry>, String>),
UserCreated(Result<crate::screens::users::UserEntry, String>),
UserRemoved {
user_id: i64,
result: Result<(), String>,
},
RegenerateKeysRequested,
KeysRegenerated(Result<(), String>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppAction {
OpenOverview,
OpenUsers,
OpenSettings,
OpenMetrics,
ToggleMetrics,
AddUser,
RemoveUser,
Back,
Quit,
FocusLogs,
FocusConsole,
FocusMetrics,
OpenMain,
SelectUser(usize),
ConfirmDialog,
CancelDialog,
RegenerateKeys,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct KeyHint {
pub keys: &'static str,
pub action: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HitRegion {
pub area: Rect,
pub action: AppAction,
}
#[derive(Debug, Default, Clone)]
pub struct HitMap {
regions: Vec<HitRegion>,
}
impl HitMap {
pub fn register(&mut self, area: Rect, action: AppAction) {
self.regions.push(HitRegion { area, action });
}
pub fn action_at(&self, column: u16, row: u16) -> Option<AppAction> {
self.regions
.iter()
.rev()
.find(|region| {
column >= region.area.x
&& column < region.area.x.saturating_add(region.area.width)
&& row >= region.area.y
&& row < region.area.y.saturating_add(region.area.height)
})
.map(|region| region.action)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NavDirection {
Up,
Down,
Left,
Right,
Next,
Prev,
}
pub trait Screen: Send + Sync + Any {
fn as_any(&self) -> &dyn Any;
fn as_any_mut(&mut self) -> &mut dyn Any;
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap);
fn handle_event(&mut self, event: UiEvent) -> InteractionResult;
fn handle_action(&mut self, _action: AppAction) -> InteractionResult {
InteractionResult::Unhandled
}
fn key_hints(&self) -> Vec<KeyHint> {
vec![
KeyHint {
keys: "Tab",
action: "Move focus",
},
KeyHint {
keys: "Enter",
action: "Activate",
},
KeyHint {
keys: "Esc",
action: "Back",
},
KeyHint {
keys: "F6",
action: "Header",
},
]
}
}

View file

@ -1,475 +0,0 @@
use std::any::Any;
use crossterm::event::KeyCode;
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use crate::{
controls::button::{ActionButton, ButtonIntent, render_button},
interaction_result::InteractionResult,
render_context::RenderContext,
screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent},
theme::{CliOutputFormat, TerminalPolicy, ThemeName, UiConfig},
};
#[derive(Clone, Copy, PartialEq, Eq)]
enum Focus {
Theme,
CliOutput,
CliConfirm,
RegenerateKeys,
Back,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Dialog {
ConfirmRegenerateKeys,
}
pub struct SettingsScreen {
selected: usize,
saved: ThemeName,
message: String,
color: TerminalPolicy,
unicode: TerminalPolicy,
cli_output: CliOutputFormat,
cli_require_confirmation: bool,
focus: Focus,
dialog: Option<Dialog>,
pending: bool,
}
impl SettingsScreen {
pub fn new(current: ThemeName) -> Self {
let selected = ThemeName::ALL
.iter()
.position(|theme| *theme == current)
.unwrap_or(0);
let config = UiConfig::load_or_default();
Self {
selected,
saved: current,
message: "Left/Right previews. Enter saves.".into(),
color: config.color,
unicode: config.unicode,
cli_output: config.cli_output,
cli_require_confirmation: config.cli_require_confirmation,
focus: Focus::Theme,
dialog: None,
pending: false,
}
}
fn selected_theme(&self) -> ThemeName {
ThemeName::ALL[self.selected]
}
fn apply(&self, persist: bool) -> InteractionResult {
let theme = self.selected_theme();
InteractionResult::AppTask {
task: Box::pin(async move { UiEvent::App(AppEvent::ApplyTheme { theme, persist }) }),
}
}
fn next_policy(policy: TerminalPolicy) -> TerminalPolicy {
match policy {
TerminalPolicy::Auto => TerminalPolicy::Always,
TerminalPolicy::Always => TerminalPolicy::Never,
TerminalPolicy::Never => TerminalPolicy::Auto,
}
}
fn next_focus(&mut self) {
self.focus = match self.focus {
Focus::Theme => Focus::CliOutput,
Focus::CliOutput => Focus::CliConfirm,
Focus::CliConfirm => Focus::RegenerateKeys,
Focus::RegenerateKeys => Focus::Back,
Focus::Back => Focus::Theme,
};
}
fn prev_focus(&mut self) {
self.focus = match self.focus {
Focus::Theme => Focus::Back,
Focus::Back => Focus::RegenerateKeys,
Focus::RegenerateKeys => Focus::CliConfirm,
Focus::CliConfirm => Focus::CliOutput,
Focus::CliOutput => Focus::Theme,
};
}
fn activate(&mut self) -> InteractionResult {
if self.pending {
return InteractionResult::Handled;
}
if let Some(dialog) = self.dialog.take() {
match dialog {
Dialog::ConfirmRegenerateKeys => {
self.pending = true;
self.message = "Regenerating keys…".into();
return InteractionResult::AppTask {
task: Box::pin(async { UiEvent::App(AppEvent::RegenerateKeysRequested) }),
};
}
}
}
match self.focus {
Focus::Theme => {
self.message = "Saving theme…".into();
}
Focus::CliOutput => {
self.message = "Output format updated.".into();
return InteractionResult::Handled;
}
Focus::CliConfirm => {
self.cli_require_confirmation = !self.cli_require_confirmation;
self.message = format!(
"Confirm: {}",
if self.cli_require_confirmation {
"On"
} else {
"Off"
},
);
return InteractionResult::Handled;
}
Focus::RegenerateKeys => {
self.dialog = Some(Dialog::ConfirmRegenerateKeys);
return InteractionResult::Handled;
}
Focus::Back => return InteractionResult::CloseScreen,
}
let theme = self.selected_theme();
let color = self.color;
let unicode = self.unicode;
let cli_output = self.cli_output;
let cli_require_confirmation = self.cli_require_confirmation;
InteractionResult::AppTask {
task: Box::pin(async move {
UiEvent::App(AppEvent::SaveSettings {
theme,
color,
unicode,
cli_output,
cli_require_confirmation,
})
}),
}
}
}
impl Screen for SettingsScreen {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(
&self,
frame: &mut Frame,
area: Rect,
context: &RenderContext<'_>,
hits: &mut HitMap,
) {
let header_block = Block::default()
.title(" Settings ")
.borders(Borders::ALL)
.border_style(context.theme.borders.focused);
let inner = header_block.inner(area);
frame.render_widget(header_block, area);
let sections = Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).split(inner);
frame.render_widget(
Paragraph::new(format!(
"Theme: < {} >{}\nColor: {:?} (C) Unicode: {:?} (U)",
self.selected_theme(),
if self.selected_theme() == self.saved {
" [saved]"
} else {
" [preview]"
},
self.color,
self.unicode,
))
.style(context.theme.text.heading),
sections[0],
);
let cli_line = format!(
"CLI output: {:?} (L) Confirm: {} (K)",
self.cli_output,
if self.cli_require_confirmation {
"required"
} else {
"disabled"
},
);
let bottom_rows =
Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(sections[1]);
let lines = vec![
Line::from(Span::styled(&self.message, context.theme.text.normal)),
Line::from(Span::styled(&cli_line, context.theme.text.normal)),
Line::from("Preview"),
Line::from("[OK] Healthy"),
Line::from("[WARN] Degraded"),
Line::from("[FAIL] Failed"),
Line::from("> Focused action <"),
];
frame.render_widget(
Paragraph::new(lines).style(context.theme.text.normal),
bottom_rows[0],
);
let buttons_area = Layout::horizontal([
Constraint::Percentage(33),
Constraint::Percentage(34),
Constraint::Percentage(33),
])
.split(bottom_rows[1]);
render_button(
frame,
buttons_area[0],
ActionButton {
label: "Back",
intent: ButtonIntent::Cancel,
focused: self.focus == Focus::Back && self.dialog.is_none(),
enabled: true,
},
context.theme,
);
hits.register(buttons_area[0], AppAction::Back);
render_button(
frame,
buttons_area[1],
ActionButton {
label: "Regenerate Keys",
intent: ButtonIntent::Destructive,
focused: self.focus == Focus::RegenerateKeys && self.dialog.is_none(),
enabled: !self.pending,
},
context.theme,
);
hits.register(buttons_area[1], AppAction::RegenerateKeys);
if self.dialog.is_some() {
frame.render_widget(Block::default().style(context.theme.surfaces.overlay), area);
let popup = crate::layout::fit::centered_rect(
area,
crate::layout::fit::RequiredSize {
width: 42,
height: 7,
},
);
let block = Block::default()
.title(" Confirm ")
.borders(Borders::ALL)
.border_style(context.theme.borders.focused)
.style(context.theme.surfaces.overlay);
let popup_inner = block.inner(popup);
frame.render_widget(block, popup);
let dialog_rows =
Layout::vertical([Constraint::Min(2), Constraint::Length(1)]).split(popup_inner);
frame.render_widget(
Paragraph::new("Regenerate the identity key pair?\nThis will rotate keys and reconnect to Omikron.").style(context.theme.text.normal),
dialog_rows[0],
);
let dialog_buttons =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(dialog_rows[1]);
render_button(
frame,
dialog_buttons[0],
ActionButton {
label: "Cancel",
intent: ButtonIntent::Cancel,
focused: false,
enabled: true,
},
context.theme,
);
render_button(
frame,
dialog_buttons[1],
ActionButton {
label: "Regenerate",
intent: ButtonIntent::Destructive,
focused: true,
enabled: true,
},
context.theme,
);
hits.register(dialog_buttons[0], AppAction::CancelDialog);
hits.register(dialog_buttons[1], AppAction::ConfirmDialog);
}
}
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
let event = match event {
UiEvent::App(AppEvent::ThemeSaved(result)) => {
match result {
Ok(()) => {
self.saved = self.selected_theme();
self.message = "Theme saved to ui.yaml.".into();
}
Err(error) => self.message = error,
}
return InteractionResult::Handled;
}
UiEvent::App(AppEvent::KeysRegenerated(result)) => {
self.pending = false;
self.dialog = None;
match result {
Ok(()) => self.message = "Keys regenerated successfully.".into(),
Err(error) => self.message = error,
}
return InteractionResult::Handled;
}
event => event,
};
if self.dialog.is_some() {
let UiEvent::Key(key) = event else {
return InteractionResult::Unhandled;
};
return match key.code {
KeyCode::Esc => {
self.dialog = None;
InteractionResult::Handled
}
KeyCode::Enter => self.activate(),
_ => InteractionResult::Handled,
};
}
let UiEvent::Key(key) = event else {
return InteractionResult::Unhandled;
};
match key.code {
KeyCode::Left => {
if self.focus == Focus::Theme {
self.selected = self.selected.saturating_sub(1);
self.apply(false)
} else {
InteractionResult::Handled
}
}
KeyCode::Right => {
if self.focus == Focus::Theme {
self.selected = (self.selected + 1).min(ThemeName::ALL.len() - 1);
self.apply(false)
} else {
InteractionResult::Handled
}
}
KeyCode::Enter | KeyCode::Char(' ') => self.activate(),
KeyCode::Tab => {
self.next_focus();
InteractionResult::Handled
}
KeyCode::BackTab => {
self.prev_focus();
InteractionResult::Handled
}
KeyCode::Char('c') | KeyCode::Char('C') => {
self.color = Self::next_policy(self.color);
InteractionResult::Handled
}
KeyCode::Char('u') | KeyCode::Char('U') => {
self.unicode = Self::next_policy(self.unicode);
InteractionResult::Handled
}
KeyCode::Char('l') | KeyCode::Char('L') => {
self.cli_output = self.cli_output.next();
self.message = format!("CLI output: {:?}", self.cli_output);
InteractionResult::Handled
}
KeyCode::Char('k') | KeyCode::Char('K') => {
self.cli_require_confirmation = !self.cli_require_confirmation;
self.message = format!(
"CLI confirm: {}",
if self.cli_require_confirmation {
"On"
} else {
"Off"
},
);
InteractionResult::Handled
}
KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => {
InteractionResult::CloseScreen
}
_ => InteractionResult::Unhandled,
}
}
fn handle_action(&mut self, action: AppAction) -> InteractionResult {
match action {
AppAction::Back => InteractionResult::CloseScreen,
AppAction::RegenerateKeys => {
self.focus = Focus::RegenerateKeys;
self.activate()
}
AppAction::ConfirmDialog if self.dialog.is_some() => self.activate(),
AppAction::CancelDialog if self.dialog.is_some() => {
self.dialog = None;
InteractionResult::Handled
}
_ => InteractionResult::Unhandled,
}
}
fn key_hints(&self) -> Vec<KeyHint> {
if self.dialog.is_some() {
vec![
KeyHint {
keys: "Enter",
action: "Confirm",
},
KeyHint {
keys: "Esc",
action: "Cancel",
},
]
} else {
vec![
KeyHint {
keys: "Left/Right",
action: "Preview theme",
},
KeyHint {
keys: "Enter",
action: "Save/Activate",
},
KeyHint {
keys: "Tab",
action: "Move focus",
},
KeyHint {
keys: "C/U",
action: "Color/Unicode",
},
KeyHint {
keys: "L/K",
action: "CLI Out/Confirm",
},
KeyHint {
keys: "Esc/B",
action: "Back",
},
]
}
}
}

View file

@ -1,741 +0,0 @@
use crate::{
controls::{
button::{ActionButton, ButtonIntent, render_button},
choice::{ChoiceKind, render_choice_line},
},
interaction_result::InteractionResult,
ipc_client::IpcClient,
render_context::RenderContext,
screens::screens::{AppAction, AppEvent, HitMap, KeyHint, Screen, UiEvent},
};
use crossterm::event::{KeyCode, KeyModifiers};
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use std::{
any::Any,
sync::{
Arc,
atomic::{AtomicU8, AtomicUsize, Ordering},
},
};
#[derive(Clone, Debug)]
pub struct UserEntry {
pub user_id: i64,
pub username: String,
pub state: iota_ipc::LocalUserState,
pub data_present: bool,
pub credential_present: bool,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Focus {
List,
AddButton,
RemoveButton,
Back,
}
#[derive(Clone, Debug)]
enum Dialog {
Add { username: String },
Remove { user: UserEntry },
}
pub struct UsersScreen {
users: Vec<UserEntry>,
focused_index: usize,
focus: Focus,
ipc: Arc<IpcClient>,
message: Option<String>,
dialog: Option<Dialog>,
pending_dialog: Option<Dialog>,
loading: bool,
pending: bool,
scroll_offset: usize,
viewport_height: AtomicUsize,
filter: String,
filtering: bool,
tick: AtomicU8,
}
impl UsersScreen {
pub fn new(ipc: Arc<IpcClient>, users: Vec<UserEntry>) -> Self {
Self {
users,
focused_index: 0,
focus: Focus::List,
ipc,
message: None,
dialog: None,
pending_dialog: None,
loading: false,
pending: false,
scroll_offset: 0,
viewport_height: AtomicUsize::new(1),
filter: String::new(),
filtering: false,
tick: AtomicU8::new(0),
}
}
pub fn loading(ipc: Arc<IpcClient>) -> Self {
let mut screen = Self::new(ipc, Vec::new());
screen.loading = true;
screen.message = Some("Loading users…".into());
screen
}
fn render_user_list(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) {
let visible_indices = self.filtered_indices();
let title = if self.filter.is_empty() {
format!("Users ({})", self.users.len())
} else {
format!(
"Users ({}/{}) filter: {}",
visible_indices.len(),
self.users.len(),
self.filter
)
};
let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
crate::controls::panel::render_panel(
f,
area,
&title,
self.focus == Focus::List,
context.theme,
)
} else {
let block = Block::default()
.title(format!(" {title} "))
.borders(Borders::ALL)
.border_style(context.theme.borders.normal);
let inner = block.inner(area);
f.render_widget(block, area);
inner
};
if self.loading {
const SPINNERS: &[u8] = b"|/-\\";
let ch = SPINNERS[self.tick.fetch_add(1, Ordering::Relaxed) as usize % SPINNERS.len()];
f.render_widget(Paragraph::new(format!("{ch} Loading users…")), inner);
return;
}
if visible_indices.is_empty() {
let par = Paragraph::new(if self.users.is_empty() {
"No users found."
} else {
"No users match the filter."
});
f.render_widget(par, inner);
return;
}
let mut lines = Vec::new();
self.viewport_height
.store(inner.height as usize, Ordering::Relaxed);
let labels: Vec<(usize, String)> = visible_indices
.iter()
.skip(self.scroll_offset)
.take(inner.height as usize)
.map(|user_index| {
let user = &self.users[*user_index];
(
*user_index,
format!(
"{:>6} {} {}{}",
user.user_id,
user.username,
match user.state {
iota_ipc::LocalUserState::Managed => "managed",
iota_ipc::LocalUserState::Released => "released",
},
if user.data_present {
""
} else {
", data purged"
}
),
)
})
.collect();
for (user_index, label) in &labels {
let visual = crate::controls::choice::ChoiceVisualState {
selected: false,
focused: self.focus == Focus::List && *user_index == self.focused_index,
enabled: !self.loading && !self.pending,
};
lines.push(render_choice_line(
&label,
ChoiceKind::Radio,
visual,
context.theme,
));
}
let par = Paragraph::new(lines);
f.render_widget(par, inner);
}
fn render_actions(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) {
let rows = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(area);
if let Some(msg) = &self.message {
let par = Paragraph::new(Line::from(Span::styled(
msg.as_str(),
context.theme.text.muted,
)));
f.render_widget(par, rows[0]);
}
let buttons_area = Layout::horizontal([
Constraint::Percentage(33),
Constraint::Percentage(33),
Constraint::Percentage(34),
])
.split(rows[1]);
render_button(
f,
buttons_area[0],
ActionButton {
label: "Back",
intent: ButtonIntent::Cancel,
focused: self.focus == Focus::Back,
enabled: true,
},
context.theme,
);
render_button(
f,
buttons_area[1],
ActionButton {
label: "Add",
intent: ButtonIntent::Primary,
focused: self.focus == Focus::AddButton,
enabled: true,
},
context.theme,
);
render_button(
f,
buttons_area[2],
ActionButton {
label: "Release",
intent: ButtonIntent::Destructive,
focused: self.focus == Focus::RemoveButton,
enabled: !self.loading && !self.pending && !self.users.is_empty(),
},
context.theme,
);
}
fn activate(&mut self) -> InteractionResult {
if self.loading || self.pending {
return InteractionResult::Handled;
}
if let Some(dialog) = self.dialog.take() {
match dialog {
Dialog::Add { username } if !username.trim().is_empty() => {
let name = username.trim().to_owned();
self.pending_dialog = Some(Dialog::Add { username });
self.pending = true;
self.message = Some("Creating user…".into());
let ipc = self.ipc.clone();
return InteractionResult::AppTask {
task: Box::pin(async move {
let result = match ipc.send_request(iota_ipc::LocalRequest::CreateUser { username: name }).await {
Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::UserCreated { user_id, username })) => Ok(UserEntry { user_id, username, state: iota_ipc::LocalUserState::Managed, data_present: true, credential_present: true }),
Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot create user: {error}")),
Ok(_) => Err("Daemon returned an unexpected response while creating the user.".into()),
Err(error) => Err(format!("Cannot create user: {error}")),
};
UiEvent::App(AppEvent::UserCreated(result))
}),
};
}
Dialog::Remove { user } => {
self.pending_dialog = Some(Dialog::Remove { user: user.clone() });
let ipc = self.ipc.clone();
let id = user.user_id;
self.pending = true;
self.message = Some(format!("Releasing {}", user.username));
return InteractionResult::AppTask {
task: Box::pin(async move {
let result = match ipc.send_request(iota_ipc::LocalRequest::ReleaseUser { user_id: id }).await {
Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::Acknowledged { .. })) => Ok(()),
Ok(iota_ipc::ResponseResult::Error(error)) => Err(format!("Cannot release user: {error}")),
Ok(_) => Err("Daemon returned an unexpected response while releasing the user.".into()),
Err(error) => Err(format!("Cannot release user: {error}")),
};
UiEvent::App(AppEvent::UserRemoved {
user_id: id,
result,
})
}),
};
}
Dialog::Add { .. } => self.message = Some("A username is required.".into()),
}
return InteractionResult::Handled;
}
match self.focus {
Focus::Back => InteractionResult::CloseScreen,
Focus::AddButton => {
self.dialog = Some(Dialog::Add {
username: String::new(),
});
InteractionResult::Handled
}
Focus::RemoveButton => {
if let Some(user) = self.users.get(self.focused_index) {
self.dialog = Some(Dialog::Remove { user: user.clone() });
}
InteractionResult::Handled
}
Focus::List => InteractionResult::Handled,
}
}
fn next_focus(&mut self) {
self.focus = match self.focus {
Focus::List => Focus::AddButton,
Focus::AddButton => Focus::RemoveButton,
Focus::RemoveButton => Focus::Back,
Focus::Back => Focus::List,
};
}
fn prev_focus(&mut self) {
self.focus = match self.focus {
Focus::List => Focus::Back,
Focus::Back => Focus::RemoveButton,
Focus::RemoveButton => Focus::AddButton,
Focus::AddButton => Focus::List,
};
}
fn keep_focused_user_visible(&mut self) {
let indices = self.filtered_indices();
let Some(position) = indices
.iter()
.position(|index| *index == self.focused_index)
else {
self.scroll_offset = 0;
return;
};
let height = self.viewport_height.load(Ordering::Relaxed).max(1);
if position < self.scroll_offset {
self.scroll_offset = position;
} else if position >= self.scroll_offset + height {
self.scroll_offset = position + 1 - height;
}
}
fn move_user_focus(&mut self, index: usize) {
if !self.users.is_empty() {
self.focused_index = index.min(self.users.len() - 1);
self.keep_focused_user_visible();
}
}
fn filtered_indices(&self) -> Vec<usize> {
let needle = self.filter.to_ascii_lowercase();
self.users
.iter()
.enumerate()
.filter(|(_, user)| {
needle.is_empty()
|| user.username.to_ascii_lowercase().contains(&needle)
|| user.user_id.to_string().contains(&needle)
})
.map(|(index, _)| index)
.collect()
}
fn move_visible(&mut self, delta: isize) {
let indices = self.filtered_indices();
if indices.is_empty() {
return;
}
let current = indices
.iter()
.position(|index| *index == self.focused_index)
.unwrap_or(0);
let next = (current as isize + delta).clamp(0, indices.len() as isize - 1) as usize;
self.move_user_focus(indices[next]);
}
fn reset_focus_to_filter(&mut self) {
self.scroll_offset = 0;
if let Some(index) = self.filtered_indices().first().copied() {
self.focused_index = index;
}
}
}
impl Screen for UsersScreen {
fn as_any(&self) -> &dyn Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn Any {
self
}
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, hits: &mut HitMap) {
let outer_block = Block::default()
.title(" Users ")
.borders(Borders::ALL)
.border_style(context.theme.borders.normal)
.title_style(context.theme.borders.title);
let inner = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
crate::controls::panel::render_panel(f, rect, "Users", false, context.theme)
} else {
let inner = outer_block.inner(rect);
f.render_widget(outer_block, rect);
inner
};
let chunks = Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(inner);
self.render_user_list(f, chunks[0], context);
self.render_actions(f, chunks[1], context);
if let Some(dialog) = &self.dialog {
f.render_widget(Block::default().style(context.theme.surfaces.overlay), rect);
let popup = crate::layout::fit::centered_rect(
rect,
crate::layout::fit::RequiredSize {
width: 42,
height: 7,
},
);
let text = match dialog {
Dialog::Add { username } => {
format!("Add user\nUsername: {username}")
}
Dialog::Remove { user } => format!(
"Remove user {} (ID {})?\nThis removes the local user record.",
user.username, user.user_id
),
};
let block = Block::default()
.title(" Confirm ")
.borders(Borders::ALL)
.border_style(context.theme.borders.focused)
.style(context.theme.surfaces.overlay);
let popup_inner = block.inner(popup);
f.render_widget(block, popup);
let dialog_rows =
Layout::vertical([Constraint::Min(2), Constraint::Length(1)]).split(popup_inner);
f.render_widget(
Paragraph::new(text).style(context.theme.text.normal),
dialog_rows[0],
);
let dialog_buttons =
Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(dialog_rows[1]);
render_button(
f,
dialog_buttons[0],
ActionButton {
label: "Cancel",
intent: ButtonIntent::Cancel,
focused: false,
enabled: true,
},
context.theme,
);
render_button(
f,
dialog_buttons[1],
ActionButton {
label: match dialog {
Dialog::Add { .. } => "Create",
Dialog::Remove { .. } => "Remove",
},
intent: match dialog {
Dialog::Add { .. } => ButtonIntent::Primary,
Dialog::Remove { .. } => ButtonIntent::Destructive,
},
focused: true,
enabled: true,
},
context.theme,
);
hits.register(dialog_buttons[0], AppAction::CancelDialog);
hits.register(dialog_buttons[1], AppAction::ConfirmDialog);
}
let buttons = Layout::horizontal([
Constraint::Percentage(33),
Constraint::Percentage(33),
Constraint::Percentage(34),
])
.split(chunks[1]);
if self.dialog.is_none() {
hits.register(buttons[0], AppAction::Back);
}
if self.dialog.is_none() && !self.loading && !self.pending {
hits.register(buttons[1], AppAction::AddUser);
}
if self.dialog.is_none() && !self.loading && !self.pending && !self.users.is_empty() {
hits.register(buttons[2], AppAction::RemoveUser);
}
if self.dialog.is_none() {
let list_height = chunks[0].height.saturating_sub(2) as usize;
let filtered_indices = self.filtered_indices();
for visible in 0..list_height {
let position = self.scroll_offset + visible;
let Some(index) = filtered_indices.get(position).copied() else {
break;
};
hits.register(
Rect {
x: chunks[0].x.saturating_add(1),
y: chunks[0].y.saturating_add(1 + visible as u16),
width: chunks[0].width.saturating_sub(2),
height: 1,
},
AppAction::SelectUser(index),
);
}
}
}
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
let event = match event {
UiEvent::App(AppEvent::UsersLoaded(result)) => {
self.loading = false;
match result {
Ok(users) => {
self.users = users;
self.message = None;
}
Err(error) => self.message = Some(error),
}
return InteractionResult::Handled;
}
UiEvent::App(AppEvent::UserCreated(result)) => {
self.pending = false;
match result {
Ok(user) => {
self.pending_dialog = None;
self.focused_index = self.users.len();
self.users.push(user.clone());
self.message = Some(format!(
"Created user {} ({}).",
user.username, user.user_id
));
}
Err(error) => {
self.dialog = self.pending_dialog.take();
self.message = Some(error);
}
}
return InteractionResult::Handled;
}
UiEvent::App(AppEvent::UserRemoved { user_id, result }) => {
self.pending = false;
match result {
Ok(()) => {
self.pending_dialog = None;
if let Some(user) =
self.users.iter_mut().find(|user| user.user_id == user_id)
{
user.state = iota_ipc::LocalUserState::Released;
user.credential_present = false;
}
self.message =
Some(format!("Released user {user_id}; hosted data retained."));
}
Err(error) => {
self.dialog = self.pending_dialog.take();
self.message = Some(error);
}
}
return InteractionResult::Handled;
}
UiEvent::Paste(text) if matches!(self.dialog, Some(Dialog::Add { .. })) => {
if let Some(Dialog::Add { username }) = self.dialog.as_mut() {
username.push_str(&text.replace(['\r', '\n'], " "));
}
return InteractionResult::Handled;
}
UiEvent::Key(event) => event,
_ => return InteractionResult::Unhandled,
};
if self.filtering && self.dialog.is_none() {
match event.code {
KeyCode::Esc => {
self.filtering = false;
self.filter.clear();
self.reset_focus_to_filter();
}
KeyCode::Enter => self.filtering = false,
KeyCode::Backspace => {
self.filter.pop();
self.reset_focus_to_filter();
}
KeyCode::Char(c)
if !event
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
self.filter.push(c);
self.reset_focus_to_filter();
}
_ => {}
}
return InteractionResult::Handled;
}
if let Some(Dialog::Add { username }) = self.dialog.as_mut() {
match event.code {
KeyCode::Esc => {
self.dialog = None;
return InteractionResult::Handled;
}
KeyCode::Enter => return self.activate(),
KeyCode::Backspace => {
username.pop();
return InteractionResult::Handled;
}
KeyCode::Char(c)
if !c.is_control()
&& !event
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
username.push(c);
return InteractionResult::Handled;
}
_ => return InteractionResult::Handled,
}
}
if self.dialog.is_some() {
return match event.code {
KeyCode::Esc => {
self.dialog = None;
InteractionResult::Handled
}
KeyCode::Enter => self.activate(),
_ => InteractionResult::Handled,
};
}
match event.code {
KeyCode::Esc => InteractionResult::CloseScreen,
KeyCode::Char('/') if self.focus == Focus::List => {
self.filtering = true;
self.filter.clear();
self.reset_focus_to_filter();
InteractionResult::Handled
}
KeyCode::Tab => {
self.next_focus();
InteractionResult::Handled
}
KeyCode::BackTab => {
self.prev_focus();
InteractionResult::Handled
}
KeyCode::Down | KeyCode::Char('j') => {
if self.focus == Focus::List {
self.move_visible(1);
}
InteractionResult::Handled
}
KeyCode::Up | KeyCode::Char('k') => {
if self.focus == Focus::List {
self.move_visible(-1);
}
InteractionResult::Handled
}
KeyCode::PageDown if self.focus == Focus::List && !self.users.is_empty() => {
let page = self.viewport_height.load(Ordering::Relaxed).max(1);
self.move_visible(page as isize);
InteractionResult::Handled
}
KeyCode::PageUp if self.focus == Focus::List && !self.users.is_empty() => {
let page = self.viewport_height.load(Ordering::Relaxed).max(1);
self.move_visible(-(page as isize));
InteractionResult::Handled
}
KeyCode::Home if self.focus == Focus::List => {
if let Some(index) = self.filtered_indices().first().copied() {
self.move_user_focus(index);
}
InteractionResult::Handled
}
KeyCode::End if self.focus == Focus::List && !self.users.is_empty() => {
if let Some(index) = self.filtered_indices().last().copied() {
self.move_user_focus(index);
}
InteractionResult::Handled
}
KeyCode::Enter | KeyCode::Char(' ') => self.activate(),
_ => InteractionResult::Unhandled,
}
}
fn handle_action(&mut self, action: AppAction) -> InteractionResult {
match action {
AppAction::Back => InteractionResult::CloseScreen,
AppAction::AddUser => {
self.focus = Focus::AddButton;
self.activate()
}
AppAction::RemoveUser => {
self.focus = Focus::RemoveButton;
self.activate()
}
AppAction::SelectUser(index) if self.dialog.is_none() => {
self.focus = Focus::List;
self.move_user_focus(index);
InteractionResult::Handled
}
AppAction::ConfirmDialog if self.dialog.is_some() => self.activate(),
AppAction::CancelDialog if self.dialog.is_some() => {
self.dialog = None;
InteractionResult::Handled
}
_ => InteractionResult::Unhandled,
}
}
fn key_hints(&self) -> Vec<KeyHint> {
if self.dialog.is_some() {
vec![
KeyHint {
keys: "Enter",
action: "Confirm",
},
KeyHint {
keys: "Esc",
action: "Cancel",
},
]
} else {
vec![
KeyHint {
keys: "Up/Down",
action: "Select user",
},
KeyHint {
keys: "PgUp/PgDn",
action: "Page",
},
KeyHint {
keys: "/",
action: "Filter",
},
KeyHint {
keys: "Tab",
action: "Move focus",
},
KeyHint {
keys: "F6",
action: "Header",
},
]
}
}
}

View file

@ -1,314 +0,0 @@
use super::ThemeName;
use serde::{Deserialize, Serialize};
use std::{
fs, io,
path::{Path, PathBuf},
str::FromStr,
};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UiConfig {
#[serde(default)]
pub theme: ThemeName,
/// Whether opening the interactive UI should launch a locally installed daemon.
#[serde(default)]
pub daemon_start_policy: DaemonStartPolicy,
#[serde(default)]
pub color: TerminalPolicy,
#[serde(default)]
pub unicode: TerminalPolicy,
/// Default CLI output format for headless commands.
#[serde(default)]
pub cli_output: CliOutputFormat,
/// Whether destructive CLI operations require --yes by default.
#[serde(default = "default_false")]
pub cli_require_confirmation: bool,
}
fn default_false() -> bool {
false
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum CliOutputFormat {
#[default]
Text,
Json,
Yaml,
Table,
}
impl CliOutputFormat {
pub fn all() -> &'static [CliOutputFormat] {
&[
CliOutputFormat::Text,
CliOutputFormat::Json,
CliOutputFormat::Yaml,
CliOutputFormat::Table,
]
}
pub fn name(&self) -> &'static str {
match self {
CliOutputFormat::Text => "text",
CliOutputFormat::Json => "json",
CliOutputFormat::Yaml => "yaml",
CliOutputFormat::Table => "table",
}
}
pub fn next(&self) -> Self {
match self {
CliOutputFormat::Text => CliOutputFormat::Json,
CliOutputFormat::Json => CliOutputFormat::Yaml,
CliOutputFormat::Yaml => CliOutputFormat::Table,
CliOutputFormat::Table => CliOutputFormat::Text,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum TerminalPolicy {
#[default]
Auto,
Always,
Never,
}
impl TerminalPolicy {
pub fn next(&self) -> Self {
match self {
TerminalPolicy::Auto => TerminalPolicy::Always,
TerminalPolicy::Always => TerminalPolicy::Never,
TerminalPolicy::Never => TerminalPolicy::Auto,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DaemonStartPolicy {
#[default]
Ask,
WithUi,
}
impl Serialize for DaemonStartPolicy {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
Self::Ask => serializer.serialize_str("ask"),
Self::WithUi => serializer.serialize_str("with_ui"),
}
}
}
impl<'de> Deserialize<'de> for DaemonStartPolicy {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Compat {
Policy(String),
Legacy(bool),
}
match Compat::deserialize(deserializer)? {
Compat::Policy(v) if v == "with_ui" || v == "WithUi" => Ok(Self::WithUi),
Compat::Policy(_) => Ok(Self::Ask),
Compat::Legacy(true) => Ok(Self::WithUi),
Compat::Legacy(false) => Ok(Self::Ask),
}
}
}
impl UiConfig {
pub fn path() -> PathBuf {
iota_paths::config_dir().join("ui.yaml")
}
fn fallback_path() -> Option<PathBuf> {
std::env::var_os("HOME")
.map(PathBuf::from)
.map(|d| d.join(".config").join("iota").join("ui.yaml"))
}
pub fn load() -> Result<Self, io::Error> {
let path = match (|| std::panic::catch_unwind(|| Self::path()))() {
Ok(path) => path,
Err(_) => Self::fallback_path().ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, "could not determine config path")
})?,
};
Self::load_from(&path)
}
fn load_from(path: &Path) -> Result<Self, io::Error> {
if !path.exists() {
return Ok(Self::default());
}
serde_yaml::from_str(&fs::read_to_string(path)?).map_err(io::Error::other)
}
pub fn save(&self) -> Result<(), io::Error> {
let path = Self::path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let yaml = serde_yaml::to_string(self).map_err(io::Error::other)?;
fs::write(path, yaml)
}
/// Load from config path, or return defaults if the config path can't be resolved.
/// This avoids panics when `IOTA_SOCKET` is not set (e.g. in unit tests).
pub fn load_or_default() -> Self {
Self::load().unwrap_or_default()
}
pub fn resolve_theme(override_theme: Option<ThemeName>) -> ThemeName {
Self::resolve_theme_from(
override_theme,
std::env::var("IOTA_THEME").ok().as_deref(),
&Self::path(),
)
}
fn resolve_theme_from(
override_theme: Option<ThemeName>,
environment_theme: Option<&str>,
config_path: &Path,
) -> ThemeName {
if let Some(theme) = override_theme {
return theme;
}
if let Some(value) = environment_theme {
match ThemeName::from_str(value) {
Ok(theme) => return theme,
Err(error) => {
eprintln!("Invalid IOTA_THEME value: {error}; checking UI configuration.");
}
}
}
match Self::load_from(config_path) {
Ok(config) => config.theme,
Err(error) => {
eprintln!(
"Could not read UI configuration {}: {error}; using ansi.",
config_path.display()
);
ThemeName::Ansi
}
}
}
/// Resolve the default CLI output format from config file and environment.
/// Priority: IOTA_OUTPUT env var > config file > "text" default.
pub fn resolve_cli_output(&self) -> CliOutputFormat {
if let Ok(value) = std::env::var("IOTA_OUTPUT") {
match value.to_ascii_lowercase().as_str() {
"json" => return CliOutputFormat::Json,
"yaml" | "yml" => return CliOutputFormat::Yaml,
"table" => return CliOutputFormat::Table,
_ => {}
}
}
self.cli_output
}
/// Resolve the default --yes behavior from config file and environment.
/// Priority: IOTA_YES env var > config file > false default.
pub fn resolve_cli_require_confirmation(&self) -> bool {
if let Ok(value) = std::env::var("IOTA_YES") {
match value.to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "y" => return false,
"0" | "false" | "no" | "n" => return true,
_ => {}
}
}
self.cli_require_confirmation
}
}
#[cfg(test)]
mod tests {
use super::*;
fn config_path(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("iota-ui-config-{}-{name}.yaml", std::process::id()))
}
#[test]
fn command_line_override_has_highest_precedence() {
let path = config_path("override");
fs::write(&path, "theme: surface\n").unwrap();
let resolved =
UiConfig::resolve_theme_from(Some(ThemeName::Binary), Some("monospace"), &path);
fs::remove_file(path).unwrap();
assert_eq!(resolved, ThemeName::Binary);
}
#[test]
fn environment_precedes_stored_configuration() {
let path = config_path("environment");
fs::write(&path, "theme: surface\n").unwrap();
let resolved = UiConfig::resolve_theme_from(None, Some("monospace"), &path);
fs::remove_file(path).unwrap();
assert_eq!(resolved, ThemeName::Monospace);
}
#[test]
fn stored_configuration_precedes_default() {
let path = config_path("stored");
fs::write(&path, "theme: surface\n").unwrap();
let resolved = UiConfig::resolve_theme_from(None, None, &path);
fs::remove_file(path).unwrap();
assert_eq!(resolved, ThemeName::Surface);
}
#[test]
fn invalid_stored_configuration_falls_back_to_ansi() {
let path = config_path("invalid");
fs::write(&path, "theme: ultraviolet\n").unwrap();
let resolved = UiConfig::resolve_theme_from(None, None, &path);
fs::remove_file(path).unwrap();
assert_eq!(resolved, ThemeName::Ansi);
}
#[test]
fn missing_configuration_falls_back_to_ansi() {
let path = config_path("missing");
let _ = fs::remove_file(&path);
assert_eq!(
UiConfig::resolve_theme_from(None, None, &path),
ThemeName::Ansi
);
}
#[test]
fn cli_output_defaults_to_text() {
let config = UiConfig::default();
assert_eq!(config.resolve_cli_output(), CliOutputFormat::Text);
}
#[test]
fn cli_output_cycles_through_variants() {
assert_eq!(CliOutputFormat::Text.next(), CliOutputFormat::Json);
assert_eq!(CliOutputFormat::Json.next(), CliOutputFormat::Yaml);
assert_eq!(CliOutputFormat::Yaml.next(), CliOutputFormat::Table);
assert_eq!(CliOutputFormat::Table.next(), CliOutputFormat::Text);
}
#[test]
fn require_confirmation_defaults_to_false() {
let config = UiConfig::default();
assert!(!config.resolve_cli_require_confirmation());
}
#[test]
fn cli_output_serializes_roundtrip() {
let config = UiConfig {
cli_output: CliOutputFormat::Table,
cli_require_confirmation: true,
..Default::default()
};
let yaml = serde_yaml::to_string(&config).unwrap();
let loaded: UiConfig = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(loaded.cli_output, CliOutputFormat::Table);
assert!(loaded.cli_require_confirmation);
}
}

View file

@ -1,83 +0,0 @@
mod config;
mod model;
mod name;
mod presets;
pub use config::{CliOutputFormat, DaemonStartPolicy, TerminalPolicy, UiConfig};
pub use model::*;
pub use name::ThemeName;
pub fn resolve(name: ThemeName) -> ResolvedTheme {
presets::resolve(name)
}
pub fn resolve_with_capabilities(
name: ThemeName,
color_enabled: bool,
unicode_enabled: bool,
) -> ResolvedTheme {
let mut theme = if color_enabled {
presets::resolve(name)
} else {
presets::resolve(ThemeName::Monospace)
};
theme.name = name;
theme.unicode = unicode_enabled;
if !unicode_enabled {
if matches!(theme.console.cursor, CursorPresentation::Character { .. }) {
theme.console.cursor = CursorPresentation::Character {
glyph: "|",
style: theme.console.text,
};
}
}
theme
}
/// Resolve a theme against the terminal's color depth. Surface uses RGB
/// colors, so a portable ANSI preset is selected when truecolor is absent.
pub fn resolve_with_terminal_profile(
name: ThemeName,
color_enabled: bool,
unicode_enabled: bool,
truecolor_enabled: bool,
) -> ResolvedTheme {
let effective = if color_enabled && !truecolor_enabled && matches!(name, ThemeName::Surface) {
ThemeName::Ansi
} else {
name
};
resolve_with_capabilities(effective, color_enabled, unicode_enabled)
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Color;
#[test]
fn no_color_policy_removes_palette_dependencies() {
let theme = resolve_with_capabilities(ThemeName::Surface, false, true);
assert_eq!(theme.name, ThemeName::Surface);
assert_eq!(theme.status.error.fg, None);
assert_eq!(theme.surfaces.panel.bg, None);
}
#[test]
fn ascii_policy_replaces_character_cursor() {
let theme = resolve_with_capabilities(ThemeName::Monospace, false, false);
assert!(!theme.unicode);
match theme.console.cursor {
CursorPresentation::Character { glyph, .. } => assert_eq!(glyph, "|"),
CursorPresentation::StyledCell(_) => panic!("expected an ASCII character cursor"),
}
assert_ne!(theme.graphs.ram, Color::Blue);
}
#[test]
fn surface_uses_ansi_fallback_without_truecolor() {
let theme = resolve_with_terminal_profile(ThemeName::Surface, true, true, false);
assert_eq!(theme.name, ThemeName::Ansi);
assert_eq!(theme.surfaces.panel.bg, None);
}
}

View file

@ -1,168 +0,0 @@
use super::ThemeName;
use ratatui::style::Style;
#[derive(Clone, Debug)]
pub struct TextStyles {
pub normal: Style,
pub muted: Style,
pub heading: Style,
pub link: Style,
pub code: Style,
}
#[derive(Clone, Debug)]
pub struct StatusStyles {
pub info: Style,
pub success: Style,
pub warning: Style,
pub error: Style,
}
#[derive(Clone, Debug)]
pub struct BorderStyles {
pub normal: Style,
pub focused: Style,
pub disabled: Style,
pub title: Style,
}
#[derive(Clone, Debug)]
pub struct SurfaceStyles {
pub canvas: Style,
pub toolbar: Style,
pub panel: Style,
pub panel_alternate: Style,
pub panel_focused: Style,
pub panel_selected: Style,
pub footer: Style,
pub overlay: Style,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ChromeMode {
Bordered,
Surfaces,
}
#[derive(Clone, Debug)]
pub struct ChoiceItemStyle {
pub marker: Style,
pub label: Style,
pub description: Style,
pub prefix: &'static str,
pub suffix: &'static str,
}
#[derive(Clone, Debug)]
pub struct ChoiceStyles {
pub normal: ChoiceItemStyle,
pub focused: ChoiceItemStyle,
pub selected: ChoiceItemStyle,
pub focused_selected: ChoiceItemStyle,
pub disabled: ChoiceItemStyle,
pub focused_disabled: ChoiceItemStyle,
pub selected_disabled: ChoiceItemStyle,
}
#[derive(Clone, Debug)]
pub struct ButtonStyles {
pub primary: Style,
pub primary_focused: Style,
pub neutral: Style,
pub neutral_focused: Style,
pub cancel: Style,
pub cancel_focused: Style,
pub destructive: Style,
pub disabled: Style,
}
#[derive(Clone, Debug)]
pub struct MarkerSet {
pub checkbox_unselected: &'static str,
pub checkbox_selected: &'static str,
pub radio_unselected: &'static str,
pub radio_selected: &'static str,
}
#[derive(Clone, Debug)]
pub enum CursorPresentation {
StyledCell(Style),
Character { glyph: &'static str, style: Style },
}
#[derive(Clone, Debug)]
pub struct ConsoleStyles {
pub text: Style,
pub prefix: Style,
pub hint: Style,
pub error: Style,
pub confirmation: Style,
pub cursor: CursorPresentation,
pub border: Style,
pub focused_border: Style,
pub title: Style,
}
#[derive(Clone, Debug)]
pub struct GraphStyles {
pub ram: ratatui::style::Color,
pub cpu: ratatui::style::Color,
pub ping: ratatui::style::Color,
pub text: Style,
pub border: Style,
pub focused_border: Style,
}
#[derive(Clone, Debug)]
pub struct LogStyles {
pub call: Style,
pub client: Style,
pub iota: Style,
pub omikron: Style,
pub omega: Style,
pub command: Style,
pub other: Style,
pub text: Style,
pub error: Style,
pub timestamp: Style,
pub border: Style,
pub focused_border: Style,
}
#[derive(Clone, Debug)]
pub struct MarkdownStyles {
pub normal: Style,
pub muted: Style,
pub heading: Style,
pub link: Style,
pub code: Style,
pub table_header: Style,
pub table_text: Style,
pub divider: Style,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct TextSemantics {
pub bold: bool,
pub underline: bool,
}
#[derive(Clone, Debug)]
pub struct ResolvedTheme {
pub name: ThemeName,
pub unicode: bool,
pub surfaces: SurfaceStyles,
pub chrome: ChromeMode,
pub text: TextStyles,
pub status: StatusStyles,
pub choices: ChoiceStyles,
pub buttons: ButtonStyles,
pub borders: BorderStyles,
pub console: ConsoleStyles,
pub graphs: GraphStyles,
pub logs: LogStyles,
pub markdown: MarkdownStyles,
pub markers: MarkerSet,
}
impl ResolvedTheme {
pub fn apply_text_semantics(&self, base: Style, semantics: TextSemantics) -> Style {
use ratatui::style::Modifier;
if matches!(self.name, ThemeName::Monospace) {
return base;
}
let mut style = base;
if semantics.bold {
style = style.add_modifier(Modifier::BOLD);
}
if semantics.underline {
style = style.add_modifier(Modifier::UNDERLINED);
}
style
}
}

View file

@ -1,47 +0,0 @@
use serde::{Deserialize, Serialize};
use std::{fmt, str::FromStr};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ThemeName {
Monospace,
Binary,
#[default]
Ansi,
Surface,
}
impl ThemeName {
pub const ALL: [Self; 4] = [Self::Monospace, Self::Binary, Self::Ansi, Self::Surface];
pub fn supported_names() -> &'static str {
"monospace, binary, ansi, surface"
}
}
impl fmt::Display for ThemeName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Monospace => "monospace",
Self::Binary => "binary",
Self::Ansi => "ansi",
Self::Surface => "surface",
})
}
}
impl FromStr for ThemeName {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.to_ascii_lowercase().as_str() {
"monospace" => Ok(Self::Monospace),
"binary" => Ok(Self::Binary),
"ansi" => Ok(Self::Ansi),
"surface" => Ok(Self::Surface),
_ => Err(format!(
"unknown theme `{value}`; supported themes: {}",
Self::supported_names()
)),
}
}
}

View file

@ -1,324 +0,0 @@
use super::{
BorderStyles, ButtonStyles, ChoiceItemStyle, ChoiceStyles, ChromeMode, ConsoleStyles,
CursorPresentation, GraphStyles, LogStyles, MarkdownStyles, MarkerSet, ResolvedTheme,
StatusStyles, SurfaceStyles, TextStyles, ThemeName,
};
use ratatui::style::{Color, Modifier, Style};
fn marker() -> MarkerSet {
MarkerSet {
checkbox_unselected: "[ ]",
checkbox_selected: "[x]",
radio_unselected: "( )",
radio_selected: "(x)",
}
}
fn choice(
marker: Style,
label: Style,
prefix: &'static str,
suffix: &'static str,
) -> ChoiceItemStyle {
ChoiceItemStyle {
marker,
label,
description: label,
prefix,
suffix,
}
}
fn base(
name: ThemeName,
normal: Style,
muted: Style,
focused: Style,
selected: Style,
disabled: Style,
status: StatusStyles,
buttons: ButtonStyles,
) -> ResolvedTheme {
let error = status.error;
let (prefix, suffix) = if matches!(name, ThemeName::Monospace | ThemeName::Binary) {
("> ", " <")
} else {
("", "")
};
ResolvedTheme {
name,
unicode: true,
surfaces: SurfaceStyles {
canvas: Style::default(),
toolbar: Style::default(),
panel: Style::default(),
panel_alternate: Style::default(),
panel_focused: focused,
panel_selected: selected,
footer: Style::default(),
overlay: Style::default(),
},
chrome: ChromeMode::Bordered,
text: TextStyles {
normal,
muted,
heading: normal,
link: focused,
code: normal,
},
status,
choices: ChoiceStyles {
normal: choice(normal, normal, "", ""),
focused: choice(focused, focused, prefix, suffix),
selected: choice(selected, selected, "", ""),
focused_selected: choice(
selected.patch(focused),
selected.patch(focused),
prefix,
suffix,
),
disabled: choice(disabled, disabled, "", ""),
focused_disabled: choice(disabled, error, prefix, suffix),
selected_disabled: choice(disabled, disabled, "", ""),
},
buttons,
borders: BorderStyles {
normal,
focused,
disabled,
title: normal,
},
console: ConsoleStyles {
text: normal,
prefix: muted,
hint: muted,
error,
confirmation: focused,
cursor: CursorPresentation::StyledCell(focused),
border: normal,
focused_border: focused,
title: normal,
},
graphs: GraphStyles {
ram: Color::Reset,
cpu: Color::Reset,
ping: Color::Reset,
text: normal,
border: normal,
focused_border: focused,
},
logs: LogStyles {
call: normal,
client: normal,
iota: normal,
omikron: normal,
omega: normal,
command: normal,
other: normal,
text: normal,
error,
timestamp: muted,
border: normal,
focused_border: focused,
},
markdown: MarkdownStyles {
normal,
muted,
heading: focused,
link: focused,
code: focused,
table_header: focused,
table_text: normal,
divider: muted,
},
markers: marker(),
}
}
pub fn resolve(name: ThemeName) -> ResolvedTheme {
let plain = Style::default();
match name {
ThemeName::Monospace => {
let mut theme = base(
name,
plain,
plain,
plain,
plain,
plain,
StatusStyles {
info: plain,
success: plain,
warning: plain,
error: plain,
},
ButtonStyles {
primary: plain,
primary_focused: plain,
neutral: plain,
neutral_focused: plain,
cancel: plain,
cancel_focused: plain,
destructive: plain,
disabled: plain,
},
);
theme.console.cursor = CursorPresentation::Character {
glyph: "",
style: plain,
};
theme.graphs = GraphStyles {
ram: Color::Reset,
cpu: Color::Reset,
ping: Color::Reset,
text: plain,
border: plain,
focused_border: plain,
};
theme
}
ThemeName::Binary => {
let reversed = plain.add_modifier(Modifier::REVERSED);
base(
name,
plain,
plain,
plain,
reversed,
plain,
StatusStyles {
info: plain,
success: plain,
warning: plain,
error: plain,
},
ButtonStyles {
primary: plain,
primary_focused: reversed,
neutral: plain,
neutral_focused: reversed,
cancel: plain,
cancel_focused: reversed,
destructive: plain,
disabled: plain,
},
)
}
ThemeName::Ansi => {
let yellow = plain.fg(Color::Yellow).add_modifier(Modifier::BOLD);
let mut theme = base(
name,
plain,
plain.fg(Color::DarkGray),
yellow,
plain,
plain.fg(Color::DarkGray),
StatusStyles {
info: plain,
success: plain.fg(Color::Green),
warning: plain.fg(Color::Yellow),
error: plain.fg(Color::Red),
},
ButtonStyles {
primary: plain.fg(Color::Green),
primary_focused: plain
.fg(Color::Black)
.bg(Color::Green)
.add_modifier(Modifier::BOLD),
neutral: plain,
neutral_focused: yellow,
cancel: plain.fg(Color::Red),
cancel_focused: plain
.fg(Color::Black)
.bg(Color::Red)
.add_modifier(Modifier::BOLD),
destructive: plain.fg(Color::Red),
disabled: plain.fg(Color::DarkGray),
},
);
theme.console = ConsoleStyles {
text: plain.fg(Color::White),
prefix: plain.fg(Color::DarkGray),
hint: plain.fg(Color::DarkGray),
error: plain.fg(Color::Red),
confirmation: plain.fg(Color::Yellow),
cursor: CursorPresentation::StyledCell(plain.fg(Color::White).bg(Color::DarkGray)),
border: plain,
focused_border: plain.fg(Color::Yellow),
title: plain.fg(Color::White),
};
theme.graphs = GraphStyles {
ram: Color::Blue,
cpu: Color::Red,
ping: Color::Green,
text: plain,
border: plain,
focused_border: plain.fg(Color::Yellow),
};
theme.logs = LogStyles {
call: plain.fg(Color::Magenta),
client: plain.fg(Color::Green),
iota: plain.fg(Color::Yellow),
omikron: plain.fg(Color::Blue),
omega: plain.fg(Color::Cyan),
command: plain.fg(Color::LightGreen),
other: plain.fg(Color::LightCyan),
text: plain.fg(Color::White),
error: plain.fg(Color::Red),
timestamp: plain.fg(Color::DarkGray),
border: plain,
focused_border: plain.fg(Color::Yellow),
};
theme.markdown = MarkdownStyles {
normal: plain,
muted: plain.fg(Color::DarkGray),
heading: plain.fg(Color::Cyan),
link: plain.fg(Color::Cyan),
code: plain.fg(Color::Yellow),
table_header: plain.fg(Color::Cyan),
table_text: plain.fg(Color::Green),
divider: plain.fg(Color::DarkGray),
};
theme
}
ThemeName::Surface => {
let focus = plain.fg(Color::Black).bg(Color::Yellow);
let selected = plain.fg(Color::Black).bg(Color::Cyan);
let mut theme = base(
name,
plain,
plain.fg(Color::DarkGray),
focus,
selected,
plain.fg(Color::DarkGray),
StatusStyles {
info: plain,
success: plain.fg(Color::Green),
warning: plain.fg(Color::Yellow),
error: plain.fg(Color::Red),
},
ButtonStyles {
primary: plain.fg(Color::Black).bg(Color::Green),
primary_focused: focus,
neutral: plain,
neutral_focused: focus,
cancel: plain.fg(Color::Black).bg(Color::Red),
cancel_focused: focus,
destructive: plain.fg(Color::Black).bg(Color::Red),
disabled: plain.fg(Color::DarkGray),
},
);
theme.console.cursor =
CursorPresentation::StyledCell(plain.fg(Color::Black).bg(Color::Yellow));
theme.chrome = ChromeMode::Surfaces;
theme.surfaces = SurfaceStyles {
canvas: plain.bg(Color::Black),
toolbar: plain.fg(Color::White).bg(Color::DarkGray),
panel: plain.fg(Color::White).bg(Color::Rgb(30, 35, 45)),
panel_alternate: plain.fg(Color::White).bg(Color::Rgb(45, 52, 66)),
panel_focused: plain.fg(Color::White).bg(Color::Rgb(48, 58, 78)),
panel_selected: selected,
footer: plain.fg(Color::DarkGray).bg(Color::Black),
overlay: plain.fg(Color::White).bg(Color::Rgb(45, 52, 66)),
};
theme
}
}
}

View file

@ -1,728 +0,0 @@
use crate::{
controls::header::render_header,
help_overlay::HelpOverlay,
input_handler::setup_input_handler,
interaction_result::InteractionResult,
ipc_client::{DaemonStatus, IpcClient, IpcConnectionState},
notification::{Notification, render_notification_area},
render_context::RenderContext,
screens::{
main_screen::MainScreen,
metrics::MetricsScreen,
overview::OverviewScreen,
screens::{AppAction, AppEvent, HitMap, Screen, UiEvent},
settings::SettingsScreen,
users::{UserEntry, UsersScreen},
},
theme::{self, ResolvedTheme, ThemeName},
};
use crossterm::event::{
DisableMouseCapture, EnableMouseCapture, KeyCode, KeyEvent, MouseEventKind,
};
use once_cell::sync::Lazy;
use ratatui::{
Terminal,
backend::CrosstermBackend,
layout::{Constraint, Layout, Rect},
};
use std::{
io,
io::Stdout,
panic::PanicHookInfo,
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
};
use tokio::sync::{Notify, RwLock, mpsc};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
/// UI state and rendering
pub static FPS: Lazy<RwLock<(f64, f64)>> = Lazy::new(|| RwLock::new((0.0, 0.0)));
pub struct UI {
ipc: RwLock<Option<Arc<IpcClient>>>,
shutdown_on_empty: bool,
cancellation: CancellationToken,
pub terminal: Arc<Mutex<Terminal<CrosstermBackend<Stdout>>>>,
screen_stack: Arc<RwLock<Vec<Box<dyn Screen>>>>,
theme: RwLock<Arc<ResolvedTheme>>,
pub(crate) invalidation: Notify,
failure: Arc<Mutex<Option<String>>>,
hits: Mutex<HitMap>,
app_event_tx: mpsc::UnboundedSender<UiEvent>,
app_event_rx: Mutex<Option<mpsc::UnboundedReceiver<UiEvent>>>,
header_focus: Mutex<Option<usize>>,
notifications: Arc<Mutex<Vec<Notification>>>,
}
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>, theme: ResolvedTheme) -> io::Result<TuiSession> {
start_session(UI::new(Some(ipc), true, theme)?)
}
pub fn start_bootstrap_tui() -> io::Result<TuiSession> {
start_bootstrap_tui_with_theme(theme::resolve(ThemeName::Ansi))
}
pub fn start_bootstrap_tui_with_theme(theme: ResolvedTheme) -> io::Result<TuiSession> {
start_session(UI::new(None, false, theme)?)
}
fn start_session(ui: UI) -> io::Result<TuiSession> {
let ui = Arc::new(ui);
let mut app_event_rx = ui
.app_event_rx
.lock()
.map_err(|_| io::Error::other("application event queue poisoned"))?
.take()
.ok_or_else(|| io::Error::other("application event queue already started"))?;
let app_ui = ui.clone();
let app_event_task = tokio::spawn(async move {
loop {
tokio::select! {
_ = app_ui.cancellation.cancelled() => break,
event = app_event_rx.recv() => match event {
Some(event) => app_ui.clone().handle_event(event).await,
None => break,
},
}
}
});
let uic = ui.clone();
let renderer_task = tokio::spawn(async move {
let cancellation = uic.cancellation_token();
let result: io::Result<()> = loop {
tokio::select! {
_ = cancellation.cancelled() => break Ok(()),
_ = uic.invalidation.notified() => { if !uic.is_shutdown() { uic.render().await?; } },
}
};
if let Err(error) = &result {
*uic.failure.lock().unwrap() = Some(error.to_string());
uic.request_shutdown();
}
result
});
let input_task = setup_input_handler(ui.clone());
// Some terminals deliver Ctrl+C as SIGINT even while crossterm is in raw
// mode. Keep this independent of key-event handling for bootstrap work.
let signal_task = {
#[cfg(unix)]
{
let signal_ui = ui.clone();
Some(tokio::spawn(async move {
if tokio::signal::ctrl_c().await.is_ok() {
signal_ui.request_shutdown();
}
}))
}
#[cfg(not(unix))]
{
None
}
};
let previous_hook = Arc::new(Mutex::new(Some(std::panic::take_hook())));
let hook_for_panic = previous_hook.clone();
std::panic::set_hook(Box::new(move |info: &PanicHookInfo<'_>| {
ratatui::restore();
if let Some(hook) = hook_for_panic.lock().unwrap().as_ref() {
hook(info);
}
}));
Ok(TuiSession {
ui,
renderer_task,
input_task,
app_event_task,
signal_task,
restored: AtomicBool::new(false),
previous_hook,
})
}
pub struct TuiSession {
ui: Arc<UI>,
renderer_task: JoinHandle<io::Result<()>>,
input_task: JoinHandle<Result<(), String>>,
app_event_task: JoinHandle<()>,
signal_task: Option<JoinHandle<()>>,
restored: AtomicBool,
previous_hook: Arc<Mutex<Option<Box<dyn Fn(&PanicHookInfo<'_>) + Send + Sync + 'static>>>>,
}
impl TuiSession {
pub fn ui(&self) -> Arc<UI> {
self.ui.clone()
}
pub async fn shutdown(mut self) -> Option<String> {
self.ui.request_shutdown();
// Restore raw-mode state before waiting on cooperative tasks. A
// misbehaving task must never leave the invoking shell unusable.
self.restore_terminal_once();
let renderer =
tokio::time::timeout(std::time::Duration::from_secs(2), &mut self.renderer_task).await;
let input =
tokio::time::timeout(std::time::Duration::from_secs(2), &mut self.input_task).await;
self.app_event_task.abort();
if renderer.is_err() {
self.renderer_task.abort();
}
if input.is_err() {
self.input_task.abort();
}
if let Some(task) = self.signal_task.as_mut() {
task.abort();
let _ = task.await;
}
self.restore_panic_hook();
match renderer {
Err(_) => Some("renderer did not stop within 2 seconds".into()),
Ok(Err(error)) => Some(format!("renderer task failed: {error}")),
Ok(Ok(Err(error))) => Some(error.to_string()),
Ok(Ok(Ok(()))) => match input {
Err(_) => Some("input handler did not stop within 2 seconds".into()),
Ok(Err(error)) => Some(format!("input handler failed: {error}")),
Ok(Ok(Err(error))) => Some(error),
Ok(Ok(Ok(()))) => None,
},
}
}
fn restore_terminal_once(&self) {
if !self.restored.swap(true, Ordering::AcqRel) {
let _ = crossterm::execute!(io::stdout(), DisableMouseCapture);
ratatui::restore();
}
}
fn restore_panic_hook(&self) {
if let Some(hook) = self.previous_hook.lock().unwrap().take() {
std::panic::set_hook(hook);
}
}
}
impl Drop for TuiSession {
fn drop(&mut self) {
self.ui.request_shutdown();
self.renderer_task.abort();
self.input_task.abort();
self.app_event_task.abort();
if let Some(task) = self.signal_task.as_ref() {
task.abort();
}
self.restore_panic_hook();
self.restore_terminal_once();
}
}
impl UI {
pub(crate) fn new(
ipc: Option<Arc<IpcClient>>,
shutdown_on_empty: bool,
theme: ResolvedTheme,
) -> io::Result<Self> {
let terminal = ratatui::try_init()?;
crossterm::execute!(io::stdout(), EnableMouseCapture)?;
let (app_event_tx, app_event_rx) = mpsc::unbounded_channel();
Ok(Self {
ipc: RwLock::new(ipc),
shutdown_on_empty,
cancellation: CancellationToken::new(),
terminal: Arc::new(Mutex::new(terminal)),
screen_stack: Arc::new(RwLock::new(Vec::new())),
theme: RwLock::new(Arc::new(theme)),
invalidation: Notify::new(),
failure: Arc::new(Mutex::new(None)),
hits: Mutex::new(HitMap::default()),
app_event_tx,
app_event_rx: Mutex::new(Some(app_event_rx)),
header_focus: Mutex::new(None),
notifications: Arc::new(Mutex::new(Vec::new())),
})
}
pub async fn ipc(&self) -> Option<Arc<IpcClient>> {
self.ipc.read().await.clone()
}
pub async fn client_state(&self) -> Option<iota_state::ClientState> {
self.ipc.read().await.as_ref().map(|ipc| ipc.state())
}
pub async fn attach_daemon(&self, ipc: Arc<IpcClient>) {
*self.ipc.write().await = Some(ipc);
}
pub async fn set_theme(&self, theme: ResolvedTheme) {
*self.theme.write().await = Arc::new(theme);
self.invalidate();
}
pub async fn theme_name(&self) -> ThemeName {
self.theme.read().await.name
}
pub fn is_shutdown(&self) -> bool {
self.cancellation.is_cancelled()
}
pub fn request_shutdown(&self) {
self.cancellation.cancel();
self.invalidate();
}
pub fn invalidate(&self) {
self.invalidation.notify_one();
}
pub fn failure(&self) -> Option<String> {
self.failure.lock().ok().and_then(|f| f.clone())
}
/// Lets bootstrap operations race their work against Ctrl+C without
/// blocking the input task or leaving the terminal in raw mode.
pub async fn wait_for_shutdown(&self) {
self.cancellation.cancelled().await;
}
pub fn cancellation_token(&self) -> CancellationToken {
self.cancellation.clone()
}
pub async fn push_notification(&self, notification: Notification) {
if let Ok(mut notifications) = self.notifications.lock() {
notifications.push(notification);
self.invalidate();
}
}
pub async fn clear_expired_notifications(&self) {
if let Ok(mut notifications) = self.notifications.lock() {
let before = notifications.len();
notifications.retain(|n| !n.is_expired());
if notifications.len() != before {
self.invalidate();
}
}
}
pub async fn notifications(&self) -> Vec<Notification> {
self.notifications
.lock()
.map(|n| n.clone())
.unwrap_or_default()
}
pub async fn set_screen(&self, screen: Box<dyn Screen>) {
self.screen_stack.write().await.push(screen);
self.invalidate();
}
pub async fn replace_screen(&self, screen: Box<dyn Screen>) {
let mut stack = self.screen_stack.write().await;
stack.clear();
stack.push(screen);
self.invalidate();
}
pub async fn set_root_screen(&self, screen: Box<dyn Screen>) {
let mut stack = self.screen_stack.write().await;
stack.clear();
stack.push(screen);
self.invalidate();
}
pub async fn handle_input(self: Arc<Self>, key_event: KeyEvent) {
self.handle_event(UiEvent::Key(key_event)).await;
}
pub async fn handle_event(self: Arc<Self>, event: UiEvent) {
if matches!(&event, UiEvent::App(AppEvent::OpenUsers)) {
self.open_users().await;
return;
}
if matches!(&event, UiEvent::App(AppEvent::OpenMetrics)) {
if let Some(screen) = MetricsScreen::new(self.clone()).await {
self.set_screen(Box::new(screen)).await;
}
return;
}
if let UiEvent::App(AppEvent::ApplyTheme { theme, persist }) = &event {
self.set_theme(theme::resolve(*theme)).await;
if *persist {
let mut config = theme::UiConfig::load().unwrap_or_default();
config.theme = *theme;
let result = config
.save()
.map_err(|error| format!("Could not save UI settings: {error}"));
let _ = self
.app_event_tx
.send(UiEvent::App(AppEvent::ThemeSaved(result)));
}
return;
}
if let UiEvent::App(AppEvent::SaveSettings {
theme,
color,
unicode,
cli_output,
cli_require_confirmation,
}) = &event
{
self.set_theme(theme::resolve(*theme)).await;
let mut config = theme::UiConfig::load().unwrap_or_default();
config.theme = *theme;
config.color = *color;
config.unicode = *unicode;
config.cli_output = *cli_output;
config.cli_require_confirmation = *cli_require_confirmation;
let result = config
.save()
.map_err(|error| format!("Could not save UI settings: {error}"));
let _ = self
.app_event_tx
.send(UiEvent::App(AppEvent::ThemeSaved(result)));
return;
}
if matches!(&event, UiEvent::App(AppEvent::RegenerateKeysRequested)) {
let Some(ipc) = self.ipc().await else {
let _ = self
.app_event_tx
.send(UiEvent::App(AppEvent::KeysRegenerated(Err(
"Not connected to daemon.".into(),
))));
return;
};
let sender = self.app_event_tx.clone();
tokio::spawn(async move {
let result = match ipc
.send_request(iota_ipc::LocalRequest::RotateIotaIdentity)
.await
{
Ok(iota_ipc::ResponseResult::Ok(_)) => Ok(()),
Ok(iota_ipc::ResponseResult::Error(error)) => {
Err(format!("Cannot regenerate keys: {error}"))
}
Err(error) => Err(format!("Cannot regenerate keys: {error}")),
};
let _ = sender.send(UiEvent::App(AppEvent::KeysRegenerated(result)));
});
return;
}
if let UiEvent::Key(key) = &event {
let header_is_focused = self
.header_focus
.lock()
.map(|focus| focus.is_some())
.unwrap_or(false);
if key.code == KeyCode::F(6) {
if let Ok(mut focus) = self.header_focus.lock() {
*focus = if focus.is_some() { None } else { Some(0) };
}
self.invalidate();
return;
}
if key.code == KeyCode::Char('?') {
let has_help_overlay = self
.screen_stack
.read()
.await
.iter()
.any(|s| s.as_any().downcast_ref::<HelpOverlay>().is_some());
if !has_help_overlay {
self.set_screen(Box::new(HelpOverlay::new())).await;
}
return;
}
if header_is_focused {
let mut action = None;
if let Ok(mut focus) = self.header_focus.lock() {
let index = focus.unwrap_or(0);
match key.code {
KeyCode::Left | KeyCode::BackTab => *focus = Some((index + 3) % 4),
KeyCode::Right | KeyCode::Tab => *focus = Some((index + 1) % 4),
KeyCode::Enter | KeyCode::Char(' ') => {
action = Some(
[
AppAction::OpenOverview,
AppAction::OpenUsers,
AppAction::OpenSettings,
AppAction::Quit,
][index],
);
*focus = None;
}
KeyCode::Esc => *focus = None,
_ => {}
}
}
if let Some(action) = action {
self.dispatch_action(action).await;
} else {
self.invalidate();
}
return;
}
}
if let UiEvent::Mouse(mouse) = &event {
if matches!(
mouse.kind,
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown
) {
let action = self
.hits
.lock()
.ok()
.and_then(|hits| hits.action_at(mouse.column, mouse.row));
if action == Some(AppAction::FocusLogs) {
self.dispatch_action(AppAction::FocusLogs).await;
let key = if matches!(mouse.kind, MouseEventKind::ScrollUp) {
KeyCode::Up
} else {
KeyCode::Down
};
// Log scrolling is a local, handled interaction; route it
// directly rather than recursively constructing another
// async UI event future.
if let Some(screen) = self.screen_stack.write().await.last_mut() {
let _ = screen.handle_event(UiEvent::Key(KeyEvent::from(key)));
}
self.invalidate();
return;
}
}
if matches!(
mouse.kind,
MouseEventKind::Down(crossterm::event::MouseButton::Left)
) {
if let Some(action) = self
.hits
.lock()
.ok()
.and_then(|hits| hits.action_at(mouse.column, mouse.row))
{
self.dispatch_action(action).await;
return;
}
}
}
let result = {
let mut stack = self.screen_stack.write().await;
if let Some(screen) = stack.last_mut() {
screen.handle_event(event)
} else {
return;
}
};
match result {
InteractionResult::OpenScreen { screen } => {
self.set_screen(screen).await;
}
InteractionResult::OpenFutureScreen { screen: fut } => {
let ui = self.clone();
tokio::select! {
screen = fut => ui.set_screen(screen).await,
_ = ui.cancellation.cancelled() => return,
}
}
InteractionResult::AppTask { task } => {
let sender = self.app_event_tx.clone();
tokio::spawn(async move {
let event = task.await;
let _ = sender.send(event);
});
}
InteractionResult::CloseScreen => {
let mut stack = self.screen_stack.write().await;
stack.pop();
if stack.is_empty() && self.shutdown_on_empty {
self.request_shutdown();
}
}
InteractionResult::Handled => {}
InteractionResult::Unhandled => {}
}
self.invalidate();
}
async fn dispatch_action(self: &Arc<Self>, action: AppAction) {
match action {
AppAction::Quit => self.request_shutdown(),
AppAction::OpenMain => {
let mut stack = self.screen_stack.write().await;
if stack.len() > 1 {
stack.truncate(1);
}
drop(stack);
self.invalidate();
}
AppAction::OpenOverview => {
let status = {
let stack = self.screen_stack.read().await;
stack
.iter()
.rev()
.find_map(|s| s.as_any().downcast_ref::<MainScreen>())
.map(|main| (main.connection_status(), main.daemon_status()))
};
if let Some((connection, daemon)) = status {
self.set_screen(Box::new(OverviewScreen::new(connection, daemon)))
.await;
}
}
AppAction::OpenUsers => self.open_users().await,
AppAction::OpenSettings => {
let current = self.theme_name().await;
self.set_screen(Box::new(SettingsScreen::new(current)))
.await;
}
AppAction::OpenMetrics => {
if let Some(screen) = MetricsScreen::new(self.clone()).await {
self.set_screen(Box::new(screen)).await;
}
}
action => {
let result = {
let mut stack = self.screen_stack.write().await;
stack.last_mut().map(|screen| screen.handle_action(action))
};
if matches!(result, Some(InteractionResult::CloseScreen)) {
let mut stack = self.screen_stack.write().await;
stack.pop();
}
self.invalidate();
}
}
}
async fn open_users(self: &Arc<Self>) {
let Some(ipc) = self.ipc().await else { return };
self.set_screen(Box::new(UsersScreen::loading(ipc.clone())))
.await;
let sender = self.app_event_tx.clone();
let ui = self.clone();
tokio::spawn(async move {
let load = async {
match ipc.send_request(iota_ipc::LocalRequest::ListUsers).await {
Ok(iota_ipc::ResponseResult::Ok(iota_ipc::ResponsePayload::Users(users))) => {
Ok(users
.into_iter()
.map(|u| UserEntry {
user_id: u.user_id,
username: u.username,
state: u.state,
data_present: u.data_present,
credential_present: u.credential_present,
})
.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}")),
}
};
tokio::pin!(load);
let mut ticker = tokio::time::interval(std::time::Duration::from_millis(200));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let result = loop {
tokio::select! {
result = &mut load => break result,
_ = ticker.tick() => {
ui.invalidate();
}
}
};
let _ = sender.send(UiEvent::App(AppEvent::UsersLoaded(result)));
});
}
pub async fn render(&self) -> io::Result<()> {
self.clear_expired_notifications().await;
let theme = self.theme.read().await.clone();
let context = RenderContext {
theme: theme.as_ref(),
};
// The renderer is the only task that takes the terminal lock. Screen
// mutations use the stack lock briefly before invalidating a frame.
let stack_guard = self.screen_stack.read().await;
let (connection, daemon) = stack_guard
.iter()
.find_map(|item| item.as_any().downcast_ref::<MainScreen>())
.map(|main| {
(
main.connection_status().borrow().clone(),
main.daemon_status().borrow().clone(),
)
})
.unwrap_or_else(|| (IpcConnectionState::Disconnected, DaemonStatus::default()));
if let Some(screen) = stack_guard.last() {
let mut terminal = self
.terminal
.lock()
.map_err(|_| io::Error::other("terminal mutex poisoned"))?;
let mut hits = HitMap::default();
terminal.draw(|f| {
let rows = Layout::vertical([
Constraint::Length(2),
Constraint::Min(1),
Constraint::Length(1),
])
.split(f.area());
let header_focus = self.header_focus.lock().ok().and_then(|focus| *focus);
render_header(
f,
rows[0],
&connection,
&daemon,
context.theme,
&mut hits,
header_focus,
);
let hints = if header_focus.is_some() {
" Left/Right: choose Enter: activate Esc/F6: screen".to_owned()
} else {
let mut screen_hints: Vec<String> = screen
.key_hints()
.into_iter()
.map(|hint| format!("{}: {}", hint.keys, hint.action))
.collect();
if !screen_hints.iter().any(|h| h.contains("?")) {
screen_hints.push("?: Help".to_owned());
}
screen_hints.join(" ")
};
f.render_widget(
ratatui::widgets::Paragraph::new(format!(" {hints}")).style(
context
.theme
.surfaces
.footer
.patch(context.theme.text.muted),
),
rows[2],
);
screen.render(f, rows[1], &context, &mut hits);
if let Ok(notifications) = self.notifications.try_lock() {
if !notifications.is_empty() {
let notification_area = Rect {
x: rows[1].x + rows[1].width.saturating_sub(40),
y: rows[1].y,
width: 40.min(rows[1].width),
height: 3.min(rows[1].height),
};
render_notification_area(
f,
notification_area,
&notifications,
context.theme,
);
}
}
})?;
if let Ok(mut current) = self.hits.lock() {
*current = hits;
}
}
Ok(())
}
}

View file

@ -1,75 +0,0 @@
use ratatui::layout::Rect;
use crate::{
controls::button::{
ActionButton, ButtonIntent, button_minimum_width, horizontal_button_widths, render_button,
},
theme::ResolvedTheme,
util::terms_focus::Focus,
};
pub fn draw_buttons(
frame: &mut ratatui::Frame,
area: Rect,
current_focus: Focus,
state: (bool, bool),
update_needed: bool,
downgrade_scenario: bool,
tos_or_privacy: bool,
theme: &ResolvedTheme,
) {
let cancel_text = if update_needed {
"[Q] Quit"
} else {
"[Q] Not now"
};
let continue_text = if downgrade_scenario {
"Downgrade"
} else {
"Continue"
};
let mut buttons = vec![
(cancel_text, Focus::Cancel),
(continue_text, Focus::Continue),
];
if tos_or_privacy {
buttons.push(("Continue with Tensamin Services", Focus::ContinueAll));
}
let minimums = buttons
.iter()
.map(|(label, _)| button_minimum_width(label))
.collect::<Vec<_>>();
let Some(widths) = horizontal_button_widths(area.width, &minimums) else {
return;
};
let mut x = area.x;
for ((label, focus), width) in buttons.iter().zip(widths) {
let button_area = Rect {
x,
y: area.y,
width,
height: area.height,
};
x = x.saturating_add(width);
let (intent, enabled) = match focus {
Focus::Cancel => (ButtonIntent::Cancel, true),
Focus::Continue => (ButtonIntent::Primary, state.0),
Focus::ContinueAll => (ButtonIntent::Primary, state.1),
_ => (ButtonIntent::Neutral, false),
};
render_button(
frame,
button_area,
ActionButton {
label,
intent,
focused: current_focus == *focus,
enabled,
},
theme,
);
}
}

View file

@ -1,15 +0,0 @@
use iota_cli::controls::button::{button_minimum_width, horizontal_button_widths};
#[test]
fn width_allocation_handles_exact_spare_and_insufficient_space() {
assert_eq!(horizontal_button_widths(7, &[3, 4]), Some(vec![3, 4]));
assert_eq!(horizontal_button_widths(10, &[3, 4]), Some(vec![5, 5]));
assert_eq!(horizontal_button_widths(6, &[3, 4]), None);
assert_eq!(horizontal_button_widths(10, &[]), Some(Vec::new()));
}
#[test]
fn minimum_width_uses_terminal_columns() {
assert_eq!(button_minimum_width("é"), 3);
assert_eq!(button_minimum_width(""), 4);
}

View file

@ -1,71 +0,0 @@
use iota_cli::{
controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line},
theme::{ThemeName, resolve},
};
use ratatui::style::{Color, Modifier};
#[test]
fn ansi_checkbox_matches_the_existing_focused_and_disabled_styles() {
let theme = resolve(ThemeName::Ansi);
let line = render_choice_line(
"Terms",
ChoiceKind::Checkbox,
ChoiceVisualState {
selected: false,
focused: true,
enabled: true,
},
&theme,
);
assert_eq!(
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>(),
"[ ] Terms"
);
assert_eq!(line.spans[1].style.fg, Some(Color::Yellow));
assert!(line.spans[1].style.add_modifier.contains(Modifier::BOLD));
let disabled = render_choice_line(
"Terms",
ChoiceKind::Checkbox,
ChoiceVisualState {
selected: false,
focused: true,
enabled: false,
},
&theme,
);
assert_eq!(disabled.spans[1].style.fg, Some(Color::DarkGray));
assert_eq!(disabled.spans[3].style.fg, Some(Color::Red));
}
#[test]
fn colourless_themes_keep_state_and_focus_visible() {
for name in [ThemeName::Monospace, ThemeName::Binary] {
let theme = resolve(name);
let line = render_choice_line(
"Mode",
ChoiceKind::Radio,
ChoiceVisualState {
selected: true,
focused: true,
enabled: true,
},
&theme,
);
assert_eq!(
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>(),
"> (x) Mode <"
);
assert!(
line.spans
.iter()
.all(|span| span.style.fg.is_none() && span.style.bg.is_none())
);
}
}

View file

@ -1,109 +0,0 @@
use iota_cli::controls::{
checkbox_group::{CheckboxChange, CheckboxGroup, CheckboxItem},
navigation::DisabledFocusPolicy,
radio_group::{DisabledSelectionPolicy, RadioChange, RadioGroup, RadioGroupError, RadioItem},
};
fn checkbox(value: u8, enabled: bool) -> CheckboxItem<u8> {
CheckboxItem {
value,
label: value.to_string(),
description: None,
enabled,
disabled_reason: None,
}
}
fn radio(value: u8, enabled: bool) -> RadioItem<u8> {
RadioItem {
value,
label: value.to_string(),
description: None,
enabled,
disabled_reason: None,
}
}
#[test]
fn checkbox_selection_and_disabled_focus_are_independent() {
let mut group = CheckboxGroup::new(
vec![checkbox(1, true), checkbox(2, false), checkbox(3, true)],
[1, 99],
)
.unwrap();
assert_eq!(
group.selected().iter().copied().collect::<Vec<_>>(),
vec![1]
);
assert_eq!(group.toggle_focused(), CheckboxChange::Deselected(1));
group.focus_next();
assert_eq!(group.focused_item().unwrap().value, 3);
group.set_focus_policy(DisabledFocusPolicy::Include);
group.focus_previous();
assert_eq!(group.focused_item().unwrap().value, 2);
assert_eq!(group.toggle_focused(), CheckboxChange::IgnoredDisabled(2));
}
#[test]
fn checkbox_non_wrapping_navigation_stops_at_the_edge() {
let mut group = CheckboxGroup::new(vec![checkbox(1, true), checkbox(2, true)], []).unwrap();
group.set_wrap_navigation(false);
group.focus_previous();
assert_eq!(group.focused_item().unwrap().value, 1);
}
#[test]
fn radio_validates_default_and_preserves_one_selection() {
assert!(matches!(
RadioGroup::new(Vec::<RadioItem<u8>>::new(), None, 1),
Err(RadioGroupError::Empty)
));
assert!(matches!(
RadioGroup::new(vec![radio(1, true)], None, 2),
Err(RadioGroupError::DefaultMissing)
));
assert!(matches!(
RadioGroup::new(vec![radio(1, false)], None, 1),
Err(RadioGroupError::DefaultDisabled)
));
let mut group = RadioGroup::new(vec![radio(1, true), radio(2, true)], Some(2), 1).unwrap();
assert_eq!(group.selected(), &2);
group.focus_next();
assert_eq!(group.selected(), &2);
assert_eq!(group.select_focused(), RadioChange::Unchanged(2));
group.focus_previous();
assert_eq!(
group.select_focused(),
RadioChange::Changed {
previous: 2,
selected: 1
}
);
assert_eq!(group.selected(), &1);
}
#[test]
fn groups_initially_focus_the_first_enabled_item() {
let checkboxes = CheckboxGroup::new(vec![checkbox(1, false), checkbox(2, true)], []).unwrap();
assert_eq!(checkboxes.focused_item().unwrap().value, 2);
let radios = RadioGroup::new(vec![radio(1, false), radio(2, true)], None, 2).unwrap();
assert_eq!(radios.focused_item().value, 2);
}
#[test]
fn disabling_a_selected_radio_obeys_the_configured_policy() {
let mut group = RadioGroup::new(vec![radio(1, true), radio(2, true)], Some(2), 1).unwrap();
group.set_enabled(&2, false).unwrap();
assert_eq!(group.selected(), &1);
group.set_enabled(&2, true).unwrap();
group.focus_next();
group.select_focused();
group.set_disabled_selection_policy(DisabledSelectionPolicy::ReturnError);
assert_eq!(
group.set_enabled(&2, false),
Err(RadioGroupError::SelectedItemDisabled)
);
assert_eq!(group.selected(), &2);
}

View file

@ -1,45 +0,0 @@
use iota_cli::layout::fit::{
FitLevel, RequiredSize, centered_rect, inset_checked, reserve_vertical, select_fit_level,
};
use ratatui::layout::Rect;
#[test]
fn selects_fit_by_both_dimensions() {
let preferred = RequiredSize {
width: 80,
height: 20,
};
let compact = RequiredSize {
width: 50,
height: 12,
};
assert_eq!(
select_fit_level(Rect::new(0, 0, 80, 20), preferred, compact),
FitLevel::Preferred
);
assert_eq!(
select_fit_level(Rect::new(0, 0, 50, 12), preferred, compact),
FitLevel::Compact
);
assert_eq!(
select_fit_level(Rect::new(0, 0, 80, 11), preferred, compact),
FitLevel::Fallback
);
}
#[test]
fn rectangle_helpers_do_not_underflow() {
let zero = Rect::new(4, 5, 0, 0);
assert_eq!(
centered_rect(
zero,
RequiredSize {
width: 10,
height: 10
}
),
zero
);
assert_eq!(reserve_vertical(zero, 1, 0), None);
assert_eq!(inset_checked(zero, 1, 1), None);
}

View file

@ -1,78 +0,0 @@
use crossterm::event::{KeyCode, KeyEvent};
use iota_cli::{
interaction_result::InteractionResult,
render_context::RenderContext,
screens::{
screens::{AppEvent, HitMap, Screen, UiEvent},
settings::SettingsScreen,
},
theme::{ThemeName, resolve},
};
use ratatui::{Terminal, backend::TestBackend};
fn buffer_text(terminal: &Terminal<TestBackend>) -> String {
terminal
.backend()
.buffer()
.content()
.iter()
.map(|cell| cell.symbol())
.collect()
}
#[tokio::test]
async fn settings_preview_and_save_emit_typed_application_events() {
let mut screen = SettingsScreen::new(ThemeName::Ansi);
let preview = screen.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Right)));
let InteractionResult::AppTask { task } = preview else {
panic!("theme preview should emit an application task");
};
assert!(matches!(
task.await,
UiEvent::App(AppEvent::ApplyTheme {
theme: ThemeName::Surface,
persist: false
})
));
let save = screen.handle_event(UiEvent::Key(KeyEvent::from(KeyCode::Enter)));
let InteractionResult::AppTask { task } = save else {
panic!("theme save should emit an application task");
};
assert!(matches!(
task.await,
UiEvent::App(AppEvent::SaveSettings {
theme: ThemeName::Surface,
color: _,
unicode: _,
cli_output: _,
cli_require_confirmation: _
})
));
}
#[test]
fn settings_is_readable_in_every_theme_and_layout() {
for theme_name in ThemeName::ALL {
for (width, height) in [(42, 12), (72, 20), (100, 28)] {
let theme = resolve(theme_name);
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
let screen = SettingsScreen::new(theme_name);
terminal
.draw(|frame| {
screen.render(
frame,
frame.area(),
&RenderContext { theme: &theme },
&mut HitMap::default(),
);
})
.unwrap();
let rendered = buffer_text(&terminal);
assert!(rendered.contains("Settings"));
assert!(rendered.contains("Theme:"));
assert!(rendered.contains("[OK] Healthy"));
assert!(rendered.contains("[FAIL] Failed"));
}
}
}

View file

@ -1,12 +0,0 @@
[package]
name = "iota-connection"
version = "0.1.0"
edition = "2024"
[dependencies]
iota-storage = { path = "../iota-storage" }
iota-util = { path = "../iota-util" }
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = ["crypto"] }
[dev-dependencies]
tokio = { version = "1.50.0", features = ["macros", "rt"] }

View file

@ -1,36 +0,0 @@
use mtp::codec::CommunicationValue;
use std::future::Future;
use std::time::Duration;
/// Unified interface for all connection types (Omikron, Direct, future modes).
///
/// Provides the common messaging API that the rest of the codebase uses,
/// regardless of whether the connection goes through Omikron or is direct.
pub trait ConnectionHandler: Send + Sync {
/// Send a message to the remote end.
fn send_message(
&self,
cv: &CommunicationValue,
) -> impl Future<Output = Result<(), String>> + Send;
/// Send a message and wait for a correlated response.
///
/// The implementation correlates requests/responses by message ID and
/// enforces the given `timeout`. Returns an error on timeout or if the
/// connection drops while waiting.
fn await_response(
&self,
cv: &CommunicationValue,
timeout: Option<Duration>,
) -> impl Future<Output = Result<CommunicationValue, String>> + Send;
/// Returns `true` when the connection is alive and ready for traffic.
fn is_connected(&self) -> impl Future<Output = bool> + Send;
/// Returns `true` when the connection has completed identification /
/// registration and is fully operational.
fn is_identified(&self) -> impl Future<Output = bool> + Send;
/// Gracefully tear down the connection.
fn stop(&self) -> impl Future<Output = ()> + Send;
}

View file

@ -1,4 +0,0 @@
pub mod connection_handler;
pub mod message_common;
pub mod message_handlers;
pub mod relay;

View file

@ -1,156 +0,0 @@
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
use mtp::type_map::TypeMap;
use std::time::{SystemTime, UNIX_EPOCH};
pub use iota_util::mtp_compat::{MtpFieldError, OptionalDataValueExt, RequiredCommunicationFields};
pub trait CommunicationResponseExt {
fn with_request_id(self, request: &CommunicationValue) -> Self;
}
impl CommunicationResponseExt for CommunicationValue {
fn with_request_id(mut self, request: &CommunicationValue) -> Self {
self = self.without_id();
if let Some(id) = request.id() {
self = self.with_id(id);
}
self
}
}
pub fn typed_container(items: Vec<(DataType, DataValue)>) -> DataValue {
use mtp::type_map::{DataTypeId, TypeMap};
let tm = TypeMap::latest();
DataValue::Container(
items
.into_iter()
.filter_map(|(dt, dv)| tm.data_id_enum(dt).map(|id| (DataTypeId(id), dv)))
.collect(),
)
}
pub fn data_string(cv: &CommunicationValue, dt: DataType) -> Option<String> {
cv.get_data(dt)
.and_then(DataValue::as_str)
.map(|s| s.to_string())
.or_else(|| {
cv.get_data(dt)
.and_then(DataValue::as_number)
.map(|n| n.to_string())
})
.or_else(|| {
cv.get_data(dt)
.and_then(DataValue::as_signed_number)
.map(|n| n.to_string())
})
}
pub fn data_i64(cv: &CommunicationValue, dt: DataType) -> Option<i64> {
cv.get_data(dt)
.and_then(DataValue::as_number)
.and_then(|n| i64::try_from(n).ok())
.or_else(|| {
cv.get_data(dt)
.and_then(DataValue::as_signed_number)
.and_then(|n| i64::try_from(n).ok())
})
.or_else(|| {
cv.get_data(dt)
.and_then(DataValue::as_str)
.and_then(|s| s.parse::<i64>().ok())
})
}
#[derive(Debug, Clone)]
pub struct ChatSecretRecipient {
pub user_id: String,
pub encrypted_secret: Vec<u8>,
pub kem_ciphertext: Vec<u8>,
}
pub fn recipient_from_value(value: &DataValue) -> Option<ChatSecretRecipient> {
let tm = TypeMap::latest();
let user_id = value
.get_field(DataType::UserId.try_to_id(&tm)?)?
.as_str()
.map(|s| s.to_string())
.or_else(|| {
value
.get_field(DataType::UserId.try_to_id(&tm)?)?
.as_number()
.map(|n| n.to_string())
})?;
let encrypted_secret = value
.get_field(DataType::EncryptedSecret.try_to_id(&tm)?)?
.as_bytes()?;
let kem_ciphertext = value
.get_field(DataType::KemCiphertext.try_to_id(&tm)?)?
.as_bytes()?;
Some(ChatSecretRecipient {
user_id,
encrypted_secret,
kem_ciphertext,
})
}
pub fn chat_secret_recipients(cv: &CommunicationValue) -> Option<Vec<ChatSecretRecipient>> {
let recipients = cv.get_data(DataType::Recipients)?.as_array()?;
let parsed = recipients
.iter()
.map(recipient_from_value)
.collect::<Option<Vec<_>>>()?;
if parsed.is_empty() {
None
} else {
Some(parsed)
}
}
pub fn now_millis_i64() -> i64 {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
i64::try_from(millis).unwrap_or(i64::MAX)
}
pub fn error_response(request: &CommunicationValue, ty: CommunicationType) -> CommunicationValue {
let mut response = CommunicationValue::new(ty).without_id();
if let Some(id) = request.id() {
response = response.with_id(id);
}
if let Some(sender) = request.sender() {
response = response.with_receiver(sender);
}
response
}
#[cfg(test)]
mod tests {
use super::error_response;
use mtp::codec::{CommunicationType, CommunicationValue};
#[test]
fn error_response_preserves_an_absent_request_id() {
let request = CommunicationValue::new(CommunicationType::GetChats)
.without_id()
.with_sender(42);
let response = error_response(&request, CommunicationType::ErrorInvalidData);
assert_eq!(response.id(), None);
assert_eq!(response.receiver(), Some(42));
}
#[test]
fn error_response_copies_an_existing_request_id() {
let request = CommunicationValue::new(CommunicationType::GetChats)
.with_id(7)
.with_sender(42);
let response = error_response(&request, CommunicationType::ErrorInvalidData);
assert_eq!(response.id(), Some(7));
assert_eq!(response.receiver(), Some(42));
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,383 +0,0 @@
use iota_util::route_target::RouteTarget;
use mtp::codec::{
CommunicationValue, ProtectionPolicy, RelayError, RelayOpenOptions, SignaturePolicy, TypeMap,
VerifiedRelayContent, VerifiedRelayMetadata, forward_relay_frame,
open_relay_content_with_limits_without_replay, open_relay_metadata_with_without_replay,
relay_metadata_claimed_signer_id_with_options,
};
use mtp::crypto::{Keyring, PublicKeyBundle};
use std::fmt;
pub const RELAY_PROTECTION_POLICY: ProtectionPolicy = ProtectionPolicy {
signature: SignaturePolicy::Dual,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessageSecurityClass {
RelayOnly,
AuthenticatedPeerControl,
AuthenticatedLocalRequest,
}
pub fn message_security_class(frame: &CommunicationValue) -> MessageSecurityClass {
const RELAY_ONLY_TYPES: &[mtp::codec::CommunicationType] = &[
mtp::codec::CommunicationType::MessageSend,
mtp::codec::CommunicationType::MessageLive,
mtp::codec::CommunicationType::MessageState,
mtp::codec::CommunicationType::MessageEdit,
mtp::codec::CommunicationType::MessageEditLive,
mtp::codec::CommunicationType::MessageReactionAdd,
mtp::codec::CommunicationType::MessageReactionRemove,
mtp::codec::CommunicationType::MessageReactionLive,
mtp::codec::CommunicationType::MessageDelete,
mtp::codec::CommunicationType::MessageDeleteLive,
mtp::codec::CommunicationType::MessageOtherIota,
mtp::codec::CommunicationType::SetChatSecret,
mtp::codec::CommunicationType::SendChat,
mtp::codec::CommunicationType::SettingsSave,
mtp::codec::CommunicationType::GlobalSettingsSave,
mtp::codec::CommunicationType::AddConversation,
mtp::codec::CommunicationType::AddCommunity,
mtp::codec::CommunicationType::RemoveCommunity,
];
if RELAY_ONLY_TYPES.iter().any(|kind| frame.is_type(*kind)) {
MessageSecurityClass::RelayOnly
} else if frame.is_type(mtp::codec::CommunicationType::GetChatSecret)
|| frame.is_type(mtp::codec::CommunicationType::MessageGet)
|| frame.is_type(mtp::codec::CommunicationType::MessagesGet)
{
MessageSecurityClass::AuthenticatedPeerControl
} else {
MessageSecurityClass::AuthenticatedLocalRequest
}
}
#[cfg(test)]
mod security_tests {
use super::{MessageSecurityClass, message_security_class};
use mtp::codec::{CommunicationType, CommunicationValue};
#[test]
fn synchronized_setting_requests_are_authenticated_local_requests() {
for setting_type in [
CommunicationType::SyncedSettingSet,
CommunicationType::SyncedSettingGet,
CommunicationType::SyncedSettingDelete,
CommunicationType::SyncedSettingsList,
CommunicationType::SyncedSettingChanged,
] {
assert_eq!(
message_security_class(&CommunicationValue::new(setting_type)),
MessageSecurityClass::AuthenticatedLocalRequest
);
}
}
}
#[derive(Debug, Clone)]
pub struct UserIdentity {
pub user_id: u64,
pub iota_id: u64,
pub signing_keys: Vec<PublicKeyBundle>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedRelayContext {
pub signer_id: u64,
pub final_recipient_id: u64,
pub message_id: String,
pub created_at: u64,
pub type_map: TypeMap,
}
#[derive(Debug, Clone)]
pub struct VerifiedRelay {
pub metadata: VerifiedRelayMetadata,
pub context: VerifiedRelayContext,
pub signing_keys: Vec<PublicKeyBundle>,
}
#[derive(Debug)]
pub enum RelayValidationError {
WrongNextHop { expected: u64, actual: Option<u64> },
OuterSenderNotAllowed,
MissingSigningKeys(u64),
MissingTypeMap,
InvalidRouteTarget(u64),
KeyLookup(String),
Relay(RelayError),
}
impl fmt::Display for RelayValidationError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::WrongNextHop { expected, actual } => {
write!(
formatter,
"relay next hop {:?} does not match Iota {expected}",
actual
)
}
Self::OuterSenderNotAllowed => formatter.write_str("relay has an outer sender"),
Self::MissingSigningKeys(signer_id) => {
write!(formatter, "no trusted signing keys for user {signer_id}")
}
Self::MissingTypeMap => formatter.write_str("relay has no negotiated type map"),
Self::InvalidRouteTarget(target) => {
write!(formatter, "relay has invalid route target {target}")
}
Self::KeyLookup(error) => write!(formatter, "trusted signer lookup failed: {error}"),
Self::Relay(error) => error.fmt(formatter),
}
}
}
impl std::error::Error for RelayValidationError {}
impl From<RelayError> for RelayValidationError {
fn from(error: RelayError) -> Self {
Self::Relay(error)
}
}
/*
* Relay metadata is opened only after the claimed signer selects trusted key
* history. Replay reservation happens after verification and durable
* acceptance, so a failed delivery can be retried without losing the frame.
*/
pub async fn verify_relay_metadata<F, Fut>(
frame: &CommunicationValue,
local_iota_id: u64,
keyring: &Keyring,
resolve_signing_keys: F,
) -> Result<VerifiedRelay, RelayValidationError>
where
F: FnOnce(u64) -> Fut,
Fut: Future<Output = Result<Vec<PublicKeyBundle>, RelayValidationError>>,
{
let expected_next_hop = RouteTarget::Iota(local_iota_id)
.wire_id()
.ok_or(RelayValidationError::InvalidRouteTarget(local_iota_id))?;
if frame.receiver() != Some(expected_next_hop) {
return Err(RelayValidationError::WrongNextHop {
expected: expected_next_hop,
actual: frame.receiver(),
});
}
if frame.sender().is_some() {
return Err(RelayValidationError::OuterSenderNotAllowed);
}
let open_options = RelayOpenOptions::new(RELAY_PROTECTION_POLICY);
let claimed_signer = relay_metadata_claimed_signer_id_with_options(
frame,
&[keyring],
open_options.decode_limits,
open_options.protected_limits,
)?;
let signing_keys = resolve_signing_keys(claimed_signer).await?;
if signing_keys.is_empty() {
return Err(RelayValidationError::MissingSigningKeys(claimed_signer));
}
let resolver_keys = signing_keys.clone();
let type_map = frame
.type_map()
.cloned()
.ok_or(RelayValidationError::MissingTypeMap)?;
let metadata = open_relay_metadata_with_without_replay(
frame,
&[keyring],
Some(claimed_signer),
move |signer_id| (signer_id == claimed_signer).then(|| resolver_keys.clone()),
RelayOpenOptions::new(RELAY_PROTECTION_POLICY),
)?;
let context = VerifiedRelayContext {
signer_id: metadata.signer_id(),
final_recipient_id: metadata.final_recipient_id(),
message_id: metadata.message_id().to_owned(),
created_at: metadata.created_at(),
type_map,
};
Ok(VerifiedRelay {
metadata,
context,
signing_keys,
})
}
pub fn open_verified_relay_content(
relay: &VerifiedRelay,
keyrings: &[&Keyring],
expected_recipient_id: u64,
) -> Result<VerifiedRelayContent, RelayValidationError> {
Ok(open_relay_content_with_limits_without_replay(
&relay.metadata,
keyrings,
&relay.signing_keys,
Some(expected_recipient_id),
RelayOpenOptions {
policy: RELAY_PROTECTION_POLICY,
decode_limits: relay.metadata.decode_limits(),
encode_limits: relay.metadata.encode_limits(),
protected_limits: relay.metadata.protected_limits(),
},
)?)
}
pub fn forward_verified_relay(
frame: &CommunicationValue,
target: RouteTarget,
) -> Result<CommunicationValue, RelayValidationError> {
let next_hop_id = target
.wire_id()
.ok_or(RelayValidationError::InvalidRouteTarget(target.id()))?;
Ok(forward_relay_frame(frame, next_hop_id)?)
}
#[cfg(test)]
mod tests {
use super::*;
use mtp::codec::SealedRelayBuilder;
use mtp::crypto::{DualSigner, Ed25519Signer, Keyring};
fn relay(message_id: &str) -> Result<(Keyring, Keyring, CommunicationValue), String> {
let signer_keyring = Keyring::generate();
let recipient_keyring = Keyring::generate();
let signer = DualSigner::new(
&signer_keyring.sig_cl_secret_key,
&signer_keyring.sig_pq_secret_key,
&signer_keyring.sig_pq_public_key,
)
.map_err(|error| error.to_string())?;
let frame = SealedRelayBuilder::new(
"MessageSend",
mtp::codec::DataValue::Str("payload".into()),
7,
42,
RouteTarget::Iota(99)
.wire_id()
.ok_or("invalid test target")?,
&signer,
)
.message_id(message_id)
.created_at(123)
.metadata_recipients(vec![recipient_keyring.public_key_bundle()])
.content_recipients(vec![recipient_keyring.public_key_bundle()])
.build()
.map_err(|error| error.to_string())?;
Ok((signer_keyring, recipient_keyring, frame))
}
#[tokio::test]
async fn verifies_metadata_with_trusted_signing_key() -> Result<(), String> {
let (signer, recipient, frame) = relay("accepted")?;
let trusted_key = signer.public_key_bundle();
let verified = verify_relay_metadata(&frame, 99, &recipient, move |signer_id| async move {
(signer_id == 7)
.then_some(vec![trusted_key])
.ok_or(RelayValidationError::MissingSigningKeys(signer_id))
})
.await
.map_err(|error| error.to_string())?;
assert_eq!(verified.context.signer_id, 7);
assert_eq!(verified.context.final_recipient_id, 42);
assert_eq!(verified.context.message_id, "accepted");
Ok(())
}
#[tokio::test]
async fn rejects_metadata_signed_by_untrusted_key() -> Result<(), String> {
let (_signer, recipient, frame) = relay("wrong-key")?;
let wrong_signer = Keyring::generate();
let trusted_key = wrong_signer.public_key_bundle();
let result = verify_relay_metadata(&frame, 99, &recipient, move |_| async move {
Ok(vec![trusted_key])
})
.await;
assert!(matches!(result, Err(RelayValidationError::Relay(_))));
Ok(())
}
#[tokio::test]
async fn rejects_classical_only_relay_under_dual_policy() -> Result<(), String> {
let signer_keyring = Keyring::generate();
let recipient_keyring = Keyring::generate();
let signer = Ed25519Signer::new(&signer_keyring.sig_cl_secret_key)
.map_err(|error| error.to_string())?;
let frame = SealedRelayBuilder::new(
"MessageSend",
mtp::codec::DataValue::Str("payload".into()),
7,
42,
RouteTarget::Iota(99)
.wire_id()
.ok_or("invalid test target")?,
&signer,
)
.message_id("classical-only")
.created_at(123)
.metadata_recipients(vec![recipient_keyring.public_key_bundle()])
.content_recipients(vec![recipient_keyring.public_key_bundle()])
.build()
.map_err(|error| error.to_string())?;
let trusted_key = signer_keyring.public_key_bundle();
let result = verify_relay_metadata(&frame, 99, &recipient_keyring, move |_| async move {
Ok(vec![trusted_key])
})
.await;
assert!(matches!(result, Err(RelayValidationError::Relay(_))));
Ok(())
}
#[tokio::test]
async fn rejects_outer_sender_before_key_lookup() -> Result<(), String> {
let (_signer, recipient, frame) = relay("outer-sender")?;
let frame = frame.with_sender(501);
let result = verify_relay_metadata(&frame, 99, &recipient, |_| async {
Err(RelayValidationError::MissingSigningKeys(7))
})
.await;
assert!(matches!(
result,
Err(RelayValidationError::OuterSenderNotAllowed)
));
Ok(())
}
#[tokio::test]
async fn verification_does_not_commit_replay_state() -> Result<(), String> {
let (signer, recipient, frame) = relay("duplicate")?;
let trusted_key = signer.public_key_bundle();
for _ in 0..2 {
let trusted_key = trusted_key.clone();
let result = verify_relay_metadata(&frame, 99, &recipient, move |_| async move {
Ok(vec![trusted_key])
})
.await;
let _ = result.map_err(|error| error.to_string())?;
}
Ok(())
}
#[test]
fn forwarding_preserves_sealed_payload() -> Result<(), String> {
let (_signer, _recipient, frame) = relay("forwarding")?;
let forwarded = forward_verified_relay(&frame, RouteTarget::User(100))
.map_err(|error| error.to_string())?;
assert_eq!(frame.sender(), None);
assert_eq!(forwarded.sender(), None);
assert_eq!(forwarded.receiver(), RouteTarget::User(100).wire_id());
assert_eq!(frame.payload(), forwarded.payload());
Ok(())
}
}

View file

@ -1,18 +0,0 @@
[package]
name = "iota-core"
version = "0.1.0"
edition = "2024"
autobins = false
[dependencies]
iota-cli = { path = "../iota-cli" }
iota-logger = { path = "../iota-logger" }
iota-state = { path = "../iota-state" }
iota-storage = { path = "../iota-storage" }
iota-terms = { path = "../iota-terms" }
iota-updater = { path = "../iota-updater" }
iota-util = { path = "../iota-util" }
iota-paths = { path = "../iota-paths" }
web-server = { path = "../web-server" }
pnet = "0.35.0"
tokio = { version = "1.50.0", features = ["full"] }

View file

@ -1 +0,0 @@
pub mod consent_state;

View file

@ -1,25 +0,0 @@
[package]
name = "iota-daemon-lib"
version = "0.1.0"
edition = "2024"
[dependencies]
async-trait = "0.1.89"
iota-ipc = { path = "../iota-ipc" }
iota-logger = { path = "../iota-logger" }
iota-state = { path = "../iota-state" }
iota-storage = { path = "../iota-storage" }
iota-updater = { path = "../iota-updater" }
iota-util = { path = "../iota-util" }
omikron-connector = { path = "../omikron-connector" }
mtp = { git = "https://git.methanium.net/Methanium/mtp.git" }
libc = "0.2"
sysinfo = "0.38.0"
serde_yaml = "0.9"
serde_json = "1"
tokio = { version = "1.50.0", features = ["full"] }
tokio-util = { version = "0.7", features = ["rt"] }
uuid = { version = "*", features = ["v4"] }
[dev-dependencies]
tempfile = "3"

View file

@ -1,596 +0,0 @@
use crate::log_buffer::LogBuffer;
use crate::{DaemonRuntime, DaemonServices};
use iota_ipc::{
CommunitySummary, ComponentStatusResponse, ConfigResponse, DaemonMessage, ExitIntent,
IpcErrorCode, LocalRequest, LogEntriesResponse, LogEntry, MAX_MESSAGE_SIZE,
OmikronStatusResponse, ResponseEnvelope, ResponsePayload, ResponseResult, StatusResponse,
TaskSummary, UpdateStatusResponse, UserDetailResponse, UserSummary,
};
use iota_logger::{log, log_command};
use iota_storage::users::pending_operations::{
self, PendingUserOperation, PendingUserOperationKind, PendingUserOperationPhase,
};
use iota_storage::users::user_manager;
use iota_storage::util::config_util::{self};
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::daemon_state::{ShutdownReason, StartupPhase};
pub use iota_ipc::IpcRole;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PeerContext {
pub pid: i32,
pub uid: u32,
pub role: IpcRole,
}
const MAX_LOG_ENTRIES_PER_RESPONSE: usize = 512;
fn now_millis() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
fn bounded_log_entries(mut entries: Vec<LogEntry>) -> Vec<LogEntry> {
entries.truncate(MAX_LOG_ENTRIES_PER_RESPONSE);
while !entries.is_empty() {
let response = DaemonMessage::Response(ResponseEnvelope {
request_id: u64::MAX,
result: ResponseResult::Ok(ResponsePayload::LogEntries(LogEntriesResponse {
entries: entries.clone(),
})),
});
let fits = serde_json::to_vec(&response)
.map(|encoded| encoded.len() <= MAX_MESSAGE_SIZE)
.unwrap_or(false);
if fits {
return entries;
}
entries.remove(0);
}
entries
}
#[derive(Clone)]
pub struct CommandRouter {
runtime: Arc<DaemonRuntime>,
services: Arc<DaemonServices>,
log_buffer: Arc<Mutex<LogBuffer>>,
}
impl CommandRouter {
pub fn new(
runtime: Arc<DaemonRuntime>,
services: Arc<DaemonServices>,
log_buffer: Arc<Mutex<LogBuffer>>,
) -> Self {
Self {
runtime,
services,
log_buffer,
}
}
pub async fn route(
&self,
peer: &PeerContext,
request_id: u64,
request: LocalRequest,
) -> ResponseEnvelope {
if !peer.role.allows(request.required_role()) {
log!(
"IPC authorization denied: pid={}, uid={}, role={:?}, request={:?}",
peer.pid,
peer.uid,
peer.role,
request
);
return ResponseEnvelope {
request_id,
result: ResponseResult::Error(IpcErrorCode::Unauthorized),
};
}
log_command!(
"pid={} uid={} role={:?} request={:?}",
peer.pid,
peer.uid,
peer.role,
request
);
let result = self.execute(request).await;
ResponseEnvelope { request_id, result }
}
async fn execute(&self, request: LocalRequest) -> ResponseResult {
if !self.services.active
&& !matches!(
request,
LocalRequest::GetStatus | LocalRequest::GetDaemonStatus
)
{
return ResponseResult::Error(IpcErrorCode::Unauthorized);
}
let needs_omikron = matches!(
request,
LocalRequest::CreateUser { .. }
| LocalRequest::AttachUserFromTu { .. }
| LocalRequest::ReleaseUser { .. }
| LocalRequest::CompleteDeleteUser { .. }
);
if needs_omikron && !self.services.omikron.is_connected().await {
return ResponseResult::Error(
if self.runtime.current_startup_phase() != StartupPhase::Ready {
IpcErrorCode::NotReady
} else {
IpcErrorCode::OmikronUnavailable
},
);
}
match request {
LocalRequest::GetStatus => {
let phase = self.runtime.current_startup_phase();
let degraded = self.runtime.degraded_reason.borrow().clone();
let tasks: Vec<String> = self
.runtime
.state
.active_tasks
.iter()
.map(|task| task.to_string())
.collect();
ResponseResult::Ok(ResponsePayload::Status(StatusResponse {
phase: format!("{:?}", phase),
tasks: tasks.clone(),
degraded_reason: degraded,
}))
}
LocalRequest::ListTasks => {
let tasks: Vec<TaskSummary> = self
.runtime
.state
.active_tasks
.iter()
.map(|task| TaskSummary {
name: task.to_string(),
})
.collect();
ResponseResult::Ok(ResponsePayload::Tasks(tasks))
}
LocalRequest::ListUsers => {
let users = user_manager::get_residency()
.into_iter()
.map(|user| {
let profile = user_manager::get_user(user.user_id)?;
Ok(UserSummary {
credential_present: user.state == user_manager::LocalUserState::Managed
&& profile.is_some_and(|profile| {
iota_util::file_util::read_user_credential_with_legacy(
user.user_id,
&profile.username,
)
.ok()
.flatten()
.is_some()
}),
user_id: user.user_id,
username: user.username,
state: match user.state {
user_manager::LocalUserState::Managed => {
iota_ipc::LocalUserState::Managed
}
user_manager::LocalUserState::Released => {
iota_ipc::LocalUserState::Released
}
},
data_present: user.data_present,
})
})
.collect::<Result<Vec<_>, iota_storage::storage_error::StorageError>>();
let Ok(users) = users else {
return ResponseResult::Error(IpcErrorCode::StorageFailure);
};
ResponseResult::Ok(ResponsePayload::Users(users))
}
LocalRequest::CreateUser { username } => {
match omikron_connector::user_ops::create_user(
self.services.omikron.as_ref(),
&username,
)
.await
{
Ok(user) => ResponseResult::Ok(ResponsePayload::UserCreated {
user_id: user.user_id,
username: user.username,
}),
Err(error) => {
log!("User creation failed: {error:?}");
match error {
omikron_connector::user_ops::CreateUserError::InvalidUsername => {
ResponseResult::Error(IpcErrorCode::InvalidRequest)
}
omikron_connector::user_ops::CreateUserError::Transport(
omikron_connector::OmikronError::Timeout(_),
) => ResponseResult::Error(IpcErrorCode::Timeout),
omikron_connector::user_ops::CreateUserError::Transport(_) => {
ResponseResult::Error(IpcErrorCode::OmikronUnavailable)
}
omikron_connector::user_ops::CreateUserError::RemoteRejected => {
ResponseResult::Error(IpcErrorCode::Conflict)
}
omikron_connector::user_ops::CreateUserError::LocalFinalizationPending { .. } => {
ResponseResult::Error(IpcErrorCode::StorageFailure)
}
omikron_connector::user_ops::CreateUserError::LocalPersistence(_) => {
ResponseResult::Error(IpcErrorCode::StorageFailure)
}
omikron_connector::user_ops::CreateUserError::InvalidResponse => {
ResponseResult::Error(IpcErrorCode::InternalFailure)
}
}
}
}
}
LocalRequest::PurgeUserData { user_id } => match user_manager::purge_user_data(user_id)
{
Ok(()) => ResponseResult::Ok(ResponsePayload::UserDataPurged { user_id }),
Err(iota_storage::storage_error::StorageError::PendingRelayOwnershipUnknown) => {
log!(
"User data purge is waiting for pending relay ownership classification for {user_id}"
);
ResponseResult::Error(IpcErrorCode::StorageFailure)
}
Err(error) => {
log!("User data purge failed for {user_id}: {error}");
ResponseResult::Error(IpcErrorCode::StorageFailure)
}
},
LocalRequest::AttachUserFromTu { credential } => {
match omikron_connector::user_ops::attach_user_from_tu(
self.services.omikron.as_ref(),
&credential.0,
)
.await
{
Ok(user) => ResponseResult::Ok(ResponsePayload::Acknowledged {
message: format!("Added {} ({}) to this Iota", user.username, user.user_id),
}),
Err(error) => {
log!("Credential attach failed: {error:?}");
ResponseResult::Error(IpcErrorCode::Unauthorized)
}
}
}
LocalRequest::CompleteDeleteUser {
user_id,
credential,
} => {
let contents = match credential {
Some(value) => Ok(value.0),
None => user_manager::get_user(user_id)
.map_err(|_| ())
.and_then(|user| user.ok_or(()))
.and_then(|user| {
iota_util::file_util::read_user_credential_with_legacy(
user_id,
&user.username,
)
.map_err(|_| ())
})
.and_then(|value| value.ok_or(())),
};
let Ok(contents) = contents else {
return ResponseResult::Error(IpcErrorCode::Unauthorized);
};
match omikron_connector::user_ops::complete_delete_user_with_tu(
self.services.omikron.as_ref(),
&contents,
user_id,
)
.await
{
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged {
message: format!("Deleted Tensamin account {user_id}"),
}),
Err(error) => {
log!("Credential deletion failed for {user_id}: {error:?}");
ResponseResult::Error(IpcErrorCode::Unauthorized)
}
}
}
LocalRequest::RemoveUser { .. } => ResponseResult::Error(IpcErrorCode::InvalidRequest),
LocalRequest::ReleaseUser { user_id } => {
let user = match user_manager::get_user(user_id) {
Ok(user) => user,
Err(_) => return ResponseResult::Error(IpcErrorCode::StorageFailure),
};
let Some(user) = user else {
return ResponseResult::Error(IpcErrorCode::NotFound);
};
if pending_operations::upsert(&PendingUserOperation {
user_id,
operation: PendingUserOperationKind::Release,
username: user.username,
public_key: None,
private_key_hash: None,
reset_token: None,
registration_token: None,
phase: PendingUserOperationPhase::Prepared,
created_at: now_millis(),
})
.is_err()
{
return ResponseResult::Error(IpcErrorCode::StorageFailure);
}
let request = CommunicationValue::new(CommunicationType::ReleaseUserFromIota)
.add_typed_default(DataType::UserId, DataValue::SignedNumber(user_id.into()));
match self
.services
.omikron
.await_response(&request, Duration::from_secs(20))
.await
{
Ok(response) if response.is_type(CommunicationType::Success) => {
match user_manager::release_user(user_id) {
Ok(()) if pending_operations::remove(user_id).is_ok() => {
ResponseResult::Ok(ResponsePayload::Acknowledged {
message: format!(
"Released user {user_id}; hosted data was retained"
),
})
}
Ok(()) => ResponseResult::Error(IpcErrorCode::StorageFailure),
Err(error) => {
log!(
"Remote release succeeded but local cleanup failed for {user_id}: {error}"
);
ResponseResult::Error(IpcErrorCode::StorageFailure)
}
}
}
Ok(response) if response.is_type(CommunicationType::ErrorNotAuthenticated) => {
let _ = pending_operations::remove(user_id);
ResponseResult::Error(IpcErrorCode::Unauthorized)
}
Ok(_) => {
let _ = pending_operations::remove(user_id);
ResponseResult::Error(IpcErrorCode::Conflict)
}
Err(omikron_connector::OmikronError::Timeout(_)) => {
ResponseResult::Error(IpcErrorCode::Timeout)
}
Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable),
}
}
LocalRequest::ReconnectOmikron => match self.services.omikron.reconnect().await {
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged {
message: "Reconnected to Omikron server".into(),
}),
Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable),
},
LocalRequest::RotateIotaIdentity => {
match self.services.omikron.rotate_identity().await {
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged {
message: "New identity registered with Omikron".into(),
}),
Err(error) => {
log!("Iota identity rotation failed: {}", error);
ResponseResult::Error(IpcErrorCode::OmikronUnavailable)
}
}
}
LocalRequest::RequestProcessExit { intent } => {
if matches!(intent, ExitIntent::Restart)
&& !matches!(
crate::deployment::from_environment().supervisor,
iota_ipc::SupervisorKind::Systemd | iota_ipc::SupervisorKind::IotaUi
)
{
return ResponseResult::Error(IpcErrorCode::Conflict);
}
self.runtime.request_shutdown(match intent {
ExitIntent::Stop => ShutdownReason::Stop,
ExitIntent::Restart => ShutdownReason::Restart,
});
ResponseResult::Ok(ResponsePayload::Acknowledged {
message: "process exit accepted".into(),
})
}
LocalRequest::GetDaemonStatus => ResponseResult::Ok(ResponsePayload::DaemonStatus(
iota_ipc::DaemonStatusResponse {
formatted: format!("{:?}", self.runtime.snapshot()),
},
)),
LocalRequest::RestartDaemon => {
self.runtime.request_shutdown(ShutdownReason::Restart);
ResponseResult::Ok(ResponsePayload::Acknowledged {
message: "Daemon restart requested".into(),
})
}
LocalRequest::StopDaemon => {
self.runtime.request_shutdown(ShutdownReason::Stop);
ResponseResult::Ok(ResponsePayload::Acknowledged {
message: "Daemon shutdown requested".into(),
})
}
LocalRequest::GetConfig => {
let cfg = config_util::CONFIG.load();
let yaml = serde_yaml::to_string(&**cfg).unwrap_or_default();
ResponseResult::Ok(ResponsePayload::Config(ConfigResponse { yaml }))
}
LocalRequest::SetConfig { key, value } => {
match config_util::modify_config_value(&key, &value) {
Ok(()) => ResponseResult::Ok(ResponsePayload::Acknowledged {
message: format!("Set {key} = {value}"),
}),
Err(_e) => ResponseResult::Error(IpcErrorCode::InvalidRequest),
}
}
LocalRequest::ReloadConfig => {
config_util::load_config();
ResponseResult::Ok(ResponsePayload::Acknowledged {
message: "Configuration reloaded".into(),
})
}
LocalRequest::GetOmikronStatus => {
let connected = self.services.omikron.is_connected().await;
let iota_id = config_util::CONFIG.load().iota_id;
ResponseResult::Ok(ResponsePayload::OmikronStatus(OmikronStatusResponse {
connected,
iota_id,
}))
}
LocalRequest::ListComponents => {
let snapshot = self.runtime.snapshot();
let components: Vec<ComponentStatusResponse> = snapshot
.components
.into_iter()
.map(|(id, health)| ComponentStatusResponse {
id,
status: health.status,
message: health.message,
})
.collect();
ResponseResult::Ok(ResponsePayload::Components(components))
}
LocalRequest::GetUser { user_id } => match user_manager::get_user(user_id) {
Ok(Some(user)) => {
let credential_present =
iota_util::file_util::read_user_credential_with_legacy(
user_id,
&user.username,
)
.ok()
.flatten()
.is_some();
ResponseResult::Ok(ResponsePayload::UserDetail(UserDetailResponse {
user_id: user.user_id,
username: user.username,
display_name: user.display_name,
created_at: user.created_at,
trusted_apps: user.trusted_apps.keys().cloned().collect(),
state: iota_ipc::LocalUserState::Managed,
data_present: user_manager::get_residency()
.iter()
.find(|entry| entry.user_id == user_id)
.is_none_or(|entry| entry.data_present),
credential_present,
}))
}
Ok(None) => ResponseResult::Error(IpcErrorCode::NotFound),
Err(_) => ResponseResult::Error(IpcErrorCode::StorageFailure),
},
LocalRequest::ImportUser { .. } => ResponseResult::Error(IpcErrorCode::InvalidRequest),
LocalRequest::GetLogs { limit } => {
let entries = if let Ok(buf) = self.log_buffer.lock() {
bounded_log_entries(buf.recent(limit.min(MAX_LOG_ENTRIES_PER_RESPONSE)))
} else {
Vec::new()
};
ResponseResult::Ok(ResponsePayload::LogEntries(LogEntriesResponse { entries }))
}
LocalRequest::CheckUpdate => match iota_updater::check_update().await {
Ok(available) => {
ResponseResult::Ok(ResponsePayload::UpdateStatus(UpdateStatusResponse {
available,
}))
}
Err(_e) => ResponseResult::Error(IpcErrorCode::InternalFailure),
},
LocalRequest::ListCommunities => {
let iota_id = config_util::CONFIG.load().iota_id;
let Ok(iota_id) = iota_id.map(i64::try_from).unwrap_or(Ok(0)) else {
return ResponseResult::Ok(ResponsePayload::Communities(Vec::new()));
};
let stored =
iota_storage::util::communities_util::CommunitiesUtil::get_communities(iota_id);
let summaries: Vec<CommunitySummary> = stored
.into_iter()
.map(|c| CommunitySummary {
name: c.address,
title: c.title,
})
.collect();
ResponseResult::Ok(ResponsePayload::Communities(summaries))
}
}
}
}
#[cfg(test)]
mod tests {
use super::{IpcRole, LocalRequest, bounded_log_entries};
use iota_ipc::{ExitIntent, LogEntry, SecretString};
#[test]
fn every_request_has_an_explicit_role_policy() {
let requests = [
LocalRequest::GetStatus,
LocalRequest::ListTasks,
LocalRequest::ListUsers,
LocalRequest::CreateUser {
username: "alice".into(),
},
LocalRequest::AttachUserFromTu {
credential: SecretString("credential".into()),
},
LocalRequest::PurgeUserData { user_id: 1 },
LocalRequest::ReleaseUser { user_id: 1 },
LocalRequest::CompleteDeleteUser {
user_id: 1,
credential: None,
},
LocalRequest::RemoveUser { user_id: 1 },
LocalRequest::ReconnectOmikron,
LocalRequest::RotateIotaIdentity,
LocalRequest::RequestProcessExit {
intent: ExitIntent::Stop,
},
LocalRequest::GetDaemonStatus,
LocalRequest::RestartDaemon,
LocalRequest::StopDaemon,
LocalRequest::GetConfig,
LocalRequest::SetConfig {
key: "port".into(),
value: "1984".into(),
},
LocalRequest::ReloadConfig,
LocalRequest::GetOmikronStatus,
LocalRequest::ListComponents,
LocalRequest::GetUser { user_id: 1 },
LocalRequest::ImportUser {
username: "alice".into(),
},
LocalRequest::GetLogs { limit: 10 },
LocalRequest::CheckUpdate,
LocalRequest::ListCommunities,
];
assert_eq!(requests.len(), 25);
for request in requests {
let required = request.required_role();
assert!(IpcRole::Admin.allows(required));
assert_eq!(
IpcRole::Operate.allows(required),
required != IpcRole::Admin
);
assert_eq!(IpcRole::Read.allows(required), required == IpcRole::Read);
}
}
#[test]
fn log_responses_drop_entries_that_cannot_fit_one_ipc_frame() {
let entries = vec![LogEntry {
timestamp_ms: 0,
sender: "test".into(),
message: "x".repeat(2 * 1024 * 1024),
is_error: false,
}];
assert!(bounded_log_entries(entries).is_empty());
}
}

View file

@ -1,305 +0,0 @@
use crate::TaskRegistry;
use iota_ipc::StateSnapshot;
use iota_state::DaemonState;
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
use std::time::{SystemTime, UNIX_EPOCH};
use sysinfo::{RefreshKind, System};
use tokio::sync::watch;
use tokio_util::sync::CancellationToken;
/// Reason the daemon is shutting down.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ShutdownReason {
Stop,
Restart,
Fatal(String),
}
impl ShutdownReason {
pub fn exit_code(&self) -> i32 {
match self {
ShutdownReason::Stop => 0,
ShutdownReason::Restart => 75,
ShutdownReason::Fatal(_) => 1,
}
}
}
/// Tracks the lifecycle phase of the daemon for IPC visibility.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StartupPhase {
Starting,
MigratingStorage,
LoadingUsers,
StartingServices,
Ready,
Degraded,
Stopping,
}
impl From<StartupPhase> for iota_ipc::StartupPhase {
fn from(phase: StartupPhase) -> Self {
match phase {
StartupPhase::Starting => iota_ipc::StartupPhase::Starting,
StartupPhase::MigratingStorage => iota_ipc::StartupPhase::MigratingStorage,
StartupPhase::LoadingUsers => iota_ipc::StartupPhase::LoadingUsers,
StartupPhase::StartingServices => iota_ipc::StartupPhase::StartingServices,
StartupPhase::Ready => iota_ipc::StartupPhase::Ready,
StartupPhase::Degraded => iota_ipc::StartupPhase::Degraded,
StartupPhase::Stopping => iota_ipc::StartupPhase::Stopping,
}
}
}
/* This wrapper exposes daemon state as IPC-safe snapshots while preserving a
* single owned state instance for all daemon subsystems. The cancellation token
* is the single lifecycle signal, and all subsystems check it instead of a
* separate boolean. */
pub struct DaemonRuntime {
pub state: Arc<DaemonState>,
pub cancellation: CancellationToken,
pub shutdown_tx: watch::Sender<Option<ShutdownReason>>,
shutdown_rx: watch::Receiver<Option<ShutdownReason>>,
pub startup_phase: watch::Sender<StartupPhase>,
pub degraded_reason: watch::Sender<Option<String>>,
startup_phase_rx: watch::Receiver<StartupPhase>,
degraded_reason_rx: watch::Receiver<Option<String>>,
pub lifecycle: watch::Sender<iota_ipc::LifecyclePhase>,
pub startup_step: watch::Sender<Option<String>>,
pub components: watch::Sender<BTreeMap<iota_ipc::ComponentId, iota_ipc::ComponentHealth>>,
lifecycle_rx: watch::Receiver<iota_ipc::LifecyclePhase>,
startup_step_rx: watch::Receiver<Option<String>>,
components_rx: watch::Receiver<BTreeMap<iota_ipc::ComponentId, iota_ipc::ComponentHealth>>,
pub tasks: TaskRegistry,
}
impl Clone for DaemonRuntime {
fn clone(&self) -> Self {
Self {
state: self.state.clone(),
cancellation: self.cancellation.clone(),
shutdown_tx: self.shutdown_tx.clone(),
shutdown_rx: self.shutdown_rx.clone(),
startup_phase: self.startup_phase.clone(),
degraded_reason: self.degraded_reason.clone(),
startup_phase_rx: self.startup_phase_rx.clone(),
degraded_reason_rx: self.degraded_reason_rx.clone(),
lifecycle: self.lifecycle.clone(),
startup_step: self.startup_step.clone(),
components: self.components.clone(),
lifecycle_rx: self.lifecycle_rx.clone(),
startup_step_rx: self.startup_step_rx.clone(),
components_rx: self.components_rx.clone(),
tasks: self.tasks.clone(),
}
}
}
impl Default for DaemonRuntime {
fn default() -> Self {
Self::new()
}
}
impl DaemonRuntime {
pub fn new() -> Self {
let (shutdown_tx, shutdown_rx) = watch::channel(None);
let (startup_phase, startup_phase_rx) = watch::channel(StartupPhase::Starting);
let (degraded_reason, degraded_reason_rx) = watch::channel(None);
let (lifecycle, lifecycle_rx) = watch::channel(iota_ipc::LifecyclePhase::Starting);
let (startup_step, startup_step_rx) = watch::channel(Some("starting".to_string()));
let (components, components_rx) = watch::channel(BTreeMap::new());
Self {
state: Arc::new(DaemonState::new()),
cancellation: CancellationToken::new(),
shutdown_tx,
shutdown_rx,
startup_phase,
degraded_reason,
startup_phase_rx,
degraded_reason_rx,
lifecycle,
startup_step,
components,
lifecycle_rx,
startup_step_rx,
components_rx,
tasks: TaskRegistry::default(),
}
}
pub fn shutdown(&self, reason: ShutdownReason) {
self.request_shutdown(reason);
self.begin_shutdown();
}
pub fn request_shutdown(&self, reason: ShutdownReason) {
if self.shutdown_tx.borrow().is_none() {
let _ = self.shutdown_tx.send(Some(reason));
}
}
pub fn begin_shutdown(&self) {
self.cancellation.cancel();
}
pub fn shutdown_reason(&self) -> Option<ShutdownReason> {
self.shutdown_tx.borrow().clone()
}
pub fn is_shutting_down(&self) -> bool {
self.cancellation.is_cancelled()
}
pub fn set_startup_phase(&self, phase: StartupPhase) {
let _ = self.startup_phase.send(phase);
let (lifecycle, step) = match phase {
StartupPhase::Ready => (iota_ipc::LifecyclePhase::Ready, None),
StartupPhase::Stopping => (iota_ipc::LifecyclePhase::Stopping, Some("stopping".into())),
StartupPhase::MigratingStorage => (
iota_ipc::LifecyclePhase::Starting,
Some("migrating_storage".into()),
),
StartupPhase::LoadingUsers => (
iota_ipc::LifecyclePhase::Starting,
Some("loading_users".into()),
),
StartupPhase::StartingServices => (
iota_ipc::LifecyclePhase::Starting,
Some("starting_services".into()),
),
StartupPhase::Starting | StartupPhase::Degraded => {
(iota_ipc::LifecyclePhase::Starting, Some("starting".into()))
}
};
let _ = self.lifecycle.send(lifecycle);
let _ = self.startup_step.send(step);
}
pub fn current_startup_phase(&self) -> StartupPhase {
*self.startup_phase.borrow()
}
pub fn mark_degraded(&self, reason: String) {
let _ = self.degraded_reason.send(Some(reason.clone()));
self.set_component_degraded(iota_ipc::ComponentId::Omikron, reason);
}
pub fn set_component_healthy(&self, component: iota_ipc::ComponentId, message: Option<String>) {
self.update_component(component, iota_ipc::HealthStatus::Healthy, message);
}
pub fn set_component_degraded(&self, component: iota_ipc::ComponentId, message: String) {
self.update_component(component, iota_ipc::HealthStatus::Degraded, Some(message));
}
pub fn set_component_failed(&self, component: iota_ipc::ComponentId, message: String) {
self.update_component(component, iota_ipc::HealthStatus::Failed, Some(message));
}
fn update_component(
&self,
component: iota_ipc::ComponentId,
status: iota_ipc::HealthStatus,
message: Option<String>,
) {
let mut components = self.components.borrow().clone();
components.insert(
component,
iota_ipc::ComponentHealth {
status,
message,
changed_at_ms: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis(),
},
);
let _ = self.components.send(components);
}
pub fn overall_health(&self) -> iota_ipc::HealthStatus {
let components = self.components.borrow();
if [iota_ipc::ComponentId::Ipc, iota_ipc::ComponentId::Storage]
.iter()
.any(|id| {
components
.get(id)
.is_some_and(|v| v.status == iota_ipc::HealthStatus::Failed)
})
{
return iota_ipc::HealthStatus::Failed;
}
if components.values().any(|v| {
v.status == iota_ipc::HealthStatus::Degraded
|| v.status == iota_ipc::HealthStatus::Failed
}) {
iota_ipc::HealthStatus::Degraded
} else {
iota_ipc::HealthStatus::Healthy
}
}
pub fn snapshot(&self) -> StateSnapshot {
let state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
StateSnapshot {
cpu: state.cpu.clone(),
ram: state.ram.clone(),
ping: state.ping.clone(),
net_up: state.net_up.clone(),
net_down: state.net_down.clone(),
sys_info: state.sys_info.clone(),
startup_phase: self.current_startup_phase().into(),
degraded_reason: self.degraded_reason.borrow().clone(),
lifecycle: *self.lifecycle.borrow(),
startup_step: self.startup_step.borrow().clone(),
overall_health: self.overall_health(),
components: self.components.borrow().clone(),
}
}
pub async fn spawn_system_monitor(&self) {
let runtime = self.clone();
self.tasks
.spawn_tracked("system-monitor", async move {
runtime.state.active_tasks.insert("System monitor".into());
let mut system = System::new_with_specifics(RefreshKind::everything());
let mut counter = 0.0;
loop {
if runtime.is_shutting_down() {
break;
}
system.refresh_cpu_all();
system.refresh_memory();
let cpu = system.global_cpu_usage() as f64;
let total_memory = system.total_memory();
let ram = if total_memory == 0 {
0.0
} else {
system.used_memory() as f64 / total_memory as f64 * 100.0
};
{
let mut state = runtime
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
state.push_cpu((counter, cpu));
state.push_ram((counter, ram));
state.sys_info = format!("CPU: {cpu:.1}% RAM: {ram:.1}%");
}
counter += 1.0;
tokio::time::sleep(Duration::from_millis(500)).await;
}
runtime.state.active_tasks.remove("System monitor");
Ok(())
})
.await;
}
}

View file

@ -1,37 +0,0 @@
use iota_ipc::{DeploymentMode, SupervisorKind};
#[derive(Clone, Copy, Debug)]
pub struct DeploymentContext {
pub mode: DeploymentMode,
pub supervisor: SupervisorKind,
}
impl Default for DeploymentContext {
fn default() -> Self {
Self {
mode: DeploymentMode::External,
supervisor: SupervisorKind::None,
}
}
}
pub fn from_environment() -> DeploymentContext {
let mut mode = match std::env::var("IOTA_DEPLOYMENT_MODE").ok().as_deref() {
Some("session_child") => DeploymentMode::SessionChild,
Some("ui_auto_start") => DeploymentMode::UiAutoStart,
Some("user_service") => DeploymentMode::UserService,
Some("system_socket_activated") => DeploymentMode::SystemSocketActivated,
Some("system_always_on") => DeploymentMode::SystemAlwaysOn,
_ => DeploymentMode::External,
};
if std::env::var("LISTEN_FDS").ok().as_deref() == Some("1") {
mode = DeploymentMode::SystemSocketActivated;
}
let supervisor = match std::env::var("IOTA_SUPERVISOR").ok().as_deref() {
Some("iota_ui") => SupervisorKind::IotaUi,
Some("systemd") => SupervisorKind::Systemd,
Some("external") => SupervisorKind::External,
_ => SupervisorKind::None,
};
DeploymentContext { mode, supervisor }
}

View file

@ -1,745 +0,0 @@
use crate::deployment::from_environment;
use crate::log_buffer::LogBuffer;
use crate::{CommandRouter, DaemonRuntime, DaemonServices, IpcRole, PeerContext};
use iota_ipc::{
ClientMessage, DaemonMessage, HelloAck, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION, read_msg,
write_msg,
};
use iota_logger::log;
use iota_storage::util::config_util;
use std::io::Result;
use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::{env, fs::File, os::fd::FromRawFd, os::unix::net::UnixListener as StdUnixListener};
use tokio::io::AsyncWriteExt;
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::{Semaphore, broadcast, mpsc, watch};
use tokio::time::timeout;
use uuid::Uuid;
/// Per-client outbound queue capacity.
const CLIENT_CHANNEL_SIZE: usize = 256;
const MAX_CONFIGURED_IPC_CLIENTS: usize = 4096;
/// Maximum handshake retries before giving up.
const MAX_HANDSHAKE_RETRIES: u32 = 1;
const CLIENT_IO_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
/// Minimum metric subscription interval to prevent excessive update rates.
const MIN_METRIC_INTERVAL_MS: u64 = 100;
/// Maximum metric subscription interval.
const MAX_METRIC_INTERVAL_MS: u64 = 60_000;
/// Default metric interval if the client does not specify one.
const DEFAULT_METRIC_INTERVAL_MS: u64 = 500;
/// Per-client subscription state.
struct ClientSubscription {
log_classes: Vec<String>,
metric_interval_ms: u64,
}
enum WriterCommand {
Message(DaemonMessage),
Flush {
complete: tokio::sync::oneshot::Sender<()>,
},
}
fn configured_client_limit() -> usize {
config_util::CONFIG
.load()
.max_ipc_clients
.clamp(1, MAX_CONFIGURED_IPC_CLIENTS)
}
pub struct IpcServer {
listener: UnixListener,
runtime: Arc<DaemonRuntime>,
services: Arc<DaemonServices>,
log_tx: broadcast::Sender<DaemonMessage>,
log_buffer: Arc<Mutex<LogBuffer>>,
state_rx: watch::Receiver<iota_ipc::StateSnapshot>,
instance_id: String,
_instance_lock: File,
client_limit: Arc<Semaphore>,
}
impl IpcServer {
pub async fn bind(
path: impl Into<PathBuf>,
runtime: Arc<DaemonRuntime>,
services: Arc<DaemonServices>,
log_tx: broadcast::Sender<DaemonMessage>,
log_buffer: Arc<Mutex<LogBuffer>>,
state_rx: watch::Receiver<iota_ipc::StateSnapshot>,
) -> Result<Self> {
let path = path.into();
let listener = match activated_listener()? {
Some(listener) => listener,
None => {
let parent = path.parent().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"IPC socket has no parent directory",
)
})?;
if !parent.is_dir() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("IPC runtime directory does not exist: {}", parent.display()),
));
}
let lock_path = path
.parent()
.unwrap_or_else(|| Path::new("/tmp"))
.join("daemon.lock");
let lock = File::options()
.create(true)
.mode(0o600)
.read(true)
.write(true)
.open(lock_path)?;
let locked = unsafe {
libc::flock(
std::os::fd::AsRawFd::as_raw_fd(&lock),
libc::LOCK_EX | libc::LOCK_NB,
)
} == 0;
if !locked {
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"another daemon instance is already running",
));
}
remove_stale_socket(&path).await?;
let listener = UnixListener::bind(&path)?;
if let Err(error) =
tokio::fs::set_permissions(&path, PermissionsExt::from_mode(0o600)).await
{
drop(listener);
let _ = tokio::fs::remove_file(&path).await;
return Err(error);
}
if let Err(error) = validate_manual_socket(&path).await {
drop(listener);
let _ = tokio::fs::remove_file(&path).await;
return Err(error);
}
return Ok(Self {
listener,
runtime,
services,
log_tx,
log_buffer,
state_rx,
instance_id: Uuid::new_v4().to_string(),
_instance_lock: lock,
client_limit: Arc::new(Semaphore::new(configured_client_limit())),
});
}
};
Ok(Self {
listener,
runtime,
services,
log_tx,
log_buffer,
state_rx,
instance_id: Uuid::new_v4().to_string(),
_instance_lock: File::options().read(true).open("/dev/null")?,
client_limit: Arc::new(Semaphore::new(configured_client_limit())),
})
}
pub async fn serve(self) -> Result<()> {
loop {
let (stream, _addr) = self.listener.accept().await?;
let permit = match self.client_limit.clone().try_acquire_owned() {
Ok(permit) => permit,
Err(_) => {
eprintln!("IPC connection rejected: active client limit reached");
drop(stream);
continue;
}
};
eprintln!("IPC client accepted");
let runtime = self.runtime.clone();
let services = self.services.clone();
let log_tx = self.log_tx.clone();
let log_buffer = self.log_buffer.clone();
let state_rx = self.state_rx.clone();
let instance_id = self.instance_id.clone();
tokio::spawn(async move {
let _permit = permit;
if let Err(error) = handle_client(
stream,
runtime,
services,
log_tx,
log_buffer,
state_rx,
instance_id,
)
.await
{
eprintln!("IPC client error: {error}");
}
});
}
}
}
/* systemd hands the first socket-activated file descriptor to the service as
* descriptor 3. Manual launches continue to bind the configured socket path. */
fn activated_listener() -> Result<Option<UnixListener>> {
let listen_fds = env::var("LISTEN_FDS")
.ok()
.and_then(|value| value.parse::<u32>().ok());
let listen_pid = env::var("LISTEN_PID")
.ok()
.and_then(|value| value.parse::<u32>().ok());
if listen_fds != Some(1) || listen_pid != Some(std::process::id()) {
return Ok(None);
}
// SAFETY: systemd transfers ownership of the activated descriptor to us.
let listener = unsafe { StdUnixListener::from_raw_fd(3) };
into_tokio_listener(listener).map(Some)
}
fn into_tokio_listener(listener: StdUnixListener) -> Result<UnixListener> {
listener.set_nonblocking(true)?;
UnixListener::from_std(listener)
}
async fn write_client_message<W>(writer: &mut W, message: &DaemonMessage) -> Result<()>
where
W: tokio::io::AsyncWrite + Unpin,
{
timeout(CLIENT_IO_TIMEOUT, write_msg(writer, message))
.await
.map_err(|_| {
std::io::Error::new(std::io::ErrorKind::TimedOut, "IPC client write timed out")
})?
}
async fn remove_stale_socket(path: &Path) -> Result<()> {
match tokio::fs::symlink_metadata(path).await {
Ok(metadata) => {
if metadata.file_type().is_symlink() || !metadata.file_type().is_socket() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"IPC path exists but is not an owned Unix socket",
));
}
if metadata.uid() != unsafe { libc::geteuid() } as u32 {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"existing IPC socket is not owned by the current user",
));
}
match timeout(
std::time::Duration::from_millis(250),
UnixStream::connect(path),
)
.await
{
Ok(Ok(_)) => Err(std::io::Error::new(
std::io::ErrorKind::AddrInUse,
"an IPC daemon is already listening",
)),
Ok(Err(error))
if matches!(
error.kind(),
std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound
) =>
{
tokio::fs::remove_file(path).await
}
Ok(Err(error)) => Err(error),
Err(_) => Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"could not determine whether the existing IPC socket is active",
)),
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
}
async fn validate_manual_socket(path: &Path) -> Result<()> {
let metadata = tokio::fs::symlink_metadata(path).await?;
if metadata.file_type().is_symlink() || !metadata.file_type().is_socket() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"bound IPC path is no longer a Unix socket",
));
}
let mode = metadata.permissions().mode() & 0o777;
if mode != 0o600 {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!("IPC socket has unexpected mode {mode:o}"),
));
}
let expected_uid = unsafe { libc::geteuid() } as u32;
if metadata.uid() != expected_uid {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"IPC socket ownership changed after bind",
));
}
Ok(())
}
#[derive(Clone, Debug)]
struct PeerIdentity {
pid: i32,
uid: u32,
_gid: u32,
}
fn peer_credentials(stream: &UnixStream) -> Result<PeerIdentity> {
#[cfg(target_os = "linux")]
{
use std::os::unix::io::AsRawFd;
unsafe {
let mut cred: libc::ucred = std::mem::zeroed();
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
let fd = stream.as_raw_fd();
if libc::getsockopt(
fd,
libc::SOL_SOCKET,
libc::SO_PEERCRED,
&mut cred as *mut _ as *mut libc::c_void,
&mut len,
) != 0
{
return Err(std::io::Error::last_os_error());
}
Ok(PeerIdentity {
pid: cred.pid,
uid: cred.uid,
_gid: cred.gid,
})
}
}
#[cfg(not(target_os = "linux"))]
{
Ok(PeerIdentity {
pid: 0,
uid: 0,
_gid: 0,
})
}
}
fn role_for_peer(_peer: &PeerIdentity) -> IpcRole {
// This deployment has one IPC listener. Its Unix socket permissions are
// the admission boundary: systemd grants access to root, the daemon, and
// members of iota-operators. Once a peer has passed that boundary, it is
// an administrator for the operator console protocol.
IpcRole::Admin
}
async fn handle_client(
stream: UnixStream,
runtime: Arc<DaemonRuntime>,
services: Arc<DaemonServices>,
log_tx: broadcast::Sender<DaemonMessage>,
log_buffer: Arc<Mutex<LogBuffer>>,
mut state_rx: watch::Receiver<iota_ipc::StateSnapshot>,
instance_id: String,
) -> Result<()> {
let peer_identity = peer_credentials(&stream)?;
let peer = PeerContext {
pid: peer_identity.pid,
uid: peer_identity.uid,
role: role_for_peer(&peer_identity),
};
let (mut reader, mut writer) = stream.into_split();
// A failed writer must stop the reader and any subsequent command work
// for this client; otherwise the reader can remain parked forever.
let session_cancellation = runtime.cancellation.child_token();
let (directed_tx, directed_rx) = mpsc::channel::<WriterCommand>(CLIENT_CHANNEL_SIZE);
eprintln!("IPC handshake started (pid={}, uid={})", peer.pid, peer.uid);
// --- Handshake ---
let mut negotiated_version: Option<u16> = None;
for _ in 0..MAX_HANDSHAKE_RETRIES {
match timeout(
std::time::Duration::from_secs(15),
read_msg::<_, ClientMessage>(&mut reader),
)
.await
{
Err(_) => {
return Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"IPC Hello timed out",
));
}
Ok(result) => match result {
Ok(ClientMessage::Hello { supported_versions }) => {
let version = supported_versions
.iter()
.copied()
.filter(|v| *v >= MIN_PROTOCOL_VERSION && *v <= PROTOCOL_VERSION)
.max()
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::Unsupported,
"No compatible IPC protocol version",
)
})?;
negotiated_version = Some(version);
let ack = DaemonMessage::HelloAck(HelloAck {
protocol_version: version,
daemon_version: env!("CARGO_PKG_VERSION").to_string(),
instance_id: instance_id.clone(),
startup_phase: runtime.current_startup_phase().into(),
capabilities: vec!["commands".into(), "metrics".into(), "logs".into()],
lifecycle: *runtime.lifecycle.borrow(),
health: runtime.overall_health(),
deployment_mode: from_environment().mode,
supervisor: from_environment().supervisor,
});
write_client_message(&mut writer, &ack).await?;
eprintln!(
"IPC handshake acknowledged (pid={}, uid={})",
peer.pid, peer.uid
);
break;
}
Ok(_) => {
// Unexpected first message, send an error and close.
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Expected Hello as first message",
));
}
Err(e) => return Err(e),
},
}
}
let negotiated_version = negotiated_version.ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::Other, "Handshake failed after retries")
})?;
log!("IPC client connected (pid={}, uid={})", peer.pid, peer.uid);
// --- Send initial state snapshot ---
let initial = DaemonMessage::StateUpdate(runtime.snapshot());
let _ = directed_tx.send(WriterCommand::Message(initial)).await;
// --- Writer task: merge directed responses + shared log events ---
let mut log_rx = log_tx.subscribe();
let (sub_tx, mut sub_rx) = tokio::sync::watch::channel(ClientSubscription {
log_classes: Vec::new(),
metric_interval_ms: DEFAULT_METRIC_INTERVAL_MS,
});
let writer_task = {
let runtime = runtime.clone();
let session_cancellation = session_cancellation.clone();
tokio::spawn(async move {
let mut directed_rx = directed_rx;
let mut last_metric_sent = tokio::time::Instant::now();
let mut state_updates_open = true;
loop {
let metric_interval = sub_rx.borrow().metric_interval_ms;
tokio::select! {
_ = session_cancellation.cancelled() => break,
// Directed messages (responses to this client's requests)
command = directed_rx.recv() => {
match command {
Some(WriterCommand::Message(message)) => {
if let Err(error) = write_client_message(&mut writer, &message).await {
eprintln!("IPC client writer stopped while sending directed message: {error}");
session_cancellation.cancel();
break;
}
}
Some(WriterCommand::Flush { complete }) => {
if let Err(error) = writer.flush().await {
eprintln!("IPC client writer stopped while flushing: {error}");
session_cancellation.cancel();
break;
}
let _ = complete.send(());
}
None => break,
}
}
// Shared log events
result = log_rx.recv() => {
match result {
Ok(DaemonMessage::LogEntry(entry)) => {
// Filter by subscribed log classes
let log_classes = sub_rx.borrow().log_classes.clone();
if log_classes.is_empty()
|| log_classes.iter().any(|c| entry.sender == *c)
{
if let Err(error) = write_client_message(&mut writer, &DaemonMessage::LogEntry(entry)).await {
eprintln!("IPC client writer stopped while sending log message: {error}");
session_cancellation.cancel();
break;
}
}
}
Ok(DaemonMessage::MetricSample(sample)) => {
// Rate-limit metric samples based on subscription interval
let now = tokio::time::Instant::now();
if now.duration_since(last_metric_sent) >= std::time::Duration::from_millis(metric_interval) {
last_metric_sent = now;
if let Err(error) = write_client_message(&mut writer, &DaemonMessage::MetricSample(sample)).await {
eprintln!("IPC client writer stopped while sending metric sample: {error}");
session_cancellation.cancel();
break;
}
}
}
Ok(message) => {
// Forward other broadcast messages as-is
if let Err(error) = write_client_message(&mut writer, &message).await {
eprintln!("IPC client writer stopped while sending broadcast message: {error}");
session_cancellation.cancel();
break;
}
}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
if write_client_message(&mut writer, &DaemonMessage::Gap { skipped }).await.is_err()
|| write_client_message(&mut writer, &DaemonMessage::StateUpdate(runtime.snapshot())).await.is_err()
{
session_cancellation.cancel();
break;
}
}
Err(broadcast::error::RecvError::Closed) => {
session_cancellation.cancel();
break;
}
}
}
changed = state_rx.changed(), if state_updates_open => {
if changed.is_err() {
state_updates_open = false;
continue;
}
let snapshot = state_rx.borrow().clone();
if let Err(error) = write_client_message(&mut writer, &DaemonMessage::StateUpdate(snapshot)).await {
eprintln!("IPC client writer stopped while sending state update: {error}");
session_cancellation.cancel();
break;
}
}
_ = sub_rx.changed() => {}
}
}
})
};
// --- Reader loop ---
let router = CommandRouter::new(runtime.clone(), services, log_buffer);
loop {
let message = tokio::select! {
_ = session_cancellation.cancelled() => break,
result = read_msg::<_, ClientMessage>(&mut reader) => result,
};
match message {
Ok(ClientMessage::Request(envelope)) => {
let shutdown_reason = match &envelope.request {
iota_ipc::LocalRequest::RequestProcessExit {
intent: iota_ipc::ExitIntent::Restart,
}
| iota_ipc::LocalRequest::RestartDaemon => Some("restart requested"),
iota_ipc::LocalRequest::RequestProcessExit {
intent: iota_ipc::ExitIntent::Stop,
} => Some("shutdown requested"),
iota_ipc::LocalRequest::StopDaemon => Some("shutdown requested"),
_ => None,
};
let response = if envelope.protocol_version != negotiated_version {
log!(
"IPC protocol mismatch: pid={}, uid={}, negotiated={}, request={}",
peer.pid,
peer.uid,
negotiated_version,
envelope.protocol_version
);
iota_ipc::ResponseEnvelope {
request_id: envelope.request_id,
result: iota_ipc::ResponseResult::Error(
iota_ipc::IpcErrorCode::UnsupportedVersion,
),
}
} else {
router
.route(&peer, envelope.request_id, envelope.request)
.await
};
let should_shutdown = shutdown_reason.is_some()
&& matches!(&response.result, iota_ipc::ResponseResult::Ok(_));
let _ = directed_tx
.send(WriterCommand::Message(DaemonMessage::Response(response)))
.await;
if let Some(reason) = shutdown_reason.filter(|_| should_shutdown) {
let _ = directed_tx
.send(WriterCommand::Message(DaemonMessage::LifecycleEvent(
iota_ipc::LifecycleEvent::Shutdown {
reason: reason.into(),
},
)))
.await;
let (flush_tx, flush_rx) = tokio::sync::oneshot::channel();
let _ = directed_tx
.send(WriterCommand::Flush { complete: flush_tx })
.await;
timeout(CLIENT_IO_TIMEOUT, flush_rx)
.await
.map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::TimedOut,
"IPC shutdown response flush timed out",
)
})?
.map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"IPC writer stopped before shutdown flush",
)
})?;
runtime.begin_shutdown();
break;
}
}
Ok(ClientMessage::Subscribe {
log_classes,
metric_interval_ms,
}) => {
let interval = metric_interval_ms
.unwrap_or(DEFAULT_METRIC_INTERVAL_MS)
.clamp(MIN_METRIC_INTERVAL_MS, MAX_METRIC_INTERVAL_MS);
let _ = sub_tx.send(ClientSubscription {
log_classes,
metric_interval_ms: interval,
});
let snapshot = DaemonMessage::StateUpdate(runtime.snapshot());
let _ = directed_tx.send(WriterCommand::Message(snapshot)).await;
let _ = directed_tx
.send(WriterCommand::Message(DaemonMessage::Subscribed))
.await;
}
Ok(ClientMessage::Ping { seq }) => {
let _ = directed_tx
.send(WriterCommand::Message(DaemonMessage::Pong { seq }))
.await;
}
Ok(ClientMessage::Hello { .. }) => {
// Re-handshake on existing connection: treat as resubscribe
let snapshot = DaemonMessage::StateUpdate(runtime.snapshot());
let _ = directed_tx.send(WriterCommand::Message(snapshot)).await;
}
Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => break,
Err(error) => {
session_cancellation.cancel();
drop(directed_tx);
let mut writer_task = writer_task;
match timeout(CLIENT_IO_TIMEOUT, &mut writer_task).await {
Ok(_) => {}
Err(_) => {
writer_task.abort();
let _ = writer_task.await;
}
}
return Err(error);
}
}
}
drop(directed_tx);
session_cancellation.cancel();
let mut writer_task = writer_task;
match timeout(CLIENT_IO_TIMEOUT, &mut writer_task).await {
Ok(Ok(())) => {}
Ok(Err(error)) => {
eprintln!("IPC client writer task failed: {error}");
}
Err(_) => {
eprintln!("IPC client writer did not stop before timeout");
writer_task.abort();
let _ = writer_task.await;
}
}
log!(
"IPC client disconnected (pid={}, uid={})",
peer.pid,
peer.uid
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[tokio::test(flavor = "current_thread")]
async fn converted_listener_does_not_block_the_runtime() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("ipc.sock");
let listener = match StdUnixListener::bind(path) {
Ok(listener) => into_tokio_listener(listener).unwrap(),
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => return,
Err(error) => panic!("could not create test socket: {error}"),
};
assert!(
tokio::time::timeout(Duration::from_millis(50), listener.accept())
.await
.is_err()
);
}
#[tokio::test]
async fn manual_socket_validation_requires_owner_only_mode() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("ipc.sock");
let listener = StdUnixListener::bind(&path).expect("test socket binds");
tokio::fs::set_permissions(&path, PermissionsExt::from_mode(0o600))
.await
.expect("test socket permissions apply");
validate_manual_socket(&path)
.await
.expect("manual socket validation succeeds");
drop(listener);
}
#[tokio::test]
async fn manual_socket_validation_rejects_unexpected_mode() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("ipc.sock");
let listener = StdUnixListener::bind(&path).expect("test socket binds");
tokio::fs::set_permissions(&path, PermissionsExt::from_mode(0o660))
.await
.expect("test socket permissions apply");
let error = validate_manual_socket(&path)
.await
.expect_err("group-accessible manual socket must be rejected");
assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
drop(listener);
}
#[test]
fn an_admitted_operator_peer_receives_administrator_role() {
let peer = PeerIdentity {
pid: 123,
uid: 1000,
_gid: 1000,
};
assert_eq!(role_for_peer(&peer), IpcRole::Admin);
}
}

View file

@ -1,14 +0,0 @@
pub mod command_router;
pub mod daemon_state;
pub mod deployment;
pub mod ipc_server;
pub mod log_broadcaster;
pub mod log_buffer;
pub mod services;
pub mod task_registry;
pub use command_router::{CommandRouter, IpcRole, PeerContext};
pub use daemon_state::{DaemonRuntime, ShutdownReason, StartupPhase};
pub use ipc_server::IpcServer;
pub use services::DaemonServices;
pub use task_registry::TaskRegistry;

View file

@ -1,27 +0,0 @@
use crate::log_buffer::LogBuffer;
use iota_ipc::{DaemonMessage, LogEntry};
use iota_logger::subscribe;
use std::sync::{Arc, Mutex};
use tokio::sync::broadcast;
/* The daemon adapts logger output to the wire protocol so the logger stays
* independent from both the socket implementation and TUI state. */
pub fn spawn(message_tx: broadcast::Sender<DaemonMessage>, buffer: Arc<Mutex<LogBuffer>>) {
let Some(mut logs) = subscribe() else {
return;
};
tokio::spawn(async move {
while let Ok(entry) = logs.recv().await {
let entry = LogEntry {
timestamp_ms: entry.timestamp_ms,
sender: entry.sender,
message: entry.message,
is_error: entry.is_error,
};
if let Ok(mut buf) = buffer.lock() {
buf.push(entry.clone());
}
let _ = message_tx.send(DaemonMessage::LogEntry(entry));
}
});
}

View file

@ -1,36 +0,0 @@
use iota_ipc::LogEntry;
use std::collections::VecDeque;
pub struct LogBuffer {
entries: VecDeque<LogEntry>,
capacity: usize,
}
impl LogBuffer {
pub fn new(capacity: usize) -> Self {
Self {
entries: VecDeque::with_capacity(capacity),
capacity,
}
}
pub fn push(&mut self, entry: LogEntry) {
if self.entries.len() == self.capacity {
self.entries.pop_front();
}
self.entries.push_back(entry);
}
pub fn recent(&self, limit: usize) -> Vec<LogEntry> {
let _len = self.entries.len();
self.entries
.iter()
.rev()
.take(limit)
.cloned()
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect()
}
}

View file

@ -1,72 +0,0 @@
use async_trait::async_trait;
use mtp::codec::CommunicationValue;
use omikron_connector::{OmikronClient, OmikronConnection, OmikronError};
use std::sync::Arc;
use std::time::Duration;
#[derive(Default)]
pub struct UserService;
#[derive(Default)]
pub struct ConfigService;
pub struct DaemonServices {
pub omikron: Arc<dyn OmikronClient>,
pub users: Arc<UserService>,
pub config: Arc<ConfigService>,
pub active: bool,
}
impl DaemonServices {
pub fn new(omikron: Arc<OmikronConnection>) -> Arc<Self> {
Arc::new(Self {
omikron,
users: Arc::new(UserService),
config: Arc::new(ConfigService),
active: true,
})
}
/// Services used while the daemon is awaiting terms acceptance. They can
/// never initiate a connection; the command router exposes status only.
pub fn inactive() -> Arc<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 rotate_identity(&self) -> Result<(), OmikronError> {
Err(OmikronError::Disconnected(
"terms have not been accepted".into(),
))
}
async fn is_connected(&self) -> bool {
false
}
}

View file

@ -1,40 +0,0 @@
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Mutex;
use tokio::task::JoinSet;
#[derive(Clone, Default)]
pub struct TaskRegistry {
tasks: Arc<Mutex<JoinSet<(String, Result<(), String>)>>>,
}
impl TaskRegistry {
pub async fn spawn_tracked<F>(&self, name: impl Into<String>, future: F)
where
F: std::future::Future<Output = Result<(), String>> + Send + 'static,
{
let name = name.into();
self.tasks
.lock()
.await
.spawn(async move { (name, future.await) });
}
pub async fn join_with_timeout(&self, timeout: Duration) -> Vec<String> {
let mut tasks = self.tasks.lock().await;
let mut failures = Vec::new();
let deadline = tokio::time::Instant::now() + timeout;
while !tasks.is_empty() {
match tokio::time::timeout_at(deadline, tasks.join_next()).await {
Ok(Some(Ok((name, Err(error))))) => failures.push(format!("{name}: {error}")),
Ok(Some(Ok((_, Ok(()))))) | Ok(Some(Err(_))) => {}
Ok(None) => break,
Err(_) => {
tasks.abort_all();
break;
}
}
}
failures
}
}

View file

@ -1,141 +0,0 @@
use async_trait::async_trait;
use iota_daemon_lib::log_buffer::LogBuffer;
use iota_daemon_lib::{CommandRouter, DaemonRuntime, DaemonServices, IpcRole, PeerContext};
use iota_ipc::{IpcErrorCode, LocalRequest, ResponseResult};
use mtp::codec::CommunicationValue;
use omikron_connector::{OmikronClient, OmikronError};
use std::sync::{
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
};
use std::time::Duration;
struct FakeOmikron {
reconnects: AtomicUsize,
}
fn admin_peer() -> PeerContext {
PeerContext {
pid: 1,
uid: 0,
role: IpcRole::Admin,
}
}
fn read_peer() -> PeerContext {
PeerContext {
pid: 2,
uid: 1000,
role: IpcRole::Read,
}
}
#[async_trait]
impl OmikronClient for FakeOmikron {
async fn send_message(&self, _: &CommunicationValue) -> Result<(), OmikronError> {
Ok(())
}
async fn await_response(
&self,
_: &CommunicationValue,
_: Duration,
) -> Result<CommunicationValue, OmikronError> {
Err(OmikronError::Disconnected("fake".into()))
}
async fn reconnect(&self) -> Result<(), OmikronError> {
self.reconnects.fetch_add(1, Ordering::SeqCst);
Ok(())
}
async fn rotate_identity(&self) -> Result<(), OmikronError> {
self.reconnects.fetch_add(1, Ordering::SeqCst);
Ok(())
}
async fn is_connected(&self) -> bool {
false
}
}
#[tokio::test]
async fn reconnect_uses_the_injected_client() {
let fake = Arc::new(FakeOmikron {
reconnects: AtomicUsize::new(0),
});
let services = Arc::new(DaemonServices {
omikron: fake.clone(),
users: Default::default(),
config: Default::default(),
active: true,
});
let router = CommandRouter::new(
Arc::new(DaemonRuntime::new()),
services,
Arc::new(Mutex::new(LogBuffer::new(100))),
);
assert!(matches!(
router
.route(&admin_peer(), 1, LocalRequest::ReconnectOmikron)
.await
.result,
ResponseResult::Ok(_)
));
assert_eq!(fake.reconnects.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn identity_rotation_is_available_while_omikron_is_offline() {
let fake = Arc::new(FakeOmikron {
reconnects: AtomicUsize::new(0),
});
let services = Arc::new(DaemonServices {
omikron: fake.clone(),
users: Default::default(),
config: Default::default(),
active: true,
});
let router = CommandRouter::new(
Arc::new(DaemonRuntime::new()),
services,
Arc::new(Mutex::new(LogBuffer::new(100))),
);
assert!(matches!(
router
.route(&admin_peer(), 1, LocalRequest::RotateIotaIdentity)
.await
.result,
ResponseResult::Ok(_)
));
assert_eq!(fake.reconnects.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn read_role_cannot_execute_an_administrative_request() {
let fake = Arc::new(FakeOmikron {
reconnects: AtomicUsize::new(0),
});
let services = Arc::new(DaemonServices {
omikron: fake.clone(),
users: Default::default(),
config: Default::default(),
active: true,
});
let router = CommandRouter::new(
Arc::new(DaemonRuntime::new()),
services,
Arc::new(Mutex::new(LogBuffer::new(100))),
);
assert!(matches!(
router
.route(
&read_peer(),
9,
LocalRequest::SetConfig {
key: "port".into(),
value: "1984".into(),
},
)
.await
.result,
ResponseResult::Error(IpcErrorCode::Unauthorized)
));
assert_eq!(fake.reconnects.load(Ordering::SeqCst), 0);
}

View file

@ -1,29 +0,0 @@
use iota_daemon_lib::{DaemonRuntime, StartupPhase};
use iota_ipc::{ComponentId, HealthStatus, LifecyclePhase};
#[test]
fn component_failures_are_independent_and_recovery_is_scoped() {
let runtime = DaemonRuntime::new();
runtime.set_component_degraded(ComponentId::Omikron, "offline".into());
runtime.set_component_failed(ComponentId::Web, "bind failed".into());
runtime.set_startup_phase(StartupPhase::Ready);
let snapshot = runtime.snapshot();
assert_eq!(snapshot.lifecycle, LifecyclePhase::Ready);
assert_eq!(snapshot.overall_health, HealthStatus::Degraded);
assert_eq!(
snapshot.components[&ComponentId::Omikron].status,
HealthStatus::Degraded
);
runtime.set_component_healthy(ComponentId::Web, None);
assert_eq!(
runtime.snapshot().components[&ComponentId::Omikron].status,
HealthStatus::Degraded
);
}
#[test]
fn critical_failure_is_failed_but_optional_degradation_is_not() {
let runtime = DaemonRuntime::new();
runtime.set_component_failed(ComponentId::Storage, "database unavailable".into());
assert_eq!(runtime.snapshot().overall_health, HealthStatus::Failed);
}

View file

@ -1,252 +0,0 @@
use async_trait::async_trait;
use iota_daemon_lib::{DaemonRuntime, DaemonServices, IpcServer};
use iota_ipc::{
ClientMessage, DaemonMessage, ExitIntent, IpcErrorCode, LocalRequest, PROTOCOL_VERSION,
RequestEnvelope, ResponseResult, read_msg, write_msg,
};
use iota_storage::util::config_util::{self, IotaConfig};
use mtp::codec::CommunicationValue;
use omikron_connector::{OmikronClient, OmikronError};
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use tokio::net::UnixStream;
use tokio::sync::{broadcast, watch};
struct ConfigRestore(Arc<IotaConfig>);
impl Drop for ConfigRestore {
fn drop(&mut self) {
config_util::CONFIG.store(self.0.clone());
}
}
fn set_client_limit(limit: usize) -> ConfigRestore {
let previous = config_util::CONFIG.load_full();
let mut config = (*previous).clone();
config.max_ipc_clients = limit;
config_util::CONFIG.store(Arc::new(config));
ConfigRestore(previous)
}
async fn start_server(
path: &Path,
services: Arc<DaemonServices>,
) -> (Arc<DaemonRuntime>, tokio::task::JoinHandle<()>) {
let runtime = Arc::new(DaemonRuntime::new());
let (log_tx, _) = broadcast::channel(32);
let log_buffer = Arc::new(std::sync::Mutex::new(
iota_daemon_lib::log_buffer::LogBuffer::new(32),
));
let (_, state_rx) = watch::channel(runtime.snapshot());
let server = IpcServer::bind(
path.to_owned(),
runtime.clone(),
services,
log_tx,
log_buffer,
state_rx,
)
.await
.expect("IPC server binds");
let task = tokio::spawn(async move {
let _ = server.serve().await;
});
(runtime, task)
}
async fn try_connect_and_await_hello(path: &Path) -> std::io::Result<UnixStream> {
let mut stream = UnixStream::connect(path).await?;
write_msg(
&mut stream,
&ClientMessage::Hello {
supported_versions: vec![PROTOCOL_VERSION],
},
)
.await?;
let message: DaemonMessage = read_msg(&mut stream).await?;
if !matches!(message, DaemonMessage::HelloAck(_)) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"expected HelloAck",
));
}
Ok(stream)
}
async fn connect_and_await_hello(path: &Path) -> UnixStream {
try_connect_and_await_hello(path)
.await
.expect("IPC connection completes the Hello exchange")
}
#[tokio::test]
async fn active_client_limit_rejects_excess_clients_and_releases_permits() {
let _config = set_client_limit(1);
let directory = tempfile::tempdir().unwrap();
let socket = directory.path().join("ipc.sock");
let (_runtime, server_task) = start_server(&socket, DaemonServices::inactive()).await;
let first = connect_and_await_hello(&socket).await;
let mut rejected = UnixStream::connect(&socket)
.await
.expect("second connection reaches the Unix listener");
let rejected_result = tokio::time::timeout(
Duration::from_secs(2),
read_msg::<_, DaemonMessage>(&mut rejected),
)
.await
.expect("rejected client is closed promptly");
assert!(rejected_result.is_err());
drop(first);
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
let _released = loop {
match try_connect_and_await_hello(&socket).await {
Ok(stream) => break stream,
Err(_error) if tokio::time::Instant::now() < deadline => {
tokio::time::sleep(Duration::from_millis(10)).await;
}
Err(error) => panic!("client permit was not released: {error}"),
}
};
server_task.abort();
let _ = server_task.await;
}
#[tokio::test]
async fn request_with_version_different_from_hello_is_rejected() {
let directory = tempfile::tempdir().unwrap();
let socket = directory.path().join("ipc.sock");
let (_runtime, server_task) = start_server(&socket, DaemonServices::inactive()).await;
let mut stream = connect_and_await_hello(&socket).await;
write_msg(
&mut stream,
&ClientMessage::Request(RequestEnvelope {
request_id: 7,
protocol_version: PROTOCOL_VERSION + 1,
request: LocalRequest::GetStatus,
}),
)
.await
.expect("request sends");
let response = loop {
match read_msg::<_, DaemonMessage>(&mut stream)
.await
.expect("daemon response arrives")
{
DaemonMessage::Response(response) => break response,
_ => continue,
}
};
assert_eq!(response.request_id, 7);
assert!(matches!(
response.result,
ResponseResult::Error(IpcErrorCode::UnsupportedVersion)
));
drop(stream);
server_task.abort();
let _ = server_task.await;
}
struct TestOmikron;
#[async_trait]
impl OmikronClient for TestOmikron {
async fn send_message(&self, _: &CommunicationValue) -> Result<(), OmikronError> {
Ok(())
}
async fn await_response(
&self,
_: &CommunicationValue,
_: Duration,
) -> Result<CommunicationValue, OmikronError> {
Err(OmikronError::Disconnected("test client".into()))
}
async fn reconnect(&self) -> Result<(), OmikronError> {
Ok(())
}
async fn rotate_identity(&self) -> Result<(), OmikronError> {
Ok(())
}
async fn is_connected(&self) -> bool {
true
}
}
fn active_services() -> Arc<DaemonServices> {
Arc::new(DaemonServices {
omikron: Arc::new(TestOmikron),
users: Default::default(),
config: Default::default(),
active: true,
})
}
#[tokio::test]
async fn shutdown_delivers_response_and_lifecycle_event_before_eof() {
let directory = tempfile::tempdir().unwrap();
let socket = directory.path().join("ipc.sock");
let (runtime, server_task) = start_server(&socket, active_services()).await;
let mut stream = connect_and_await_hello(&socket).await;
write_msg(
&mut stream,
&ClientMessage::Request(RequestEnvelope {
request_id: 8,
protocol_version: PROTOCOL_VERSION,
request: LocalRequest::RequestProcessExit {
intent: ExitIntent::Stop,
},
}),
)
.await
.expect("shutdown request sends");
let mut response_seen = false;
let mut lifecycle_seen = false;
for _ in 0..4 {
match tokio::time::timeout(
Duration::from_secs(2),
read_msg::<_, DaemonMessage>(&mut stream),
)
.await
.expect("shutdown message arrives")
.expect("shutdown stream remains readable")
{
DaemonMessage::Response(response) => {
assert_eq!(response.request_id, 8);
assert!(matches!(response.result, ResponseResult::Ok(_)));
response_seen = true;
}
DaemonMessage::LifecycleEvent(iota_ipc::LifecycleEvent::Shutdown { .. }) => {
lifecycle_seen = true;
}
_ => {}
}
if response_seen && lifecycle_seen {
break;
}
}
assert!(response_seen);
assert!(lifecycle_seen);
let eof = tokio::time::timeout(
Duration::from_secs(2),
read_msg::<_, DaemonMessage>(&mut stream),
)
.await
.expect("shutdown connection closes after flush");
assert!(eof.is_err());
assert!(runtime.is_shutting_down());
server_task.abort();
let _ = server_task.await;
}

View file

@ -1,40 +0,0 @@
use iota_daemon_lib::{DaemonRuntime, ShutdownReason};
use std::time::Duration;
#[tokio::test]
async fn shutdown_reason_is_first_write_wins_and_tasks_join() {
let runtime = DaemonRuntime::new();
runtime.shutdown(ShutdownReason::Fatal("first".into()));
runtime.shutdown(ShutdownReason::Restart);
assert_eq!(
runtime.shutdown_reason(),
Some(ShutdownReason::Fatal("first".into()))
);
runtime.tasks.spawn_tracked("quick", async { Ok(()) }).await;
assert!(
runtime
.tasks
.join_with_timeout(Duration::from_millis(100))
.await
.is_empty()
);
}
#[tokio::test]
async fn long_task_is_aborted_at_join_timeout() {
let runtime = DaemonRuntime::new();
runtime
.tasks
.spawn_tracked("slow", async {
tokio::time::sleep(Duration::from_secs(10)).await;
Ok(())
})
.await;
assert!(
runtime
.tasks
.join_with_timeout(Duration::from_millis(10))
.await
.is_empty()
);
}

View file

@ -1,16 +0,0 @@
[package]
name = "iota-daemon"
version = "0.1.0"
edition = "2024"
[dependencies]
iota-daemon-lib = { path = "../iota-daemon-lib" }
iota-ipc = { path = "../iota-ipc" }
iota-logger = { path = "../iota-logger" }
iota-paths = { path = "../iota-paths" }
iota-storage = { path = "../iota-storage" }
iota-util = { path = "../iota-util" }
iota-terms = { path = "../iota-terms" }
omikron-connector = { path = "../omikron-connector" }
web-server = { path = "../web-server" }
tokio = { version = "1.50.0", features = ["full"] }

View file

@ -1,473 +0,0 @@
use iota_daemon_lib::{
DaemonRuntime, DaemonServices, IpcServer, ShutdownReason, StartupPhase, log_broadcaster,
log_buffer::LogBuffer,
};
use iota_logger::{self as logger, log};
use iota_storage::users::user_manager;
use iota_storage::util::config_util::CONFIG;
use std::process::ExitCode;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::{broadcast, watch};
const MESSAGE_RETENTION_INTERVAL: Duration = Duration::from_secs(60);
const SYNC_COMPACTION_INTERVAL: Duration = Duration::from_secs(60 * 60);
#[tokio::main(flavor = "multi_thread")]
async fn main() -> ExitCode {
let scope = match std::env::var("IOTA_DEPLOYMENT_MODE").ok().as_deref() {
Some("system_socket_activated") | Some("system_always_on") => iota_paths::Scope::System,
_ => iota_paths::Scope::User,
};
let paths = match iota_paths::IotaPaths::resolve(scope) {
Ok(paths) => paths,
Err(error) => {
eprintln!("Cannot resolve Iota paths: {error}");
return ExitCode::FAILURE;
}
};
// Bind a deliberately dormant IPC daemon before terms are accepted. This
// makes socket activation and `iota terms accept --system` usable, while
// the router exposes status only and the inactive service cannot connect.
if !iota_terms::consent::load(&paths.state_dir).has_all_required() {
let socket = match &paths.ipc_endpoint {
iota_paths::IpcEndpoint::UnixSocket(path) => path.clone(),
iota_paths::IpcEndpoint::WindowsPipe(_) => {
eprintln!("Windows named-pipe daemon transport is not implemented yet");
return ExitCode::FAILURE;
}
};
let runtime = Arc::new(DaemonRuntime::new());
let (log_tx, _) = broadcast::channel(64);
let log_buffer = Arc::new(Mutex::new(LogBuffer::new(64)));
let (_, state_rx) = watch::channel(runtime.snapshot());
let server = match IpcServer::bind(
socket,
runtime.clone(),
DaemonServices::inactive(),
log_tx,
log_buffer,
state_rx,
)
.await
{
Ok(server) => server,
Err(error) => {
eprintln!("Cannot bind dormant daemon IPC socket: {error}");
return ExitCode::FAILURE;
}
};
tokio::spawn(async move {
let _ = server.serve().await;
});
eprintln!(
"Iota daemon is awaiting terms acceptance. Run `iota terms accept{}` in an interactive terminal.",
if paths.scope == iota_paths::Scope::System {
" --system"
} else {
""
}
);
loop {
tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(1)) => {
if iota_terms::consent::load(&paths.state_dir).has_all_required() {
// systemd restarts this daemon; a locally-launched daemon can
// simply be started again after accepting the documents.
return ExitCode::from(75);
}
}
_ = tokio::signal::ctrl_c() => return ExitCode::SUCCESS,
}
}
}
if let Err(error) = paths.migrate_legacy_layout() {
eprintln!("Cannot migrate legacy Iota layout: {error}");
return ExitCode::FAILURE;
}
if let Err(error) = paths.prepare_writable_directories() {
eprintln!("Cannot prepare Iota directories: {error}");
return ExitCode::FAILURE;
}
iota_util::file_util::configure_storage_directory(paths.storage_dir.clone());
iota_storage::util::config_util::configure_config_path(paths.config_file.clone());
iota_storage::util::config_util::load_config_from(&paths.config_file);
omikron_connector::omikron_connection::configure_identity_path(paths.keyring_file());
match paths.scope {
iota_paths::Scope::User => logger::startup_with_log_dir(Some(paths.log_dir.clone())),
iota_paths::Scope::System => logger::startup_with_log_dir(None),
}
let runtime = Arc::new(DaemonRuntime::new());
// --- IPC infrastructure ---
let (log_tx, _) = broadcast::channel(512);
let log_buffer = Arc::new(Mutex::new(LogBuffer::new(1024)));
log_broadcaster::spawn(log_tx.clone(), log_buffer.clone());
let (state_tx, state_rx) = watch::channel(runtime.snapshot());
runtime.set_startup_phase(StartupPhase::LoadingUsers);
let storage_error = match iota_storage::util::db::verify_and_backup_database() {
Ok(()) => tokio::task::spawn_blocking(user_manager::load_users_sync)
.await
.map_err(|error| format!("user storage task failed: {error}"))
.and_then(|result| result.map_err(|error| error.to_string()))
.err(),
Err(error) => Some(error.to_string()),
};
if let Some(error) = storage_error {
runtime.set_component_failed(
iota_ipc::ComponentId::Storage,
format!("user storage failed to load: {error}"),
);
} else {
runtime.set_component_healthy(iota_ipc::ComponentId::Storage, None);
}
// Bind before migration and service startup: a successful bind is the
// readiness boundary visible to clients and socket activation.
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 omikron = match omikron_connector::omikron_connection::connect_initial(
runtime.cancellation.clone(),
runtime.state.active_tasks.clone(),
runtime.state.app.clone(),
)
.await
{
Ok(connection) => connection,
Err(omikron_connector::OmikronStartupError::InitialConnectionTimeout { connection }) => {
runtime.set_component_degraded(
iota_ipc::ComponentId::Omikron,
"Omikron connection unavailable; retrying".into(),
);
connection
}
Err(omikron_connector::OmikronStartupError::Authentication { connection }) => {
runtime.set_component_failed(
iota_ipc::ComponentId::Omikron,
"Omikron authentication failed; regenerate the Iota identity to register again"
.into(),
);
// Keep IPC alive: identity rotation is the supported recovery
// action and must remain available after authentication fails.
connection
}
Err(omikron_connector::OmikronStartupError::Construction(error)) => {
eprintln!("Cannot construct Omikron connection: {error}");
return ExitCode::FAILURE;
}
};
let omikron_health = omikron.clone();
let omikron_reconcile = omikron.clone();
let services = DaemonServices::new(omikron);
let health_runtime = runtime.clone();
runtime
.tasks
.spawn_tracked("omikron-health", async move {
let mut states = omikron_health.connection_state();
loop {
let state = *states.borrow();
match state {
omikron_connector::omikron_connection::ConnectionState::Connected {
..
} => {
let ping_ms = *omikron_health.last_ping.lock().await;
let message = if ping_ms >= 0 {
format!("connected (RTT: {ping_ms} ms)")
} else {
"connected (waiting for RTT sample)".into()
};
health_runtime
.set_component_healthy(iota_ipc::ComponentId::Omikron, Some(message));
}
omikron_connector::omikron_connection::ConnectionState::Connecting => {
health_runtime.set_component_degraded(
iota_ipc::ComponentId::Omikron,
"connecting to Omikron".into(),
);
}
omikron_connector::omikron_connection::ConnectionState::Disconnected => {
let message = omikron_health
.get_auth_failure()
.await
.unwrap_or_else(|| "disconnected; retrying".into());
if omikron_health.has_auth_failure().await {
health_runtime
.set_component_failed(iota_ipc::ComponentId::Omikron, message);
} else {
health_runtime
.set_component_degraded(iota_ipc::ComponentId::Omikron, message);
}
}
}
tokio::select! {
changed = states.changed() => if changed.is_err() { break },
// RTT is updated by MTP's heartbeat independently of a
// connection-state transition, so periodically refresh
// the component detail while connected.
_ = tokio::time::sleep(Duration::from_secs(1)) => {},
_ = health_runtime.cancellation.cancelled() => break,
}
}
Ok(())
})
.await;
runtime
.tasks
.spawn_tracked("user-lifecycle-reconciliation", async move {
let mut states = omikron_reconcile.connection_state();
loop {
if matches!(
*states.borrow(),
omikron_connector::omikron_connection::ConnectionState::Connected { .. }
) {
omikron_connector::user_ops::reconcile_managed_users(
omikron_reconcile.as_ref(),
)
.await;
}
tokio::select! {
changed = states.changed() => if changed.is_err() { break },
_ = tokio::time::sleep(Duration::from_secs(30)) => {},
}
}
Ok(())
})
.await;
let ipc_server = match IpcServer::bind(
socket.clone(),
runtime.clone(),
services,
log_tx.clone(),
log_buffer.clone(),
state_rx,
)
.await
{
Ok(server) => server,
Err(error) => {
eprintln!("Cannot bind daemon IPC socket: {error}");
return ExitCode::FAILURE;
}
};
eprintln!("iota-daemon IPC listener ready at {}", socket.display());
runtime.set_component_healthy(iota_ipc::ComponentId::Ipc, None);
let listener_runtime = runtime.clone();
runtime
.tasks
.spawn_tracked("ipc-server", async move {
if let Err(error) = ipc_server.serve().await {
eprintln!("iota-daemon IPC server failed: {error}");
listener_runtime.shutdown(ShutdownReason::Fatal(format!(
"IPC listener stopped: {error}"
)));
}
Ok(())
})
.await;
log!("iota-daemon IPC server ready");
log!(
"iota-daemon paths (scope={:?}): config={} state={} storage={} identity={} cache={} log={} asset={} ipc={}",
paths.scope,
paths.config_file.display(),
paths.state_dir.display(),
paths.storage_dir.display(),
paths.identity_dir.display(),
paths.cache_dir.display(),
paths.log_dir.display(),
paths.asset_dir.display(),
match &paths.ipc_endpoint {
iota_paths::IpcEndpoint::UnixSocket(p) => p.display().to_string(),
iota_paths::IpcEndpoint::WindowsPipe(n) => n.clone(),
},
);
runtime.set_startup_phase(StartupPhase::StartingServices);
// --- System monitor ---
runtime.spawn_system_monitor().await;
// --- State update publisher (watch-based, no full broadcast per tick) ---
let state_publisher = runtime.clone();
runtime
.tasks
.spawn_tracked("state-publisher", async move {
loop {
if state_publisher.is_shutting_down() {
break;
}
let snapshot = state_publisher.snapshot();
let _ = state_tx.send(snapshot);
tokio::time::sleep(Duration::from_millis(500)).await;
}
Ok(())
})
.await;
// --- Web server ---
let web = CONFIG.load().web.clone();
let web_config = web_server::WebConfig {
mode: match web.mode {
iota_storage::util::config_util::WebMode::Disabled => web_server::WebMode::Disabled,
iota_storage::util::config_util::WebMode::Loopback => web_server::WebMode::Loopback,
iota_storage::util::config_util::WebMode::Network => web_server::WebMode::Network,
},
bind: web
.bind
.parse()
.unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)),
port: web.port,
asset_dir: resolve_config_path(&paths.config_file, &web.asset_dir, &paths.asset_dir),
tls: web
.certificate
.zip(web.key)
.map(|(certificate, key)| web_server::TlsConfig {
certificate: resolve_config_path(
&paths.config_file,
&certificate,
&paths.config_dir,
),
key: resolve_config_path(&paths.config_file, &key, &paths.config_dir),
}),
required: web.required,
};
match web_server::start(web_config, runtime.cancellation.clone()).await {
Ok(None) => {
runtime.set_component_healthy(iota_ipc::ComponentId::Web, Some("disabled".into()))
}
Ok(Some(handle)) => {
runtime.set_component_healthy(iota_ipc::ComponentId::Web, None);
runtime
.tasks
.spawn_tracked("web-server", async move {
handle.join().await;
Ok(())
})
.await;
}
Err(error) if web.required => {
runtime.set_component_failed(iota_ipc::ComponentId::Web, error.to_string());
}
Err(error) => {
runtime.set_component_degraded(iota_ipc::ComponentId::Web, error.to_string());
}
}
runtime.set_startup_phase(StartupPhase::Ready);
log!("iota-daemon started (phase: Ready)");
let retention_runtime = runtime.clone();
runtime
.tasks
.spawn_tracked("message-retention", async move {
loop {
let purge = tokio::task::spawn_blocking(|| {
iota_storage::util::message_retention::purge_expired_messages(
iota_storage::util::sync::now_millis(),
)
})
.await;
match purge {
Ok(Ok(result)) if result.deleted_messages > 0 => {
log!("purged {} expired messages", result.deleted_messages);
}
Ok(Ok(_)) => {}
Ok(Err(error)) => log!("message retention cleanup failed: {}", error),
Err(error) => log!("message retention task failed: {}", error),
}
tokio::select! {
_ = tokio::time::sleep(MESSAGE_RETENTION_INTERVAL) => {},
_ = retention_runtime.cancellation.cancelled() => break,
}
}
Ok(())
})
.await;
let compaction_runtime = runtime.clone();
runtime
.tasks
.spawn_tracked("sync-compaction", async move {
loop {
let compact =
tokio::task::spawn_blocking(iota_storage::util::sync::compact_all_sync_state)
.await;
match compact {
Ok(Ok(result))
if result.removed_events > 0 || result.removed_blob_tombstones > 0 =>
{
log!(
"compacted {} sync events and {} blob tombstones",
result.removed_events,
result.removed_blob_tombstones
);
}
Ok(Ok(_)) => {}
Ok(Err(error)) => log!("sync compaction failed: {}", error),
Err(error) => log!("sync compaction task failed: {}", error),
}
tokio::select! {
_ = tokio::time::sleep(SYNC_COMPACTION_INTERVAL) => {},
_ = compaction_runtime.cancellation.cancelled() => break,
}
}
Ok(())
})
.await;
// --- Main lifecycle loop ---
let signal = async {
#[cfg(unix)]
{
let mut term =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("SIGTERM handler");
tokio::select! { _ = tokio::signal::ctrl_c() => ShutdownReason::Stop, _ = term.recv() => ShutdownReason::Stop }
}
#[cfg(not(unix))]
{
let _ = tokio::signal::ctrl_c().await;
ShutdownReason::Stop
}
};
tokio::select! {
_ = runtime.cancellation.cancelled() => {},
reason = signal => runtime.shutdown(reason),
}
let reason = runtime.shutdown_reason().unwrap_or(ShutdownReason::Stop);
log!("iota-daemon shutting down (reason: {:?})", reason);
runtime.set_startup_phase(StartupPhase::Stopping);
let _ = runtime
.tasks
.join_with_timeout(Duration::from_secs(5))
.await;
let exit_code = reason.exit_code();
log!("iota-daemon exited (code: {})", exit_code);
ExitCode::from(exit_code as u8)
}
fn resolve_config_path(
config_file: &std::path::Path,
value: &str,
default: &std::path::Path,
) -> std::path::PathBuf {
if value.is_empty() {
return default.to_path_buf();
}
let path = std::path::PathBuf::from(value);
if path.is_absolute() {
path
} else {
config_file
.parent()
.expect("absolute configuration file has a parent")
.join(path)
}
}

View file

@ -1,11 +0,0 @@
[package]
name = "iota-installer"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1"
tempfile = "3"
zip = "6"
serde_json = "1"
iota-paths = { path = "../iota-paths" }

View file

@ -1,183 +0,0 @@
use anyhow::{Context, Result, bail};
use std::{fs, io, path::Path, process::Command};
use tempfile::tempdir;
use zip::ZipArchive;
const REQUIRED: &[&str] = &[
"bin/iota",
"bin/iota-daemon",
"bin/iota-updater",
"systemd/iota-daemon.service",
"systemd/iota-daemon.socket",
"systemd/sysusers.d/iota.conf",
"systemd/iota-update.service",
"systemd/iota-update.timer",
"manifest.json",
];
pub fn install_linux_bundle(bundle: &Path) -> Result<()> {
install_linux_bundle_with_operator(bundle, None)
}
pub fn bootstrap_linux_bundle(bundle: &Path, operator: Option<&str>) -> Result<()> {
install_linux_bundle_with_operator(bundle, operator)
}
pub fn install_linux_bundle_with_operator(bundle: &Path, operator: Option<&str>) -> Result<()> {
if std::env::consts::OS != "linux" {
bail!("Linux systemd bundles are not supported on this platform");
}
let staging = tempdir().context("create installer staging directory")?;
let file = fs::File::open(bundle).context("open release bundle")?;
let mut archive = ZipArchive::new(file).context("read release bundle")?;
for name in REQUIRED {
let mut entry = archive
.by_name(name)
.with_context(|| format!("bundle is missing {name}"))?;
let output = staging.path().join(name);
if let Some(parent) = output.parent() {
fs::create_dir_all(parent)?;
}
let mut out = fs::File::create(&output)?;
io::copy(&mut entry, &mut out)?;
}
install(
&staging.path().join("bin/iota"),
&format!(
"{}/versions/{}/bin/iota",
iota_paths::install_root().display(),
product_version(staging.path())
),
"0755",
)?;
install(
&staging.path().join("bin/iota-daemon"),
&format!(
"{}/versions/{}/bin/iota-daemon",
iota_paths::install_root().display(),
product_version(staging.path())
),
"0755",
)?;
let version_dir = format!(
"{}/versions/{}",
iota_paths::install_root().display(),
product_version(staging.path())
);
if !Path::new(&format!("{version_dir}/bin/iota-daemon")).is_file() {
bail!("installed daemon executable is missing: {version_dir}/bin/iota-daemon");
}
install(
&staging.path().join("bin/iota-updater"),
&format!(
"{}/versions/{}/bin/iota-updater",
iota_paths::install_root().display(),
product_version(staging.path())
),
"0755",
)?;
for unit in [
"iota-daemon.service",
"iota-daemon.socket",
"iota-update.service",
"iota-update.timer",
] {
install(
&staging.path().join("systemd").join(unit),
&format!("/usr/local/lib/systemd/system/{unit}"),
"0644",
)?;
}
install(
&staging.path().join("systemd/sysusers.d/iota.conf"),
"/etc/sysusers.d/iota.conf",
"0644",
)?;
run(
"ln",
&[
"-sfn",
&version_dir,
&iota_paths::current_version_link().to_string_lossy(),
],
)?;
run(
"ln",
&[
"-sfn",
&format!("{}/current/bin/iota", iota_paths::install_root().display()),
"/usr/local/bin/iota",
],
)?;
run(
"ln",
&[
"-sfn",
&format!(
"{}/current/bin/iota-daemon",
iota_paths::install_root().display()
),
"/usr/local/libexec/iota/iota-daemon",
],
)?;
run("systemd-sysusers", &[])?;
for directory in ["/var/lib/iota", "/var/cache/iota", "/var/log/iota"] {
run(
"install",
&["-d", "-m", "0750", "-o", "iota", "-g", "iota", directory],
)?;
}
if let Some(operator) = operator {
run("usermod", &["-aG", "iota-operators", operator])?;
} else {
eprintln!("To grant socket access, run: usermod -aG iota-operators USER");
eprintln!(
"A new login session is required before supplementary group membership is visible."
);
}
run("systemctl", &["daemon-reload"])?;
run("systemctl", &["enable", "--now", "iota-daemon.socket"])?;
run("systemctl", &["is-active", "iota-daemon.socket"])?;
run("systemctl", &["is-enabled", "iota-daemon.socket"])?;
let socket = iota_paths::socket_path(iota_paths::Scope::System);
if !socket.exists() {
bail!(
"systemd socket is active but {} was not created",
socket.display()
);
}
Ok(())
}
fn product_version(staging: &Path) -> String {
fs::read_to_string(staging.join("manifest.json"))
.ok()
.and_then(|value| serde_json::from_str::<serde_json::Value>(&value).ok())
.and_then(|value| {
value
.get("product_version")
.and_then(|v| v.as_str())
.map(str::to_owned)
})
.unwrap_or_else(|| "unversioned".into())
}
fn install(source: &Path, destination: &str, mode: &str) -> Result<()> {
run(
"install",
&["-D", "-m", mode, &source.to_string_lossy(), destination],
)
}
fn run(program: &str, args: &[&str]) -> Result<()> {
let status = Command::new(program)
.args(args)
.status()
.with_context(|| format!("run {program}"))?;
if status.success() {
Ok(())
} else {
bail!("{program} failed; run the installer as root")
}
}

View file

@ -1,9 +0,0 @@
[package]
name = "iota-ipc"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1.53.1", features = ["io-util", "macros", "rt"] }

View file

@ -1,19 +0,0 @@
pub mod protocol;
pub mod text_commands;
pub mod transport;
pub use protocol::{
ClientMessage, CommunitySummary, ComponentHealth, ComponentId, ComponentStatusResponse,
ConfigResponse, ConnectionStatus, DaemonMessage, DaemonStatusResponse, DeploymentMode,
ExitIntent, HealthStatus, HelloAck, IpcErrorCode, IpcRole, LifecycleEvent, LifecyclePhase,
LocalRequest, LocalUserState, LogEntriesResponse, LogEntry, MetricSample,
OmikronStatusResponse, RequestEnvelope, ResponseEnvelope, ResponsePayload, ResponseResult,
SecretString, StartupPhase, StateSnapshot, StatusResponse, SupervisorKind, TaskSummary,
UpdateStatusResponse, UserDetailResponse, UserSummary,
};
pub use transport::{MAX_MESSAGE_SIZE, read_msg, write_msg};
/// Current IPC protocol version.
pub const PROTOCOL_VERSION: u16 = 2;
/// Minimum protocol version this daemon understands.
pub const MIN_PROTOCOL_VERSION: u16 = 2;

View file

@ -1,515 +0,0 @@
use serde::{Deserialize, Serialize};
/// IPC credentials are supplied by the interactive CLI, never a daemon-side
/// path lookup. Debug is deliberately redacted because command routing logs
/// the request value.
#[derive(Clone, Deserialize, Serialize)]
pub struct SecretString(pub String);
impl std::fmt::Debug for SecretString {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("<redacted>")
}
}
// ---------------------------------------------------------------------------
// Client → Daemon
// ---------------------------------------------------------------------------
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
pub enum ClientMessage {
Hello {
supported_versions: Vec<u16>,
},
Subscribe {
log_classes: Vec<String>,
metric_interval_ms: Option<u64>,
},
Request(RequestEnvelope),
Ping {
seq: u64,
},
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct RequestEnvelope {
pub request_id: u64,
pub protocol_version: u16,
pub request: LocalRequest,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
pub enum LocalRequest {
GetStatus,
ListTasks,
ListUsers,
CreateUser {
username: String,
},
AttachUserFromTu {
credential: SecretString,
},
PurgeUserData {
user_id: i64,
},
ReleaseUser {
user_id: i64,
},
CompleteDeleteUser {
user_id: i64,
credential: Option<SecretString>,
},
/// Retained only to return an actionable deprecation error to old IPC
/// clients. It must never select lifecycle semantics implicitly.
RemoveUser {
user_id: i64,
},
ReconnectOmikron,
RotateIotaIdentity,
RequestProcessExit {
intent: ExitIntent,
},
GetDaemonStatus,
#[serde(skip)]
RestartDaemon,
#[serde(skip)]
StopDaemon,
GetConfig,
SetConfig {
key: String,
value: String,
},
ReloadConfig,
GetOmikronStatus,
ListComponents,
GetUser {
user_id: i64,
},
ImportUser {
username: String,
},
GetLogs {
limit: usize,
},
CheckUpdate,
ListCommunities,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum IpcRole {
Read,
Operate,
Admin,
}
impl IpcRole {
pub fn allows(self, required: IpcRole) -> bool {
matches!(
(self, required),
(IpcRole::Admin, _)
| (IpcRole::Operate, IpcRole::Operate | IpcRole::Read)
| (IpcRole::Read, IpcRole::Read)
)
}
}
impl LocalRequest {
/// Return the minimum authenticated local role required to execute a
/// request. New request variants must be assigned explicitly here.
pub fn required_role(&self) -> IpcRole {
match self {
Self::GetStatus
| Self::ListTasks
| Self::ListUsers
| Self::GetDaemonStatus
| Self::GetOmikronStatus
| Self::ListComponents
| Self::GetUser { .. }
| Self::GetLogs { .. }
| Self::CheckUpdate
| Self::ListCommunities => IpcRole::Read,
Self::ReconnectOmikron | Self::ReloadConfig => IpcRole::Operate,
Self::CreateUser { .. }
| Self::AttachUserFromTu { .. }
| Self::PurgeUserData { .. }
| Self::ReleaseUser { .. }
| Self::CompleteDeleteUser { .. }
| Self::RemoveUser { .. }
| Self::RotateIotaIdentity
| Self::RequestProcessExit { .. }
| Self::RestartDaemon
| Self::StopDaemon
| Self::GetConfig
| Self::SetConfig { .. }
| Self::ImportUser { .. } => IpcRole::Admin,
}
}
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ExitIntent {
Stop,
Restart,
}
// ---------------------------------------------------------------------------
// Daemon → Client
// ---------------------------------------------------------------------------
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
pub enum DaemonMessage {
HelloAck(HelloAck),
/// Confirms that the server has installed this connection's subscription.
Subscribed,
LogEntry(LogEntry),
StateUpdate(StateSnapshot),
MetricSample(MetricSample),
Response(ResponseEnvelope),
Pong {
seq: u64,
},
LifecycleEvent(LifecycleEvent),
Gap {
skipped: u64,
},
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct HelloAck {
pub protocol_version: u16,
pub daemon_version: String,
pub instance_id: String,
pub startup_phase: StartupPhase,
pub capabilities: Vec<String>,
#[serde(default)]
pub lifecycle: LifecyclePhase,
#[serde(default)]
pub health: HealthStatus,
#[serde(default)]
pub deployment_mode: DeploymentMode,
#[serde(default)]
pub supervisor: SupervisorKind,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ResponseEnvelope {
pub request_id: u64,
pub result: ResponseResult,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
pub enum ResponseResult {
Ok(ResponsePayload),
Error(IpcErrorCode),
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
pub enum ResponsePayload {
Status(StatusResponse),
Tasks(Vec<TaskSummary>),
Users(Vec<UserSummary>),
UserCreated {
user_id: i64,
username: String,
},
/// Retained only for wire compatibility. New lifecycle code never emits it.
UserRemoved {
user_id: i64,
},
UserDataPurged {
user_id: i64,
},
Acknowledged {
message: String,
},
DaemonStatus(DaemonStatusResponse),
Config(ConfigResponse),
OmikronStatus(OmikronStatusResponse),
Components(Vec<ComponentStatusResponse>),
UserDetail(UserDetailResponse),
LogEntries(LogEntriesResponse),
UpdateStatus(UpdateStatusResponse),
Communities(Vec<CommunitySummary>),
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ConfigResponse {
pub yaml: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct OmikronStatusResponse {
pub connected: bool,
pub iota_id: Option<u64>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ComponentStatusResponse {
pub id: ComponentId,
pub status: HealthStatus,
pub message: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct UserDetailResponse {
pub user_id: i64,
pub username: String,
pub display_name: Option<String>,
pub created_at: i64,
pub trusted_apps: Vec<String>,
pub state: LocalUserState,
pub data_present: bool,
pub credential_present: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct LogEntriesResponse {
pub entries: Vec<LogEntry>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct UpdateStatusResponse {
pub available: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct CommunitySummary {
pub name: String,
pub title: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct StatusResponse {
pub phase: String,
pub tasks: Vec<String>,
pub degraded_reason: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct TaskSummary {
pub name: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct UserSummary {
pub user_id: i64,
pub username: String,
pub state: LocalUserState,
pub data_present: bool,
pub credential_present: bool,
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum LocalUserState {
Managed,
Released,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct DaemonStatusResponse {
pub formatted: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum IpcErrorCode {
InvalidRequest,
NotFound,
Conflict,
StorageFailure,
OmikronUnavailable,
UnsupportedVersion,
NotReady,
Disconnected,
Timeout,
Cancelled,
Unauthorized,
InternalFailure,
}
impl std::fmt::Display for IpcErrorCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::InvalidRequest => "the daemon rejected the request",
Self::NotFound => "the requested resource was not found",
Self::Conflict => "the request conflicts with current daemon state",
Self::StorageFailure => "the daemon could not access local storage",
Self::OmikronUnavailable => "Omikron is unavailable",
Self::UnsupportedVersion => "the client and daemon protocol versions are incompatible",
Self::NotReady => "the daemon is not ready yet",
Self::Disconnected => "the daemon connection was lost",
Self::Timeout => "the daemon did not respond in time",
Self::Cancelled => "the daemon cancelled the request",
Self::Unauthorized => {
"the daemon denied this operation because the IPC account lacks the required role"
}
Self::InternalFailure => "the daemon encountered an internal failure",
})
}
}
#[cfg(test)]
mod error_tests {
use super::IpcErrorCode;
#[test]
fn error_codes_have_operator_facing_messages() {
assert_eq!(
IpcErrorCode::NotReady.to_string(),
"the daemon is not ready yet"
);
assert!(
!IpcErrorCode::InternalFailure
.to_string()
.contains("InternalFailure")
);
assert!(
IpcErrorCode::Unauthorized
.to_string()
.contains("required role")
);
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
pub enum LifecycleEvent {
StateChanged(ConnectionStatus),
Shutdown { reason: String },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConnectionStatus {
Connected,
Reconnecting,
Degraded,
Disconnected,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StartupPhase {
Starting,
MigratingStorage,
LoadingUsers,
StartingServices,
Ready,
Degraded,
Stopping,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LifecyclePhase {
#[default]
Starting,
Ready,
Stopping,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HealthStatus {
#[default]
Healthy,
Degraded,
Failed,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ComponentId {
Storage,
Ipc,
Omikron,
Web,
Updater,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DeploymentMode {
SessionChild,
UiAutoStart,
UserService,
SystemSocketActivated,
SystemAlwaysOn,
#[default]
External,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SupervisorKind {
#[default]
None,
IotaUi,
Systemd,
External,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ComponentHealth {
pub status: HealthStatus,
pub message: Option<String>,
pub changed_at_ms: u128,
}
impl Default for StartupPhase {
fn default() -> Self {
Self::Starting
}
}
// ---------------------------------------------------------------------------
// Shared types
// ---------------------------------------------------------------------------
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct LogEntry {
pub timestamp_ms: u128,
pub sender: String,
pub message: String,
pub is_error: bool,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct StateSnapshot {
pub cpu: Vec<(f64, f64)>,
pub ram: Vec<(f64, f64)>,
pub ping: Vec<(f64, f64)>,
pub net_up: Vec<(f64, f64)>,
pub net_down: Vec<(f64, f64)>,
pub sys_info: String,
#[serde(default)]
pub startup_phase: StartupPhase,
#[serde(default)]
pub degraded_reason: Option<String>,
#[serde(default)]
pub lifecycle: LifecyclePhase,
#[serde(default)]
pub startup_step: Option<String>,
#[serde(default)]
pub overall_health: HealthStatus,
#[serde(default)]
pub components: std::collections::BTreeMap<ComponentId, ComponentHealth>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct MetricSample {
pub cpu: Option<f64>,
pub ram: Option<f64>,
pub ping: Option<f64>,
pub net_up: Option<f64>,
pub net_down: Option<f64>,
}

View file

@ -1,334 +0,0 @@
use crate::LocalRequest;
pub const COMMANDS: &[&str] = &[
"status",
"tasks",
"users list",
"users show ",
"users add ",
"users remove ",
"users import ",
"omikron status",
"reconnect",
"identity rotate",
"daemon status",
"config get",
"config set ",
"config reload",
"health",
"components",
"logs",
"update check",
"community list",
"restart",
"stop",
];
pub fn completions(prefix: &str) -> Vec<&'static str> {
let normalized = prefix.trim_start_matches('/');
COMMANDS
.iter()
.copied()
.filter(|command| command.starts_with(normalized))
.collect()
}
pub fn validation_error(line: &str) -> Option<String> {
let normalized = line.trim_start_matches('/').trim();
if normalized == "help" || parse(normalized).is_some() {
None
} else {
Some(format!(
"Unknown command `{normalized}`. Use /help or Tab completion."
))
}
}
/// Parse a text command string into a typed IPC request.
///
/// Both the CLI console and the TUI command palette use this single parser.
/// Commands are case-insensitive and support an optional leading `/`.
pub fn parse(line: &str) -> Option<LocalRequest> {
let parts: Vec<&str> = line.trim_start_matches('/').split_whitespace().collect();
match parts.as_slice() {
["help"] => None,
["status"] => Some(LocalRequest::GetStatus),
["tasks"] => Some(LocalRequest::ListTasks),
["users"] | ["user", "list"] | ["users", "list"] => Some(LocalRequest::ListUsers),
["user" | "users", "show", id_str] => {
let user_id = id_str.parse::<i64>().ok()?;
Some(LocalRequest::GetUser { user_id })
}
["user" | "users", "add", username] => Some(LocalRequest::CreateUser {
username: username.to_string(),
}),
["user" | "users", "remove", id_str] => {
let user_id = id_str.parse::<i64>().ok()?;
Some(LocalRequest::RemoveUser { user_id })
}
["user" | "users", "import", username] => Some(LocalRequest::ImportUser {
username: username.to_string(),
}),
["reconnect"] => Some(LocalRequest::ReconnectOmikron),
["regenerate", "keys"] | ["identity", "rotate"] => Some(LocalRequest::RotateIotaIdentity),
["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit {
intent: crate::ExitIntent::Restart,
}),
["shutdown"] | ["stop"] => Some(LocalRequest::RequestProcessExit {
intent: crate::ExitIntent::Stop,
}),
["daemon", "status"] => Some(LocalRequest::GetDaemonStatus),
["config", "get"] => Some(LocalRequest::GetConfig),
["config", "set", key, value] => Some(LocalRequest::SetConfig {
key: key.to_string(),
value: value.to_string(),
}),
["config", "reload"] => Some(LocalRequest::ReloadConfig),
["omikron", "status"] => Some(LocalRequest::GetOmikronStatus),
["health"] => Some(LocalRequest::ListComponents),
["components"] => Some(LocalRequest::ListComponents),
["logs"] => Some(LocalRequest::GetLogs { limit: 100 }),
["update", "check"] => Some(LocalRequest::CheckUpdate),
["community", "list"] | ["communities"] => Some(LocalRequest::ListCommunities),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_status() {
assert!(matches!(parse("status"), Some(LocalRequest::GetStatus)));
}
#[test]
fn parses_tasks() {
assert!(matches!(parse("tasks"), Some(LocalRequest::ListTasks)));
}
#[test]
fn parses_user_list_shortcuts() {
assert!(matches!(parse("users"), Some(LocalRequest::ListUsers)));
assert!(matches!(parse("user list"), Some(LocalRequest::ListUsers)));
}
#[test]
fn parses_user_add() {
let req = parse("user add alice").unwrap();
match req {
LocalRequest::CreateUser { username } => assert_eq!(username, "alice"),
_ => panic!("expected CreateUser"),
}
}
#[test]
fn accepts_the_headless_cli_user_vocabulary() {
assert!(matches!(parse("users list"), Some(LocalRequest::ListUsers)));
assert!(matches!(
parse("users add alice"),
Some(LocalRequest::CreateUser { .. })
));
assert!(matches!(
parse("users remove 42"),
Some(LocalRequest::RemoveUser { user_id: 42 })
));
assert!(matches!(
parse("identity rotate"),
Some(LocalRequest::RotateIotaIdentity)
));
}
#[test]
fn parses_user_remove_by_id() {
let req = parse("user remove 42").unwrap();
match req {
LocalRequest::RemoveUser { user_id } => assert_eq!(user_id, 42),
_ => panic!("expected RemoveUser"),
}
}
#[test]
fn user_remove_requires_numeric_id() {
assert!(parse("user remove alice").is_none());
}
#[test]
fn parses_reconnect() {
assert!(matches!(
parse("reconnect"),
Some(LocalRequest::ReconnectOmikron)
));
}
#[test]
fn parses_regenerate_keys() {
assert!(matches!(
parse("regenerate keys"),
Some(LocalRequest::RotateIotaIdentity)
));
}
#[test]
fn parses_restart_aliases() {
assert!(matches!(
parse("restart"),
Some(LocalRequest::RequestProcessExit { .. })
));
assert!(matches!(
parse("reload"),
Some(LocalRequest::RequestProcessExit { .. })
));
}
#[test]
fn parses_stop_aliases() {
assert!(matches!(
parse("stop"),
Some(LocalRequest::RequestProcessExit { .. })
));
assert!(matches!(
parse("shutdown"),
Some(LocalRequest::RequestProcessExit { .. })
));
}
#[test]
fn parses_daemon_status() {
assert!(matches!(
parse("daemon status"),
Some(LocalRequest::GetDaemonStatus)
));
}
#[test]
fn parses_config_get() {
assert!(matches!(parse("config get"), Some(LocalRequest::GetConfig)));
}
#[test]
fn parses_config_reload() {
assert!(matches!(
parse("config reload"),
Some(LocalRequest::ReloadConfig)
));
}
#[test]
fn parses_omikron_status() {
assert!(matches!(
parse("omikron status"),
Some(LocalRequest::GetOmikronStatus)
));
}
#[test]
fn parses_components() {
assert!(matches!(
parse("components"),
Some(LocalRequest::ListComponents)
));
}
#[test]
fn parses_health() {
assert!(matches!(
parse("health"),
Some(LocalRequest::ListComponents)
));
}
#[test]
fn parses_users_show() {
let req = parse("users show 42").unwrap();
match req {
LocalRequest::GetUser { user_id } => assert_eq!(user_id, 42),
_ => panic!("expected GetUser"),
}
}
#[test]
fn user_show_requires_numeric_id() {
assert!(parse("users show alice").is_none());
}
#[test]
fn parses_config_set() {
let req = parse("config set port 8080").unwrap();
match req {
LocalRequest::SetConfig { key, value } => {
assert_eq!(key, "port");
assert_eq!(value, "8080");
}
_ => panic!("expected SetConfig"),
}
}
#[test]
fn parses_logs() {
assert!(matches!(parse("logs"), Some(LocalRequest::GetLogs { .. })));
}
#[test]
fn parses_update_check() {
assert!(matches!(
parse("update check"),
Some(LocalRequest::CheckUpdate)
));
}
#[test]
fn parses_community_list() {
assert!(matches!(
parse("community list"),
Some(LocalRequest::ListCommunities)
));
}
#[test]
fn parses_communities_alias() {
assert!(matches!(
parse("communities"),
Some(LocalRequest::ListCommunities)
));
}
#[test]
fn parses_users_import() {
let req = parse("users import alice").unwrap();
match req {
LocalRequest::ImportUser { username } => assert_eq!(username, "alice"),
_ => panic!("expected ImportUser"),
}
}
#[test]
fn parses_with_slash_prefix() {
assert!(matches!(parse("/status"), Some(LocalRequest::GetStatus)));
assert!(matches!(parse("/tasks"), Some(LocalRequest::ListTasks)));
}
#[test]
fn unknown_returns_none() {
assert!(parse("nonexistent").is_none());
}
#[test]
fn completion_is_prefix_based_and_deterministic() {
assert_eq!(completions("identity r"), vec!["identity rotate"]);
assert_eq!(completions("/users a"), vec!["users add "]);
assert!(completions("definitely-unknown").is_empty());
}
#[test]
fn validation_distinguishes_help_and_unknown_commands() {
assert_eq!(validation_error("/help"), None);
assert!(validation_error("status").is_none());
assert!(
validation_error("statuz")
.unwrap()
.contains("Unknown command")
);
}
}

View file

@ -1,96 +0,0 @@
use serde::Serialize;
use serde::de::DeserializeOwned;
use std::io::{Error, ErrorKind, Result};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
/// Maximum encoded payload size for a single IPC frame.
///
/// This is a wire-level contract shared by both sides of the connection.
pub const MAX_MESSAGE_SIZE: usize = 1024 * 1024;
/* Length-prefixing preserves message boundaries on a byte stream and bounds
* allocations before JSON is deserialized. */
pub async fn write_msg<W, T>(writer: &mut W, message: &T) -> Result<()>
where
W: AsyncWrite + Unpin,
T: Serialize,
{
let payload =
serde_json::to_vec(message).map_err(|error| Error::new(ErrorKind::InvalidData, error))?;
if payload.len() > MAX_MESSAGE_SIZE {
return Err(Error::new(
ErrorKind::InvalidData,
"IPC message exceeds limit",
));
}
let len = u32::try_from(payload.len())
.map_err(|_| Error::new(ErrorKind::InvalidData, "IPC message is too large"))?;
writer.write_u32(len).await?;
writer.write_all(&payload).await?;
writer.flush().await
}
pub async fn read_msg<R, T>(reader: &mut R) -> Result<T>
where
R: AsyncRead + Unpin,
T: DeserializeOwned,
{
let len = reader.read_u32().await? as usize;
if len > MAX_MESSAGE_SIZE {
return Err(Error::new(
ErrorKind::InvalidData,
"IPC message exceeds limit",
));
}
let mut payload = vec![0; len];
reader.read_exact(&mut payload).await?;
serde_json::from_slice(&payload).map_err(|error| Error::new(ErrorKind::InvalidData, error))
}
#[cfg(test)]
mod tests {
use super::{MAX_MESSAGE_SIZE, read_msg, write_msg};
use crate::protocol::{ClientMessage, LocalRequest, RequestEnvelope};
#[tokio::test]
async fn round_trips_framed_messages() {
let (mut writer, mut reader) = tokio::io::duplex(1024);
let message = ClientMessage::Request(RequestEnvelope {
request_id: 4,
protocol_version: 2,
request: LocalRequest::GetStatus,
});
write_msg(&mut writer, &message)
.await
.expect("write succeeds");
let received: ClientMessage = read_msg(&mut reader).await.expect("read succeeds");
assert!(matches!(received, ClientMessage::Request(req) if req.request_id == 4));
}
#[tokio::test]
async fn write_rejects_message_above_frame_limit() {
let (mut writer, _reader) = tokio::io::duplex(MAX_MESSAGE_SIZE + 16);
let message = "x".repeat(MAX_MESSAGE_SIZE + 1);
let error = write_msg(&mut writer, &message)
.await
.expect_err("oversized payload must be rejected before framing");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
assert!(error.to_string().contains("exceeds limit"));
}
#[tokio::test]
async fn read_rejects_frame_above_limit_before_allocating_payload() {
let (mut writer, mut reader) = tokio::io::duplex(16);
tokio::io::AsyncWriteExt::write_u32(&mut writer, (MAX_MESSAGE_SIZE + 1) as u32)
.await
.expect("length prefix write succeeds");
let error = read_msg::<_, ClientMessage>(&mut reader)
.await
.expect_err("oversized frame must be rejected");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
}
}

View file

@ -1,16 +0,0 @@
[package]
name = "iota-logger"
version = "0.1.0"
edition = "2024"
[dependencies]
iota-paths = { path = "../iota-paths" }
iota-state = { path = "../iota-state" }
iota-util = { path = "../iota-util" }
mtp = { git = "https://git.methanium.net/Methanium/mtp.git" }
ratatui = "0.30.0"
json = "0.12.4"
once_cell = "1.21.4"
tokio = { version = "1.50.0", features = ["sync"] }

View file

@ -1,4 +0,0 @@
[package]
name = "iota-paths"
version = "0.1.0"
edition = "2024"

View file

@ -1,569 +0,0 @@
//! Platform and deployment aware locations used by Iota.
//!
//! This module deliberately keeps environment handling in one place. In
//! particular, an override is never interpreted relative to the process
//! working directory.
use std::env;
use std::fmt;
use std::path::{Path, PathBuf};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Scope {
User,
System,
}
/// Compatibility name retained for callers which have not yet been migrated.
pub type SocketScope = Scope;
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum IpcEndpoint {
UnixSocket(PathBuf),
WindowsPipe(String),
}
#[derive(Debug)]
pub enum PathError {
MissingPlatformDirectory(&'static str),
MissingRequiredOverride(&'static str),
EmptyOverride(&'static str),
RelativeOverride {
variable: &'static str,
value: PathBuf,
},
InvalidPipeName(String),
UnsupportedScope,
}
impl fmt::Display for PathError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingPlatformDirectory(name) => write!(f, "missing platform directory: {name}"),
Self::MissingRequiredOverride(name) => write!(f, "{name} must be set"),
Self::EmptyOverride(name) => write!(f, "{name} must not be empty"),
Self::RelativeOverride { variable, value } => {
write!(f, "{variable} must be absolute, got {}", value.display())
}
Self::InvalidPipeName(name) => write!(f, "invalid Windows pipe name: {name}"),
Self::UnsupportedScope => write!(f, "this path scope is unsupported on this platform"),
}
}
}
impl std::error::Error for PathError {}
#[derive(Clone, Debug)]
pub struct IotaPaths {
pub scope: Scope,
pub config_dir: PathBuf,
pub config_file: PathBuf,
pub state_dir: PathBuf,
pub storage_dir: PathBuf,
pub identity_dir: PathBuf,
pub cache_dir: PathBuf,
pub runtime_dir: Option<PathBuf>,
pub log_dir: PathBuf,
/// Directory containing static web assets (not its parent).
pub asset_dir: PathBuf,
pub install_root: PathBuf,
pub ipc_endpoint: IpcEndpoint,
}
impl IotaPaths {
pub fn resolve(scope: Scope) -> Result<Self, PathError> {
let defaults = Defaults::for_scope(scope)?;
let data_root = if scope == Scope::User {
absolute_env("IOTA_DATA_ROOT")?
} else {
None
};
let config_dir = override_first(&["IOTA_CONFIG_DIR"])?
.or_else(|| data_root.as_ref().map(|root| root.join("config")))
.unwrap_or(defaults.config_dir);
// IOTA_DATA_DIR is intentionally only a compatibility alias. Parse it
// exactly like every other override; do not hide an invalid value.
let state_dir = override_first(&["IOTA_STATE_DIR", "IOTA_DATA_DIR"])?
.or_else(|| data_root.as_ref().map(|root| root.join("state")))
.unwrap_or(defaults.state_dir);
let cache_dir = override_first(&["IOTA_CACHE_DIR"])?
.or_else(|| data_root.as_ref().map(|root| root.join("cache")))
.unwrap_or(defaults.cache_dir);
let runtime_dir = override_first(&["IOTA_RUNTIME_DIR"])?
.or_else(|| data_root.as_ref().map(|root| root.join("runtime")))
.or(defaults.runtime_dir);
let log_dir = override_first(&["IOTA_LOG_DIR"])?
.or_else(|| data_root.as_ref().map(|root| root.join("logs")))
.unwrap_or(defaults.log_dir);
let asset_dir = override_first(&["IOTA_ASSET_DIR", "IOTA_WEB_ASSET_DIR"])?
.or_else(|| data_root.as_ref().map(|root| root.join("web")))
.unwrap_or(defaults.asset_dir);
let install_root = override_first(&["IOTA_INSTALL_ROOT"])?
.or_else(|| data_root.as_ref().map(|root| root.join("bin")))
.unwrap_or(defaults.install_root);
let config_file = override_first(&["IOTA_CONFIG_FILE"])?
.unwrap_or_else(|| config_dir.join("config.yaml"));
let ipc_endpoint = resolve_ipc(scope, defaults.ipc_endpoint, data_root.as_deref())?;
let runtime_dir = runtime_dir.or_else(|| match &ipc_endpoint {
IpcEndpoint::UnixSocket(path) => path.parent().map(Path::to_path_buf),
IpcEndpoint::WindowsPipe(_) => None,
});
Ok(Self {
scope,
config_dir,
config_file,
storage_dir: state_dir.join("storage"),
identity_dir: state_dir.join("identity"),
state_dir,
cache_dir,
runtime_dir,
log_dir,
asset_dir,
install_root,
ipc_endpoint,
})
}
pub fn database_file(&self) -> PathBuf {
self.storage_dir.join("messages.sqlite3")
}
pub fn keyring_file(&self) -> PathBuf {
self.identity_dir.join("iota.mk")
}
pub fn update_staging_dir(&self) -> PathBuf {
self.cache_dir.join("updates/staging")
}
pub fn update_status_file(&self) -> PathBuf {
self.state_dir.join("update-status.json")
}
pub fn update_lock_file(&self) -> Result<PathBuf, PathError> {
self.runtime_dir
.as_ref()
.map(|p| p.join("update.lock"))
.ok_or(PathError::MissingPlatformDirectory("runtime directory"))
}
pub fn daemon_lock_file(&self) -> Result<PathBuf, PathError> {
self.runtime_dir
.as_ref()
.map(|p| p.join("daemon.lock"))
.ok_or(PathError::MissingPlatformDirectory("runtime directory"))
}
pub fn prepare_writable_directories(&self) -> std::io::Result<()> {
for directory in [
&self.state_dir,
&self.storage_dir,
&self.identity_dir,
&self.cache_dir,
&self.log_dir,
] {
create_directory(directory, self.scope == Scope::User).map_err(|error| {
std::io::Error::new(
error.kind(),
format!("cannot prepare {}: {error}", directory.display()),
)
})?;
}
if let Some(runtime) = &self.runtime_dir {
create_directory(runtime, self.scope == Scope::User).map_err(|error| {
std::io::Error::new(
error.kind(),
format!("cannot prepare {}: {error}", runtime.display()),
)
})?;
}
Ok(())
}
/// Move the pre-v2 resources that were all placed directly below the
/// state root. This is deliberately idempotent: an existing destination
/// is never overwritten and the marker is only written after the moves.
pub fn migrate_legacy_layout(&self) -> std::io::Result<()> {
let marker = self.state_dir.join("path-layout-v2.json");
if marker.exists() {
return Ok(());
}
move_if_absent(&self.state_dir.join("config.yaml"), &self.config_file)?;
move_if_absent(&self.state_dir.join("certs"), &self.config_dir.join("tls"))?;
for suffix in [
"messages.sqlite3",
"messages.sqlite3-wal",
"messages.sqlite3-shm",
] {
move_if_absent(&self.state_dir.join(suffix), &self.storage_dir.join(suffix))?;
}
for name in ["users", "communities"] {
move_if_absent(&self.state_dir.join(name), &self.storage_dir.join(name))?;
}
move_if_absent(&self.state_dir.join("iota.mk"), &self.keyring_file())?;
move_if_absent(
&self.state_dir.join("update-staging"),
&self.update_staging_dir(),
)?;
// Runtime objects must not survive a layout migration or reboot.
for name in ["update.lock", "iota.sock", "iota.sock.lock"] {
let path = self.state_dir.join(name);
if path.is_file() || path.is_symlink() {
let _ = std::fs::remove_file(path);
}
}
std::fs::create_dir_all(&self.state_dir)?;
std::fs::write(marker, "{\"version\":2}\n")
}
}
fn move_if_absent(source: &Path, destination: &Path) -> std::io::Result<()> {
if !source.exists() || destination.exists() {
return Ok(());
}
let metadata = std::fs::symlink_metadata(source)?;
if metadata.file_type().is_symlink() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("refusing symlink migration source {}", source.display()),
));
}
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent)?;
}
match std::fs::rename(source, destination) {
Ok(()) => Ok(()),
Err(error) if error.raw_os_error() == Some(libc_exdev()) => {
copy_recursively(source, destination)?;
if source.is_dir() {
std::fs::remove_dir_all(source)
} else {
std::fs::remove_file(source)
}
}
Err(error) => Err(error),
}
}
// EXDEV is stable on Unix. A literal is used on non-Unix where the fallback
// copy is harmlessly skipped because rename normally remains on one volume.
#[cfg(unix)]
fn libc_exdev() -> i32 {
18
}
#[cfg(not(unix))]
fn libc_exdev() -> i32 {
-1
}
fn copy_recursively(source: &Path, destination: &Path) -> std::io::Result<()> {
if source.is_dir() {
std::fs::create_dir_all(destination)?;
for entry in std::fs::read_dir(source)? {
let entry = entry?;
copy_recursively(&entry.path(), &destination.join(entry.file_name()))?;
}
Ok(())
} else {
std::fs::copy(source, destination).map(|_| ())
}
}
struct Defaults {
config_dir: PathBuf,
state_dir: PathBuf,
cache_dir: PathBuf,
runtime_dir: Option<PathBuf>,
log_dir: PathBuf,
asset_dir: PathBuf,
install_root: PathBuf,
ipc_endpoint: Option<IpcEndpoint>,
}
impl Defaults {
fn for_scope(scope: Scope) -> Result<Self, PathError> {
match scope {
Scope::System => {
#[cfg(target_os = "linux")]
{
Ok(Self {
config_dir: "/etc/iota".into(),
state_dir: "/var/lib/iota".into(),
cache_dir: "/var/cache/iota".into(),
runtime_dir: Some("/run/iota".into()),
log_dir: "/var/log/iota".into(),
asset_dir: "/usr/local/share/iota/web".into(),
install_root: "/usr/local/libexec/iota".into(),
ipc_endpoint: Some(IpcEndpoint::UnixSocket("/run/iota/iota.sock".into())),
})
}
#[cfg(not(target_os = "linux"))]
{
Err(PathError::UnsupportedScope)
}
}
Scope::User => user_defaults(),
}
}
}
#[cfg(unix)]
fn user_defaults() -> Result<Defaults, PathError> {
let home =
absolute_env("HOME")?.ok_or(PathError::MissingPlatformDirectory("home directory"))?;
let config_base = xdg_or_home("XDG_CONFIG_HOME", &home, ".config")?;
let state_base = xdg_or_home("XDG_STATE_HOME", &home, ".local/state")?;
let cache_base = xdg_or_home("XDG_CACHE_HOME", &home, ".cache")?;
let data_base = xdg_or_home("XDG_DATA_HOME", &home, ".local/share")?;
Ok(Defaults {
config_dir: config_base.join("iota"),
state_dir: state_base.join("iota"),
cache_dir: cache_base.join("iota"),
runtime_dir: None,
log_dir: state_base.join("iota/logs"),
asset_dir: data_base.join("iota/web"),
install_root: data_base.join("iota/bin"),
ipc_endpoint: None,
})
}
#[cfg(windows)]
fn user_defaults() -> Result<Defaults, PathError> {
let config = absolute_env("APPDATA")?
.ok_or(PathError::MissingPlatformDirectory("Roaming AppData"))?
.join("Tensamin/Iota/config");
let local = absolute_env("LOCALAPPDATA")?
.ok_or(PathError::MissingPlatformDirectory("Local AppData"))?
.join("Tensamin/Iota");
Ok(Defaults {
config_dir: config,
state_dir: local.join("state"),
cache_dir: local.join("cache"),
runtime_dir: None,
log_dir: local.join("logs"),
asset_dir: local.join("data"),
install_root: local.join("bin"),
ipc_endpoint: Some(IpcEndpoint::WindowsPipe(
r"\\.\pipe\Tensamin.Iota.User".into(),
)),
})
}
fn xdg_or_home(variable: &'static str, home: &Path, fallback: &str) -> Result<PathBuf, PathError> {
Ok(absolute_env(variable)?.unwrap_or_else(|| home.join(fallback)))
}
fn absolute_env(name: &'static str) -> Result<Option<PathBuf>, PathError> {
let Some(value) = env::var_os(name) else {
return Ok(None);
};
if value.is_empty() {
return Err(PathError::EmptyOverride(name));
}
let path = PathBuf::from(value);
if !path.is_absolute() {
return Err(PathError::RelativeOverride {
variable: name,
value: path,
});
}
Ok(Some(path))
}
fn override_first(names: &[&'static str]) -> Result<Option<PathBuf>, PathError> {
for name in names {
if let Some(value) = absolute_env(name)? {
return Ok(Some(value));
}
}
Ok(None)
}
fn resolve_ipc(
scope: Scope,
default: Option<IpcEndpoint>,
data_root: Option<&Path>,
) -> Result<IpcEndpoint, PathError> {
#[cfg(unix)]
{
if let Some(path) = absolute_env("IOTA_SOCKET")? {
return Ok(IpcEndpoint::UnixSocket(path));
}
if let Some(root) = data_root {
return Ok(IpcEndpoint::UnixSocket(root.join("runtime/iota.sock")));
}
if scope == Scope::User {
return Err(PathError::MissingRequiredOverride("IOTA_SOCKET"));
}
}
#[cfg(windows)]
{
if let Some(name) = env::var_os("IOTA_PIPE") {
let name = name.to_string_lossy().into_owned();
if !name.starts_with(r"\\.\pipe\") {
return Err(PathError::InvalidPipeName(name));
}
return Ok(IpcEndpoint::WindowsPipe(name));
}
}
default.ok_or(PathError::UnsupportedScope)
}
fn create_directory(path: &Path, private: bool) -> std::io::Result<()> {
std::fs::create_dir_all(path)?;
#[cfg(unix)]
if private {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
}
Ok(())
}
// Compatibility helpers. New code should resolve IotaPaths once and pass it
// to its dependencies instead of calling these independently.
pub fn data_dir() -> PathBuf {
IotaPaths::resolve(Scope::User)
.expect("resolve Iota user paths")
.state_dir
}
pub fn config_dir() -> PathBuf {
IotaPaths::resolve(Scope::User)
.expect("resolve Iota user paths")
.config_dir
}
pub fn socket_override() -> Option<PathBuf> {
absolute_env("IOTA_SOCKET").ok().flatten()
}
pub fn socket_path(scope: SocketScope) -> PathBuf {
match IotaPaths::resolve(scope)
.expect("resolve Iota paths")
.ipc_endpoint
{
IpcEndpoint::UnixSocket(path) => path,
IpcEndpoint::WindowsPipe(_) => panic!("Windows IPC endpoint is not a filesystem path"),
}
}
pub fn socket_lock_path(scope: SocketScope) -> PathBuf {
IotaPaths::resolve(scope)
.expect("resolve Iota paths")
.daemon_lock_file()
.expect("runtime directory")
}
/// The compatibility installation helpers describe the machine installation,
/// not a user's data directory. Per-user launchers should keep an
/// `IotaPaths` instance and use its `install_root` directly.
pub fn install_root() -> PathBuf {
IotaPaths::resolve(Scope::System)
.expect("resolve Iota system paths")
.install_root
}
pub fn versions_dir() -> PathBuf {
install_root().join("versions")
}
pub fn current_version_link() -> PathBuf {
install_root().join("current")
}
pub fn updater_lock_path() -> PathBuf {
IotaPaths::resolve(Scope::User)
.expect("resolve Iota user paths")
.update_lock_file()
.expect("runtime directory")
}
pub fn updater_status_path() -> PathBuf {
IotaPaths::resolve(Scope::User)
.expect("resolve Iota user paths")
.update_status_file()
}
pub fn updater_staging_dir() -> PathBuf {
IotaPaths::resolve(Scope::User)
.expect("resolve Iota user paths")
.update_staging_dir()
}
pub fn web_asset_dir() -> PathBuf {
IotaPaths::resolve(Scope::User)
.expect("resolve Iota user paths")
.asset_dir
}
pub fn daemon_executable() -> PathBuf {
absolute_env("IOTA_DAEMON_PATH")
.expect("valid IOTA_DAEMON_PATH")
.unwrap_or_else(|| install_root().join("current/bin/iota-daemon"))
}
pub fn updater_executable() -> PathBuf {
absolute_env("IOTA_UPDATER_PATH")
.expect("valid IOTA_UPDATER_PATH")
.unwrap_or_else(|| install_root().join("current/bin/iota-updater"))
}
pub fn daemon_endpoints() -> Vec<PathBuf> {
vec![socket_path(Scope::User), socket_path(Scope::System)]
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{
Mutex,
atomic::{AtomicU64, Ordering},
};
static TEST_ID: AtomicU64 = AtomicU64::new(0);
static ENVIRONMENT: Mutex<()> = Mutex::new(());
#[test]
fn system_layout_is_fhs() {
let p = IotaPaths::resolve(Scope::System).unwrap();
assert_eq!(p.config_file, PathBuf::from("/etc/iota/config.yaml"));
assert_eq!(
p.database_file(),
PathBuf::from("/var/lib/iota/storage/messages.sqlite3")
);
assert_eq!(
p.update_staging_dir(),
PathBuf::from("/var/cache/iota/updates/staging")
);
}
#[test]
fn migration_moves_state_resources_without_overwriting_destination() {
let root = std::env::temp_dir().join(format!(
"iota-paths-test-{}-{}",
std::process::id(),
TEST_ID.fetch_add(1, Ordering::Relaxed)
));
let state = root.join("state");
let config = root.join("config");
let paths = IotaPaths {
scope: Scope::User,
config_dir: config.clone(),
config_file: config.join("config.yaml"),
storage_dir: state.join("storage"),
identity_dir: state.join("identity"),
cache_dir: root.join("cache"),
runtime_dir: Some(root.join("runtime")),
log_dir: state.join("logs"),
asset_dir: root.join("data/web"),
install_root: root.join("bin"),
ipc_endpoint: IpcEndpoint::UnixSocket(root.join("runtime/iota.sock")),
state_dir: state.clone(),
};
std::fs::create_dir_all(state.join("users")).unwrap();
std::fs::write(state.join("messages.sqlite3"), b"db").unwrap();
std::fs::write(state.join("config.yaml"), b"web: {}\n").unwrap();
paths.migrate_legacy_layout().unwrap();
assert!(paths.database_file().is_file());
assert!(paths.storage_dir.join("users").is_dir());
assert!(paths.config_file.is_file());
assert!(state.join("path-layout-v2.json").is_file());
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn data_root_keeps_unmanaged_user_paths_together() {
let _guard = ENVIRONMENT.lock().unwrap();
let root = std::env::temp_dir().join(format!(
"iota-data-root-test-{}-{}",
std::process::id(),
TEST_ID.fetch_add(1, Ordering::Relaxed)
));
unsafe {
std::env::set_var("IOTA_DATA_ROOT", &root);
}
let paths = IotaPaths::resolve(Scope::User).unwrap();
unsafe {
std::env::remove_var("IOTA_DATA_ROOT");
}
assert_eq!(paths.config_file, root.join("config/config.yaml"));
assert_eq!(paths.state_dir, root.join("state"));
assert_eq!(paths.cache_dir, root.join("cache"));
assert_eq!(paths.log_dir, root.join("logs"));
assert_eq!(paths.runtime_dir, Some(root.join("runtime")));
assert_eq!(
paths.ipc_endpoint,
IpcEndpoint::UnixSocket(root.join("runtime/iota.sock"))
);
}
}

View file

@ -1,12 +0,0 @@
[package]
name = "iota-process-manager"
version = "0.1.0"
edition = "2024"
[dependencies]
async-trait = "0.1"
tokio = { version = "1.50", features = ["process", "time", "io-util", "macros", "rt"] }
[dev-dependencies]
libc = "0.2"
tempfile = "3"

View file

@ -1,628 +0,0 @@
use async_trait::async_trait;
use std::{
fmt::{Display, Formatter},
sync::Arc,
};
pub const PROCESS_MANAGER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct UnitStatus {
pub active: bool,
pub enabled: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StartupMode {
AlwaysOn,
SocketActivated,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProcessAction {
Start,
Stop,
Restart,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DetectedStartupMode {
AlwaysOn,
SocketActivated,
Disabled,
Conflicting,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DaemonStartupStatus {
pub service: UnitStatus,
pub socket: UnitStatus,
pub detected: DetectedStartupMode,
}
impl DaemonStartupStatus {
pub fn classify(service: UnitStatus, socket: UnitStatus) -> Self {
let detected = match (service.enabled, socket.enabled) {
(true, false) => DetectedStartupMode::AlwaysOn,
(false, true) => DetectedStartupMode::SocketActivated,
(false, false) => DetectedStartupMode::Disabled,
(true, true) => DetectedStartupMode::Conflicting,
};
Self {
service,
socket,
detected,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ProcessManagerErrorKind {
CommandUnavailable,
PermissionDenied,
UnitMissing,
CommandFailed,
ParseFailed,
VerificationFailed,
TimedOut,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProcessManagerError {
pub kind: ProcessManagerErrorKind,
message: String,
}
impl ProcessManagerError {
pub fn new(kind: ProcessManagerErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
}
}
pub fn kind(&self) -> ProcessManagerErrorKind {
self.kind
}
}
impl Display for ProcessManagerError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for ProcessManagerError {}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CommandOutput {
pub success: bool,
pub stdout: String,
pub stderr: String,
}
#[async_trait]
pub trait CommandExecutor: Send + Sync {
async fn output(
&self,
program: &str,
args: &[&str],
) -> Result<CommandOutput, ProcessManagerError>;
}
#[async_trait]
pub trait ProcessManager: Send + Sync {
fn name(&self) -> &'static str;
async fn unit_status(&self, unit: &str) -> Result<UnitStatus, ProcessManagerError>;
async fn set_iota_startup_mode(
&self,
mode: StartupMode,
) -> Result<DaemonStartupStatus, ProcessManagerError>;
async fn iota_startup_status(&self) -> Result<DaemonStartupStatus, ProcessManagerError> {
Ok(DaemonStartupStatus::classify(
self.unit_status("iota-daemon.service").await?,
self.unit_status("iota-daemon.socket").await?,
))
}
async fn enable_startup(
&self,
mode: StartupMode,
) -> Result<DaemonStartupStatus, ProcessManagerError> {
self.set_iota_startup_mode(mode).await
}
async fn disable_startup(&self) -> Result<DaemonStartupStatus, ProcessManagerError> {
self.set_iota_startup_mode(StartupMode::SocketActivated)
.await
}
async fn process_action(
&self,
action: ProcessAction,
) -> Result<DaemonStartupStatus, ProcessManagerError> {
let unit = "iota-daemon.service";
match action {
ProcessAction::Start => self.unit_action(&["start", unit]).await?,
ProcessAction::Stop => self.unit_action(&["stop", unit]).await?,
ProcessAction::Restart => self.unit_action(&["restart", unit]).await?,
}
self.iota_startup_status().await
}
async fn unit_action(&self, _action: &[&str]) -> Result<(), ProcessManagerError> {
Err(ProcessManagerError::new(
ProcessManagerErrorKind::CommandFailed,
"process actions are unsupported",
))
}
}
pub async fn detect() -> Option<Arc<dyn ProcessManager>> {
#[cfg(target_os = "linux")]
{
systemd::SystemdManager::detect()
.await
.map(|m| Arc::new(m) as Arc<dyn ProcessManager>)
}
#[cfg(not(target_os = "linux"))]
{
None
}
}
#[cfg(target_os = "linux")]
mod systemd {
use super::*;
use std::{path::Path, process::Stdio};
use tokio::{io::AsyncRead, process::Command, time::timeout};
const SERVICE: &str = "iota-daemon.service";
const SOCKET: &str = "iota-daemon.socket";
const COMMON: [&str; 2] = ["--no-pager", "--no-ask-password"];
const MAX_COMMAND_OUTPUT_BYTES: usize = 1024 * 1024;
pub struct RealExecutor;
async fn read_bounded<R>(reader: R) -> std::io::Result<Vec<u8>>
where
R: AsyncRead + Unpin,
{
use tokio::io::AsyncReadExt;
let mut output = Vec::new();
reader
.take((MAX_COMMAND_OUTPUT_BYTES + 1) as u64)
.read_to_end(&mut output)
.await?;
if output.len() > MAX_COMMAND_OUTPUT_BYTES {
output.truncate(MAX_COMMAND_OUTPUT_BYTES);
}
Ok(output)
}
async fn collect_output(
stdout: tokio::process::ChildStdout,
stderr: tokio::process::ChildStderr,
) -> Result<(Vec<u8>, Vec<u8>), ProcessManagerError> {
let (stdout_result, stderr_result) =
tokio::join!(read_bounded(stdout), read_bounded(stderr));
let stdout = stdout_result.map_err(|error| {
ProcessManagerError::new(
ProcessManagerErrorKind::CommandFailed,
format!("stdout read failed: {error}"),
)
})?;
let stderr = stderr_result.map_err(|error| {
ProcessManagerError::new(
ProcessManagerErrorKind::CommandFailed,
format!("stderr read failed: {error}"),
)
})?;
Ok((stdout, stderr))
}
impl RealExecutor {
async fn output_with_timeout(
&self,
program: &str,
args: &[&str],
process_timeout: std::time::Duration,
) -> Result<CommandOutput, ProcessManagerError> {
let mut child = Command::new(program)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(|e| {
ProcessManagerError::new(
ProcessManagerErrorKind::CommandUnavailable,
format!("Could not run {program}: {e}"),
)
})?;
let stdout = child.stdout.take().ok_or_else(|| {
ProcessManagerError::new(
ProcessManagerErrorKind::CommandFailed,
"command stdout pipe was not created",
)
})?;
let stderr = child.stderr.take().ok_or_else(|| {
ProcessManagerError::new(
ProcessManagerErrorKind::CommandFailed,
"command stderr pipe was not created",
)
})?;
let output_task = tokio::spawn(collect_output(stdout, stderr));
let status = match timeout(process_timeout, child.wait()).await {
Ok(result) => result.map_err(|e| {
ProcessManagerError::new(ProcessManagerErrorKind::CommandFailed, e.to_string())
})?,
Err(_) => {
// Keep the Child alive across the timeout. Explicitly
// terminate it and await wait() so the OS child is
// reaped before reporting the timeout.
let kill_error = child.start_kill().err();
let wait_error = child.wait().await.err();
output_task.abort();
let _ = output_task.await;
if let Some(error) = wait_error {
return Err(ProcessManagerError::new(
ProcessManagerErrorKind::CommandFailed,
format!("{program} timed out and could not be reaped: {error}"),
));
}
let termination_detail = kill_error
.map(|error| format!("; termination request reported: {error}"))
.unwrap_or_default();
return Err(ProcessManagerError::new(
ProcessManagerErrorKind::TimedOut,
format!(
"{program} timed out after {} seconds{termination_detail}",
process_timeout.as_secs(),
),
));
}
};
let (stdout, stderr) = output_task.await.map_err(|error| {
ProcessManagerError::new(
ProcessManagerErrorKind::CommandFailed,
format!("command output task failed: {error}"),
)
})??;
Ok(CommandOutput {
success: status.success(),
stdout: String::from_utf8_lossy(&stdout).into_owned(),
stderr: String::from_utf8_lossy(&stderr).into_owned(),
})
}
}
#[async_trait]
impl CommandExecutor for RealExecutor {
async fn output(
&self,
program: &str,
args: &[&str],
) -> Result<CommandOutput, ProcessManagerError> {
self.output_with_timeout(program, args, PROCESS_MANAGER_TIMEOUT)
.await
}
}
pub struct SystemdManager {
executor: Arc<dyn CommandExecutor>,
service: &'static str,
socket: &'static str,
}
impl SystemdManager {
pub async fn detect() -> Option<Self> {
if !Path::new("/run/systemd/system").is_dir() {
return None;
}
let executor: Arc<dyn CommandExecutor> = Arc::new(RealExecutor);
let mut manager = executor
.output("systemctl", &["--version", &COMMON[0], &COMMON[1]])
.await
.ok()
.filter(|r| r.success)
.map(|_| Self {
executor,
service: SERVICE,
socket: SOCKET,
})?;
if manager.status("iota.service").await.is_ok() {
manager.service = "iota.service";
manager.socket = "iota.socket";
}
Some(manager)
}
#[cfg(test)]
pub fn with_executor(executor: Arc<dyn CommandExecutor>) -> Self {
Self {
executor,
service: SERVICE,
socket: SOCKET,
}
}
async fn run(&self, action: &[&str]) -> Result<(), ProcessManagerError> {
let mut args = COMMON.to_vec();
args.extend_from_slice(action);
let output = self.executor.output("systemctl", &args).await?;
if output.success {
return Ok(());
}
let detail = if output.stderr.trim().is_empty() {
output.stdout.trim()
} else {
output.stderr.trim()
};
let kind = if detail.to_ascii_lowercase().contains("access denied")
|| detail.to_ascii_lowercase().contains("permission denied")
{
ProcessManagerErrorKind::PermissionDenied
} else {
ProcessManagerErrorKind::CommandFailed
};
Err(ProcessManagerError::new(
kind,
if detail.is_empty() {
format!("systemctl {} failed", action.join(" "))
} else {
detail.to_owned()
},
))
}
async fn status(&self, unit: &str) -> Result<UnitStatus, ProcessManagerError> {
let mut args = COMMON.to_vec();
args.extend_from_slice(&[
"show",
"--property=LoadState",
"--property=ActiveState",
"--property=UnitFileState",
"--value",
unit,
]);
let output = self.executor.output("systemctl", &args).await?;
if !output.success {
let detail = if output.stderr.trim().is_empty() {
output.stdout.trim()
} else {
output.stderr.trim()
};
let kind = if detail.to_ascii_lowercase().contains("denied") {
ProcessManagerErrorKind::PermissionDenied
} else {
ProcessManagerErrorKind::CommandFailed
};
return Err(ProcessManagerError::new(
kind,
format!("systemctl could not inspect {unit}: {detail}"),
));
}
let values: Vec<_> = output.stdout.lines().map(str::trim).collect();
if values.len() < 3 {
return Err(ProcessManagerError::new(
ProcessManagerErrorKind::ParseFailed,
format!("systemctl returned incomplete state for {unit}"),
));
}
if values[0] == "not-found" {
return Err(ProcessManagerError::new(
ProcessManagerErrorKind::UnitMissing,
format!("systemd unit {unit} was not found"),
));
}
if values[0] != "loaded" {
return Err(ProcessManagerError::new(
ProcessManagerErrorKind::ParseFailed,
format!("unsupported LoadState `{}` for {unit}", values[0]),
));
}
let active = match values[1] {
"active" => true,
"inactive" | "failed" | "activating" | "deactivating" | "reloading" => false,
v => {
return Err(ProcessManagerError::new(
ProcessManagerErrorKind::ParseFailed,
format!("unsupported ActiveState `{v}` for {unit}"),
));
}
};
let enabled = match values[2] {
"enabled" | "enabled-runtime" => true,
"disabled" | "static" | "indirect" | "masked" | "generated" | "transient" => false,
v => {
return Err(ProcessManagerError::new(
ProcessManagerErrorKind::ParseFailed,
format!("unsupported UnitFileState `{v}` for {unit}"),
));
}
};
Ok(UnitStatus { active, enabled })
}
async fn verify(
&self,
expected: DetectedStartupMode,
) -> Result<DaemonStartupStatus, ProcessManagerError> {
let status = self.iota_startup_status().await?;
if status.detected == expected {
Ok(status)
} else {
Err(ProcessManagerError::new(
ProcessManagerErrorKind::VerificationFailed,
format!(
"systemd reported {:?} after applying {:?}",
status.detected, expected
),
))
}
}
}
#[async_trait]
impl ProcessManager for SystemdManager {
fn name(&self) -> &'static str {
"systemd"
}
async fn unit_status(&self, unit: &str) -> Result<UnitStatus, ProcessManagerError> {
self.status(unit).await
}
async fn iota_startup_status(&self) -> Result<DaemonStartupStatus, ProcessManagerError> {
Ok(DaemonStartupStatus::classify(
self.status(self.service).await?,
self.status(self.socket).await?,
))
}
async fn set_iota_startup_mode(
&self,
mode: StartupMode,
) -> Result<DaemonStartupStatus, ProcessManagerError> {
match mode {
StartupMode::AlwaysOn => {
self.run(&["disable", self.socket]).await?;
self.run(&["enable", "--now", self.service]).await?;
self.verify(DetectedStartupMode::AlwaysOn).await
}
StartupMode::SocketActivated => {
self.run(&["disable", "--now", self.service]).await?;
self.run(&["enable", "--now", self.socket]).await?;
self.verify(DetectedStartupMode::SocketActivated).await
}
}
}
async fn unit_action(&self, action: &[&str]) -> Result<(), ProcessManagerError> {
self.run(action).await
}
async fn process_action(
&self,
action: ProcessAction,
) -> Result<DaemonStartupStatus, ProcessManagerError> {
let verb = match action {
ProcessAction::Start => "start",
ProcessAction::Stop => "stop",
ProcessAction::Restart => "restart",
};
self.run(&[verb, self.service]).await?;
self.iota_startup_status().await
}
async fn disable_startup(&self) -> Result<DaemonStartupStatus, ProcessManagerError> {
self.run(&["disable", "--now", self.service]).await?;
self.run(&["disable", "--now", self.socket]).await?;
self.verify(DetectedStartupMode::Disabled).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
struct Fake {
calls: Mutex<Vec<Vec<String>>>,
results: Mutex<Vec<CommandOutput>>,
}
#[async_trait]
impl CommandExecutor for Fake {
async fn output(
&self,
_: &str,
args: &[&str],
) -> Result<CommandOutput, ProcessManagerError> {
self.calls
.lock()
.unwrap()
.push(args.iter().map(|arg| (*arg).to_owned()).collect());
Ok(self.results.lock().unwrap().remove(0))
}
}
fn ok(stdout: &str) -> CommandOutput {
CommandOutput {
success: true,
stdout: stdout.into(),
stderr: String::new(),
}
}
#[tokio::test]
async fn every_systemctl_operation_disables_interactive_features() {
let fake = Arc::new(Fake {
calls: Mutex::new(Vec::new()),
results: Mutex::new(vec![ok("loaded\nactive\nenabled\n")]),
});
let manager = SystemdManager::with_executor(fake.clone());
manager.unit_status(SERVICE).await.unwrap();
let call = &fake.calls.lock().unwrap()[0];
assert!(call.contains(&"--no-pager".into()));
assert!(call.contains(&"--no-ask-password".into()));
}
#[tokio::test]
async fn timed_out_real_child_is_terminated_and_reaped() {
use std::fs;
use std::time::Duration;
let directory = tempfile::tempdir().unwrap();
let pid_file = directory.path().join("child.pid");
let script = format!(
"printf '%s' \"$$\" > '{}'; exec sleep 60",
pid_file.display()
);
let executor = RealExecutor;
let task = tokio::spawn(async move {
executor
.output_with_timeout("sh", &["-c", &script], Duration::from_millis(50))
.await
});
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
let pid = loop {
if let Ok(contents) = fs::read_to_string(&pid_file) {
if let Ok(pid) = contents.parse::<libc::pid_t>() {
break pid;
}
}
assert!(tokio::time::Instant::now() < deadline);
tokio::task::yield_now().await;
};
let result = task.await.unwrap();
assert_eq!(
result.unwrap_err().kind(),
ProcessManagerErrorKind::TimedOut
);
assert!(!std::path::Path::new(&format!("/proc/{pid}")).exists());
let mut status = 0;
let wait_result = unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) };
assert_eq!(wait_result, -1);
assert_eq!(
std::io::Error::last_os_error().raw_os_error(),
Some(libc::ECHILD)
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn modes_distinct() {
assert_ne!(StartupMode::AlwaysOn, StartupMode::SocketActivated);
}
struct BlockingExecutor;
#[async_trait::async_trait]
impl CommandExecutor for BlockingExecutor {
async fn output(&self, _: &str, _: &[&str]) -> Result<CommandOutput, ProcessManagerError> {
std::future::pending().await
}
}
#[tokio::test]
async fn executor_future_can_be_cancelled_without_blocking_runtime() {
let result = tokio::time::timeout(
std::time::Duration::from_millis(20),
BlockingExecutor.output("systemctl", &["show"]),
)
.await;
assert!(result.is_err());
}
}

View file

@ -1,15 +0,0 @@
[package]
name = "iota-state"
version = "0.1.0"
edition = "2024"
[features]
default = ["legacy-globals"]
legacy-globals = []
[dependencies]
dashmap = "6.1.0"
once_cell = "1.21.3"
tokio = { version = "1.50.0", features = ["full"] }
json = "*"
sysinfo = "0.38.0"

View file

@ -1,308 +0,0 @@
use dashmap::DashSet;
use json::{JsonValue, object};
#[cfg(feature = "legacy-globals")]
use once_cell::sync::Lazy;
use std::collections::VecDeque;
#[cfg(feature = "legacy-globals")]
use std::sync::LazyLock;
use std::sync::{Arc, Mutex, atomic::AtomicBool};
#[cfg(feature = "legacy-globals")]
use std::thread;
#[cfg(feature = "legacy-globals")]
use std::time::Duration;
#[cfg(feature = "legacy-globals")]
use sysinfo::{RefreshKind, System};
use tokio::sync::{Mutex as TokioMutex, RwLock};
/* Process-owned daemon state and TUI-local state must be separate because IPC,
* rather than shared memory, is the boundary between the two binaries. */
#[derive(Clone)]
pub struct DaemonState {
pub app: Arc<Mutex<AppState>>,
pub shutdown: Arc<RwLock<bool>>,
pub reload: Arc<RwLock<bool>>,
pub active_tasks: Arc<DashSet<String>>,
}
impl DaemonState {
pub fn new() -> Self {
Self {
app: Arc::new(Mutex::new(AppState::new())),
shutdown: Arc::new(RwLock::new(false)),
reload: Arc::new(RwLock::new(false)),
active_tasks: Arc::new(DashSet::new()),
}
}
}
impl Default for DaemonState {
fn default() -> Self {
Self::new()
}
}
/* The TUI keeps only the daemon data it renders. This state is never shared
* with the daemon and is populated from daemon IPC messages. */
#[derive(Clone)]
pub struct ClientState {
pub app: Arc<TokioMutex<AppState>>,
}
impl ClientState {
pub fn new() -> Self {
Self {
app: Arc::new(TokioMutex::new(AppState::new())),
}
}
}
impl Default for ClientState {
fn default() -> Self {
Self::new()
}
}
pub const MAX_POINTS: usize = 1000;
pub const MAX_LOGS: usize = 100;
pub static UNIQUE: AtomicBool = AtomicBool::new(true);
#[derive(Clone, Debug)]
pub struct UiLogEntry {
pub timestamp_ms: u128,
pub sender: String,
pub message: String,
pub is_error: bool,
}
impl UiLogEntry {
pub fn format_timestamp(&self) -> String {
let secs = (self.timestamp_ms / 1000) as i64;
let hours = (secs / 3600) % 24;
let minutes = (secs / 60) % 60;
let seconds = secs % 60;
format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
}
}
#[derive(Clone)]
pub struct AppState {
pub logs: VecDeque<UiLogEntry>,
pub cpu: Vec<(f64, f64)>,
pub ram: Vec<(f64, f64)>,
pub ping: Vec<(f64, f64)>,
pub net_up: Vec<(f64, f64)>,
pub net_down: Vec<(f64, f64)>,
pub sys_info: String,
next_sample_id: u64,
}
impl AppState {
pub fn new() -> Self {
Self {
logs: VecDeque::new(),
cpu: Vec::new(),
ram: Vec::new(),
ping: Vec::new(),
net_up: Vec::new(),
net_down: Vec::new(),
sys_info: String::from("Loading..."),
next_sample_id: 0,
}
}
pub fn push_log(&mut self, msg: UiLogEntry) {
if self.logs.len() >= MAX_LOGS {
self.logs.pop_front();
}
self.logs.push_back(msg);
}
pub fn get_logs(&self) -> &VecDeque<UiLogEntry> {
&self.logs
}
pub fn push_cpu(&mut self, pt: (f64, f64)) {
let x = self.next_sample();
self.cpu.push((x, pt.1));
if self.cpu.len() > MAX_POINTS {
self.cpu.remove(0);
}
}
pub fn push_ram(&mut self, pt: (f64, f64)) {
let x = self.next_sample();
self.ram.push((x, pt.1));
if self.ram.len() > MAX_POINTS {
self.ram.remove(0);
}
}
pub fn push_ping_val(&mut self, pt: f64) {
let x = self.next_sample();
self.ping.push((x, pt));
if self.ping.len() > MAX_POINTS {
self.ping.remove(0);
}
}
pub fn push_net_up(&mut self, pt: (f64, f64)) {
let x = self.next_sample();
self.net_up.push((x, pt.1));
if self.net_up.len() > MAX_POINTS {
self.net_up.remove(0);
}
}
pub fn push_net_down(&mut self, pt: (f64, f64)) {
let x = self.next_sample();
self.net_down.push((x, pt.1));
if self.net_down.len() > MAX_POINTS {
self.net_down.remove(0);
}
}
fn next_sample(&mut self) -> f64 {
let value = self.next_sample_id as f64;
self.next_sample_id = self.next_sample_id.saturating_add(1);
value
}
pub fn to_json(&self) -> JsonValue {
object! {
"cpu" => self.cpu.iter().map(|(_, y)| *y).collect::<Vec<f64>>(),
"ram" => self.ram.iter().map(|(_, y)| *y).collect::<Vec<f64>>(),
"ping" => self.ping.iter().map(|(_, y)| *y).collect::<Vec<f64>>(),
"net_up" => self.net_up.iter().map(|(_, y)| *y).collect::<Vec<f64>>(),
"net_down" => self.net_down.iter().map(|(_, y)| *y).collect::<Vec<f64>>(),
}
}
pub fn with_width(&self, width: u16) -> Self {
let mut new = self.clone();
new.cpu = Self::downsample_to_fit_width(&new.cpu, width);
new.ram = Self::downsample_to_fit_width(&new.ram, width);
new.ping = Self::downsample_to_fit_width(&new.ping, width);
new.net_up = Self::downsample_to_fit_width(&new.net_up, width);
new.net_down = Self::downsample_to_fit_width(&new.net_down, width);
new
}
fn downsample_to_fit_width(data: &[(f64, f64)], width: u16) -> Vec<(f64, f64)> {
let width_usize = (width as usize) * 2;
let len = data.len();
if len >= width_usize {
data[len - width_usize..].to_vec()
} else {
let mut result = Vec::with_capacity(width_usize);
let dx = 1.0;
let pad_len = width_usize - len;
let start_x = data
.first()
.map(|(x, _)| x - (dx * pad_len as f64))
.unwrap_or(0.0);
for i in 0..pad_len {
result.push((start_x + i as f64 * dx, -1.0));
}
result.extend_from_slice(data);
result
}
}
}
#[cfg(feature = "legacy-globals")]
#[deprecated(note = "use DaemonState or ClientState")]
pub static APP_STATE: LazyLock<Arc<Mutex<AppState>>> =
LazyLock::new(|| Arc::new(Mutex::new(AppState::new())));
#[cfg(feature = "legacy-globals")]
#[deprecated(note = "use DaemonState")]
pub static SHUTDOWN: Lazy<RwLock<bool>> = Lazy::new(|| RwLock::new(false));
#[cfg(feature = "legacy-globals")]
#[deprecated(note = "use DaemonState")]
pub static RELOAD: Lazy<RwLock<bool>> = Lazy::new(|| RwLock::new(true));
#[cfg(feature = "legacy-globals")]
#[deprecated(note = "use DaemonState")]
pub static ACTIVE_TASKS: Lazy<DashSet<String>> = Lazy::new(|| DashSet::new());
#[cfg(feature = "legacy-globals")]
pub fn setup(state: &DaemonState) {
state.active_tasks.insert("System info loader".to_string());
let state = state.clone();
tokio::spawn(async move {
let mut sys = System::new_with_specifics(RefreshKind::everything());
let mut last_total_received = 0u64;
let mut last_total_transmitted = 0u64;
let mut counter = 0.0;
loop {
if *state.shutdown.read().await {
break;
}
sys.refresh_all();
let mut tcpu = 0;
for cpu in sys.cpus() {
tcpu += cpu.cpu_usage() as i64;
tcpu /= 2;
}
let ram = (sys.used_memory() as f64 / sys.total_memory() as f64) * 100.0;
let total_received = 0u64;
let total_transmitted = 0u64;
let delta_received = if last_total_received == 0 {
0
} else {
total_received.saturating_sub(last_total_received)
};
let delta_transmitted = if last_total_transmitted == 0 {
0
} else {
total_transmitted.saturating_sub(last_total_transmitted)
};
last_total_received = total_received;
last_total_transmitted = total_transmitted;
let net_down = delta_received as f64;
let net_up = delta_transmitted as f64;
{
let mut st = state.app.lock().unwrap();
st.push_cpu((counter, tcpu as f64));
st.push_ram((counter, ram));
st.push_net_down((counter, net_down));
st.push_net_up((counter, net_up));
st.sys_info = format!("NetDown: {} NetUp: {}", delta_received, delta_transmitted);
}
counter += 1.0;
if counter > 30.0 {
thread::sleep(Duration::from_millis(500));
} else {
thread::sleep(Duration::from_millis(5));
}
}
state.active_tasks.remove("System info loader");
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn metric_coordinates_remain_monotonic_after_history_rollover() {
let mut state = AppState::new();
for value in 0..(MAX_POINTS + 25) {
state.push_ping_val(value as f64);
}
assert_eq!(state.ping.len(), MAX_POINTS);
assert!(state.ping.windows(2).all(|pair| pair[0].0 < pair[1].0));
}
}

Some files were not shown because too many files have changed in this diff Show more