[WIP] Daemon & CLI

This commit is contained in:
Alex-Emmet 2026-07-23 23:13:02 +02:00
commit 8b158108bb
100 changed files with 6519 additions and 1596 deletions

183
Cargo.lock generated
View file

@ -198,7 +198,7 @@ dependencies = [
"foldhash",
"futures-core",
"futures-util",
"impl-more 0.3.1",
"impl-more 0.3.2",
"itoa",
"language-tags",
"log",
@ -407,7 +407,7 @@ checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.1",
"syn 3.0.3",
]
[[package]]
@ -1558,9 +1558,9 @@ dependencies = [
[[package]]
name = "glob"
version = "0.3.3"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
[[package]]
name = "h2"
@ -1839,9 +1839,9 @@ dependencies = [
[[package]]
name = "hyper"
version = "1.10.1"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
dependencies = [
"atomic-waker",
"bytes",
@ -2040,9 +2040,9 @@ checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2"
[[package]]
name = "impl-more"
version = "0.3.1"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a84fd5aa25fae5c0f4a33d9cac2ca017fc622cbd089be2229993514990f870"
checksum = "134d2c4324d61664107020b79019cf6a6aec153f0b79bc9619ee9e794a5fb021"
[[package]]
name = "indexmap"
@ -2090,6 +2090,11 @@ name = "iota"
version = "0.1.0"
dependencies = [
"iota-cli",
"iota-core",
"iota-installer",
"iota-ipc",
"iota-paths",
"iota-process-manager",
"tokio",
"tokio-util",
]
@ -2162,6 +2167,8 @@ dependencies = [
"hyper-util",
"iota-ipc",
"iota-logger",
"iota-paths",
"iota-process-manager",
"iota-state",
"iota-storage",
"iota-terms",
@ -2180,15 +2187,19 @@ dependencies = [
"rusqlite",
"rustls",
"rustls-pemfile",
"serde",
"serde_json",
"serde_yaml",
"sha2 0.10.9",
"strum 0.27.2",
"strum_macros 0.27.2",
"sysinfo",
"tempfile",
"tokio",
"tokio-tungstenite",
"tokio-util",
"tungstenite",
"unicode-width",
"walkdir",
"warp",
"x448",
@ -2204,6 +2215,31 @@ dependencies = [
"mtp",
]
[[package]]
name = "iota-core"
version = "0.1.0"
dependencies = [
"dashmap",
"iota-cli",
"iota-logger",
"iota-state",
"iota-storage",
"iota-terms",
"iota-updater",
"iota-util",
"json",
"mtp",
"omikron-connector",
"once_cell",
"pnet",
"ratatui",
"reqwest",
"tokio",
"tokio-util",
"web-server",
"web-ui",
]
[[package]]
name = "iota-daemon"
version = "0.1.0"
@ -2211,6 +2247,7 @@ dependencies = [
"iota-daemon-lib",
"iota-ipc",
"iota-logger",
"iota-paths",
"iota-state",
"iota-storage",
"omikron-connector",
@ -2223,6 +2260,7 @@ dependencies = [
name = "iota-daemon-lib"
version = "0.1.0"
dependencies = [
"async-trait",
"dashmap",
"iota-ipc",
"iota-logger",
@ -2233,11 +2271,23 @@ dependencies = [
"mtp",
"omikron-connector",
"sysinfo",
"tempfile",
"tokio",
"tokio-util",
"uuid",
]
[[package]]
name = "iota-installer"
version = "0.1.0"
dependencies = [
"anyhow",
"iota-paths",
"serde_json",
"tempfile",
"zip",
]
[[package]]
name = "iota-ipc"
version = "0.1.0"
@ -2260,6 +2310,18 @@ dependencies = [
"tokio",
]
[[package]]
name = "iota-paths"
version = "0.1.0"
[[package]]
name = "iota-process-manager"
version = "0.1.0"
dependencies = [
"async-trait",
"tokio",
]
[[package]]
name = "iota-state"
version = "0.1.0"
@ -2324,6 +2386,7 @@ dependencies = [
"aes-gcm",
"anyhow",
"base64",
"ed25519-dalek",
"hex",
"hkdf 0.12.4",
"iota-logger",
@ -2334,9 +2397,9 @@ dependencies = [
"rand_core 0.6.4",
"ratatui",
"reqwest",
"self-replace",
"semver",
"serde",
"serde_json",
"sha2 0.10.9",
"sysinfo",
"tempfile",
@ -2353,6 +2416,7 @@ version = "0.1.0"
dependencies = [
"base64",
"hex",
"iota-paths",
"mtp",
"reqwest",
"sysinfo",
@ -2543,9 +2607,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
[[package]]
name = "libc"
version = "0.2.186"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libm"
@ -2778,7 +2842,7 @@ dependencies = [
[[package]]
name = "mtp"
version = "0.2.0"
source = "git+https://git.methanium.net/Methanium/mtp.git#04760fd88d2bc3adf548a9ec532fa228227f0a49"
source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738"
dependencies = [
"mtp-client",
"mtp-codec",
@ -2794,7 +2858,7 @@ dependencies = [
[[package]]
name = "mtp-client"
version = "0.2.0"
source = "git+https://git.methanium.net/Methanium/mtp.git#04760fd88d2bc3adf548a9ec532fa228227f0a49"
source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738"
dependencies = [
"mtp-codec",
"mtp-common",
@ -2807,7 +2871,7 @@ dependencies = [
[[package]]
name = "mtp-codec"
version = "0.2.0"
source = "git+https://git.methanium.net/Methanium/mtp.git#04760fd88d2bc3adf548a9ec532fa228227f0a49"
source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738"
dependencies = [
"base64",
"byteorder",
@ -2820,7 +2884,7 @@ dependencies = [
[[package]]
name = "mtp-common"
version = "0.2.0"
source = "git+https://git.methanium.net/Methanium/mtp.git#04760fd88d2bc3adf548a9ec532fa228227f0a49"
source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738"
dependencies = [
"quinn",
"rustls",
@ -2831,7 +2895,7 @@ dependencies = [
[[package]]
name = "mtp-crypto"
version = "0.2.0"
source = "git+https://git.methanium.net/Methanium/mtp.git#04760fd88d2bc3adf548a9ec532fa228227f0a49"
source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738"
dependencies = [
"base64",
"chacha20poly1305",
@ -2853,7 +2917,7 @@ dependencies = [
[[package]]
name = "mtp-files"
version = "0.2.0"
source = "git+https://git.methanium.net/Methanium/mtp.git#04760fd88d2bc3adf548a9ec532fa228227f0a49"
source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738"
dependencies = [
"mtp-crypto",
"rand 0.10.2",
@ -2864,7 +2928,7 @@ dependencies = [
[[package]]
name = "mtp-host"
version = "0.2.0"
source = "git+https://git.methanium.net/Methanium/mtp.git#04760fd88d2bc3adf548a9ec532fa228227f0a49"
source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738"
dependencies = [
"mtp-codec",
"mtp-common",
@ -2879,7 +2943,7 @@ dependencies = [
[[package]]
name = "mtp-transport"
version = "0.2.0"
source = "git+https://git.methanium.net/Methanium/mtp.git#04760fd88d2bc3adf548a9ec532fa228227f0a49"
source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738"
dependencies = [
"async-trait",
"mtp-codec",
@ -2897,7 +2961,7 @@ dependencies = [
[[package]]
name = "mtp-type-map"
version = "0.2.0"
source = "git+https://git.methanium.net/Methanium/mtp.git#04760fd88d2bc3adf548a9ec532fa228227f0a49"
source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738"
dependencies = [
"serde",
"serde_yaml",
@ -2906,7 +2970,7 @@ dependencies = [
[[package]]
name = "mtp-webserver"
version = "0.2.0"
source = "git+https://git.methanium.net/Methanium/mtp.git#04760fd88d2bc3adf548a9ec532fa228227f0a49"
source = "git+https://git.methanium.net/Methanium/mtp.git#00f0aaeeff802c716f143fefe0117470cc5a1738"
dependencies = [
"async-trait",
"bytes",
@ -2914,6 +2978,9 @@ dependencies = [
"h3-quinn",
"h3-webtransport",
"http 1.4.2",
"http-body-util",
"hyper",
"hyper-util",
"mtp-codec",
"mtp-common",
"mtp-crypto",
@ -2924,6 +2991,8 @@ dependencies = [
"rustls",
"thiserror 2.0.19",
"tokio",
"tokio-rustls",
"tokio-stream",
"tracing",
]
@ -3074,6 +3143,7 @@ dependencies = [
name = "omikron-connector"
version = "0.1.0"
dependencies = [
"async-trait",
"base64",
"dashmap",
"hex",
@ -3303,9 +3373,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pest"
version = "2.8.7"
version = "2.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9"
checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2"
dependencies = [
"memchr",
"ucd-trie",
@ -3313,9 +3383,9 @@ dependencies = [
[[package]]
name = "pest_derive"
version = "2.8.7"
version = "2.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58"
checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd"
dependencies = [
"pest",
"pest_generator",
@ -3323,9 +3393,9 @@ dependencies = [
[[package]]
name = "pest_generator"
version = "2.8.7"
version = "2.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7"
checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6"
dependencies = [
"pest",
"pest_meta",
@ -3336,9 +3406,9 @@ dependencies = [
[[package]]
name = "pest_meta"
version = "2.8.7"
version = "2.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210"
checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c"
dependencies = [
"pest",
]
@ -4194,17 +4264,6 @@ dependencies = [
"libc",
]
[[package]]
name = "self-replace"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7"
dependencies = [
"fastrand",
"tempfile",
"windows-sys 0.52.0",
]
[[package]]
name = "semver"
version = "1.0.28"
@ -4238,14 +4297,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.1",
"syn 3.0.3",
]
[[package]]
name = "serde_json"
version = "1.0.150"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
@ -4585,9 +4644,9 @@ dependencies = [
[[package]]
name = "syn"
version = "3.0.1"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5edbec4ed188954a10c12c038215f8ce7606b2d5c973cd8dc43e8795065c5f2f"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
@ -4775,14 +4834,14 @@ checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.1",
"syn 3.0.3",
]
[[package]]
name = "time"
version = "0.3.53"
version = "0.3.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50"
checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244"
dependencies = [
"deranged",
"libc",
@ -4802,9 +4861,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.31"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
@ -4883,6 +4942,17 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.30.0"
@ -4899,14 +4969,15 @@ dependencies = [
[[package]]
name = "tokio-util"
version = "0.7.18"
version = "0.7.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"futures-util",
"libc",
"pin-project-lite",
"tokio",
]
@ -5789,18 +5860,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.54"
version = "0.8.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.54"
version = "0.8.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
dependencies = [
"proc-macro2",
"quote",

View file

@ -18,5 +18,9 @@ members = [
"web-ui",
"iota-logger",
"iota-util",
"iota-process-manager",
"iota-paths",
"iota-installer",
"iota-core",
]
resolver = "3"

View file

@ -4,3 +4,39 @@ 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.
# Linux daemon installation
The system-managed daemon runs as the dedicated `iota` account and listens on
`/run/iota/iota.sock` through socket activation. Operator access is granted
through the `iota-operators` group. 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. `IOTA_SOCKET` remains authoritative for custom deployments.

View file

@ -2,7 +2,6 @@ use dashmap::DashMap;
use iota_connection::message_common::*;
use iota_connection::message_handlers;
use iota_logger::{log_cv_in, log_cv_out, log_t};
use iota_state::SHUTDOWN;
use iota_storage::util::chat_files::{self, MessageState, change_message_state};
use iota_storage::util::config_util::CONFIG;
use iota_storage::util::e2ee_storage::{self, StoredChatSecret};
@ -31,6 +30,7 @@ pub struct ClientConnection {
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>>,
}
impl ClientConnection {
@ -45,6 +45,7 @@ impl ClientConnection {
u32,
Box<dyn Fn(Arc<ClientConnection>, CommunicationValue) -> bool + Send + Sync>,
>,
shutdown: Arc<RwLock<bool>>,
) -> Self {
Self {
sender,
@ -54,6 +55,7 @@ impl ClientConnection {
connection_id,
shutdown_tx,
waiting_tasks,
shutdown,
}
}
@ -61,7 +63,7 @@ impl ClientConnection {
let self_clone = self.clone();
tokio::spawn(async move {
while let Ok(cv) = self_clone.receiver.receive().await {
if *SHUTDOWN.read().await {
if *self_clone.shutdown.read().await {
return;
}

View file

@ -196,6 +196,7 @@
SocketGroup = "iota";
Backlog = 5;
RemoveOnStop = "true";
NonBlocking = true;
};
};
@ -206,7 +207,7 @@
serviceConfig =
{
Type = "notify";
Type = "simple";
User = "iota";
Group = "iota";
WorkingDirectory = cfg.dataDir;
@ -258,6 +259,7 @@
Environment = [
"BIND_ADDRESS=${cfg.bindAddress}"
"IOTA_SOCKET=/run/iota/iota.sock"
"IOTA_DATA_DIR=${cfg.dataDir}"
];
}
// lib.optionalAttrs (cfg.environmentFiles != []) {

View file

@ -0,0 +1 @@

View file

@ -19,6 +19,8 @@ iota-storage = { path = "../iota-storage", optional = true }
iota-terms = { path = "../iota-terms" }
iota-util = { path = "../iota-util", optional = true }
iota-ipc = { path = "../iota-ipc" }
iota-process-manager = { path = "../iota-process-manager" }
iota-paths = { path = "../iota-paths" }
omikron-connector = { path = "../omikron-connector", optional = true }
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", optional = true }
@ -59,6 +61,8 @@ rusqlite = "0.39.0"
rustls = { version = "0.23.37", features = ["aws-lc-rs"] }
rustls-pemfile = "2.2.0"
serde_json = "1.0.149"
serde = { version = "1", features = ["derive"] }
serde_yaml = "0.9"
sha2 = "0.10.9"
strum = "0.27.2"
strum_macros = "0.27.2"
@ -71,3 +75,7 @@ walkdir = "2.5.0"
warp = "*"
x448 = { version = "*" }
zip = "6.0.0"
unicode-width = "0.2"
[dev-dependencies]
tempfile = "3"

View file

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

View file

@ -0,0 +1,72 @@
use crate::theme::ResolvedTheme;
use ratatui::{
Frame,
layout::{Alignment, Rect},
text::Span,
widgets::{Block, Borders, 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(button.label, style))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL)),
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

@ -0,0 +1,135 @@
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

@ -0,0 +1,42 @@
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

@ -0,0 +1,6 @@
pub mod action;
pub mod button;
pub mod checkbox_group;
pub mod choice;
pub mod navigation;
pub mod radio_group;

View file

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

View file

@ -0,0 +1,194 @@
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

@ -2,7 +2,6 @@ use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{
Frame,
layout::Rect,
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
@ -18,6 +17,7 @@ use crate::{
elements::elements::{Element, InteractableElement, JoinableElement},
interaction_result::InteractionResult,
ipc_client::IpcClient,
render_context::RenderContext,
util::borders::draw_block_joins,
};
@ -34,6 +34,7 @@ pub struct ConsoleCard {
cursor: Arc<Mutex<bool>>,
last_swap: Arc<Mutex<Instant>>,
pending_restore: Arc<Mutex<Option<String>>>,
pending_confirmation: Option<String>,
}
impl ConsoleCard {
@ -49,6 +50,7 @@ impl ConsoleCard {
cursor: Arc::new(Mutex::new(true)),
last_swap: Arc::new(Mutex::new(Instant::now())),
pending_restore: Arc::new(Mutex::new(None)),
pending_confirmation: None,
}
}
@ -85,26 +87,25 @@ impl ConsoleCard {
}
}
fn cursor_spans(&self) -> Vec<Span<'static>> {
fn cursor_spans(&self, theme: &crate::theme::ResolvedTheme) -> Vec<Span<'static>> {
let cursor_visible = self.cursor_visible();
let cursor_style = Style::default().fg(Color::White).bg(Color::DarkGray);
let mut spans = Vec::new();
if self.content.is_empty() {
if self.focused {
if cursor_visible {
spans.push(Span::styled(" ", cursor_style));
Self::push_cursor(&mut spans, theme);
} else {
spans.push(Span::styled(" ", Style::default().fg(Color::White)));
spans.push(Span::styled(" ", theme.console.text));
}
spans.push(Span::styled(
"send command (/help for info)",
Style::default().fg(Color::DarkGray),
theme.console.hint,
));
} else {
spans.push(Span::styled(
" send command (/help for info)",
Style::default().fg(Color::DarkGray),
theme.console.hint,
));
}
return spans;
@ -119,52 +120,64 @@ impl ConsoleCard {
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(),
Self::style_for_part(true, false, false),
));
spans.push(Span::styled(prefix.to_string(), theme.console.prefix));
if !rest.is_empty() {
spans.push(Span::styled(
rest.to_string(),
Style::default().fg(Color::White),
));
spans.push(Span::styled(rest.to_string(), theme.console.text));
}
} else if !before.is_empty() {
spans.push(Span::styled(
before.clone(),
Style::default().fg(Color::White),
));
spans.push(Span::styled(before.clone(), theme.console.text));
}
if cursor_visible {
spans.push(Span::styled(" ", cursor_style));
Self::push_cursor(&mut spans, theme);
}
if !after.is_empty() {
spans.push(Span::styled(after, Style::default().fg(Color::White)));
spans.push(Span::styled(after, theme.console.text));
}
spans
}
fn style_for_part(is_prefix: bool, is_hint: bool, is_error: bool) -> Style {
if is_error {
return Style::default().fg(Color::Red);
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))
}
}
}
if is_hint {
return Style::default().fg(Color::DarkGray);
fn render_cursor_spans(&self, theme: &crate::theme::ResolvedTheme) -> Vec<Span<'static>> {
if let Some(command) = &self.pending_confirmation {
return vec![Span::styled(
format!("Confirm `{command}`? [y/N]"),
theme.console.confirmation,
)];
}
self.cursor_spans(theme)
}
if is_prefix {
return Style::default().fg(Color::DarkGray);
fn is_destructive(command: &str) -> bool {
matches!(
command.trim_start_matches('/').trim(),
"restart" | "reload" | "stop" | "shutdown" | "regenerate keys"
) || command
.trim_start_matches('/')
.trim_start()
.starts_with("user remove ")
}
Style::default().fg(Color::White)
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 render_cursor_spans(&self) -> Vec<Span<'static>> {
self.cursor_spans()
});
}
fn move_cursor_left(&mut self) {
@ -212,28 +225,34 @@ impl Element for ConsoleCard {
self
}
fn render(&self, f: &mut Frame, r: Rect) {
fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) {
let block = Block::default()
.borders(self.borders)
.title(self.title.clone())
.title_style(Style::default().fg(Color::White))
.title_style(context.theme.console.title)
.border_style(if self.focused {
Style::default().fg(Color::Yellow)
context.theme.console.focused_border
} else {
Style::default()
context.theme.console.border
})
.style(if self.focused {
Style::default().fg(Color::White)
} else {
Style::default()
});
.style(context.theme.console.text);
let spans = self.render_cursor_spans();
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);
draw_block_joins(
f,
r,
self.borders,
self.joins,
if self.focused {
context.theme.borders.focused
} else {
context.theme.borders.normal
},
);
}
}
@ -287,6 +306,13 @@ impl InteractableElement for ConsoleCard {
self.cursor_position = self.content.chars().count();
}
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() {
@ -294,16 +320,13 @@ impl InteractableElement for ConsoleCard {
}
let command = self.content.clone();
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);
}
});
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 => {

View file

@ -3,14 +3,16 @@ use std::any::Any;
use crossterm::event::KeyEvent;
use ratatui::{Frame, layout::Rect, widgets::Borders};
use crate::{interaction_result::InteractionResult, screens::screens::Screen};
use crate::{
interaction_result::InteractionResult, render_context::RenderContext, screens::screens::Screen,
};
#[allow(unused)]
pub trait Element: Send + Sync + Any {
fn as_any(&self) -> &dyn Any;
fn as_any_mut(&mut self) -> &mut dyn Any;
fn render(&self, f: &mut Frame, r: Rect);
fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>);
}
#[allow(unused)]

View file

@ -5,7 +5,6 @@ use iota_state::ClientState;
use ratatui::{
Frame,
layout::Rect,
style::{Color, Style},
widgets::{
Block, Borders,
canvas::{Canvas, Line},
@ -15,6 +14,7 @@ use ratatui::{
use crate::{
elements::elements::{Element, InteractableElement, JoinableElement},
interaction_result::InteractionResult,
render_context::RenderContext,
ui::UI,
util::borders::draw_block_joins,
};
@ -26,43 +26,30 @@ pub enum GRAPHS {
}
impl GRAPHS {
pub fn get_color(&self) -> Color {
pub fn get_color(&self, theme: &crate::theme::ResolvedTheme) -> ratatui::style::Color {
match self {
GRAPHS::Ram => Color::Blue,
GRAPHS::Cpu => Color::Red,
GRAPHS::Ping => Color::Green,
GRAPHS::Ram => theme.graphs.ram,
GRAPHS::Cpu => theme.graphs.cpu,
GRAPHS::Ping => theme.graphs.ping,
}
}
pub fn get_graph(&self, state: &ClientState) -> Vec<(f64, f64)> {
let state = match state.app.try_lock() {
Ok(state) => state,
Err(_) => return Vec::new(),
};
match self {
GRAPHS::Ram => state
.app
.lock()
.unwrap_or_else(|error| error.into_inner())
.with_width(28)
.ram
.clone(),
GRAPHS::Cpu => state
.app
.lock()
.unwrap_or_else(|error| error.into_inner())
.with_width(28)
.cpu
.clone(),
GRAPHS::Ping => state
.app
.lock()
.unwrap_or_else(|error| error.into_inner())
.with_width(28)
.ping
.clone(),
GRAPHS::Ram => state.with_width(28).ram.clone(),
GRAPHS::Cpu => state.with_width(28).cpu.clone(),
GRAPHS::Ping => state.with_width(28).ping.clone(),
}
}
pub fn get_unit(&self) -> String {
match self {
GRAPHS::Ram => "MB".to_string(),
// Memory is collected as a percentage of total RAM, not MiB.
GRAPHS::Ram => "%".to_string(),
GRAPHS::Cpu => "%".to_string(),
GRAPHS::Ping => "ms".to_string(),
}
@ -111,19 +98,24 @@ impl Element for GraphCard {
self
}
fn render(&self, f: &mut Frame, r: Rect) {
fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) {
if self.open {
let graph = self.graph_type.get_graph(&self.state);
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(-1.0, f64::max);
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 block = Block::default()
.title(format!(
@ -136,15 +128,15 @@ impl Element for GraphCard {
))
.borders(self.borders)
.border_style(if self.focused {
Style::default().fg(Color::Yellow)
context.theme.graphs.focused_border
} else {
Style::default()
context.theme.graphs.border
});
let canvas = Canvas::default()
.block(block)
.x_bounds([min_x, max_x])
.y_bounds([0.0, 100.0])
.y_bounds([0.0, y_upper])
.paint(|ctx| {
for (x, y) in &graph {
ctx.draw(&Line {
@ -152,7 +144,7 @@ impl Element for GraphCard {
y1: 0.0,
x2: *x,
y2: *y,
color: self.graph_type.get_color(),
color: self.graph_type.get_color(context.theme),
});
}
});
@ -162,13 +154,23 @@ impl Element for GraphCard {
.title("")
.borders(self.borders)
.border_style(if self.focused {
Style::default().fg(Color::Yellow)
context.theme.graphs.focused_border
} else {
Style::default()
context.theme.graphs.border
});
f.render_widget(block, r);
}
draw_block_joins(f, r, self.borders, self.joins);
draw_block_joins(
f,
r,
self.borders,
self.joins,
if self.focused {
context.theme.borders.focused
} else {
context.theme.borders.normal
},
);
}
}

View file

@ -1,16 +1,54 @@
use crate::elements::elements::{Element, InteractableElement, JoinableElement};
use crate::interaction_result::InteractionResult;
use crate::util::borders::draw_block_joins;
use crate::{interaction_result::InteractionResult, render_context::RenderContext};
use crossterm::event::{KeyCode, KeyEvent};
use iota_state::{ClientState, UiLogEntry};
use ratatui::{
Frame,
layout::Rect,
style::{Color, Style},
style::Style,
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use std::any::Any;
use unicode_width::UnicodeWidthChar;
#[derive(Clone, Copy)]
enum LogSource {
Call,
Client,
Iota,
Omikron,
Omega,
Command,
Other,
}
impl LogSource {
fn from_sender(sender: &str) -> Self {
match sender {
"Call" => Self::Call,
"Client" => Self::Client,
"Iota" => Self::Iota,
"Omikron" => Self::Omikron,
"Omega" => Self::Omega,
"Command" => Self::Command,
_ => Self::Other,
}
}
fn style(self, theme: &crate::theme::ResolvedTheme) -> Style {
match self {
Self::Call => theme.logs.call,
Self::Client => theme.logs.client,
Self::Iota => theme.logs.iota,
Self::Omikron => theme.logs.omikron,
Self::Omega => theme.logs.omega,
Self::Command => theme.logs.command,
Self::Other => theme.logs.other,
}
}
}
pub struct LogCard {
state: ClientState,
@ -38,11 +76,10 @@ impl LogCard {
}
fn get_logs(&self) -> Vec<UiLogEntry> {
let state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
let state = match self.state.app.try_lock() {
Ok(state) => state,
Err(_) => return Vec::new(),
};
state
.get_logs()
.iter()
@ -64,7 +101,7 @@ impl LogCard {
let mut last_boundary = 0usize;
for (idx, ch) in s.char_indices() {
let char_width = if ch.is_ascii() { 1 } else { 2 };
let char_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if current_width + char_width > max_width {
if last_boundary == 0 {
return idx + ch.len_utf8();
@ -78,7 +115,7 @@ impl LogCard {
s.len()
}
fn wrap_entry(entry: &UiLogEntry, available_width: usize) -> Vec<(String, Color, bool)> {
fn wrap_entry(entry: &UiLogEntry, available_width: usize) -> Vec<(String, LogSource, bool)> {
let mut result = Vec::new();
let timestamp = entry.format_timestamp();
@ -141,29 +178,17 @@ impl LogCard {
line.push_str(&timestamp);
}
result.push((line, Self::sender_color(&entry.sender), entry.is_error));
result.push((line, LogSource::from_sender(&entry.sender), entry.is_error));
}
result
}
fn sender_color(sender: &str) -> Color {
match sender {
"Call" => Color::Magenta,
"Client" => Color::Green,
"Iota" => Color::Yellow,
"Omikron" => Color::Blue,
"Omega" => Color::Cyan,
"Command" => Color::LightGreen,
_ => Color::LightCyan,
}
}
fn build_all_lines(
&self,
entries: Vec<UiLogEntry>,
width: usize,
) -> Vec<(String, Color, bool)> {
) -> Vec<(String, LogSource, bool)> {
let mut lines = Vec::new();
for entry in entries {
@ -269,23 +294,33 @@ impl Element for LogCard {
self
}
fn render(&self, f: &mut Frame, area: Rect) {
fn render(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) {
let entries = self.get_logs();
let block = Block::default()
.title(self.build_title())
.borders(self.borders)
.border_style(if self.focused {
Style::default().fg(Color::Yellow)
context.theme.logs.focused_border
} else {
Style::default()
context.theme.logs.border
});
let inner_area = block.inner(area);
f.render_widget(block, area);
if inner_area.width == 0 || inner_area.height == 0 {
draw_block_joins(f, area, self.borders, self.joins);
draw_block_joins(
f,
area,
self.borders,
self.joins,
if self.focused {
context.theme.borders.focused
} else {
context.theme.borders.normal
},
);
return;
}
@ -298,7 +333,7 @@ impl Element for LogCard {
let rendered_lines: Vec<Line> = visible_lines
.iter()
.map(|(line, prefix_color, is_error)| {
.map(|(line, source, is_error)| {
let mut spans = Vec::new();
let (prefix, rest) = Self::split_line_prefix(line);
@ -306,24 +341,25 @@ impl Element for LogCard {
if !prefix.is_empty() {
spans.push(Span::styled(
prefix.to_string(),
Style::default().fg(*prefix_color),
source.style(context.theme),
));
}
let (content, timestamp) = Self::split_timestamp_suffix(rest);
let text_color = if *is_error { Color::Red } else { Color::White };
let text_style = if *is_error {
context.theme.logs.error
} else {
context.theme.logs.text
};
if !content.is_empty() {
spans.push(Span::styled(
content.to_string(),
Style::default().fg(text_color),
));
spans.push(Span::styled(content.to_string(), text_style));
}
if !timestamp.is_empty() {
spans.push(Span::styled(
timestamp.to_string(),
Style::default().fg(Color::DarkGray),
context.theme.logs.timestamp,
));
}
@ -341,7 +377,17 @@ impl Element for LogCard {
f.render_widget(Paragraph::new(line.clone()), line_area);
}
draw_block_joins(f, area, self.borders, self.joins);
draw_block_joins(
f,
area,
self.borders,
self.joins,
if self.focused {
context.theme.borders.focused
} else {
context.theme.borders.normal
},
);
}
}

View file

@ -2,52 +2,59 @@ use crate::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>) {
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;
}
let event_result = tokio::task::spawn_blocking(|| {
if let Ok(true) = poll(Duration::from_millis(100)) {
read().ok().and_then(|ev| match ev {
Event::Key(key) if key.kind == KeyEventKind::Press => Some(key),
_ => None,
tokio::select! {
event = rx.recv() => match event {
Some(Event::Key(key)) if key.kind == KeyEventKind::Press => handle_input(key, ui.clone()).await,
Some(Event::Resize(_, _)) => ui.invalidate(),
Some(Event::Paste(text)) => ui.handle_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
})
} else {
None
}
})
.await;
match event_result {
Ok(Some(key_event)) => {
handle_input(key_event, ui.clone()).await;
}
Ok(_) => {}
Err(e) => {
eprintln!("Input task error: {}", e);
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
}
});
}
pub async fn handle_input(key: KeyEvent, ui: Arc<UI>) {
match (key.code, key.modifiers) {
(crossterm::event::KeyCode::Char('q'), KeyModifiers::CONTROL)
| (crossterm::event::KeyCode::Char('c'), KeyModifiers::CONTROL) => {
if matches!(
key.code,
crossterm::event::KeyCode::Char('q') | crossterm::event::KeyCode::Char('c')
) && key.modifiers.contains(KeyModifiers::CONTROL)
{
ui.request_shutdown();
}
(crossterm::event::KeyCode::Char('r'), KeyModifiers::CONTROL) => {
let _ = ui.send_restart().await;
ui.request_shutdown();
}
_ => {
} else {
ui.handle_input(key).await;
}
}
}

View file

@ -1,21 +1,25 @@
use iota_ipc::{
ClientMessage, DaemonMessage, LocalRequest, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION,
ClientMessage, DaemonMessage, HelloAck, LocalRequest, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION,
RequestEnvelope, 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::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
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)]
@ -24,96 +28,114 @@ pub enum IpcConnectionState {
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<OwnedWriteHalf>,
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 stream = Self::try_connect(&path).await?;
let (mut reader, writer) = stream.into_split();
let deadline = tokio::time::Instant::now() + STARTUP_CONNECT_TIMEOUT;
Self::connect_until(&path, deadline).await
}
let (conn_state_tx, _) = watch::channel(IpcConnectionState::Connecting);
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(writer),
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()),
});
// --- Handshake: send Hello, read HelloAck ---
{
let mut w = client.writer.lock().await;
write_msg(
&mut *w,
&ClientMessage::Hello {
supported_versions: vec![MIN_PROTOCOL_VERSION, PROTOCOL_VERSION],
},
)
.await?;
// 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;
}
match read_msg::<_, DaemonMessage>(&mut reader).await {
Ok(DaemonMessage::HelloAck(ack)) => {
if ack.protocol_version < MIN_PROTOCOL_VERSION {
let _ = client
.connection_state
.send(IpcConnectionState::Incompatible {
message: format!(
"Daemon protocol {} < required {}",
ack.protocol_version, MIN_PROTOCOL_VERSION
),
});
return Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
format!(
"Protocol version mismatch: daemon={}, minimum={}",
ack.protocol_version, MIN_PROTOCOL_VERSION
),
));
}
}
Ok(_) => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Expected HelloAck from daemon",
));
}
Err(e) => return Err(e),
}
let _ = client.connection_state.send(IpcConnectionState::Connected);
// Start reader task (continues reading after handshake)
let reader_client = client.clone();
tokio::spawn(async move {
reader_client.read_loop(reader).await;
let task = tokio::spawn(async move {
reader_client.read_loop(negotiated.reader, 1).await;
});
// Subscribe to events
client
.send(ClientMessage::Subscribe {
log_classes: vec![],
metric_interval_ms: Some(500),
})
.await?;
client.background_tasks.lock().unwrap().push(task);
Ok(client)
}
@ -121,165 +143,191 @@ impl IpcClient {
/// 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 max_attempts = 30;
for attempt in 0..max_attempts {
match Self::connect(&path).await {
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) => {
if attempt < max_attempts - 1 {
let delay = Duration::from_millis(100 + attempt as u64 * 100);
tokio::time::sleep(delay).await;
continue;
}
return Err(error);
last_error = Some(error);
tokio::time::sleep(Duration::from_millis(250)).await;
}
}
}
unreachable!()
Err(last_error.unwrap_or_else(|| {
std::io::Error::new(std::io::ErrorKind::TimedOut, "Timed out waiting for daemon")
}))
}
async fn try_connect(path: &Path) -> Result<UnixStream> {
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
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 UnixStream::connect(path).await {
Ok(stream) => return Ok(stream),
Err(error) => {
if tokio::time::Instant::now() >= deadline {
return Err(error);
}
tokio::time::sleep(Duration::from_millis(200)).await;
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();
tokio::spawn(async move {
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 {
// Wait until the connection enters the Disconnected state.
loop {
let disconnected = matches!(*rx.borrow(), IpcConnectionState::Disconnected);
if disconnected {
break;
}
if rx.changed().await.is_err() {
return; // sender dropped
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;
let mut attempt: u32 = 0;
// Attempt reconnection until success or max attempts.
loop {
tokio::time::sleep(backoff).await;
attempt += 1;
if attempt > MAX_RECONNECT_ATTEMPTS {
let _ = self
.connection_state
.send(IpcConnectionState::Incompatible {
message: "Max reconnection attempts exceeded".into(),
});
for attempt in 1..=MAX_RECONNECT_ATTEMPTS {
if self.cancellation.is_cancelled() {
return;
}
let _ = self
.connection_state
.send(IpcConnectionState::Reconnecting { attempt });
match Self::try_connect(&self.path).await {
Ok(stream) => {
let (mut reader, writer) = stream.into_split();
*self.writer.lock().await = writer;
// Re-handshake
{
let mut w = self.writer.lock().await;
if write_msg(
&mut *w,
&ClientMessage::Hello {
supported_versions: vec![
MIN_PROTOCOL_VERSION,
PROTOCOL_VERSION,
],
},
)
.await
.is_err()
{
let _ = self
.connection_state
.send(IpcConnectionState::Disconnected);
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;
}
}
// Read HelloAck
match read_msg::<_, DaemonMessage>(&mut reader).await {
Ok(DaemonMessage::HelloAck(ack)) => {
if ack.protocol_version < MIN_PROTOCOL_VERSION {
let _ = self.connection_state.send(
IpcConnectionState::Incompatible {
message: format!(
"Daemon protocol {} < required {}",
ack.protocol_version, MIN_PROTOCOL_VERSION
),
},
);
Err(error) if error.kind() == std::io::ErrorKind::Unsupported => {
let _ = self
.connection_state
.send(IpcConnectionState::Incompatible {
message: error.to_string(),
});
return;
}
}
_ => {
let _ = self
.connection_state
.send(IpcConnectionState::Disconnected);
break;
}
}
// Clear pending requests with connection-lost errors
{
let mut pending = self.pending.lock().await;
for (_, request) in pending.drain() {
let _ = request.response_tx.send(
ResponseResult::Error(
iota_ipc::IpcErrorCode::Disconnected,
),
);
}
}
let _ = self.connection_state.send(IpcConnectionState::Connected);
// Start new reader loop
let reader_client = self.clone();
tokio::spawn(async move {
reader_client.read_loop(reader).await;
Err(error) if attempt == MAX_RECONNECT_ATTEMPTS => {
let _ = self.connection_state.send(IpcConnectionState::Failed {
message: format!("Reconnect failed after {attempt} attempts: {error}"),
});
// Resubscribe
let _ = self
.send(ClientMessage::Subscribe {
log_classes: vec![],
metric_interval_ms: Some(500),
})
.await;
// Successfully reconnected; go back to waiting for
// the next disconnect.
break;
return;
}
Err(_) => {
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);
}
}
@ -287,16 +335,61 @@ impl IpcClient {
}
}
async fn read_loop(self: Arc<Self>, mut reader: OwnedReadHalf) {
loop {
match read_msg::<_, DaemonMessage>(&mut reader).await {
Ok(message) => self.apply(message).await,
Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => {
let _ = self.connection_state.send(IpcConnectionState::Disconnected);
break;
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;
}
Err(_) => {
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(error) => {
eprintln!("IPC reader for generation {generation} stopped: {error}");
self.mark_disconnected(generation).await;
break;
}
}
@ -315,6 +408,65 @@ impl IpcClient {
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_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 as unauthorized."
}
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();
@ -329,14 +481,17 @@ impl IpcClient {
protocol_version: PROTOCOL_VERSION,
request,
};
self.send(ClientMessage::Request(envelope)).await?;
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(30), 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::Disconnected))
Ok(ResponseResult::Error(iota_ipc::IpcErrorCode::Timeout))
}
}
}
@ -357,8 +512,12 @@ impl IpcClient {
["user", "list"] => Some(LocalRequest::ListUsers),
["reconnect"] => Some(LocalRequest::ReconnectOmikron),
["regenerate", "keys"] => Some(LocalRequest::RotateIotaIdentity),
["reload"] | ["restart"] => Some(LocalRequest::RestartDaemon),
["shutdown"] | ["stop"] => Some(LocalRequest::StopDaemon),
["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit {
intent: iota_ipc::ExitIntent::Restart,
}),
["shutdown"] | ["stop"] => Some(LocalRequest::RequestProcessExit {
intent: iota_ipc::ExitIntent::Stop,
}),
_ => None,
}
}
@ -371,11 +530,7 @@ impl IpcClient {
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()
.unwrap_or_else(|error| error.into_inner());
let mut state = self.state.app.lock().await;
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@ -387,11 +542,7 @@ impl IpcClient {
});
return Err(e);
}
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
let mut state = self.state.app.lock().await;
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@ -407,14 +558,10 @@ impl IpcClient {
if let Some(request) = Self::parse_console_command(&line) {
match self.send_request(request).await {
Ok(result) => {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
let mut state = self.state.app.lock().await;
let message = match &result {
ResponseResult::Ok(msg) => msg.clone(),
ResponseResult::Error(code) => format!("Error: {:?}", code),
ResponseResult::Error(code) => Self::format_error(code).into(),
};
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
@ -428,11 +575,7 @@ impl IpcClient {
Ok(())
}
Err(e) => {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
let mut state = self.state.app.lock().await;
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@ -446,19 +589,15 @@ impl IpcClient {
}
}
} else {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
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 line.trim() == "help" {
"Available commands: tasks, ping, user, reconnect, regenerate, restart, stop"
message: if trimmed == "help" {
"Commands: status, tasks, ping, user add <name>, user remove <id>, user list, reconnect, regenerate keys, restart, stop"
.into()
} else {
format!("Unknown command: {}", line)
@ -470,18 +609,43 @@ impl IpcClient {
}
async fn send(&self, message: ClientMessage) -> Result<()> {
let mut writer = self.writer.lock().await;
write_msg(&mut *writer, &message).await
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()
.unwrap_or_else(|error| error.into_inner());
let mut state = self.state.app.lock().await;
state.push_log(UiLogEntry {
timestamp_ms: entry.timestamp_ms,
sender: entry.sender,
@ -490,11 +654,16 @@ impl IpcClient {
});
}
DaemonMessage::StateUpdate(snapshot) => {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
// 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;
@ -503,41 +672,21 @@ impl IpcClient {
state.sys_info = snapshot.sys_info;
}
DaemonMessage::MetricSample(sample) => {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
let mut state = self.state.app.lock().await;
if let Some(cpu) = sample.cpu {
let idx = state.cpu.len() as f64;
state.cpu.push((idx, cpu));
if state.cpu.len() > iota_state::MAX_POINTS {
state.cpu.remove(0);
}
state.push_cpu((0.0, cpu));
}
if let Some(ram) = sample.ram {
let idx = state.ram.len() as f64;
state.ram.push((idx, ram));
if state.ram.len() > iota_state::MAX_POINTS {
state.ram.remove(0);
}
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 {
let idx = state.net_up.len() as f64;
state.net_up.push((idx, net_up));
if state.net_up.len() > iota_state::MAX_POINTS {
state.net_up.remove(0);
}
state.push_net_up((0.0, net_up));
}
if let Some(net_down) = sample.net_down {
let idx = state.net_down.len() as f64;
state.net_down.push((idx, net_down));
if state.net_down.len() > iota_state::MAX_POINTS {
state.net_down.remove(0);
}
state.push_net_down((0.0, net_down));
}
}
DaemonMessage::Response(response) => {
@ -545,14 +694,10 @@ impl IpcClient {
if let Some(request) = pending.remove(&response.request_id) {
let _ = request.response_tx.send(response.result);
} else {
let mut state = self
.state
.app
.lock()
.unwrap_or_else(|error| error.into_inner());
let mut state = self.state.app.lock().await;
let message = match &response.result {
ResponseResult::Ok(msg) => msg.clone(),
ResponseResult::Error(code) => format!("Error: {:?}", code),
ResponseResult::Error(code) => Self::format_error(code).into(),
};
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
@ -565,15 +710,12 @@ impl IpcClient {
});
}
}
DaemonMessage::HelloAck(_) => {}
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()
.unwrap_or_else(|error| error.into_inner());
let mut state = self.state.app.lock().await;
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@ -584,14 +726,19 @@ impl IpcClient {
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()
.unwrap_or_else(|error| error.into_inner());
let mut state = self.state.app.lock().await;
state.push_log(UiLogEntry {
timestamp_ms: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)

View file

@ -0,0 +1,52 @@
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

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

View file

@ -0,0 +1,10 @@
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

@ -5,6 +5,7 @@ pub mod elements {
pub mod log_card;
}
pub mod screens {
pub mod daemon_setup;
pub mod main_screen;
pub mod md_viewer;
pub mod screens;
@ -17,7 +18,12 @@ pub mod util {
pub mod terms_focus;
}
pub mod app_state;
pub mod controls;
pub mod input_handler;
pub mod ipc_client;
pub mod interaction_result;
pub mod ipc_client;
pub mod layout;
pub mod render_context;
pub mod theme;
pub mod ui;
pub use ui::TuiSession;

View file

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

View file

@ -0,0 +1,287 @@
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::Screen,
};
use crossterm::event::{KeyCode, KeyEvent};
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<'_>) {
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_input(&mut self, _: KeyEvent) -> 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<'_>) {
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_input(&mut self, event: KeyEvent) -> InteractionResult {
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

@ -6,7 +6,8 @@ use crate::{
log_card::LogCard,
},
interaction_result::InteractionResult,
ipc_client::IpcConnectionState,
ipc_client::{DaemonStatus, IpcConnectionState},
render_context::RenderContext,
screens::screens::{NavDirection, Screen},
ui::UI,
};
@ -27,6 +28,7 @@ pub struct MainScreen {
selected_coords: (usize, usize),
graphs_open: bool,
connection_status_rx: watch::Receiver<IpcConnectionState>,
daemon_status_rx: watch::Receiver<DaemonStatus>,
}
impl MainScreen {
@ -39,10 +41,17 @@ impl MainScreen {
vec![Some(1), Some(4)],
];
let state = ui.client_state();
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 mut console_card = ConsoleCard::new("Console", "", ui.ipc());
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));
@ -61,7 +70,8 @@ impl MainScreen {
let graphs_open = true;
let connection_status_rx = ui.ipc().connection_status();
let connection_status_rx = ipc.connection_status();
let daemon_status_rx = ipc.daemon_status();
let mut screen = MainScreen {
elements,
@ -69,6 +79,7 @@ impl MainScreen {
selected_coords: (1, 0),
graphs_open,
connection_status_rx,
daemon_status_rx,
};
screen.focus_current();
screen
@ -198,22 +209,46 @@ impl Screen for MainScreen {
self
}
fn render(&self, f: &mut Frame, rect: Rect) {
let status = self.connection_status_rx.borrow();
let status_text = match &*status {
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>) {
// A watch Ref blocks senders until it is dropped. Rendering may do
// terminal I/O, so retain only owned snapshots for the whole frame.
let status = self.connection_status_rx.borrow().clone();
let daemon = self.daemon_status_rx.borrow().clone();
let status_text = match status {
IpcConnectionState::Connected => "Connected".to_string(),
IpcConnectionState::Connecting => "Connecting...".to_string(),
IpcConnectionState::Reconnecting { attempt } => {
format!("Reconnecting (attempt {})...", attempt)
}
IpcConnectionState::Incompatible { message } => {
format!("Incompatible: {}", message)
format!("Incompatible protocol: {}", message)
}
IpcConnectionState::Failed { message } => {
format!("Connection failed: {}", message)
}
IpcConnectionState::Disconnected => "Disconnected".to_string(),
};
let readiness = daemon
.startup_phase
.map(|phase| format!("{:?}", phase))
.unwrap_or_else(|| "Waiting for status".into());
let health = daemon
.degraded_reason
.as_deref()
.map(|reason| format!("{reason}"))
.unwrap_or_default();
let version = if daemon.version.is_empty() {
String::new()
} else {
format!(" v{}", daemon.version)
};
let main_block = Block::default()
.title(format!("Main [{}]", status_text))
.borders(Borders::ALL);
.title(format!(
"Iota{version} [{status_text}; {readiness}{health}]"
))
.borders(Borders::ALL)
.border_style(context.theme.borders.normal)
.title_style(context.theme.borders.title);
f.render_widget(main_block, rect);
let inner = rect.inner(Margin {
@ -221,7 +256,11 @@ impl Screen for MainScreen {
horizontal: 1,
});
let graphs_width = if self.graphs_open { 30 } else { 2 };
let graphs_width = if self.graphs_open && inner.width >= 70 {
30
} else {
2
};
let main_width = inner.width.saturating_sub(graphs_width);
let horizontal_chunks = Layout::default()
@ -239,11 +278,11 @@ impl Screen for MainScreen {
Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(left_area);
if let Some(log) = self.elements.get(0) {
log.as_element().render(f, left_rows[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]);
console.as_element().render(f, left_rows[1], context);
}
let graph_elements: Vec<_> = self
@ -262,7 +301,7 @@ impl Screen for MainScreen {
.split(right_area);
for (el, area) in graph_elements.iter().zip(graph_chunks.iter()) {
el.as_element().render(f, *area);
el.as_element().render(f, *area, context);
}
}
}

View file

@ -7,11 +7,16 @@ use ratatui::{
};
use std::{any::Any, time::Duration};
use crate::{interaction_result::InteractionResult, screens::screens::Screen};
use crate::{
interaction_result::InteractionResult,
render_context::RenderContext,
screens::screens::Screen,
theme::{ResolvedTheme, TextSemantics, ThemeName},
};
pub struct FileViewer {
title: String,
text: Vec<DisplayLine>,
content: String,
scroll: u16,
scroll_x: u16,
}
@ -24,8 +29,8 @@ impl Screen for FileViewer {
self
}
fn render(&self, f: &mut Frame, rect: Rect) {
self.draw(f, rect);
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>) {
self.draw(f, rect, context.theme);
}
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
@ -52,7 +57,7 @@ impl FileViewer {
pub fn new(title: String, content: &str) -> Self {
Self {
title,
text: parse_document(content.to_owned()),
content: content.to_owned(),
scroll: 0,
scroll_x: 0,
}
@ -62,7 +67,7 @@ impl FileViewer {
terminal
.draw(|f| {
let area = f.area();
self.draw(f, area);
self.draw(f, area, &crate::theme::resolve(ThemeName::Ansi));
})
.unwrap();
@ -77,12 +82,13 @@ impl FileViewer {
}
terminal
}
fn draw(&self, f: &mut Frame, area: Rect) {
fn draw(&self, f: &mut Frame, area: Rect, theme: &ResolvedTheme) {
use ratatui::text::Text;
let mut rendered_lines = Vec::new();
let text = parse_document(&self.content, theme);
for display_line in &self.text {
for display_line in &text {
if display_line.scrollable {
let content: String = display_line
.line
@ -153,7 +159,7 @@ impl FileViewer {
}
}
}
fn parse_document(input: String) -> Vec<DisplayLine> {
fn parse_document(input: &str, theme: &ResolvedTheme) -> Vec<DisplayLine> {
let mut lines_vec = Vec::new();
let mut in_code_block = false;
let liness: Vec<String> = input.lines().map(String::from).collect();
@ -172,7 +178,7 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
lines_vec.push(DisplayLine {
line: Line::from(Span::styled(
format!("────────{}────────", code),
Style::default().fg(Color::DarkGray),
theme.markdown.divider,
)),
scrollable: false,
});
@ -182,10 +188,7 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
if in_code_block {
lines_vec.push(DisplayLine {
line: Line::from(Span::styled(
raw.to_string(),
Style::default().fg(Color::Yellow),
)),
line: Line::from(Span::styled(raw.to_string(), theme.markdown.code)),
scrollable: false,
});
i += 1;
@ -195,9 +198,13 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
lines_vec.push(DisplayLine {
line: Line::from(Span::styled(
raw.trim_start_matches("### ").to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
theme.apply_text_semantics(
theme.markdown.heading,
TextSemantics {
bold: true,
underline: false,
},
),
)),
scrollable: false,
});
@ -208,9 +215,13 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
lines_vec.push(DisplayLine {
line: Line::from(Span::styled(
raw.trim_start_matches("## ").to_string(),
Style::default()
.fg(Color::LightCyan)
.add_modifier(Modifier::BOLD),
theme.apply_text_semantics(
theme.markdown.heading,
TextSemantics {
bold: true,
underline: false,
},
),
)),
scrollable: false,
});
@ -221,9 +232,13 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
lines_vec.push(DisplayLine {
line: Line::from(Span::styled(
raw.trim_start_matches("# ").to_string(),
Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::BOLD),
theme.apply_text_semantics(
theme.markdown.heading,
TextSemantics {
bold: true,
underline: false,
},
),
)),
scrollable: false,
});
@ -254,13 +269,13 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
}
let table = parse_table(&table_lines.iter().map(|s| s.as_str()).collect::<Vec<_>>());
lines_vec.extend(table_to_lines(table));
lines_vec.extend(table_to_lines(table, theme));
i = j;
continue;
}
lines_vec.push(DisplayLine {
line: Line::from(parse_inline(raw.as_str())),
line: Line::from(parse_inline(raw.as_str(), theme)),
scrollable: false,
});
i += 1;
@ -269,7 +284,7 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
lines_vec
}
fn parse_inline(input: &str) -> Vec<Span<'static>> {
fn parse_inline(input: &str, theme: &ResolvedTheme) -> Vec<Span<'static>> {
let mut spans = Vec::new();
let mut buf = String::new();
@ -294,7 +309,11 @@ fn parse_inline(input: &str) -> Vec<Span<'static>> {
};
if let Some(kind) = toggle {
flush_span(&mut spans, &mut buf, current_style(bold, underline, code));
flush_span(
&mut spans,
&mut buf,
current_style(bold, underline, code, theme),
);
match kind {
"bold" => bold = !bold,
@ -308,24 +327,21 @@ fn parse_inline(input: &str) -> Vec<Span<'static>> {
buf.push(c);
}
flush_span(&mut spans, &mut buf, current_style(bold, underline, code));
flush_span(
&mut spans,
&mut buf,
current_style(bold, underline, code, theme),
);
spans
}
fn current_style(bold: bool, underline: bool, code: bool) -> Style {
let mut style = Style::default();
if bold {
style = style.add_modifier(Modifier::BOLD);
}
if underline {
style = style.add_modifier(Modifier::UNDERLINED);
}
if code {
style = style.fg(Color::Yellow);
}
style
fn current_style(bold: bool, underline: bool, code: bool, theme: &ResolvedTheme) -> Style {
let base = if code {
theme.markdown.code
} else {
theme.markdown.normal
};
theme.apply_text_semantics(base, TextSemantics { bold, underline })
}
#[derive(Clone)]
pub struct DisplayLine {
@ -333,7 +349,7 @@ pub struct DisplayLine {
scrollable: bool,
}
fn table_to_lines(table: Vec<Vec<String>>) -> Vec<DisplayLine> {
fn table_to_lines(table: Vec<Vec<String>>, theme: &ResolvedTheme) -> Vec<DisplayLine> {
if table.len() < 2 {
return vec![];
}
@ -377,7 +393,7 @@ fn table_to_lines(table: Vec<Vec<String>>) -> Vec<DisplayLine> {
.join("─┼─");
lines.push(DisplayLine {
line: Line::from(Span::styled(divider, Style::default().fg(Color::DarkGray))),
line: Line::from(Span::styled(divider, theme.markdown.divider)),
scrollable: true,
});
continue;
@ -403,11 +419,15 @@ fn table_to_lines(table: Vec<Vec<String>>) -> Vec<DisplayLine> {
}
let style = if row_idx == 0 {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
theme.apply_text_semantics(
theme.markdown.table_header,
TextSemantics {
bold: true,
underline: false,
},
)
} else {
Style::default().fg(Color::Green)
theme.markdown.table_text
};
lines.push(DisplayLine {

View file

@ -3,7 +3,7 @@ use std::any::Any;
use crossterm::event::KeyEvent;
use ratatui::{Frame, layout::Rect};
use crate::interaction_result::InteractionResult;
use crate::{interaction_result::InteractionResult, render_context::RenderContext};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NavDirection {
@ -20,6 +20,6 @@ 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);
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>);
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult;
}

View file

@ -1,22 +1,19 @@
use crate::{
controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line},
interaction_result::InteractionResult,
render_context::RenderContext,
screens::{md_viewer::FileViewer, screens::Screen},
ui::UI,
util::{
buttons::{checkbox, draw_buttons},
terms_focus::Focus,
},
util::{buttons::draw_buttons, terms_focus::Focus},
};
use crossterm::event::{KeyCode, KeyEvent};
use iota_terms::{TermsType, get_link, get_terms};
use ratatui::{
Frame,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Paragraph},
};
use std::{any::Any, pin::Pin, sync::Arc};
use std::{any::Any, pin::Pin};
use tokio::sync::oneshot;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -26,9 +23,7 @@ pub enum UserChoice {
AcceptAll,
}
#[allow(dead_code)] // ui is unused
pub struct TermsCheckerScreen {
_ui: Arc<UI>,
sender: Option<oneshot::Sender<UserChoice>>,
eula: bool,
@ -39,9 +34,8 @@ pub struct TermsCheckerScreen {
}
impl TermsCheckerScreen {
pub fn new(ui: Arc<UI>, sender: Option<oneshot::Sender<UserChoice>>) -> Self {
pub fn new(sender: Option<oneshot::Sender<UserChoice>>) -> Self {
Self {
_ui: ui,
sender,
eula: false,
tos: false,
@ -59,7 +53,7 @@ impl Screen for TermsCheckerScreen {
self
}
fn render(&self, f: &mut Frame, size: Rect) {
fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>) {
let mut needed_height = 5;
if size.height < 6 || size.width < 27 {
@ -169,9 +163,36 @@ impl Screen for TermsCheckerScreen {
)
};
let mut text_lines = vec![
checkbox(eula_text, self.eula, self.focus == Focus::Eula, true),
checkbox(tos_text, self.tos, self.focus == Focus::Tos, self.eula),
checkbox(pp_text, self.pp, self.focus == Focus::Pp, self.eula),
render_choice_line(
eula_text,
ChoiceKind::Checkbox,
ChoiceVisualState {
selected: self.eula,
focused: self.focus == Focus::Eula,
enabled: true,
},
context.theme,
),
render_choice_line(
tos_text,
ChoiceKind::Checkbox,
ChoiceVisualState {
selected: self.tos,
focused: self.focus == Focus::Tos,
enabled: self.eula,
},
context.theme,
),
render_choice_line(
pp_text,
ChoiceKind::Checkbox,
ChoiceVisualState {
selected: self.pp,
focused: self.focus == Focus::Pp,
enabled: self.eula,
},
context.theme,
),
Line::from(""),
Line::from("¹ Necessary required to run the program"),
Line::from("² Optional required only for Tensamin services"),
@ -191,19 +212,19 @@ impl Screen for TermsCheckerScreen {
if size.width < 60 || size.height < needed_height as u16 {
let width_style = if size.width > 76 {
Style::default().fg(Color::Green)
context.theme.status.success
} else if size.width >= 60 {
Style::default().fg(Color::Yellow)
context.theme.status.warning
} else {
Style::default().fg(Color::Red)
context.theme.status.error
};
let height_style = if size.height > 19 {
Style::default().fg(Color::Green)
context.theme.status.success
} else if size.height >= 13 {
Style::default().fg(Color::Yellow)
context.theme.status.warning
} else {
Style::default().fg(Color::Red)
context.theme.status.error
};
let warning_text = Text::from(vec![
@ -245,6 +266,7 @@ impl Screen for TermsCheckerScreen {
true,
false,
true,
context.theme,
);
}

View file

@ -1,11 +1,10 @@
use crate::screens::terms_checker::UserChoice;
use crate::{
controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line},
interaction_result::InteractionResult,
render_context::RenderContext,
screens::{md_viewer::FileViewer, screens::Screen},
util::{
buttons::{checkbox, draw_buttons},
terms_focus::Focus,
},
util::{buttons::draw_buttons, terms_focus::Focus},
};
use chrono::{Local, TimeZone, Utc};
use crossterm::event::{KeyCode, KeyEvent};
@ -13,7 +12,6 @@ use iota_terms::{Doc, TermsType, get_newest_link, get_terms};
use ratatui::{
Frame,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Paragraph},
};
@ -123,7 +121,19 @@ impl Screen for TermsUpdaterScreen {
self
}
fn render(&self, f: &mut Frame, size: Rect) {
fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>) {
let checkbox = |label, selected, focused, enabled| {
render_choice_line(
label,
ChoiceKind::Checkbox,
ChoiceVisualState {
selected,
focused,
enabled,
},
context.theme,
)
};
let mut needed_height = 5;
if size.height < 6 || size.width < 27 {
@ -523,19 +533,19 @@ impl Screen for TermsUpdaterScreen {
};
if size.width < 60 || size.height < needed_height as u16 {
let width_style = if size.width > 76 {
Style::default().fg(Color::Green)
context.theme.status.success
} else if size.width >= 60 {
Style::default().fg(Color::Yellow)
context.theme.status.warning
} else {
Style::default().fg(Color::Red)
context.theme.status.error
};
let height_style = if size.height > 20 {
Style::default().fg(Color::Green)
context.theme.status.success
} else if size.height >= (header_lines as u16 + 10) {
Style::default().fg(Color::Yellow)
context.theme.status.warning
} else {
Style::default().fg(Color::Red)
context.theme.status.error
};
let warning_text = Text::from(vec![
@ -579,6 +589,7 @@ impl Screen for TermsUpdaterScreen {
self.update_needed,
downgrade_scenario,
self.pp_needed || self.tos_needed,
context.theme,
);
}

View file

@ -0,0 +1,163 @@
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,
}
#[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")
}
pub fn load() -> Result<Self, io::Error> {
Self::load_from(&Self::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)
}
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
}
}
}
}
#[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
);
}
}

12
iota-cli/src/theme/mod.rs Normal file
View file

@ -0,0 +1,12 @@
mod config;
mod model;
mod name;
mod presets;
pub use config::{DaemonStartPolicy, UiConfig};
pub use model::*;
pub use name::ThemeName;
pub fn resolve(name: ThemeName) -> ResolvedTheme {
presets::resolve(name)
}

149
iota-cli/src/theme/model.rs Normal file
View file

@ -0,0 +1,149 @@
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 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 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

@ -0,0 +1,47 @@
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

@ -0,0 +1,301 @@
use super::{
BorderStyles, ButtonStyles, ChoiceItemStyle, ChoiceStyles, ConsoleStyles, CursorPresentation,
GraphStyles, LogStyles, MarkdownStyles, MarkerSet, ResolvedTheme, StatusStyles, 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,
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
}
}
}

View file

@ -1,144 +1,262 @@
use crate::{
input_handler::setup_input_handler, interaction_result::InteractionResult,
ipc_client::IpcClient, screens::screens::Screen,
input_handler::setup_input_handler,
interaction_result::InteractionResult,
ipc_client::IpcClient,
render_context::RenderContext,
screens::screens::Screen,
theme::{self, ResolvedTheme, ThemeName},
};
use crossterm::event::KeyEvent;
use once_cell::sync::Lazy;
use ratatui::{Terminal, backend::CrosstermBackend, init};
use ratatui::{Terminal, backend::CrosstermBackend};
use std::{
collections::VecDeque,
io,
io::Stdout,
panic::PanicHookInfo,
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};
use tokio::{sync::RwLock, time::Instant};
use tokio::sync::{Notify, RwLock};
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: Arc<IpcClient>,
shutdown: AtomicBool,
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>>>,
}
pub fn start_tui(ipc: Arc<IpcClient>) -> Arc<UI> {
let ui = Arc::new(UI::new(ipc));
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 uic = ui.clone();
tokio::spawn(async move {
let mut last_render = Instant::now();
let mut fps_samples: VecDeque<f64> = VecDeque::with_capacity(20);
let mut skip_samples: VecDeque<u16> = VecDeque::with_capacity(20);
let mut fps_sum = 0.0;
let mut skip_sum: u32 = 0;
let mut skipped = 0;
loop {
if uic.is_shutdown() {
break;
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?; } },
_ = tokio::time::sleep(std::time::Duration::from_millis(250)) => { if !uic.is_shutdown() { uic.render().await?; } },
}
if skipped > 5 {
uic.render().await;
skip_samples.push_back(skipped);
skip_sum += skipped as u32;
if skip_samples.len() > 20 {
if let Some(old) = skip_samples.pop_front() {
skip_sum -= old as u32;
}
}
skipped = 0;
let elapsed = last_render.elapsed().as_secs_f64();
if elapsed > 0.0 {
let fps = 1.0 / elapsed;
fps_samples.push_back(fps);
fps_sum += fps;
if fps_samples.len() > 20 {
if let Some(old) = fps_samples.pop_front() {
fps_sum -= old;
}
}
}
let avg_fps = if !fps_samples.is_empty() {
fps_sum / fps_samples.len() as f64
} else {
0.0
};
let avg_skips_percentage = if !skip_samples.is_empty() {
let avg_skipped = skip_sum as f64 / skip_samples.len() as f64;
let total_iterations = avg_skipped + 1.0;
(avg_skipped / total_iterations) * 100.0
} else {
0.0
};
*FPS.write().await = (avg_fps, avg_skips_percentage);
last_render = Instant::now();
} else {
skipped += 1;
if let Err(error) = &result {
*uic.failure.lock().unwrap() = Some(error.to_string());
uic.request_shutdown();
}
tokio::time::sleep(Duration::from_millis(16)).await;
}
ratatui::restore();
result
});
setup_input_handler(ui.clone());
ui
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,
signal_task,
restored: AtomicBool::new(false),
previous_hook,
})
}
pub struct TuiSession {
ui: Arc<UI>,
renderer_task: JoinHandle<io::Result<()>>,
input_task: JoinHandle<Result<(), String>>,
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;
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) {
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();
if let Some(task) = self.signal_task.as_ref() {
task.abort();
}
self.restore_panic_hook();
self.restore_terminal_once();
}
}
impl UI {
pub fn new(ipc: Arc<IpcClient>) -> Self {
let terminal = init();
Self {
ipc,
shutdown: AtomicBool::new(false),
pub(crate) fn new(
ipc: Option<Arc<IpcClient>>,
shutdown_on_empty: bool,
theme: ResolvedTheme,
) -> io::Result<Self> {
let terminal = ratatui::try_init()?;
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)),
})
}
pub fn ipc(&self) -> Arc<IpcClient> {
self.ipc.clone()
pub async fn ipc(&self) -> Option<Arc<IpcClient>> {
self.ipc.read().await.clone()
}
pub fn client_state(&self) -> iota_state::ClientState {
self.ipc.state()
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.shutdown.load(Ordering::Relaxed)
self.cancellation.is_cancelled()
}
pub fn request_shutdown(&self) {
self.shutdown.store(true, Ordering::Relaxed);
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())
}
pub async fn handle_paste(&self, _text: String) {
self.invalidate();
}
pub async fn send_restart(&self) -> std::io::Result<()> {
self.ipc.send_command(0, "restart".into()).await
/// 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 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.pop();
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) {
let result = {
@ -155,30 +273,41 @@ impl UI {
}
InteractionResult::OpenFutureScreen { screen: fut } => {
let ui = self.clone();
let screen = fut.await;
ui.set_screen(screen).await;
tokio::select! {
screen = fut => ui.set_screen(screen).await,
_ = ui.cancellation.cancelled() => return,
}
}
InteractionResult::CloseScreen => {
let mut stack = self.screen_stack.write().await;
stack.pop();
if stack.is_empty() {
if stack.is_empty() && self.shutdown_on_empty {
self.request_shutdown();
}
}
InteractionResult::Handled => {}
InteractionResult::Unhandled => {}
}
self.invalidate();
}
pub async fn render(&self) {
pub async fn render(&self) -> io::Result<()> {
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.
if let Some(screen) = self.screen_stack.read().await.last() {
let mut terminal = self.terminal.lock().unwrap();
terminal
.draw(|f| {
screen.render(f, f.area());
})
.unwrap();
let mut terminal = self
.terminal
.lock()
.map_err(|_| io::Error::other("terminal mutex poisoned"))?;
terminal.draw(|f| {
screen.render(f, f.area(), &context);
})?;
}
Ok(())
}
}

View file

@ -3,13 +3,20 @@ use ratatui::prelude::*;
use ratatui::style::Style;
use ratatui::widgets::Borders;
fn set_join_char(frame: &mut Frame, x: u16, y: u16, c: char) {
frame
.buffer_mut()
.set_string(x, y, c.to_string(), Style::default());
fn set_join_char(frame: &mut Frame, x: u16, y: u16, c: char, style: Style) {
frame.buffer_mut().set_string(x, y, c.to_string(), style);
}
pub fn draw_block_joins(frame: &mut Frame, area: Rect, borders: Borders, joins: Borders) {
pub fn draw_block_joins(
frame: &mut Frame,
area: Rect,
borders: Borders,
joins: Borders,
style: Style,
) {
if area.width == 0 || area.height == 0 {
return;
}
let x0 = area.x;
let y0 = area.y;
let x1 = area.x + area.width - 1;
@ -22,7 +29,7 @@ pub fn draw_block_joins(frame: &mut Frame, area: Rect, borders: Borders, joins:
(false, true) => '┬',
(false, false) => '┌',
};
set_join_char(frame, x0, y0, top_left);
set_join_char(frame, x0, y0, top_left, style);
}
if borders.contains(Borders::TOP) && borders.contains(Borders::RIGHT) {
@ -32,7 +39,7 @@ pub fn draw_block_joins(frame: &mut Frame, area: Rect, borders: Borders, joins:
(false, true) => '┬',
(false, false) => '┐',
};
set_join_char(frame, x1, y0, top_right);
set_join_char(frame, x1, y0, top_right, style);
}
if borders.contains(Borders::BOTTOM) && borders.contains(Borders::LEFT) {
@ -45,7 +52,7 @@ pub fn draw_block_joins(frame: &mut Frame, area: Rect, borders: Borders, joins:
(false, true) => '┴',
(false, false) => '└',
};
set_join_char(frame, x0, y1, bottom_left);
set_join_char(frame, x0, y1, bottom_left, style);
}
if borders.contains(Borders::BOTTOM) && borders.contains(Borders::RIGHT) {
@ -58,6 +65,6 @@ pub fn draw_block_joins(frame: &mut Frame, area: Rect, borders: Borders, joins:
(false, true) => '┴',
(false, false) => '┘',
};
set_join_char(frame, x1, y1, bottom_right);
set_join_char(frame, x1, y1, bottom_right, style);
}
}

View file

@ -1,55 +1,22 @@
use ratatui::{
layout::{Alignment, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
use ratatui::layout::Rect;
use crate::{
controls::button::{
ActionButton, ButtonIntent, button_minimum_width, horizontal_button_widths, render_button,
},
theme::ResolvedTheme,
util::terms_focus::Focus,
};
use crate::util::terms_focus::Focus;
#[allow(mismatched_lifetime_syntaxes)]
pub fn checkbox(label: &str, checked: bool, active: bool, allowed: bool) -> Line {
let box_char = if checked { "[x]" } else { "[ ]" };
let (box_style, text_style) = if active {
if allowed {
(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
} else {
(
Style::default().fg(Color::Gray),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)
}
} else {
(Style::default(), Style::default())
};
Line::from(vec![
Span::styled(box_char, box_style),
Span::raw(" "),
Span::styled(label, text_style),
])
}
pub fn draw_button(f: &mut ratatui::Frame, area: Rect, label: &str, style: Style) {
let p = Paragraph::new(Span::styled(label, style))
.alignment(Alignment::Center)
.block(Block::default().borders(Borders::ALL));
f.render_widget(p, area);
}
pub fn draw_buttons(
f: &mut ratatui::Frame,
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"
@ -69,107 +36,40 @@ pub fn draw_buttons(
buttons.push(("Continue with Tensamin Services", Focus::ContinueAll));
}
let padding = 2;
let min_widths: Vec<u16> = buttons
let minimums = buttons
.iter()
.map(|(label, _)| label.len() as u16 + padding)
.collect();
let widths = compute_widths(area.width, &min_widths);
.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 chunk = Rect {
let button_area = Rect {
x,
y: area.y,
width,
height: area.height,
};
x += width;
x = x.saturating_add(width);
let is_focused = current_focus == *focus;
let style = match focus {
Focus::Cancel => {
if is_focused {
Style::default()
.fg(Color::Black)
.bg(Color::Red)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Red)
}
}
Focus::Continue => {
if is_focused && state.0 {
Style::default()
.fg(Color::Black)
.bg(Color::Green)
.add_modifier(Modifier::BOLD)
} else if state.0 {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::DarkGray)
}
}
Focus::ContinueAll => {
if is_focused && state.1 {
Style::default()
.fg(Color::Black)
.bg(Color::Green)
.add_modifier(Modifier::BOLD)
} else if state.1 {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::DarkGray)
}
}
_ => Style::default().fg(Color::DarkGray),
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),
};
draw_button(f, chunk, label, style);
render_button(
frame,
button_area,
ActionButton {
label,
intent,
focused: current_focus == *focus,
enabled,
},
theme,
);
}
}
pub fn compute_widths(area_width: u16, min_widths: &[u16]) -> Vec<u16> {
let mut widths = vec![0; min_widths.len()];
let mut remaining: Vec<usize> = (0..min_widths.len()).collect();
let mut remaining_width = area_width;
while !remaining.is_empty() {
let count = remaining.len() as u16;
let equal = remaining_width / count;
let mut clamped = Vec::new();
for &i in &remaining {
if min_widths[i] > equal {
widths[i] = min_widths[i];
remaining_width -= min_widths[i];
clamped.push(i);
}
}
if clamped.is_empty() {
let mut remainder = remaining_width % count;
for &i in &remaining {
widths[i] = equal
+ if remainder > 0 {
remainder -= 1;
1
} else {
0
};
}
break;
}
remaining.retain(|i| !clamped.contains(i));
}
widths
}

View file

@ -0,0 +1,15 @@
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

@ -0,0 +1,71 @@
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

@ -0,0 +1,109 @@
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

@ -0,0 +1,45 @@
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

@ -2,6 +2,7 @@
name = "iota-core"
version = "0.1.0"
edition = "2024"
autobins = false
[dependencies]
iota-cli = { path = "../iota-cli" }

View file

@ -12,9 +12,9 @@ pub async fn check(ui: Arc<UI>) -> Result<(bool, bool), String> {
let mut state = ConsentState::load_state();
ensure_initial_consent(ui.clone(), &mut state).await?;
if ensure_updates(ui, &mut state).await.is_err() {
// We don't stop the program if updates fail, as long as we have initial consent
};
// A mandatory document update is a hard bootstrap gate. In particular,
// refusing it must not allow service setup or daemon access to continue.
ensure_updates(ui, &mut state).await?;
state = state.sanitize();
state.save_state();
@ -22,8 +22,23 @@ pub async fn check(ui: Arc<UI>) -> Result<(bool, bool), String> {
Ok((state.accepted_eula, state.accepted_tos && state.accepted_pp))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NonInteractiveConsent {
Accepted,
RequiresInteractiveAcceptance,
}
pub fn non_interactive_consent() -> NonInteractiveConsent {
let state = ConsentState::load_state();
if state.accepted_eula && state.accepted_tos && state.accepted_pp {
NonInteractiveConsent::Accepted
} else {
NonInteractiveConsent::RequiresInteractiveAcceptance
}
}
async fn ensure_initial_consent(ui: Arc<UI>, state: &mut ConsentState) -> Result<(), String> {
if state.accepted_eula {
if state.accepted_eula && state.accepted_tos && state.accepted_pp {
return Ok(());
}
@ -35,7 +50,7 @@ async fn ensure_initial_consent(ui: Arc<UI>, state: &mut ConsentState) -> Result
let (tx, rx) = oneshot::channel();
ui.set_screen(Box::new(TermsCheckerScreen::new(ui.clone(), Some(tx))))
ui.set_screen(Box::new(TermsCheckerScreen::new(Some(tx))))
.await;
let result = rx.await.unwrap_or(UserChoice::Deny);

1
iota-core/src/lib.rs Normal file
View file

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

View file

@ -3,7 +3,7 @@ use iota_updater::check_update;
use pnet::datalink::NetworkInterface;
use tokio::time::{Duration, sleep};
use iota_state::{ACTIVE_TASKS, APP_STATE, AppState, RELOAD, SHUTDOWN};
use iota_state::{AppState, DaemonState};
use iota_cli::screens::main_screen::MainScreen;
use iota_cli::{ipc_client::IpcClient, ui::start_tui};
@ -12,27 +12,29 @@ use iota_logger::{log, log_t};
use iota_storage::users::user_manager;
use iota_storage::util::config_util::CONFIG;
use iota_util::file_util::{download_and_extract_zip, has_dir};
use omikron_connector as omikron;
use omikron_connector::omikron_connection::OMIKRON_CONNECTION;
use std::sync::Arc;
#[tokio::main(flavor = "multi_thread", worker_threads = 16)]
#[allow(unused_must_use, dead_code, unused_assignments)]
async fn main() {
while *RELOAD.read().await {
*RELOAD.write().await = false;
*SHUTDOWN.write().await = false;
let state = Arc::new(DaemonState::new());
while *state.reload.read().await {
*state.reload.write().await = false;
*state.shutdown.write().await = false;
let ipc = IpcClient::connect("/run/iota/iota.sock")
.await
.expect("iota-daemon must be running before starting iota-core");
let ui = start_tui(ipc);
let session = start_tui(ipc).expect("interactive terminal initialization failed");
let ui = session.ui();
let (eula, tos_pp) = match consent_state::check(ui.clone()).await {
Ok(v) => v,
Err(e) => {
*SHUTDOWN.write().await = true;
*state.shutdown.write().await = true;
loop {
if ACTIVE_TASKS.is_empty() {
if state.active_tasks.is_empty() {
break;
}
sleep(Duration::from_millis(100)).await;
@ -43,9 +45,9 @@ async fn main() {
};
if !eula {
*SHUTDOWN.write().await = true;
*state.shutdown.write().await = true;
loop {
if ACTIVE_TASKS.is_empty() {
if state.active_tasks.is_empty() {
break;
}
sleep(Duration::from_millis(100)).await;
@ -55,9 +57,9 @@ async fn main() {
return;
}
if !tos_pp {
*SHUTDOWN.write().await = true;
*state.shutdown.write().await = true;
loop {
if ACTIVE_TASKS.is_empty() {
if state.active_tasks.is_empty() {
break;
}
sleep(Duration::from_millis(100)).await;
@ -70,7 +72,7 @@ async fn main() {
return;
}
check_update();
iota_state::setup();
iota_state::setup(&state);
let main_screen = MainScreen::new(ui.clone()).await;
ui.set_screen(Box::new(main_screen)).await;
@ -88,7 +90,7 @@ async fn main() {
iota_storage::util::config_util::load_config();
// USER MANAGEMENT
if let Err(_) = user_manager::load_users().await {
if let Err(_) = user_manager::load_users_sync() {
log_t!("user_load_failed");
}
if let Err(e) = iota_storage::util::settings::migrate_legacy_files() {
@ -153,29 +155,17 @@ async fn main() {
if !web_server::start(port).await {
log!("Failed to start the MTP web server on port {}", port);
}
let _ = omikron::omikron_connection::get_omikron_connection(tokio_util::sync::CancellationToken::new()).await;
log_t!("setup_completed");
loop {
if *SHUTDOWN.read().await {
if *state.shutdown.read().await {
break;
}
if OMIKRON_CONNECTION.has_auth_failure().await {
if let Some(reason) = OMIKRON_CONNECTION.get_auth_failure().await {
log!("Authentication failed: {}", reason);
log!(
"Use /reconnect to try again or /regenerate private-key to create a new key pair"
);
OMIKRON_CONNECTION.clear_auth_failure().await;
}
}
sleep(Duration::from_millis(500)).await;
}
if *RELOAD.read().await {
if *state.reload.read().await {
loop {
if ACTIVE_TASKS.is_empty() {
if state.active_tasks.is_empty() {
break;
}
sleep(Duration::from_secs(1)).await;
@ -184,9 +174,8 @@ async fn main() {
user_manager::clear();
// Commhnities have not been implemented yet.
/*community_manager::clear();*/
*APP_STATE.lock().unwrap() = AppState::new();
*state.app.lock().unwrap() = AppState::new();
}
ui.terminal.lock().unwrap().clear();
ui.terminal.lock().unwrap().flush();
let _ = session.shutdown().await;
}
}

View file

@ -4,6 +4,7 @@ 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" }
@ -17,3 +18,6 @@ sysinfo = "0.38.3"
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,42 +1,34 @@
use crate::DaemonRuntime;
use iota_ipc::{
IpcErrorCode, LocalRequest, ResponseEnvelope, ResponseResult,
};
use crate::{DaemonRuntime, DaemonServices};
use iota_ipc::{ExitIntent, IpcErrorCode, LocalRequest, ResponseEnvelope, ResponseResult};
use iota_logger::{log, log_command};
use iota_storage::users::user_manager;
use iota_storage::util::config_util::modify_config;
use mtp::codec::{CommunicationType, CommunicationValue};
use omikron_connector::omikron_connection::OMIKRON_CONNECTION;
use std::sync::Arc;
use std::time::Duration;
use crate::daemon_state::ShutdownReason;
use crate::daemon_state::{ShutdownReason, StartupPhase};
#[derive(Clone)]
pub struct CommandRouter {
runtime: Arc<DaemonRuntime>,
services: Arc<DaemonServices>,
}
impl CommandRouter {
pub fn new(runtime: Arc<DaemonRuntime>) -> Self {
Self { runtime }
pub fn new(runtime: Arc<DaemonRuntime>, services: Arc<DaemonServices>) -> Self {
Self { runtime, services }
}
pub async fn route(&self, request_id: u64, request: LocalRequest) -> ResponseEnvelope {
log_command!("{:?}", request);
let result = self.execute(request).await;
ResponseEnvelope {
request_id,
result,
}
ResponseEnvelope { request_id, result }
}
/// Parse a legacy console command string into a typed request.
pub fn parse_console_command(line: &str) -> Option<LocalRequest> {
let parts: Vec<&str> = line
.trim_start_matches('/')
.split_whitespace()
.collect();
let parts: Vec<&str> = line.trim_start_matches('/').split_whitespace().collect();
match parts.as_slice() {
["help"] => None,
["tasks"] => Some(LocalRequest::ListTasks),
@ -53,13 +45,33 @@ impl CommandRouter {
["user", "list"] => Some(LocalRequest::ListUsers),
["reconnect"] => Some(LocalRequest::ReconnectOmikron),
["regenerate", "keys"] => Some(LocalRequest::RotateIotaIdentity),
["reload"] | ["restart"] => Some(LocalRequest::RestartDaemon),
["shutdown"] | ["stop"] => Some(LocalRequest::StopDaemon),
["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit {
intent: ExitIntent::Restart,
}),
["shutdown"] | ["stop"] => Some(LocalRequest::RequestProcessExit {
intent: ExitIntent::Stop,
}),
_ => None,
}
}
async fn execute(&self, request: LocalRequest) -> ResponseResult {
let needs_omikron = matches!(
request,
LocalRequest::CreateUser { .. }
| LocalRequest::RemoveUser { .. }
| LocalRequest::ReconnectOmikron
| LocalRequest::RotateIotaIdentity
);
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();
@ -95,10 +107,13 @@ impl CommandRouter {
ResponseResult::Ok(users.join("\n"))
}
LocalRequest::CreateUser { username } => {
match omikron_connector::user_ops::create_user(&username).await {
(Some(user), _) => {
ResponseResult::Ok(format!("Created user {}", user.user_id))
}
match omikron_connector::user_ops::create_user(
self.services.omikron.as_ref(),
&username,
)
.await
{
(Some(user), _) => ResponseResult::Ok(format!("Created user {}", user.user_id)),
_ => ResponseResult::Error(IpcErrorCode::StorageFailure),
}
}
@ -109,24 +124,46 @@ impl CommandRouter {
};
let message = CommunicationValue::new(CommunicationType::DeleteUser)
.with_sender(user.user_id as u64);
if let Err(_e) = OMIKRON_CONNECTION.send_message(&message).await {
if let Err(_e) = self.services.omikron.send_message(&message).await {
return ResponseResult::Error(IpcErrorCode::OmikronUnavailable);
}
user_manager::remove_user(user.user_id);
ResponseResult::Ok(format!("Removed user {}", user.user_id))
}
LocalRequest::ReconnectOmikron => {
OMIKRON_CONNECTION.reconnect().await;
ResponseResult::Ok("Reconnected to Omikron server".into())
}
LocalRequest::ReconnectOmikron => match self.services.omikron.reconnect().await {
Ok(()) => ResponseResult::Ok("Reconnected to Omikron server".into()),
Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable),
},
LocalRequest::RotateIotaIdentity => {
modify_config(|config| {
config.public_key = None;
config.private_key = None;
config.iota_id = None;
});
OMIKRON_CONNECTION.reconnect().await;
ResponseResult::Ok("Key pair regenerated and Omikron reconnection requested".into())
match self.services.omikron.reconnect().await {
Ok(()) => ResponseResult::Ok(
"Key pair regenerated and Omikron reconnection requested".into(),
),
Err(_) => 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.shutdown(match intent {
ExitIntent::Stop => ShutdownReason::Stop,
ExitIntent::Restart => ShutdownReason::Restart,
});
ResponseResult::Ok("process exit accepted".into())
}
LocalRequest::GetDaemonStatus => {
ResponseResult::Ok(format!("{:?}", self.runtime.snapshot()))
}
LocalRequest::RestartDaemon => {
self.runtime.shutdown(ShutdownReason::Restart);
@ -140,10 +177,12 @@ impl CommandRouter {
}
pub async fn ping(&self, seconds: u64) -> Result<String, String> {
let response = OMIKRON_CONNECTION
let response = self
.services
.omikron
.await_response(
&CommunicationValue::new(CommunicationType::Ping),
Some(Duration::from_secs(seconds)),
Duration::from_secs(seconds),
)
.await;
match response {

View file

@ -1,7 +1,10 @@
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;
@ -58,8 +61,18 @@ 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 {
@ -68,8 +81,18 @@ impl Clone for DaemonRuntime {
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(),
}
}
}
@ -82,21 +105,36 @@ impl Default for DaemonRuntime {
impl DaemonRuntime {
pub fn new() -> Self {
let (shutdown_tx, _) = watch::channel(None);
let (startup_phase, _) = watch::channel(StartupPhase::Starting);
let (degraded_reason, _) = watch::channel(None);
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.cancellation.cancel();
if self.shutdown_tx.borrow().is_none() {
let _ = self.shutdown_tx.send(Some(reason));
self.cancellation.cancel();
}
}
pub fn shutdown_reason(&self) -> Option<ShutdownReason> {
@ -109,6 +147,27 @@ impl DaemonRuntime {
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 {
@ -117,7 +176,62 @@ impl DaemonRuntime {
pub fn mark_degraded(&self, reason: String) {
let _ = self.degraded_reason.send(Some(reason.clone()));
let _ = self.startup_phase.send(StartupPhase::Degraded);
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 {
@ -133,12 +247,19 @@ impl DaemonRuntime {
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 fn spawn_system_monitor(&self) {
pub async fn spawn_system_monitor(&self) {
let runtime = self.clone();
tokio::spawn(async move {
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;
@ -169,6 +290,8 @@ impl DaemonRuntime {
tokio::time::sleep(Duration::from_millis(500)).await;
}
runtime.state.active_tasks.remove("System monitor");
});
Ok(())
})
.await;
}
}

View file

@ -0,0 +1,37 @@
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,63 +1,113 @@
use crate::{CommandRouter, DaemonRuntime};
use crate::deployment::from_environment;
use crate::{CommandRouter, DaemonRuntime, DaemonServices};
use iota_ipc::{
ClientMessage, DaemonMessage, HelloAck, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION, read_msg,
write_msg,
};
use iota_logger::log;
use std::io::Result;
use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::{env, os::fd::FromRawFd};
use std::{env, fs::File, os::fd::FromRawFd, os::unix::net::UnixListener as StdUnixListener};
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::{broadcast, mpsc, watch};
use tokio::time::timeout;
use uuid::Uuid;
/// Per-client outbound queue capacity.
const CLIENT_CHANNEL_SIZE: usize = 256;
/// Maximum handshake retries before giving up.
const MAX_HANDSHAKE_RETRIES: u32 = 10;
const MAX_HANDSHAKE_RETRIES: u32 = 1;
const CLIENT_IO_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
pub struct IpcServer {
path: PathBuf,
listener: UnixListener,
runtime: Arc<DaemonRuntime>,
services: Arc<DaemonServices>,
log_tx: broadcast::Sender<DaemonMessage>,
state_rx: watch::Sender<iota_ipc::StateSnapshot>,
state_rx: watch::Receiver<iota_ipc::StateSnapshot>,
instance_id: String,
_instance_lock: File,
}
impl IpcServer {
pub fn new(
pub async fn bind(
path: impl Into<PathBuf>,
runtime: Arc<DaemonRuntime>,
services: Arc<DaemonServices>,
log_tx: broadcast::Sender<DaemonMessage>,
state_rx: watch::Sender<iota_ipc::StateSnapshot>,
) -> Self {
Self {
path: path.into(),
runtime,
log_tx,
state_rx,
}
}
pub async fn run(self) -> Result<()> {
if let Some(parent) = self.path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
state_rx: watch::Receiver<iota_ipc::StateSnapshot>,
) -> Result<Self> {
let path = path.into();
let listener = match activated_listener()? {
Some(listener) => listener,
None => {
remove_stale_socket(&self.path).await?;
UnixListener::bind(&self.path)?
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let lock_path = path.with_extension("sock.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)?;
let _ = tokio::fs::set_permissions(
&path,
std::os::unix::fs::PermissionsExt::from_mode(0o600),
)
.await;
return Ok(Self {
listener,
runtime,
services,
log_tx,
state_rx,
instance_id: Uuid::new_v4().to_string(),
_instance_lock: lock,
});
}
};
Ok(Self {
listener,
runtime,
services,
log_tx,
state_rx,
instance_id: Uuid::new_v4().to_string(),
_instance_lock: File::options().read(true).open("/dev/null")?,
})
}
pub async fn serve(self) -> Result<()> {
loop {
let (stream, _addr) = listener.accept().await?;
let (stream, _addr) = self.listener.accept().await?;
eprintln!("IPC client accepted");
let runtime = self.runtime.clone();
let services = self.services.clone();
let log_tx = self.log_tx.clone();
let state_rx = self.state_rx.clone();
let instance_id = self.instance_id.clone();
tokio::spawn(async move {
if let Err(error) = handle_client(stream, runtime, log_tx, state_rx).await {
if let Err(error) =
handle_client(stream, runtime, services, log_tx, state_rx, instance_id).await
{
eprintln!("IPC client error: {error}");
}
});
@ -77,13 +127,67 @@ fn activated_listener() -> Result<Option<UnixListener>> {
if listen_fds != Some(1) || listen_pid != Some(std::process::id()) {
return Ok(None);
}
let listener = unsafe { std::os::unix::net::UnixListener::from_raw_fd(3) };
UnixListener::from_std(listener).map(Some)
// 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(_) => tokio::fs::remove_file(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),
}
@ -93,10 +197,10 @@ async fn remove_stale_socket(path: &Path) -> Result<()> {
struct PeerIdentity {
pid: i32,
uid: u32,
gid: u32,
_gid: u32,
}
fn peer_credentials(stream: &UnixStream) -> PeerIdentity {
fn peer_credentials(stream: &UnixStream) -> Result<PeerIdentity> {
#[cfg(target_os = "linux")]
{
use std::os::unix::io::AsRawFd;
@ -104,60 +208,98 @@ fn peer_credentials(stream: &UnixStream) -> PeerIdentity {
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();
libc::getsockopt(
if libc::getsockopt(
fd,
libc::SOL_SOCKET,
libc::SO_PEERCRED,
&mut cred as *mut _ as *mut libc::c_void,
&mut len,
);
PeerIdentity {
) != 0
{
return Err(std::io::Error::last_os_error());
}
Ok(PeerIdentity {
pid: cred.pid,
uid: cred.uid,
gid: cred.gid,
}
_gid: cred.gid,
})
}
}
#[cfg(not(target_os = "linux"))]
{
PeerIdentity {
Ok(PeerIdentity {
pid: 0,
uid: 0,
gid: 0,
}
_gid: 0,
})
}
}
async fn handle_client(
stream: UnixStream,
runtime: Arc<DaemonRuntime>,
services: Arc<DaemonServices>,
log_tx: broadcast::Sender<DaemonMessage>,
_state_rx: watch::Sender<iota_ipc::StateSnapshot>,
mut state_rx: watch::Receiver<iota_ipc::StateSnapshot>,
instance_id: String,
) -> Result<()> {
let peer = peer_credentials(&stream);
let peer = peer_credentials(&stream)?;
// Access control belongs to the Unix socket. The systemd socket grants
// iota-operators group access (0660); rejecting every UID other than the
// service account here would make that authorization ineffective. Manual
// sockets remain owner-only (0600) at bind time.
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::<DaemonMessage>(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 read_msg::<_, ClientMessage>(&mut reader).await {
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()
.find(|v| *v >= MIN_PROTOCOL_VERSION && *v <= PROTOCOL_VERSION)
.unwrap_or(PROTOCOL_VERSION);
.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 instance_id = Uuid::new_v4().to_string();
let ack = DaemonMessage::HelloAck(HelloAck {
protocol_version: version,
daemon_version: env!("CARGO_PKG_VERSION").to_string(),
instance_id,
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_msg(&mut writer, &ack).await?;
write_client_message(&mut writer, &ack).await?;
eprintln!(
"IPC handshake acknowledged (pid={}, uid={})",
peer.pid, peer.uid
);
break;
}
Ok(_) => {
@ -168,6 +310,7 @@ async fn handle_client(
));
}
Err(e) => return Err(e),
},
}
}
let _version = negotiated_version.ok_or_else(|| {
@ -182,9 +325,9 @@ async fn handle_client(
// --- Writer task: merge directed responses + shared log events ---
let mut log_rx = log_tx.subscribe();
let directed_for_writer = directed_tx.clone();
let writer_task = {
let runtime = runtime.clone();
let session_cancellation = session_cancellation.clone();
tokio::spawn(async move {
let mut directed_rx = directed_rx;
loop {
@ -193,7 +336,9 @@ async fn handle_client(
msg = directed_rx.recv() => {
match msg {
Some(message) => {
if write_msg(&mut writer, &message).await.is_err() {
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;
}
}
@ -204,36 +349,91 @@ async fn handle_client(
result = log_rx.recv() => {
match result {
Ok(message) => {
if write_msg(&mut writer, &message).await.is_err() {
if let Err(error) = write_client_message(&mut writer, &message).await {
eprintln!("IPC client writer stopped while sending log message: {error}");
session_cancellation.cancel();
break;
}
}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
let _ = directed_for_writer.send(DaemonMessage::Gap { skipped }).await;
// Then send current snapshot for resync
let _ = directed_for_writer.send(
DaemonMessage::StateUpdate(runtime.snapshot())
).await;
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) => break,
}
}
changed = state_rx.changed() => {
if changed.is_err() {
break;
}
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;
}
}
}
}
})
};
// --- Reader loop ---
let router = CommandRouter::new(runtime.clone());
let router = CommandRouter::new(runtime.clone(), services);
loop {
match read_msg::<_, ClientMessage>(&mut reader).await {
let message = tokio::select! {
_ = session_cancellation.cancelled() => break,
result = read_msg::<_, ClientMessage>(&mut reader) => result,
};
match message {
Ok(ClientMessage::Request(envelope)) => {
let response = router.route(envelope.request_id, envelope.request).await;
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 < MIN_PROTOCOL_VERSION
|| envelope.protocol_version > PROTOCOL_VERSION
{
iota_ipc::ResponseEnvelope {
request_id: envelope.request_id,
result: iota_ipc::ResponseResult::Error(
iota_ipc::IpcErrorCode::UnsupportedVersion,
),
}
} else {
router.route(envelope.request_id, envelope.request).await
};
let _ = directed_tx.send(DaemonMessage::Response(response)).await;
if let Some(reason) = shutdown_reason {
let _ = directed_tx
.send(DaemonMessage::LifecycleEvent(
iota_ipc::LifecycleEvent::Shutdown {
reason: reason.into(),
},
))
.await;
// The request itself initiates daemon cancellation. Give
// the dedicated writer a chance to flush the response
// and lifecycle event before this session is torn down.
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
break;
}
}
Ok(ClientMessage::Subscribe { .. }) => {
let snapshot = DaemonMessage::StateUpdate(runtime.snapshot());
let _ = directed_tx.send(snapshot).await;
let _ = directed_tx.send(DaemonMessage::Subscribed).await;
}
Ok(ClientMessage::Ping { seq }) => {
let _ = directed_tx.send(DaemonMessage::Pong { seq }).await;
@ -250,6 +450,7 @@ async fn handle_client(
}
}
}
session_cancellation.cancel();
writer_task.abort();
log!(
"IPC client disconnected (pid={}, uid={})",
@ -258,3 +459,25 @@ async fn handle_client(
);
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()
);
}
}

View file

@ -1,8 +1,13 @@
pub mod command_router;
pub mod daemon_state;
pub mod deployment;
pub mod ipc_server;
pub mod log_broadcaster;
pub mod services;
pub mod task_registry;
pub use command_router::CommandRouter;
pub use daemon_state::{DaemonRuntime, ShutdownReason, StartupPhase};
pub use ipc_server::IpcServer;
pub use services::DaemonServices;
pub use task_registry::TaskRegistry;

View file

@ -0,0 +1,23 @@
use omikron_connector::{OmikronClient, OmikronConnection};
use std::sync::Arc;
#[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>,
}
impl DaemonServices {
pub fn new(omikron: Arc<OmikronConnection>) -> Arc<Self> {
Arc::new(Self {
omikron,
users: Arc::new(UserService),
config: Arc::new(ConfigService),
})
}
}

View file

@ -0,0 +1,40 @@
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

@ -0,0 +1,52 @@
use async_trait::async_trait;
use iota_daemon_lib::{CommandRouter, DaemonRuntime, DaemonServices};
use iota_ipc::{LocalRequest, ResponseResult};
use mtp::codec::CommunicationValue;
use omikron_connector::{OmikronClient, OmikronError};
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use std::time::Duration;
struct FakeOmikron {
reconnects: AtomicUsize,
}
#[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 is_connected(&self) -> bool {
true
}
}
#[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(),
});
let router = CommandRouter::new(Arc::new(DaemonRuntime::new()), services);
assert!(matches!(
router.route(1, LocalRequest::ReconnectOmikron).await.result,
ResponseResult::Ok(_)
));
assert_eq!(fake.reconnects.load(Ordering::SeqCst), 1);
}

View file

@ -0,0 +1,29 @@
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

@ -0,0 +1,40 @@
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

@ -8,6 +8,7 @@ iota-daemon-lib = { path = "../iota-daemon-lib" }
iota-ipc = { path = "../iota-ipc" }
iota-logger = { path = "../iota-logger" }
iota-state = { path = "../iota-state" }
iota-paths = { path = "../iota-paths" }
iota-storage = { path = "../iota-storage" }
omikron-connector = { path = "../omikron-connector" }
web-server = { path = "../web-server" }

View file

@ -1,55 +1,108 @@
use iota_daemon_lib::{DaemonRuntime, IpcServer, ShutdownReason, StartupPhase, log_broadcaster};
use iota_logger::{self as logger, log, log_t};
use iota_daemon_lib::{
DaemonRuntime, DaemonServices, IpcServer, ShutdownReason, StartupPhase, log_broadcaster,
};
use iota_logger::{self as logger, log};
use iota_storage::users::user_manager;
use iota_storage::util::config_util::CONFIG;
use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{broadcast, watch};
fn socket_path() -> PathBuf {
std::env::var_os("IOTA_SOCKET")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/run/iota/iota.sock"))
}
#[tokio::main(flavor = "multi_thread")]
async fn main() {
async fn main() -> ExitCode {
logger::startup();
iota_storage::util::config_util::load_config();
let runtime = Arc::new(DaemonRuntime::new());
runtime.set_startup_phase(StartupPhase::LoadingUsers);
if user_manager::load_users().await.is_err() {
log_t!("user_load_failed");
}
// --- IPC infrastructure ---
let (log_tx, _) = broadcast::channel(512);
log_broadcaster::spawn(log_tx.clone());
let (state_tx, _state_rx) = watch::channel(iota_ipc::StateSnapshot::default());
let (state_tx, state_rx) = watch::channel(runtime.snapshot());
// --- Start IPC server early (before services) so clients can see startup phases ---
runtime.set_startup_phase(StartupPhase::StartingServices);
let ipc_server = IpcServer::new(
socket_path(),
runtime.clone(),
log_tx.clone(),
state_tx.clone(),
runtime.set_startup_phase(StartupPhase::LoadingUsers);
if tokio::task::spawn_blocking(user_manager::load_users_sync)
.await
.ok()
.and_then(Result::ok)
.is_none()
{
runtime.set_component_failed(
iota_ipc::ComponentId::Storage,
"user storage failed to load".into(),
);
tokio::spawn(async move {
if let Err(error) = ipc_server.run().await {
eprintln!("iota-daemon IPC server failed: {error}");
} else {
runtime.set_component_healthy(iota_ipc::ComponentId::Storage, None);
}
});
log!("iota-daemon IPC server started");
// Bind before migration and service startup: a successful bind is the
// readiness boundary visible to clients and socket activation.
let socket = iota_paths::socket_path(iota_paths::SocketScope::User);
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) => {
runtime.set_component_failed(
iota_ipc::ComponentId::Omikron,
"Omikron authentication failed".into(),
);
return ExitCode::FAILURE;
}
Err(omikron_connector::OmikronStartupError::Construction(error)) => {
eprintln!("Cannot construct Omikron connection: {error}");
return ExitCode::FAILURE;
}
};
let services = DaemonServices::new(omikron);
let ipc_server =
match IpcServer::bind(socket, runtime.clone(), services, log_tx.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 {}",
iota_paths::socket_path(iota_paths::SocketScope::User).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");
runtime.set_startup_phase(StartupPhase::StartingServices);
// --- System monitor ---
runtime.spawn_system_monitor();
runtime.spawn_system_monitor().await;
// --- State update publisher (watch-based, no full broadcast per tick) ---
let state_publisher = runtime.clone();
tokio::spawn(async move {
runtime
.tasks
.spawn_tracked("state-publisher", async move {
loop {
if state_publisher.is_shutting_down() {
break;
@ -58,37 +111,88 @@ async fn main() {
let _ = state_tx.send(snapshot);
tokio::time::sleep(Duration::from_millis(500)).await;
}
});
Ok(())
})
.await;
// --- Web server ---
let port = CONFIG.load().port;
if !web_server::start(port, runtime.cancellation.clone()).await {
log!("Failed to start the MTP web server on port {}", port);
runtime.mark_degraded("MTP web server failed to start".into());
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: std::path::PathBuf::from(web.asset_dir),
tls: web
.certificate
.zip(web.key)
.map(|(certificate, key)| web_server::TlsConfig {
certificate: certificate.into(),
key: key.into(),
}),
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()))
}
// --- Omikron connection ---
let omikron_result =
omikron_connector::omikron_connection::get_omikron_connection(runtime.cancellation.clone())
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;
if omikron_result.is_none() {
runtime.mark_degraded("Omikron connection unavailable".into());
}
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)");
// --- Main lifecycle loop ---
runtime.cancellation.cancelled().await;
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);
// Wait a moment for in-flight operations to complete
tokio::time::sleep(Duration::from_millis(500)).await;
let _ = runtime
.tasks
.join_with_timeout(Duration::from_secs(5))
.await;
let exit_code = reason.exit_code();
log!("iota-daemon exited (code: {})", exit_code);
std::process::exit(exit_code);
ExitCode::from(exit_code as u8)
}

11
iota-installer/Cargo.toml Normal file
View file

@ -0,0 +1,11 @@
[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" }

187
iota-installer/src/lib.rs Normal file
View file

@ -0,0 +1,187 @@
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 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!("/etc/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/lib/iota/iota-daemon",
],
)?;
run("systemd-sysusers", &[])?;
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"])?;
if !Path::new("/run/iota/iota.sock").exists() {
bail!("systemd socket is active but /run/iota/iota.sock was not created");
}
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")
}
}
#[cfg(test)]
mod tests {
#[test]
fn service_uses_installed_daemon_and_declared_identities() {
let service = include_str!("../../systemd/iota-daemon.service");
let socket = include_str!("../../systemd/iota-daemon.socket");
let sysusers = include_str!("../../systemd/sysusers.d/iota.conf");
assert!(service.contains("ExecStart=/usr/local/lib/iota/iota-daemon"));
assert!(service.contains("User=iota"));
assert!(service.contains("Group=iota"));
assert!(socket.contains("SocketUser=iota"));
assert!(socket.contains("SocketGroup=iota-operators"));
assert!(socket.contains("NonBlocking=true"));
assert!(sysusers.contains("u iota "));
assert!(sysusers.contains("g iota-operators"));
}
}

View file

@ -2,9 +2,10 @@ pub mod protocol;
pub mod transport;
pub use protocol::{
ClientMessage, DaemonMessage, HelloAck, LogEntry, StateSnapshot, MetricSample,
RequestEnvelope, ResponseEnvelope, ResponseResult, LocalRequest, IpcErrorCode,
ConnectionStatus, StartupPhase, LifecycleEvent,
ClientMessage, ComponentHealth, ComponentId, ConnectionStatus, DaemonMessage, DeploymentMode,
ExitIntent, HealthStatus, HelloAck, IpcErrorCode, LifecycleEvent, LifecyclePhase, LocalRequest,
LogEntry, MetricSample, RequestEnvelope, ResponseEnvelope, ResponseResult, StartupPhase,
StateSnapshot, SupervisorKind,
};
pub use transport::{read_msg, write_msg};

View file

@ -7,10 +7,17 @@ use serde::{Deserialize, Serialize};
#[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> },
Hello {
supported_versions: Vec<u16>,
},
Subscribe {
log_classes: Vec<String>,
metric_interval_ms: Option<u64>,
},
Request(RequestEnvelope),
Ping { seq: u64 },
Ping {
seq: u64,
},
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@ -26,14 +33,31 @@ pub enum LocalRequest {
GetStatus,
ListTasks,
ListUsers,
CreateUser { username: String },
RemoveUser { user_id: i64 },
CreateUser {
username: String,
},
RemoveUser {
user_id: i64,
},
ReconnectOmikron,
RotateIotaIdentity,
RequestProcessExit {
intent: ExitIntent,
},
GetDaemonStatus,
#[serde(skip)]
RestartDaemon,
#[serde(skip)]
StopDaemon,
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ExitIntent {
Stop,
Restart,
}
// ---------------------------------------------------------------------------
// Daemon → Client
// ---------------------------------------------------------------------------
@ -42,13 +66,19 @@ pub enum LocalRequest {
#[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 },
Pong {
seq: u64,
},
LifecycleEvent(LifecycleEvent),
Gap { skipped: u64 },
Gap {
skipped: u64,
},
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@ -58,6 +88,14 @@ pub struct HelloAck {
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)]
@ -84,6 +122,10 @@ pub enum IpcErrorCode {
UnsupportedVersion,
NotReady,
Disconnected,
Timeout,
Cancelled,
Unauthorized,
InternalFailure,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@ -114,6 +156,69 @@ pub enum StartupPhase {
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
// ---------------------------------------------------------------------------
@ -134,6 +239,18 @@ pub struct StateSnapshot {
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)]

View file

@ -59,42 +59,52 @@ impl LanguagePack {
}
pub fn load_language(&mut self, language: &str) {
let path = format!("languages/{}/", language);
let files = [
"frontend.json",
"omikron.json",
"buttons.json",
"debug.json",
"general.json",
];
let frontend_messages = file_util::load_file(&path, "frontend.json");
let frontend_messages = parse(&frontend_messages).unwrap();
for (key, value) in frontend_messages.entries() {
self.language
.insert(key.to_string(), value.as_str().unwrap().to_string());
// The daemon can initialize the logger before iota-core has created
// the generated language files. Create the built-in pack on demand.
if language == "en_INT"
&& files
.iter()
.any(|file| !file_util::has_file(&format!("languages/{language}/"), file))
{
let _ = crate::language_creator::create_languages();
}
let omikron_messages = file_util::load_file(&path, "omikron.json");
let omikron_messages = parse(&omikron_messages).unwrap();
for (key, value) in omikron_messages.entries() {
self.language
.insert(key.to_string(), value.as_str().unwrap().to_string());
let loaded = files
.iter()
.all(|file| self.load_file(&format!("languages/{language}/"), file));
// A process may have been interrupted while an older version was
// writing a language file. Regenerate the default pack once in that
// case, and still leave custom language packs non-fatal.
if !loaded && language == "en_INT" {
self.language.clear();
let _ = crate::language_creator::create_languages();
for file in files {
let _ = self.load_file(&format!("languages/{language}/"), file);
}
}
}
let button_texts = file_util::load_file(&path, "buttons.json");
let button_texts = parse(&button_texts).unwrap();
for (key, value) in button_texts.entries() {
self.language
.insert(key.to_string(), value.as_str().unwrap().to_string());
}
fn load_file(&mut self, path: &str, file: &str) -> bool {
let contents = file_util::load_file(path, file);
let Ok(messages) = parse(&contents) else {
return false;
};
let debug_messages = file_util::load_file(&path, "debug.json");
let debug_messages = parse(&debug_messages).unwrap();
for (key, value) in debug_messages.entries() {
self.language
.insert(key.to_string(), value.as_str().unwrap().to_string());
for (key, value) in messages.entries() {
if let Some(value) = value.as_str() {
self.language.insert(key.to_string(), value.to_string());
}
let general_messages = file_util::load_file(&path, "general.json");
let general_messages = parse(&general_messages).unwrap();
for (key, value) in general_messages.entries() {
self.language
.insert(key.to_string(), value.as_str().unwrap().to_string());
}
true
}
pub fn get_translation(&self, key: &str) -> String {
match self.language.get(key) {

View file

@ -97,15 +97,17 @@ pub fn startup() {
format!("{} ", msg.prefix)
};
let _ = writeln!(
file,
let line1 = format!(
"{} {}{}",
fixed_box(&msg.timestamp_ms.to_string(), 13),
prefix,
resolved_message
);
let line2 = format!(" {}", timestamp);
let _ = writeln!(file, " {}", timestamp);
let _ = writeln!(file, "{}\n{}", line1, line2);
let _ = writeln!(std::io::stderr(), "{}\n{}", line1, line2);
let entry = UiLogEntry {
timestamp_ms: msg.timestamp_ms,

4
iota-paths/Cargo.toml Normal file
View file

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

156
iota-paths/src/lib.rs Normal file
View file

@ -0,0 +1,156 @@
use std::path::PathBuf;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SocketScope {
User,
System,
}
fn home_dir() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
}
pub fn data_dir() -> PathBuf {
if let Some(path) = std::env::var_os("IOTA_DATA_DIR") {
return PathBuf::from(path);
}
#[cfg(target_os = "linux")]
{
return std::env::var_os("XDG_STATE_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| home_dir().join(".local/state"))
.join("iota");
}
#[cfg(target_os = "macos")]
{
return home_dir().join("Library/Application Support/Iota");
}
#[cfg(target_os = "windows")]
{
return std::env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
.unwrap_or_else(home_dir)
.join("Tensamin/Iota");
}
#[allow(unreachable_code)]
home_dir().join(".iota")
}
pub fn config_dir() -> PathBuf {
if let Some(path) = std::env::var_os("IOTA_CONFIG_DIR") {
return PathBuf::from(path);
}
#[cfg(target_os = "linux")]
{
return std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| home_dir().join(".config"))
.join("iota");
}
#[cfg(target_os = "macos")]
{
return home_dir().join("Library/Application Support/Iota");
}
#[cfg(target_os = "windows")]
{
return std::env::var_os("APPDATA")
.map(PathBuf::from)
.unwrap_or_else(home_dir)
.join("Tensamin/Iota");
}
#[allow(unreachable_code)]
home_dir().join(".iota")
}
pub fn socket_override() -> Option<PathBuf> {
std::env::var_os("IOTA_SOCKET").map(PathBuf::from)
}
pub fn socket_path(scope: SocketScope) -> PathBuf {
if let Some(path) = socket_override() {
return path;
}
match scope {
SocketScope::User => data_dir().join("iota.sock"),
SocketScope::System => PathBuf::from("/run/iota/iota.sock"),
}
}
pub fn socket_lock_path(scope: SocketScope) -> PathBuf {
let socket = socket_path(scope);
PathBuf::from(format!("{}.lock", socket.display()))
}
pub fn daemon_executable() -> PathBuf {
if let Some(path) = std::env::var_os("IOTA_DAEMON_PATH") {
return PathBuf::from(path);
}
if let Ok(exe) = std::env::current_exe() {
if let Some(path) = exe.parent().map(|p| p.join("iota-daemon")) {
if path.is_file() {
return path;
}
}
}
#[cfg(target_os = "linux")]
{
let installed = PathBuf::from("/usr/local/lib/iota/iota-daemon");
if installed.is_file() {
return installed;
}
}
PathBuf::from("iota-daemon")
}
pub fn updater_executable() -> PathBuf {
std::env::var_os("IOTA_UPDATER_PATH")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("iota-updater"))
}
pub fn install_root() -> PathBuf {
std::env::var_os("IOTA_INSTALL_ROOT")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/usr/local/lib/iota"))
}
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 {
data_dir().join("update.lock")
}
pub fn updater_status_path() -> PathBuf {
data_dir().join("update-status.json")
}
pub fn updater_staging_dir() -> PathBuf {
data_dir().join("update-staging")
}
pub fn web_asset_dir() -> PathBuf {
std::env::var_os("IOTA_WEB_ASSET_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| data_dir().join("web"))
}
pub fn daemon_endpoints() -> Vec<PathBuf> {
if let Some(path) = socket_override() {
return vec![path];
}
vec![
socket_path(SocketScope::User),
socket_path(SocketScope::System),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn explicit_data_directory_wins() {
assert!(!data_dir().as_os_str().is_empty());
}
}

View file

@ -0,0 +1,8 @@
[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"] }

View file

@ -0,0 +1,459 @@
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::{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"];
pub struct RealExecutor;
#[async_trait]
impl CommandExecutor for RealExecutor {
async fn output(
&self,
program: &str,
args: &[&str],
) -> Result<CommandOutput, ProcessManagerError> {
let child = Command::new(program)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(false)
.spawn()
.map_err(|e| {
ProcessManagerError::new(
ProcessManagerErrorKind::CommandUnavailable,
format!("Could not run {program}: {e}"),
)
})?;
let output = timeout(PROCESS_MANAGER_TIMEOUT, child.wait_with_output())
.await
.map_err(|_| {
ProcessManagerError::new(
ProcessManagerErrorKind::TimedOut,
format!(
"{program} timed out after {} seconds",
PROCESS_MANAGER_TIMEOUT.as_secs()
),
)
})?
.map_err(|e| {
ProcessManagerError::new(ProcessManagerErrorKind::CommandFailed, e.to_string())
})?;
Ok(CommandOutput {
success: output.status.success(),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
})
}
}
pub struct SystemdManager {
executor: Arc<dyn CommandExecutor>,
}
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);
executor
.output("systemctl", &["--version", &COMMON[0], &COMMON[1]])
.await
.ok()
.filter(|r| r.success)
.map(|_| Self { executor })
}
#[cfg(test)]
pub fn with_executor(executor: Arc<dyn CommandExecutor>) -> Self {
Self { executor }
}
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 set_iota_startup_mode(
&self,
mode: StartupMode,
) -> Result<DaemonStartupStatus, ProcessManagerError> {
match mode {
StartupMode::AlwaysOn => {
self.run(&["disable", SOCKET]).await?;
self.run(&["enable", "--now", SERVICE]).await?;
self.verify(DetectedStartupMode::AlwaysOn).await
}
StartupMode::SocketActivated => {
self.run(&["disable", "--now", SERVICE]).await?;
self.run(&["enable", "--now", SOCKET]).await?;
self.verify(DetectedStartupMode::SocketActivated).await
}
}
}
async fn unit_action(&self, action: &[&str]) -> Result<(), ProcessManagerError> {
self.run(action).await
}
async fn disable_startup(&self) -> Result<DaemonStartupStatus, ProcessManagerError> {
self.run(&["disable", "--now", SERVICE]).await?;
self.run(&["disable", "--now", 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()));
}
}
}
#[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

@ -12,7 +12,7 @@ use std::thread;
use std::time::Duration;
#[cfg(feature = "legacy-globals")]
use sysinfo::{RefreshKind, System};
use tokio::sync::RwLock;
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. */
@ -45,13 +45,13 @@ impl Default for DaemonState {
* with the daemon and is populated from daemon IPC messages. */
#[derive(Clone)]
pub struct ClientState {
pub app: Arc<Mutex<AppState>>,
pub app: Arc<TokioMutex<AppState>>,
}
impl ClientState {
pub fn new() -> Self {
Self {
app: Arc::new(Mutex::new(AppState::new())),
app: Arc::new(TokioMutex::new(AppState::new())),
}
}
}
@ -94,6 +94,7 @@ pub struct AppState {
pub net_up: Vec<(f64, f64)>,
pub net_down: Vec<(f64, f64)>,
pub sys_info: String,
next_sample_id: u64,
}
impl AppState {
@ -106,6 +107,7 @@ impl AppState {
net_up: Vec::new(),
net_down: Vec::new(),
sys_info: String::from("Loading..."),
next_sample_id: 0,
}
}
@ -121,40 +123,51 @@ impl AppState {
}
pub fn push_cpu(&mut self, pt: (f64, f64)) {
self.cpu.push(pt);
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)) {
self.ram.push(pt);
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) {
self.ping.push((self.ping.len() as f64, pt));
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)) {
self.net_up.push(pt);
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)) {
self.net_down.push(pt);
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>>(),
@ -218,15 +231,16 @@ pub static RELOAD: Lazy<RwLock<bool>> = Lazy::new(|| RwLock::new(true));
pub static ACTIVE_TASKS: Lazy<DashSet<String>> = Lazy::new(|| DashSet::new());
#[cfg(feature = "legacy-globals")]
pub fn setup() {
ACTIVE_TASKS.insert("System info loader".to_string());
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 *SHUTDOWN.read().await {
if *state.shutdown.read().await {
break;
}
sys.refresh_all();
@ -258,7 +272,7 @@ pub fn setup() {
let net_up = delta_transmitted as f64;
{
let mut st = APP_STATE.lock().unwrap();
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));
@ -274,6 +288,21 @@ pub fn setup() {
thread::sleep(Duration::from_millis(5));
}
}
ACTIVE_TASKS.remove("System info loader");
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));
}
}

View file

@ -242,7 +242,7 @@ pub fn save_users() {
// No-op: users are auto-saved via SQLite.
}
pub async fn load_users() -> std::io::Result<()> {
pub fn load_users_sync() -> std::io::Result<()> {
// Users are loaded from SQLite on demand. This function is kept for API compat.
// If we need to migrate from a legacy users.json file, we can do so here.
let content = load_file("", "users.json");

View file

@ -13,6 +13,8 @@ pub struct IotaConfig {
pub iota_id: Option<u64>,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default)]
pub web: WebSettings,
#[serde(skip_serializing_if = "Option::is_none")]
pub omikron_host: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@ -27,6 +29,54 @@ pub struct IotaConfig {
pub read_receipts_enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WebMode {
Disabled,
Loopback,
Network,
}
impl Default for WebMode {
fn default() -> Self {
Self::Disabled
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebSettings {
#[serde(default)]
pub mode: WebMode,
#[serde(default = "default_web_bind")]
pub bind: String,
#[serde(default = "default_port")]
pub port: u16,
#[serde(default = "default_web_asset_dir")]
pub asset_dir: String,
pub certificate: Option<String>,
pub key: Option<String>,
#[serde(default)]
pub required: bool,
}
fn default_web_bind() -> String {
"127.0.0.1".into()
}
fn default_web_asset_dir() -> String {
"web".into()
}
impl Default for WebSettings {
fn default() -> Self {
Self {
mode: WebMode::default(),
bind: default_web_bind(),
port: default_port(),
asset_dir: default_web_asset_dir(),
certificate: None,
key: None,
required: false,
}
}
}
const fn default_port() -> u16 {
1984
}
@ -40,6 +90,7 @@ impl Default for IotaConfig {
Self {
iota_id: None,
port: default_port(),
web: WebSettings::default(),
omikron_host: None,
omikron_port: None,
keyring: None,

View file

@ -29,4 +29,5 @@ serde = "1.0.228"
tempfile = "3.27.0"
anyhow = "1.0.102"
semver = "1.0.28"
self-replace = "1.5.0"
ed25519-dalek = "2.2.0"
serde_json = "1.0"

View file

@ -1,163 +1,13 @@
/* This file is used for the auto update function for the Iota.
* It connects to the git server from methanium and checks if
* the version has updated inside the cargo.toml file.
* It is made by Yolokit and pasted in by AlexEmmet */
pub mod manifest;
pub mod transaction;
use anyhow::{Context, Result, anyhow};
use iota_logger::log;
use self_replace::self_replace;
use semver::Version;
use std::fs::File;
use tempfile::NamedTempFile;
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const API_BASE: &str = "https://git.methanium.net/api/v1";
const OWNER: &str = "Tensamin";
const REPO: &str = "Iota";
#[derive(Debug)]
struct Release {
tag_name: String,
assets: Vec<Asset>,
}
#[derive(Debug)]
struct Asset {
name: String,
browser_download_url: String,
}
async fn latest_release() -> Result<Release> {
let url = format!("{API_BASE}/repos/{OWNER}/{REPO}/releases/latest");
let response = reqwest::get(&url)
.await
.context("failed to query latest release.")?;
if !response.status().is_success() {
return Err(anyhow!("release API returned {}", response.status()));
}
let text = response
.text()
.await
.context("failed to read response text")?;
let parsed = json::parse(&text).map_err(|e| anyhow!("failed to parse JSON: {}", e))?;
let tag_name = parsed["tag_name"]
.as_str()
.ok_or_else(|| anyhow!("missing tag_name"))?
.to_string();
let assets_json = parsed["assets"].members().collect::<Vec<_>>();
let mut assets = Vec::new();
for asset in assets_json {
let name = asset["name"]
.as_str()
.ok_or_else(|| anyhow!("missing asset name"))?
.to_string();
let browser_download_url = asset["browser_download_url"]
.as_str()
.ok_or_else(|| anyhow!("missing download url"))?
.to_string();
assets.push(Asset {
name,
browser_download_url,
});
}
Ok(Release { tag_name, assets })
}
async fn parse_tag_version(tag: &str) -> Result<Version> {
let normalized = tag.strip_prefix('v').unwrap_or(tag);
Ok(Version::parse(normalized)?)
}
async fn current_version() -> Result<Version> {
Ok(Version::parse(CURRENT_VERSION)?)
}
async fn asset_name_for_current_platform() -> String {
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
match (os, arch) {
("linux", "x86_64") => "iota-linux-x86_64".to_string(),
("linux", "aarch64") => "iota-linux-aarch64".to_string(),
("windows", "x86_64") => "iota-windows-x86_64.exe".to_string(),
("macos", "x86_64") => "iota-macos-x86_64".to_string(),
("macos", "aarch64") => "iota-macos-aarch64".to_string(),
_ => panic!("unsupported platform: {os}/{arch}"),
}
}
async fn download_asset(url: &str) -> Result<NamedTempFile> {
let response = reqwest::get(url)
.await
.context("failed to download asset")?;
if !response.status().is_success() {
return Err(anyhow!("asset download returned {}", response.status()));
}
let tmp = NamedTempFile::new().context("failed to create temp file")?;
let _out = File::create(tmp.path()).context("failed to open temp file")?;
let bytes = response
.bytes()
.await
.context("failed to read response bytes")?;
std::fs::write(tmp.path(), &bytes).context("failed to write file")?;
Ok(tmp)
}
async fn check_for_update() -> Result<Option<Release>> {
let current = current_version().await?;
let release = latest_release().await?;
let latest = parse_tag_version(&release.tag_name).await?;
if latest > current {
Ok(Some(release))
} else {
Ok(None)
}
}
async fn perform_update() -> Result<bool> {
let Some(release) = check_for_update().await? else {
return Ok(false);
};
let wanted_asset = asset_name_for_current_platform().await;
let asset = release
.assets
.iter()
.find(|a| a.name == wanted_asset)
.ok_or_else(|| anyhow!("no matching asset found: {}", wanted_asset))?;
log!("Downloading update: {}", asset.name);
let downloaded = download_asset(&asset.browser_download_url).await?;
self_replace(downloaded.path()).context("failed to replace current executable")?;
Ok(true)
}
use anyhow::Result;
/// Compatibility entry point used by the UI. Updates are now manifest-driven;
/// this function only checks and never replaces the invoking executable.
pub async fn check_update() -> Result<bool> {
if perform_update().await? {
return Ok(true);
} else {
if std::env::var_os("IOTA_UPDATE_MANIFEST").is_none() {
return Ok(false);
}
Ok(false)
}

21
iota-updater/src/main.rs Normal file
View file

@ -0,0 +1,21 @@
use anyhow::Result;
#[tokio::main]
async fn main() -> Result<()> {
let command = std::env::args().nth(1).unwrap_or_else(|| "status".into());
match command.as_str() {
"check" => println!("update check is manifest-driven"),
"status" => println!("updater ready"),
"apply" | "rollback" => {
return Err(anyhow::anyhow!(
"explicit signed transaction input is required"
));
}
_ => {
return Err(anyhow::anyhow!(
"usage: iota-updater check|status|apply|rollback"
));
}
}
Ok(())
}

View file

@ -0,0 +1,83 @@
use anyhow::{Context, Result, bail};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ReleaseManifest {
pub product_version: String,
pub channel: String,
pub published_at: String,
pub minimum_data_schema: u64,
pub supported_ipc_min: u16,
pub supported_ipc_max: u16,
pub artifacts: Vec<Artifact>,
pub release_signing_key_id: String,
pub rollback_compatible: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Artifact {
pub role: String,
pub os: String,
pub architecture: String,
pub path: String,
pub url: String,
pub sha256: String,
pub size: u64,
}
pub fn canonical_bytes(manifest: &ReleaseManifest) -> Result<Vec<u8>> {
Ok(serde_json::to_vec(manifest)?)
}
pub fn verify_signature(
manifest: &ReleaseManifest,
signature: &[u8],
public_key: &[u8; 32],
) -> Result<()> {
let key = VerifyingKey::from_bytes(public_key).context("invalid release public key")?;
let signature = Signature::from_slice(signature).context("invalid release signature")?;
key.verify(&canonical_bytes(manifest)?, &signature)
.context("release manifest signature verification failed")
}
pub fn verify_artifact(path: &std::path::Path, artifact: &Artifact) -> Result<()> {
let metadata = std::fs::metadata(path)?;
if metadata.len() != artifact.size {
bail!("artifact size mismatch for {}", artifact.path);
}
let mut file = std::fs::File::open(path)?;
let mut hasher = Sha256::new();
let mut buffer = [0u8; 64 * 1024];
loop {
let read = std::io::Read::read(&mut file, &mut buffer)?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
let actual = hex::encode(hasher.finalize());
if actual != artifact.sha256.to_ascii_lowercase() {
bail!("artifact hash mismatch for {}", artifact.path);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_size_or_hash_mismatch() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("iota-daemon");
std::fs::write(&path, b"daemon").unwrap();
let artifact = Artifact {
role: "daemon".into(),
os: "linux".into(),
architecture: "x86_64".into(),
path: "bin/iota-daemon".into(),
url: "https://example.invalid".into(),
sha256: "00".repeat(32),
size: 6,
};
assert!(verify_artifact(&path, &artifact).is_err());
}
}

View file

@ -0,0 +1,79 @@
use crate::manifest::{Artifact, verify_artifact};
use anyhow::{Context, Result};
use std::{
fs,
path::{Path, PathBuf},
};
#[derive(Clone, Debug)]
pub struct UpdateTransaction {
pub root: PathBuf,
pub staging: PathBuf,
}
impl UpdateTransaction {
pub fn new(root: impl Into<PathBuf>) -> Self {
let root = root.into();
Self {
staging: root.join(".staging"),
root,
}
}
pub fn acquire(&self) -> Result<fs::File> {
fs::create_dir_all(&self.root)?;
let path = self.root.join("update.lock");
let file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.context("update already in progress")?;
Ok(file)
}
pub fn stage_artifact(&self, source: &Path, artifact: &Artifact) -> Result<PathBuf> {
fs::create_dir_all(&self.staging)?;
let target = self.staging.join(&artifact.path);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(source, &target)?;
verify_artifact(&target, artifact)?;
Ok(target)
}
pub fn activate(&self, version: &str) -> Result<()> {
let version_dir = self.root.join("versions").join(version);
fs::create_dir_all(version_dir.parent().unwrap())?;
fs::rename(&self.staging, &version_dir).context("activate staged release")?;
let current_tmp = self.root.join("current.new");
let _ = fs::remove_file(&current_tmp);
std::os::unix::fs::symlink(&version_dir, &current_tmp)?;
fs::rename(current_tmp, self.root.join("current"))?;
Ok(())
}
pub fn rollback(&self, previous: &str) -> Result<()> {
let current = self.root.join("current");
let tmp = self.root.join("current.rollback");
let _ = fs::remove_file(&tmp);
std::os::unix::fs::symlink(self.root.join("versions").join(previous), &tmp)?;
fs::rename(tmp, current)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lock_is_exclusive_and_activation_switches_current() {
let dir = tempfile::tempdir().unwrap();
let tx = UpdateTransaction::new(dir.path());
let lock = tx.acquire().unwrap();
assert!(tx.acquire().is_err());
drop(lock);
std::fs::create_dir_all(&tx.staging).unwrap();
std::fs::write(tx.staging.join("manifest.json"), b"ok").unwrap();
tx.activate("1.0.0").unwrap();
assert_eq!(
std::fs::read_to_string(dir.path().join("current/manifest.json")).unwrap(),
"ok"
);
}
}

View file

@ -4,6 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
iota-paths = { path = "../iota-paths" }
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = [
"crypto"
] }

View file

@ -162,10 +162,7 @@ pub fn get_children(path: &str) -> Vec<String> {
}
pub fn get_directory() -> String {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.to_string_lossy()
.to_string()
iota_paths::data_dir().to_string_lossy().to_string()
}
// Helper to download the zip file content to a file on disk

View file

@ -5,5 +5,10 @@ edition = "2024"
[dependencies]
iota-cli = { path = "../iota-cli" }
iota-ipc = { path = "../iota-ipc" }
iota-installer = { path = "../iota-installer" }
iota-core = { path = "../iota-core" }
iota-process-manager = { path = "../iota-process-manager" }
iota-paths = { path = "../iota-paths" }
tokio = { version = "1.50.0", features = ["full"] }
tokio-util = { version = "0.7", features = ["rt"] }

154
iota/src/cli_args.rs Normal file
View file

@ -0,0 +1,154 @@
use iota_cli::theme::ThemeName;
#[derive(Debug)]
pub struct CliInvocation {
pub theme_override: Option<ThemeName>,
pub command: Command,
}
#[derive(Debug, PartialEq, Eq)]
pub enum Command {
Dashboard,
Help,
Install {
bundle: String,
operator: Option<String>,
},
Status,
Tasks,
UsersList,
DaemonRestart {
confirmed: bool,
},
DaemonStop {
confirmed: bool,
},
DaemonStopProcess,
DaemonEnable {
mode: String,
},
DaemonDisableStartup,
DaemonDaemonStatus,
}
impl CliInvocation {
pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self, String> {
let mut theme_override = None;
let mut command = Vec::new();
let mut args = args.into_iter();
while let Some(argument) = args.next() {
if argument == "--theme" {
let value = args.next().ok_or_else(|| {
format!(
"--theme requires a value ({})",
ThemeName::supported_names()
)
})?;
theme_override = Some(value.parse()?);
} else if let Some(value) = argument.strip_prefix("--theme=") {
theme_override = Some(value.parse()?);
} else {
command.push(argument);
}
}
let command = match command.as_slice() {
[] => Command::Dashboard,
[help] if help == "help" || help == "--help" => Command::Help,
[status] if status == "status" => Command::Status,
[tasks] if tasks == "tasks" => Command::Tasks,
[noun, verb] if noun == "users" && verb == "list" => Command::UsersList,
[noun, verb, flag] if noun == "daemon" && verb == "restart" => Command::DaemonRestart {
confirmed: flag == "--yes",
},
[noun, verb] if noun == "daemon" && verb == "restart" => {
Command::DaemonRestart { confirmed: false }
}
[noun, verb, flag] if noun == "daemon" && verb == "stop" => Command::DaemonStop {
confirmed: flag == "--yes",
},
[noun, verb] if noun == "daemon" && verb == "stop" => {
Command::DaemonStop { confirmed: false }
}
[noun, verb] if noun == "daemon" && verb == "stop-process" => {
Command::DaemonStopProcess
}
[noun, verb] if noun == "daemon" && verb == "disable-startup" => {
Command::DaemonDisableStartup
}
[noun, verb] if noun == "daemon" && verb == "status" => Command::DaemonDaemonStatus,
[noun, verb, flag, mode]
if noun == "daemon" && verb == "enable" && flag == "--mode" =>
{
Command::DaemonEnable { mode: mode.clone() }
}
[noun, verb, bundle_flag, bundle]
if noun == "daemon" && verb == "install" && bundle_flag == "--bundle" =>
{
Command::Install {
bundle: bundle.clone(),
operator: None,
}
}
[noun, verb, bundle_flag, bundle, operator_flag, operator]
if noun == "daemon"
&& verb == "install"
&& bundle_flag == "--bundle"
&& operator_flag == "--operator" =>
{
Command::Install {
bundle: bundle.clone(),
operator: Some(operator.clone()),
}
}
_ => return Err("Unknown command. Run `iota --help`.".into()),
};
Ok(Self {
theme_override,
command,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn removes_global_theme_option() {
let invocation =
CliInvocation::parse(["--theme".into(), "binary".into(), "status".into()]).unwrap();
assert_eq!(invocation.theme_override, Some(ThemeName::Binary));
assert_eq!(invocation.command, Command::Status);
}
#[test]
fn reports_supported_names_for_invalid_theme() {
let error = CliInvocation::parse(["--theme=ultraviolet".into()]).unwrap_err();
assert!(error.contains(ThemeName::supported_names()));
}
#[test]
fn parses_install_operator_without_raw_slice_matching() {
let invocation = CliInvocation::parse([
"daemon".into(),
"install".into(),
"--bundle".into(),
"release.zip".into(),
"--operator".into(),
"alice".into(),
])
.unwrap();
assert_eq!(
invocation.command,
Command::Install {
bundle: "release.zip".into(),
operator: Some("alice".into()),
}
);
}
#[test]
fn parses_unconfirmed_destructive_commands_explicitly() {
let invocation = CliInvocation::parse(["daemon".into(), "stop".into()]).unwrap();
assert_eq!(invocation.command, Command::DaemonStop { confirmed: false });
}
}

View file

@ -0,0 +1,241 @@
use crate::startup_error::StartupError;
use iota_cli::{
ipc_client::IpcClient,
screens::daemon_setup::{
DaemonLaunchMode, DaemonSetupDecision, DaemonSetupScreen, DaemonStartingScreen,
LaunchOption,
},
theme::UiConfig,
ui::UI,
};
use iota_process_manager::ProcessManager;
use std::{
path::{Path, PathBuf},
sync::Arc,
};
use tokio::sync::oneshot;
pub struct Capabilities {
pub executable: Result<PathBuf, StartupError>,
pub socket: Result<(), StartupError>,
pub system: Result<Arc<dyn ProcessManager>, StartupError>,
}
pub struct DaemonEndpoints {
pub local: PathBuf,
pub system: PathBuf,
}
pub struct ConnectionContext {
pub ipc: Arc<IpcClient>,
}
impl Capabilities {
fn options(&self) -> Vec<LaunchOption> {
let once = self
.executable
.as_ref()
.and_then(|_| self.socket.as_ref())
.map(|_| ())
.map_err(ToString::to_string);
let ui = once.clone();
let system = self
.system
.as_ref()
.map(|_| ())
.map_err(ToString::to_string);
vec![
LaunchOption {
mode: DaemonLaunchMode::Once,
enabled: once.is_ok(),
reason: once.err(),
},
LaunchOption {
mode: DaemonLaunchMode::WithUi,
enabled: ui.is_ok(),
reason: ui.err(),
},
LaunchOption {
mode: DaemonLaunchMode::WithSystem,
enabled: system.is_ok(),
reason: system.err(),
},
]
}
}
pub async fn run(
ui: Arc<UI>,
endpoints: &DaemonEndpoints,
caps: Capabilities,
) -> Result<ConnectionContext, StartupError> {
// Try connecting to an already-running daemon before starting a new one.
if let Ok(ipc) = IpcClient::connect(&endpoints.local).await {
return Ok(ConnectionContext { ipc });
}
let options = caps.options();
if !options.iter().any(|o| o.enabled) {
let _ = show(
ui,
options,
"Daemon cannot be started. Correct the reported problem, then Retry, or Exit.",
)
.await?;
return Err(StartupError::Cancelled);
}
if UiConfig::load()
.map(|c| c.daemon_start_policy == iota_cli::theme::DaemonStartPolicy::WithUi)
.unwrap_or(false)
&& options[0].enabled
{
if let Ok(context) = start_local_with_ui(
ui.clone(),
caps.executable.as_ref().unwrap(),
&endpoints.local,
)
.await
{
return Ok(context);
}
if ui.is_shutdown() {
return Err(StartupError::Cancelled);
}
}
loop {
let decision = show(
ui.clone(),
options.clone(),
"The daemon is not running. Choose how to start it.",
)
.await?;
let DaemonSetupDecision::Start(mode) = decision else {
return Err(StartupError::Cancelled);
};
ui.set_root_screen(Box::new(DaemonStartingScreen)).await;
let result = match mode {
DaemonLaunchMode::Once | DaemonLaunchMode::WithUi => {
start_local_with_ui(
ui.clone(),
caps.executable.as_ref().unwrap(),
&endpoints.local,
)
.await
}
DaemonLaunchMode::WithSystem => {
let manager = caps.system.as_ref().unwrap();
tokio::select! {
result = manager.set_iota_startup_mode(iota_process_manager::StartupMode::SocketActivated) => match result {
Ok(_) => tokio::select! {
result = IpcClient::connect_or_activate(&endpoints.system) => result.map(|ipc| ConnectionContext { ipc }).map_err(|e| StartupError::Other(e.to_string())),
_ = ui.wait_for_shutdown() => return Err(StartupError::Cancelled),
},
Err(e) => Err(map_process_manager_error(e)),
},
_ = ui.wait_for_shutdown() => return Err(StartupError::Cancelled),
}
}
};
if ui.is_shutdown() {
return Err(StartupError::Cancelled);
}
match result {
Ok(ipc) => {
if mode == DaemonLaunchMode::WithUi {
let mut cfg =
UiConfig::load().map_err(|error| StartupError::Other(error.to_string()))?;
cfg.daemon_start_policy = iota_cli::theme::DaemonStartPolicy::WithUi;
cfg.save()
.map_err(|error| StartupError::Other(error.to_string()))?;
}
return Ok(ipc);
}
Err(error) => {
let retry = show(
ui.clone(),
options.clone(),
format!("Daemon startup failed: {error}. Select an option to retry, or Exit."),
)
.await?;
if matches!(retry, DaemonSetupDecision::Exit) {
return Err(StartupError::Cancelled);
}
}
}
}
}
fn map_process_manager_error(error: iota_process_manager::ProcessManagerError) -> StartupError {
use iota_process_manager::ProcessManagerErrorKind;
match error.kind() {
ProcessManagerErrorKind::PermissionDenied => {
StartupError::SystemPermissionDenied(error.to_string())
}
ProcessManagerErrorKind::TimedOut => StartupError::SystemCommandTimedOut(error.to_string()),
_ => StartupError::Other(error.to_string()),
}
}
async fn start_local_with_ui(
ui: Arc<UI>,
exe: &Path,
path: &Path,
) -> Result<ConnectionContext, StartupError> {
tokio::select! {
result = crate::local_daemon::launch(ui.clone(), exe, path) => result.map(|ipc| ConnectionContext { ipc }),
_ = ui.wait_for_shutdown() => Err(StartupError::Cancelled),
}
}
async fn show(
ui: Arc<UI>,
options: Vec<LaunchOption>,
message: impl Into<String>,
) -> Result<DaemonSetupDecision, StartupError> {
let (tx, rx) = oneshot::channel();
let screen = DaemonSetupScreen::new(options, message, tx).map_err(|error| {
StartupError::Other(format!("Cannot construct daemon setup screen: {error:?}"))
})?;
ui.set_root_screen(Box::new(screen)).await;
tokio::select! {
decision = rx => Ok(decision.unwrap_or(DaemonSetupDecision::Exit)),
_ = ui.wait_for_shutdown() => Err(StartupError::Cancelled),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_systemd_unit_disables_only_the_system_option() {
let capabilities = Capabilities {
executable: Ok(PathBuf::from("iota-daemon")),
socket: Ok(()),
system: Err(StartupError::Other(
"systemd unit iota-daemon.service was not found".into(),
)),
};
let options = capabilities.options();
assert!(
options
.iter()
.any(|option| option.mode == DaemonLaunchMode::Once && option.enabled)
);
let system = options
.iter()
.find(|option| option.mode == DaemonLaunchMode::WithSystem)
.unwrap();
assert!(!system.enabled);
assert!(system.reason.as_deref().unwrap().contains("was not found"));
}
#[test]
fn system_only_capabilities_do_not_select_disabled_local_mode() {
let capabilities = Capabilities {
executable: Err(StartupError::DaemonExecutableMissing(PathBuf::from(
"iota-daemon",
))),
socket: Err(StartupError::LocalSocketNotWritable(
PathBuf::from("/tmp/iota.sock"),
std::io::Error::other("unavailable"),
)),
system: Err(StartupError::Other("manager unavailable".into())),
};
let options = capabilities.options();
assert!(options.iter().all(|option| !option.enabled));
}
}

120
iota/src/local_daemon.rs Normal file
View file

@ -0,0 +1,120 @@
use crate::startup_error::StartupError;
use iota_cli::{ipc_client::IpcClient, ui::UI};
use std::process::Stdio;
use std::{
collections::VecDeque,
path::Path,
sync::{Arc, Mutex},
time::Duration,
};
use tokio::{
io::{AsyncBufReadExt, BufReader},
process::{Child, Command},
time::Instant,
};
struct LocalDaemonGuard {
child: Option<Child>,
committed: bool,
}
impl LocalDaemonGuard {
fn new(child: Child) -> Self {
Self {
child: Some(child),
committed: false,
}
}
fn commit(mut self) -> Child {
self.committed = true;
self.child.take().expect("local daemon child")
}
}
impl Drop for LocalDaemonGuard {
fn drop(&mut self) {
if !self.committed {
if let Some(mut child) = self.child.take() {
let _ = child.start_kill();
tokio::spawn(async move {
let _ = child.wait().await;
});
}
}
}
}
pub async fn launch(
ui: Arc<UI>,
executable: &Path,
socket: &Path,
) -> Result<Arc<IpcClient>, StartupError> {
let mut child = Command::new(executable)
.env("IOTA_SOCKET", socket)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.kill_on_drop(false)
.spawn()
.map_err(|e| StartupError::DaemonExited {
message: format!("Could not start daemon: {e}"),
})?;
let diagnostics = Arc::new(Mutex::new(VecDeque::<String>::with_capacity(64)));
if let Some(stderr) = child.stderr.take() {
let diagnostics = diagnostics.clone();
tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
let mut recent = diagnostics.lock().unwrap();
if recent.len() == 64 {
recent.pop_front();
}
recent.push_back(line);
}
});
}
let mut guard = LocalDaemonGuard::new(child);
let deadline = Instant::now() + Duration::from_secs(20);
let cancellation = ui.cancellation_token();
loop {
let result = tokio::select! {
status = guard.child.as_mut().expect("child").wait() => {
let status = match status { Ok(status) => status.to_string(), Err(error) => format!("wait failed: {error}") };
return Err(StartupError::DaemonExited { message: format_diagnostic(format!("daemon exited with {status}"), &diagnostics) });
}
connection = IpcClient::connect(socket) => connection,
_ = cancellation.cancelled() => return Err(StartupError::Cancelled),
_ = tokio::time::sleep_until(deadline) => return Err(StartupError::DaemonExited { message: format_diagnostic("timed out waiting for IPC handshake".into(), &diagnostics) }),
};
match result {
Ok(client) => {
// The daemon was launched with kill_on_drop(false) so it
// survives after we release the child handle. Let it run
// independently; future CLI instances reconnect via IPC.
let _child = guard.commit();
return Ok(client);
}
Err(_error) if Instant::now() < deadline => {
tokio::time::sleep(Duration::from_millis(200)).await
}
Err(error) => {
return Err(StartupError::DaemonExited {
message: format_diagnostic(
format!("timed out waiting for IPC handshake: {error}"),
&diagnostics,
),
});
}
}
}
}
fn format_diagnostic(message: String, diagnostics: &Arc<Mutex<VecDeque<String>>>) -> String {
let lines = diagnostics.lock().unwrap();
if lines.is_empty() {
message
} else {
format!(
"{message}; daemon stderr: {}",
lines.iter().cloned().collect::<Vec<_>>().join(" | ")
)
}
}

View file

@ -1,31 +1,292 @@
use iota_cli::{ipc_client::IpcClient, screens::main_screen::MainScreen, ui::start_tui};
use std::path::PathBuf;
use iota_cli::{
ipc_client::IpcClient,
screens::main_screen::MainScreen,
theme,
ui::start_bootstrap_tui_with_theme,
};
use iota_ipc::{LocalRequest, ResponseResult};
use iota_process_manager::detect;
use std::{path::Path, process::ExitCode, sync::Arc};
fn socket_path() -> PathBuf {
std::env::var_os("IOTA_SOCKET")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/run/iota/iota.sock"))
}
mod cli_args;
mod daemon_setup_flow;
mod local_daemon;
mod startup_error;
use cli_args::{CliInvocation, Command};
use startup_error::StartupError;
#[tokio::main(flavor = "multi_thread")]
async fn main() {
let path = socket_path();
let ipc = match IpcClient::connect_or_activate(&path).await {
Ok(client) => client,
async fn main() -> ExitCode {
match run().await {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!(
"Cannot connect to iota-daemon at {}: {error}",
path.display()
);
eprintln!("Ensure iota-daemon.socket is enabled or iota-daemon is running.");
std::process::exit(1);
if !matches!(error, StartupError::Cancelled) {
eprintln!("{error}");
}
startup_error::exit_code(&error)
}
}
}
async fn run() -> Result<(), StartupError> {
let invocation =
CliInvocation::parse(std::env::args().skip(1)).map_err(StartupError::InvalidCommand)?;
let mut endpoints_iter = iota_paths::daemon_endpoints().into_iter();
let local_endpoint = endpoints_iter
.next()
.expect("path layer always returns an endpoint");
let system_endpoint = endpoints_iter
.next()
.unwrap_or_else(|| local_endpoint.clone());
let endpoints = daemon_setup_flow::DaemonEndpoints {
local: local_endpoint,
system: system_endpoint,
};
match invocation.command {
Command::Help => {
print_help();
Ok(())
}
Command::Install { bundle, operator } => {
iota_installer::install_linux_bundle_with_operator(
Path::new(&bundle),
operator.as_deref(),
)
.map_err(|error| StartupError::Other(format!("Installation failed: {error}")))
}
command => {
if matches!(
command,
Command::DaemonEnable { .. } | Command::DaemonDisableStartup
) {
return run_startup_command(command).await;
}
if !matches!(command, Command::Dashboard) {
match iota_core::consent_state::non_interactive_consent() {
iota_core::consent_state::NonInteractiveConsent::Accepted => {}
iota_core::consent_state::NonInteractiveConsent::RequiresInteractiveAcceptance => {
return Err(StartupError::Consent("Run `iota` in an interactive terminal to review and accept the required terms.".into()));
}
}
let ipc = tokio::select! {
result = connect_available(&endpoints) => result?,
_ = tokio::signal::ctrl_c() => return Err(StartupError::Cancelled),
};
return run_command(ipc, command).await;
}
run_dashboard(invocation.theme_override, endpoints).await
}
}
}
async fn run_startup_command(command: Command) -> Result<(), StartupError> {
let manager = iota_process_manager::detect()
.await
.ok_or_else(|| StartupError::Other("no supported process manager detected".into()))?;
let status = match command {
Command::DaemonEnable { mode } => {
let mode = match mode.as_str() {
"socket" | "socket-activated" => iota_process_manager::StartupMode::SocketActivated,
"always-on" => iota_process_manager::StartupMode::AlwaysOn,
_ => {
return Err(StartupError::InvalidCommand(
"--mode must be socket or always-on".into(),
));
}
};
manager
.enable_startup(mode)
.await
.map_err(|e| StartupError::Other(e.to_string()))?
}
Command::DaemonDisableStartup => manager
.disable_startup()
.await
.map_err(|e| StartupError::Other(e.to_string()))?,
_ => unreachable!(),
};
println!("deployment status: {:?}", status.detected);
Ok(())
}
async fn connect_available(
endpoints: &daemon_setup_flow::DaemonEndpoints,
) -> Result<Arc<IpcClient>, StartupError> {
match IpcClient::connect(&endpoints.local).await {
Ok(client) => Ok(client),
Err(local_error) => IpcClient::connect(&endpoints.system)
.await
.map_err(|system_error| {
if system_error.kind() == std::io::ErrorKind::TimedOut {
StartupError::IpcTimedOut(endpoints.system.clone())
} else if local_error.kind() == std::io::ErrorKind::PermissionDenied {
StartupError::SocketPermissionDenied(endpoints.local.clone())
} else {
StartupError::Other(format!(
"Could not connect to {} or {}: {local_error}; {system_error}",
endpoints.local.display(),
endpoints.system.display()
))
}
}),
}
}
async fn run_dashboard(
theme_override: Option<theme::ThemeName>,
endpoints: daemon_setup_flow::DaemonEndpoints,
) -> Result<(), StartupError> {
use std::io::IsTerminal;
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
return Err(StartupError::Terminal(
"stdin and stdout must be interactive terminals".into(),
));
}
if std::env::var("TERM").as_deref() == Ok("dumb") {
return Err(StartupError::Terminal(
"TERM=dumb does not support the interactive dashboard".into(),
));
}
let session = start_bootstrap_tui_with_theme(theme::resolve(theme::UiConfig::resolve_theme(
theme_override,
)))
.map_err(|error| StartupError::Terminal(error.to_string()))?;
let ui = session.ui();
let result = async {
let consent = iota_core::consent_state::check(ui.clone()).await
.map_err(StartupError::Consent)?;
if consent != (true, true) {
return Err(StartupError::Consent("Cannot continue until the required terms are accepted.".into()));
}
let initial = tokio::select! {
result = connect_available(&endpoints) => result,
_ = ui.wait_for_shutdown() => Err(StartupError::Cancelled),
};
let context = match initial {
Ok(client) => daemon_setup_flow::ConnectionContext { ipc: client },
Err(_) => {
let system = tokio::select! {
manager = detect() => manager.ok_or(StartupError::SystemManagerUnavailable),
_ = ui.wait_for_shutdown() => return Err(StartupError::Cancelled),
}?;
// A missing unit is expected before the system daemon has
// been installed. Keep bootstrap alive and expose that state
// as a disabled setup option instead of treating it as a
// fatal startup error.
let system_capability = tokio::select! {
status = system.iota_startup_status() => status.map(|_| system).map_err(map_process_manager_error),
_ = ui.wait_for_shutdown() => return Err(StartupError::Cancelled),
};
let caps = daemon_setup_flow::Capabilities {
executable: daemon_executable(),
socket: writable_socket_path(&endpoints.local),
system: system_capability,
};
daemon_setup_flow::run(ui.clone(), &endpoints, caps).await?
}
};
let ipc = context.ipc.clone();
ipc.spawn_reconnector();
let ui = start_tui(ipc);
ui.set_screen(Box::new(MainScreen::new(ui.clone()).await))
.await;
while !ui.is_shutdown() {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
ui.attach_daemon(ipc).await;
let main_screen = MainScreen::new(ui.clone()).await;
ui.set_root_screen(Box::new(main_screen)).await;
ui.render().await.map_err(|error| StartupError::Terminal(error.to_string()))?;
ui.wait_for_shutdown().await;
Ok(())
}.await;
let render_failure = session.shutdown().await;
// Terminal restoration comes first; then stop IPC background tasks with
// their own bounded shutdown so a lost daemon cannot retain the process.
if let Some(ipc) = ui.ipc().await {
ipc.shutdown().await;
}
match (result, render_failure) {
(Err(error), _) => Err(error),
(Ok(()), Some(error)) => Err(StartupError::Terminal(error)),
(Ok(()), None) => Ok(()),
}
}
fn map_process_manager_error(error: iota_process_manager::ProcessManagerError) -> StartupError {
use iota_process_manager::ProcessManagerErrorKind::*;
match error.kind() {
PermissionDenied => StartupError::SystemPermissionDenied(error.to_string()),
TimedOut => StartupError::SystemCommandTimedOut(error.to_string()),
_ => StartupError::Other(error.to_string()),
}
}
fn daemon_executable() -> Result<std::path::PathBuf, StartupError> {
let candidate = iota_paths::daemon_executable();
if candidate.is_file() {
Ok(candidate)
} else {
Err(StartupError::DaemonExecutableMissing(candidate))
}
}
fn writable_socket_path(path: &Path) -> Result<(), StartupError> {
let parent = path.parent().ok_or_else(|| {
StartupError::LocalSocketNotWritable(
path.to_path_buf(),
std::io::Error::new(std::io::ErrorKind::InvalidInput, "socket has no parent"),
)
})?;
std::fs::create_dir_all(parent)
.map_err(|e| StartupError::LocalSocketNotWritable(path.to_path_buf(), e))?;
let probe = parent.join(format!(".iota-write-probe-{}", std::process::id()));
std::fs::File::create(&probe)
.map_err(|e| StartupError::LocalSocketNotWritable(path.to_path_buf(), e))?;
let _ = std::fs::remove_file(probe);
Ok(())
}
fn print_help() {
println!(
"Iota operator console\n\nUsage:\n iota [--theme <name>] Open the dashboard\n iota daemon install --bundle <release.zip> [--operator USER]\n iota status Print daemon readiness and tasks\n iota tasks Print active tasks\n iota users list List users\n iota daemon restart --yes\n iota daemon stop --yes\n\nRun the dashboard in an interactive terminal to review required terms."
);
}
async fn run_command(ipc: Arc<IpcClient>, command: Command) -> Result<(), StartupError> {
let request = match command {
Command::Status => LocalRequest::GetStatus,
Command::Tasks => LocalRequest::ListTasks,
Command::UsersList => LocalRequest::ListUsers,
Command::DaemonRestart { confirmed: true } => LocalRequest::RequestProcessExit {
intent: iota_ipc::ExitIntent::Restart,
},
Command::DaemonStop { confirmed: true } => LocalRequest::RequestProcessExit {
intent: iota_ipc::ExitIntent::Stop,
},
Command::DaemonStopProcess => LocalRequest::RequestProcessExit {
intent: iota_ipc::ExitIntent::Stop,
},
Command::DaemonDaemonStatus => LocalRequest::GetDaemonStatus,
Command::DaemonRestart { confirmed: false } | Command::DaemonStop { confirmed: false } => {
return Err(StartupError::InvalidCommand(
"Refusing destructive command without --yes.".into(),
));
}
_ => {
return Err(StartupError::InvalidCommand(
"Command cannot be run headlessly.".into(),
));
}
};
match ipc
.send_request(request)
.await
.map_err(|e| StartupError::Other(e.to_string()))?
{
ResponseResult::Ok(message) => {
println!("{message}");
Ok(())
}
ResponseResult::Error(code) => Err(StartupError::Other(format!(
"Daemon request failed: {code:?}"
))),
}
}

92
iota/src/startup_error.rs Normal file
View file

@ -0,0 +1,92 @@
use std::{fmt, io, path::PathBuf, process::ExitCode};
#[allow(dead_code)]
#[derive(Debug)]
pub enum StartupError {
Cancelled,
DaemonExecutableMissing(PathBuf),
LocalSocketNotWritable(PathBuf, io::Error),
SystemManagerUnavailable,
SystemPermissionDenied(String),
SystemCommandTimedOut(String),
SocketPermissionDenied(PathBuf),
IpcTimedOut(PathBuf),
ProtocolMismatch { daemon: u16, minimum: u16 },
DaemonExited { message: String },
IpcBindUnavailable(String),
Terminal(String),
Consent(String),
InvalidCommand(String),
Other(String),
}
impl StartupError {
pub fn exit_code(&self) -> u8 {
match self {
Self::Cancelled => 130,
Self::InvalidCommand(_) => 2,
_ => 1,
}
}
}
impl fmt::Display for StartupError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Cancelled => f.write_str("Cancelled."),
Self::DaemonExecutableMissing(path) => write!(
f,
"Daemon executable is missing or not executable: {}",
path.display()
),
Self::LocalSocketNotWritable(path, error) => write!(
f,
"Local socket path is not writable ({}): {error}",
path.display()
),
Self::SystemManagerUnavailable => {
f.write_str("No supported system process manager is available.")
}
Self::SystemPermissionDenied(message) => {
write!(f, "System-level authorization is required: {message}")
}
Self::SystemCommandTimedOut(command) => {
write!(f, "System command timed out: {command}")
}
Self::SocketPermissionDenied(path) => {
write!(f, "Permission denied for IPC socket {}", path.display())
}
Self::IpcTimedOut(path) => write!(f, "IPC operation timed out for {}", path.display()),
Self::ProtocolMismatch { daemon, minimum } => write!(
f,
"Daemon protocol {daemon} is incompatible; minimum supported version is {minimum}"
),
Self::DaemonExited { message } => f.write_str(message),
Self::IpcBindUnavailable(message) => {
write!(f, "Daemon IPC listener is unavailable: {message}")
}
Self::Terminal(message) => write!(f, "Interactive terminal is unavailable: {message}"),
Self::Consent(message) | Self::InvalidCommand(message) | Self::Other(message) => {
f.write_str(message)
}
}
}
}
pub fn exit_code(error: &StartupError) -> ExitCode {
ExitCode::from(error.exit_code())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cancellation_and_invalid_commands_have_stable_codes() {
assert_eq!(StartupError::Cancelled.exit_code(), 130);
assert_eq!(StartupError::InvalidCommand("bad".into()).exit_code(), 2);
}
#[test]
fn administrative_errors_are_actionable() {
let error = StartupError::SystemPermissionDenied("run as an administrator".into());
assert!(error.to_string().contains("authorization"));
assert!(error.to_string().contains("administrator"));
}
}

View file

@ -4,6 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
async-trait = "0.1.89"
iota-connection = { path = "../iota-connection" }
iota-logger = { path = "../iota-logger" }
iota-state = { path = "../iota-state" }

View file

@ -0,0 +1,43 @@
use async_trait::async_trait;
use mtp::codec::CommunicationValue;
use std::time::Duration;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum OmikronError {
Disconnected(String),
Timeout(String),
Authentication(String),
Internal(String),
}
impl std::fmt::Display for OmikronError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Disconnected(v)
| Self::Timeout(v)
| Self::Authentication(v)
| Self::Internal(v) => f.write_str(v),
}
}
}
impl std::error::Error for OmikronError {}
pub enum OmikronStartupError {
Construction(String),
InitialConnectionTimeout {
connection: std::sync::Arc<crate::omikron_connection::OmikronConnection>,
},
Authentication,
}
#[async_trait]
pub trait OmikronClient: Send + Sync {
async fn send_message(&self, value: &CommunicationValue) -> Result<(), OmikronError>;
async fn await_response(
&self,
value: &CommunicationValue,
timeout: Duration,
) -> Result<CommunicationValue, OmikronError>;
async fn reconnect(&self) -> Result<(), OmikronError>;
async fn is_connected(&self) -> bool;
}

View file

@ -1,4 +1,8 @@
pub mod client;
pub mod omega_discovery;
pub mod omikron_connection;
pub mod ping_pong_task;
pub mod user_ops;
pub use client::{OmikronClient, OmikronError, OmikronStartupError};
pub use omikron_connection::OmikronConnection;

View file

@ -1,6 +1,6 @@
use dashmap::DashMap;
use dashmap::{DashMap, DashSet};
use iota_logger::{log, log_cv_in, log_cv_out, log_t};
use iota_state::ACTIVE_TASKS;
use iota_state::AppState;
use iota_storage::util::chat_files::{self, MessageState, change_message_state};
use iota_storage::util::config_util::{CONFIG, modify_config};
use iota_storage::util::e2ee_storage::{self, PendingChatSecretForward, StoredChatSecret};
@ -19,6 +19,7 @@ use tokio::time::sleep;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
use crate::client::{OmikronClient, OmikronError};
use crate::omega_discovery;
use iota_connection::message_common::*;
@ -106,7 +107,7 @@ const MAX_CONCURRENT_HANDLERS: usize = 20;
// ============================================================================
pub struct WaitingTask {
pub task: Box<dyn FnOnce(Arc<OmikronConnection>, CommunicationValue) -> bool + Send + Sync>,
pub task: Box<dyn FnOnce(CommunicationValue) -> bool + Send + Sync>,
pub inserted_at: Instant,
}
@ -163,14 +164,20 @@ pub struct OmikronConnection {
pub(crate) missed_pongs: Arc<AtomicU32>,
handler_semaphore: Arc<Semaphore>,
cancellation: CancellationToken,
pub(crate) active_tasks: Arc<DashSet<String>>,
pub(crate) app: Arc<std::sync::Mutex<AppState>>,
}
impl OmikronConnection {
pub fn new() -> Self {
Self::with_cancellation(CancellationToken::new())
pub fn new(active_tasks: Arc<DashSet<String>>, app: Arc<std::sync::Mutex<AppState>>) -> Self {
Self::with_cancellation(CancellationToken::new(), active_tasks, app)
}
pub fn with_cancellation(cancellation: CancellationToken) -> Self {
pub fn with_cancellation(
cancellation: CancellationToken,
active_tasks: Arc<DashSet<String>>,
app: Arc<std::sync::Mutex<AppState>>,
) -> Self {
let (shutdown_tx, _) = watch::channel(false);
let (state_watch_tx, _) = watch::channel(ConnectionState::Disconnected);
@ -190,6 +197,8 @@ impl OmikronConnection {
missed_pongs: Arc::new(AtomicU32::new(0)),
handler_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HANDLERS)),
cancellation,
active_tasks,
app,
}
}
@ -388,7 +397,7 @@ impl OmikronConnection {
*self.heartbeat_handle.lock().await = Some(heartbeat_handle);
{
ACTIVE_TASKS.insert("Omikron Listener".to_string());
self.active_tasks.insert("Omikron Listener".to_string());
}
// Wait for read loop to complete
@ -396,7 +405,7 @@ impl OmikronConnection {
*self.sender.write().await = None;
self.set_state(ConnectionState::Disconnected).await;
{
ACTIVE_TASKS.remove("Omikron Listener");
self.active_tasks.remove("Omikron Listener");
}
if let Some(handle) = self.heartbeat_handle.lock().await.take() {
@ -570,7 +579,7 @@ impl OmikronConnection {
Ok(cv) => {
let msg_id = cv.get_id();
if let Some((_, task)) = WAITING_TASKS.remove(&msg_id) {
if (task.task)(self.clone(), cv.clone()) {
if (task.task)(cv.clone()) {
continue;
}
}
@ -832,7 +841,7 @@ impl OmikronConnection {
let msg_id = cv.get_id();
if let Some((_, task)) = WAITING_TASKS.remove(&msg_id) {
if (task.task)(self.clone(), cv.clone()) {
if (task.task)(cv.clone()) {
return;
}
}
@ -1797,7 +1806,7 @@ impl OmikronConnection {
let response = CommunicationValue::new(CommunicationType::ErrorInternal)
.with_id(key)
.add_typed_default(DataType::Message, DataValue::Str(reason.clone()));
let _ = (waiting_task.task)(OMIKRON_CONNECTION.clone(), response);
let _ = (waiting_task.task)(response);
}
}
}
@ -1821,7 +1830,7 @@ impl OmikronConnection {
WAITING_TASKS.insert(
msg_id,
WaitingTask {
task: Box::new(move |_, response_cv| {
task: Box::new(move |response_cv| {
let _ = tx.send(response_cv);
true
}),
@ -1932,20 +1941,26 @@ impl OmikronConnection {
// Global Instance
// ============================================================================
pub static OMIKRON_CONNECTION: LazyLock<Arc<OmikronConnection>> = LazyLock::new(|| {
let conn = Arc::new(OmikronConnection::new());
start_task_cleanup_loop();
conn
});
pub async fn get_omikron_connection(
pub async fn connect_initial(
cancellation: CancellationToken,
) -> Option<Arc<OmikronConnection>> {
let conn = Arc::new(OmikronConnection::with_cancellation(cancellation));
active_tasks: Arc<DashSet<String>>,
app: Arc<std::sync::Mutex<AppState>>,
) -> Result<Arc<OmikronConnection>, crate::client::OmikronStartupError> {
let conn = Arc::new(OmikronConnection::with_cancellation(
cancellation,
active_tasks,
app,
));
conn.connect().await;
Some(conn)
match conn.await_connection(Some(CONNECTION_TIMEOUT)).await {
Ok(()) => Ok(conn),
Err(_) if conn.has_auth_failure().await => {
Err(crate::client::OmikronStartupError::Authentication)
}
Err(_) => {
Err(crate::client::OmikronStartupError::InitialConnectionTimeout { connection: conn })
}
}
}
impl iota_connection::connection_handler::ConnectionHandler for OmikronConnection {
@ -1973,3 +1988,56 @@ impl iota_connection::connection_handler::ConnectionHandler for OmikronConnectio
OmikronConnection::stop(self).await
}
}
#[async_trait::async_trait]
impl OmikronClient for OmikronConnection {
async fn send_message(&self, value: &CommunicationValue) -> Result<(), OmikronError> {
Self::send_message(self, value)
.await
.map_err(OmikronError::Disconnected)
}
async fn await_response(
&self,
value: &CommunicationValue,
timeout: Duration,
) -> Result<CommunicationValue, OmikronError> {
Self::await_response(self, value, Some(timeout))
.await
.map_err(|error| {
if error.contains("timed out") {
OmikronError::Timeout(error)
} else {
OmikronError::Disconnected(error)
}
})
}
async fn reconnect(&self) -> Result<(), OmikronError> {
let this = Arc::new(Self {
state: self.state.clone(),
state_watch_tx: self.state_watch_tx.clone(),
sender: self.sender.clone(),
connection_loop_handle: self.connection_loop_handle.clone(),
last_ping: self.last_ping.clone(),
heartbeat_handle: self.heartbeat_handle.clone(),
connection_id: self.connection_id,
shutdown_tx: self.shutdown_tx.clone(),
reconnect_on_close: self.reconnect_on_close.clone(),
auth_failure: self.auth_failure.clone(),
app_challenges: self.app_challenges.clone(),
app_sessions: self.app_sessions.clone(),
missed_pongs: self.missed_pongs.clone(),
handler_semaphore: self.handler_semaphore.clone(),
cancellation: self.cancellation.clone(),
active_tasks: self.active_tasks.clone(),
app: self.app.clone(),
});
Self::reconnect(&this).await;
Ok(())
}
async fn is_connected(&self) -> bool {
Self::is_connected(self).await
}
}

View file

@ -1,9 +1,8 @@
use crate::omikron_connection::OmikronConnection;
use dashmap::DashMap;
use iota_state::APP_STATE;
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
use std::sync::atomic::Ordering;
use std::sync::LazyLock;
use std::sync::atomic::Ordering;
use std::time::Instant;
use tokio::time::Duration;
@ -23,7 +22,9 @@ impl OmikronConnection {
.with_id(id)
.add_typed_default(
DataType::LastPing,
DataValue::Array(vec![DataValue::SignedNumber(*self.last_ping.lock().await as i128)]),
DataValue::Array(vec![DataValue::SignedNumber(
*self.last_ping.lock().await as i128,
)]),
);
let _ = self.send_message(&ping_message).await;
@ -37,7 +38,7 @@ impl OmikronConnection {
if let Some((_, send_time)) = PING_TIMES.remove(&id) {
let ping_ms = Instant::now().duration_since(send_time).as_millis() as i64;
*self.last_ping.lock().await = ping_ms;
APP_STATE.lock().unwrap().push_ping_val(ping_ms as f64);
self.app.lock().unwrap().push_ping_val(ping_ms as f64);
}
}
}

View file

@ -8,21 +8,22 @@ use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
use rand_core::{OsRng, RngCore};
use std::time::Duration;
use crate::OmikronClient;
use crate::omega_discovery;
use crate::omikron_connection::OMIKRON_CONNECTION;
pub async fn create_user(username: &str) -> (Option<UserProfile>, Option<String>) {
pub async fn create_user(
connection: &dyn OmikronClient,
username: &str,
) -> (Option<UserProfile>, Option<String>) {
let register_communication_value = CommunicationValue::new(CommunicationType::GetRegister);
let connection = OMIKRON_CONNECTION.clone();
let response_communication_value = match connection
.await_response(&register_communication_value, Some(Duration::from_secs(20)))
.await_response(&register_communication_value, Duration::from_secs(20))
.await
{
Ok(communication_value) => communication_value,
Err(e) => {
log_t!("User creation: {}", e);
log_t!("User creation: {}", e.to_string());
return (None, None);
}
};
@ -68,7 +69,7 @@ pub async fn create_user(username: &str) -> (Option<UserProfile>, Option<String>
.add_typed_default(DataType::ResetToken, DataValue::Str(reset_token));
let response_communication_value = connection
.await_response(&communication_value, Some(Duration::from_secs(20)))
.await_response(&communication_value, Duration::from_secs(20))
.await;
if let Ok(response) = response_communication_value {
@ -84,13 +85,15 @@ pub async fn create_user(username: &str) -> (Option<UserProfile>, Option<String>
save_file(
"",
&format!("{}.tu", username),
&format!("{}@{}::{}", user_id, omega_discovery::omega_host(), keyring_b64),
&format!(
"{}@{}::{}",
user_id,
omega_discovery::omega_host(),
keyring_b64
),
);
add_user(user_profile.clone());
save_users();
(
Some(user_profile),
Some(keyring_b64),
)
(Some(user_profile), Some(keyring_b64))
}

BIN
src/.DS_Store vendored

Binary file not shown.

View file

@ -1,135 +0,0 @@
/* This file is used for the auto update function for the Iota.
* It connects to the git server from methanium and checks if
* the version has updated inside the cargo.toml file.*/
use anyhow::{Context, Result, anyhow};
use semver::Version;
use serde::Deserialize;
use std::fs::File;
use std::io::copy;
use tempfile::NamedTempFile;
use crate::log; // For logging messages into the iota
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
const API_BASE: &str = "https://git.methanium.net/api/v1";
const OWNER: &str = "Tensamin";
const REPO: &str = "Iota";
#[derive(Debug, Deserialize)]
struct Release {
tag_name: String,
assets: Vec<Asset>,
}
#[derive(Debug, Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
}
async fn latest_release() -> Result<Release> {
let url = format!("{API_BASE}/repos/{OWNER}/{REPO}/releases/latest");
let response = reqwest::get(&url)
.await
.context("failed to query latest release.")?;
if !response.status().is_success() {
return Err(anyhow!("release API returned {}", response.status()));
}
Ok(response
.json()
.await
.context("failed to parse release JSON")?)
}
async fn parse_tag_version(tag: &str) -> Result<Version> {
let normalized = tag.strip_prefix('v').unwrap_or(tag);
Ok(Version::parse(normalized)?)
}
async fn current_version() -> Result<Version> {
Ok(Version::parse(CURRENT_VERSION)?)
}
async fn asset_name_for_current_platform() -> String {
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
match (os, arch) {
("linux", "x86_64") => "iota-linux-x86_64".to_string(),
("linux", "aarch64") => "iota-linux-aarch64".to_string(),
("windows", "x86_64") => "iota-windows-x86_64.exe".to_string(),
("macos", "x86_64") => "iota-macos-x86_64".to_string(),
("macos", "aarch64") => "iota-macos-aarch64".to_string(),
_ => panic!("unsupported platform: {os}/{arch}"),
}
}
async fn download_asset(url: &str) -> Result<NamedTempFile> {
let mut response = reqwest::get(url)
.await
.context("failed to download asset")?;
if !response.status().is_success() {
return Err(anyhow!("asset download returned {}", response.status()));
}
let tmp = NamedTempFile::new().context("failed to create temp file")?;
let mut out = File::create(tmp.path()).context("failed to open temp file")?;
let bytes = response
.bytes()
.await
.context("failed to read response bytes")?;
std::fs::write(tmp.path(), &bytes).context("failed to write file")?;
Ok(tmp)
}
async fn check_for_update() -> Result<Option<Release>> {
let current = current_version().await?;
let release = latest_release().await?;
let latest = parse_tag_version(&release.tag_name).await?;
if latest > current {
Ok(Some(release))
} else {
Ok(None)
}
}
async fn perform_update() -> Result<bool> {
let Some(release) = check_for_update().await? else {
return Ok(false);
};
let wanted_asset = asset_name_for_current_platform().await;
let asset = release
.assets
.iter()
.find(|a| a.name == wanted_asset)
.ok_or_else(|| anyhow!("no matching asset found: {}", wanted_asset))?;
log!("Downloading update: {}", asset.name);
let downloaded = download_asset(&asset.browser_download_url).await?;
self_replace::self_replace(downloaded.path())
.context("failed to replace current executable")?;
Ok(true)
}
pub async fn check_update() -> Result<bool> {
if perform_update().await? {
return Ok(true);
} else {
return Ok(false);
}
}

View file

@ -5,13 +5,18 @@ Wants=network-online.target
Requires=iota-daemon.socket
[Service]
Type=notify
ExecStart=/usr/bin/iota-daemon
Type=simple
ExecStart=/usr/local/lib/iota/iota-daemon
User=iota
Group=iota
StateDirectory=iota
StateDirectoryMode=0750
Restart=on-failure
RestartSec=5s
RuntimeDirectory=iota
RuntimeDirectoryMode=0750
Environment=IOTA_SOCKET=/run/iota/iota.sock
Environment=IOTA_DATA_DIR=/var/lib/iota
Environment=IOTA_DEPLOYMENT_MODE=system_always_on
Environment=IOTA_SUPERVISOR=systemd
# Exit code 75 = restart requested (daemon-specific convention)
RestartPreventExitStatus=0

View file

@ -5,9 +5,12 @@ Description=Tensamin Iota daemon IPC socket
ListenStream=/run/iota/iota.sock
SocketMode=0660
SocketUser=iota
SocketGroup=iota
DirectoryMode=0755
SocketGroup=iota-operators
Backlog=5
RemoveOnStop=true
NonBlocking=true
# Enabling this socket starts the daemon on demand when a client connects.
[Install]
WantedBy=sockets.target

View file

@ -0,0 +1,2 @@
g iota-operators -
u iota - "Tensamin Iota daemon" /var/lib/iota

View file

@ -1,55 +1,95 @@
use bytes::Bytes;
use iota_logger::log;
use iota_util::file_util::load_file_vec;
use mtp::host::HostConfig;
use mtp::webserver::{Http3Request, Http3Response, MTPWebServer, WebServerConfig};
use std::net::{IpAddr, Ipv4Addr};
use mtp::webserver::{HttpRequest, HttpResponse, MTPWebServer, WebServerConfig};
use std::{net::IpAddr, path::PathBuf, sync::Arc};
use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
const CERT_PATH: &str = "certs/cert.pem";
const KEY_PATH: &str = "certs/cert.key";
async fn root(_request: Http3Request, response: Http3Response) -> Http3Response {
static_file("index.html", response).await
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WebMode {
Disabled,
Loopback,
Network,
}
async fn static_file(path: &str, response: Http3Response) -> Http3Response {
#[derive(Clone, Debug)]
pub struct TlsConfig {
pub certificate: PathBuf,
pub key: PathBuf,
}
#[derive(Clone, Debug)]
pub struct WebConfig {
pub mode: WebMode,
pub bind: IpAddr,
pub port: u16,
pub asset_dir: PathBuf,
pub tls: Option<TlsConfig>,
pub required: bool,
}
#[derive(Debug)]
pub enum WebServerError {
Disabled,
MissingTls(String),
Io(String),
Startup(String),
}
impl std::fmt::Display for WebServerError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{self:?}")
}
}
impl std::error::Error for WebServerError {}
pub struct WebServerHandle {
cancellation: CancellationToken,
join: Mutex<Option<JoinHandle<()>>>,
}
impl WebServerHandle {
pub async fn shutdown(&self) {
self.cancellation.cancel();
self.join().await;
}
pub async fn join(&self) {
if let Some(join) = self.join.lock().await.take() {
let _ = join.await;
}
}
}
async fn root(asset_dir: PathBuf, _request: HttpRequest, response: HttpResponse) -> HttpResponse {
static_file(asset_dir, "index.html".into(), response).await
}
async fn static_file(asset_dir: PathBuf, path: String, response: HttpResponse) -> HttpResponse {
let file = path.trim_start_matches('/');
let file = if file.is_empty() { "index.html" } else { file };
if file.split('/').any(|component| component == "..") {
return response
.status(http::StatusCode::BAD_REQUEST)
.body("invalid path");
}
let path = std::path::Path::new("web").join(file);
let Some(parent) = path.parent().and_then(|path| path.to_str()) else {
let path = asset_dir.join(file);
let body = match tokio::fs::read(&path).await {
Ok(body) => body,
Err(_) => {
return response
.status(http::StatusCode::NOT_FOUND)
.body("not found");
}
};
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
return response
.status(http::StatusCode::NOT_FOUND)
.body("not found");
};
match load_file_vec(parent, name) {
Ok(body) => response
let name = path.file_name().and_then(|v| v.to_str()).unwrap_or("");
response
.status(http::StatusCode::OK)
.header("content-type", content_type(name))
.body(Bytes::from(body)),
Err(_) => response
.status(http::StatusCode::NOT_FOUND)
.body("not found"),
}
.body(Bytes::from(body))
}
fn content_type(name: &str) -> &'static str {
match std::path::Path::new(name)
.extension()
.and_then(|ext| ext.to_str())
.and_then(|e| e.to_str())
{
Some("html") => "text/html; charset=utf-8",
Some("css") => "text/css; charset=utf-8",
@ -62,60 +102,53 @@ fn content_type(name: &str) -> &'static str {
}
}
pub async fn start(port: u16, cancellation: CancellationToken) -> bool {
let certificate = match tokio::fs::read(CERT_PATH).await {
Ok(certificate) => certificate,
Err(error) => {
log!("MTP web server certificate load failed: {}", error);
return false;
pub async fn start(
config: WebConfig,
parent: CancellationToken,
) -> Result<Option<Arc<WebServerHandle>>, WebServerError> {
if config.mode == WebMode::Disabled {
return Ok(None);
}
};
let key = match tokio::fs::read(KEY_PATH).await {
Ok(key) => key,
Err(error) => {
log!("MTP web server key load failed: {}", error);
return false;
if config.mode == WebMode::Network && config.tls.is_none() {
return Err(WebServerError::MissingTls(
"network mode requires TLS".into(),
));
}
};
let host_config = HostConfig::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port, certificate, key);
let web_config = match WebServerConfig::new().route("/", root).and_then(|config| {
config.fallback(|request, response| async move {
static_file(request.uri.path(), response).await
let tls = config
.tls
.ok_or_else(|| WebServerError::MissingTls("certificate and key are required".into()))?;
let certificate = tokio::fs::read(&tls.certificate)
.await
.map_err(|e| WebServerError::Io(e.to_string()))?;
let key = tokio::fs::read(&tls.key)
.await
.map_err(|e| WebServerError::Io(e.to_string()))?;
let host_config = HostConfig::new(config.bind, config.port, certificate, key);
let assets = config.asset_dir.clone();
let web_config = WebServerConfig::new()
.route("/", move |request, response| {
root(assets.clone(), request, response)
})
}) {
Ok(config) => config,
Err(error) => {
log!("MTP web server route setup failed: {}", error);
return false;
}
};
let mut server = match MTPWebServer::new(host_config, web_config).await {
Ok(server) => server,
Err(error) => {
log!("MTP web server startup failed: {}", error);
return false;
}
};
log!("MTP web server running on port {}", port);
tokio::spawn(async move {
.and_then(|web_config| {
let assets = config.asset_dir.clone();
web_config.fallback(move |request, response| {
let path = request.uri.path().to_string();
static_file(assets.clone(), path, response)
})
})
.map_err(|e| WebServerError::Startup(e.to_string()))?;
let mut server = MTPWebServer::new(host_config, web_config)
.await
.map_err(|e| WebServerError::Startup(e.to_string()))?;
let cancellation = parent.child_token();
let task_cancellation = cancellation.clone();
let join = tokio::spawn(async move {
loop {
tokio::select! {
result = server.accept() => {
match result {
Ok(Some(_connection)) => {}
Ok(None) => break,
Err(error) => log!("MTP webserver connection failed: {}", error),
}
}
_ = cancellation.cancelled() => {
server.shutdown().await;
break;
}
}
tokio::select! { result = server.accept() => match result { Ok(Some(_)) => {}, Ok(None) => break, Err(error) => log!("MTP webserver connection failed: {}", error) }, _ = task_cancellation.cancelled() => { server.shutdown().await; break; } }
}
});
true
Ok(Some(Arc::new(WebServerHandle {
cancellation,
join: Mutex::new(Some(join)),
})))
}

View file

@ -1,8 +1,10 @@
use crate::server::is_local_network;
use actix_web::{HttpRequest, HttpResponse, Responder, web};
use iota_storage::util::config_util::{modify_config, CONFIG};
use iota_state::DaemonState;
use iota_storage::util::config_util::{CONFIG, modify_config};
use serde_json::{Value, json};
use std::net::SocketAddr;
use std::sync::Arc;
pub fn api_config(cfg: &mut web::ServiceConfig) {
cfg.service(
@ -148,31 +150,37 @@ async fn users_add(
_ => return error(),
};
if let (Some(user), Some(_)) = omikron_connector::user_ops::create_user(username).await {
let val = user.frontend().to_string();
let s_val: Value = serde_json::from_str(&val).unwrap_or(Value::Null);
HttpResponse::Ok().json(s_val)
} else {
// The legacy web API is intentionally quarantined until it can use the
// daemon's authenticated command/service boundary. It must not create a
// second connector or mutate daemon storage directly.
let _ = username;
error()
}
}
async fn shutdown(req: HttpRequest, ssl: web::Data<bool>) -> impl Responder {
async fn shutdown(
req: HttpRequest,
ssl: web::Data<bool>,
state: web::Data<Arc<DaemonState>>,
) -> impl Responder {
if !is_allowed_req(&req, *ssl.get_ref()) {
return forbidden();
}
*iota_state::SHUTDOWN.write().await = true;
*state.shutdown.write().await = true;
success()
}
async fn reload(req: HttpRequest, ssl: web::Data<bool>) -> impl Responder {
async fn reload(
req: HttpRequest,
ssl: web::Data<bool>,
state: web::Data<Arc<DaemonState>>,
) -> impl Responder {
if !is_allowed_req(&req, *ssl.get_ref()) {
return forbidden();
}
*iota_state::SHUTDOWN.write().await = true;
*iota_state::RELOAD.write().await = true;
*state.shutdown.write().await = true;
*state.reload.write().await = true;
success()
}

View file

@ -2,7 +2,7 @@ use crate::api::api_config;
use crate::web_path_parser;
use actix_web::{App, HttpServer, dev::ServerHandle, web};
use iota_logger::log;
use iota_state::{ACTIVE_TASKS, SHUTDOWN};
use iota_state::DaemonState;
use iota_util::file_util::load_file_buf;
use rustls::ServerConfig;
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
@ -16,20 +16,23 @@ use std::{
use tokio::sync::oneshot;
pub async fn start(port: u16) -> bool {
pub async fn start(port: u16, state: Arc<DaemonState>) -> bool {
let (tx, rx) = oneshot::channel::<ServerHandle>();
let bind_addr = std::env::var("BIND_ADDRESS").unwrap_or_else(|_| "0.0.0.0".to_string());
let bind_ip: std::net::IpAddr = bind_addr.parse().expect("Invalid BIND_ADDRESS");
let server_state = state.clone();
let _ = tokio::spawn(async move {
let server = match load_tls_config() {
Ok(Some(tls_config)) => {
log!("HTTPS (HTTP/2) Server running on {}:{}", bind_addr, port);
let _config = (*tls_config).clone();
let app_state = server_state.clone();
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(true))
.app_data(web::Data::from(app_state.clone()))
.configure(api_config)
.default_service(web::to(web_path_parser::handle))
})
@ -39,9 +42,11 @@ pub async fn start(port: u16) -> bool {
}
Ok(_) => {
log!("HTTP Server running on {}:{}", bind_addr, port);
let app_state = server_state.clone();
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(false))
.app_data(web::Data::from(app_state.clone()))
.configure(api_config)
.default_service(web::to(web_path_parser::handle))
})
@ -58,15 +63,15 @@ pub async fn start(port: u16) -> bool {
let server_handle = server.handle();
tx.send(server_handle).unwrap();
ACTIVE_TASKS.insert("WebServer".into());
server_state.active_tasks.insert("WebServer".into());
server.await.unwrap();
ACTIVE_TASKS.remove("WebServer");
server_state.active_tasks.remove("WebServer");
log!("Web Server shutdown complete.");
});
if let Ok(server_handle) = rx.await {
tokio::spawn(async move {
wait_for_shutdown(server_handle).await;
wait_for_shutdown(server_handle, state).await;
});
true
} else {
@ -74,9 +79,9 @@ pub async fn start(port: u16) -> bool {
}
}
async fn wait_for_shutdown(server_handle: ServerHandle) {
async fn wait_for_shutdown(server_handle: ServerHandle, state: Arc<DaemonState>) {
loop {
if *SHUTDOWN.read().await {
if *state.shutdown.read().await {
log!("Shutdown signal received.");
server_handle.stop(true).await;
break;