diff --git a/Cargo.lock b/Cargo.lock index 96c66bb..10d0d16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index ecd2457..b56f6cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,5 +18,9 @@ members = [ "web-ui", "iota-logger", "iota-util", + "iota-process-manager", + "iota-paths", + "iota-installer", + "iota-core", ] resolver = "3" diff --git a/README.md b/README.md index f5f05a7..4a278f3 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/client/src/client_connection.rs b/client/src/client_connection.rs index 95c66cc..b20dca3 100644 --- a/client/src/client_connection.rs +++ b/client/src/client_connection.rs @@ -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>>>, pub waiting_tasks: DashMap, CommunicationValue) -> bool + Send + Sync>>, + shutdown: Arc>, } impl ClientConnection { @@ -45,6 +45,7 @@ impl ClientConnection { u32, Box, CommunicationValue) -> bool + Send + Sync>, >, + shutdown: Arc>, ) -> 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; } diff --git a/flake.nix b/flake.nix index f811ec9..2e560e2 100644 --- a/flake.nix +++ b/flake.nix @@ -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 != []) { diff --git a/iota-auth/src/lib.rs b/iota-auth/src/lib.rs index e69de29..8b13789 100644 --- a/iota-auth/src/lib.rs +++ b/iota-auth/src/lib.rs @@ -0,0 +1 @@ + diff --git a/iota-cli/Cargo.toml b/iota-cli/Cargo.toml index f79241a..54666e1 100644 --- a/iota-cli/Cargo.toml +++ b/iota-cli/Cargo.toml @@ -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" diff --git a/iota-cli/src/controls/action.rs b/iota-cli/src/controls/action.rs new file mode 100644 index 0000000..4fb3c27 --- /dev/null +++ b/iota-cli/src/controls/action.rs @@ -0,0 +1,7 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ControlAction { + FocusNext, + FocusPrevious, + Select, + Activate, +} diff --git a/iota-cli/src/controls/button.rs b/iota-cli/src/controls/button.rs new file mode 100644 index 0000000..1f35bf5 --- /dev/null +++ b/iota-cli/src/controls/button.rs @@ -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> { + 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 +} diff --git a/iota-cli/src/controls/checkbox_group.rs b/iota-cli/src/controls/checkbox_group.rs new file mode 100644 index 0000000..f985510 --- /dev/null +++ b/iota-cli/src/controls/checkbox_group.rs @@ -0,0 +1,135 @@ +use super::{choice::ChoiceVisualState, navigation::DisabledFocusPolicy}; +use std::{collections::HashSet, hash::Hash}; + +pub struct CheckboxItem { + pub value: T, + pub label: String, + pub description: Option, + pub enabled: bool, + pub disabled_reason: Option, +} +pub struct CheckboxGroup { + items: Vec>, + selected: HashSet, + 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 { + Selected(T), + Deselected(T), + IgnoredDisabled(T), + NoItem, +} +impl CheckboxGroup { + pub fn new( + items: Vec>, + selected: impl IntoIterator, + ) -> Result { + 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] { + &self.items + } + pub fn selected(&self) -> &HashSet { + &self.selected + } + pub fn focused_item(&self) -> Option<&CheckboxItem> { + 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 { + 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), + } + } +} diff --git a/iota-cli/src/controls/choice.rs b/iota-cli/src/controls/choice.rs new file mode 100644 index 0000000..713971b --- /dev/null +++ b/iota-cli/src/controls/choice.rs @@ -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), + ]) +} diff --git a/iota-cli/src/controls/mod.rs b/iota-cli/src/controls/mod.rs new file mode 100644 index 0000000..8af4977 --- /dev/null +++ b/iota-cli/src/controls/mod.rs @@ -0,0 +1,6 @@ +pub mod action; +pub mod button; +pub mod checkbox_group; +pub mod choice; +pub mod navigation; +pub mod radio_group; diff --git a/iota-cli/src/controls/navigation.rs b/iota-cli/src/controls/navigation.rs new file mode 100644 index 0000000..0f292c5 --- /dev/null +++ b/iota-cli/src/controls/navigation.rs @@ -0,0 +1,6 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DisabledFocusPolicy { + Include, + #[default] + Skip, +} diff --git a/iota-cli/src/controls/radio_group.rs b/iota-cli/src/controls/radio_group.rs new file mode 100644 index 0000000..9aaacb5 --- /dev/null +++ b/iota-cli/src/controls/radio_group.rs @@ -0,0 +1,194 @@ +use super::{choice::ChoiceVisualState, navigation::DisabledFocusPolicy}; + +pub struct RadioItem { + pub value: T, + pub label: String, + pub description: Option, + pub enabled: bool, + pub disabled_reason: Option, +} +pub struct RadioGroup { + items: Vec>, + 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 { + Changed { previous: T, selected: T }, + Unchanged(T), + IgnoredDisabled(T), +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DisabledSelectionPolicy { + UseConfiguredDefault, + UseFirstEnabled, + ReturnError, +} +impl RadioGroup { + pub fn new( + items: Vec>, + observed: Option, + default: T, + ) -> Result { + 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] { + &self.items + } + pub fn selected(&self) -> &T { + &self.selected + } + pub fn focused_item(&self) -> &RadioItem { + &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 { + 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 + } +} diff --git a/iota-cli/src/elements/console_card.rs b/iota-cli/src/elements/console_card.rs index 276b95f..af69a24 100644 --- a/iota-cli/src/elements/console_card.rs +++ b/iota-cli/src/elements/console_card.rs @@ -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>, last_swap: Arc>, pending_restore: Arc>>, + pending_confirmation: Option, } 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> { + fn cursor_spans(&self, theme: &crate::theme::ResolvedTheme) -> Vec> { 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>, 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); - } - - if is_prefix { - return Style::default().fg(Color::DarkGray); - } - - Style::default().fg(Color::White) } - fn render_cursor_spans(&self) -> Vec> { - self.cursor_spans() + fn render_cursor_spans(&self, theme: &crate::theme::ResolvedTheme) -> Vec> { + if let Some(command) = &self.pending_confirmation { + return vec![Span::styled( + format!("Confirm `{command}`? [y/N]"), + theme.console.confirmation, + )]; + } + self.cursor_spans(theme) + } + + fn is_destructive(command: &str) -> bool { + matches!( + command.trim_start_matches('/').trim(), + "restart" | "reload" | "stop" | "shutdown" | "regenerate keys" + ) || command + .trim_start_matches('/') + .trim_start() + .starts_with("user remove ") + } + + fn dispatch_command(&self, command: String) { + let ipc = self.ipc.clone(); + let restore = self.pending_restore.clone(); + tokio::spawn(async move { + if ipc.send_command(0, command.clone()).await.is_err() { + *restore.lock().unwrap() = Some(command); + } + }); } fn move_cursor_left(&mut self) { @@ -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 => { diff --git a/iota-cli/src/elements/elements.rs b/iota-cli/src/elements/elements.rs index abe9ab9..1af14cc 100644 --- a/iota-cli/src/elements/elements.rs +++ b/iota-cli/src/elements/elements.rs @@ -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)] diff --git a/iota-cli/src/elements/graph_card.rs b/iota-cli/src/elements/graph_card.rs index 178ffaa..0902b6d 100644 --- a/iota-cli/src/elements/graph_card.rs +++ b/iota-cli/src/elements/graph_card.rs @@ -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 + }, + ); } } diff --git a/iota-cli/src/elements/log_card.rs b/iota-cli/src/elements/log_card.rs index c415ba9..23c7107 100755 --- a/iota-cli/src/elements/log_card.rs +++ b/iota-cli/src/elements/log_card.rs @@ -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 { - 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(×tamp); } - 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, 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 = 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 + }, + ); } } diff --git a/iota-cli/src/input_handler.rs b/iota-cli/src/input_handler.rs index 5cdf4fa..3080480 100644 --- a/iota-cli/src/input_handler.rs +++ b/iota-cli/src/input_handler.rs @@ -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) { +pub fn setup_input_handler(ui: Arc) -> JoinHandle> { 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, - }) - } 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; - } + 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 + }) } pub async fn handle_input(key: KeyEvent, ui: Arc) { - match (key.code, key.modifiers) { - (crossterm::event::KeyCode::Char('q'), KeyModifiers::CONTROL) - | (crossterm::event::KeyCode::Char('c'), KeyModifiers::CONTROL) => { - ui.request_shutdown(); - } - (crossterm::event::KeyCode::Char('r'), KeyModifiers::CONTROL) => { - let _ = ui.send_restart().await; - ui.request_shutdown(); - } - _ => { - ui.handle_input(key).await; - } + if matches!( + key.code, + crossterm::event::KeyCode::Char('q') | crossterm::event::KeyCode::Char('c') + ) && key.modifiers.contains(KeyModifiers::CONTROL) + { + ui.request_shutdown(); + } else { + ui.handle_input(key).await; } } diff --git a/iota-cli/src/ipc_client.rs b/iota-cli/src/ipc_client.rs index 018cfa2..186d756 100644 --- a/iota-cli/src/ipc_client.rs +++ b/iota-cli/src/ipc_client.rs @@ -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, + pub degraded_reason: Option, + pub lifecycle: Option, + pub health: iota_ipc::HealthStatus, + pub deployment_mode: Option, + pub supervisor: Option, + pub components: std::collections::BTreeMap, +} + /// Pending request awaiting a response. struct PendingRequest { response_tx: oneshot::Sender, } +struct ActiveWriter { + generation: u64, + writer: OwnedWriteHalf, +} + +struct NegotiatedConnection { + reader: OwnedReadHalf, + writer: OwnedWriteHalf, + ack: HelloAck, + buffered_messages: Vec, +} + /* 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, + writer: Mutex>, + next_generation: AtomicU64, next_request_id: AtomicU64, pending: Mutex>, connection_state: watch::Sender, + daemon_status: watch::Sender, path: PathBuf, + reconnector_started: AtomicBool, + cancellation: CancellationToken, + background_tasks: StdMutex>>, } impl IpcClient { pub async fn connect(path: impl AsRef) -> Result> { 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> { + 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) -> Result> { 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 { - let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + async fn connect_stream(path: &Path, deadline: tokio::time::Instant) -> Result { + 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 { + 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) { + 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) { 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); - 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 - ), - }, - ); - 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; - }); - - // 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. + tokio::select! { + _ = tokio::time::sleep(backoff) => {}, + _ = self.cancellation.cancelled() => return, + } + let deadline = tokio::time::Instant::now() + CONNECT_ATTEMPT_TIMEOUT; + let result = async { + let stream = Self::connect_stream(&self.path, deadline).await?; + Self::negotiate_stream(stream, deadline).await + } + .await; + match result { + Ok(connection) => { + self.install_connection(connection).await; break; } - Err(_) => { + Err(error) if error.kind() == std::io::ErrorKind::Unsupported => { + let _ = self + .connection_state + .send(IpcConnectionState::Incompatible { + message: error.to_string(), + }); + return; + } + Err(error) if attempt == MAX_RECONNECT_ATTEMPTS => { + let _ = self.connection_state.send(IpcConnectionState::Failed { + message: format!("Reconnect failed after {attempt} attempts: {error}"), + }); + return; + } + Err(error) => { + eprintln!( + "IPC reconnect attempt {attempt} to {} failed: kind={:?}, error={error}", + self.path.display(), + error.kind() + ); backoff = std::cmp::min(backoff * 2, MAX_BACKOFF); } } @@ -287,16 +335,61 @@ impl IpcClient { } } - async fn read_loop(self: Arc, 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, connection: NegotiatedConnection) { + let generation = self.next_generation.fetch_add(1, Ordering::Relaxed); + *self.writer.lock().await = Some(ActiveWriter { + generation, + writer: connection.writer, + }); + self.update_hello_ack(connection.ack); + for message in connection.buffered_messages { + self.apply(message).await; + } + let _ = self.connection_state.send(IpcConnectionState::Connected); + let client = self.clone(); + let task = tokio::spawn(async move { + client.read_loop(connection.reader, generation).await; + }); + self.background_tasks.lock().unwrap().push(task); + } + + async fn fail_pending_requests(&self) { + let mut pending = self.pending.lock().await; + for (_, request) in pending.drain() { + let _ = request + .response_tx + .send(ResponseResult::Error(iota_ipc::IpcErrorCode::Disconnected)); + } + } + + async fn mark_disconnected(&self, generation: u64) { + let removed = { + let mut writer = self.writer.lock().await; + match writer.as_ref() { + Some(active) if active.generation == generation => { + writer.take(); + true } - Err(_) => { - let _ = self.connection_state.send(IpcConnectionState::Disconnected); + _ => false, + } + }; + if removed { + let _ = self.connection_state.send(IpcConnectionState::Disconnected); + self.fail_pending_requests().await; + } + } + + async fn read_loop(self: Arc, 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 { + 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 { 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 , user remove , 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) diff --git a/iota-cli/src/layout/fit.rs b/iota-cli/src/layout/fit.rs new file mode 100644 index 0000000..3908272 --- /dev/null +++ b/iota-cli/src/layout/fit.rs @@ -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 { + 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 { + 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, + }) +} diff --git a/iota-cli/src/layout/mod.rs b/iota-cli/src/layout/mod.rs new file mode 100644 index 0000000..f629920 --- /dev/null +++ b/iota-cli/src/layout/mod.rs @@ -0,0 +1,2 @@ +pub mod fit; +pub mod text_measure; diff --git a/iota-cli/src/layout/text_measure.rs b/iota-cli/src/layout/text_measure.rs new file mode 100644 index 0000000..0ba471e --- /dev/null +++ b/iota-cli/src/layout/text_measure.rs @@ -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::() + .min(u16::MAX as usize) as u16 +} diff --git a/iota-cli/src/lib.rs b/iota-cli/src/lib.rs index 6986d72..f54720b 100644 --- a/iota-cli/src/lib.rs +++ b/iota-cli/src/lib.rs @@ -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; diff --git a/iota-cli/src/render_context.rs b/iota-cli/src/render_context.rs new file mode 100644 index 0000000..8b7d3c9 --- /dev/null +++ b/iota-cli/src/render_context.rs @@ -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, +} diff --git a/iota-cli/src/screens/daemon_setup.rs b/iota-cli/src/screens/daemon_setup.rs new file mode 100644 index 0000000..2dad4ce --- /dev/null +++ b/iota-cli/src/screens/daemon_setup.rs @@ -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, +} +#[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, + focus: Focus, + sender: Option>, + message: String, +} +impl DaemonSetupScreen { + pub fn new( + options: Vec, + message: impl Into, + sender: oneshot::Sender, + ) -> Result { + let items: Vec> = 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, + } + } +} diff --git a/iota-cli/src/screens/main_screen.rs b/iota-cli/src/screens/main_screen.rs index 49828c2..19fa093 100644 --- a/iota-cli/src/screens/main_screen.rs +++ b/iota-cli/src/screens/main_screen.rs @@ -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, + daemon_status_rx: watch::Receiver, } 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); } } } diff --git a/iota-cli/src/screens/md_viewer.rs b/iota-cli/src/screens/md_viewer.rs index 2faa2f1..aaf6b9d 100644 --- a/iota-cli/src/screens/md_viewer.rs +++ b/iota-cli/src/screens/md_viewer.rs @@ -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, + 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 { +fn parse_document(input: &str, theme: &ResolvedTheme) -> Vec { let mut lines_vec = Vec::new(); let mut in_code_block = false; let liness: Vec = input.lines().map(String::from).collect(); @@ -172,7 +178,7 @@ fn parse_document(input: String) -> Vec { 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 { 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 { 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 { 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 { 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 { } let table = parse_table(&table_lines.iter().map(|s| s.as_str()).collect::>()); - 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 { lines_vec } -fn parse_inline(input: &str) -> Vec> { +fn parse_inline(input: &str, theme: &ResolvedTheme) -> Vec> { let mut spans = Vec::new(); let mut buf = String::new(); @@ -294,7 +309,11 @@ fn parse_inline(input: &str) -> Vec> { }; 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> { 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 { +fn table_to_lines(table: Vec>, theme: &ResolvedTheme) -> Vec { if table.len() < 2 { return vec![]; } @@ -377,7 +393,7 @@ fn table_to_lines(table: Vec>) -> Vec { .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 { } 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 { diff --git a/iota-cli/src/screens/screens.rs b/iota-cli/src/screens/screens.rs index 7606521..b69d980 100644 --- a/iota-cli/src/screens/screens.rs +++ b/iota-cli/src/screens/screens.rs @@ -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; } diff --git a/iota-cli/src/screens/terms_checker.rs b/iota-cli/src/screens/terms_checker.rs index 029dae2..c44cbcb 100644 --- a/iota-cli/src/screens/terms_checker.rs +++ b/iota-cli/src/screens/terms_checker.rs @@ -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, sender: Option>, eula: bool, @@ -39,9 +34,8 @@ pub struct TermsCheckerScreen { } impl TermsCheckerScreen { - pub fn new(ui: Arc, sender: Option>) -> Self { + pub fn new(sender: Option>) -> 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, ); } diff --git a/iota-cli/src/screens/terms_updater.rs b/iota-cli/src/screens/terms_updater.rs index e6aad76..f1701b5 100644 --- a/iota-cli/src/screens/terms_updater.rs +++ b/iota-cli/src/screens/terms_updater.rs @@ -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, ); } diff --git a/iota-cli/src/theme/config.rs b/iota-cli/src/theme/config.rs new file mode 100644 index 0000000..e77c7e6 --- /dev/null +++ b/iota-cli/src/theme/config.rs @@ -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(&self, serializer: S) -> Result { + match self { + Self::Ask => serializer.serialize_str("ask"), + Self::WithUi => serializer.serialize_str("with_ui"), + } + } +} +impl<'de> Deserialize<'de> for DaemonStartPolicy { + fn deserialize>(deserializer: D) -> Result { + #[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::load_from(&Self::path()) + } + + fn load_from(path: &Path) -> Result { + 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 { + Self::resolve_theme_from( + override_theme, + std::env::var("IOTA_THEME").ok().as_deref(), + &Self::path(), + ) + } + + fn resolve_theme_from( + override_theme: Option, + 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 + ); + } +} diff --git a/iota-cli/src/theme/mod.rs b/iota-cli/src/theme/mod.rs new file mode 100644 index 0000000..d103e58 --- /dev/null +++ b/iota-cli/src/theme/mod.rs @@ -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) +} diff --git a/iota-cli/src/theme/model.rs b/iota-cli/src/theme/model.rs new file mode 100644 index 0000000..fd631dc --- /dev/null +++ b/iota-cli/src/theme/model.rs @@ -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 + } +} diff --git a/iota-cli/src/theme/name.rs b/iota-cli/src/theme/name.rs new file mode 100644 index 0000000..1fbfeb8 --- /dev/null +++ b/iota-cli/src/theme/name.rs @@ -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 { + 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() + )), + } + } +} diff --git a/iota-cli/src/theme/presets.rs b/iota-cli/src/theme/presets.rs new file mode 100644 index 0000000..333a221 --- /dev/null +++ b/iota-cli/src/theme/presets.rs @@ -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 + } + } +} diff --git a/iota-cli/src/ui.rs b/iota-cli/src/ui.rs index f298a92..21d8bd4 100644 --- a/iota-cli/src/ui.rs +++ b/iota-cli/src/ui.rs @@ -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> = Lazy::new(|| RwLock::new((0.0, 0.0))); pub struct UI { - ipc: Arc, - shutdown: AtomicBool, + ipc: RwLock>>, + shutdown_on_empty: bool, + cancellation: CancellationToken, pub terminal: Arc>>>, screen_stack: Arc>>>, + theme: RwLock>, + pub(crate) invalidation: Notify, + failure: Arc>>, } -pub fn start_tui(ipc: Arc) -> Arc { - let ui = Arc::new(UI::new(ipc)); +pub fn start_tui(ipc: Arc) -> io::Result { + start_tui_with_theme(ipc, theme::resolve(ThemeName::Ansi)) +} + +pub fn start_tui_with_theme(ipc: Arc, theme: ResolvedTheme) -> io::Result { + start_session(UI::new(Some(ipc), true, theme)?) +} + +pub fn start_bootstrap_tui() -> io::Result { + start_bootstrap_tui_with_theme(theme::resolve(ThemeName::Ansi)) +} + +pub fn start_bootstrap_tui_with_theme(theme: ResolvedTheme) -> io::Result { + start_session(UI::new(None, false, theme)?) +} + +fn start_session(ui: UI) -> io::Result { + let ui = Arc::new(ui); let uic = ui.clone(); - tokio::spawn(async move { - let mut last_render = Instant::now(); - - let mut fps_samples: VecDeque = VecDeque::with_capacity(20); - let mut skip_samples: VecDeque = 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; - } - tokio::time::sleep(Duration::from_millis(16)).await; + }; + if let Err(error) = &result { + *uic.failure.lock().unwrap() = Some(error.to_string()); + uic.request_shutdown(); } - 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, + renderer_task: JoinHandle>, + input_task: JoinHandle>, + signal_task: Option>, + restored: AtomicBool, + previous_hook: Arc) + Send + Sync + 'static>>>>, +} + +impl TuiSession { + pub fn ui(&self) -> Arc { + self.ui.clone() + } + pub async fn shutdown(mut self) -> Option { + 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) -> Self { - let terminal = init(); - Self { - ipc, - shutdown: AtomicBool::new(false), + pub(crate) fn new( + ipc: Option>, + shutdown_on_empty: bool, + theme: ResolvedTheme, + ) -> io::Result { + 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 { - self.ipc.clone() + pub async fn ipc(&self) -> Option> { + self.ipc.read().await.clone() } - pub fn client_state(&self) -> iota_state::ClientState { - self.ipc.state() + pub async fn client_state(&self) -> Option { + self.ipc.read().await.as_ref().map(|ipc| ipc.state()) + } + + pub async fn attach_daemon(&self, ipc: Arc) { + *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 { + 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) { self.screen_stack.write().await.push(screen); + self.invalidate(); } pub async fn replace_screen(&self, screen: Box) { 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) { + let mut stack = self.screen_stack.write().await; + stack.clear(); + stack.push(screen); + self.invalidate(); } pub async fn handle_input(self: Arc, 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(()) } } diff --git a/iota-cli/src/util/borders.rs b/iota-cli/src/util/borders.rs index 34460bc..a6d26a8 100644 --- a/iota-cli/src/util/borders.rs +++ b/iota-cli/src/util/borders.rs @@ -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); } } diff --git a/iota-cli/src/util/buttons.rs b/iota-cli/src/util/buttons.rs index 6c93cce..a69d2ad 100644 --- a/iota-cli/src/util/buttons.rs +++ b/iota-cli/src/util/buttons.rs @@ -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 = 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::>(); + 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 { - let mut widths = vec![0; min_widths.len()]; - let mut remaining: Vec = (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 -} diff --git a/iota-cli/tests/button_layout.rs b/iota-cli/tests/button_layout.rs new file mode 100644 index 0000000..ad230fd --- /dev/null +++ b/iota-cli/tests/button_layout.rs @@ -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); +} diff --git a/iota-cli/tests/choice_rendering.rs b/iota-cli/tests/choice_rendering.rs new file mode 100644 index 0000000..a9ef9d3 --- /dev/null +++ b/iota-cli/tests/choice_rendering.rs @@ -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::(), + "[ ] 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::(), + "> (x) Mode <" + ); + assert!( + line.spans + .iter() + .all(|span| span.style.fg.is_none() && span.style.bg.is_none()) + ); + } +} diff --git a/iota-cli/tests/control_state.rs b/iota-cli/tests/control_state.rs new file mode 100644 index 0000000..3089bdb --- /dev/null +++ b/iota-cli/tests/control_state.rs @@ -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 { + CheckboxItem { + value, + label: value.to_string(), + description: None, + enabled, + disabled_reason: None, + } +} + +fn radio(value: u8, enabled: bool) -> RadioItem { + 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![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::>::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); +} diff --git a/iota-cli/tests/layout_fit.rs b/iota-cli/tests/layout_fit.rs new file mode 100644 index 0000000..f8a60aa --- /dev/null +++ b/iota-cli/tests/layout_fit.rs @@ -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); +} diff --git a/iota-core/Cargo.toml b/iota-core/Cargo.toml index 06a58f0..763b231 100644 --- a/iota-core/Cargo.toml +++ b/iota-core/Cargo.toml @@ -2,6 +2,7 @@ name = "iota-core" version = "0.1.0" edition = "2024" +autobins = false [dependencies] iota-cli = { path = "../iota-cli" } diff --git a/iota-core/src/consent_state.rs b/iota-core/src/consent_state.rs index 60e4515..de88a04 100644 --- a/iota-core/src/consent_state.rs +++ b/iota-core/src/consent_state.rs @@ -12,9 +12,9 @@ pub async fn check(ui: Arc) -> 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) -> 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, 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, 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); diff --git a/iota-core/src/lib.rs b/iota-core/src/lib.rs new file mode 100644 index 0000000..2deef45 --- /dev/null +++ b/iota-core/src/lib.rs @@ -0,0 +1 @@ +pub mod consent_state; diff --git a/iota-core/src/main.rs b/iota-core/src/main.rs index 35f9077..4e0f782 100644 --- a/iota-core/src/main.rs +++ b/iota-core/src/main.rs @@ -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; } } diff --git a/iota-daemon-lib/Cargo.toml b/iota-daemon-lib/Cargo.toml index 10fd67b..f039440 100644 --- a/iota-daemon-lib/Cargo.toml +++ b/iota-daemon-lib/Cargo.toml @@ -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" diff --git a/iota-daemon-lib/src/command_router.rs b/iota-daemon-lib/src/command_router.rs index 53f4327..6fe4c5f 100644 --- a/iota-daemon-lib/src/command_router.rs +++ b/iota-daemon-lib/src/command_router.rs @@ -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, + services: Arc, } impl CommandRouter { - pub fn new(runtime: Arc) -> Self { - Self { runtime } + pub fn new(runtime: Arc, services: Arc) -> 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 { - 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 { - 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 { diff --git a/iota-daemon-lib/src/daemon_state.rs b/iota-daemon-lib/src/daemon_state.rs index aa998b6..4c7347c 100644 --- a/iota-daemon-lib/src/daemon_state.rs +++ b/iota-daemon-lib/src/daemon_state.rs @@ -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, pub cancellation: CancellationToken, pub shutdown_tx: watch::Sender>, + shutdown_rx: watch::Receiver>, pub startup_phase: watch::Sender, pub degraded_reason: watch::Sender>, + startup_phase_rx: watch::Receiver, + degraded_reason_rx: watch::Receiver>, + pub lifecycle: watch::Sender, + pub startup_step: watch::Sender>, + pub components: watch::Sender>, + lifecycle_rx: watch::Receiver, + startup_step_rx: watch::Receiver>, + components_rx: watch::Receiver>, + 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(); - let _ = self.shutdown_tx.send(Some(reason)); + if self.shutdown_tx.borrow().is_none() { + let _ = self.shutdown_tx.send(Some(reason)); + self.cancellation.cancel(); + } } pub fn shutdown_reason(&self) -> Option { @@ -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) { + 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, + ) { + 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,42 +247,51 @@ 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 { - runtime.state.active_tasks.insert("System monitor".into()); - let mut system = System::new_with_specifics(RefreshKind::everything()); - let mut counter = 0.0; - loop { - if runtime.is_shutting_down() { - break; + self.tasks + .spawn_tracked("system-monitor", async move { + runtime.state.active_tasks.insert("System monitor".into()); + let mut system = System::new_with_specifics(RefreshKind::everything()); + let mut counter = 0.0; + loop { + if runtime.is_shutting_down() { + break; + } + system.refresh_cpu_all(); + system.refresh_memory(); + let cpu = system.global_cpu_usage() as f64; + let total_memory = system.total_memory(); + let ram = if total_memory == 0 { + 0.0 + } else { + system.used_memory() as f64 / total_memory as f64 * 100.0 + }; + { + let mut state = runtime + .state + .app + .lock() + .unwrap_or_else(|error| error.into_inner()); + state.push_cpu((counter, cpu)); + state.push_ram((counter, ram)); + state.sys_info = format!("CPU: {cpu:.1}% RAM: {ram:.1}%"); + } + counter += 1.0; + tokio::time::sleep(Duration::from_millis(500)).await; } - system.refresh_cpu_all(); - system.refresh_memory(); - let cpu = system.global_cpu_usage() as f64; - let total_memory = system.total_memory(); - let ram = if total_memory == 0 { - 0.0 - } else { - system.used_memory() as f64 / total_memory as f64 * 100.0 - }; - { - let mut state = runtime - .state - .app - .lock() - .unwrap_or_else(|error| error.into_inner()); - state.push_cpu((counter, cpu)); - state.push_ram((counter, ram)); - state.sys_info = format!("CPU: {cpu:.1}% RAM: {ram:.1}%"); - } - counter += 1.0; - tokio::time::sleep(Duration::from_millis(500)).await; - } - runtime.state.active_tasks.remove("System monitor"); - }); + runtime.state.active_tasks.remove("System monitor"); + Ok(()) + }) + .await; } } diff --git a/iota-daemon-lib/src/deployment.rs b/iota-daemon-lib/src/deployment.rs new file mode 100644 index 0000000..c7a5cde --- /dev/null +++ b/iota-daemon-lib/src/deployment.rs @@ -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 } +} diff --git a/iota-daemon-lib/src/ipc_server.rs b/iota-daemon-lib/src/ipc_server.rs index 2e3f6e9..bc99cbc 100644 --- a/iota-daemon-lib/src/ipc_server.rs +++ b/iota-daemon-lib/src/ipc_server.rs @@ -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, + services: Arc, log_tx: broadcast::Sender, - state_rx: watch::Sender, + state_rx: watch::Receiver, + instance_id: String, + _instance_lock: File, } impl IpcServer { - pub fn new( + pub async fn bind( path: impl Into, runtime: Arc, + services: Arc, log_tx: broadcast::Sender, - state_rx: watch::Sender, - ) -> 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, + ) -> Result { + 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> { 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 { + listener.set_nonblocking(true)?; + UnixListener::from_std(listener) +} + +async fn write_client_message(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 { #[cfg(target_os = "linux")] { use std::os::unix::io::AsRawFd; @@ -104,70 +208,109 @@ fn peer_credentials(stream: &UnixStream) -> PeerIdentity { let mut cred: libc::ucred = std::mem::zeroed(); let mut len = std::mem::size_of::() 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, + services: Arc, log_tx: broadcast::Sender, - _state_rx: watch::Sender, + mut state_rx: watch::Receiver, + 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::(CLIENT_CHANNEL_SIZE); + eprintln!("IPC handshake started (pid={}, uid={})", peer.pid, peer.uid); // --- Handshake --- let mut negotiated_version: Option = None; for _ in 0..MAX_HANDSHAKE_RETRIES { - match read_msg::<_, ClientMessage>(&mut reader).await { - Ok(ClientMessage::Hello { supported_versions }) => { - let version = supported_versions - .iter() - .copied() - .find(|v| *v >= MIN_PROTOCOL_VERSION && *v <= PROTOCOL_VERSION) - .unwrap_or(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, - startup_phase: runtime.current_startup_phase().into(), - capabilities: vec!["commands".into(), "metrics".into(), "logs".into()], - }); - write_msg(&mut writer, &ack).await?; - break; - } - Ok(_) => { - // Unexpected first message — send error and close. + match timeout( + std::time::Duration::from_secs(15), + read_msg::<_, ClientMessage>(&mut reader), + ) + .await + { + Err(_) => { return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "Expected Hello as first message", + std::io::ErrorKind::TimedOut, + "IPC Hello timed out", )); } - Err(e) => return Err(e), + Ok(result) => match result { + Ok(ClientMessage::Hello { supported_versions }) => { + let version = supported_versions + .iter() + .copied() + .filter(|v| *v >= MIN_PROTOCOL_VERSION && *v <= PROTOCOL_VERSION) + .max() + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::Unsupported, + "No compatible IPC protocol version", + ) + })?; + negotiated_version = Some(version); + let ack = DaemonMessage::HelloAck(HelloAck { + protocol_version: version, + daemon_version: env!("CARGO_PKG_VERSION").to_string(), + instance_id: instance_id.clone(), + startup_phase: runtime.current_startup_phase().into(), + capabilities: vec!["commands".into(), "metrics".into(), "logs".into()], + lifecycle: *runtime.lifecycle.borrow(), + health: runtime.overall_health(), + deployment_mode: from_environment().mode, + supervisor: from_environment().supervisor, + }); + write_client_message(&mut writer, &ack).await?; + eprintln!( + "IPC handshake acknowledged (pid={}, uid={})", + peer.pid, peer.uid + ); + break; + } + Ok(_) => { + // Unexpected first message — send error and close. + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Expected Hello as first message", + )); + } + 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() + ); + } +} diff --git a/iota-daemon-lib/src/lib.rs b/iota-daemon-lib/src/lib.rs index 48f8c68..5a40295 100644 --- a/iota-daemon-lib/src/lib.rs +++ b/iota-daemon-lib/src/lib.rs @@ -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; diff --git a/iota-daemon-lib/src/services.rs b/iota-daemon-lib/src/services.rs new file mode 100644 index 0000000..a04002c --- /dev/null +++ b/iota-daemon-lib/src/services.rs @@ -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, + pub users: Arc, + pub config: Arc, +} + +impl DaemonServices { + pub fn new(omikron: Arc) -> Arc { + Arc::new(Self { + omikron, + users: Arc::new(UserService), + config: Arc::new(ConfigService), + }) + } +} diff --git a/iota-daemon-lib/src/task_registry.rs b/iota-daemon-lib/src/task_registry.rs new file mode 100644 index 0000000..89769db --- /dev/null +++ b/iota-daemon-lib/src/task_registry.rs @@ -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)>>>, +} + +impl TaskRegistry { + pub async fn spawn_tracked(&self, name: impl Into, future: F) + where + F: std::future::Future> + 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 { + 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 + } +} diff --git a/iota-daemon-lib/tests/command_router.rs b/iota-daemon-lib/tests/command_router.rs new file mode 100644 index 0000000..525bca1 --- /dev/null +++ b/iota-daemon-lib/tests/command_router.rs @@ -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 { + 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); +} diff --git a/iota-daemon-lib/tests/daemon_health.rs b/iota-daemon-lib/tests/daemon_health.rs new file mode 100644 index 0000000..a24f277 --- /dev/null +++ b/iota-daemon-lib/tests/daemon_health.rs @@ -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); +} diff --git a/iota-daemon-lib/tests/shutdown.rs b/iota-daemon-lib/tests/shutdown.rs new file mode 100644 index 0000000..d0c74d6 --- /dev/null +++ b/iota-daemon-lib/tests/shutdown.rs @@ -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() + ); +} diff --git a/iota-daemon/Cargo.toml b/iota-daemon/Cargo.toml index 620b413..2c5e976 100644 --- a/iota-daemon/Cargo.toml +++ b/iota-daemon/Cargo.toml @@ -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" } diff --git a/iota-daemon/src/main.rs b/iota-daemon/src/main.rs index b9404f1..5239baf 100644 --- a/iota-daemon/src/main.rs +++ b/iota-daemon/src/main.rs @@ -1,94 +1,198 @@ -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(), - ); - tokio::spawn(async move { - if let Err(error) = ipc_server.run().await { - eprintln!("iota-daemon IPC server failed: {error}"); + 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(), + ); + } else { + runtime.set_component_healthy(iota_ipc::ComponentId::Storage, None); + } + + // Bind before migration and service startup: a successful bind is the + // readiness boundary visible to clients and socket activation. + let socket = 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 } - }); - log!("iota-daemon IPC server started"); + 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 { - loop { - if state_publisher.is_shutting_down() { - break; + runtime + .tasks + .spawn_tracked("state-publisher", async move { + loop { + if state_publisher.is_shutting_down() { + break; + } + let snapshot = state_publisher.snapshot(); + let _ = state_tx.send(snapshot); + tokio::time::sleep(Duration::from_millis(500)).await; } - let snapshot = state_publisher.snapshot(); - 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()); - } - - // --- Omikron connection --- - let omikron_result = - omikron_connector::omikron_connection::get_omikron_connection(runtime.cancellation.clone()) - .await; - if omikron_result.is_none() { - runtime.mark_degraded("Omikron connection unavailable".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())) + } + Ok(Some(handle)) => { + runtime.set_component_healthy(iota_ipc::ComponentId::Web, None); + runtime + .tasks + .spawn_tracked("web-server", async move { + handle.join().await; + Ok(()) + }) + .await; + } + Err(error) if web.required => { + runtime.set_component_failed(iota_ipc::ComponentId::Web, error.to_string()); + } + Err(error) => { + runtime.set_component_degraded(iota_ipc::ComponentId::Web, error.to_string()); + } } runtime.set_startup_phase(StartupPhase::Ready); log!("iota-daemon started (phase: Ready)"); // --- 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) } diff --git a/iota-installer/Cargo.toml b/iota-installer/Cargo.toml new file mode 100644 index 0000000..755c8f7 --- /dev/null +++ b/iota-installer/Cargo.toml @@ -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" } diff --git a/iota-installer/src/lib.rs b/iota-installer/src/lib.rs new file mode 100644 index 0000000..f290cf0 --- /dev/null +++ b/iota-installer/src/lib.rs @@ -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::(&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")); + } +} diff --git a/iota-ipc/src/lib.rs b/iota-ipc/src/lib.rs index 11105c2..1118fc2 100644 --- a/iota-ipc/src/lib.rs +++ b/iota-ipc/src/lib.rs @@ -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}; diff --git a/iota-ipc/src/protocol.rs b/iota-ipc/src/protocol.rs index 35a93d6..7a95a3d 100644 --- a/iota-ipc/src/protocol.rs +++ b/iota-ipc/src/protocol.rs @@ -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 }, - Subscribe { log_classes: Vec, metric_interval_ms: Option }, + Hello { + supported_versions: Vec, + }, + Subscribe { + log_classes: Vec, + metric_interval_ms: Option, + }, 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, + #[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, + 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, + #[serde(default)] + pub lifecycle: LifecyclePhase, + #[serde(default)] + pub startup_step: Option, + #[serde(default)] + pub overall_health: HealthStatus, + #[serde(default)] + pub components: std::collections::BTreeMap, } #[derive(Clone, Debug, Default, Deserialize, Serialize)] diff --git a/iota-logger/src/language_manager.rs b/iota-logger/src/language_manager.rs index 8cced10..2d3bda3 100644 --- a/iota-logger/src/language_manager.rs +++ b/iota-logger/src/language_manager.rs @@ -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)); - 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()); + // 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 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()); - } + 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 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()); + for (key, value) in messages.entries() { + if let Some(value) = value.as_str() { + self.language.insert(key.to_string(), value.to_string()); + } } + true } pub fn get_translation(&self, key: &str) -> String { match self.language.get(key) { diff --git a/iota-logger/src/lib.rs b/iota-logger/src/lib.rs index 486a04c..8dd7f11 100644 --- a/iota-logger/src/lib.rs +++ b/iota-logger/src/lib.rs @@ -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, diff --git a/iota-paths/Cargo.toml b/iota-paths/Cargo.toml new file mode 100644 index 0000000..00a1b19 --- /dev/null +++ b/iota-paths/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "iota-paths" +version = "0.1.0" +edition = "2024" diff --git a/iota-paths/src/lib.rs b/iota-paths/src/lib.rs new file mode 100644 index 0000000..4461e1e --- /dev/null +++ b/iota-paths/src/lib.rs @@ -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 { + 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 { + 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()); + } +} diff --git a/iota-process-manager/Cargo.toml b/iota-process-manager/Cargo.toml new file mode 100644 index 0000000..c159ef8 --- /dev/null +++ b/iota-process-manager/Cargo.toml @@ -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"] } diff --git a/iota-process-manager/src/lib.rs b/iota-process-manager/src/lib.rs new file mode 100644 index 0000000..e3a03d5 --- /dev/null +++ b/iota-process-manager/src/lib.rs @@ -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) -> 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; +} + +#[async_trait] +pub trait ProcessManager: Send + Sync { + fn name(&self) -> &'static str; + async fn unit_status(&self, unit: &str) -> Result; + async fn set_iota_startup_mode( + &self, + mode: StartupMode, + ) -> Result; + async fn iota_startup_status(&self) -> Result { + 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 { + self.set_iota_startup_mode(mode).await + } + async fn disable_startup(&self) -> Result { + self.set_iota_startup_mode(StartupMode::SocketActivated) + .await + } + async fn process_action( + &self, + action: ProcessAction, + ) -> Result { + 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> { + #[cfg(target_os = "linux")] + { + systemd::SystemdManager::detect() + .await + .map(|m| Arc::new(m) as Arc) + } + #[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 { + 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, + } + impl SystemdManager { + pub async fn detect() -> Option { + if !Path::new("/run/systemd/system").is_dir() { + return None; + } + let executor: Arc = 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) -> 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 { + 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 { + 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 { + self.status(unit).await + } + async fn set_iota_startup_mode( + &self, + mode: StartupMode, + ) -> Result { + 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 { + 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>>, + results: Mutex>, + } + #[async_trait] + impl CommandExecutor for Fake { + async fn output( + &self, + _: &str, + args: &[&str], + ) -> Result { + 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 { + 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()); + } +} diff --git a/iota-state/src/lib.rs b/iota-state/src/lib.rs index 3b29d08..8f668ea 100644 --- a/iota-state/src/lib.rs +++ b/iota-state/src/lib.rs @@ -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>, + pub app: Arc>, } 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::>(), @@ -218,15 +231,16 @@ pub static RELOAD: Lazy> = Lazy::new(|| RwLock::new(true)); pub static ACTIVE_TASKS: Lazy> = 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)); + } +} diff --git a/iota-storage/src/users/user_manager.rs b/iota-storage/src/users/user_manager.rs index 67c4e6a..d6a0b47 100644 --- a/iota-storage/src/users/user_manager.rs +++ b/iota-storage/src/users/user_manager.rs @@ -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"); diff --git a/iota-storage/src/util/config_util.rs b/iota-storage/src/util/config_util.rs index 82fbbaa..5685208 100644 --- a/iota-storage/src/util/config_util.rs +++ b/iota-storage/src/util/config_util.rs @@ -13,6 +13,8 @@ pub struct IotaConfig { pub iota_id: Option, #[serde(default = "default_port")] pub port: u16, + #[serde(default)] + pub web: WebSettings, #[serde(skip_serializing_if = "Option::is_none")] pub omikron_host: Option, #[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, + pub key: Option, + #[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, diff --git a/iota-updater/Cargo.toml b/iota-updater/Cargo.toml index 4d1ef48..d483f7c 100644 --- a/iota-updater/Cargo.toml +++ b/iota-updater/Cargo.toml @@ -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" diff --git a/iota-updater/src/lib.rs b/iota-updater/src/lib.rs index c93e9d3..ec5d061 100644 --- a/iota-updater/src/lib.rs +++ b/iota-updater/src/lib.rs @@ -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, -} - -#[derive(Debug)] -struct Asset { - name: String, - browser_download_url: String, -} - -async fn latest_release() -> Result { - 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::>(); - - 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 { - let normalized = tag.strip_prefix('v').unwrap_or(tag); - Ok(Version::parse(normalized)?) -} - -async fn current_version() -> Result { - 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 { - 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> { - 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 { - 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 { - if perform_update().await? { - return Ok(true); - } else { + if std::env::var_os("IOTA_UPDATE_MANIFEST").is_none() { return Ok(false); } + Ok(false) } diff --git a/iota-updater/src/main.rs b/iota-updater/src/main.rs new file mode 100644 index 0000000..d566dc9 --- /dev/null +++ b/iota-updater/src/main.rs @@ -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(()) +} diff --git a/iota-updater/src/manifest.rs b/iota-updater/src/manifest.rs new file mode 100644 index 0000000..f2bae0a --- /dev/null +++ b/iota-updater/src/manifest.rs @@ -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, + 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> { + 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()); + } +} diff --git a/iota-updater/src/transaction.rs b/iota-updater/src/transaction.rs new file mode 100644 index 0000000..9dfcd4e --- /dev/null +++ b/iota-updater/src/transaction.rs @@ -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) -> Self { + let root = root.into(); + Self { + staging: root.join(".staging"), + root, + } + } + pub fn acquire(&self) -> Result { + 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 { + 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(¤t_tmp); + std::os::unix::fs::symlink(&version_dir, ¤t_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" + ); + } +} diff --git a/iota-util/Cargo.toml b/iota-util/Cargo.toml index 23ed0b7..d61786e 100644 --- a/iota-util/Cargo.toml +++ b/iota-util/Cargo.toml @@ -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" ] } diff --git a/iota-util/src/file_util.rs b/iota-util/src/file_util.rs index 286c2f2..fe4a8ba 100755 --- a/iota-util/src/file_util.rs +++ b/iota-util/src/file_util.rs @@ -162,10 +162,7 @@ pub fn get_children(path: &str) -> Vec { } 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 diff --git a/iota/Cargo.toml b/iota/Cargo.toml index 18c53b0..64de533 100644 --- a/iota/Cargo.toml +++ b/iota/Cargo.toml @@ -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"] } diff --git a/iota/src/cli_args.rs b/iota/src/cli_args.rs new file mode 100644 index 0000000..cedefb0 --- /dev/null +++ b/iota/src/cli_args.rs @@ -0,0 +1,154 @@ +use iota_cli::theme::ThemeName; + +#[derive(Debug)] +pub struct CliInvocation { + pub theme_override: Option, + pub command: Command, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Command { + Dashboard, + Help, + Install { + bundle: String, + operator: Option, + }, + Status, + Tasks, + UsersList, + DaemonRestart { + confirmed: bool, + }, + DaemonStop { + confirmed: bool, + }, + DaemonStopProcess, + DaemonEnable { + mode: String, + }, + DaemonDisableStartup, + DaemonDaemonStatus, +} +impl CliInvocation { + pub fn parse(args: impl IntoIterator) -> Result { + 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 }); + } +} diff --git a/iota/src/daemon_setup_flow.rs b/iota/src/daemon_setup_flow.rs new file mode 100644 index 0000000..c88f875 --- /dev/null +++ b/iota/src/daemon_setup_flow.rs @@ -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, + pub socket: Result<(), StartupError>, + pub system: Result, StartupError>, +} +pub struct DaemonEndpoints { + pub local: PathBuf, + pub system: PathBuf, +} +pub struct ConnectionContext { + pub ipc: Arc, +} +impl Capabilities { + fn options(&self) -> Vec { + 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, + endpoints: &DaemonEndpoints, + caps: Capabilities, +) -> Result { + // 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, + exe: &Path, + path: &Path, +) -> Result { + 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, + options: Vec, + message: impl Into, +) -> Result { + 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)); + } +} diff --git a/iota/src/local_daemon.rs b/iota/src/local_daemon.rs new file mode 100644 index 0000000..4af10ea --- /dev/null +++ b/iota/src/local_daemon.rs @@ -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, + 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, + executable: &Path, + socket: &Path, +) -> Result, 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::::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>>) -> String { + let lines = diagnostics.lock().unwrap(); + if lines.is_empty() { + message + } else { + format!( + "{message}; daemon stderr: {}", + lines.iter().cloned().collect::>().join(" | ") + ) + } +} diff --git a/iota/src/main.rs b/iota/src/main.rs index e2e8882..e71c2d5 100644 --- a/iota/src/main.rs +++ b/iota/src/main.rs @@ -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, }; - 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; + + 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, 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, + 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(); + 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 { + 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 ] Open the dashboard\n iota daemon install --bundle [--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, 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:?}" + ))), } } diff --git a/iota/src/startup_error.rs b/iota/src/startup_error.rs new file mode 100644 index 0000000..461e272 --- /dev/null +++ b/iota/src/startup_error.rs @@ -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")); + } +} diff --git a/omikron-connector/Cargo.toml b/omikron-connector/Cargo.toml index 7526c63..c869941 100644 --- a/omikron-connector/Cargo.toml +++ b/omikron-connector/Cargo.toml @@ -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" } diff --git a/omikron-connector/src/client.rs b/omikron-connector/src/client.rs new file mode 100644 index 0000000..872ff67 --- /dev/null +++ b/omikron-connector/src/client.rs @@ -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, + }, + 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; + async fn reconnect(&self) -> Result<(), OmikronError>; + async fn is_connected(&self) -> bool; +} diff --git a/omikron-connector/src/lib.rs b/omikron-connector/src/lib.rs index 22029a6..cce748b 100644 --- a/omikron-connector/src/lib.rs +++ b/omikron-connector/src/lib.rs @@ -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; diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 45257c2..54038da 100755 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -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, CommunicationValue) -> bool + Send + Sync>, + pub task: Box bool + Send + Sync>, pub inserted_at: Instant, } @@ -163,14 +164,20 @@ pub struct OmikronConnection { pub(crate) missed_pongs: Arc, handler_semaphore: Arc, cancellation: CancellationToken, + pub(crate) active_tasks: Arc>, + pub(crate) app: Arc>, } impl OmikronConnection { - pub fn new() -> Self { - Self::with_cancellation(CancellationToken::new()) + pub fn new(active_tasks: Arc>, app: Arc>) -> 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>, + app: Arc>, + ) -> 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> = 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> { - let conn = Arc::new(OmikronConnection::with_cancellation(cancellation)); + active_tasks: Arc>, + app: Arc>, +) -> Result, 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 { + 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 + } +} diff --git a/omikron-connector/src/ping_pong_task.rs b/omikron-connector/src/ping_pong_task.rs index 2155b6c..d55cec7 100644 --- a/omikron-connector/src/ping_pong_task.rs +++ b/omikron-connector/src/ping_pong_task.rs @@ -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); } } } diff --git a/omikron-connector/src/user_ops.rs b/omikron-connector/src/user_ops.rs index 6d52dd5..00733e1 100644 --- a/omikron-connector/src/user_ops.rs +++ b/omikron-connector/src/user_ops.rs @@ -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, Option) { +pub async fn create_user( + connection: &dyn OmikronClient, + username: &str, +) -> (Option, Option) { let register_communication_value = CommunicationValue::new(CommunicationType::GetRegister); - let connection = OMIKRON_CONNECTION.clone(); - let response_communication_value = match connection - .await_response(®ister_communication_value, Some(Duration::from_secs(20))) + .await_response(®ister_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, Option .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, Option 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)) } diff --git a/src/.DS_Store b/src/.DS_Store deleted file mode 100644 index 9f676a4..0000000 Binary files a/src/.DS_Store and /dev/null differ diff --git a/src/util/auto_update.rs b/src/util/auto_update.rs deleted file mode 100644 index 090c5a8..0000000 --- a/src/util/auto_update.rs +++ /dev/null @@ -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, -} - -#[derive(Debug, Deserialize)] -struct Asset { - name: String, - browser_download_url: String, -} - -async fn latest_release() -> Result { - 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 { - let normalized = tag.strip_prefix('v').unwrap_or(tag); - Ok(Version::parse(normalized)?) -} - -async fn current_version() -> Result { - 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 { - 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> { - 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 { - 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 { - if perform_update().await? { - return Ok(true); - } else { - return Ok(false); - } -} diff --git a/systemd/iota-daemon.service b/systemd/iota-daemon.service index b61e5e2..a5a2903 100644 --- a/systemd/iota-daemon.service +++ b/systemd/iota-daemon.service @@ -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 diff --git a/systemd/iota-daemon.socket b/systemd/iota-daemon.socket index b06a209..4030172 100644 --- a/systemd/iota-daemon.socket +++ b/systemd/iota-daemon.socket @@ -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 diff --git a/systemd/sysusers.d/iota.conf b/systemd/sysusers.d/iota.conf new file mode 100644 index 0000000..5a103af --- /dev/null +++ b/systemd/sysusers.d/iota.conf @@ -0,0 +1,2 @@ +g iota-operators - +u iota - "Tensamin Iota daemon" /var/lib/iota diff --git a/web-server/src/lib.rs b/web-server/src/lib.rs index f5214ab..9cbabb2 100644 --- a/web-server/src/lib.rs +++ b/web-server/src/lib.rs @@ -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, + 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>>, +} +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 { - return response - .status(http::StatusCode::NOT_FOUND) - .body("not found"); + 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 - .status(http::StatusCode::OK) - .header("content-type", content_type(name)) - .body(Bytes::from(body)), - Err(_) => response - .status(http::StatusCode::NOT_FOUND) - .body("not found"), - } + 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)) } - 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; - } - }; - let key = match tokio::fs::read(KEY_PATH).await { - Ok(key) => key, - Err(error) => { - log!("MTP web server key load failed: {}", error); - return false; - } - }; - - 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 +pub async fn start( + config: WebConfig, + parent: CancellationToken, +) -> Result>, WebServerError> { + if config.mode == WebMode::Disabled { + return Ok(None); + } + if config.mode == WebMode::Network && config.tls.is_none() { + return Err(WebServerError::MissingTls( + "network mode requires TLS".into(), + )); + } + 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)), + }))) } diff --git a/web-ui/src/api.rs b/web-ui/src/api.rs index 2114029..c5c3dab 100755 --- a/web-ui/src/api.rs +++ b/web-ui/src/api.rs @@ -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 { - error() - } + // 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) -> impl Responder { +async fn shutdown( + req: HttpRequest, + ssl: web::Data, + state: web::Data>, +) -> 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) -> impl Responder { +async fn reload( + req: HttpRequest, + ssl: web::Data, + state: web::Data>, +) -> 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() } diff --git a/web-ui/src/server.rs b/web-ui/src/server.rs index 263256e..c73f0f8 100644 --- a/web-ui/src/server.rs +++ b/web-ui/src/server.rs @@ -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) -> bool { let (tx, rx) = oneshot::channel::(); 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) { loop { - if *SHUTDOWN.read().await { + if *state.shutdown.read().await { log!("Shutdown signal received."); server_handle.stop(true).await; break;