[WIP] Daemon & CLI
This commit is contained in:
parent
56aad3a023
commit
8b158108bb
100 changed files with 6519 additions and 1596 deletions
183
Cargo.lock
generated
183
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -18,5 +18,9 @@ members = [
|
|||
"web-ui",
|
||||
"iota-logger",
|
||||
"iota-util",
|
||||
"iota-process-manager",
|
||||
"iota-paths",
|
||||
"iota-installer",
|
||||
"iota-core",
|
||||
]
|
||||
resolver = "3"
|
||||
|
|
|
|||
36
README.md
36
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.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ use dashmap::DashMap;
|
|||
use iota_connection::message_common::*;
|
||||
use iota_connection::message_handlers;
|
||||
use iota_logger::{log_cv_in, log_cv_out, log_t};
|
||||
use iota_state::SHUTDOWN;
|
||||
use iota_storage::util::chat_files::{self, MessageState, change_message_state};
|
||||
use iota_storage::util::config_util::CONFIG;
|
||||
use iota_storage::util::e2ee_storage::{self, StoredChatSecret};
|
||||
|
|
@ -31,6 +30,7 @@ pub struct ClientConnection {
|
|||
shutdown_tx: Arc<Mutex<Option<watch::Sender<bool>>>>,
|
||||
pub waiting_tasks:
|
||||
DashMap<u32, Box<dyn Fn(Arc<ClientConnection>, CommunicationValue) -> bool + Send + Sync>>,
|
||||
shutdown: Arc<RwLock<bool>>,
|
||||
}
|
||||
|
||||
impl ClientConnection {
|
||||
|
|
@ -45,6 +45,7 @@ impl ClientConnection {
|
|||
u32,
|
||||
Box<dyn Fn(Arc<ClientConnection>, CommunicationValue) -> bool + Send + Sync>,
|
||||
>,
|
||||
shutdown: Arc<RwLock<bool>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
sender,
|
||||
|
|
@ -54,6 +55,7 @@ impl ClientConnection {
|
|||
connection_id,
|
||||
shutdown_tx,
|
||||
waiting_tasks,
|
||||
shutdown,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -61,7 +63,7 @@ impl ClientConnection {
|
|||
let self_clone = self.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Ok(cv) = self_clone.receiver.receive().await {
|
||||
if *SHUTDOWN.read().await {
|
||||
if *self_clone.shutdown.read().await {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 != []) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
7
iota-cli/src/controls/action.rs
Normal file
7
iota-cli/src/controls/action.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ControlAction {
|
||||
FocusNext,
|
||||
FocusPrevious,
|
||||
Select,
|
||||
Activate,
|
||||
}
|
||||
72
iota-cli/src/controls/button.rs
Normal file
72
iota-cli/src/controls/button.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
use crate::theme::ResolvedTheme;
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Alignment, Rect},
|
||||
text::Span,
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ButtonIntent {
|
||||
Primary,
|
||||
Neutral,
|
||||
Cancel,
|
||||
Destructive,
|
||||
}
|
||||
pub struct ActionButton<'a> {
|
||||
pub label: &'a str,
|
||||
pub intent: ButtonIntent,
|
||||
pub focused: bool,
|
||||
pub enabled: bool,
|
||||
}
|
||||
pub fn render_button(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
button: ActionButton<'_>,
|
||||
theme: &ResolvedTheme,
|
||||
) {
|
||||
let style = if !button.enabled {
|
||||
theme.buttons.disabled
|
||||
} else {
|
||||
match (button.intent, button.focused) {
|
||||
(ButtonIntent::Primary, true) => theme.buttons.primary_focused,
|
||||
(ButtonIntent::Primary, false) => theme.buttons.primary,
|
||||
(ButtonIntent::Neutral, true) => theme.buttons.neutral_focused,
|
||||
(ButtonIntent::Neutral, false) => theme.buttons.neutral,
|
||||
(ButtonIntent::Cancel, true) => theme.buttons.cancel_focused,
|
||||
(ButtonIntent::Cancel, false) => theme.buttons.cancel,
|
||||
(ButtonIntent::Destructive, _) => theme.buttons.destructive,
|
||||
}
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(Span::styled(button.label, style))
|
||||
.alignment(Alignment::Center)
|
||||
.block(Block::default().borders(Borders::ALL)),
|
||||
area,
|
||||
);
|
||||
}
|
||||
pub fn horizontal_button_widths(available: u16, minimums: &[u16]) -> Option<Vec<u16>> {
|
||||
let required = minimums
|
||||
.iter()
|
||||
.try_fold(0u16, |total, width| total.checked_add(*width))?;
|
||||
if required > available {
|
||||
return None;
|
||||
}
|
||||
if minimums.is_empty() {
|
||||
return Some(Vec::new());
|
||||
}
|
||||
let extra = available - required;
|
||||
let count = minimums.len() as u16;
|
||||
Some(
|
||||
minimums
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, width)| width + extra / count + u16::from((index as u16) < extra % count))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
pub fn button_minimum_width(label: &str) -> u16 {
|
||||
UnicodeWidthStr::width(label)
|
||||
.saturating_add(2)
|
||||
.min(u16::MAX as usize) as u16
|
||||
}
|
||||
135
iota-cli/src/controls/checkbox_group.rs
Normal file
135
iota-cli/src/controls/checkbox_group.rs
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
use super::{choice::ChoiceVisualState, navigation::DisabledFocusPolicy};
|
||||
use std::{collections::HashSet, hash::Hash};
|
||||
|
||||
pub struct CheckboxItem<T> {
|
||||
pub value: T,
|
||||
pub label: String,
|
||||
pub description: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub disabled_reason: Option<String>,
|
||||
}
|
||||
pub struct CheckboxGroup<T: Clone + Eq + Hash> {
|
||||
items: Vec<CheckboxItem<T>>,
|
||||
selected: HashSet<T>,
|
||||
focused_index: usize,
|
||||
focus_policy: DisabledFocusPolicy,
|
||||
wrap_navigation: bool,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CheckboxGroupError {
|
||||
Empty,
|
||||
DuplicateValue,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CheckboxChange<T> {
|
||||
Selected(T),
|
||||
Deselected(T),
|
||||
IgnoredDisabled(T),
|
||||
NoItem,
|
||||
}
|
||||
impl<T: Clone + Eq + Hash> CheckboxGroup<T> {
|
||||
pub fn new(
|
||||
items: Vec<CheckboxItem<T>>,
|
||||
selected: impl IntoIterator<Item = T>,
|
||||
) -> Result<Self, CheckboxGroupError> {
|
||||
let mut values = HashSet::new();
|
||||
if items.iter().any(|item| !values.insert(item.value.clone())) {
|
||||
return Err(CheckboxGroupError::DuplicateValue);
|
||||
}
|
||||
let selected = selected
|
||||
.into_iter()
|
||||
.filter(|value| values.contains(value))
|
||||
.collect();
|
||||
let focused_index = items.iter().position(|item| item.enabled).unwrap_or(0);
|
||||
Ok(Self {
|
||||
items,
|
||||
selected,
|
||||
focused_index,
|
||||
focus_policy: DisabledFocusPolicy::Skip,
|
||||
wrap_navigation: true,
|
||||
})
|
||||
}
|
||||
pub fn items(&self) -> &[CheckboxItem<T>] {
|
||||
&self.items
|
||||
}
|
||||
pub fn selected(&self) -> &HashSet<T> {
|
||||
&self.selected
|
||||
}
|
||||
pub fn focused_item(&self) -> Option<&CheckboxItem<T>> {
|
||||
self.items.get(self.focused_index)
|
||||
}
|
||||
pub fn set_focus_policy(&mut self, policy: DisabledFocusPolicy) {
|
||||
self.focus_policy = policy;
|
||||
}
|
||||
pub fn set_wrap_navigation(&mut self, wrap: bool) {
|
||||
self.wrap_navigation = wrap;
|
||||
}
|
||||
pub fn focus_next(&mut self) {
|
||||
self.move_focus(true);
|
||||
}
|
||||
pub fn focus_previous(&mut self) {
|
||||
self.move_focus(false);
|
||||
}
|
||||
fn move_focus(&mut self, forward: bool) {
|
||||
if self.items.is_empty() {
|
||||
return;
|
||||
}
|
||||
for step in 1..=self.items.len() {
|
||||
let current = self.focused_index as isize;
|
||||
let delta = if forward {
|
||||
step as isize
|
||||
} else {
|
||||
-(step as isize)
|
||||
};
|
||||
let raw = current + delta;
|
||||
let next = if self.wrap_navigation {
|
||||
raw.rem_euclid(self.items.len() as isize) as usize
|
||||
} else if raw < 0 || raw >= self.items.len() as isize {
|
||||
return;
|
||||
} else {
|
||||
raw as usize
|
||||
};
|
||||
if self.focus_policy == DisabledFocusPolicy::Include || self.items[next].enabled {
|
||||
self.focused_index = next;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn toggle_focused(&mut self) -> CheckboxChange<T> {
|
||||
let Some(item) = self.items.get(self.focused_index) else {
|
||||
return CheckboxChange::NoItem;
|
||||
};
|
||||
let value = item.value.clone();
|
||||
if !item.enabled {
|
||||
return CheckboxChange::IgnoredDisabled(value);
|
||||
}
|
||||
if self.selected.remove(&value) {
|
||||
CheckboxChange::Deselected(value)
|
||||
} else {
|
||||
self.selected.insert(value.clone());
|
||||
CheckboxChange::Selected(value)
|
||||
}
|
||||
}
|
||||
pub fn set_enabled(&mut self, value: &T, enabled: bool) {
|
||||
if let Some(item) = self.items.iter_mut().find(|item| &item.value == value) {
|
||||
item.enabled = enabled;
|
||||
}
|
||||
}
|
||||
pub fn set_selected(&mut self, value: T, selected: bool) {
|
||||
if selected {
|
||||
self.selected.insert(value);
|
||||
} else {
|
||||
self.selected.remove(&value);
|
||||
}
|
||||
}
|
||||
pub fn visual_state(&self, value: &T) -> ChoiceVisualState {
|
||||
let item = self.items.iter().position(|item| &item.value == value);
|
||||
ChoiceVisualState {
|
||||
selected: self.selected.contains(value),
|
||||
focused: item == Some(self.focused_index),
|
||||
enabled: item
|
||||
.and_then(|index| self.items.get(index))
|
||||
.is_some_and(|item| item.enabled),
|
||||
}
|
||||
}
|
||||
}
|
||||
42
iota-cli/src/controls/choice.rs
Normal file
42
iota-cli/src/controls/choice.rs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
use crate::theme::ResolvedTheme;
|
||||
use ratatui::text::{Line, Span};
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ChoiceKind {
|
||||
Checkbox,
|
||||
Radio,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ChoiceVisualState {
|
||||
pub selected: bool,
|
||||
pub focused: bool,
|
||||
pub enabled: bool,
|
||||
}
|
||||
pub fn render_choice_line<'a>(
|
||||
label: &'a str,
|
||||
kind: ChoiceKind,
|
||||
state: ChoiceVisualState,
|
||||
theme: &'a ResolvedTheme,
|
||||
) -> Line<'a> {
|
||||
let item = match (state.selected, state.focused, state.enabled) {
|
||||
(_, true, false) => &theme.choices.focused_disabled,
|
||||
(true, false, false) => &theme.choices.selected_disabled,
|
||||
(false, false, false) => &theme.choices.disabled,
|
||||
(true, true, true) => &theme.choices.focused_selected,
|
||||
(true, false, true) => &theme.choices.selected,
|
||||
(false, true, true) => &theme.choices.focused,
|
||||
(false, false, true) => &theme.choices.normal,
|
||||
};
|
||||
let marker = match (kind, state.selected) {
|
||||
(ChoiceKind::Checkbox, false) => theme.markers.checkbox_unselected,
|
||||
(ChoiceKind::Checkbox, true) => theme.markers.checkbox_selected,
|
||||
(ChoiceKind::Radio, false) => theme.markers.radio_unselected,
|
||||
(ChoiceKind::Radio, true) => theme.markers.radio_selected,
|
||||
};
|
||||
Line::from(vec![
|
||||
Span::styled(item.prefix, item.label),
|
||||
Span::styled(marker, item.marker),
|
||||
Span::raw(" "),
|
||||
Span::styled(label, item.label),
|
||||
Span::styled(item.suffix, item.label),
|
||||
])
|
||||
}
|
||||
6
iota-cli/src/controls/mod.rs
Normal file
6
iota-cli/src/controls/mod.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
pub mod action;
|
||||
pub mod button;
|
||||
pub mod checkbox_group;
|
||||
pub mod choice;
|
||||
pub mod navigation;
|
||||
pub mod radio_group;
|
||||
6
iota-cli/src/controls/navigation.rs
Normal file
6
iota-cli/src/controls/navigation.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum DisabledFocusPolicy {
|
||||
Include,
|
||||
#[default]
|
||||
Skip,
|
||||
}
|
||||
194
iota-cli/src/controls/radio_group.rs
Normal file
194
iota-cli/src/controls/radio_group.rs
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
use super::{choice::ChoiceVisualState, navigation::DisabledFocusPolicy};
|
||||
|
||||
pub struct RadioItem<T> {
|
||||
pub value: T,
|
||||
pub label: String,
|
||||
pub description: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub disabled_reason: Option<String>,
|
||||
}
|
||||
pub struct RadioGroup<T: Clone + Eq> {
|
||||
items: Vec<RadioItem<T>>,
|
||||
selected: T,
|
||||
default: T,
|
||||
focused_index: usize,
|
||||
focus_policy: DisabledFocusPolicy,
|
||||
wrap_navigation: bool,
|
||||
disabled_selection_policy: DisabledSelectionPolicy,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RadioGroupError {
|
||||
Empty,
|
||||
DefaultMissing,
|
||||
DefaultDisabled,
|
||||
NoEnabledItems,
|
||||
SelectedItemDisabled,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RadioChange<T> {
|
||||
Changed { previous: T, selected: T },
|
||||
Unchanged(T),
|
||||
IgnoredDisabled(T),
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DisabledSelectionPolicy {
|
||||
UseConfiguredDefault,
|
||||
UseFirstEnabled,
|
||||
ReturnError,
|
||||
}
|
||||
impl<T: Clone + Eq> RadioGroup<T> {
|
||||
pub fn new(
|
||||
items: Vec<RadioItem<T>>,
|
||||
observed: Option<T>,
|
||||
default: T,
|
||||
) -> Result<Self, RadioGroupError> {
|
||||
if items.is_empty() {
|
||||
return Err(RadioGroupError::Empty);
|
||||
}
|
||||
let default_item = items
|
||||
.iter()
|
||||
.find(|item| item.value == default)
|
||||
.ok_or(RadioGroupError::DefaultMissing)?;
|
||||
if !default_item.enabled {
|
||||
return Err(RadioGroupError::DefaultDisabled);
|
||||
}
|
||||
let focused_index = items
|
||||
.iter()
|
||||
.position(|item| item.enabled)
|
||||
.ok_or(RadioGroupError::NoEnabledItems)?;
|
||||
let selected = observed
|
||||
.filter(|value| {
|
||||
items
|
||||
.iter()
|
||||
.any(|item| item.enabled && item.value == *value)
|
||||
})
|
||||
.unwrap_or_else(|| default.clone());
|
||||
Ok(Self {
|
||||
items,
|
||||
selected,
|
||||
default,
|
||||
focused_index,
|
||||
focus_policy: DisabledFocusPolicy::Skip,
|
||||
wrap_navigation: true,
|
||||
disabled_selection_policy: DisabledSelectionPolicy::UseConfiguredDefault,
|
||||
})
|
||||
}
|
||||
pub fn items(&self) -> &[RadioItem<T>] {
|
||||
&self.items
|
||||
}
|
||||
pub fn selected(&self) -> &T {
|
||||
&self.selected
|
||||
}
|
||||
pub fn focused_item(&self) -> &RadioItem<T> {
|
||||
&self.items[self.focused_index]
|
||||
}
|
||||
pub fn focus_next(&mut self) {
|
||||
self.move_focus(true);
|
||||
}
|
||||
pub fn focus_previous(&mut self) {
|
||||
self.move_focus(false);
|
||||
}
|
||||
fn move_focus(&mut self, forward: bool) {
|
||||
for step in 1..=self.items.len() {
|
||||
let raw = self.focused_index as isize
|
||||
+ if forward {
|
||||
step as isize
|
||||
} else {
|
||||
-(step as isize)
|
||||
};
|
||||
let next = if self.wrap_navigation {
|
||||
raw.rem_euclid(self.items.len() as isize) as usize
|
||||
} else if raw < 0 || raw >= self.items.len() as isize {
|
||||
return;
|
||||
} else {
|
||||
raw as usize
|
||||
};
|
||||
if self.focus_policy == DisabledFocusPolicy::Include || self.items[next].enabled {
|
||||
self.focused_index = next;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn select_focused(&mut self) -> RadioChange<T> {
|
||||
let item = self.focused_item();
|
||||
let enabled = item.enabled;
|
||||
let value = item.value.clone();
|
||||
if !enabled {
|
||||
return RadioChange::IgnoredDisabled(value);
|
||||
}
|
||||
if value == self.selected {
|
||||
RadioChange::Unchanged(self.selected.clone())
|
||||
} else {
|
||||
let previous = std::mem::replace(&mut self.selected, value);
|
||||
RadioChange::Changed {
|
||||
previous,
|
||||
selected: self.selected.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn visual_state(&self, value: &T) -> ChoiceVisualState {
|
||||
let item = self.items.iter().position(|item| &item.value == value);
|
||||
ChoiceVisualState {
|
||||
selected: &self.selected == value,
|
||||
focused: item == Some(self.focused_index),
|
||||
enabled: item
|
||||
.and_then(|index| self.items.get(index))
|
||||
.is_some_and(|item| item.enabled),
|
||||
}
|
||||
}
|
||||
pub fn set_disabled_selection_policy(&mut self, policy: DisabledSelectionPolicy) {
|
||||
self.disabled_selection_policy = policy;
|
||||
}
|
||||
pub fn set_focus_policy(&mut self, policy: DisabledFocusPolicy) {
|
||||
self.focus_policy = policy;
|
||||
}
|
||||
pub fn set_wrap_navigation(&mut self, wrap: bool) {
|
||||
self.wrap_navigation = wrap;
|
||||
}
|
||||
pub fn set_enabled(&mut self, value: &T, enabled: bool) -> Result<(), RadioGroupError> {
|
||||
let Some(index) = self.items.iter().position(|item| &item.value == value) else {
|
||||
return Ok(());
|
||||
};
|
||||
if self.items[index].enabled == enabled {
|
||||
return Ok(());
|
||||
}
|
||||
if !enabled
|
||||
&& self
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(other, item)| other == index || !item.enabled)
|
||||
{
|
||||
return Err(RadioGroupError::NoEnabledItems);
|
||||
}
|
||||
if !enabled && self.selected == *value {
|
||||
let replacement = match self.disabled_selection_policy {
|
||||
DisabledSelectionPolicy::UseConfiguredDefault if self.default != *value => self
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| item.enabled && item.value == self.default)
|
||||
.map(|item| item.value.clone()),
|
||||
DisabledSelectionPolicy::UseConfiguredDefault => None,
|
||||
DisabledSelectionPolicy::UseFirstEnabled => self
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(other, item)| *other != index && item.enabled)
|
||||
.map(|(_, item)| item.value.clone()),
|
||||
DisabledSelectionPolicy::ReturnError => {
|
||||
return Err(RadioGroupError::SelectedItemDisabled);
|
||||
}
|
||||
};
|
||||
self.selected = replacement.ok_or(RadioGroupError::SelectedItemDisabled)?;
|
||||
}
|
||||
self.items[index].enabled = enabled;
|
||||
if !enabled && self.focused_index == index && self.focus_policy == DisabledFocusPolicy::Skip
|
||||
{
|
||||
self.focus_next();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn default(&self) -> &T {
|
||||
&self.default
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ use crossterm::event::{KeyCode, KeyEvent};
|
|||
use ratatui::{
|
||||
Frame,
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
};
|
||||
|
|
@ -18,6 +17,7 @@ use crate::{
|
|||
elements::elements::{Element, InteractableElement, JoinableElement},
|
||||
interaction_result::InteractionResult,
|
||||
ipc_client::IpcClient,
|
||||
render_context::RenderContext,
|
||||
util::borders::draw_block_joins,
|
||||
};
|
||||
|
||||
|
|
@ -34,6 +34,7 @@ pub struct ConsoleCard {
|
|||
cursor: Arc<Mutex<bool>>,
|
||||
last_swap: Arc<Mutex<Instant>>,
|
||||
pending_restore: Arc<Mutex<Option<String>>>,
|
||||
pending_confirmation: Option<String>,
|
||||
}
|
||||
|
||||
impl ConsoleCard {
|
||||
|
|
@ -49,6 +50,7 @@ impl ConsoleCard {
|
|||
cursor: Arc::new(Mutex::new(true)),
|
||||
last_swap: Arc::new(Mutex::new(Instant::now())),
|
||||
pending_restore: Arc::new(Mutex::new(None)),
|
||||
pending_confirmation: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -85,26 +87,25 @@ impl ConsoleCard {
|
|||
}
|
||||
}
|
||||
|
||||
fn cursor_spans(&self) -> Vec<Span<'static>> {
|
||||
fn cursor_spans(&self, theme: &crate::theme::ResolvedTheme) -> Vec<Span<'static>> {
|
||||
let cursor_visible = self.cursor_visible();
|
||||
let cursor_style = Style::default().fg(Color::White).bg(Color::DarkGray);
|
||||
let mut spans = Vec::new();
|
||||
|
||||
if self.content.is_empty() {
|
||||
if self.focused {
|
||||
if cursor_visible {
|
||||
spans.push(Span::styled(" ", cursor_style));
|
||||
Self::push_cursor(&mut spans, theme);
|
||||
} else {
|
||||
spans.push(Span::styled(" ", Style::default().fg(Color::White)));
|
||||
spans.push(Span::styled(" ", theme.console.text));
|
||||
}
|
||||
spans.push(Span::styled(
|
||||
"send command (/help for info)",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
theme.console.hint,
|
||||
));
|
||||
} else {
|
||||
spans.push(Span::styled(
|
||||
" send command (/help for info)",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
theme.console.hint,
|
||||
));
|
||||
}
|
||||
return spans;
|
||||
|
|
@ -119,52 +120,64 @@ impl ConsoleCard {
|
|||
if prefix_len > 0 && before.len() >= prefix_len {
|
||||
let prefix = &before[..prefix_len];
|
||||
let rest = &before[prefix_len..];
|
||||
spans.push(Span::styled(
|
||||
prefix.to_string(),
|
||||
Self::style_for_part(true, false, false),
|
||||
));
|
||||
spans.push(Span::styled(prefix.to_string(), theme.console.prefix));
|
||||
if !rest.is_empty() {
|
||||
spans.push(Span::styled(
|
||||
rest.to_string(),
|
||||
Style::default().fg(Color::White),
|
||||
));
|
||||
spans.push(Span::styled(rest.to_string(), theme.console.text));
|
||||
}
|
||||
} else if !before.is_empty() {
|
||||
spans.push(Span::styled(
|
||||
before.clone(),
|
||||
Style::default().fg(Color::White),
|
||||
));
|
||||
spans.push(Span::styled(before.clone(), theme.console.text));
|
||||
}
|
||||
|
||||
if cursor_visible {
|
||||
spans.push(Span::styled(" ", cursor_style));
|
||||
Self::push_cursor(&mut spans, theme);
|
||||
}
|
||||
|
||||
if !after.is_empty() {
|
||||
spans.push(Span::styled(after, Style::default().fg(Color::White)));
|
||||
spans.push(Span::styled(after, theme.console.text));
|
||||
}
|
||||
|
||||
spans
|
||||
}
|
||||
|
||||
fn style_for_part(is_prefix: bool, is_hint: bool, is_error: bool) -> Style {
|
||||
if is_error {
|
||||
return Style::default().fg(Color::Red);
|
||||
fn push_cursor(spans: &mut Vec<Span<'static>>, theme: &crate::theme::ResolvedTheme) {
|
||||
match &theme.console.cursor {
|
||||
crate::theme::CursorPresentation::StyledCell(style) => {
|
||||
spans.push(Span::styled(" ", *style))
|
||||
}
|
||||
crate::theme::CursorPresentation::Character { glyph, style } => {
|
||||
spans.push(Span::styled(*glyph, *style))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if is_hint {
|
||||
return Style::default().fg(Color::DarkGray);
|
||||
fn render_cursor_spans(&self, theme: &crate::theme::ResolvedTheme) -> Vec<Span<'static>> {
|
||||
if let Some(command) = &self.pending_confirmation {
|
||||
return vec![Span::styled(
|
||||
format!("Confirm `{command}`? [y/N]"),
|
||||
theme.console.confirmation,
|
||||
)];
|
||||
}
|
||||
self.cursor_spans(theme)
|
||||
}
|
||||
|
||||
if is_prefix {
|
||||
return Style::default().fg(Color::DarkGray);
|
||||
fn is_destructive(command: &str) -> bool {
|
||||
matches!(
|
||||
command.trim_start_matches('/').trim(),
|
||||
"restart" | "reload" | "stop" | "shutdown" | "regenerate keys"
|
||||
) || command
|
||||
.trim_start_matches('/')
|
||||
.trim_start()
|
||||
.starts_with("user remove ")
|
||||
}
|
||||
|
||||
Style::default().fg(Color::White)
|
||||
fn dispatch_command(&self, command: String) {
|
||||
let ipc = self.ipc.clone();
|
||||
let restore = self.pending_restore.clone();
|
||||
tokio::spawn(async move {
|
||||
if ipc.send_command(0, command.clone()).await.is_err() {
|
||||
*restore.lock().unwrap() = Some(command);
|
||||
}
|
||||
|
||||
fn render_cursor_spans(&self) -> Vec<Span<'static>> {
|
||||
self.cursor_spans()
|
||||
});
|
||||
}
|
||||
|
||||
fn move_cursor_left(&mut self) {
|
||||
|
|
@ -212,28 +225,34 @@ impl Element for ConsoleCard {
|
|||
self
|
||||
}
|
||||
|
||||
fn render(&self, f: &mut Frame, r: Rect) {
|
||||
fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) {
|
||||
let block = Block::default()
|
||||
.borders(self.borders)
|
||||
.title(self.title.clone())
|
||||
.title_style(Style::default().fg(Color::White))
|
||||
.title_style(context.theme.console.title)
|
||||
.border_style(if self.focused {
|
||||
Style::default().fg(Color::Yellow)
|
||||
context.theme.console.focused_border
|
||||
} else {
|
||||
Style::default()
|
||||
context.theme.console.border
|
||||
})
|
||||
.style(if self.focused {
|
||||
Style::default().fg(Color::White)
|
||||
} else {
|
||||
Style::default()
|
||||
});
|
||||
.style(context.theme.console.text);
|
||||
|
||||
let spans = self.render_cursor_spans();
|
||||
let spans = self.render_cursor_spans(context.theme);
|
||||
let par = Paragraph::new(Line::from(spans))
|
||||
.block(block)
|
||||
.scroll((0, 0));
|
||||
f.render_widget(par, r);
|
||||
draw_block_joins(f, r, self.borders, self.joins);
|
||||
draw_block_joins(
|
||||
f,
|
||||
r,
|
||||
self.borders,
|
||||
self.joins,
|
||||
if self.focused {
|
||||
context.theme.borders.focused
|
||||
} else {
|
||||
context.theme.borders.normal
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -287,6 +306,13 @@ impl InteractableElement for ConsoleCard {
|
|||
self.cursor_position = self.content.chars().count();
|
||||
}
|
||||
|
||||
if let Some(command) = self.pending_confirmation.take() {
|
||||
if matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y')) {
|
||||
self.dispatch_command(command);
|
||||
}
|
||||
return InteractionResult::Handled;
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Enter => {
|
||||
if self.content.is_empty() {
|
||||
|
|
@ -294,16 +320,13 @@ impl InteractableElement for ConsoleCard {
|
|||
}
|
||||
|
||||
let command = self.content.clone();
|
||||
let ipc = self.ipc.clone();
|
||||
let restore = self.pending_restore.clone();
|
||||
tokio::spawn(async move {
|
||||
if ipc.send_command(0, command.clone()).await.is_err() {
|
||||
*restore.lock().unwrap() = Some(command);
|
||||
}
|
||||
});
|
||||
|
||||
self.content.clear();
|
||||
self.cursor_position = 0;
|
||||
if Self::is_destructive(&command) {
|
||||
self.pending_confirmation = Some(command);
|
||||
} else {
|
||||
self.dispatch_command(command);
|
||||
}
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,54 @@
|
|||
use crate::elements::elements::{Element, InteractableElement, JoinableElement};
|
||||
use crate::interaction_result::InteractionResult;
|
||||
use crate::util::borders::draw_block_joins;
|
||||
use crate::{interaction_result::InteractionResult, render_context::RenderContext};
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use iota_state::{ClientState, UiLogEntry};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
style::Style,
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
};
|
||||
use std::any::Any;
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum LogSource {
|
||||
Call,
|
||||
Client,
|
||||
Iota,
|
||||
Omikron,
|
||||
Omega,
|
||||
Command,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl LogSource {
|
||||
fn from_sender(sender: &str) -> Self {
|
||||
match sender {
|
||||
"Call" => Self::Call,
|
||||
"Client" => Self::Client,
|
||||
"Iota" => Self::Iota,
|
||||
"Omikron" => Self::Omikron,
|
||||
"Omega" => Self::Omega,
|
||||
"Command" => Self::Command,
|
||||
_ => Self::Other,
|
||||
}
|
||||
}
|
||||
|
||||
fn style(self, theme: &crate::theme::ResolvedTheme) -> Style {
|
||||
match self {
|
||||
Self::Call => theme.logs.call,
|
||||
Self::Client => theme.logs.client,
|
||||
Self::Iota => theme.logs.iota,
|
||||
Self::Omikron => theme.logs.omikron,
|
||||
Self::Omega => theme.logs.omega,
|
||||
Self::Command => theme.logs.command,
|
||||
Self::Other => theme.logs.other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct LogCard {
|
||||
state: ClientState,
|
||||
|
|
@ -38,11 +76,10 @@ impl LogCard {
|
|||
}
|
||||
|
||||
fn get_logs(&self) -> Vec<UiLogEntry> {
|
||||
let state = self
|
||||
.state
|
||||
.app
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
let state = match self.state.app.try_lock() {
|
||||
Ok(state) => state,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
state
|
||||
.get_logs()
|
||||
.iter()
|
||||
|
|
@ -64,7 +101,7 @@ impl LogCard {
|
|||
let mut last_boundary = 0usize;
|
||||
|
||||
for (idx, ch) in s.char_indices() {
|
||||
let char_width = if ch.is_ascii() { 1 } else { 2 };
|
||||
let char_width = UnicodeWidthChar::width(ch).unwrap_or(0);
|
||||
if current_width + char_width > max_width {
|
||||
if last_boundary == 0 {
|
||||
return idx + ch.len_utf8();
|
||||
|
|
@ -78,7 +115,7 @@ impl LogCard {
|
|||
s.len()
|
||||
}
|
||||
|
||||
fn wrap_entry(entry: &UiLogEntry, available_width: usize) -> Vec<(String, Color, bool)> {
|
||||
fn wrap_entry(entry: &UiLogEntry, available_width: usize) -> Vec<(String, LogSource, bool)> {
|
||||
let mut result = Vec::new();
|
||||
|
||||
let timestamp = entry.format_timestamp();
|
||||
|
|
@ -141,29 +178,17 @@ impl LogCard {
|
|||
line.push_str(×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<UiLogEntry>,
|
||||
width: usize,
|
||||
) -> Vec<(String, Color, bool)> {
|
||||
) -> Vec<(String, LogSource, bool)> {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
for entry in entries {
|
||||
|
|
@ -269,23 +294,33 @@ impl Element for LogCard {
|
|||
self
|
||||
}
|
||||
|
||||
fn render(&self, f: &mut Frame, area: Rect) {
|
||||
fn render(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) {
|
||||
let entries = self.get_logs();
|
||||
|
||||
let block = Block::default()
|
||||
.title(self.build_title())
|
||||
.borders(self.borders)
|
||||
.border_style(if self.focused {
|
||||
Style::default().fg(Color::Yellow)
|
||||
context.theme.logs.focused_border
|
||||
} else {
|
||||
Style::default()
|
||||
context.theme.logs.border
|
||||
});
|
||||
|
||||
let inner_area = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
|
||||
if inner_area.width == 0 || inner_area.height == 0 {
|
||||
draw_block_joins(f, area, self.borders, self.joins);
|
||||
draw_block_joins(
|
||||
f,
|
||||
area,
|
||||
self.borders,
|
||||
self.joins,
|
||||
if self.focused {
|
||||
context.theme.borders.focused
|
||||
} else {
|
||||
context.theme.borders.normal
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -298,7 +333,7 @@ impl Element for LogCard {
|
|||
|
||||
let rendered_lines: Vec<Line> = visible_lines
|
||||
.iter()
|
||||
.map(|(line, prefix_color, is_error)| {
|
||||
.map(|(line, source, is_error)| {
|
||||
let mut spans = Vec::new();
|
||||
|
||||
let (prefix, rest) = Self::split_line_prefix(line);
|
||||
|
|
@ -306,24 +341,25 @@ impl Element for LogCard {
|
|||
if !prefix.is_empty() {
|
||||
spans.push(Span::styled(
|
||||
prefix.to_string(),
|
||||
Style::default().fg(*prefix_color),
|
||||
source.style(context.theme),
|
||||
));
|
||||
}
|
||||
|
||||
let (content, timestamp) = Self::split_timestamp_suffix(rest);
|
||||
let text_color = if *is_error { Color::Red } else { Color::White };
|
||||
let text_style = if *is_error {
|
||||
context.theme.logs.error
|
||||
} else {
|
||||
context.theme.logs.text
|
||||
};
|
||||
|
||||
if !content.is_empty() {
|
||||
spans.push(Span::styled(
|
||||
content.to_string(),
|
||||
Style::default().fg(text_color),
|
||||
));
|
||||
spans.push(Span::styled(content.to_string(), text_style));
|
||||
}
|
||||
|
||||
if !timestamp.is_empty() {
|
||||
spans.push(Span::styled(
|
||||
timestamp.to_string(),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
context.theme.logs.timestamp,
|
||||
));
|
||||
}
|
||||
|
||||
|
|
@ -341,7 +377,17 @@ impl Element for LogCard {
|
|||
f.render_widget(Paragraph::new(line.clone()), line_area);
|
||||
}
|
||||
|
||||
draw_block_joins(f, area, self.borders, self.joins);
|
||||
draw_block_joins(
|
||||
f,
|
||||
area,
|
||||
self.borders,
|
||||
self.joins,
|
||||
if self.focused {
|
||||
context.theme.borders.focused
|
||||
} else {
|
||||
context.theme.borders.normal
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,52 +2,59 @@ use crate::ui::UI;
|
|||
use crossterm::event::{Event, KeyEvent, KeyEventKind, KeyModifiers, poll, read};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
pub fn setup_input_handler(ui: Arc<UI>) {
|
||||
pub fn setup_input_handler(ui: Arc<UI>) -> JoinHandle<Result<(), String>> {
|
||||
tokio::spawn(async move {
|
||||
let cancellation = ui.cancellation_token();
|
||||
let (tx, mut rx) = mpsc::unbounded_channel();
|
||||
let worker_cancellation = cancellation.clone();
|
||||
let worker = tokio::task::spawn_blocking(move || -> Result<(), String> {
|
||||
while !worker_cancellation.is_cancelled() {
|
||||
if poll(Duration::from_millis(100)).map_err(|e| e.to_string())? {
|
||||
tx.send(read().map_err(|e| e.to_string())?)
|
||||
.map_err(|_| "input session closed".to_string())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
loop {
|
||||
if ui.is_shutdown() {
|
||||
break;
|
||||
}
|
||||
|
||||
let event_result = tokio::task::spawn_blocking(|| {
|
||||
if let Ok(true) = poll(Duration::from_millis(100)) {
|
||||
read().ok().and_then(|ev| match ev {
|
||||
Event::Key(key) if key.kind == KeyEventKind::Press => Some(key),
|
||||
_ => None,
|
||||
tokio::select! {
|
||||
event = rx.recv() => match event {
|
||||
Some(Event::Key(key)) if key.kind == KeyEventKind::Press => handle_input(key, ui.clone()).await,
|
||||
Some(Event::Resize(_, _)) => ui.invalidate(),
|
||||
Some(Event::Paste(text)) => ui.handle_paste(text).await,
|
||||
Some(_) => {},
|
||||
None => break,
|
||||
},
|
||||
_ = cancellation.cancelled() => break,
|
||||
}
|
||||
}
|
||||
let result = match worker.await {
|
||||
Ok(result) => result,
|
||||
Err(error) if error.is_cancelled() => Ok(()),
|
||||
Err(error) => Err(format!("input worker failed: {error}")),
|
||||
};
|
||||
if result.is_err() {
|
||||
ui.request_shutdown();
|
||||
}
|
||||
result
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
match event_result {
|
||||
Ok(Some(key_event)) => {
|
||||
handle_input(key_event, ui.clone()).await;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
eprintln!("Input task error: {}", e);
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn handle_input(key: KeyEvent, ui: Arc<UI>) {
|
||||
match (key.code, key.modifiers) {
|
||||
(crossterm::event::KeyCode::Char('q'), KeyModifiers::CONTROL)
|
||||
| (crossterm::event::KeyCode::Char('c'), KeyModifiers::CONTROL) => {
|
||||
if matches!(
|
||||
key.code,
|
||||
crossterm::event::KeyCode::Char('q') | crossterm::event::KeyCode::Char('c')
|
||||
) && key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
{
|
||||
ui.request_shutdown();
|
||||
}
|
||||
(crossterm::event::KeyCode::Char('r'), KeyModifiers::CONTROL) => {
|
||||
let _ = ui.send_restart().await;
|
||||
ui.request_shutdown();
|
||||
}
|
||||
_ => {
|
||||
} else {
|
||||
ui.handle_input(key).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,25 @@
|
|||
use iota_ipc::{
|
||||
ClientMessage, DaemonMessage, LocalRequest, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION,
|
||||
ClientMessage, DaemonMessage, HelloAck, LocalRequest, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION,
|
||||
RequestEnvelope, ResponseResult, read_msg, write_msg,
|
||||
};
|
||||
use iota_state::{ClientState, UiLogEntry};
|
||||
use std::collections::HashMap;
|
||||
use std::io::Result;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex as StdMutex};
|
||||
use std::time::Duration;
|
||||
use tokio::net::UnixStream;
|
||||
use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf};
|
||||
use tokio::sync::{Mutex, oneshot, watch};
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
const INITIAL_BACKOFF: Duration = Duration::from_millis(200);
|
||||
const MAX_BACKOFF: Duration = Duration::from_secs(10);
|
||||
const MAX_RECONNECT_ATTEMPTS: u32 = 50;
|
||||
const STARTUP_CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const CONNECT_ATTEMPT_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Connection state exposed to the UI.
|
||||
#[derive(Clone, Debug)]
|
||||
|
|
@ -24,96 +28,114 @@ pub enum IpcConnectionState {
|
|||
Connected,
|
||||
Reconnecting { attempt: u32 },
|
||||
Incompatible { message: String },
|
||||
Failed { message: String },
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
/// Daemon information shown by the UI. This is separate from socket connectivity:
|
||||
/// a connected daemon may still be starting or degraded.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct DaemonStatus {
|
||||
pub version: String,
|
||||
pub instance_id: String,
|
||||
pub startup_phase: Option<iota_ipc::StartupPhase>,
|
||||
pub degraded_reason: Option<String>,
|
||||
pub lifecycle: Option<iota_ipc::LifecyclePhase>,
|
||||
pub health: iota_ipc::HealthStatus,
|
||||
pub deployment_mode: Option<iota_ipc::DeploymentMode>,
|
||||
pub supervisor: Option<iota_ipc::SupervisorKind>,
|
||||
pub components: std::collections::BTreeMap<iota_ipc::ComponentId, iota_ipc::ComponentHealth>,
|
||||
}
|
||||
|
||||
/// Pending request awaiting a response.
|
||||
struct PendingRequest {
|
||||
response_tx: oneshot::Sender<ResponseResult>,
|
||||
}
|
||||
|
||||
struct ActiveWriter {
|
||||
generation: u64,
|
||||
writer: OwnedWriteHalf,
|
||||
}
|
||||
|
||||
struct NegotiatedConnection {
|
||||
reader: OwnedReadHalf,
|
||||
writer: OwnedWriteHalf,
|
||||
ack: HelloAck,
|
||||
buffered_messages: Vec<DaemonMessage>,
|
||||
}
|
||||
|
||||
/* The TUI owns this cache. IPC updates replace daemon snapshots and append
|
||||
* logs, so rendering never reaches into daemon-owned storage or connections. */
|
||||
pub struct IpcClient {
|
||||
state: ClientState,
|
||||
writer: Mutex<OwnedWriteHalf>,
|
||||
writer: Mutex<Option<ActiveWriter>>,
|
||||
next_generation: AtomicU64,
|
||||
next_request_id: AtomicU64,
|
||||
pending: Mutex<HashMap<u64, PendingRequest>>,
|
||||
connection_state: watch::Sender<IpcConnectionState>,
|
||||
daemon_status: watch::Sender<DaemonStatus>,
|
||||
path: PathBuf,
|
||||
reconnector_started: AtomicBool,
|
||||
cancellation: CancellationToken,
|
||||
background_tasks: StdMutex<Vec<JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
impl IpcClient {
|
||||
pub async fn connect(path: impl AsRef<Path>) -> Result<Arc<Self>> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
let stream = Self::try_connect(&path).await?;
|
||||
let (mut reader, writer) = stream.into_split();
|
||||
let deadline = tokio::time::Instant::now() + STARTUP_CONNECT_TIMEOUT;
|
||||
Self::connect_until(&path, deadline).await
|
||||
}
|
||||
|
||||
let (conn_state_tx, _) = watch::channel(IpcConnectionState::Connecting);
|
||||
async fn connect_until(path: &Path, deadline: tokio::time::Instant) -> Result<Arc<Self>> {
|
||||
let path = path.to_path_buf();
|
||||
let stream = Self::connect_stream(&path, deadline).await?;
|
||||
let negotiated = Self::negotiate_stream(stream, deadline).await?;
|
||||
|
||||
// These values are visible before MainScreen subscribes. Do not
|
||||
// publish the handshake into a channel with no retained receiver.
|
||||
let initial_status = DaemonStatus {
|
||||
version: negotiated.ack.daemon_version.clone(),
|
||||
instance_id: negotiated.ack.instance_id.clone(),
|
||||
startup_phase: Some(negotiated.ack.startup_phase),
|
||||
degraded_reason: None,
|
||||
lifecycle: Some(negotiated.ack.lifecycle),
|
||||
health: negotiated.ack.health,
|
||||
deployment_mode: Some(negotiated.ack.deployment_mode),
|
||||
supervisor: Some(negotiated.ack.supervisor),
|
||||
components: std::collections::BTreeMap::new(),
|
||||
};
|
||||
let (conn_state_tx, _) = watch::channel(IpcConnectionState::Connected);
|
||||
let (daemon_status_tx, _) = watch::channel(initial_status);
|
||||
let client = Arc::new(Self {
|
||||
state: ClientState::new(),
|
||||
writer: Mutex::new(writer),
|
||||
writer: Mutex::new(Some(ActiveWriter {
|
||||
generation: 1,
|
||||
writer: negotiated.writer,
|
||||
})),
|
||||
next_generation: AtomicU64::new(2),
|
||||
next_request_id: AtomicU64::new(1),
|
||||
pending: Mutex::new(HashMap::new()),
|
||||
connection_state: conn_state_tx,
|
||||
daemon_status: daemon_status_tx,
|
||||
path: path.clone(),
|
||||
reconnector_started: AtomicBool::new(false),
|
||||
cancellation: CancellationToken::new(),
|
||||
background_tasks: StdMutex::new(Vec::new()),
|
||||
});
|
||||
|
||||
// --- Handshake: send Hello, read HelloAck ---
|
||||
{
|
||||
let mut w = client.writer.lock().await;
|
||||
write_msg(
|
||||
&mut *w,
|
||||
&ClientMessage::Hello {
|
||||
supported_versions: vec![MIN_PROTOCOL_VERSION, PROTOCOL_VERSION],
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
// Apply messages received while waiting for subscription confirmation
|
||||
// before exposing the connection to the UI.
|
||||
for message in negotiated.buffered_messages {
|
||||
client.apply(message).await;
|
||||
}
|
||||
match read_msg::<_, DaemonMessage>(&mut reader).await {
|
||||
Ok(DaemonMessage::HelloAck(ack)) => {
|
||||
if ack.protocol_version < MIN_PROTOCOL_VERSION {
|
||||
let _ = client
|
||||
.connection_state
|
||||
.send(IpcConnectionState::Incompatible {
|
||||
message: format!(
|
||||
"Daemon protocol {} < required {}",
|
||||
ack.protocol_version, MIN_PROTOCOL_VERSION
|
||||
),
|
||||
});
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Unsupported,
|
||||
format!(
|
||||
"Protocol version mismatch: daemon={}, minimum={}",
|
||||
ack.protocol_version, MIN_PROTOCOL_VERSION
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"Expected HelloAck from daemon",
|
||||
));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
|
||||
let _ = client.connection_state.send(IpcConnectionState::Connected);
|
||||
|
||||
// Start reader task (continues reading after handshake)
|
||||
let reader_client = client.clone();
|
||||
tokio::spawn(async move {
|
||||
reader_client.read_loop(reader).await;
|
||||
let task = tokio::spawn(async move {
|
||||
reader_client.read_loop(negotiated.reader, 1).await;
|
||||
});
|
||||
|
||||
// Subscribe to events
|
||||
client
|
||||
.send(ClientMessage::Subscribe {
|
||||
log_classes: vec![],
|
||||
metric_interval_ms: Some(500),
|
||||
})
|
||||
.await?;
|
||||
client.background_tasks.lock().unwrap().push(task);
|
||||
|
||||
Ok(client)
|
||||
}
|
||||
|
|
@ -121,165 +143,191 @@ impl IpcClient {
|
|||
/// Try to connect with retries for socket activation.
|
||||
pub async fn connect_or_activate(path: impl AsRef<Path>) -> Result<Arc<Self>> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
let max_attempts = 30;
|
||||
for attempt in 0..max_attempts {
|
||||
match Self::connect(&path).await {
|
||||
let deadline = tokio::time::Instant::now() + STARTUP_CONNECT_TIMEOUT;
|
||||
let mut last_error = None;
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
match Self::connect_until(&path, deadline).await {
|
||||
Ok(client) => return Ok(client),
|
||||
Err(error) => {
|
||||
if attempt < max_attempts - 1 {
|
||||
let delay = Duration::from_millis(100 + attempt as u64 * 100);
|
||||
tokio::time::sleep(delay).await;
|
||||
continue;
|
||||
}
|
||||
return Err(error);
|
||||
last_error = Some(error);
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
unreachable!()
|
||||
Err(last_error.unwrap_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::TimedOut, "Timed out waiting for daemon")
|
||||
}))
|
||||
}
|
||||
|
||||
async fn try_connect(path: &Path) -> Result<UnixStream> {
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
|
||||
async fn connect_stream(path: &Path, deadline: tokio::time::Instant) -> Result<UnixStream> {
|
||||
tokio::time::timeout_at(deadline, UnixStream::connect(path))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
"Timed out connecting to daemon",
|
||||
)
|
||||
})?
|
||||
}
|
||||
|
||||
async fn negotiate_stream(
|
||||
stream: UnixStream,
|
||||
deadline: tokio::time::Instant,
|
||||
) -> Result<NegotiatedConnection> {
|
||||
let (mut reader, mut writer) = stream.into_split();
|
||||
tokio::time::timeout_at(
|
||||
deadline,
|
||||
write_msg(
|
||||
&mut writer,
|
||||
&ClientMessage::Hello {
|
||||
supported_versions: vec![MIN_PROTOCOL_VERSION, PROTOCOL_VERSION],
|
||||
},
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
std::io::Error::new(std::io::ErrorKind::TimedOut, "Timed out sending IPC Hello")
|
||||
})??;
|
||||
let ack = match tokio::time::timeout_at(deadline, read_msg::<_, DaemonMessage>(&mut reader))
|
||||
.await
|
||||
{
|
||||
Err(_) => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
"Timed out waiting for IPC HelloAck",
|
||||
));
|
||||
}
|
||||
Ok(Ok(DaemonMessage::HelloAck(ack))) => ack,
|
||||
Ok(Ok(_)) => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"Expected HelloAck as the first daemon message",
|
||||
));
|
||||
}
|
||||
Ok(Err(error)) => return Err(error),
|
||||
};
|
||||
if !Self::is_compatible_version(ack.protocol_version) {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Unsupported,
|
||||
format!(
|
||||
"Unsupported daemon protocol version {}",
|
||||
ack.protocol_version
|
||||
),
|
||||
));
|
||||
}
|
||||
tokio::time::timeout_at(
|
||||
deadline,
|
||||
write_msg(
|
||||
&mut writer,
|
||||
&ClientMessage::Subscribe {
|
||||
log_classes: vec![],
|
||||
metric_interval_ms: Some(500),
|
||||
},
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
"Timed out sending IPC subscription",
|
||||
)
|
||||
})??;
|
||||
|
||||
// The daemon may send its initial StateUpdate before the acknowledgement.
|
||||
// Keep draining until the subscription itself is confirmed, otherwise a
|
||||
// UI can report Connected while no state stream exists yet.
|
||||
let mut buffered_messages = Vec::new();
|
||||
loop {
|
||||
match UnixStream::connect(path).await {
|
||||
Ok(stream) => return Ok(stream),
|
||||
Err(error) => {
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(error);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
match tokio::time::timeout_at(deadline, read_msg::<_, DaemonMessage>(&mut reader)).await
|
||||
{
|
||||
Err(_) => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
"Timed out waiting for IPC subscription acknowledgement",
|
||||
));
|
||||
}
|
||||
Ok(Ok(DaemonMessage::Subscribed)) => break,
|
||||
Ok(Ok(message)) => buffered_messages.push(message),
|
||||
Ok(Err(error)) => return Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(NegotiatedConnection {
|
||||
reader,
|
||||
writer,
|
||||
ack,
|
||||
buffered_messages,
|
||||
})
|
||||
}
|
||||
|
||||
/// Start the reconnection actor.
|
||||
pub fn spawn_reconnector(self: &Arc<Self>) {
|
||||
if self.reconnector_started.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
let client = self.clone();
|
||||
tokio::spawn(async move {
|
||||
let task = tokio::spawn(async move {
|
||||
client.reconnection_loop().await;
|
||||
});
|
||||
self.background_tasks.lock().unwrap().push(task);
|
||||
}
|
||||
|
||||
async fn reconnection_loop(self: Arc<Self>) {
|
||||
let mut rx = self.connection_status();
|
||||
|
||||
loop {
|
||||
// Wait until the connection enters the Disconnected state.
|
||||
loop {
|
||||
let disconnected = matches!(*rx.borrow(), IpcConnectionState::Disconnected);
|
||||
if disconnected {
|
||||
break;
|
||||
}
|
||||
if rx.changed().await.is_err() {
|
||||
return; // sender dropped
|
||||
while !matches!(*rx.borrow(), IpcConnectionState::Disconnected) {
|
||||
if tokio::select! {
|
||||
changed = rx.changed() => changed.is_err(),
|
||||
_ = self.cancellation.cancelled() => true,
|
||||
} {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let mut backoff = INITIAL_BACKOFF;
|
||||
let mut attempt: u32 = 0;
|
||||
|
||||
// Attempt reconnection until success or max attempts.
|
||||
loop {
|
||||
tokio::time::sleep(backoff).await;
|
||||
attempt += 1;
|
||||
|
||||
if attempt > MAX_RECONNECT_ATTEMPTS {
|
||||
let _ = self
|
||||
.connection_state
|
||||
.send(IpcConnectionState::Incompatible {
|
||||
message: "Max reconnection attempts exceeded".into(),
|
||||
});
|
||||
for attempt in 1..=MAX_RECONNECT_ATTEMPTS {
|
||||
if self.cancellation.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = self
|
||||
.connection_state
|
||||
.send(IpcConnectionState::Reconnecting { attempt });
|
||||
|
||||
match Self::try_connect(&self.path).await {
|
||||
Ok(stream) => {
|
||||
let (mut reader, writer) = stream.into_split();
|
||||
*self.writer.lock().await = writer;
|
||||
|
||||
// Re-handshake
|
||||
{
|
||||
let mut w = self.writer.lock().await;
|
||||
if write_msg(
|
||||
&mut *w,
|
||||
&ClientMessage::Hello {
|
||||
supported_versions: vec![
|
||||
MIN_PROTOCOL_VERSION,
|
||||
PROTOCOL_VERSION,
|
||||
],
|
||||
},
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
let _ = self
|
||||
.connection_state
|
||||
.send(IpcConnectionState::Disconnected);
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(backoff) => {},
|
||||
_ = self.cancellation.cancelled() => return,
|
||||
}
|
||||
let deadline = tokio::time::Instant::now() + CONNECT_ATTEMPT_TIMEOUT;
|
||||
let result = async {
|
||||
let stream = Self::connect_stream(&self.path, deadline).await?;
|
||||
Self::negotiate_stream(stream, deadline).await
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
Ok(connection) => {
|
||||
self.install_connection(connection).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Read HelloAck
|
||||
match read_msg::<_, DaemonMessage>(&mut reader).await {
|
||||
Ok(DaemonMessage::HelloAck(ack)) => {
|
||||
if ack.protocol_version < MIN_PROTOCOL_VERSION {
|
||||
let _ = self.connection_state.send(
|
||||
IpcConnectionState::Incompatible {
|
||||
message: format!(
|
||||
"Daemon protocol {} < required {}",
|
||||
ack.protocol_version, MIN_PROTOCOL_VERSION
|
||||
),
|
||||
},
|
||||
);
|
||||
Err(error) if error.kind() == std::io::ErrorKind::Unsupported => {
|
||||
let _ = self
|
||||
.connection_state
|
||||
.send(IpcConnectionState::Incompatible {
|
||||
message: error.to_string(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let _ = self
|
||||
.connection_state
|
||||
.send(IpcConnectionState::Disconnected);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear pending requests with connection-lost errors
|
||||
{
|
||||
let mut pending = self.pending.lock().await;
|
||||
for (_, request) in pending.drain() {
|
||||
let _ = request.response_tx.send(
|
||||
ResponseResult::Error(
|
||||
iota_ipc::IpcErrorCode::Disconnected,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let _ = self.connection_state.send(IpcConnectionState::Connected);
|
||||
|
||||
// Start new reader loop
|
||||
let reader_client = self.clone();
|
||||
tokio::spawn(async move {
|
||||
reader_client.read_loop(reader).await;
|
||||
Err(error) if attempt == MAX_RECONNECT_ATTEMPTS => {
|
||||
let _ = self.connection_state.send(IpcConnectionState::Failed {
|
||||
message: format!("Reconnect failed after {attempt} attempts: {error}"),
|
||||
});
|
||||
|
||||
// Resubscribe
|
||||
let _ = self
|
||||
.send(ClientMessage::Subscribe {
|
||||
log_classes: vec![],
|
||||
metric_interval_ms: Some(500),
|
||||
})
|
||||
.await;
|
||||
|
||||
// Successfully reconnected; go back to waiting for
|
||||
// the next disconnect.
|
||||
break;
|
||||
return;
|
||||
}
|
||||
Err(_) => {
|
||||
Err(error) => {
|
||||
eprintln!(
|
||||
"IPC reconnect attempt {attempt} to {} failed: kind={:?}, error={error}",
|
||||
self.path.display(),
|
||||
error.kind()
|
||||
);
|
||||
backoff = std::cmp::min(backoff * 2, MAX_BACKOFF);
|
||||
}
|
||||
}
|
||||
|
|
@ -287,16 +335,61 @@ impl IpcClient {
|
|||
}
|
||||
}
|
||||
|
||||
async fn read_loop(self: Arc<Self>, mut reader: OwnedReadHalf) {
|
||||
loop {
|
||||
match read_msg::<_, DaemonMessage>(&mut reader).await {
|
||||
Ok(message) => self.apply(message).await,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => {
|
||||
let _ = self.connection_state.send(IpcConnectionState::Disconnected);
|
||||
break;
|
||||
async fn install_connection(self: &Arc<Self>, connection: NegotiatedConnection) {
|
||||
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
|
||||
*self.writer.lock().await = Some(ActiveWriter {
|
||||
generation,
|
||||
writer: connection.writer,
|
||||
});
|
||||
self.update_hello_ack(connection.ack);
|
||||
for message in connection.buffered_messages {
|
||||
self.apply(message).await;
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = self.connection_state.send(IpcConnectionState::Connected);
|
||||
let client = self.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
client.read_loop(connection.reader, generation).await;
|
||||
});
|
||||
self.background_tasks.lock().unwrap().push(task);
|
||||
}
|
||||
|
||||
async fn fail_pending_requests(&self) {
|
||||
let mut pending = self.pending.lock().await;
|
||||
for (_, request) in pending.drain() {
|
||||
let _ = request
|
||||
.response_tx
|
||||
.send(ResponseResult::Error(iota_ipc::IpcErrorCode::Disconnected));
|
||||
}
|
||||
}
|
||||
|
||||
async fn mark_disconnected(&self, generation: u64) {
|
||||
let removed = {
|
||||
let mut writer = self.writer.lock().await;
|
||||
match writer.as_ref() {
|
||||
Some(active) if active.generation == generation => {
|
||||
writer.take();
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
};
|
||||
if removed {
|
||||
let _ = self.connection_state.send(IpcConnectionState::Disconnected);
|
||||
self.fail_pending_requests().await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_loop(self: Arc<Self>, mut reader: OwnedReadHalf, generation: u64) {
|
||||
loop {
|
||||
let result = tokio::select! {
|
||||
result = read_msg::<_, DaemonMessage>(&mut reader) => result,
|
||||
_ = self.cancellation.cancelled() => break,
|
||||
};
|
||||
match result {
|
||||
Ok(message) => self.apply(message).await,
|
||||
Err(error) => {
|
||||
eprintln!("IPC reader for generation {generation} stopped: {error}");
|
||||
self.mark_disconnected(generation).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -315,6 +408,65 @@ impl IpcClient {
|
|||
self.connection_state.borrow().clone()
|
||||
}
|
||||
|
||||
pub fn daemon_status(&self) -> watch::Receiver<DaemonStatus> {
|
||||
self.daemon_status.subscribe()
|
||||
}
|
||||
|
||||
/// Stop the IPC reader/reconnector and release the socket writer. This
|
||||
/// is deliberately bounded so UI shutdown cannot hang on a peer.
|
||||
pub async fn shutdown(&self) {
|
||||
self.cancellation.cancel();
|
||||
self.writer.lock().await.take();
|
||||
let tasks = std::mem::take(&mut *self.background_tasks.lock().unwrap());
|
||||
for mut task in tasks {
|
||||
if tokio::time::timeout(Duration::from_secs(2), &mut task)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_compatible_version(version: u16) -> bool {
|
||||
(MIN_PROTOCOL_VERSION..=PROTOCOL_VERSION).contains(&version)
|
||||
}
|
||||
|
||||
fn update_hello_ack(&self, ack: HelloAck) {
|
||||
self.daemon_status.send_modify(|status| {
|
||||
status.version = ack.daemon_version;
|
||||
status.instance_id = ack.instance_id;
|
||||
status.startup_phase = Some(ack.startup_phase);
|
||||
status.lifecycle = Some(ack.lifecycle);
|
||||
status.health = ack.health;
|
||||
status.deployment_mode = Some(ack.deployment_mode);
|
||||
status.supervisor = Some(ack.supervisor);
|
||||
});
|
||||
}
|
||||
|
||||
fn format_error(code: &iota_ipc::IpcErrorCode) -> &'static str {
|
||||
match code {
|
||||
iota_ipc::IpcErrorCode::InvalidRequest => "The command is not valid.",
|
||||
iota_ipc::IpcErrorCode::NotFound => "The requested user or resource was not found.",
|
||||
iota_ipc::IpcErrorCode::Conflict => "The request conflicts with existing state.",
|
||||
iota_ipc::IpcErrorCode::StorageFailure => "The daemon could not update its storage.",
|
||||
iota_ipc::IpcErrorCode::OmikronUnavailable => {
|
||||
"Omikron is unavailable; try reconnecting."
|
||||
}
|
||||
iota_ipc::IpcErrorCode::UnsupportedVersion => {
|
||||
"CLI and daemon versions are incompatible."
|
||||
}
|
||||
iota_ipc::IpcErrorCode::NotReady => "The daemon is still starting; try again shortly.",
|
||||
iota_ipc::IpcErrorCode::Disconnected => "The daemon connection was lost.",
|
||||
iota_ipc::IpcErrorCode::Timeout => "The daemon request timed out.",
|
||||
iota_ipc::IpcErrorCode::Cancelled => "The daemon request was cancelled.",
|
||||
iota_ipc::IpcErrorCode::Unauthorized => {
|
||||
"The daemon rejected this operation as unauthorized."
|
||||
}
|
||||
iota_ipc::IpcErrorCode::InternalFailure => "The daemon reported an internal failure.",
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn send_request(&self, request: LocalRequest) -> Result<ResponseResult> {
|
||||
let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed);
|
||||
let (response_tx, response_rx) = oneshot::channel();
|
||||
|
|
@ -329,14 +481,17 @@ impl IpcClient {
|
|||
protocol_version: PROTOCOL_VERSION,
|
||||
request,
|
||||
};
|
||||
self.send(ClientMessage::Request(envelope)).await?;
|
||||
if let Err(error) = self.send(ClientMessage::Request(envelope)).await {
|
||||
self.pending.lock().await.remove(&request_id);
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
match tokio::time::timeout(Duration::from_secs(30), response_rx).await {
|
||||
Ok(Ok(result)) => Ok(result),
|
||||
Ok(Err(_)) => Ok(ResponseResult::Error(iota_ipc::IpcErrorCode::Disconnected)),
|
||||
Err(_) => {
|
||||
self.pending.lock().await.remove(&request_id);
|
||||
Ok(ResponseResult::Error(iota_ipc::IpcErrorCode::Disconnected))
|
||||
Ok(ResponseResult::Error(iota_ipc::IpcErrorCode::Timeout))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -357,8 +512,12 @@ impl IpcClient {
|
|||
["user", "list"] => Some(LocalRequest::ListUsers),
|
||||
["reconnect"] => Some(LocalRequest::ReconnectOmikron),
|
||||
["regenerate", "keys"] => Some(LocalRequest::RotateIotaIdentity),
|
||||
["reload"] | ["restart"] => Some(LocalRequest::RestartDaemon),
|
||||
["shutdown"] | ["stop"] => Some(LocalRequest::StopDaemon),
|
||||
["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit {
|
||||
intent: iota_ipc::ExitIntent::Restart,
|
||||
}),
|
||||
["shutdown"] | ["stop"] => Some(LocalRequest::RequestProcessExit {
|
||||
intent: iota_ipc::ExitIntent::Stop,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -371,11 +530,7 @@ impl IpcClient {
|
|||
if trimmed == "ping" || trimmed.starts_with("ping ") {
|
||||
let seq = self.next_request_id.fetch_add(1, Ordering::Relaxed);
|
||||
if let Err(e) = self.send(ClientMessage::Ping { seq }).await {
|
||||
let mut state = self
|
||||
.state
|
||||
.app
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
let mut state = self.state.app.lock().await;
|
||||
state.push_log(UiLogEntry {
|
||||
timestamp_ms: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
|
|
@ -387,11 +542,7 @@ impl IpcClient {
|
|||
});
|
||||
return Err(e);
|
||||
}
|
||||
let mut state = self
|
||||
.state
|
||||
.app
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
let mut state = self.state.app.lock().await;
|
||||
state.push_log(UiLogEntry {
|
||||
timestamp_ms: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
|
|
@ -407,14 +558,10 @@ impl IpcClient {
|
|||
if let Some(request) = Self::parse_console_command(&line) {
|
||||
match self.send_request(request).await {
|
||||
Ok(result) => {
|
||||
let mut state = self
|
||||
.state
|
||||
.app
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
let mut state = self.state.app.lock().await;
|
||||
let message = match &result {
|
||||
ResponseResult::Ok(msg) => msg.clone(),
|
||||
ResponseResult::Error(code) => format!("Error: {:?}", code),
|
||||
ResponseResult::Error(code) => Self::format_error(code).into(),
|
||||
};
|
||||
state.push_log(UiLogEntry {
|
||||
timestamp_ms: std::time::SystemTime::now()
|
||||
|
|
@ -428,11 +575,7 @@ impl IpcClient {
|
|||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let mut state = self
|
||||
.state
|
||||
.app
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
let mut state = self.state.app.lock().await;
|
||||
state.push_log(UiLogEntry {
|
||||
timestamp_ms: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
|
|
@ -446,19 +589,15 @@ impl IpcClient {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
let mut state = self
|
||||
.state
|
||||
.app
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
let mut state = self.state.app.lock().await;
|
||||
state.push_log(UiLogEntry {
|
||||
timestamp_ms: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis(),
|
||||
sender: "Console".into(),
|
||||
message: if line.trim() == "help" {
|
||||
"Available commands: tasks, ping, user, reconnect, regenerate, restart, stop"
|
||||
message: if trimmed == "help" {
|
||||
"Commands: status, tasks, ping, user add <name>, user remove <id>, user list, reconnect, regenerate keys, restart, stop"
|
||||
.into()
|
||||
} else {
|
||||
format!("Unknown command: {}", line)
|
||||
|
|
@ -470,18 +609,43 @@ impl IpcClient {
|
|||
}
|
||||
|
||||
async fn send(&self, message: ClientMessage) -> Result<()> {
|
||||
let mut writer = self.writer.lock().await;
|
||||
write_msg(&mut *writer, &message).await
|
||||
let deadline = tokio::time::Instant::now() + CONNECT_ATTEMPT_TIMEOUT;
|
||||
let mut writer_guard = tokio::time::timeout_at(deadline, self.writer.lock())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
"Timed out acquiring IPC writer",
|
||||
)
|
||||
})?;
|
||||
let active = writer_guard.as_mut().ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::NotConnected,
|
||||
"IPC connection is not active",
|
||||
)
|
||||
})?;
|
||||
let generation = active.generation;
|
||||
let write_result =
|
||||
tokio::time::timeout_at(deadline, write_msg(&mut active.writer, &message))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
"Timed out writing IPC message",
|
||||
)
|
||||
})
|
||||
.and_then(|result| result);
|
||||
drop(writer_guard);
|
||||
if write_result.is_err() {
|
||||
self.mark_disconnected(generation).await;
|
||||
}
|
||||
write_result
|
||||
}
|
||||
|
||||
async fn apply(&self, message: DaemonMessage) {
|
||||
match message {
|
||||
DaemonMessage::LogEntry(entry) => {
|
||||
let mut state = self
|
||||
.state
|
||||
.app
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
let mut state = self.state.app.lock().await;
|
||||
state.push_log(UiLogEntry {
|
||||
timestamp_ms: entry.timestamp_ms,
|
||||
sender: entry.sender,
|
||||
|
|
@ -490,11 +654,16 @@ impl IpcClient {
|
|||
});
|
||||
}
|
||||
DaemonMessage::StateUpdate(snapshot) => {
|
||||
let mut state = self
|
||||
.state
|
||||
.app
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
// Never hold a watch borrow while sending to that same
|
||||
// channel: send waits for outstanding Ref guards.
|
||||
self.daemon_status.send_modify(|status| {
|
||||
status.startup_phase = Some(snapshot.startup_phase);
|
||||
status.degraded_reason = snapshot.degraded_reason.clone();
|
||||
status.lifecycle = Some(snapshot.lifecycle);
|
||||
status.health = snapshot.overall_health;
|
||||
status.components = snapshot.components.clone();
|
||||
});
|
||||
let mut state = self.state.app.lock().await;
|
||||
state.cpu = snapshot.cpu;
|
||||
state.ram = snapshot.ram;
|
||||
state.ping = snapshot.ping;
|
||||
|
|
@ -503,41 +672,21 @@ impl IpcClient {
|
|||
state.sys_info = snapshot.sys_info;
|
||||
}
|
||||
DaemonMessage::MetricSample(sample) => {
|
||||
let mut state = self
|
||||
.state
|
||||
.app
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
let mut state = self.state.app.lock().await;
|
||||
if let Some(cpu) = sample.cpu {
|
||||
let idx = state.cpu.len() as f64;
|
||||
state.cpu.push((idx, cpu));
|
||||
if state.cpu.len() > iota_state::MAX_POINTS {
|
||||
state.cpu.remove(0);
|
||||
}
|
||||
state.push_cpu((0.0, cpu));
|
||||
}
|
||||
if let Some(ram) = sample.ram {
|
||||
let idx = state.ram.len() as f64;
|
||||
state.ram.push((idx, ram));
|
||||
if state.ram.len() > iota_state::MAX_POINTS {
|
||||
state.ram.remove(0);
|
||||
}
|
||||
state.push_ram((0.0, ram));
|
||||
}
|
||||
if let Some(ping) = sample.ping {
|
||||
state.push_ping_val(ping);
|
||||
}
|
||||
if let Some(net_up) = sample.net_up {
|
||||
let idx = state.net_up.len() as f64;
|
||||
state.net_up.push((idx, net_up));
|
||||
if state.net_up.len() > iota_state::MAX_POINTS {
|
||||
state.net_up.remove(0);
|
||||
}
|
||||
state.push_net_up((0.0, net_up));
|
||||
}
|
||||
if let Some(net_down) = sample.net_down {
|
||||
let idx = state.net_down.len() as f64;
|
||||
state.net_down.push((idx, net_down));
|
||||
if state.net_down.len() > iota_state::MAX_POINTS {
|
||||
state.net_down.remove(0);
|
||||
}
|
||||
state.push_net_down((0.0, net_down));
|
||||
}
|
||||
}
|
||||
DaemonMessage::Response(response) => {
|
||||
|
|
@ -545,14 +694,10 @@ impl IpcClient {
|
|||
if let Some(request) = pending.remove(&response.request_id) {
|
||||
let _ = request.response_tx.send(response.result);
|
||||
} else {
|
||||
let mut state = self
|
||||
.state
|
||||
.app
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
let mut state = self.state.app.lock().await;
|
||||
let message = match &response.result {
|
||||
ResponseResult::Ok(msg) => msg.clone(),
|
||||
ResponseResult::Error(code) => format!("Error: {:?}", code),
|
||||
ResponseResult::Error(code) => Self::format_error(code).into(),
|
||||
};
|
||||
state.push_log(UiLogEntry {
|
||||
timestamp_ms: std::time::SystemTime::now()
|
||||
|
|
@ -565,15 +710,12 @@ impl IpcClient {
|
|||
});
|
||||
}
|
||||
}
|
||||
DaemonMessage::HelloAck(_) => {}
|
||||
DaemonMessage::HelloAck(ack) => self.update_hello_ack(ack),
|
||||
DaemonMessage::Subscribed => {}
|
||||
DaemonMessage::Pong { .. } => {}
|
||||
DaemonMessage::LifecycleEvent(event) => match event {
|
||||
iota_ipc::LifecycleEvent::Shutdown { reason } => {
|
||||
let mut state = self
|
||||
.state
|
||||
.app
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
let mut state = self.state.app.lock().await;
|
||||
state.push_log(UiLogEntry {
|
||||
timestamp_ms: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
|
|
@ -584,14 +726,19 @@ impl IpcClient {
|
|||
is_error: true,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
iota_ipc::LifecycleEvent::StateChanged(status) => {
|
||||
self.daemon_status.send_modify(|daemon_status| {
|
||||
daemon_status.degraded_reason = match status {
|
||||
iota_ipc::ConnectionStatus::Degraded => {
|
||||
Some("A daemon dependency is degraded".into())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
});
|
||||
}
|
||||
},
|
||||
DaemonMessage::Gap { skipped } => {
|
||||
let mut state = self
|
||||
.state
|
||||
.app
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
let mut state = self.state.app.lock().await;
|
||||
state.push_log(UiLogEntry {
|
||||
timestamp_ms: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
|
|
|
|||
52
iota-cli/src/layout/fit.rs
Normal file
52
iota-cli/src/layout/fit.rs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
use ratatui::layout::Rect;
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RequiredSize {
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FitLevel {
|
||||
Preferred,
|
||||
Compact,
|
||||
Fallback,
|
||||
}
|
||||
pub fn select_fit_level(area: Rect, preferred: RequiredSize, compact: RequiredSize) -> FitLevel {
|
||||
if area.width >= preferred.width && area.height >= preferred.height {
|
||||
FitLevel::Preferred
|
||||
} else if area.width >= compact.width && area.height >= compact.height {
|
||||
FitLevel::Compact
|
||||
} else {
|
||||
FitLevel::Fallback
|
||||
}
|
||||
}
|
||||
pub fn centered_rect(area: Rect, maximum: RequiredSize) -> Rect {
|
||||
let width = area.width.min(maximum.width);
|
||||
let height = area.height.min(maximum.height);
|
||||
Rect {
|
||||
x: area.x.saturating_add(area.width.saturating_sub(width) / 2),
|
||||
y: area
|
||||
.y
|
||||
.saturating_add(area.height.saturating_sub(height) / 2),
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
pub fn reserve_vertical(area: Rect, top: u16, bottom: u16) -> Option<Rect> {
|
||||
let height = area.height.checked_sub(top)?.checked_sub(bottom)?;
|
||||
Some(Rect {
|
||||
x: area.x,
|
||||
y: area.y.checked_add(top)?,
|
||||
width: area.width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
pub fn inset_checked(area: Rect, horizontal: u16, vertical: u16) -> Option<Rect> {
|
||||
let width = area.width.checked_sub(horizontal.checked_mul(2)?)?;
|
||||
let height = area.height.checked_sub(vertical.checked_mul(2)?)?;
|
||||
Some(Rect {
|
||||
x: area.x.checked_add(horizontal)?,
|
||||
y: area.y.checked_add(vertical)?,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
2
iota-cli/src/layout/mod.rs
Normal file
2
iota-cli/src/layout/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod fit;
|
||||
pub mod text_measure;
|
||||
10
iota-cli/src/layout/text_measure.rs
Normal file
10
iota-cli/src/layout/text_measure.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
use unicode_width::UnicodeWidthStr;
|
||||
pub fn wrapped_line_count(text: &str, width: u16) -> u16 {
|
||||
if width == 0 {
|
||||
return 0;
|
||||
}
|
||||
text.split('\n')
|
||||
.map(|line| (UnicodeWidthStr::width(line).max(1) + width as usize - 1) / width as usize)
|
||||
.sum::<usize>()
|
||||
.min(u16::MAX as usize) as u16
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
6
iota-cli/src/render_context.rs
Normal file
6
iota-cli/src/render_context.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
use crate::theme::ResolvedTheme;
|
||||
|
||||
/// Immutable state shared by every component during one render pass.
|
||||
pub struct RenderContext<'a> {
|
||||
pub theme: &'a ResolvedTheme,
|
||||
}
|
||||
287
iota-cli/src/screens/daemon_setup.rs
Normal file
287
iota-cli/src/screens/daemon_setup.rs
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
use crate::{
|
||||
controls::{
|
||||
button::{ActionButton, ButtonIntent, render_button},
|
||||
choice::{ChoiceKind, render_choice_line},
|
||||
radio_group::{RadioGroup, RadioItem},
|
||||
},
|
||||
interaction_result::InteractionResult,
|
||||
render_context::RenderContext,
|
||||
screens::screens::Screen,
|
||||
};
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Constraint, Layout, Rect},
|
||||
text::{Line, Text},
|
||||
widgets::{Block, Borders, Paragraph, Wrap},
|
||||
};
|
||||
use std::any::Any;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
/// Kept on screen while the launcher waits for the daemon's IPC hello. The
|
||||
/// setup choice screen is intentionally closed before its decision is sent,
|
||||
/// so without this the terminal would otherwise be blank during startup.
|
||||
pub struct DaemonStartingScreen;
|
||||
impl Screen for DaemonStartingScreen {
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
fn render(&self, frame: &mut Frame, area: Rect, context: &RenderContext<'_>) {
|
||||
let popup = crate::layout::fit::centered_rect(
|
||||
area,
|
||||
crate::layout::fit::RequiredSize {
|
||||
width: 48,
|
||||
height: 5,
|
||||
},
|
||||
);
|
||||
frame.render_widget(
|
||||
Paragraph::new(
|
||||
"Starting iota-daemon…\nWaiting for its IPC handshake.\nPress Ctrl+C to cancel.",
|
||||
)
|
||||
.wrap(Wrap { trim: true })
|
||||
.block(
|
||||
Block::default()
|
||||
.title(" Iota daemon ")
|
||||
.borders(Borders::ALL)
|
||||
.border_style(context.theme.borders.normal),
|
||||
),
|
||||
popup,
|
||||
);
|
||||
}
|
||||
fn handle_input(&mut self, _: KeyEvent) -> InteractionResult {
|
||||
InteractionResult::Handled
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DaemonLaunchMode {
|
||||
Once,
|
||||
WithUi,
|
||||
WithSystem,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LaunchOption {
|
||||
pub mode: DaemonLaunchMode,
|
||||
pub enabled: bool,
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DaemonSetupDecision {
|
||||
Start(DaemonLaunchMode),
|
||||
Exit,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Focus {
|
||||
Options,
|
||||
Exit,
|
||||
Action,
|
||||
}
|
||||
|
||||
/// The launcher owns the actual side effects. This screen only presents the
|
||||
/// capabilities discovered for this machine, keeping disabled choices visible.
|
||||
pub struct DaemonSetupScreen {
|
||||
choices: RadioGroup<DaemonLaunchMode>,
|
||||
focus: Focus,
|
||||
sender: Option<oneshot::Sender<DaemonSetupDecision>>,
|
||||
message: String,
|
||||
}
|
||||
impl DaemonSetupScreen {
|
||||
pub fn new(
|
||||
options: Vec<LaunchOption>,
|
||||
message: impl Into<String>,
|
||||
sender: oneshot::Sender<DaemonSetupDecision>,
|
||||
) -> Result<Self, crate::controls::radio_group::RadioGroupError> {
|
||||
let items: Vec<RadioItem<DaemonLaunchMode>> = options
|
||||
.into_iter()
|
||||
.map(|o| RadioItem {
|
||||
value: o.mode,
|
||||
label: match o.mode {
|
||||
DaemonLaunchMode::Once => "Start once",
|
||||
DaemonLaunchMode::WithUi => "Start with Iota UI",
|
||||
DaemonLaunchMode::WithSystem => "Start with the system",
|
||||
}
|
||||
.into(),
|
||||
description: o.reason,
|
||||
enabled: o.enabled,
|
||||
disabled_reason: None,
|
||||
})
|
||||
.collect();
|
||||
let default = items
|
||||
.iter()
|
||||
.find(|item| item.enabled)
|
||||
.map(|item| item.value)
|
||||
.ok_or(crate::controls::radio_group::RadioGroupError::NoEnabledItems)?;
|
||||
let mut choices = RadioGroup::new(items, None, default)?;
|
||||
choices.set_focus_policy(crate::controls::navigation::DisabledFocusPolicy::Include);
|
||||
Ok(Self {
|
||||
choices,
|
||||
focus: Focus::Options,
|
||||
sender: Some(sender),
|
||||
message: message.into(),
|
||||
})
|
||||
}
|
||||
fn complete(&mut self, d: DaemonSetupDecision) {
|
||||
if let Some(tx) = self.sender.take() {
|
||||
let _ = tx.send(d);
|
||||
}
|
||||
}
|
||||
fn activate(&mut self) -> InteractionResult {
|
||||
match self.focus {
|
||||
Focus::Options => {
|
||||
self.choices.select_focused();
|
||||
InteractionResult::Handled
|
||||
}
|
||||
Focus::Exit => {
|
||||
self.complete(DaemonSetupDecision::Exit);
|
||||
InteractionResult::CloseScreen
|
||||
}
|
||||
Focus::Action => {
|
||||
let choice = *self.choices.selected();
|
||||
if self
|
||||
.choices
|
||||
.items()
|
||||
.iter()
|
||||
.find(|i| i.value == choice)
|
||||
.is_some_and(|i| i.enabled)
|
||||
{
|
||||
self.complete(DaemonSetupDecision::Start(choice));
|
||||
InteractionResult::CloseScreen
|
||||
} else {
|
||||
InteractionResult::Handled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn next(&mut self) {
|
||||
self.focus = match self.focus {
|
||||
Focus::Options => {
|
||||
self.choices.focus_next();
|
||||
if self.choices.focused_item().value == DaemonLaunchMode::Once {
|
||||
Focus::Exit
|
||||
} else {
|
||||
Focus::Options
|
||||
}
|
||||
}
|
||||
Focus::Exit => Focus::Action,
|
||||
Focus::Action => Focus::Options,
|
||||
};
|
||||
}
|
||||
fn previous(&mut self) {
|
||||
self.focus = match self.focus {
|
||||
Focus::Options => {
|
||||
self.choices.focus_previous();
|
||||
if self.choices.focused_item().value == DaemonLaunchMode::WithSystem {
|
||||
Focus::Action
|
||||
} else {
|
||||
Focus::Options
|
||||
}
|
||||
}
|
||||
Focus::Exit => Focus::Options,
|
||||
Focus::Action => Focus::Exit,
|
||||
};
|
||||
}
|
||||
}
|
||||
impl Screen for DaemonSetupScreen {
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
fn render(&self, frame: &mut Frame, area: Rect, context: &RenderContext<'_>) {
|
||||
let popup = crate::layout::fit::centered_rect(
|
||||
area,
|
||||
crate::layout::fit::RequiredSize {
|
||||
width: 68,
|
||||
height: 16,
|
||||
},
|
||||
);
|
||||
let mut lines = vec![Line::from(self.message.as_str()), Line::from("")];
|
||||
for item in self.choices.items() {
|
||||
lines.push(render_choice_line(
|
||||
&item.label,
|
||||
ChoiceKind::Radio,
|
||||
self.choices.visual_state(&item.value),
|
||||
context.theme,
|
||||
));
|
||||
if let Some(reason) = &item.description {
|
||||
lines.push(Line::styled(
|
||||
format!(" {reason}"),
|
||||
context.theme.text.muted,
|
||||
));
|
||||
}
|
||||
}
|
||||
let rows = Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(popup);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Text::from(lines))
|
||||
.wrap(Wrap { trim: true })
|
||||
.block(
|
||||
Block::default()
|
||||
.title(" Iota daemon setup ")
|
||||
.borders(Borders::ALL),
|
||||
),
|
||||
rows[0],
|
||||
);
|
||||
let b = Layout::horizontal([Constraint::Percentage(50), Constraint::Percentage(50)])
|
||||
.split(rows[1]);
|
||||
render_button(
|
||||
frame,
|
||||
b[0],
|
||||
ActionButton {
|
||||
label: "Exit",
|
||||
intent: ButtonIntent::Cancel,
|
||||
focused: self.focus == Focus::Exit,
|
||||
enabled: true,
|
||||
},
|
||||
context.theme,
|
||||
);
|
||||
let selected = *self.choices.selected();
|
||||
let enabled = self
|
||||
.choices
|
||||
.items()
|
||||
.iter()
|
||||
.find(|i| i.value == selected)
|
||||
.is_some_and(|i| i.enabled);
|
||||
let label = match selected {
|
||||
DaemonLaunchMode::Once => "Start once",
|
||||
DaemonLaunchMode::WithUi => "Save and start",
|
||||
DaemonLaunchMode::WithSystem => "Configure and start",
|
||||
};
|
||||
render_button(
|
||||
frame,
|
||||
b[1],
|
||||
ActionButton {
|
||||
label,
|
||||
intent: if enabled {
|
||||
ButtonIntent::Primary
|
||||
} else {
|
||||
ButtonIntent::Destructive
|
||||
},
|
||||
focused: self.focus == Focus::Action,
|
||||
enabled,
|
||||
},
|
||||
context.theme,
|
||||
);
|
||||
}
|
||||
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
|
||||
match event.code {
|
||||
KeyCode::Esc => {
|
||||
self.complete(DaemonSetupDecision::Exit);
|
||||
InteractionResult::CloseScreen
|
||||
}
|
||||
KeyCode::Down | KeyCode::Right | KeyCode::Tab => {
|
||||
self.next();
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Up | KeyCode::Left | KeyCode::BackTab => {
|
||||
self.previous();
|
||||
InteractionResult::Handled
|
||||
}
|
||||
KeyCode::Enter | KeyCode::Char(' ') => self.activate(),
|
||||
_ => InteractionResult::Unhandled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,8 @@ use crate::{
|
|||
log_card::LogCard,
|
||||
},
|
||||
interaction_result::InteractionResult,
|
||||
ipc_client::IpcConnectionState,
|
||||
ipc_client::{DaemonStatus, IpcConnectionState},
|
||||
render_context::RenderContext,
|
||||
screens::screens::{NavDirection, Screen},
|
||||
ui::UI,
|
||||
};
|
||||
|
|
@ -27,6 +28,7 @@ pub struct MainScreen {
|
|||
selected_coords: (usize, usize),
|
||||
graphs_open: bool,
|
||||
connection_status_rx: watch::Receiver<IpcConnectionState>,
|
||||
daemon_status_rx: watch::Receiver<DaemonStatus>,
|
||||
}
|
||||
|
||||
impl MainScreen {
|
||||
|
|
@ -39,10 +41,17 @@ impl MainScreen {
|
|||
vec![Some(1), Some(4)],
|
||||
];
|
||||
|
||||
let state = ui.client_state();
|
||||
let state = ui
|
||||
.client_state()
|
||||
.await
|
||||
.expect("MainScreen requires an attached daemon");
|
||||
let mut log_card = LogCard::new(state.clone());
|
||||
log_card.set_borders(Borders::TOP.union(Borders::RIGHT).union(Borders::LEFT));
|
||||
let mut console_card = ConsoleCard::new("Console", "", ui.ipc());
|
||||
let ipc = ui
|
||||
.ipc()
|
||||
.await
|
||||
.expect("MainScreen requires an attached daemon");
|
||||
let mut console_card = ConsoleCard::new("Console", "", ipc.clone());
|
||||
console_card.set_joins(Borders::TOP);
|
||||
|
||||
elements.push(Box::new(log_card));
|
||||
|
|
@ -61,7 +70,8 @@ impl MainScreen {
|
|||
|
||||
let graphs_open = true;
|
||||
|
||||
let connection_status_rx = ui.ipc().connection_status();
|
||||
let connection_status_rx = ipc.connection_status();
|
||||
let daemon_status_rx = ipc.daemon_status();
|
||||
|
||||
let mut screen = MainScreen {
|
||||
elements,
|
||||
|
|
@ -69,6 +79,7 @@ impl MainScreen {
|
|||
selected_coords: (1, 0),
|
||||
graphs_open,
|
||||
connection_status_rx,
|
||||
daemon_status_rx,
|
||||
};
|
||||
screen.focus_current();
|
||||
screen
|
||||
|
|
@ -198,22 +209,46 @@ impl Screen for MainScreen {
|
|||
self
|
||||
}
|
||||
|
||||
fn render(&self, f: &mut Frame, rect: Rect) {
|
||||
let status = self.connection_status_rx.borrow();
|
||||
let status_text = match &*status {
|
||||
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>) {
|
||||
// A watch Ref blocks senders until it is dropped. Rendering may do
|
||||
// terminal I/O, so retain only owned snapshots for the whole frame.
|
||||
let status = self.connection_status_rx.borrow().clone();
|
||||
let daemon = self.daemon_status_rx.borrow().clone();
|
||||
let status_text = match status {
|
||||
IpcConnectionState::Connected => "Connected".to_string(),
|
||||
IpcConnectionState::Connecting => "Connecting...".to_string(),
|
||||
IpcConnectionState::Reconnecting { attempt } => {
|
||||
format!("Reconnecting (attempt {})...", attempt)
|
||||
}
|
||||
IpcConnectionState::Incompatible { message } => {
|
||||
format!("Incompatible: {}", message)
|
||||
format!("Incompatible protocol: {}", message)
|
||||
}
|
||||
IpcConnectionState::Failed { message } => {
|
||||
format!("Connection failed: {}", message)
|
||||
}
|
||||
IpcConnectionState::Disconnected => "Disconnected".to_string(),
|
||||
};
|
||||
let readiness = daemon
|
||||
.startup_phase
|
||||
.map(|phase| format!("{:?}", phase))
|
||||
.unwrap_or_else(|| "Waiting for status".into());
|
||||
let health = daemon
|
||||
.degraded_reason
|
||||
.as_deref()
|
||||
.map(|reason| format!(" — {reason}"))
|
||||
.unwrap_or_default();
|
||||
let version = if daemon.version.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" v{}", daemon.version)
|
||||
};
|
||||
let main_block = Block::default()
|
||||
.title(format!("Main [{}]", status_text))
|
||||
.borders(Borders::ALL);
|
||||
.title(format!(
|
||||
"Iota{version} [{status_text}; {readiness}{health}]"
|
||||
))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(context.theme.borders.normal)
|
||||
.title_style(context.theme.borders.title);
|
||||
f.render_widget(main_block, rect);
|
||||
|
||||
let inner = rect.inner(Margin {
|
||||
|
|
@ -221,7 +256,11 @@ impl Screen for MainScreen {
|
|||
horizontal: 1,
|
||||
});
|
||||
|
||||
let graphs_width = if self.graphs_open { 30 } else { 2 };
|
||||
let graphs_width = if self.graphs_open && inner.width >= 70 {
|
||||
30
|
||||
} else {
|
||||
2
|
||||
};
|
||||
let main_width = inner.width.saturating_sub(graphs_width);
|
||||
|
||||
let horizontal_chunks = Layout::default()
|
||||
|
|
@ -239,11 +278,11 @@ impl Screen for MainScreen {
|
|||
Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(left_area);
|
||||
|
||||
if let Some(log) = self.elements.get(0) {
|
||||
log.as_element().render(f, left_rows[0]);
|
||||
log.as_element().render(f, left_rows[0], context);
|
||||
}
|
||||
|
||||
if let Some(console) = self.elements.get(1) {
|
||||
console.as_element().render(f, left_rows[1]);
|
||||
console.as_element().render(f, left_rows[1], context);
|
||||
}
|
||||
|
||||
let graph_elements: Vec<_> = self
|
||||
|
|
@ -262,7 +301,7 @@ impl Screen for MainScreen {
|
|||
.split(right_area);
|
||||
|
||||
for (el, area) in graph_elements.iter().zip(graph_chunks.iter()) {
|
||||
el.as_element().render(f, *area);
|
||||
el.as_element().render(f, *area, context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,16 @@ use ratatui::{
|
|||
};
|
||||
use std::{any::Any, time::Duration};
|
||||
|
||||
use crate::{interaction_result::InteractionResult, screens::screens::Screen};
|
||||
use crate::{
|
||||
interaction_result::InteractionResult,
|
||||
render_context::RenderContext,
|
||||
screens::screens::Screen,
|
||||
theme::{ResolvedTheme, TextSemantics, ThemeName},
|
||||
};
|
||||
|
||||
pub struct FileViewer {
|
||||
title: String,
|
||||
text: Vec<DisplayLine>,
|
||||
content: String,
|
||||
scroll: u16,
|
||||
scroll_x: u16,
|
||||
}
|
||||
|
|
@ -24,8 +29,8 @@ impl Screen for FileViewer {
|
|||
self
|
||||
}
|
||||
|
||||
fn render(&self, f: &mut Frame, rect: Rect) {
|
||||
self.draw(f, rect);
|
||||
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>) {
|
||||
self.draw(f, rect, context.theme);
|
||||
}
|
||||
|
||||
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
|
||||
|
|
@ -52,7 +57,7 @@ impl FileViewer {
|
|||
pub fn new(title: String, content: &str) -> Self {
|
||||
Self {
|
||||
title,
|
||||
text: parse_document(content.to_owned()),
|
||||
content: content.to_owned(),
|
||||
scroll: 0,
|
||||
scroll_x: 0,
|
||||
}
|
||||
|
|
@ -62,7 +67,7 @@ impl FileViewer {
|
|||
terminal
|
||||
.draw(|f| {
|
||||
let area = f.area();
|
||||
self.draw(f, area);
|
||||
self.draw(f, area, &crate::theme::resolve(ThemeName::Ansi));
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
|
|
@ -77,12 +82,13 @@ impl FileViewer {
|
|||
}
|
||||
terminal
|
||||
}
|
||||
fn draw(&self, f: &mut Frame, area: Rect) {
|
||||
fn draw(&self, f: &mut Frame, area: Rect, theme: &ResolvedTheme) {
|
||||
use ratatui::text::Text;
|
||||
|
||||
let mut rendered_lines = Vec::new();
|
||||
let text = parse_document(&self.content, theme);
|
||||
|
||||
for display_line in &self.text {
|
||||
for display_line in &text {
|
||||
if display_line.scrollable {
|
||||
let content: String = display_line
|
||||
.line
|
||||
|
|
@ -153,7 +159,7 @@ impl FileViewer {
|
|||
}
|
||||
}
|
||||
}
|
||||
fn parse_document(input: String) -> Vec<DisplayLine> {
|
||||
fn parse_document(input: &str, theme: &ResolvedTheme) -> Vec<DisplayLine> {
|
||||
let mut lines_vec = Vec::new();
|
||||
let mut in_code_block = false;
|
||||
let liness: Vec<String> = input.lines().map(String::from).collect();
|
||||
|
|
@ -172,7 +178,7 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
|
|||
lines_vec.push(DisplayLine {
|
||||
line: Line::from(Span::styled(
|
||||
format!("────────{}────────", code),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
theme.markdown.divider,
|
||||
)),
|
||||
scrollable: false,
|
||||
});
|
||||
|
|
@ -182,10 +188,7 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
|
|||
|
||||
if in_code_block {
|
||||
lines_vec.push(DisplayLine {
|
||||
line: Line::from(Span::styled(
|
||||
raw.to_string(),
|
||||
Style::default().fg(Color::Yellow),
|
||||
)),
|
||||
line: Line::from(Span::styled(raw.to_string(), theme.markdown.code)),
|
||||
scrollable: false,
|
||||
});
|
||||
i += 1;
|
||||
|
|
@ -195,9 +198,13 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
|
|||
lines_vec.push(DisplayLine {
|
||||
line: Line::from(Span::styled(
|
||||
raw.trim_start_matches("### ").to_string(),
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
theme.apply_text_semantics(
|
||||
theme.markdown.heading,
|
||||
TextSemantics {
|
||||
bold: true,
|
||||
underline: false,
|
||||
},
|
||||
),
|
||||
)),
|
||||
scrollable: false,
|
||||
});
|
||||
|
|
@ -208,9 +215,13 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
|
|||
lines_vec.push(DisplayLine {
|
||||
line: Line::from(Span::styled(
|
||||
raw.trim_start_matches("## ").to_string(),
|
||||
Style::default()
|
||||
.fg(Color::LightCyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
theme.apply_text_semantics(
|
||||
theme.markdown.heading,
|
||||
TextSemantics {
|
||||
bold: true,
|
||||
underline: false,
|
||||
},
|
||||
),
|
||||
)),
|
||||
scrollable: false,
|
||||
});
|
||||
|
|
@ -221,9 +232,13 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
|
|||
lines_vec.push(DisplayLine {
|
||||
line: Line::from(Span::styled(
|
||||
raw.trim_start_matches("# ").to_string(),
|
||||
Style::default()
|
||||
.fg(Color::Gray)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
theme.apply_text_semantics(
|
||||
theme.markdown.heading,
|
||||
TextSemantics {
|
||||
bold: true,
|
||||
underline: false,
|
||||
},
|
||||
),
|
||||
)),
|
||||
scrollable: false,
|
||||
});
|
||||
|
|
@ -254,13 +269,13 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
|
|||
}
|
||||
|
||||
let table = parse_table(&table_lines.iter().map(|s| s.as_str()).collect::<Vec<_>>());
|
||||
lines_vec.extend(table_to_lines(table));
|
||||
lines_vec.extend(table_to_lines(table, theme));
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
|
||||
lines_vec.push(DisplayLine {
|
||||
line: Line::from(parse_inline(raw.as_str())),
|
||||
line: Line::from(parse_inline(raw.as_str(), theme)),
|
||||
scrollable: false,
|
||||
});
|
||||
i += 1;
|
||||
|
|
@ -269,7 +284,7 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
|
|||
lines_vec
|
||||
}
|
||||
|
||||
fn parse_inline(input: &str) -> Vec<Span<'static>> {
|
||||
fn parse_inline(input: &str, theme: &ResolvedTheme) -> Vec<Span<'static>> {
|
||||
let mut spans = Vec::new();
|
||||
let mut buf = String::new();
|
||||
|
||||
|
|
@ -294,7 +309,11 @@ fn parse_inline(input: &str) -> Vec<Span<'static>> {
|
|||
};
|
||||
|
||||
if let Some(kind) = toggle {
|
||||
flush_span(&mut spans, &mut buf, current_style(bold, underline, code));
|
||||
flush_span(
|
||||
&mut spans,
|
||||
&mut buf,
|
||||
current_style(bold, underline, code, theme),
|
||||
);
|
||||
|
||||
match kind {
|
||||
"bold" => bold = !bold,
|
||||
|
|
@ -308,24 +327,21 @@ fn parse_inline(input: &str) -> Vec<Span<'static>> {
|
|||
buf.push(c);
|
||||
}
|
||||
|
||||
flush_span(&mut spans, &mut buf, current_style(bold, underline, code));
|
||||
flush_span(
|
||||
&mut spans,
|
||||
&mut buf,
|
||||
current_style(bold, underline, code, theme),
|
||||
);
|
||||
spans
|
||||
}
|
||||
|
||||
fn current_style(bold: bool, underline: bool, code: bool) -> Style {
|
||||
let mut style = Style::default();
|
||||
|
||||
if bold {
|
||||
style = style.add_modifier(Modifier::BOLD);
|
||||
}
|
||||
if underline {
|
||||
style = style.add_modifier(Modifier::UNDERLINED);
|
||||
}
|
||||
if code {
|
||||
style = style.fg(Color::Yellow);
|
||||
}
|
||||
|
||||
style
|
||||
fn current_style(bold: bool, underline: bool, code: bool, theme: &ResolvedTheme) -> Style {
|
||||
let base = if code {
|
||||
theme.markdown.code
|
||||
} else {
|
||||
theme.markdown.normal
|
||||
};
|
||||
theme.apply_text_semantics(base, TextSemantics { bold, underline })
|
||||
}
|
||||
#[derive(Clone)]
|
||||
pub struct DisplayLine {
|
||||
|
|
@ -333,7 +349,7 @@ pub struct DisplayLine {
|
|||
scrollable: bool,
|
||||
}
|
||||
|
||||
fn table_to_lines(table: Vec<Vec<String>>) -> Vec<DisplayLine> {
|
||||
fn table_to_lines(table: Vec<Vec<String>>, theme: &ResolvedTheme) -> Vec<DisplayLine> {
|
||||
if table.len() < 2 {
|
||||
return vec![];
|
||||
}
|
||||
|
|
@ -377,7 +393,7 @@ fn table_to_lines(table: Vec<Vec<String>>) -> Vec<DisplayLine> {
|
|||
.join("─┼─");
|
||||
|
||||
lines.push(DisplayLine {
|
||||
line: Line::from(Span::styled(divider, Style::default().fg(Color::DarkGray))),
|
||||
line: Line::from(Span::styled(divider, theme.markdown.divider)),
|
||||
scrollable: true,
|
||||
});
|
||||
continue;
|
||||
|
|
@ -403,11 +419,15 @@ fn table_to_lines(table: Vec<Vec<String>>) -> Vec<DisplayLine> {
|
|||
}
|
||||
|
||||
let style = if row_idx == 0 {
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
theme.apply_text_semantics(
|
||||
theme.markdown.table_header,
|
||||
TextSemantics {
|
||||
bold: true,
|
||||
underline: false,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Style::default().fg(Color::Green)
|
||||
theme.markdown.table_text
|
||||
};
|
||||
|
||||
lines.push(DisplayLine {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,19 @@
|
|||
use crate::{
|
||||
controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line},
|
||||
interaction_result::InteractionResult,
|
||||
render_context::RenderContext,
|
||||
screens::{md_viewer::FileViewer, screens::Screen},
|
||||
ui::UI,
|
||||
util::{
|
||||
buttons::{checkbox, draw_buttons},
|
||||
terms_focus::Focus,
|
||||
},
|
||||
util::{buttons::draw_buttons, terms_focus::Focus},
|
||||
};
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use iota_terms::{TermsType, get_link, get_terms};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
};
|
||||
use std::{any::Any, pin::Pin, sync::Arc};
|
||||
use std::{any::Any, pin::Pin};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
|
@ -26,9 +23,7 @@ pub enum UserChoice {
|
|||
AcceptAll,
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // ui is unused
|
||||
pub struct TermsCheckerScreen {
|
||||
_ui: Arc<UI>,
|
||||
sender: Option<oneshot::Sender<UserChoice>>,
|
||||
|
||||
eula: bool,
|
||||
|
|
@ -39,9 +34,8 @@ pub struct TermsCheckerScreen {
|
|||
}
|
||||
|
||||
impl TermsCheckerScreen {
|
||||
pub fn new(ui: Arc<UI>, sender: Option<oneshot::Sender<UserChoice>>) -> Self {
|
||||
pub fn new(sender: Option<oneshot::Sender<UserChoice>>) -> Self {
|
||||
Self {
|
||||
_ui: ui,
|
||||
sender,
|
||||
eula: false,
|
||||
tos: false,
|
||||
|
|
@ -59,7 +53,7 @@ impl Screen for TermsCheckerScreen {
|
|||
self
|
||||
}
|
||||
|
||||
fn render(&self, f: &mut Frame, size: Rect) {
|
||||
fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>) {
|
||||
let mut needed_height = 5;
|
||||
|
||||
if size.height < 6 || size.width < 27 {
|
||||
|
|
@ -169,9 +163,36 @@ impl Screen for TermsCheckerScreen {
|
|||
)
|
||||
};
|
||||
let mut text_lines = vec![
|
||||
checkbox(eula_text, self.eula, self.focus == Focus::Eula, true),
|
||||
checkbox(tos_text, self.tos, self.focus == Focus::Tos, self.eula),
|
||||
checkbox(pp_text, self.pp, self.focus == Focus::Pp, self.eula),
|
||||
render_choice_line(
|
||||
eula_text,
|
||||
ChoiceKind::Checkbox,
|
||||
ChoiceVisualState {
|
||||
selected: self.eula,
|
||||
focused: self.focus == Focus::Eula,
|
||||
enabled: true,
|
||||
},
|
||||
context.theme,
|
||||
),
|
||||
render_choice_line(
|
||||
tos_text,
|
||||
ChoiceKind::Checkbox,
|
||||
ChoiceVisualState {
|
||||
selected: self.tos,
|
||||
focused: self.focus == Focus::Tos,
|
||||
enabled: self.eula,
|
||||
},
|
||||
context.theme,
|
||||
),
|
||||
render_choice_line(
|
||||
pp_text,
|
||||
ChoiceKind::Checkbox,
|
||||
ChoiceVisualState {
|
||||
selected: self.pp,
|
||||
focused: self.focus == Focus::Pp,
|
||||
enabled: self.eula,
|
||||
},
|
||||
context.theme,
|
||||
),
|
||||
Line::from(""),
|
||||
Line::from("¹ Necessary– required to run the program"),
|
||||
Line::from("² Optional – required only for Tensamin services"),
|
||||
|
|
@ -191,19 +212,19 @@ impl Screen for TermsCheckerScreen {
|
|||
|
||||
if size.width < 60 || size.height < needed_height as u16 {
|
||||
let width_style = if size.width > 76 {
|
||||
Style::default().fg(Color::Green)
|
||||
context.theme.status.success
|
||||
} else if size.width >= 60 {
|
||||
Style::default().fg(Color::Yellow)
|
||||
context.theme.status.warning
|
||||
} else {
|
||||
Style::default().fg(Color::Red)
|
||||
context.theme.status.error
|
||||
};
|
||||
|
||||
let height_style = if size.height > 19 {
|
||||
Style::default().fg(Color::Green)
|
||||
context.theme.status.success
|
||||
} else if size.height >= 13 {
|
||||
Style::default().fg(Color::Yellow)
|
||||
context.theme.status.warning
|
||||
} else {
|
||||
Style::default().fg(Color::Red)
|
||||
context.theme.status.error
|
||||
};
|
||||
|
||||
let warning_text = Text::from(vec![
|
||||
|
|
@ -245,6 +266,7 @@ impl Screen for TermsCheckerScreen {
|
|||
true,
|
||||
false,
|
||||
true,
|
||||
context.theme,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
163
iota-cli/src/theme/config.rs
Normal file
163
iota-cli/src/theme/config.rs
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
use super::ThemeName;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fs, io,
|
||||
path::{Path, PathBuf},
|
||||
str::FromStr,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct UiConfig {
|
||||
#[serde(default)]
|
||||
pub theme: ThemeName,
|
||||
/// Whether opening the interactive UI should launch a locally installed daemon.
|
||||
#[serde(default)]
|
||||
pub daemon_start_policy: DaemonStartPolicy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum DaemonStartPolicy {
|
||||
#[default]
|
||||
Ask,
|
||||
WithUi,
|
||||
}
|
||||
impl Serialize for DaemonStartPolicy {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
match self {
|
||||
Self::Ask => serializer.serialize_str("ask"),
|
||||
Self::WithUi => serializer.serialize_str("with_ui"),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<'de> Deserialize<'de> for DaemonStartPolicy {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Compat {
|
||||
Policy(String),
|
||||
Legacy(bool),
|
||||
}
|
||||
match Compat::deserialize(deserializer)? {
|
||||
Compat::Policy(v) if v == "with_ui" || v == "WithUi" => Ok(Self::WithUi),
|
||||
Compat::Policy(_) => Ok(Self::Ask),
|
||||
Compat::Legacy(true) => Ok(Self::WithUi),
|
||||
Compat::Legacy(false) => Ok(Self::Ask),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl UiConfig {
|
||||
pub fn path() -> PathBuf {
|
||||
iota_paths::config_dir().join("ui.yaml")
|
||||
}
|
||||
pub fn load() -> Result<Self, io::Error> {
|
||||
Self::load_from(&Self::path())
|
||||
}
|
||||
|
||||
fn load_from(path: &Path) -> Result<Self, io::Error> {
|
||||
if !path.exists() {
|
||||
return Ok(Self::default());
|
||||
}
|
||||
serde_yaml::from_str(&fs::read_to_string(path)?).map_err(io::Error::other)
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<(), io::Error> {
|
||||
let path = Self::path();
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let yaml = serde_yaml::to_string(self).map_err(io::Error::other)?;
|
||||
fs::write(path, yaml)
|
||||
}
|
||||
|
||||
pub fn resolve_theme(override_theme: Option<ThemeName>) -> ThemeName {
|
||||
Self::resolve_theme_from(
|
||||
override_theme,
|
||||
std::env::var("IOTA_THEME").ok().as_deref(),
|
||||
&Self::path(),
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_theme_from(
|
||||
override_theme: Option<ThemeName>,
|
||||
environment_theme: Option<&str>,
|
||||
config_path: &Path,
|
||||
) -> ThemeName {
|
||||
if let Some(theme) = override_theme {
|
||||
return theme;
|
||||
}
|
||||
if let Some(value) = environment_theme {
|
||||
match ThemeName::from_str(value) {
|
||||
Ok(theme) => return theme,
|
||||
Err(error) => {
|
||||
eprintln!("Invalid IOTA_THEME value: {error}; checking UI configuration.");
|
||||
}
|
||||
}
|
||||
}
|
||||
match Self::load_from(config_path) {
|
||||
Ok(config) => config.theme,
|
||||
Err(error) => {
|
||||
eprintln!(
|
||||
"Could not read UI configuration {}: {error}; using ansi.",
|
||||
config_path.display()
|
||||
);
|
||||
ThemeName::Ansi
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn config_path(name: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!("iota-ui-config-{}-{name}.yaml", std::process::id()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_line_override_has_highest_precedence() {
|
||||
let path = config_path("override");
|
||||
fs::write(&path, "theme: surface\n").unwrap();
|
||||
let resolved =
|
||||
UiConfig::resolve_theme_from(Some(ThemeName::Binary), Some("monospace"), &path);
|
||||
fs::remove_file(path).unwrap();
|
||||
assert_eq!(resolved, ThemeName::Binary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_precedes_stored_configuration() {
|
||||
let path = config_path("environment");
|
||||
fs::write(&path, "theme: surface\n").unwrap();
|
||||
let resolved = UiConfig::resolve_theme_from(None, Some("monospace"), &path);
|
||||
fs::remove_file(path).unwrap();
|
||||
assert_eq!(resolved, ThemeName::Monospace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_configuration_precedes_default() {
|
||||
let path = config_path("stored");
|
||||
fs::write(&path, "theme: surface\n").unwrap();
|
||||
let resolved = UiConfig::resolve_theme_from(None, None, &path);
|
||||
fs::remove_file(path).unwrap();
|
||||
assert_eq!(resolved, ThemeName::Surface);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_stored_configuration_falls_back_to_ansi() {
|
||||
let path = config_path("invalid");
|
||||
fs::write(&path, "theme: ultraviolet\n").unwrap();
|
||||
let resolved = UiConfig::resolve_theme_from(None, None, &path);
|
||||
fs::remove_file(path).unwrap();
|
||||
assert_eq!(resolved, ThemeName::Ansi);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_configuration_falls_back_to_ansi() {
|
||||
let path = config_path("missing");
|
||||
let _ = fs::remove_file(&path);
|
||||
assert_eq!(
|
||||
UiConfig::resolve_theme_from(None, None, &path),
|
||||
ThemeName::Ansi
|
||||
);
|
||||
}
|
||||
}
|
||||
12
iota-cli/src/theme/mod.rs
Normal file
12
iota-cli/src/theme/mod.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
mod config;
|
||||
mod model;
|
||||
mod name;
|
||||
mod presets;
|
||||
|
||||
pub use config::{DaemonStartPolicy, UiConfig};
|
||||
pub use model::*;
|
||||
pub use name::ThemeName;
|
||||
|
||||
pub fn resolve(name: ThemeName) -> ResolvedTheme {
|
||||
presets::resolve(name)
|
||||
}
|
||||
149
iota-cli/src/theme/model.rs
Normal file
149
iota-cli/src/theme/model.rs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
use super::ThemeName;
|
||||
use ratatui::style::Style;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TextStyles {
|
||||
pub normal: Style,
|
||||
pub muted: Style,
|
||||
pub heading: Style,
|
||||
pub link: Style,
|
||||
pub code: Style,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct StatusStyles {
|
||||
pub info: Style,
|
||||
pub success: Style,
|
||||
pub warning: Style,
|
||||
pub error: Style,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BorderStyles {
|
||||
pub normal: Style,
|
||||
pub focused: Style,
|
||||
pub disabled: Style,
|
||||
pub title: Style,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ChoiceItemStyle {
|
||||
pub marker: Style,
|
||||
pub label: Style,
|
||||
pub description: Style,
|
||||
pub prefix: &'static str,
|
||||
pub suffix: &'static str,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ChoiceStyles {
|
||||
pub normal: ChoiceItemStyle,
|
||||
pub focused: ChoiceItemStyle,
|
||||
pub selected: ChoiceItemStyle,
|
||||
pub focused_selected: ChoiceItemStyle,
|
||||
pub disabled: ChoiceItemStyle,
|
||||
pub focused_disabled: ChoiceItemStyle,
|
||||
pub selected_disabled: ChoiceItemStyle,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ButtonStyles {
|
||||
pub primary: Style,
|
||||
pub primary_focused: Style,
|
||||
pub neutral: Style,
|
||||
pub neutral_focused: Style,
|
||||
pub cancel: Style,
|
||||
pub cancel_focused: Style,
|
||||
pub destructive: Style,
|
||||
pub disabled: Style,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MarkerSet {
|
||||
pub checkbox_unselected: &'static str,
|
||||
pub checkbox_selected: &'static str,
|
||||
pub radio_unselected: &'static str,
|
||||
pub radio_selected: &'static str,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum CursorPresentation {
|
||||
StyledCell(Style),
|
||||
Character { glyph: &'static str, style: Style },
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ConsoleStyles {
|
||||
pub text: Style,
|
||||
pub prefix: Style,
|
||||
pub hint: Style,
|
||||
pub error: Style,
|
||||
pub confirmation: Style,
|
||||
pub cursor: CursorPresentation,
|
||||
pub border: Style,
|
||||
pub focused_border: Style,
|
||||
pub title: Style,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GraphStyles {
|
||||
pub ram: ratatui::style::Color,
|
||||
pub cpu: ratatui::style::Color,
|
||||
pub ping: ratatui::style::Color,
|
||||
pub text: Style,
|
||||
pub border: Style,
|
||||
pub focused_border: Style,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct LogStyles {
|
||||
pub call: Style,
|
||||
pub client: Style,
|
||||
pub iota: Style,
|
||||
pub omikron: Style,
|
||||
pub omega: Style,
|
||||
pub command: Style,
|
||||
pub other: Style,
|
||||
pub text: Style,
|
||||
pub error: Style,
|
||||
pub timestamp: Style,
|
||||
pub border: Style,
|
||||
pub focused_border: Style,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MarkdownStyles {
|
||||
pub normal: Style,
|
||||
pub muted: Style,
|
||||
pub heading: Style,
|
||||
pub link: Style,
|
||||
pub code: Style,
|
||||
pub table_header: Style,
|
||||
pub table_text: Style,
|
||||
pub divider: Style,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct TextSemantics {
|
||||
pub bold: bool,
|
||||
pub underline: bool,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ResolvedTheme {
|
||||
pub name: ThemeName,
|
||||
pub text: TextStyles,
|
||||
pub status: StatusStyles,
|
||||
pub choices: ChoiceStyles,
|
||||
pub buttons: ButtonStyles,
|
||||
pub borders: BorderStyles,
|
||||
pub console: ConsoleStyles,
|
||||
pub graphs: GraphStyles,
|
||||
pub logs: LogStyles,
|
||||
pub markdown: MarkdownStyles,
|
||||
pub markers: MarkerSet,
|
||||
}
|
||||
|
||||
impl ResolvedTheme {
|
||||
pub fn apply_text_semantics(&self, base: Style, semantics: TextSemantics) -> Style {
|
||||
use ratatui::style::Modifier;
|
||||
if matches!(self.name, ThemeName::Monospace) {
|
||||
return base;
|
||||
}
|
||||
let mut style = base;
|
||||
if semantics.bold {
|
||||
style = style.add_modifier(Modifier::BOLD);
|
||||
}
|
||||
if semantics.underline {
|
||||
style = style.add_modifier(Modifier::UNDERLINED);
|
||||
}
|
||||
style
|
||||
}
|
||||
}
|
||||
47
iota-cli/src/theme/name.rs
Normal file
47
iota-cli/src/theme/name.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::{fmt, str::FromStr};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ThemeName {
|
||||
Monospace,
|
||||
Binary,
|
||||
#[default]
|
||||
Ansi,
|
||||
Surface,
|
||||
}
|
||||
|
||||
impl ThemeName {
|
||||
pub const ALL: [Self; 4] = [Self::Monospace, Self::Binary, Self::Ansi, Self::Surface];
|
||||
|
||||
pub fn supported_names() -> &'static str {
|
||||
"monospace, binary, ansi, surface"
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ThemeName {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Monospace => "monospace",
|
||||
Self::Binary => "binary",
|
||||
Self::Ansi => "ansi",
|
||||
Self::Surface => "surface",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ThemeName {
|
||||
type Err = String;
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"monospace" => Ok(Self::Monospace),
|
||||
"binary" => Ok(Self::Binary),
|
||||
"ansi" => Ok(Self::Ansi),
|
||||
"surface" => Ok(Self::Surface),
|
||||
_ => Err(format!(
|
||||
"unknown theme `{value}`; supported themes: {}",
|
||||
Self::supported_names()
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
301
iota-cli/src/theme/presets.rs
Normal file
301
iota-cli/src/theme/presets.rs
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
use super::{
|
||||
BorderStyles, ButtonStyles, ChoiceItemStyle, ChoiceStyles, ConsoleStyles, CursorPresentation,
|
||||
GraphStyles, LogStyles, MarkdownStyles, MarkerSet, ResolvedTheme, StatusStyles, TextStyles,
|
||||
ThemeName,
|
||||
};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
|
||||
fn marker() -> MarkerSet {
|
||||
MarkerSet {
|
||||
checkbox_unselected: "[ ]",
|
||||
checkbox_selected: "[x]",
|
||||
radio_unselected: "( )",
|
||||
radio_selected: "(x)",
|
||||
}
|
||||
}
|
||||
fn choice(
|
||||
marker: Style,
|
||||
label: Style,
|
||||
prefix: &'static str,
|
||||
suffix: &'static str,
|
||||
) -> ChoiceItemStyle {
|
||||
ChoiceItemStyle {
|
||||
marker,
|
||||
label,
|
||||
description: label,
|
||||
prefix,
|
||||
suffix,
|
||||
}
|
||||
}
|
||||
fn base(
|
||||
name: ThemeName,
|
||||
normal: Style,
|
||||
muted: Style,
|
||||
focused: Style,
|
||||
selected: Style,
|
||||
disabled: Style,
|
||||
status: StatusStyles,
|
||||
buttons: ButtonStyles,
|
||||
) -> ResolvedTheme {
|
||||
let error = status.error;
|
||||
let (prefix, suffix) = if matches!(name, ThemeName::Monospace | ThemeName::Binary) {
|
||||
("> ", " <")
|
||||
} else {
|
||||
("", "")
|
||||
};
|
||||
ResolvedTheme {
|
||||
name,
|
||||
text: TextStyles {
|
||||
normal,
|
||||
muted,
|
||||
heading: normal,
|
||||
link: focused,
|
||||
code: normal,
|
||||
},
|
||||
status,
|
||||
choices: ChoiceStyles {
|
||||
normal: choice(normal, normal, "", ""),
|
||||
focused: choice(focused, focused, prefix, suffix),
|
||||
selected: choice(selected, selected, "", ""),
|
||||
focused_selected: choice(
|
||||
selected.patch(focused),
|
||||
selected.patch(focused),
|
||||
prefix,
|
||||
suffix,
|
||||
),
|
||||
disabled: choice(disabled, disabled, "", ""),
|
||||
focused_disabled: choice(disabled, error, prefix, suffix),
|
||||
selected_disabled: choice(disabled, disabled, "", ""),
|
||||
},
|
||||
buttons,
|
||||
borders: BorderStyles {
|
||||
normal,
|
||||
focused,
|
||||
disabled,
|
||||
title: normal,
|
||||
},
|
||||
console: ConsoleStyles {
|
||||
text: normal,
|
||||
prefix: muted,
|
||||
hint: muted,
|
||||
error,
|
||||
confirmation: focused,
|
||||
cursor: CursorPresentation::StyledCell(focused),
|
||||
border: normal,
|
||||
focused_border: focused,
|
||||
title: normal,
|
||||
},
|
||||
graphs: GraphStyles {
|
||||
ram: Color::Reset,
|
||||
cpu: Color::Reset,
|
||||
ping: Color::Reset,
|
||||
text: normal,
|
||||
border: normal,
|
||||
focused_border: focused,
|
||||
},
|
||||
logs: LogStyles {
|
||||
call: normal,
|
||||
client: normal,
|
||||
iota: normal,
|
||||
omikron: normal,
|
||||
omega: normal,
|
||||
command: normal,
|
||||
other: normal,
|
||||
text: normal,
|
||||
error,
|
||||
timestamp: muted,
|
||||
border: normal,
|
||||
focused_border: focused,
|
||||
},
|
||||
markdown: MarkdownStyles {
|
||||
normal,
|
||||
muted,
|
||||
heading: focused,
|
||||
link: focused,
|
||||
code: focused,
|
||||
table_header: focused,
|
||||
table_text: normal,
|
||||
divider: muted,
|
||||
},
|
||||
markers: marker(),
|
||||
}
|
||||
}
|
||||
pub fn resolve(name: ThemeName) -> ResolvedTheme {
|
||||
let plain = Style::default();
|
||||
match name {
|
||||
ThemeName::Monospace => {
|
||||
let mut theme = base(
|
||||
name,
|
||||
plain,
|
||||
plain,
|
||||
plain,
|
||||
plain,
|
||||
plain,
|
||||
StatusStyles {
|
||||
info: plain,
|
||||
success: plain,
|
||||
warning: plain,
|
||||
error: plain,
|
||||
},
|
||||
ButtonStyles {
|
||||
primary: plain,
|
||||
primary_focused: plain,
|
||||
neutral: plain,
|
||||
neutral_focused: plain,
|
||||
cancel: plain,
|
||||
cancel_focused: plain,
|
||||
destructive: plain,
|
||||
disabled: plain,
|
||||
},
|
||||
);
|
||||
theme.console.cursor = CursorPresentation::Character {
|
||||
glyph: "▌",
|
||||
style: plain,
|
||||
};
|
||||
theme.graphs = GraphStyles {
|
||||
ram: Color::Reset,
|
||||
cpu: Color::Reset,
|
||||
ping: Color::Reset,
|
||||
text: plain,
|
||||
border: plain,
|
||||
focused_border: plain,
|
||||
};
|
||||
theme
|
||||
}
|
||||
ThemeName::Binary => {
|
||||
let reversed = plain.add_modifier(Modifier::REVERSED);
|
||||
base(
|
||||
name,
|
||||
plain,
|
||||
plain,
|
||||
plain,
|
||||
reversed,
|
||||
plain,
|
||||
StatusStyles {
|
||||
info: plain,
|
||||
success: plain,
|
||||
warning: plain,
|
||||
error: plain,
|
||||
},
|
||||
ButtonStyles {
|
||||
primary: plain,
|
||||
primary_focused: reversed,
|
||||
neutral: plain,
|
||||
neutral_focused: reversed,
|
||||
cancel: plain,
|
||||
cancel_focused: reversed,
|
||||
destructive: plain,
|
||||
disabled: plain,
|
||||
},
|
||||
)
|
||||
}
|
||||
ThemeName::Ansi => {
|
||||
let yellow = plain.fg(Color::Yellow).add_modifier(Modifier::BOLD);
|
||||
let mut theme = base(
|
||||
name,
|
||||
plain,
|
||||
plain.fg(Color::DarkGray),
|
||||
yellow,
|
||||
plain,
|
||||
plain.fg(Color::DarkGray),
|
||||
StatusStyles {
|
||||
info: plain,
|
||||
success: plain.fg(Color::Green),
|
||||
warning: plain.fg(Color::Yellow),
|
||||
error: plain.fg(Color::Red),
|
||||
},
|
||||
ButtonStyles {
|
||||
primary: plain.fg(Color::Green),
|
||||
primary_focused: plain
|
||||
.fg(Color::Black)
|
||||
.bg(Color::Green)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
neutral: plain,
|
||||
neutral_focused: yellow,
|
||||
cancel: plain.fg(Color::Red),
|
||||
cancel_focused: plain
|
||||
.fg(Color::Black)
|
||||
.bg(Color::Red)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
destructive: plain.fg(Color::Red),
|
||||
disabled: plain.fg(Color::DarkGray),
|
||||
},
|
||||
);
|
||||
theme.console = ConsoleStyles {
|
||||
text: plain.fg(Color::White),
|
||||
prefix: plain.fg(Color::DarkGray),
|
||||
hint: plain.fg(Color::DarkGray),
|
||||
error: plain.fg(Color::Red),
|
||||
confirmation: plain.fg(Color::Yellow),
|
||||
cursor: CursorPresentation::StyledCell(plain.fg(Color::White).bg(Color::DarkGray)),
|
||||
border: plain,
|
||||
focused_border: plain.fg(Color::Yellow),
|
||||
title: plain.fg(Color::White),
|
||||
};
|
||||
theme.graphs = GraphStyles {
|
||||
ram: Color::Blue,
|
||||
cpu: Color::Red,
|
||||
ping: Color::Green,
|
||||
text: plain,
|
||||
border: plain,
|
||||
focused_border: plain.fg(Color::Yellow),
|
||||
};
|
||||
theme.logs = LogStyles {
|
||||
call: plain.fg(Color::Magenta),
|
||||
client: plain.fg(Color::Green),
|
||||
iota: plain.fg(Color::Yellow),
|
||||
omikron: plain.fg(Color::Blue),
|
||||
omega: plain.fg(Color::Cyan),
|
||||
command: plain.fg(Color::LightGreen),
|
||||
other: plain.fg(Color::LightCyan),
|
||||
text: plain.fg(Color::White),
|
||||
error: plain.fg(Color::Red),
|
||||
timestamp: plain.fg(Color::DarkGray),
|
||||
border: plain,
|
||||
focused_border: plain.fg(Color::Yellow),
|
||||
};
|
||||
theme.markdown = MarkdownStyles {
|
||||
normal: plain,
|
||||
muted: plain.fg(Color::DarkGray),
|
||||
heading: plain.fg(Color::Cyan),
|
||||
link: plain.fg(Color::Cyan),
|
||||
code: plain.fg(Color::Yellow),
|
||||
table_header: plain.fg(Color::Cyan),
|
||||
table_text: plain.fg(Color::Green),
|
||||
divider: plain.fg(Color::DarkGray),
|
||||
};
|
||||
theme
|
||||
}
|
||||
ThemeName::Surface => {
|
||||
let focus = plain.fg(Color::Black).bg(Color::Yellow);
|
||||
let selected = plain.fg(Color::Black).bg(Color::Cyan);
|
||||
let mut theme = base(
|
||||
name,
|
||||
plain,
|
||||
plain.fg(Color::DarkGray),
|
||||
focus,
|
||||
selected,
|
||||
plain.fg(Color::DarkGray),
|
||||
StatusStyles {
|
||||
info: plain,
|
||||
success: plain.fg(Color::Green),
|
||||
warning: plain.fg(Color::Yellow),
|
||||
error: plain.fg(Color::Red),
|
||||
},
|
||||
ButtonStyles {
|
||||
primary: plain.fg(Color::Black).bg(Color::Green),
|
||||
primary_focused: focus,
|
||||
neutral: plain,
|
||||
neutral_focused: focus,
|
||||
cancel: plain.fg(Color::Black).bg(Color::Red),
|
||||
cancel_focused: focus,
|
||||
destructive: plain.fg(Color::Black).bg(Color::Red),
|
||||
disabled: plain.fg(Color::DarkGray),
|
||||
},
|
||||
);
|
||||
theme.console.cursor =
|
||||
CursorPresentation::StyledCell(plain.fg(Color::Black).bg(Color::Yellow));
|
||||
theme
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,144 +1,262 @@
|
|||
use crate::{
|
||||
input_handler::setup_input_handler, interaction_result::InteractionResult,
|
||||
ipc_client::IpcClient, screens::screens::Screen,
|
||||
input_handler::setup_input_handler,
|
||||
interaction_result::InteractionResult,
|
||||
ipc_client::IpcClient,
|
||||
render_context::RenderContext,
|
||||
screens::screens::Screen,
|
||||
theme::{self, ResolvedTheme, ThemeName},
|
||||
};
|
||||
use crossterm::event::KeyEvent;
|
||||
use once_cell::sync::Lazy;
|
||||
use ratatui::{Terminal, backend::CrosstermBackend, init};
|
||||
use ratatui::{Terminal, backend::CrosstermBackend};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
io,
|
||||
io::Stdout,
|
||||
panic::PanicHookInfo,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{sync::RwLock, time::Instant};
|
||||
use tokio::sync::{Notify, RwLock};
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
/// UI state and rendering
|
||||
|
||||
pub static FPS: Lazy<RwLock<(f64, f64)>> = Lazy::new(|| RwLock::new((0.0, 0.0)));
|
||||
|
||||
pub struct UI {
|
||||
ipc: Arc<IpcClient>,
|
||||
shutdown: AtomicBool,
|
||||
ipc: RwLock<Option<Arc<IpcClient>>>,
|
||||
shutdown_on_empty: bool,
|
||||
cancellation: CancellationToken,
|
||||
pub terminal: Arc<Mutex<Terminal<CrosstermBackend<Stdout>>>>,
|
||||
screen_stack: Arc<RwLock<Vec<Box<dyn Screen>>>>,
|
||||
theme: RwLock<Arc<ResolvedTheme>>,
|
||||
pub(crate) invalidation: Notify,
|
||||
failure: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
pub fn start_tui(ipc: Arc<IpcClient>) -> Arc<UI> {
|
||||
let ui = Arc::new(UI::new(ipc));
|
||||
pub fn start_tui(ipc: Arc<IpcClient>) -> io::Result<TuiSession> {
|
||||
start_tui_with_theme(ipc, theme::resolve(ThemeName::Ansi))
|
||||
}
|
||||
|
||||
pub fn start_tui_with_theme(ipc: Arc<IpcClient>, theme: ResolvedTheme) -> io::Result<TuiSession> {
|
||||
start_session(UI::new(Some(ipc), true, theme)?)
|
||||
}
|
||||
|
||||
pub fn start_bootstrap_tui() -> io::Result<TuiSession> {
|
||||
start_bootstrap_tui_with_theme(theme::resolve(ThemeName::Ansi))
|
||||
}
|
||||
|
||||
pub fn start_bootstrap_tui_with_theme(theme: ResolvedTheme) -> io::Result<TuiSession> {
|
||||
start_session(UI::new(None, false, theme)?)
|
||||
}
|
||||
|
||||
fn start_session(ui: UI) -> io::Result<TuiSession> {
|
||||
let ui = Arc::new(ui);
|
||||
let uic = ui.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut last_render = Instant::now();
|
||||
|
||||
let mut fps_samples: VecDeque<f64> = VecDeque::with_capacity(20);
|
||||
let mut skip_samples: VecDeque<u16> = VecDeque::with_capacity(20);
|
||||
|
||||
let mut fps_sum = 0.0;
|
||||
let mut skip_sum: u32 = 0;
|
||||
|
||||
let mut skipped = 0;
|
||||
|
||||
loop {
|
||||
if uic.is_shutdown() {
|
||||
break;
|
||||
let renderer_task = tokio::spawn(async move {
|
||||
let cancellation = uic.cancellation_token();
|
||||
let result: io::Result<()> = loop {
|
||||
tokio::select! {
|
||||
_ = cancellation.cancelled() => break Ok(()),
|
||||
_ = uic.invalidation.notified() => { if !uic.is_shutdown() { uic.render().await?; } },
|
||||
_ = tokio::time::sleep(std::time::Duration::from_millis(250)) => { if !uic.is_shutdown() { uic.render().await?; } },
|
||||
}
|
||||
|
||||
if skipped > 5 {
|
||||
uic.render().await;
|
||||
|
||||
skip_samples.push_back(skipped);
|
||||
skip_sum += skipped as u32;
|
||||
|
||||
if skip_samples.len() > 20 {
|
||||
if let Some(old) = skip_samples.pop_front() {
|
||||
skip_sum -= old as u32;
|
||||
}
|
||||
}
|
||||
|
||||
skipped = 0;
|
||||
|
||||
let elapsed = last_render.elapsed().as_secs_f64();
|
||||
if elapsed > 0.0 {
|
||||
let fps = 1.0 / elapsed;
|
||||
|
||||
fps_samples.push_back(fps);
|
||||
fps_sum += fps;
|
||||
|
||||
if fps_samples.len() > 20 {
|
||||
if let Some(old) = fps_samples.pop_front() {
|
||||
fps_sum -= old;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let avg_fps = if !fps_samples.is_empty() {
|
||||
fps_sum / fps_samples.len() as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let avg_skips_percentage = if !skip_samples.is_empty() {
|
||||
let avg_skipped = skip_sum as f64 / skip_samples.len() as f64;
|
||||
let total_iterations = avg_skipped + 1.0;
|
||||
(avg_skipped / total_iterations) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
*FPS.write().await = (avg_fps, avg_skips_percentage);
|
||||
|
||||
last_render = Instant::now();
|
||||
} else {
|
||||
skipped += 1;
|
||||
if let Err(error) = &result {
|
||||
*uic.failure.lock().unwrap() = Some(error.to_string());
|
||||
uic.request_shutdown();
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(16)).await;
|
||||
}
|
||||
ratatui::restore();
|
||||
result
|
||||
});
|
||||
setup_input_handler(ui.clone());
|
||||
ui
|
||||
let input_task = setup_input_handler(ui.clone());
|
||||
// Some terminals deliver Ctrl+C as SIGINT even while crossterm is in raw
|
||||
// mode. Keep this independent of key-event handling for bootstrap work.
|
||||
let signal_task = {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let signal_ui = ui.clone();
|
||||
Some(tokio::spawn(async move {
|
||||
if tokio::signal::ctrl_c().await.is_ok() {
|
||||
signal_ui.request_shutdown();
|
||||
}
|
||||
}))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
None
|
||||
}
|
||||
};
|
||||
let previous_hook = Arc::new(Mutex::new(Some(std::panic::take_hook())));
|
||||
let hook_for_panic = previous_hook.clone();
|
||||
std::panic::set_hook(Box::new(move |info: &PanicHookInfo<'_>| {
|
||||
ratatui::restore();
|
||||
if let Some(hook) = hook_for_panic.lock().unwrap().as_ref() {
|
||||
hook(info);
|
||||
}
|
||||
}));
|
||||
Ok(TuiSession {
|
||||
ui,
|
||||
renderer_task,
|
||||
input_task,
|
||||
signal_task,
|
||||
restored: AtomicBool::new(false),
|
||||
previous_hook,
|
||||
})
|
||||
}
|
||||
|
||||
pub struct TuiSession {
|
||||
ui: Arc<UI>,
|
||||
renderer_task: JoinHandle<io::Result<()>>,
|
||||
input_task: JoinHandle<Result<(), String>>,
|
||||
signal_task: Option<JoinHandle<()>>,
|
||||
restored: AtomicBool,
|
||||
previous_hook: Arc<Mutex<Option<Box<dyn Fn(&PanicHookInfo<'_>) + Send + Sync + 'static>>>>,
|
||||
}
|
||||
|
||||
impl TuiSession {
|
||||
pub fn ui(&self) -> Arc<UI> {
|
||||
self.ui.clone()
|
||||
}
|
||||
pub async fn shutdown(mut self) -> Option<String> {
|
||||
self.ui.request_shutdown();
|
||||
// Restore raw-mode state before waiting on cooperative tasks. A
|
||||
// misbehaving task must never leave the invoking shell unusable.
|
||||
self.restore_terminal_once();
|
||||
let renderer =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), &mut self.renderer_task).await;
|
||||
let input =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), &mut self.input_task).await;
|
||||
if renderer.is_err() {
|
||||
self.renderer_task.abort();
|
||||
}
|
||||
if input.is_err() {
|
||||
self.input_task.abort();
|
||||
}
|
||||
if let Some(task) = self.signal_task.as_mut() {
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
}
|
||||
self.restore_panic_hook();
|
||||
match renderer {
|
||||
Err(_) => Some("renderer did not stop within 2 seconds".into()),
|
||||
Ok(Err(error)) => Some(format!("renderer task failed: {error}")),
|
||||
Ok(Ok(Err(error))) => Some(error.to_string()),
|
||||
Ok(Ok(Ok(()))) => match input {
|
||||
Err(_) => Some("input handler did not stop within 2 seconds".into()),
|
||||
Ok(Err(error)) => Some(format!("input handler failed: {error}")),
|
||||
Ok(Ok(Err(error))) => Some(error),
|
||||
Ok(Ok(Ok(()))) => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
fn restore_terminal_once(&self) {
|
||||
if !self.restored.swap(true, Ordering::AcqRel) {
|
||||
ratatui::restore();
|
||||
}
|
||||
}
|
||||
fn restore_panic_hook(&self) {
|
||||
if let Some(hook) = self.previous_hook.lock().unwrap().take() {
|
||||
std::panic::set_hook(hook);
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for TuiSession {
|
||||
fn drop(&mut self) {
|
||||
self.ui.request_shutdown();
|
||||
self.renderer_task.abort();
|
||||
self.input_task.abort();
|
||||
if let Some(task) = self.signal_task.as_ref() {
|
||||
task.abort();
|
||||
}
|
||||
self.restore_panic_hook();
|
||||
self.restore_terminal_once();
|
||||
}
|
||||
}
|
||||
impl UI {
|
||||
pub fn new(ipc: Arc<IpcClient>) -> Self {
|
||||
let terminal = init();
|
||||
Self {
|
||||
ipc,
|
||||
shutdown: AtomicBool::new(false),
|
||||
pub(crate) fn new(
|
||||
ipc: Option<Arc<IpcClient>>,
|
||||
shutdown_on_empty: bool,
|
||||
theme: ResolvedTheme,
|
||||
) -> io::Result<Self> {
|
||||
let terminal = ratatui::try_init()?;
|
||||
Ok(Self {
|
||||
ipc: RwLock::new(ipc),
|
||||
shutdown_on_empty,
|
||||
cancellation: CancellationToken::new(),
|
||||
terminal: Arc::new(Mutex::new(terminal)),
|
||||
screen_stack: Arc::new(RwLock::new(Vec::new())),
|
||||
}
|
||||
theme: RwLock::new(Arc::new(theme)),
|
||||
invalidation: Notify::new(),
|
||||
failure: Arc::new(Mutex::new(None)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ipc(&self) -> Arc<IpcClient> {
|
||||
self.ipc.clone()
|
||||
pub async fn ipc(&self) -> Option<Arc<IpcClient>> {
|
||||
self.ipc.read().await.clone()
|
||||
}
|
||||
|
||||
pub fn client_state(&self) -> iota_state::ClientState {
|
||||
self.ipc.state()
|
||||
pub async fn client_state(&self) -> Option<iota_state::ClientState> {
|
||||
self.ipc.read().await.as_ref().map(|ipc| ipc.state())
|
||||
}
|
||||
|
||||
pub async fn attach_daemon(&self, ipc: Arc<IpcClient>) {
|
||||
*self.ipc.write().await = Some(ipc);
|
||||
}
|
||||
|
||||
pub async fn set_theme(&self, theme: ResolvedTheme) {
|
||||
*self.theme.write().await = Arc::new(theme);
|
||||
self.invalidate();
|
||||
}
|
||||
pub async fn theme_name(&self) -> ThemeName {
|
||||
self.theme.read().await.name
|
||||
}
|
||||
|
||||
pub fn is_shutdown(&self) -> bool {
|
||||
self.shutdown.load(Ordering::Relaxed)
|
||||
self.cancellation.is_cancelled()
|
||||
}
|
||||
|
||||
pub fn request_shutdown(&self) {
|
||||
self.shutdown.store(true, Ordering::Relaxed);
|
||||
self.cancellation.cancel();
|
||||
self.invalidate();
|
||||
}
|
||||
pub fn invalidate(&self) {
|
||||
self.invalidation.notify_one();
|
||||
}
|
||||
pub fn failure(&self) -> Option<String> {
|
||||
self.failure.lock().ok().and_then(|f| f.clone())
|
||||
}
|
||||
pub async fn handle_paste(&self, _text: String) {
|
||||
self.invalidate();
|
||||
}
|
||||
|
||||
pub async fn send_restart(&self) -> std::io::Result<()> {
|
||||
self.ipc.send_command(0, "restart".into()).await
|
||||
/// Lets bootstrap operations race their work against Ctrl+C without
|
||||
/// blocking the input task or leaving the terminal in raw mode.
|
||||
pub async fn wait_for_shutdown(&self) {
|
||||
self.cancellation.cancelled().await;
|
||||
}
|
||||
|
||||
pub fn cancellation_token(&self) -> CancellationToken {
|
||||
self.cancellation.clone()
|
||||
}
|
||||
|
||||
pub async fn set_screen(&self, screen: Box<dyn Screen>) {
|
||||
self.screen_stack.write().await.push(screen);
|
||||
self.invalidate();
|
||||
}
|
||||
pub async fn replace_screen(&self, screen: Box<dyn Screen>) {
|
||||
let mut stack = self.screen_stack.write().await;
|
||||
stack.pop();
|
||||
stack.clear();
|
||||
stack.push(screen);
|
||||
self.invalidate();
|
||||
}
|
||||
pub async fn set_root_screen(&self, screen: Box<dyn Screen>) {
|
||||
let mut stack = self.screen_stack.write().await;
|
||||
stack.clear();
|
||||
stack.push(screen);
|
||||
self.invalidate();
|
||||
}
|
||||
pub async fn handle_input(self: Arc<Self>, key_event: KeyEvent) {
|
||||
let result = {
|
||||
|
|
@ -155,30 +273,41 @@ impl UI {
|
|||
}
|
||||
InteractionResult::OpenFutureScreen { screen: fut } => {
|
||||
let ui = self.clone();
|
||||
let screen = fut.await;
|
||||
ui.set_screen(screen).await;
|
||||
tokio::select! {
|
||||
screen = fut => ui.set_screen(screen).await,
|
||||
_ = ui.cancellation.cancelled() => return,
|
||||
}
|
||||
}
|
||||
InteractionResult::CloseScreen => {
|
||||
let mut stack = self.screen_stack.write().await;
|
||||
stack.pop();
|
||||
|
||||
if stack.is_empty() {
|
||||
if stack.is_empty() && self.shutdown_on_empty {
|
||||
self.request_shutdown();
|
||||
}
|
||||
}
|
||||
InteractionResult::Handled => {}
|
||||
InteractionResult::Unhandled => {}
|
||||
}
|
||||
self.invalidate();
|
||||
}
|
||||
|
||||
pub async fn render(&self) {
|
||||
pub async fn render(&self) -> io::Result<()> {
|
||||
let theme = self.theme.read().await.clone();
|
||||
let context = RenderContext {
|
||||
theme: theme.as_ref(),
|
||||
};
|
||||
// The renderer is the only task that takes the terminal lock. Screen
|
||||
// mutations use the stack lock briefly before invalidating a frame.
|
||||
if let Some(screen) = self.screen_stack.read().await.last() {
|
||||
let mut terminal = self.terminal.lock().unwrap();
|
||||
terminal
|
||||
.draw(|f| {
|
||||
screen.render(f, f.area());
|
||||
})
|
||||
.unwrap();
|
||||
let mut terminal = self
|
||||
.terminal
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("terminal mutex poisoned"))?;
|
||||
terminal.draw(|f| {
|
||||
screen.render(f, f.area(), &context);
|
||||
})?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,55 +1,22 @@
|
|||
use ratatui::{
|
||||
layout::{Alignment, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
use crate::{
|
||||
controls::button::{
|
||||
ActionButton, ButtonIntent, button_minimum_width, horizontal_button_widths, render_button,
|
||||
},
|
||||
theme::ResolvedTheme,
|
||||
util::terms_focus::Focus,
|
||||
};
|
||||
|
||||
use crate::util::terms_focus::Focus;
|
||||
|
||||
#[allow(mismatched_lifetime_syntaxes)]
|
||||
pub fn checkbox(label: &str, checked: bool, active: bool, allowed: bool) -> Line {
|
||||
let box_char = if checked { "[x]" } else { "[ ]" };
|
||||
let (box_style, text_style) = if active {
|
||||
if allowed {
|
||||
(
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
Style::default().fg(Color::Gray),
|
||||
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
(Style::default(), Style::default())
|
||||
};
|
||||
Line::from(vec![
|
||||
Span::styled(box_char, box_style),
|
||||
Span::raw(" "),
|
||||
Span::styled(label, text_style),
|
||||
])
|
||||
}
|
||||
|
||||
pub fn draw_button(f: &mut ratatui::Frame, area: Rect, label: &str, style: Style) {
|
||||
let p = Paragraph::new(Span::styled(label, style))
|
||||
.alignment(Alignment::Center)
|
||||
.block(Block::default().borders(Borders::ALL));
|
||||
f.render_widget(p, area);
|
||||
}
|
||||
pub fn draw_buttons(
|
||||
f: &mut ratatui::Frame,
|
||||
frame: &mut ratatui::Frame,
|
||||
area: Rect,
|
||||
current_focus: Focus,
|
||||
state: (bool, bool),
|
||||
update_needed: bool,
|
||||
downgrade_scenario: bool,
|
||||
tos_or_privacy: bool,
|
||||
theme: &ResolvedTheme,
|
||||
) {
|
||||
let cancel_text = if update_needed {
|
||||
"[Q] Quit"
|
||||
|
|
@ -69,107 +36,40 @@ pub fn draw_buttons(
|
|||
buttons.push(("Continue with Tensamin Services", Focus::ContinueAll));
|
||||
}
|
||||
|
||||
let padding = 2;
|
||||
let min_widths: Vec<u16> = buttons
|
||||
let minimums = buttons
|
||||
.iter()
|
||||
.map(|(label, _)| label.len() as u16 + padding)
|
||||
.collect();
|
||||
|
||||
let widths = compute_widths(area.width, &min_widths);
|
||||
.map(|(label, _)| button_minimum_width(label))
|
||||
.collect::<Vec<_>>();
|
||||
let Some(widths) = horizontal_button_widths(area.width, &minimums) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut x = area.x;
|
||||
|
||||
for ((label, focus), width) in buttons.iter().zip(widths) {
|
||||
let chunk = Rect {
|
||||
let button_area = Rect {
|
||||
x,
|
||||
y: area.y,
|
||||
width,
|
||||
height: area.height,
|
||||
};
|
||||
x += width;
|
||||
x = x.saturating_add(width);
|
||||
|
||||
let is_focused = current_focus == *focus;
|
||||
|
||||
let style = match focus {
|
||||
Focus::Cancel => {
|
||||
if is_focused {
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(Color::Red)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::Red)
|
||||
}
|
||||
}
|
||||
|
||||
Focus::Continue => {
|
||||
if is_focused && state.0 {
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(Color::Green)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if state.0 {
|
||||
Style::default().fg(Color::Green)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
}
|
||||
}
|
||||
|
||||
Focus::ContinueAll => {
|
||||
if is_focused && state.1 {
|
||||
Style::default()
|
||||
.fg(Color::Black)
|
||||
.bg(Color::Green)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else if state.1 {
|
||||
Style::default().fg(Color::Green)
|
||||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
}
|
||||
}
|
||||
|
||||
_ => Style::default().fg(Color::DarkGray),
|
||||
let (intent, enabled) = match focus {
|
||||
Focus::Cancel => (ButtonIntent::Cancel, true),
|
||||
Focus::Continue => (ButtonIntent::Primary, state.0),
|
||||
Focus::ContinueAll => (ButtonIntent::Primary, state.1),
|
||||
_ => (ButtonIntent::Neutral, false),
|
||||
};
|
||||
|
||||
draw_button(f, chunk, label, style);
|
||||
render_button(
|
||||
frame,
|
||||
button_area,
|
||||
ActionButton {
|
||||
label,
|
||||
intent,
|
||||
focused: current_focus == *focus,
|
||||
enabled,
|
||||
},
|
||||
theme,
|
||||
);
|
||||
}
|
||||
}
|
||||
pub fn compute_widths(area_width: u16, min_widths: &[u16]) -> Vec<u16> {
|
||||
let mut widths = vec![0; min_widths.len()];
|
||||
let mut remaining: Vec<usize> = (0..min_widths.len()).collect();
|
||||
|
||||
let mut remaining_width = area_width;
|
||||
|
||||
while !remaining.is_empty() {
|
||||
let count = remaining.len() as u16;
|
||||
let equal = remaining_width / count;
|
||||
|
||||
let mut clamped = Vec::new();
|
||||
|
||||
for &i in &remaining {
|
||||
if min_widths[i] > equal {
|
||||
widths[i] = min_widths[i];
|
||||
remaining_width -= min_widths[i];
|
||||
clamped.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
if clamped.is_empty() {
|
||||
let mut remainder = remaining_width % count;
|
||||
for &i in &remaining {
|
||||
widths[i] = equal
|
||||
+ if remainder > 0 {
|
||||
remainder -= 1;
|
||||
1
|
||||
} else {
|
||||
0
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
remaining.retain(|i| !clamped.contains(i));
|
||||
}
|
||||
|
||||
widths
|
||||
}
|
||||
|
|
|
|||
15
iota-cli/tests/button_layout.rs
Normal file
15
iota-cli/tests/button_layout.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
use iota_cli::controls::button::{button_minimum_width, horizontal_button_widths};
|
||||
|
||||
#[test]
|
||||
fn width_allocation_handles_exact_spare_and_insufficient_space() {
|
||||
assert_eq!(horizontal_button_widths(7, &[3, 4]), Some(vec![3, 4]));
|
||||
assert_eq!(horizontal_button_widths(10, &[3, 4]), Some(vec![5, 5]));
|
||||
assert_eq!(horizontal_button_widths(6, &[3, 4]), None);
|
||||
assert_eq!(horizontal_button_widths(10, &[]), Some(Vec::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimum_width_uses_terminal_columns() {
|
||||
assert_eq!(button_minimum_width("é"), 3);
|
||||
assert_eq!(button_minimum_width("界"), 4);
|
||||
}
|
||||
71
iota-cli/tests/choice_rendering.rs
Normal file
71
iota-cli/tests/choice_rendering.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
use iota_cli::{
|
||||
controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line},
|
||||
theme::{ThemeName, resolve},
|
||||
};
|
||||
use ratatui::style::{Color, Modifier};
|
||||
|
||||
#[test]
|
||||
fn ansi_checkbox_matches_the_existing_focused_and_disabled_styles() {
|
||||
let theme = resolve(ThemeName::Ansi);
|
||||
let line = render_choice_line(
|
||||
"Terms",
|
||||
ChoiceKind::Checkbox,
|
||||
ChoiceVisualState {
|
||||
selected: false,
|
||||
focused: true,
|
||||
enabled: true,
|
||||
},
|
||||
&theme,
|
||||
);
|
||||
assert_eq!(
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>(),
|
||||
"[ ] Terms"
|
||||
);
|
||||
assert_eq!(line.spans[1].style.fg, Some(Color::Yellow));
|
||||
assert!(line.spans[1].style.add_modifier.contains(Modifier::BOLD));
|
||||
|
||||
let disabled = render_choice_line(
|
||||
"Terms",
|
||||
ChoiceKind::Checkbox,
|
||||
ChoiceVisualState {
|
||||
selected: false,
|
||||
focused: true,
|
||||
enabled: false,
|
||||
},
|
||||
&theme,
|
||||
);
|
||||
assert_eq!(disabled.spans[1].style.fg, Some(Color::DarkGray));
|
||||
assert_eq!(disabled.spans[3].style.fg, Some(Color::Red));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colourless_themes_keep_state_and_focus_visible() {
|
||||
for name in [ThemeName::Monospace, ThemeName::Binary] {
|
||||
let theme = resolve(name);
|
||||
let line = render_choice_line(
|
||||
"Mode",
|
||||
ChoiceKind::Radio,
|
||||
ChoiceVisualState {
|
||||
selected: true,
|
||||
focused: true,
|
||||
enabled: true,
|
||||
},
|
||||
&theme,
|
||||
);
|
||||
assert_eq!(
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>(),
|
||||
"> (x) Mode <"
|
||||
);
|
||||
assert!(
|
||||
line.spans
|
||||
.iter()
|
||||
.all(|span| span.style.fg.is_none() && span.style.bg.is_none())
|
||||
);
|
||||
}
|
||||
}
|
||||
109
iota-cli/tests/control_state.rs
Normal file
109
iota-cli/tests/control_state.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
use iota_cli::controls::{
|
||||
checkbox_group::{CheckboxChange, CheckboxGroup, CheckboxItem},
|
||||
navigation::DisabledFocusPolicy,
|
||||
radio_group::{DisabledSelectionPolicy, RadioChange, RadioGroup, RadioGroupError, RadioItem},
|
||||
};
|
||||
|
||||
fn checkbox(value: u8, enabled: bool) -> CheckboxItem<u8> {
|
||||
CheckboxItem {
|
||||
value,
|
||||
label: value.to_string(),
|
||||
description: None,
|
||||
enabled,
|
||||
disabled_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn radio(value: u8, enabled: bool) -> RadioItem<u8> {
|
||||
RadioItem {
|
||||
value,
|
||||
label: value.to_string(),
|
||||
description: None,
|
||||
enabled,
|
||||
disabled_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkbox_selection_and_disabled_focus_are_independent() {
|
||||
let mut group = CheckboxGroup::new(
|
||||
vec![checkbox(1, true), checkbox(2, false), checkbox(3, true)],
|
||||
[1, 99],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
group.selected().iter().copied().collect::<Vec<_>>(),
|
||||
vec![1]
|
||||
);
|
||||
assert_eq!(group.toggle_focused(), CheckboxChange::Deselected(1));
|
||||
group.focus_next();
|
||||
assert_eq!(group.focused_item().unwrap().value, 3);
|
||||
group.set_focus_policy(DisabledFocusPolicy::Include);
|
||||
group.focus_previous();
|
||||
assert_eq!(group.focused_item().unwrap().value, 2);
|
||||
assert_eq!(group.toggle_focused(), CheckboxChange::IgnoredDisabled(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkbox_non_wrapping_navigation_stops_at_the_edge() {
|
||||
let mut group = CheckboxGroup::new(vec![checkbox(1, true), checkbox(2, true)], []).unwrap();
|
||||
group.set_wrap_navigation(false);
|
||||
group.focus_previous();
|
||||
assert_eq!(group.focused_item().unwrap().value, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radio_validates_default_and_preserves_one_selection() {
|
||||
assert!(matches!(
|
||||
RadioGroup::new(Vec::<RadioItem<u8>>::new(), None, 1),
|
||||
Err(RadioGroupError::Empty)
|
||||
));
|
||||
assert!(matches!(
|
||||
RadioGroup::new(vec![radio(1, true)], None, 2),
|
||||
Err(RadioGroupError::DefaultMissing)
|
||||
));
|
||||
assert!(matches!(
|
||||
RadioGroup::new(vec![radio(1, false)], None, 1),
|
||||
Err(RadioGroupError::DefaultDisabled)
|
||||
));
|
||||
|
||||
let mut group = RadioGroup::new(vec![radio(1, true), radio(2, true)], Some(2), 1).unwrap();
|
||||
assert_eq!(group.selected(), &2);
|
||||
group.focus_next();
|
||||
assert_eq!(group.selected(), &2);
|
||||
assert_eq!(group.select_focused(), RadioChange::Unchanged(2));
|
||||
group.focus_previous();
|
||||
assert_eq!(
|
||||
group.select_focused(),
|
||||
RadioChange::Changed {
|
||||
previous: 2,
|
||||
selected: 1
|
||||
}
|
||||
);
|
||||
assert_eq!(group.selected(), &1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_initially_focus_the_first_enabled_item() {
|
||||
let checkboxes = CheckboxGroup::new(vec![checkbox(1, false), checkbox(2, true)], []).unwrap();
|
||||
assert_eq!(checkboxes.focused_item().unwrap().value, 2);
|
||||
let radios = RadioGroup::new(vec![radio(1, false), radio(2, true)], None, 2).unwrap();
|
||||
assert_eq!(radios.focused_item().value, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_a_selected_radio_obeys_the_configured_policy() {
|
||||
let mut group = RadioGroup::new(vec![radio(1, true), radio(2, true)], Some(2), 1).unwrap();
|
||||
group.set_enabled(&2, false).unwrap();
|
||||
assert_eq!(group.selected(), &1);
|
||||
|
||||
group.set_enabled(&2, true).unwrap();
|
||||
group.focus_next();
|
||||
group.select_focused();
|
||||
group.set_disabled_selection_policy(DisabledSelectionPolicy::ReturnError);
|
||||
assert_eq!(
|
||||
group.set_enabled(&2, false),
|
||||
Err(RadioGroupError::SelectedItemDisabled)
|
||||
);
|
||||
assert_eq!(group.selected(), &2);
|
||||
}
|
||||
45
iota-cli/tests/layout_fit.rs
Normal file
45
iota-cli/tests/layout_fit.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
use iota_cli::layout::fit::{
|
||||
FitLevel, RequiredSize, centered_rect, inset_checked, reserve_vertical, select_fit_level,
|
||||
};
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
#[test]
|
||||
fn selects_fit_by_both_dimensions() {
|
||||
let preferred = RequiredSize {
|
||||
width: 80,
|
||||
height: 20,
|
||||
};
|
||||
let compact = RequiredSize {
|
||||
width: 50,
|
||||
height: 12,
|
||||
};
|
||||
assert_eq!(
|
||||
select_fit_level(Rect::new(0, 0, 80, 20), preferred, compact),
|
||||
FitLevel::Preferred
|
||||
);
|
||||
assert_eq!(
|
||||
select_fit_level(Rect::new(0, 0, 50, 12), preferred, compact),
|
||||
FitLevel::Compact
|
||||
);
|
||||
assert_eq!(
|
||||
select_fit_level(Rect::new(0, 0, 80, 11), preferred, compact),
|
||||
FitLevel::Fallback
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangle_helpers_do_not_underflow() {
|
||||
let zero = Rect::new(4, 5, 0, 0);
|
||||
assert_eq!(
|
||||
centered_rect(
|
||||
zero,
|
||||
RequiredSize {
|
||||
width: 10,
|
||||
height: 10
|
||||
}
|
||||
),
|
||||
zero
|
||||
);
|
||||
assert_eq!(reserve_vertical(zero, 1, 0), None);
|
||||
assert_eq!(inset_checked(zero, 1, 1), None);
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
name = "iota-core"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
[dependencies]
|
||||
iota-cli = { path = "../iota-cli" }
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ pub async fn check(ui: Arc<UI>) -> Result<(bool, bool), String> {
|
|||
let mut state = ConsentState::load_state();
|
||||
|
||||
ensure_initial_consent(ui.clone(), &mut state).await?;
|
||||
if ensure_updates(ui, &mut state).await.is_err() {
|
||||
// We don't stop the program if updates fail, as long as we have initial consent
|
||||
};
|
||||
// A mandatory document update is a hard bootstrap gate. In particular,
|
||||
// refusing it must not allow service setup or daemon access to continue.
|
||||
ensure_updates(ui, &mut state).await?;
|
||||
|
||||
state = state.sanitize();
|
||||
state.save_state();
|
||||
|
|
@ -22,8 +22,23 @@ pub async fn check(ui: Arc<UI>) -> Result<(bool, bool), String> {
|
|||
Ok((state.accepted_eula, state.accepted_tos && state.accepted_pp))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum NonInteractiveConsent {
|
||||
Accepted,
|
||||
RequiresInteractiveAcceptance,
|
||||
}
|
||||
|
||||
pub fn non_interactive_consent() -> NonInteractiveConsent {
|
||||
let state = ConsentState::load_state();
|
||||
if state.accepted_eula && state.accepted_tos && state.accepted_pp {
|
||||
NonInteractiveConsent::Accepted
|
||||
} else {
|
||||
NonInteractiveConsent::RequiresInteractiveAcceptance
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_initial_consent(ui: Arc<UI>, state: &mut ConsentState) -> Result<(), String> {
|
||||
if state.accepted_eula {
|
||||
if state.accepted_eula && state.accepted_tos && state.accepted_pp {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
|
|
@ -35,7 +50,7 @@ async fn ensure_initial_consent(ui: Arc<UI>, state: &mut ConsentState) -> Result
|
|||
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
ui.set_screen(Box::new(TermsCheckerScreen::new(ui.clone(), Some(tx))))
|
||||
ui.set_screen(Box::new(TermsCheckerScreen::new(Some(tx))))
|
||||
.await;
|
||||
|
||||
let result = rx.await.unwrap_or(UserChoice::Deny);
|
||||
|
|
|
|||
1
iota-core/src/lib.rs
Normal file
1
iota-core/src/lib.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod consent_state;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,42 +1,34 @@
|
|||
use crate::DaemonRuntime;
|
||||
use iota_ipc::{
|
||||
IpcErrorCode, LocalRequest, ResponseEnvelope, ResponseResult,
|
||||
};
|
||||
use crate::{DaemonRuntime, DaemonServices};
|
||||
use iota_ipc::{ExitIntent, IpcErrorCode, LocalRequest, ResponseEnvelope, ResponseResult};
|
||||
use iota_logger::{log, log_command};
|
||||
use iota_storage::users::user_manager;
|
||||
use iota_storage::util::config_util::modify_config;
|
||||
use mtp::codec::{CommunicationType, CommunicationValue};
|
||||
use omikron_connector::omikron_connection::OMIKRON_CONNECTION;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::daemon_state::ShutdownReason;
|
||||
use crate::daemon_state::{ShutdownReason, StartupPhase};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CommandRouter {
|
||||
runtime: Arc<DaemonRuntime>,
|
||||
services: Arc<DaemonServices>,
|
||||
}
|
||||
|
||||
impl CommandRouter {
|
||||
pub fn new(runtime: Arc<DaemonRuntime>) -> Self {
|
||||
Self { runtime }
|
||||
pub fn new(runtime: Arc<DaemonRuntime>, services: Arc<DaemonServices>) -> Self {
|
||||
Self { runtime, services }
|
||||
}
|
||||
|
||||
pub async fn route(&self, request_id: u64, request: LocalRequest) -> ResponseEnvelope {
|
||||
log_command!("{:?}", request);
|
||||
let result = self.execute(request).await;
|
||||
ResponseEnvelope {
|
||||
request_id,
|
||||
result,
|
||||
}
|
||||
ResponseEnvelope { request_id, result }
|
||||
}
|
||||
|
||||
/// Parse a legacy console command string into a typed request.
|
||||
pub fn parse_console_command(line: &str) -> Option<LocalRequest> {
|
||||
let parts: Vec<&str> = line
|
||||
.trim_start_matches('/')
|
||||
.split_whitespace()
|
||||
.collect();
|
||||
let parts: Vec<&str> = line.trim_start_matches('/').split_whitespace().collect();
|
||||
match parts.as_slice() {
|
||||
["help"] => None,
|
||||
["tasks"] => Some(LocalRequest::ListTasks),
|
||||
|
|
@ -53,13 +45,33 @@ impl CommandRouter {
|
|||
["user", "list"] => Some(LocalRequest::ListUsers),
|
||||
["reconnect"] => Some(LocalRequest::ReconnectOmikron),
|
||||
["regenerate", "keys"] => Some(LocalRequest::RotateIotaIdentity),
|
||||
["reload"] | ["restart"] => Some(LocalRequest::RestartDaemon),
|
||||
["shutdown"] | ["stop"] => Some(LocalRequest::StopDaemon),
|
||||
["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit {
|
||||
intent: ExitIntent::Restart,
|
||||
}),
|
||||
["shutdown"] | ["stop"] => Some(LocalRequest::RequestProcessExit {
|
||||
intent: ExitIntent::Stop,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute(&self, request: LocalRequest) -> ResponseResult {
|
||||
let needs_omikron = matches!(
|
||||
request,
|
||||
LocalRequest::CreateUser { .. }
|
||||
| LocalRequest::RemoveUser { .. }
|
||||
| LocalRequest::ReconnectOmikron
|
||||
| LocalRequest::RotateIotaIdentity
|
||||
);
|
||||
if needs_omikron && !self.services.omikron.is_connected().await {
|
||||
return ResponseResult::Error(
|
||||
if self.runtime.current_startup_phase() != StartupPhase::Ready {
|
||||
IpcErrorCode::NotReady
|
||||
} else {
|
||||
IpcErrorCode::OmikronUnavailable
|
||||
},
|
||||
);
|
||||
}
|
||||
match request {
|
||||
LocalRequest::GetStatus => {
|
||||
let phase = self.runtime.current_startup_phase();
|
||||
|
|
@ -95,10 +107,13 @@ impl CommandRouter {
|
|||
ResponseResult::Ok(users.join("\n"))
|
||||
}
|
||||
LocalRequest::CreateUser { username } => {
|
||||
match omikron_connector::user_ops::create_user(&username).await {
|
||||
(Some(user), _) => {
|
||||
ResponseResult::Ok(format!("Created user {}", user.user_id))
|
||||
}
|
||||
match omikron_connector::user_ops::create_user(
|
||||
self.services.omikron.as_ref(),
|
||||
&username,
|
||||
)
|
||||
.await
|
||||
{
|
||||
(Some(user), _) => ResponseResult::Ok(format!("Created user {}", user.user_id)),
|
||||
_ => ResponseResult::Error(IpcErrorCode::StorageFailure),
|
||||
}
|
||||
}
|
||||
|
|
@ -109,24 +124,46 @@ impl CommandRouter {
|
|||
};
|
||||
let message = CommunicationValue::new(CommunicationType::DeleteUser)
|
||||
.with_sender(user.user_id as u64);
|
||||
if let Err(_e) = OMIKRON_CONNECTION.send_message(&message).await {
|
||||
if let Err(_e) = self.services.omikron.send_message(&message).await {
|
||||
return ResponseResult::Error(IpcErrorCode::OmikronUnavailable);
|
||||
}
|
||||
user_manager::remove_user(user.user_id);
|
||||
ResponseResult::Ok(format!("Removed user {}", user.user_id))
|
||||
}
|
||||
LocalRequest::ReconnectOmikron => {
|
||||
OMIKRON_CONNECTION.reconnect().await;
|
||||
ResponseResult::Ok("Reconnected to Omikron server".into())
|
||||
}
|
||||
LocalRequest::ReconnectOmikron => match self.services.omikron.reconnect().await {
|
||||
Ok(()) => ResponseResult::Ok("Reconnected to Omikron server".into()),
|
||||
Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable),
|
||||
},
|
||||
LocalRequest::RotateIotaIdentity => {
|
||||
modify_config(|config| {
|
||||
config.public_key = None;
|
||||
config.private_key = None;
|
||||
config.iota_id = None;
|
||||
});
|
||||
OMIKRON_CONNECTION.reconnect().await;
|
||||
ResponseResult::Ok("Key pair regenerated and Omikron reconnection requested".into())
|
||||
match self.services.omikron.reconnect().await {
|
||||
Ok(()) => ResponseResult::Ok(
|
||||
"Key pair regenerated and Omikron reconnection requested".into(),
|
||||
),
|
||||
Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable),
|
||||
}
|
||||
}
|
||||
LocalRequest::RequestProcessExit { intent } => {
|
||||
if matches!(intent, ExitIntent::Restart)
|
||||
&& !matches!(
|
||||
crate::deployment::from_environment().supervisor,
|
||||
iota_ipc::SupervisorKind::Systemd | iota_ipc::SupervisorKind::IotaUi
|
||||
)
|
||||
{
|
||||
return ResponseResult::Error(IpcErrorCode::Conflict);
|
||||
}
|
||||
self.runtime.shutdown(match intent {
|
||||
ExitIntent::Stop => ShutdownReason::Stop,
|
||||
ExitIntent::Restart => ShutdownReason::Restart,
|
||||
});
|
||||
ResponseResult::Ok("process exit accepted".into())
|
||||
}
|
||||
LocalRequest::GetDaemonStatus => {
|
||||
ResponseResult::Ok(format!("{:?}", self.runtime.snapshot()))
|
||||
}
|
||||
LocalRequest::RestartDaemon => {
|
||||
self.runtime.shutdown(ShutdownReason::Restart);
|
||||
|
|
@ -140,10 +177,12 @@ impl CommandRouter {
|
|||
}
|
||||
|
||||
pub async fn ping(&self, seconds: u64) -> Result<String, String> {
|
||||
let response = OMIKRON_CONNECTION
|
||||
let response = self
|
||||
.services
|
||||
.omikron
|
||||
.await_response(
|
||||
&CommunicationValue::new(CommunicationType::Ping),
|
||||
Some(Duration::from_secs(seconds)),
|
||||
Duration::from_secs(seconds),
|
||||
)
|
||||
.await;
|
||||
match response {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
use crate::TaskRegistry;
|
||||
use iota_ipc::StateSnapshot;
|
||||
use iota_state::DaemonState;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use sysinfo::{RefreshKind, System};
|
||||
use tokio::sync::watch;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
|
@ -58,8 +61,18 @@ pub struct DaemonRuntime {
|
|||
pub state: Arc<DaemonState>,
|
||||
pub cancellation: CancellationToken,
|
||||
pub shutdown_tx: watch::Sender<Option<ShutdownReason>>,
|
||||
shutdown_rx: watch::Receiver<Option<ShutdownReason>>,
|
||||
pub startup_phase: watch::Sender<StartupPhase>,
|
||||
pub degraded_reason: watch::Sender<Option<String>>,
|
||||
startup_phase_rx: watch::Receiver<StartupPhase>,
|
||||
degraded_reason_rx: watch::Receiver<Option<String>>,
|
||||
pub lifecycle: watch::Sender<iota_ipc::LifecyclePhase>,
|
||||
pub startup_step: watch::Sender<Option<String>>,
|
||||
pub components: watch::Sender<BTreeMap<iota_ipc::ComponentId, iota_ipc::ComponentHealth>>,
|
||||
lifecycle_rx: watch::Receiver<iota_ipc::LifecyclePhase>,
|
||||
startup_step_rx: watch::Receiver<Option<String>>,
|
||||
components_rx: watch::Receiver<BTreeMap<iota_ipc::ComponentId, iota_ipc::ComponentHealth>>,
|
||||
pub tasks: TaskRegistry,
|
||||
}
|
||||
|
||||
impl Clone for DaemonRuntime {
|
||||
|
|
@ -68,8 +81,18 @@ impl Clone for DaemonRuntime {
|
|||
state: self.state.clone(),
|
||||
cancellation: self.cancellation.clone(),
|
||||
shutdown_tx: self.shutdown_tx.clone(),
|
||||
shutdown_rx: self.shutdown_rx.clone(),
|
||||
startup_phase: self.startup_phase.clone(),
|
||||
degraded_reason: self.degraded_reason.clone(),
|
||||
startup_phase_rx: self.startup_phase_rx.clone(),
|
||||
degraded_reason_rx: self.degraded_reason_rx.clone(),
|
||||
lifecycle: self.lifecycle.clone(),
|
||||
startup_step: self.startup_step.clone(),
|
||||
components: self.components.clone(),
|
||||
lifecycle_rx: self.lifecycle_rx.clone(),
|
||||
startup_step_rx: self.startup_step_rx.clone(),
|
||||
components_rx: self.components_rx.clone(),
|
||||
tasks: self.tasks.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -82,21 +105,36 @@ impl Default for DaemonRuntime {
|
|||
|
||||
impl DaemonRuntime {
|
||||
pub fn new() -> Self {
|
||||
let (shutdown_tx, _) = watch::channel(None);
|
||||
let (startup_phase, _) = watch::channel(StartupPhase::Starting);
|
||||
let (degraded_reason, _) = watch::channel(None);
|
||||
let (shutdown_tx, shutdown_rx) = watch::channel(None);
|
||||
let (startup_phase, startup_phase_rx) = watch::channel(StartupPhase::Starting);
|
||||
let (degraded_reason, degraded_reason_rx) = watch::channel(None);
|
||||
let (lifecycle, lifecycle_rx) = watch::channel(iota_ipc::LifecyclePhase::Starting);
|
||||
let (startup_step, startup_step_rx) = watch::channel(Some("starting".to_string()));
|
||||
let (components, components_rx) = watch::channel(BTreeMap::new());
|
||||
Self {
|
||||
state: Arc::new(DaemonState::new()),
|
||||
cancellation: CancellationToken::new(),
|
||||
shutdown_tx,
|
||||
shutdown_rx,
|
||||
startup_phase,
|
||||
degraded_reason,
|
||||
startup_phase_rx,
|
||||
degraded_reason_rx,
|
||||
lifecycle,
|
||||
startup_step,
|
||||
components,
|
||||
lifecycle_rx,
|
||||
startup_step_rx,
|
||||
components_rx,
|
||||
tasks: TaskRegistry::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shutdown(&self, reason: ShutdownReason) {
|
||||
self.cancellation.cancel();
|
||||
if self.shutdown_tx.borrow().is_none() {
|
||||
let _ = self.shutdown_tx.send(Some(reason));
|
||||
self.cancellation.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shutdown_reason(&self) -> Option<ShutdownReason> {
|
||||
|
|
@ -109,6 +147,27 @@ impl DaemonRuntime {
|
|||
|
||||
pub fn set_startup_phase(&self, phase: StartupPhase) {
|
||||
let _ = self.startup_phase.send(phase);
|
||||
let (lifecycle, step) = match phase {
|
||||
StartupPhase::Ready => (iota_ipc::LifecyclePhase::Ready, None),
|
||||
StartupPhase::Stopping => (iota_ipc::LifecyclePhase::Stopping, Some("stopping".into())),
|
||||
StartupPhase::MigratingStorage => (
|
||||
iota_ipc::LifecyclePhase::Starting,
|
||||
Some("migrating_storage".into()),
|
||||
),
|
||||
StartupPhase::LoadingUsers => (
|
||||
iota_ipc::LifecyclePhase::Starting,
|
||||
Some("loading_users".into()),
|
||||
),
|
||||
StartupPhase::StartingServices => (
|
||||
iota_ipc::LifecyclePhase::Starting,
|
||||
Some("starting_services".into()),
|
||||
),
|
||||
StartupPhase::Starting | StartupPhase::Degraded => {
|
||||
(iota_ipc::LifecyclePhase::Starting, Some("starting".into()))
|
||||
}
|
||||
};
|
||||
let _ = self.lifecycle.send(lifecycle);
|
||||
let _ = self.startup_step.send(step);
|
||||
}
|
||||
|
||||
pub fn current_startup_phase(&self) -> StartupPhase {
|
||||
|
|
@ -117,7 +176,62 @@ impl DaemonRuntime {
|
|||
|
||||
pub fn mark_degraded(&self, reason: String) {
|
||||
let _ = self.degraded_reason.send(Some(reason.clone()));
|
||||
let _ = self.startup_phase.send(StartupPhase::Degraded);
|
||||
self.set_component_degraded(iota_ipc::ComponentId::Omikron, reason);
|
||||
}
|
||||
|
||||
pub fn set_component_healthy(&self, component: iota_ipc::ComponentId, message: Option<String>) {
|
||||
self.update_component(component, iota_ipc::HealthStatus::Healthy, message);
|
||||
}
|
||||
|
||||
pub fn set_component_degraded(&self, component: iota_ipc::ComponentId, message: String) {
|
||||
self.update_component(component, iota_ipc::HealthStatus::Degraded, Some(message));
|
||||
}
|
||||
|
||||
pub fn set_component_failed(&self, component: iota_ipc::ComponentId, message: String) {
|
||||
self.update_component(component, iota_ipc::HealthStatus::Failed, Some(message));
|
||||
}
|
||||
|
||||
fn update_component(
|
||||
&self,
|
||||
component: iota_ipc::ComponentId,
|
||||
status: iota_ipc::HealthStatus,
|
||||
message: Option<String>,
|
||||
) {
|
||||
let mut components = self.components.borrow().clone();
|
||||
components.insert(
|
||||
component,
|
||||
iota_ipc::ComponentHealth {
|
||||
status,
|
||||
message,
|
||||
changed_at_ms: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis(),
|
||||
},
|
||||
);
|
||||
let _ = self.components.send(components);
|
||||
}
|
||||
|
||||
pub fn overall_health(&self) -> iota_ipc::HealthStatus {
|
||||
let components = self.components.borrow();
|
||||
if [iota_ipc::ComponentId::Ipc, iota_ipc::ComponentId::Storage]
|
||||
.iter()
|
||||
.any(|id| {
|
||||
components
|
||||
.get(id)
|
||||
.is_some_and(|v| v.status == iota_ipc::HealthStatus::Failed)
|
||||
})
|
||||
{
|
||||
return iota_ipc::HealthStatus::Failed;
|
||||
}
|
||||
if components.values().any(|v| {
|
||||
v.status == iota_ipc::HealthStatus::Degraded
|
||||
|| v.status == iota_ipc::HealthStatus::Failed
|
||||
}) {
|
||||
iota_ipc::HealthStatus::Degraded
|
||||
} else {
|
||||
iota_ipc::HealthStatus::Healthy
|
||||
}
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> StateSnapshot {
|
||||
|
|
@ -133,12 +247,19 @@ impl DaemonRuntime {
|
|||
net_up: state.net_up.clone(),
|
||||
net_down: state.net_down.clone(),
|
||||
sys_info: state.sys_info.clone(),
|
||||
startup_phase: self.current_startup_phase().into(),
|
||||
degraded_reason: self.degraded_reason.borrow().clone(),
|
||||
lifecycle: *self.lifecycle.borrow(),
|
||||
startup_step: self.startup_step.borrow().clone(),
|
||||
overall_health: self.overall_health(),
|
||||
components: self.components.borrow().clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn_system_monitor(&self) {
|
||||
pub async fn spawn_system_monitor(&self) {
|
||||
let runtime = self.clone();
|
||||
tokio::spawn(async move {
|
||||
self.tasks
|
||||
.spawn_tracked("system-monitor", async move {
|
||||
runtime.state.active_tasks.insert("System monitor".into());
|
||||
let mut system = System::new_with_specifics(RefreshKind::everything());
|
||||
let mut counter = 0.0;
|
||||
|
|
@ -169,6 +290,8 @@ impl DaemonRuntime {
|
|||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
runtime.state.active_tasks.remove("System monitor");
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
37
iota-daemon-lib/src/deployment.rs
Normal file
37
iota-daemon-lib/src/deployment.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
use iota_ipc::{DeploymentMode, SupervisorKind};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct DeploymentContext {
|
||||
pub mode: DeploymentMode,
|
||||
pub supervisor: SupervisorKind,
|
||||
}
|
||||
|
||||
impl Default for DeploymentContext {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: DeploymentMode::External,
|
||||
supervisor: SupervisorKind::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_environment() -> DeploymentContext {
|
||||
let mut mode = match std::env::var("IOTA_DEPLOYMENT_MODE").ok().as_deref() {
|
||||
Some("session_child") => DeploymentMode::SessionChild,
|
||||
Some("ui_auto_start") => DeploymentMode::UiAutoStart,
|
||||
Some("user_service") => DeploymentMode::UserService,
|
||||
Some("system_socket_activated") => DeploymentMode::SystemSocketActivated,
|
||||
Some("system_always_on") => DeploymentMode::SystemAlwaysOn,
|
||||
_ => DeploymentMode::External,
|
||||
};
|
||||
if std::env::var("LISTEN_FDS").ok().as_deref() == Some("1") {
|
||||
mode = DeploymentMode::SystemSocketActivated;
|
||||
}
|
||||
let supervisor = match std::env::var("IOTA_SUPERVISOR").ok().as_deref() {
|
||||
Some("iota_ui") => SupervisorKind::IotaUi,
|
||||
Some("systemd") => SupervisorKind::Systemd,
|
||||
Some("external") => SupervisorKind::External,
|
||||
_ => SupervisorKind::None,
|
||||
};
|
||||
DeploymentContext { mode, supervisor }
|
||||
}
|
||||
|
|
@ -1,63 +1,113 @@
|
|||
use crate::{CommandRouter, DaemonRuntime};
|
||||
use crate::deployment::from_environment;
|
||||
use crate::{CommandRouter, DaemonRuntime, DaemonServices};
|
||||
use iota_ipc::{
|
||||
ClientMessage, DaemonMessage, HelloAck, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION, read_msg,
|
||||
write_msg,
|
||||
};
|
||||
use iota_logger::log;
|
||||
use std::io::Result;
|
||||
use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::{env, os::fd::FromRawFd};
|
||||
use std::{env, fs::File, os::fd::FromRawFd, os::unix::net::UnixListener as StdUnixListener};
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
use tokio::sync::{broadcast, mpsc, watch};
|
||||
use tokio::time::timeout;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Per-client outbound queue capacity.
|
||||
const CLIENT_CHANNEL_SIZE: usize = 256;
|
||||
|
||||
/// Maximum handshake retries before giving up.
|
||||
const MAX_HANDSHAKE_RETRIES: u32 = 10;
|
||||
const MAX_HANDSHAKE_RETRIES: u32 = 1;
|
||||
const CLIENT_IO_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
|
||||
|
||||
pub struct IpcServer {
|
||||
path: PathBuf,
|
||||
listener: UnixListener,
|
||||
runtime: Arc<DaemonRuntime>,
|
||||
services: Arc<DaemonServices>,
|
||||
log_tx: broadcast::Sender<DaemonMessage>,
|
||||
state_rx: watch::Sender<iota_ipc::StateSnapshot>,
|
||||
state_rx: watch::Receiver<iota_ipc::StateSnapshot>,
|
||||
instance_id: String,
|
||||
_instance_lock: File,
|
||||
}
|
||||
|
||||
impl IpcServer {
|
||||
pub fn new(
|
||||
pub async fn bind(
|
||||
path: impl Into<PathBuf>,
|
||||
runtime: Arc<DaemonRuntime>,
|
||||
services: Arc<DaemonServices>,
|
||||
log_tx: broadcast::Sender<DaemonMessage>,
|
||||
state_rx: watch::Sender<iota_ipc::StateSnapshot>,
|
||||
) -> Self {
|
||||
Self {
|
||||
path: path.into(),
|
||||
runtime,
|
||||
log_tx,
|
||||
state_rx,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(self) -> Result<()> {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
state_rx: watch::Receiver<iota_ipc::StateSnapshot>,
|
||||
) -> Result<Self> {
|
||||
let path = path.into();
|
||||
let listener = match activated_listener()? {
|
||||
Some(listener) => listener,
|
||||
None => {
|
||||
remove_stale_socket(&self.path).await?;
|
||||
UnixListener::bind(&self.path)?
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let lock_path = path.with_extension("sock.lock");
|
||||
let lock = File::options()
|
||||
.create(true)
|
||||
.mode(0o600)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(lock_path)?;
|
||||
let locked = unsafe {
|
||||
libc::flock(
|
||||
std::os::fd::AsRawFd::as_raw_fd(&lock),
|
||||
libc::LOCK_EX | libc::LOCK_NB,
|
||||
)
|
||||
} == 0;
|
||||
if !locked {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::AlreadyExists,
|
||||
"another daemon instance is already running",
|
||||
));
|
||||
}
|
||||
remove_stale_socket(&path).await?;
|
||||
let listener = UnixListener::bind(&path)?;
|
||||
let _ = tokio::fs::set_permissions(
|
||||
&path,
|
||||
std::os::unix::fs::PermissionsExt::from_mode(0o600),
|
||||
)
|
||||
.await;
|
||||
return Ok(Self {
|
||||
listener,
|
||||
runtime,
|
||||
services,
|
||||
log_tx,
|
||||
state_rx,
|
||||
instance_id: Uuid::new_v4().to_string(),
|
||||
_instance_lock: lock,
|
||||
});
|
||||
}
|
||||
};
|
||||
Ok(Self {
|
||||
listener,
|
||||
runtime,
|
||||
services,
|
||||
log_tx,
|
||||
state_rx,
|
||||
instance_id: Uuid::new_v4().to_string(),
|
||||
_instance_lock: File::options().read(true).open("/dev/null")?,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn serve(self) -> Result<()> {
|
||||
loop {
|
||||
let (stream, _addr) = listener.accept().await?;
|
||||
let (stream, _addr) = self.listener.accept().await?;
|
||||
eprintln!("IPC client accepted");
|
||||
let runtime = self.runtime.clone();
|
||||
let services = self.services.clone();
|
||||
let log_tx = self.log_tx.clone();
|
||||
let state_rx = self.state_rx.clone();
|
||||
let instance_id = self.instance_id.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = handle_client(stream, runtime, log_tx, state_rx).await {
|
||||
if let Err(error) =
|
||||
handle_client(stream, runtime, services, log_tx, state_rx, instance_id).await
|
||||
{
|
||||
eprintln!("IPC client error: {error}");
|
||||
}
|
||||
});
|
||||
|
|
@ -77,13 +127,67 @@ fn activated_listener() -> Result<Option<UnixListener>> {
|
|||
if listen_fds != Some(1) || listen_pid != Some(std::process::id()) {
|
||||
return Ok(None);
|
||||
}
|
||||
let listener = unsafe { std::os::unix::net::UnixListener::from_raw_fd(3) };
|
||||
UnixListener::from_std(listener).map(Some)
|
||||
// SAFETY: systemd transfers ownership of the activated descriptor to us.
|
||||
let listener = unsafe { StdUnixListener::from_raw_fd(3) };
|
||||
into_tokio_listener(listener).map(Some)
|
||||
}
|
||||
|
||||
fn into_tokio_listener(listener: StdUnixListener) -> Result<UnixListener> {
|
||||
listener.set_nonblocking(true)?;
|
||||
UnixListener::from_std(listener)
|
||||
}
|
||||
|
||||
async fn write_client_message<W>(writer: &mut W, message: &DaemonMessage) -> Result<()>
|
||||
where
|
||||
W: tokio::io::AsyncWrite + Unpin,
|
||||
{
|
||||
timeout(CLIENT_IO_TIMEOUT, write_msg(writer, message))
|
||||
.await
|
||||
.map_err(|_| {
|
||||
std::io::Error::new(std::io::ErrorKind::TimedOut, "IPC client write timed out")
|
||||
})?
|
||||
}
|
||||
|
||||
async fn remove_stale_socket(path: &Path) -> Result<()> {
|
||||
match tokio::fs::symlink_metadata(path).await {
|
||||
Ok(_) => tokio::fs::remove_file(path).await,
|
||||
Ok(metadata) => {
|
||||
if metadata.file_type().is_symlink() || !metadata.file_type().is_socket() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"IPC path exists but is not an owned Unix socket",
|
||||
));
|
||||
}
|
||||
if metadata.uid() != unsafe { libc::geteuid() } as u32 {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"existing IPC socket is not owned by the current user",
|
||||
));
|
||||
}
|
||||
match timeout(
|
||||
std::time::Duration::from_millis(250),
|
||||
UnixStream::connect(path),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::AddrInUse,
|
||||
"an IPC daemon is already listening",
|
||||
)),
|
||||
Ok(Err(error))
|
||||
if matches!(
|
||||
error.kind(),
|
||||
std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound
|
||||
) =>
|
||||
{
|
||||
tokio::fs::remove_file(path).await
|
||||
}
|
||||
Ok(Err(error)) => Err(error),
|
||||
Err(_) => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
"could not determine whether the existing IPC socket is active",
|
||||
)),
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
|
|
@ -93,10 +197,10 @@ async fn remove_stale_socket(path: &Path) -> Result<()> {
|
|||
struct PeerIdentity {
|
||||
pid: i32,
|
||||
uid: u32,
|
||||
gid: u32,
|
||||
_gid: u32,
|
||||
}
|
||||
|
||||
fn peer_credentials(stream: &UnixStream) -> PeerIdentity {
|
||||
fn peer_credentials(stream: &UnixStream) -> Result<PeerIdentity> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::os::unix::io::AsRawFd;
|
||||
|
|
@ -104,60 +208,98 @@ fn peer_credentials(stream: &UnixStream) -> PeerIdentity {
|
|||
let mut cred: libc::ucred = std::mem::zeroed();
|
||||
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
|
||||
let fd = stream.as_raw_fd();
|
||||
libc::getsockopt(
|
||||
if libc::getsockopt(
|
||||
fd,
|
||||
libc::SOL_SOCKET,
|
||||
libc::SO_PEERCRED,
|
||||
&mut cred as *mut _ as *mut libc::c_void,
|
||||
&mut len,
|
||||
);
|
||||
PeerIdentity {
|
||||
) != 0
|
||||
{
|
||||
return Err(std::io::Error::last_os_error());
|
||||
}
|
||||
Ok(PeerIdentity {
|
||||
pid: cred.pid,
|
||||
uid: cred.uid,
|
||||
gid: cred.gid,
|
||||
}
|
||||
_gid: cred.gid,
|
||||
})
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
PeerIdentity {
|
||||
Ok(PeerIdentity {
|
||||
pid: 0,
|
||||
uid: 0,
|
||||
gid: 0,
|
||||
}
|
||||
_gid: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_client(
|
||||
stream: UnixStream,
|
||||
runtime: Arc<DaemonRuntime>,
|
||||
services: Arc<DaemonServices>,
|
||||
log_tx: broadcast::Sender<DaemonMessage>,
|
||||
_state_rx: watch::Sender<iota_ipc::StateSnapshot>,
|
||||
mut state_rx: watch::Receiver<iota_ipc::StateSnapshot>,
|
||||
instance_id: String,
|
||||
) -> Result<()> {
|
||||
let peer = peer_credentials(&stream);
|
||||
let peer = peer_credentials(&stream)?;
|
||||
// Access control belongs to the Unix socket. The systemd socket grants
|
||||
// iota-operators group access (0660); rejecting every UID other than the
|
||||
// service account here would make that authorization ineffective. Manual
|
||||
// sockets remain owner-only (0600) at bind time.
|
||||
let (mut reader, mut writer) = stream.into_split();
|
||||
// A failed writer must stop the reader and any subsequent command work
|
||||
// for this client; otherwise the reader can remain parked forever.
|
||||
let session_cancellation = runtime.cancellation.child_token();
|
||||
let (directed_tx, directed_rx) = mpsc::channel::<DaemonMessage>(CLIENT_CHANNEL_SIZE);
|
||||
eprintln!("IPC handshake started (pid={}, uid={})", peer.pid, peer.uid);
|
||||
|
||||
// --- Handshake ---
|
||||
let mut negotiated_version: Option<u16> = None;
|
||||
for _ in 0..MAX_HANDSHAKE_RETRIES {
|
||||
match read_msg::<_, ClientMessage>(&mut reader).await {
|
||||
match timeout(
|
||||
std::time::Duration::from_secs(15),
|
||||
read_msg::<_, ClientMessage>(&mut reader),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Err(_) => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::TimedOut,
|
||||
"IPC Hello timed out",
|
||||
));
|
||||
}
|
||||
Ok(result) => match result {
|
||||
Ok(ClientMessage::Hello { supported_versions }) => {
|
||||
let version = supported_versions
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|v| *v >= MIN_PROTOCOL_VERSION && *v <= PROTOCOL_VERSION)
|
||||
.unwrap_or(PROTOCOL_VERSION);
|
||||
.filter(|v| *v >= MIN_PROTOCOL_VERSION && *v <= PROTOCOL_VERSION)
|
||||
.max()
|
||||
.ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::Unsupported,
|
||||
"No compatible IPC protocol version",
|
||||
)
|
||||
})?;
|
||||
negotiated_version = Some(version);
|
||||
let instance_id = Uuid::new_v4().to_string();
|
||||
let ack = DaemonMessage::HelloAck(HelloAck {
|
||||
protocol_version: version,
|
||||
daemon_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
instance_id,
|
||||
instance_id: instance_id.clone(),
|
||||
startup_phase: runtime.current_startup_phase().into(),
|
||||
capabilities: vec!["commands".into(), "metrics".into(), "logs".into()],
|
||||
lifecycle: *runtime.lifecycle.borrow(),
|
||||
health: runtime.overall_health(),
|
||||
deployment_mode: from_environment().mode,
|
||||
supervisor: from_environment().supervisor,
|
||||
});
|
||||
write_msg(&mut writer, &ack).await?;
|
||||
write_client_message(&mut writer, &ack).await?;
|
||||
eprintln!(
|
||||
"IPC handshake acknowledged (pid={}, uid={})",
|
||||
peer.pid, peer.uid
|
||||
);
|
||||
break;
|
||||
}
|
||||
Ok(_) => {
|
||||
|
|
@ -168,6 +310,7 @@ async fn handle_client(
|
|||
));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
let _version = negotiated_version.ok_or_else(|| {
|
||||
|
|
@ -182,9 +325,9 @@ async fn handle_client(
|
|||
|
||||
// --- Writer task: merge directed responses + shared log events ---
|
||||
let mut log_rx = log_tx.subscribe();
|
||||
let directed_for_writer = directed_tx.clone();
|
||||
let writer_task = {
|
||||
let runtime = runtime.clone();
|
||||
let session_cancellation = session_cancellation.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut directed_rx = directed_rx;
|
||||
loop {
|
||||
|
|
@ -193,7 +336,9 @@ async fn handle_client(
|
|||
msg = directed_rx.recv() => {
|
||||
match msg {
|
||||
Some(message) => {
|
||||
if write_msg(&mut writer, &message).await.is_err() {
|
||||
if let Err(error) = write_client_message(&mut writer, &message).await {
|
||||
eprintln!("IPC client writer stopped while sending directed message: {error}");
|
||||
session_cancellation.cancel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
@ -204,36 +349,91 @@ async fn handle_client(
|
|||
result = log_rx.recv() => {
|
||||
match result {
|
||||
Ok(message) => {
|
||||
if write_msg(&mut writer, &message).await.is_err() {
|
||||
if let Err(error) = write_client_message(&mut writer, &message).await {
|
||||
eprintln!("IPC client writer stopped while sending log message: {error}");
|
||||
session_cancellation.cancel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
||||
let _ = directed_for_writer.send(DaemonMessage::Gap { skipped }).await;
|
||||
// Then send current snapshot for resync
|
||||
let _ = directed_for_writer.send(
|
||||
DaemonMessage::StateUpdate(runtime.snapshot())
|
||||
).await;
|
||||
if write_client_message(&mut writer, &DaemonMessage::Gap { skipped }).await.is_err()
|
||||
|| write_client_message(&mut writer, &DaemonMessage::StateUpdate(runtime.snapshot())).await.is_err()
|
||||
{
|
||||
session_cancellation.cancel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
changed = state_rx.changed() => {
|
||||
if changed.is_err() {
|
||||
break;
|
||||
}
|
||||
let snapshot = state_rx.borrow().clone();
|
||||
if let Err(error) = write_client_message(&mut writer, &DaemonMessage::StateUpdate(snapshot)).await {
|
||||
eprintln!("IPC client writer stopped while sending state update: {error}");
|
||||
session_cancellation.cancel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// --- Reader loop ---
|
||||
let router = CommandRouter::new(runtime.clone());
|
||||
let router = CommandRouter::new(runtime.clone(), services);
|
||||
loop {
|
||||
match read_msg::<_, ClientMessage>(&mut reader).await {
|
||||
let message = tokio::select! {
|
||||
_ = session_cancellation.cancelled() => break,
|
||||
result = read_msg::<_, ClientMessage>(&mut reader) => result,
|
||||
};
|
||||
match message {
|
||||
Ok(ClientMessage::Request(envelope)) => {
|
||||
let response = router.route(envelope.request_id, envelope.request).await;
|
||||
let shutdown_reason = match &envelope.request {
|
||||
iota_ipc::LocalRequest::RequestProcessExit {
|
||||
intent: iota_ipc::ExitIntent::Restart,
|
||||
}
|
||||
| iota_ipc::LocalRequest::RestartDaemon => Some("restart requested"),
|
||||
iota_ipc::LocalRequest::RequestProcessExit {
|
||||
intent: iota_ipc::ExitIntent::Stop,
|
||||
} => Some("shutdown requested"),
|
||||
iota_ipc::LocalRequest::StopDaemon => Some("shutdown requested"),
|
||||
_ => None,
|
||||
};
|
||||
let response = if envelope.protocol_version < MIN_PROTOCOL_VERSION
|
||||
|| envelope.protocol_version > PROTOCOL_VERSION
|
||||
{
|
||||
iota_ipc::ResponseEnvelope {
|
||||
request_id: envelope.request_id,
|
||||
result: iota_ipc::ResponseResult::Error(
|
||||
iota_ipc::IpcErrorCode::UnsupportedVersion,
|
||||
),
|
||||
}
|
||||
} else {
|
||||
router.route(envelope.request_id, envelope.request).await
|
||||
};
|
||||
let _ = directed_tx.send(DaemonMessage::Response(response)).await;
|
||||
if let Some(reason) = shutdown_reason {
|
||||
let _ = directed_tx
|
||||
.send(DaemonMessage::LifecycleEvent(
|
||||
iota_ipc::LifecycleEvent::Shutdown {
|
||||
reason: reason.into(),
|
||||
},
|
||||
))
|
||||
.await;
|
||||
// The request itself initiates daemon cancellation. Give
|
||||
// the dedicated writer a chance to flush the response
|
||||
// and lifecycle event before this session is torn down.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(ClientMessage::Subscribe { .. }) => {
|
||||
let snapshot = DaemonMessage::StateUpdate(runtime.snapshot());
|
||||
let _ = directed_tx.send(snapshot).await;
|
||||
let _ = directed_tx.send(DaemonMessage::Subscribed).await;
|
||||
}
|
||||
Ok(ClientMessage::Ping { seq }) => {
|
||||
let _ = directed_tx.send(DaemonMessage::Pong { seq }).await;
|
||||
|
|
@ -250,6 +450,7 @@ async fn handle_client(
|
|||
}
|
||||
}
|
||||
}
|
||||
session_cancellation.cancel();
|
||||
writer_task.abort();
|
||||
log!(
|
||||
"IPC client disconnected (pid={}, uid={})",
|
||||
|
|
@ -258,3 +459,25 @@ async fn handle_client(
|
|||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn converted_listener_does_not_block_the_runtime() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("ipc.sock");
|
||||
let listener = match StdUnixListener::bind(path) {
|
||||
Ok(listener) => into_tokio_listener(listener).unwrap(),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => return,
|
||||
Err(error) => panic!("could not create test socket: {error}"),
|
||||
};
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(50), listener.accept())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
23
iota-daemon-lib/src/services.rs
Normal file
23
iota-daemon-lib/src/services.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
use omikron_connector::{OmikronClient, OmikronConnection};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct UserService;
|
||||
#[derive(Default)]
|
||||
pub struct ConfigService;
|
||||
|
||||
pub struct DaemonServices {
|
||||
pub omikron: Arc<dyn OmikronClient>,
|
||||
pub users: Arc<UserService>,
|
||||
pub config: Arc<ConfigService>,
|
||||
}
|
||||
|
||||
impl DaemonServices {
|
||||
pub fn new(omikron: Arc<OmikronConnection>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
omikron,
|
||||
users: Arc::new(UserService),
|
||||
config: Arc::new(ConfigService),
|
||||
})
|
||||
}
|
||||
}
|
||||
40
iota-daemon-lib/src/task_registry.rs
Normal file
40
iota-daemon-lib/src/task_registry.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct TaskRegistry {
|
||||
tasks: Arc<Mutex<JoinSet<(String, Result<(), String>)>>>,
|
||||
}
|
||||
|
||||
impl TaskRegistry {
|
||||
pub async fn spawn_tracked<F>(&self, name: impl Into<String>, future: F)
|
||||
where
|
||||
F: std::future::Future<Output = Result<(), String>> + Send + 'static,
|
||||
{
|
||||
let name = name.into();
|
||||
self.tasks
|
||||
.lock()
|
||||
.await
|
||||
.spawn(async move { (name, future.await) });
|
||||
}
|
||||
|
||||
pub async fn join_with_timeout(&self, timeout: Duration) -> Vec<String> {
|
||||
let mut tasks = self.tasks.lock().await;
|
||||
let mut failures = Vec::new();
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
while !tasks.is_empty() {
|
||||
match tokio::time::timeout_at(deadline, tasks.join_next()).await {
|
||||
Ok(Some(Ok((name, Err(error))))) => failures.push(format!("{name}: {error}")),
|
||||
Ok(Some(Ok((_, Ok(()))))) | Ok(Some(Err(_))) => {}
|
||||
Ok(None) => break,
|
||||
Err(_) => {
|
||||
tasks.abort_all();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
failures
|
||||
}
|
||||
}
|
||||
52
iota-daemon-lib/tests/command_router.rs
Normal file
52
iota-daemon-lib/tests/command_router.rs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
use async_trait::async_trait;
|
||||
use iota_daemon_lib::{CommandRouter, DaemonRuntime, DaemonServices};
|
||||
use iota_ipc::{LocalRequest, ResponseResult};
|
||||
use mtp::codec::CommunicationValue;
|
||||
use omikron_connector::{OmikronClient, OmikronError};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
struct FakeOmikron {
|
||||
reconnects: AtomicUsize,
|
||||
}
|
||||
#[async_trait]
|
||||
impl OmikronClient for FakeOmikron {
|
||||
async fn send_message(&self, _: &CommunicationValue) -> Result<(), OmikronError> {
|
||||
Ok(())
|
||||
}
|
||||
async fn await_response(
|
||||
&self,
|
||||
_: &CommunicationValue,
|
||||
_: Duration,
|
||||
) -> Result<CommunicationValue, OmikronError> {
|
||||
Err(OmikronError::Disconnected("fake".into()))
|
||||
}
|
||||
async fn reconnect(&self) -> Result<(), OmikronError> {
|
||||
self.reconnects.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
async fn is_connected(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconnect_uses_the_injected_client() {
|
||||
let fake = Arc::new(FakeOmikron {
|
||||
reconnects: AtomicUsize::new(0),
|
||||
});
|
||||
let services = Arc::new(DaemonServices {
|
||||
omikron: fake.clone(),
|
||||
users: Default::default(),
|
||||
config: Default::default(),
|
||||
});
|
||||
let router = CommandRouter::new(Arc::new(DaemonRuntime::new()), services);
|
||||
assert!(matches!(
|
||||
router.route(1, LocalRequest::ReconnectOmikron).await.result,
|
||||
ResponseResult::Ok(_)
|
||||
));
|
||||
assert_eq!(fake.reconnects.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
29
iota-daemon-lib/tests/daemon_health.rs
Normal file
29
iota-daemon-lib/tests/daemon_health.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
use iota_daemon_lib::{DaemonRuntime, StartupPhase};
|
||||
use iota_ipc::{ComponentId, HealthStatus, LifecyclePhase};
|
||||
|
||||
#[test]
|
||||
fn component_failures_are_independent_and_recovery_is_scoped() {
|
||||
let runtime = DaemonRuntime::new();
|
||||
runtime.set_component_degraded(ComponentId::Omikron, "offline".into());
|
||||
runtime.set_component_failed(ComponentId::Web, "bind failed".into());
|
||||
runtime.set_startup_phase(StartupPhase::Ready);
|
||||
let snapshot = runtime.snapshot();
|
||||
assert_eq!(snapshot.lifecycle, LifecyclePhase::Ready);
|
||||
assert_eq!(snapshot.overall_health, HealthStatus::Degraded);
|
||||
assert_eq!(
|
||||
snapshot.components[&ComponentId::Omikron].status,
|
||||
HealthStatus::Degraded
|
||||
);
|
||||
runtime.set_component_healthy(ComponentId::Web, None);
|
||||
assert_eq!(
|
||||
runtime.snapshot().components[&ComponentId::Omikron].status,
|
||||
HealthStatus::Degraded
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn critical_failure_is_failed_but_optional_degradation_is_not() {
|
||||
let runtime = DaemonRuntime::new();
|
||||
runtime.set_component_failed(ComponentId::Storage, "database unavailable".into());
|
||||
assert_eq!(runtime.snapshot().overall_health, HealthStatus::Failed);
|
||||
}
|
||||
40
iota-daemon-lib/tests/shutdown.rs
Normal file
40
iota-daemon-lib/tests/shutdown.rs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
use iota_daemon_lib::{DaemonRuntime, ShutdownReason};
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::test]
|
||||
async fn shutdown_reason_is_first_write_wins_and_tasks_join() {
|
||||
let runtime = DaemonRuntime::new();
|
||||
runtime.shutdown(ShutdownReason::Fatal("first".into()));
|
||||
runtime.shutdown(ShutdownReason::Restart);
|
||||
assert_eq!(
|
||||
runtime.shutdown_reason(),
|
||||
Some(ShutdownReason::Fatal("first".into()))
|
||||
);
|
||||
runtime.tasks.spawn_tracked("quick", async { Ok(()) }).await;
|
||||
assert!(
|
||||
runtime
|
||||
.tasks
|
||||
.join_with_timeout(Duration::from_millis(100))
|
||||
.await
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn long_task_is_aborted_at_join_timeout() {
|
||||
let runtime = DaemonRuntime::new();
|
||||
runtime
|
||||
.tasks
|
||||
.spawn_tracked("slow", async {
|
||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
runtime
|
||||
.tasks
|
||||
.join_with_timeout(Duration::from_millis(10))
|
||||
.await
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -1,55 +1,108 @@
|
|||
use iota_daemon_lib::{DaemonRuntime, IpcServer, ShutdownReason, StartupPhase, log_broadcaster};
|
||||
use iota_logger::{self as logger, log, log_t};
|
||||
use iota_daemon_lib::{
|
||||
DaemonRuntime, DaemonServices, IpcServer, ShutdownReason, StartupPhase, log_broadcaster,
|
||||
};
|
||||
use iota_logger::{self as logger, log};
|
||||
use iota_storage::users::user_manager;
|
||||
use iota_storage::util::config_util::CONFIG;
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{broadcast, watch};
|
||||
fn socket_path() -> PathBuf {
|
||||
std::env::var_os("IOTA_SOCKET")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("/run/iota/iota.sock"))
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "multi_thread")]
|
||||
async fn main() {
|
||||
async fn main() -> ExitCode {
|
||||
logger::startup();
|
||||
iota_storage::util::config_util::load_config();
|
||||
|
||||
let runtime = Arc::new(DaemonRuntime::new());
|
||||
runtime.set_startup_phase(StartupPhase::LoadingUsers);
|
||||
|
||||
if user_manager::load_users().await.is_err() {
|
||||
log_t!("user_load_failed");
|
||||
}
|
||||
|
||||
// --- IPC infrastructure ---
|
||||
let (log_tx, _) = broadcast::channel(512);
|
||||
log_broadcaster::spawn(log_tx.clone());
|
||||
let (state_tx, _state_rx) = watch::channel(iota_ipc::StateSnapshot::default());
|
||||
let (state_tx, state_rx) = watch::channel(runtime.snapshot());
|
||||
|
||||
// --- Start IPC server early (before services) so clients can see startup phases ---
|
||||
runtime.set_startup_phase(StartupPhase::StartingServices);
|
||||
let ipc_server = IpcServer::new(
|
||||
socket_path(),
|
||||
runtime.clone(),
|
||||
log_tx.clone(),
|
||||
state_tx.clone(),
|
||||
runtime.set_startup_phase(StartupPhase::LoadingUsers);
|
||||
if tokio::task::spawn_blocking(user_manager::load_users_sync)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(Result::ok)
|
||||
.is_none()
|
||||
{
|
||||
runtime.set_component_failed(
|
||||
iota_ipc::ComponentId::Storage,
|
||||
"user storage failed to load".into(),
|
||||
);
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = ipc_server.run().await {
|
||||
eprintln!("iota-daemon IPC server failed: {error}");
|
||||
} else {
|
||||
runtime.set_component_healthy(iota_ipc::ComponentId::Storage, None);
|
||||
}
|
||||
});
|
||||
log!("iota-daemon IPC server started");
|
||||
|
||||
// Bind before migration and service startup: a successful bind is the
|
||||
// readiness boundary visible to clients and socket activation.
|
||||
let socket = iota_paths::socket_path(iota_paths::SocketScope::User);
|
||||
let omikron = match omikron_connector::omikron_connection::connect_initial(
|
||||
runtime.cancellation.clone(),
|
||||
runtime.state.active_tasks.clone(),
|
||||
runtime.state.app.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(connection) => connection,
|
||||
Err(omikron_connector::OmikronStartupError::InitialConnectionTimeout { connection }) => {
|
||||
runtime.set_component_degraded(
|
||||
iota_ipc::ComponentId::Omikron,
|
||||
"Omikron connection unavailable; retrying".into(),
|
||||
);
|
||||
connection
|
||||
}
|
||||
Err(omikron_connector::OmikronStartupError::Authentication) => {
|
||||
runtime.set_component_failed(
|
||||
iota_ipc::ComponentId::Omikron,
|
||||
"Omikron authentication failed".into(),
|
||||
);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
Err(omikron_connector::OmikronStartupError::Construction(error)) => {
|
||||
eprintln!("Cannot construct Omikron connection: {error}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
let services = DaemonServices::new(omikron);
|
||||
let ipc_server =
|
||||
match IpcServer::bind(socket, runtime.clone(), services, log_tx.clone(), state_rx).await {
|
||||
Ok(server) => server,
|
||||
Err(error) => {
|
||||
eprintln!("Cannot bind daemon IPC socket: {error}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
};
|
||||
eprintln!(
|
||||
"iota-daemon IPC listener ready at {}",
|
||||
iota_paths::socket_path(iota_paths::SocketScope::User).display()
|
||||
);
|
||||
runtime.set_component_healthy(iota_ipc::ComponentId::Ipc, None);
|
||||
let listener_runtime = runtime.clone();
|
||||
runtime
|
||||
.tasks
|
||||
.spawn_tracked("ipc-server", async move {
|
||||
if let Err(error) = ipc_server.serve().await {
|
||||
eprintln!("iota-daemon IPC server failed: {error}");
|
||||
listener_runtime.shutdown(ShutdownReason::Fatal(format!(
|
||||
"IPC listener stopped: {error}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
log!("iota-daemon IPC server ready");
|
||||
|
||||
runtime.set_startup_phase(StartupPhase::StartingServices);
|
||||
|
||||
// --- System monitor ---
|
||||
runtime.spawn_system_monitor();
|
||||
runtime.spawn_system_monitor().await;
|
||||
|
||||
// --- State update publisher (watch-based, no full broadcast per tick) ---
|
||||
let state_publisher = runtime.clone();
|
||||
tokio::spawn(async move {
|
||||
runtime
|
||||
.tasks
|
||||
.spawn_tracked("state-publisher", async move {
|
||||
loop {
|
||||
if state_publisher.is_shutting_down() {
|
||||
break;
|
||||
|
|
@ -58,37 +111,88 @@ async fn main() {
|
|||
let _ = state_tx.send(snapshot);
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
|
||||
// --- Web server ---
|
||||
let port = CONFIG.load().port;
|
||||
if !web_server::start(port, runtime.cancellation.clone()).await {
|
||||
log!("Failed to start the MTP web server on port {}", port);
|
||||
runtime.mark_degraded("MTP web server failed to start".into());
|
||||
let web = CONFIG.load().web.clone();
|
||||
let web_config = web_server::WebConfig {
|
||||
mode: match web.mode {
|
||||
iota_storage::util::config_util::WebMode::Disabled => web_server::WebMode::Disabled,
|
||||
iota_storage::util::config_util::WebMode::Loopback => web_server::WebMode::Loopback,
|
||||
iota_storage::util::config_util::WebMode::Network => web_server::WebMode::Network,
|
||||
},
|
||||
bind: web
|
||||
.bind
|
||||
.parse()
|
||||
.unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)),
|
||||
port: web.port,
|
||||
asset_dir: std::path::PathBuf::from(web.asset_dir),
|
||||
tls: web
|
||||
.certificate
|
||||
.zip(web.key)
|
||||
.map(|(certificate, key)| web_server::TlsConfig {
|
||||
certificate: certificate.into(),
|
||||
key: key.into(),
|
||||
}),
|
||||
required: web.required,
|
||||
};
|
||||
match web_server::start(web_config, runtime.cancellation.clone()).await {
|
||||
Ok(None) => {
|
||||
runtime.set_component_healthy(iota_ipc::ComponentId::Web, Some("disabled".into()))
|
||||
}
|
||||
|
||||
// --- Omikron connection ---
|
||||
let omikron_result =
|
||||
omikron_connector::omikron_connection::get_omikron_connection(runtime.cancellation.clone())
|
||||
Ok(Some(handle)) => {
|
||||
runtime.set_component_healthy(iota_ipc::ComponentId::Web, None);
|
||||
runtime
|
||||
.tasks
|
||||
.spawn_tracked("web-server", async move {
|
||||
handle.join().await;
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
if omikron_result.is_none() {
|
||||
runtime.mark_degraded("Omikron connection unavailable".into());
|
||||
}
|
||||
Err(error) if web.required => {
|
||||
runtime.set_component_failed(iota_ipc::ComponentId::Web, error.to_string());
|
||||
}
|
||||
Err(error) => {
|
||||
runtime.set_component_degraded(iota_ipc::ComponentId::Web, error.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
runtime.set_startup_phase(StartupPhase::Ready);
|
||||
log!("iota-daemon started (phase: Ready)");
|
||||
|
||||
// --- Main lifecycle loop ---
|
||||
runtime.cancellation.cancelled().await;
|
||||
let signal = async {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let mut term =
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
.expect("SIGTERM handler");
|
||||
tokio::select! { _ = tokio::signal::ctrl_c() => ShutdownReason::Stop, _ = term.recv() => ShutdownReason::Stop }
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
ShutdownReason::Stop
|
||||
}
|
||||
};
|
||||
tokio::select! {
|
||||
_ = runtime.cancellation.cancelled() => {},
|
||||
reason = signal => runtime.shutdown(reason),
|
||||
}
|
||||
|
||||
let reason = runtime.shutdown_reason().unwrap_or(ShutdownReason::Stop);
|
||||
log!("iota-daemon shutting down (reason: {:?})", reason);
|
||||
runtime.set_startup_phase(StartupPhase::Stopping);
|
||||
|
||||
// Wait a moment for in-flight operations to complete
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
let _ = runtime
|
||||
.tasks
|
||||
.join_with_timeout(Duration::from_secs(5))
|
||||
.await;
|
||||
|
||||
let exit_code = reason.exit_code();
|
||||
log!("iota-daemon exited (code: {})", exit_code);
|
||||
std::process::exit(exit_code);
|
||||
ExitCode::from(exit_code as u8)
|
||||
}
|
||||
|
|
|
|||
11
iota-installer/Cargo.toml
Normal file
11
iota-installer/Cargo.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
[package]
|
||||
name = "iota-installer"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
tempfile = "3"
|
||||
zip = "6"
|
||||
serde_json = "1"
|
||||
iota-paths = { path = "../iota-paths" }
|
||||
187
iota-installer/src/lib.rs
Normal file
187
iota-installer/src/lib.rs
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
use anyhow::{Context, Result, bail};
|
||||
use std::{fs, io, path::Path, process::Command};
|
||||
use tempfile::tempdir;
|
||||
use zip::ZipArchive;
|
||||
|
||||
const REQUIRED: &[&str] = &[
|
||||
"bin/iota",
|
||||
"bin/iota-daemon",
|
||||
"bin/iota-updater",
|
||||
"systemd/iota-daemon.service",
|
||||
"systemd/iota-daemon.socket",
|
||||
"systemd/sysusers.d/iota.conf",
|
||||
"systemd/iota-update.service",
|
||||
"systemd/iota-update.timer",
|
||||
"manifest.json",
|
||||
];
|
||||
|
||||
pub fn install_linux_bundle(bundle: &Path) -> Result<()> {
|
||||
install_linux_bundle_with_operator(bundle, None)
|
||||
}
|
||||
|
||||
pub fn install_linux_bundle_with_operator(bundle: &Path, operator: Option<&str>) -> Result<()> {
|
||||
if std::env::consts::OS != "linux" {
|
||||
bail!("Linux systemd bundles are not supported on this platform");
|
||||
}
|
||||
let staging = tempdir().context("create installer staging directory")?;
|
||||
let file = fs::File::open(bundle).context("open release bundle")?;
|
||||
let mut archive = ZipArchive::new(file).context("read release bundle")?;
|
||||
for name in REQUIRED {
|
||||
let mut entry = archive
|
||||
.by_name(name)
|
||||
.with_context(|| format!("bundle is missing {name}"))?;
|
||||
let output = staging.path().join(name);
|
||||
if let Some(parent) = output.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut out = fs::File::create(&output)?;
|
||||
io::copy(&mut entry, &mut out)?;
|
||||
}
|
||||
|
||||
install(
|
||||
&staging.path().join("bin/iota"),
|
||||
&format!(
|
||||
"{}/versions/{}/bin/iota",
|
||||
iota_paths::install_root().display(),
|
||||
product_version(staging.path())
|
||||
),
|
||||
"0755",
|
||||
)?;
|
||||
install(
|
||||
&staging.path().join("bin/iota-daemon"),
|
||||
&format!(
|
||||
"{}/versions/{}/bin/iota-daemon",
|
||||
iota_paths::install_root().display(),
|
||||
product_version(staging.path())
|
||||
),
|
||||
"0755",
|
||||
)?;
|
||||
let version_dir = format!(
|
||||
"{}/versions/{}",
|
||||
iota_paths::install_root().display(),
|
||||
product_version(staging.path())
|
||||
);
|
||||
if !Path::new(&format!("{version_dir}/bin/iota-daemon")).is_file() {
|
||||
bail!("installed daemon executable is missing: {version_dir}/bin/iota-daemon");
|
||||
}
|
||||
install(
|
||||
&staging.path().join("bin/iota-updater"),
|
||||
&format!(
|
||||
"{}/versions/{}/bin/iota-updater",
|
||||
iota_paths::install_root().display(),
|
||||
product_version(staging.path())
|
||||
),
|
||||
"0755",
|
||||
)?;
|
||||
for unit in [
|
||||
"iota-daemon.service",
|
||||
"iota-daemon.socket",
|
||||
"iota-update.service",
|
||||
"iota-update.timer",
|
||||
] {
|
||||
install(
|
||||
&staging.path().join("systemd").join(unit),
|
||||
&format!("/etc/systemd/system/{unit}"),
|
||||
"0644",
|
||||
)?;
|
||||
}
|
||||
install(
|
||||
&staging.path().join("systemd/sysusers.d/iota.conf"),
|
||||
"/etc/sysusers.d/iota.conf",
|
||||
"0644",
|
||||
)?;
|
||||
run(
|
||||
"ln",
|
||||
&[
|
||||
"-sfn",
|
||||
&version_dir,
|
||||
&iota_paths::current_version_link().to_string_lossy(),
|
||||
],
|
||||
)?;
|
||||
run(
|
||||
"ln",
|
||||
&[
|
||||
"-sfn",
|
||||
&format!("{}/current/bin/iota", iota_paths::install_root().display()),
|
||||
"/usr/local/bin/iota",
|
||||
],
|
||||
)?;
|
||||
run(
|
||||
"ln",
|
||||
&[
|
||||
"-sfn",
|
||||
&format!(
|
||||
"{}/current/bin/iota-daemon",
|
||||
iota_paths::install_root().display()
|
||||
),
|
||||
"/usr/local/lib/iota/iota-daemon",
|
||||
],
|
||||
)?;
|
||||
run("systemd-sysusers", &[])?;
|
||||
if let Some(operator) = operator {
|
||||
run("usermod", &["-aG", "iota-operators", operator])?;
|
||||
} else {
|
||||
eprintln!("To grant socket access, run: usermod -aG iota-operators USER");
|
||||
eprintln!(
|
||||
"A new login session is required before supplementary group membership is visible."
|
||||
);
|
||||
}
|
||||
run("systemctl", &["daemon-reload"])?;
|
||||
run("systemctl", &["enable", "--now", "iota-daemon.socket"])?;
|
||||
run("systemctl", &["is-active", "iota-daemon.socket"])?;
|
||||
run("systemctl", &["is-enabled", "iota-daemon.socket"])?;
|
||||
if !Path::new("/run/iota/iota.sock").exists() {
|
||||
bail!("systemd socket is active but /run/iota/iota.sock was not created");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn product_version(staging: &Path) -> String {
|
||||
fs::read_to_string(staging.join("manifest.json"))
|
||||
.ok()
|
||||
.and_then(|value| serde_json::from_str::<serde_json::Value>(&value).ok())
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get("product_version")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_owned)
|
||||
})
|
||||
.unwrap_or_else(|| "unversioned".into())
|
||||
}
|
||||
|
||||
fn install(source: &Path, destination: &str, mode: &str) -> Result<()> {
|
||||
run(
|
||||
"install",
|
||||
&["-D", "-m", mode, &source.to_string_lossy(), destination],
|
||||
)
|
||||
}
|
||||
|
||||
fn run(program: &str, args: &[&str]) -> Result<()> {
|
||||
let status = Command::new(program)
|
||||
.args(args)
|
||||
.status()
|
||||
.with_context(|| format!("run {program}"))?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
bail!("{program} failed; run the installer as root")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn service_uses_installed_daemon_and_declared_identities() {
|
||||
let service = include_str!("../../systemd/iota-daemon.service");
|
||||
let socket = include_str!("../../systemd/iota-daemon.socket");
|
||||
let sysusers = include_str!("../../systemd/sysusers.d/iota.conf");
|
||||
assert!(service.contains("ExecStart=/usr/local/lib/iota/iota-daemon"));
|
||||
assert!(service.contains("User=iota"));
|
||||
assert!(service.contains("Group=iota"));
|
||||
assert!(socket.contains("SocketUser=iota"));
|
||||
assert!(socket.contains("SocketGroup=iota-operators"));
|
||||
assert!(socket.contains("NonBlocking=true"));
|
||||
assert!(sysusers.contains("u iota "));
|
||||
assert!(sysusers.contains("g iota-operators"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,17 @@ use serde::{Deserialize, Serialize};
|
|||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
|
||||
pub enum ClientMessage {
|
||||
Hello { supported_versions: Vec<u16> },
|
||||
Subscribe { log_classes: Vec<String>, metric_interval_ms: Option<u64> },
|
||||
Hello {
|
||||
supported_versions: Vec<u16>,
|
||||
},
|
||||
Subscribe {
|
||||
log_classes: Vec<String>,
|
||||
metric_interval_ms: Option<u64>,
|
||||
},
|
||||
Request(RequestEnvelope),
|
||||
Ping { seq: u64 },
|
||||
Ping {
|
||||
seq: u64,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
|
|
@ -26,14 +33,31 @@ pub enum LocalRequest {
|
|||
GetStatus,
|
||||
ListTasks,
|
||||
ListUsers,
|
||||
CreateUser { username: String },
|
||||
RemoveUser { user_id: i64 },
|
||||
CreateUser {
|
||||
username: String,
|
||||
},
|
||||
RemoveUser {
|
||||
user_id: i64,
|
||||
},
|
||||
ReconnectOmikron,
|
||||
RotateIotaIdentity,
|
||||
RequestProcessExit {
|
||||
intent: ExitIntent,
|
||||
},
|
||||
GetDaemonStatus,
|
||||
#[serde(skip)]
|
||||
RestartDaemon,
|
||||
#[serde(skip)]
|
||||
StopDaemon,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExitIntent {
|
||||
Stop,
|
||||
Restart,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Daemon → Client
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -42,13 +66,19 @@ pub enum LocalRequest {
|
|||
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
|
||||
pub enum DaemonMessage {
|
||||
HelloAck(HelloAck),
|
||||
/// Confirms that the server has installed this connection's subscription.
|
||||
Subscribed,
|
||||
LogEntry(LogEntry),
|
||||
StateUpdate(StateSnapshot),
|
||||
MetricSample(MetricSample),
|
||||
Response(ResponseEnvelope),
|
||||
Pong { seq: u64 },
|
||||
Pong {
|
||||
seq: u64,
|
||||
},
|
||||
LifecycleEvent(LifecycleEvent),
|
||||
Gap { skipped: u64 },
|
||||
Gap {
|
||||
skipped: u64,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
|
|
@ -58,6 +88,14 @@ pub struct HelloAck {
|
|||
pub instance_id: String,
|
||||
pub startup_phase: StartupPhase,
|
||||
pub capabilities: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub lifecycle: LifecyclePhase,
|
||||
#[serde(default)]
|
||||
pub health: HealthStatus,
|
||||
#[serde(default)]
|
||||
pub deployment_mode: DeploymentMode,
|
||||
#[serde(default)]
|
||||
pub supervisor: SupervisorKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
|
|
@ -84,6 +122,10 @@ pub enum IpcErrorCode {
|
|||
UnsupportedVersion,
|
||||
NotReady,
|
||||
Disconnected,
|
||||
Timeout,
|
||||
Cancelled,
|
||||
Unauthorized,
|
||||
InternalFailure,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
|
|
@ -114,6 +156,69 @@ pub enum StartupPhase {
|
|||
Stopping,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LifecyclePhase {
|
||||
#[default]
|
||||
Starting,
|
||||
Ready,
|
||||
Stopping,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HealthStatus {
|
||||
#[default]
|
||||
Healthy,
|
||||
Degraded,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ComponentId {
|
||||
Storage,
|
||||
Ipc,
|
||||
Omikron,
|
||||
Web,
|
||||
Updater,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DeploymentMode {
|
||||
SessionChild,
|
||||
UiAutoStart,
|
||||
UserService,
|
||||
SystemSocketActivated,
|
||||
SystemAlwaysOn,
|
||||
#[default]
|
||||
External,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SupervisorKind {
|
||||
#[default]
|
||||
None,
|
||||
IotaUi,
|
||||
Systemd,
|
||||
External,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ComponentHealth {
|
||||
pub status: HealthStatus,
|
||||
pub message: Option<String>,
|
||||
pub changed_at_ms: u128,
|
||||
}
|
||||
|
||||
impl Default for StartupPhase {
|
||||
fn default() -> Self {
|
||||
Self::Starting
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -134,6 +239,18 @@ pub struct StateSnapshot {
|
|||
pub net_up: Vec<(f64, f64)>,
|
||||
pub net_down: Vec<(f64, f64)>,
|
||||
pub sys_info: String,
|
||||
#[serde(default)]
|
||||
pub startup_phase: StartupPhase,
|
||||
#[serde(default)]
|
||||
pub degraded_reason: Option<String>,
|
||||
#[serde(default)]
|
||||
pub lifecycle: LifecyclePhase,
|
||||
#[serde(default)]
|
||||
pub startup_step: Option<String>,
|
||||
#[serde(default)]
|
||||
pub overall_health: HealthStatus,
|
||||
#[serde(default)]
|
||||
pub components: std::collections::BTreeMap<ComponentId, ComponentHealth>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
|
|
|
|||
|
|
@ -59,42 +59,52 @@ impl LanguagePack {
|
|||
}
|
||||
|
||||
pub fn load_language(&mut self, language: &str) {
|
||||
let path = format!("languages/{}/", language);
|
||||
let files = [
|
||||
"frontend.json",
|
||||
"omikron.json",
|
||||
"buttons.json",
|
||||
"debug.json",
|
||||
"general.json",
|
||||
];
|
||||
|
||||
let frontend_messages = file_util::load_file(&path, "frontend.json");
|
||||
let frontend_messages = parse(&frontend_messages).unwrap();
|
||||
for (key, value) in frontend_messages.entries() {
|
||||
self.language
|
||||
.insert(key.to_string(), value.as_str().unwrap().to_string());
|
||||
// The daemon can initialize the logger before iota-core has created
|
||||
// the generated language files. Create the built-in pack on demand.
|
||||
if language == "en_INT"
|
||||
&& files
|
||||
.iter()
|
||||
.any(|file| !file_util::has_file(&format!("languages/{language}/"), file))
|
||||
{
|
||||
let _ = crate::language_creator::create_languages();
|
||||
}
|
||||
|
||||
let omikron_messages = file_util::load_file(&path, "omikron.json");
|
||||
let omikron_messages = parse(&omikron_messages).unwrap();
|
||||
for (key, value) in omikron_messages.entries() {
|
||||
self.language
|
||||
.insert(key.to_string(), value.as_str().unwrap().to_string());
|
||||
let loaded = files
|
||||
.iter()
|
||||
.all(|file| self.load_file(&format!("languages/{language}/"), file));
|
||||
|
||||
// A process may have been interrupted while an older version was
|
||||
// writing a language file. Regenerate the default pack once in that
|
||||
// case, and still leave custom language packs non-fatal.
|
||||
if !loaded && language == "en_INT" {
|
||||
self.language.clear();
|
||||
let _ = crate::language_creator::create_languages();
|
||||
for file in files {
|
||||
let _ = self.load_file(&format!("languages/{language}/"), file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let button_texts = file_util::load_file(&path, "buttons.json");
|
||||
let button_texts = parse(&button_texts).unwrap();
|
||||
for (key, value) in button_texts.entries() {
|
||||
self.language
|
||||
.insert(key.to_string(), value.as_str().unwrap().to_string());
|
||||
}
|
||||
fn load_file(&mut self, path: &str, file: &str) -> bool {
|
||||
let contents = file_util::load_file(path, file);
|
||||
let Ok(messages) = parse(&contents) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let debug_messages = file_util::load_file(&path, "debug.json");
|
||||
let debug_messages = parse(&debug_messages).unwrap();
|
||||
for (key, value) in debug_messages.entries() {
|
||||
self.language
|
||||
.insert(key.to_string(), value.as_str().unwrap().to_string());
|
||||
for (key, value) in messages.entries() {
|
||||
if let Some(value) = value.as_str() {
|
||||
self.language.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
|
||||
let general_messages = file_util::load_file(&path, "general.json");
|
||||
let general_messages = parse(&general_messages).unwrap();
|
||||
for (key, value) in general_messages.entries() {
|
||||
self.language
|
||||
.insert(key.to_string(), value.as_str().unwrap().to_string());
|
||||
}
|
||||
true
|
||||
}
|
||||
pub fn get_translation(&self, key: &str) -> String {
|
||||
match self.language.get(key) {
|
||||
|
|
|
|||
|
|
@ -97,15 +97,17 @@ pub fn startup() {
|
|||
format!("{} ", msg.prefix)
|
||||
};
|
||||
|
||||
let _ = writeln!(
|
||||
file,
|
||||
let line1 = format!(
|
||||
"{} {}{}",
|
||||
fixed_box(&msg.timestamp_ms.to_string(), 13),
|
||||
prefix,
|
||||
resolved_message
|
||||
);
|
||||
let line2 = format!(" {}", timestamp);
|
||||
|
||||
let _ = writeln!(file, " {}", timestamp);
|
||||
let _ = writeln!(file, "{}\n{}", line1, line2);
|
||||
|
||||
let _ = writeln!(std::io::stderr(), "{}\n{}", line1, line2);
|
||||
|
||||
let entry = UiLogEntry {
|
||||
timestamp_ms: msg.timestamp_ms,
|
||||
|
|
|
|||
4
iota-paths/Cargo.toml
Normal file
4
iota-paths/Cargo.toml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
[package]
|
||||
name = "iota-paths"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
156
iota-paths/src/lib.rs
Normal file
156
iota-paths/src/lib.rs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SocketScope {
|
||||
User,
|
||||
System,
|
||||
}
|
||||
|
||||
fn home_dir() -> PathBuf {
|
||||
std::env::var_os("HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
pub fn data_dir() -> PathBuf {
|
||||
if let Some(path) = std::env::var_os("IOTA_DATA_DIR") {
|
||||
return PathBuf::from(path);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
return std::env::var_os("XDG_STATE_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| home_dir().join(".local/state"))
|
||||
.join("iota");
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
return home_dir().join("Library/Application Support/Iota");
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
return std::env::var_os("LOCALAPPDATA")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(home_dir)
|
||||
.join("Tensamin/Iota");
|
||||
}
|
||||
#[allow(unreachable_code)]
|
||||
home_dir().join(".iota")
|
||||
}
|
||||
pub fn config_dir() -> PathBuf {
|
||||
if let Some(path) = std::env::var_os("IOTA_CONFIG_DIR") {
|
||||
return PathBuf::from(path);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
return std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| home_dir().join(".config"))
|
||||
.join("iota");
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
return home_dir().join("Library/Application Support/Iota");
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
return std::env::var_os("APPDATA")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(home_dir)
|
||||
.join("Tensamin/Iota");
|
||||
}
|
||||
#[allow(unreachable_code)]
|
||||
home_dir().join(".iota")
|
||||
}
|
||||
|
||||
pub fn socket_override() -> Option<PathBuf> {
|
||||
std::env::var_os("IOTA_SOCKET").map(PathBuf::from)
|
||||
}
|
||||
|
||||
pub fn socket_path(scope: SocketScope) -> PathBuf {
|
||||
if let Some(path) = socket_override() {
|
||||
return path;
|
||||
}
|
||||
match scope {
|
||||
SocketScope::User => data_dir().join("iota.sock"),
|
||||
SocketScope::System => PathBuf::from("/run/iota/iota.sock"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn socket_lock_path(scope: SocketScope) -> PathBuf {
|
||||
let socket = socket_path(scope);
|
||||
PathBuf::from(format!("{}.lock", socket.display()))
|
||||
}
|
||||
|
||||
pub fn daemon_executable() -> PathBuf {
|
||||
if let Some(path) = std::env::var_os("IOTA_DAEMON_PATH") {
|
||||
return PathBuf::from(path);
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(path) = exe.parent().map(|p| p.join("iota-daemon")) {
|
||||
if path.is_file() {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let installed = PathBuf::from("/usr/local/lib/iota/iota-daemon");
|
||||
if installed.is_file() {
|
||||
return installed;
|
||||
}
|
||||
}
|
||||
PathBuf::from("iota-daemon")
|
||||
}
|
||||
|
||||
pub fn updater_executable() -> PathBuf {
|
||||
std::env::var_os("IOTA_UPDATER_PATH")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("iota-updater"))
|
||||
}
|
||||
pub fn install_root() -> PathBuf {
|
||||
std::env::var_os("IOTA_INSTALL_ROOT")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("/usr/local/lib/iota"))
|
||||
}
|
||||
pub fn versions_dir() -> PathBuf {
|
||||
install_root().join("versions")
|
||||
}
|
||||
pub fn current_version_link() -> PathBuf {
|
||||
install_root().join("current")
|
||||
}
|
||||
pub fn updater_lock_path() -> PathBuf {
|
||||
data_dir().join("update.lock")
|
||||
}
|
||||
pub fn updater_status_path() -> PathBuf {
|
||||
data_dir().join("update-status.json")
|
||||
}
|
||||
pub fn updater_staging_dir() -> PathBuf {
|
||||
data_dir().join("update-staging")
|
||||
}
|
||||
pub fn web_asset_dir() -> PathBuf {
|
||||
std::env::var_os("IOTA_WEB_ASSET_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| data_dir().join("web"))
|
||||
}
|
||||
|
||||
pub fn daemon_endpoints() -> Vec<PathBuf> {
|
||||
if let Some(path) = socket_override() {
|
||||
return vec![path];
|
||||
}
|
||||
vec![
|
||||
socket_path(SocketScope::User),
|
||||
socket_path(SocketScope::System),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn explicit_data_directory_wins() {
|
||||
assert!(!data_dir().as_os_str().is_empty());
|
||||
}
|
||||
}
|
||||
8
iota-process-manager/Cargo.toml
Normal file
8
iota-process-manager/Cargo.toml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
[package]
|
||||
name = "iota-process-manager"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1"
|
||||
tokio = { version = "1.50", features = ["process", "time", "io-util", "macros", "rt"] }
|
||||
459
iota-process-manager/src/lib.rs
Normal file
459
iota-process-manager/src/lib.rs
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
use async_trait::async_trait;
|
||||
use std::{
|
||||
fmt::{Display, Formatter},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
pub const PROCESS_MANAGER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct UnitStatus {
|
||||
pub active: bool,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum StartupMode {
|
||||
AlwaysOn,
|
||||
SocketActivated,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ProcessAction {
|
||||
Start,
|
||||
Stop,
|
||||
Restart,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DetectedStartupMode {
|
||||
AlwaysOn,
|
||||
SocketActivated,
|
||||
Disabled,
|
||||
Conflicting,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct DaemonStartupStatus {
|
||||
pub service: UnitStatus,
|
||||
pub socket: UnitStatus,
|
||||
pub detected: DetectedStartupMode,
|
||||
}
|
||||
|
||||
impl DaemonStartupStatus {
|
||||
pub fn classify(service: UnitStatus, socket: UnitStatus) -> Self {
|
||||
let detected = match (service.enabled, socket.enabled) {
|
||||
(true, false) => DetectedStartupMode::AlwaysOn,
|
||||
(false, true) => DetectedStartupMode::SocketActivated,
|
||||
(false, false) => DetectedStartupMode::Disabled,
|
||||
(true, true) => DetectedStartupMode::Conflicting,
|
||||
};
|
||||
Self {
|
||||
service,
|
||||
socket,
|
||||
detected,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ProcessManagerErrorKind {
|
||||
CommandUnavailable,
|
||||
PermissionDenied,
|
||||
UnitMissing,
|
||||
CommandFailed,
|
||||
ParseFailed,
|
||||
VerificationFailed,
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ProcessManagerError {
|
||||
pub kind: ProcessManagerErrorKind,
|
||||
message: String,
|
||||
}
|
||||
impl ProcessManagerError {
|
||||
pub fn new(kind: ProcessManagerErrorKind, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
pub fn kind(&self) -> ProcessManagerErrorKind {
|
||||
self.kind
|
||||
}
|
||||
}
|
||||
impl Display for ProcessManagerError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
impl std::error::Error for ProcessManagerError {}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CommandOutput {
|
||||
pub success: bool,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait CommandExecutor: Send + Sync {
|
||||
async fn output(
|
||||
&self,
|
||||
program: &str,
|
||||
args: &[&str],
|
||||
) -> Result<CommandOutput, ProcessManagerError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ProcessManager: Send + Sync {
|
||||
fn name(&self) -> &'static str;
|
||||
async fn unit_status(&self, unit: &str) -> Result<UnitStatus, ProcessManagerError>;
|
||||
async fn set_iota_startup_mode(
|
||||
&self,
|
||||
mode: StartupMode,
|
||||
) -> Result<DaemonStartupStatus, ProcessManagerError>;
|
||||
async fn iota_startup_status(&self) -> Result<DaemonStartupStatus, ProcessManagerError> {
|
||||
Ok(DaemonStartupStatus::classify(
|
||||
self.unit_status("iota-daemon.service").await?,
|
||||
self.unit_status("iota-daemon.socket").await?,
|
||||
))
|
||||
}
|
||||
async fn enable_startup(
|
||||
&self,
|
||||
mode: StartupMode,
|
||||
) -> Result<DaemonStartupStatus, ProcessManagerError> {
|
||||
self.set_iota_startup_mode(mode).await
|
||||
}
|
||||
async fn disable_startup(&self) -> Result<DaemonStartupStatus, ProcessManagerError> {
|
||||
self.set_iota_startup_mode(StartupMode::SocketActivated)
|
||||
.await
|
||||
}
|
||||
async fn process_action(
|
||||
&self,
|
||||
action: ProcessAction,
|
||||
) -> Result<DaemonStartupStatus, ProcessManagerError> {
|
||||
let unit = "iota-daemon.service";
|
||||
match action {
|
||||
ProcessAction::Start => self.unit_action(&["start", unit]).await?,
|
||||
ProcessAction::Stop => self.unit_action(&["stop", unit]).await?,
|
||||
ProcessAction::Restart => self.unit_action(&["restart", unit]).await?,
|
||||
}
|
||||
self.iota_startup_status().await
|
||||
}
|
||||
async fn unit_action(&self, _action: &[&str]) -> Result<(), ProcessManagerError> {
|
||||
Err(ProcessManagerError::new(
|
||||
ProcessManagerErrorKind::CommandFailed,
|
||||
"process actions are unsupported",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn detect() -> Option<Arc<dyn ProcessManager>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
systemd::SystemdManager::detect()
|
||||
.await
|
||||
.map(|m| Arc::new(m) as Arc<dyn ProcessManager>)
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod systemd {
|
||||
use super::*;
|
||||
use std::{path::Path, process::Stdio};
|
||||
use tokio::{process::Command, time::timeout};
|
||||
|
||||
const SERVICE: &str = "iota-daemon.service";
|
||||
const SOCKET: &str = "iota-daemon.socket";
|
||||
const COMMON: [&str; 2] = ["--no-pager", "--no-ask-password"];
|
||||
|
||||
pub struct RealExecutor;
|
||||
#[async_trait]
|
||||
impl CommandExecutor for RealExecutor {
|
||||
async fn output(
|
||||
&self,
|
||||
program: &str,
|
||||
args: &[&str],
|
||||
) -> Result<CommandOutput, ProcessManagerError> {
|
||||
let child = Command::new(program)
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(false)
|
||||
.spawn()
|
||||
.map_err(|e| {
|
||||
ProcessManagerError::new(
|
||||
ProcessManagerErrorKind::CommandUnavailable,
|
||||
format!("Could not run {program}: {e}"),
|
||||
)
|
||||
})?;
|
||||
let output = timeout(PROCESS_MANAGER_TIMEOUT, child.wait_with_output())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
ProcessManagerError::new(
|
||||
ProcessManagerErrorKind::TimedOut,
|
||||
format!(
|
||||
"{program} timed out after {} seconds",
|
||||
PROCESS_MANAGER_TIMEOUT.as_secs()
|
||||
),
|
||||
)
|
||||
})?
|
||||
.map_err(|e| {
|
||||
ProcessManagerError::new(ProcessManagerErrorKind::CommandFailed, e.to_string())
|
||||
})?;
|
||||
Ok(CommandOutput {
|
||||
success: output.status.success(),
|
||||
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
|
||||
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SystemdManager {
|
||||
executor: Arc<dyn CommandExecutor>,
|
||||
}
|
||||
impl SystemdManager {
|
||||
pub async fn detect() -> Option<Self> {
|
||||
if !Path::new("/run/systemd/system").is_dir() {
|
||||
return None;
|
||||
}
|
||||
let executor: Arc<dyn CommandExecutor> = Arc::new(RealExecutor);
|
||||
executor
|
||||
.output("systemctl", &["--version", &COMMON[0], &COMMON[1]])
|
||||
.await
|
||||
.ok()
|
||||
.filter(|r| r.success)
|
||||
.map(|_| Self { executor })
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub fn with_executor(executor: Arc<dyn CommandExecutor>) -> Self {
|
||||
Self { executor }
|
||||
}
|
||||
async fn run(&self, action: &[&str]) -> Result<(), ProcessManagerError> {
|
||||
let mut args = COMMON.to_vec();
|
||||
args.extend_from_slice(action);
|
||||
let output = self.executor.output("systemctl", &args).await?;
|
||||
if output.success {
|
||||
return Ok(());
|
||||
}
|
||||
let detail = if output.stderr.trim().is_empty() {
|
||||
output.stdout.trim()
|
||||
} else {
|
||||
output.stderr.trim()
|
||||
};
|
||||
let kind = if detail.to_ascii_lowercase().contains("access denied")
|
||||
|| detail.to_ascii_lowercase().contains("permission denied")
|
||||
{
|
||||
ProcessManagerErrorKind::PermissionDenied
|
||||
} else {
|
||||
ProcessManagerErrorKind::CommandFailed
|
||||
};
|
||||
Err(ProcessManagerError::new(
|
||||
kind,
|
||||
if detail.is_empty() {
|
||||
format!("systemctl {} failed", action.join(" "))
|
||||
} else {
|
||||
detail.to_owned()
|
||||
},
|
||||
))
|
||||
}
|
||||
async fn status(&self, unit: &str) -> Result<UnitStatus, ProcessManagerError> {
|
||||
let mut args = COMMON.to_vec();
|
||||
args.extend_from_slice(&[
|
||||
"show",
|
||||
"--property=LoadState",
|
||||
"--property=ActiveState",
|
||||
"--property=UnitFileState",
|
||||
"--value",
|
||||
unit,
|
||||
]);
|
||||
let output = self.executor.output("systemctl", &args).await?;
|
||||
if !output.success {
|
||||
let detail = if output.stderr.trim().is_empty() {
|
||||
output.stdout.trim()
|
||||
} else {
|
||||
output.stderr.trim()
|
||||
};
|
||||
let kind = if detail.to_ascii_lowercase().contains("denied") {
|
||||
ProcessManagerErrorKind::PermissionDenied
|
||||
} else {
|
||||
ProcessManagerErrorKind::CommandFailed
|
||||
};
|
||||
return Err(ProcessManagerError::new(
|
||||
kind,
|
||||
format!("systemctl could not inspect {unit}: {detail}"),
|
||||
));
|
||||
}
|
||||
let values: Vec<_> = output.stdout.lines().map(str::trim).collect();
|
||||
if values.len() < 3 {
|
||||
return Err(ProcessManagerError::new(
|
||||
ProcessManagerErrorKind::ParseFailed,
|
||||
format!("systemctl returned incomplete state for {unit}"),
|
||||
));
|
||||
}
|
||||
if values[0] == "not-found" {
|
||||
return Err(ProcessManagerError::new(
|
||||
ProcessManagerErrorKind::UnitMissing,
|
||||
format!("systemd unit {unit} was not found"),
|
||||
));
|
||||
}
|
||||
if values[0] != "loaded" {
|
||||
return Err(ProcessManagerError::new(
|
||||
ProcessManagerErrorKind::ParseFailed,
|
||||
format!("unsupported LoadState `{}` for {unit}", values[0]),
|
||||
));
|
||||
}
|
||||
let active = match values[1] {
|
||||
"active" => true,
|
||||
"inactive" | "failed" | "activating" | "deactivating" | "reloading" => false,
|
||||
v => {
|
||||
return Err(ProcessManagerError::new(
|
||||
ProcessManagerErrorKind::ParseFailed,
|
||||
format!("unsupported ActiveState `{v}` for {unit}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
let enabled = match values[2] {
|
||||
"enabled" | "enabled-runtime" => true,
|
||||
"disabled" | "static" | "indirect" | "masked" | "generated" | "transient" => false,
|
||||
v => {
|
||||
return Err(ProcessManagerError::new(
|
||||
ProcessManagerErrorKind::ParseFailed,
|
||||
format!("unsupported UnitFileState `{v}` for {unit}"),
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(UnitStatus { active, enabled })
|
||||
}
|
||||
async fn verify(
|
||||
&self,
|
||||
expected: DetectedStartupMode,
|
||||
) -> Result<DaemonStartupStatus, ProcessManagerError> {
|
||||
let status = self.iota_startup_status().await?;
|
||||
if status.detected == expected {
|
||||
Ok(status)
|
||||
} else {
|
||||
Err(ProcessManagerError::new(
|
||||
ProcessManagerErrorKind::VerificationFailed,
|
||||
format!(
|
||||
"systemd reported {:?} after applying {:?}",
|
||||
status.detected, expected
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl ProcessManager for SystemdManager {
|
||||
fn name(&self) -> &'static str {
|
||||
"systemd"
|
||||
}
|
||||
async fn unit_status(&self, unit: &str) -> Result<UnitStatus, ProcessManagerError> {
|
||||
self.status(unit).await
|
||||
}
|
||||
async fn set_iota_startup_mode(
|
||||
&self,
|
||||
mode: StartupMode,
|
||||
) -> Result<DaemonStartupStatus, ProcessManagerError> {
|
||||
match mode {
|
||||
StartupMode::AlwaysOn => {
|
||||
self.run(&["disable", SOCKET]).await?;
|
||||
self.run(&["enable", "--now", SERVICE]).await?;
|
||||
self.verify(DetectedStartupMode::AlwaysOn).await
|
||||
}
|
||||
StartupMode::SocketActivated => {
|
||||
self.run(&["disable", "--now", SERVICE]).await?;
|
||||
self.run(&["enable", "--now", SOCKET]).await?;
|
||||
self.verify(DetectedStartupMode::SocketActivated).await
|
||||
}
|
||||
}
|
||||
}
|
||||
async fn unit_action(&self, action: &[&str]) -> Result<(), ProcessManagerError> {
|
||||
self.run(action).await
|
||||
}
|
||||
async fn disable_startup(&self) -> Result<DaemonStartupStatus, ProcessManagerError> {
|
||||
self.run(&["disable", "--now", SERVICE]).await?;
|
||||
self.run(&["disable", "--now", SOCKET]).await?;
|
||||
self.verify(DetectedStartupMode::Disabled).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
struct Fake {
|
||||
calls: Mutex<Vec<Vec<String>>>,
|
||||
results: Mutex<Vec<CommandOutput>>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl CommandExecutor for Fake {
|
||||
async fn output(
|
||||
&self,
|
||||
_: &str,
|
||||
args: &[&str],
|
||||
) -> Result<CommandOutput, ProcessManagerError> {
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(args.iter().map(|arg| (*arg).to_owned()).collect());
|
||||
Ok(self.results.lock().unwrap().remove(0))
|
||||
}
|
||||
}
|
||||
fn ok(stdout: &str) -> CommandOutput {
|
||||
CommandOutput {
|
||||
success: true,
|
||||
stdout: stdout.into(),
|
||||
stderr: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_systemctl_operation_disables_interactive_features() {
|
||||
let fake = Arc::new(Fake {
|
||||
calls: Mutex::new(Vec::new()),
|
||||
results: Mutex::new(vec![ok("loaded\nactive\nenabled\n")]),
|
||||
});
|
||||
let manager = SystemdManager::with_executor(fake.clone());
|
||||
manager.unit_status(SERVICE).await.unwrap();
|
||||
let call = &fake.calls.lock().unwrap()[0];
|
||||
assert!(call.contains(&"--no-pager".into()));
|
||||
assert!(call.contains(&"--no-ask-password".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn modes_distinct() {
|
||||
assert_ne!(StartupMode::AlwaysOn, StartupMode::SocketActivated);
|
||||
}
|
||||
|
||||
struct BlockingExecutor;
|
||||
#[async_trait::async_trait]
|
||||
impl CommandExecutor for BlockingExecutor {
|
||||
async fn output(&self, _: &str, _: &[&str]) -> Result<CommandOutput, ProcessManagerError> {
|
||||
std::future::pending().await
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn executor_future_can_be_cancelled_without_blocking_runtime() {
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_millis(20),
|
||||
BlockingExecutor.output("systemctl", &["show"]),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ use std::thread;
|
|||
use std::time::Duration;
|
||||
#[cfg(feature = "legacy-globals")]
|
||||
use sysinfo::{RefreshKind, System};
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::{Mutex as TokioMutex, RwLock};
|
||||
|
||||
/* Process-owned daemon state and TUI-local state must be separate because IPC,
|
||||
* rather than shared memory, is the boundary between the two binaries. */
|
||||
|
|
@ -45,13 +45,13 @@ impl Default for DaemonState {
|
|||
* with the daemon and is populated from daemon IPC messages. */
|
||||
#[derive(Clone)]
|
||||
pub struct ClientState {
|
||||
pub app: Arc<Mutex<AppState>>,
|
||||
pub app: Arc<TokioMutex<AppState>>,
|
||||
}
|
||||
|
||||
impl ClientState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
app: Arc::new(Mutex::new(AppState::new())),
|
||||
app: Arc::new(TokioMutex::new(AppState::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -94,6 +94,7 @@ pub struct AppState {
|
|||
pub net_up: Vec<(f64, f64)>,
|
||||
pub net_down: Vec<(f64, f64)>,
|
||||
pub sys_info: String,
|
||||
next_sample_id: u64,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
|
|
@ -106,6 +107,7 @@ impl AppState {
|
|||
net_up: Vec::new(),
|
||||
net_down: Vec::new(),
|
||||
sys_info: String::from("Loading..."),
|
||||
next_sample_id: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -121,40 +123,51 @@ impl AppState {
|
|||
}
|
||||
|
||||
pub fn push_cpu(&mut self, pt: (f64, f64)) {
|
||||
self.cpu.push(pt);
|
||||
let x = self.next_sample();
|
||||
self.cpu.push((x, pt.1));
|
||||
if self.cpu.len() > MAX_POINTS {
|
||||
self.cpu.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_ram(&mut self, pt: (f64, f64)) {
|
||||
self.ram.push(pt);
|
||||
let x = self.next_sample();
|
||||
self.ram.push((x, pt.1));
|
||||
if self.ram.len() > MAX_POINTS {
|
||||
self.ram.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_ping_val(&mut self, pt: f64) {
|
||||
self.ping.push((self.ping.len() as f64, pt));
|
||||
let x = self.next_sample();
|
||||
self.ping.push((x, pt));
|
||||
if self.ping.len() > MAX_POINTS {
|
||||
self.ping.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_net_up(&mut self, pt: (f64, f64)) {
|
||||
self.net_up.push(pt);
|
||||
let x = self.next_sample();
|
||||
self.net_up.push((x, pt.1));
|
||||
if self.net_up.len() > MAX_POINTS {
|
||||
self.net_up.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_net_down(&mut self, pt: (f64, f64)) {
|
||||
self.net_down.push(pt);
|
||||
let x = self.next_sample();
|
||||
self.net_down.push((x, pt.1));
|
||||
if self.net_down.len() > MAX_POINTS {
|
||||
self.net_down.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
fn next_sample(&mut self) -> f64 {
|
||||
let value = self.next_sample_id as f64;
|
||||
self.next_sample_id = self.next_sample_id.saturating_add(1);
|
||||
value
|
||||
}
|
||||
|
||||
pub fn to_json(&self) -> JsonValue {
|
||||
object! {
|
||||
"cpu" => self.cpu.iter().map(|(_, y)| *y).collect::<Vec<f64>>(),
|
||||
|
|
@ -218,15 +231,16 @@ pub static RELOAD: Lazy<RwLock<bool>> = Lazy::new(|| RwLock::new(true));
|
|||
pub static ACTIVE_TASKS: Lazy<DashSet<String>> = Lazy::new(|| DashSet::new());
|
||||
|
||||
#[cfg(feature = "legacy-globals")]
|
||||
pub fn setup() {
|
||||
ACTIVE_TASKS.insert("System info loader".to_string());
|
||||
pub fn setup(state: &DaemonState) {
|
||||
state.active_tasks.insert("System info loader".to_string());
|
||||
let state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut sys = System::new_with_specifics(RefreshKind::everything());
|
||||
let mut last_total_received = 0u64;
|
||||
let mut last_total_transmitted = 0u64;
|
||||
let mut counter = 0.0;
|
||||
loop {
|
||||
if *SHUTDOWN.read().await {
|
||||
if *state.shutdown.read().await {
|
||||
break;
|
||||
}
|
||||
sys.refresh_all();
|
||||
|
|
@ -258,7 +272,7 @@ pub fn setup() {
|
|||
let net_up = delta_transmitted as f64;
|
||||
|
||||
{
|
||||
let mut st = APP_STATE.lock().unwrap();
|
||||
let mut st = state.app.lock().unwrap();
|
||||
st.push_cpu((counter, tcpu as f64));
|
||||
st.push_ram((counter, ram));
|
||||
st.push_net_down((counter, net_down));
|
||||
|
|
@ -274,6 +288,21 @@ pub fn setup() {
|
|||
thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
ACTIVE_TASKS.remove("System info loader");
|
||||
state.active_tasks.remove("System info loader");
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn metric_coordinates_remain_monotonic_after_history_rollover() {
|
||||
let mut state = AppState::new();
|
||||
for value in 0..(MAX_POINTS + 25) {
|
||||
state.push_ping_val(value as f64);
|
||||
}
|
||||
assert_eq!(state.ping.len(), MAX_POINTS);
|
||||
assert!(state.ping.windows(2).all(|pair| pair[0].0 < pair[1].0));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ pub struct IotaConfig {
|
|||
pub iota_id: Option<u64>,
|
||||
#[serde(default = "default_port")]
|
||||
pub port: u16,
|
||||
#[serde(default)]
|
||||
pub web: WebSettings,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub omikron_host: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
|
@ -27,6 +29,54 @@ pub struct IotaConfig {
|
|||
pub read_receipts_enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WebMode {
|
||||
Disabled,
|
||||
Loopback,
|
||||
Network,
|
||||
}
|
||||
impl Default for WebMode {
|
||||
fn default() -> Self {
|
||||
Self::Disabled
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WebSettings {
|
||||
#[serde(default)]
|
||||
pub mode: WebMode,
|
||||
#[serde(default = "default_web_bind")]
|
||||
pub bind: String,
|
||||
#[serde(default = "default_port")]
|
||||
pub port: u16,
|
||||
#[serde(default = "default_web_asset_dir")]
|
||||
pub asset_dir: String,
|
||||
pub certificate: Option<String>,
|
||||
pub key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub required: bool,
|
||||
}
|
||||
fn default_web_bind() -> String {
|
||||
"127.0.0.1".into()
|
||||
}
|
||||
fn default_web_asset_dir() -> String {
|
||||
"web".into()
|
||||
}
|
||||
impl Default for WebSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: WebMode::default(),
|
||||
bind: default_web_bind(),
|
||||
port: default_port(),
|
||||
asset_dir: default_web_asset_dir(),
|
||||
certificate: None,
|
||||
key: None,
|
||||
required: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fn default_port() -> u16 {
|
||||
1984
|
||||
}
|
||||
|
|
@ -40,6 +90,7 @@ impl Default for IotaConfig {
|
|||
Self {
|
||||
iota_id: None,
|
||||
port: default_port(),
|
||||
web: WebSettings::default(),
|
||||
omikron_host: None,
|
||||
omikron_port: None,
|
||||
keyring: None,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,163 +1,13 @@
|
|||
/* This file is used for the auto update function for the Iota.
|
||||
* It connects to the git server from methanium and checks if
|
||||
* the version has updated inside the cargo.toml file.
|
||||
* It is made by Yolokit and pasted in by AlexEmmet */
|
||||
pub mod manifest;
|
||||
pub mod transaction;
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use iota_logger::log;
|
||||
use self_replace::self_replace;
|
||||
use semver::Version;
|
||||
use std::fs::File;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
const API_BASE: &str = "https://git.methanium.net/api/v1";
|
||||
const OWNER: &str = "Tensamin";
|
||||
const REPO: &str = "Iota";
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Release {
|
||||
tag_name: String,
|
||||
assets: Vec<Asset>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Asset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
}
|
||||
|
||||
async fn latest_release() -> Result<Release> {
|
||||
let url = format!("{API_BASE}/repos/{OWNER}/{REPO}/releases/latest");
|
||||
|
||||
let response = reqwest::get(&url)
|
||||
.await
|
||||
.context("failed to query latest release.")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!("release API returned {}", response.status()));
|
||||
}
|
||||
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.context("failed to read response text")?;
|
||||
|
||||
let parsed = json::parse(&text).map_err(|e| anyhow!("failed to parse JSON: {}", e))?;
|
||||
|
||||
let tag_name = parsed["tag_name"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("missing tag_name"))?
|
||||
.to_string();
|
||||
|
||||
let assets_json = parsed["assets"].members().collect::<Vec<_>>();
|
||||
|
||||
let mut assets = Vec::new();
|
||||
|
||||
for asset in assets_json {
|
||||
let name = asset["name"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("missing asset name"))?
|
||||
.to_string();
|
||||
|
||||
let browser_download_url = asset["browser_download_url"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("missing download url"))?
|
||||
.to_string();
|
||||
|
||||
assets.push(Asset {
|
||||
name,
|
||||
browser_download_url,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Release { tag_name, assets })
|
||||
}
|
||||
|
||||
async fn parse_tag_version(tag: &str) -> Result<Version> {
|
||||
let normalized = tag.strip_prefix('v').unwrap_or(tag);
|
||||
Ok(Version::parse(normalized)?)
|
||||
}
|
||||
|
||||
async fn current_version() -> Result<Version> {
|
||||
Ok(Version::parse(CURRENT_VERSION)?)
|
||||
}
|
||||
|
||||
async fn asset_name_for_current_platform() -> String {
|
||||
let os = std::env::consts::OS;
|
||||
let arch = std::env::consts::ARCH;
|
||||
|
||||
match (os, arch) {
|
||||
("linux", "x86_64") => "iota-linux-x86_64".to_string(),
|
||||
("linux", "aarch64") => "iota-linux-aarch64".to_string(),
|
||||
("windows", "x86_64") => "iota-windows-x86_64.exe".to_string(),
|
||||
("macos", "x86_64") => "iota-macos-x86_64".to_string(),
|
||||
("macos", "aarch64") => "iota-macos-aarch64".to_string(),
|
||||
_ => panic!("unsupported platform: {os}/{arch}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn download_asset(url: &str) -> Result<NamedTempFile> {
|
||||
let response = reqwest::get(url)
|
||||
.await
|
||||
.context("failed to download asset")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!("asset download returned {}", response.status()));
|
||||
}
|
||||
|
||||
let tmp = NamedTempFile::new().context("failed to create temp file")?;
|
||||
let _out = File::create(tmp.path()).context("failed to open temp file")?;
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.context("failed to read response bytes")?;
|
||||
|
||||
std::fs::write(tmp.path(), &bytes).context("failed to write file")?;
|
||||
|
||||
Ok(tmp)
|
||||
}
|
||||
|
||||
async fn check_for_update() -> Result<Option<Release>> {
|
||||
let current = current_version().await?;
|
||||
let release = latest_release().await?;
|
||||
let latest = parse_tag_version(&release.tag_name).await?;
|
||||
|
||||
if latest > current {
|
||||
Ok(Some(release))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
async fn perform_update() -> Result<bool> {
|
||||
let Some(release) = check_for_update().await? else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let wanted_asset = asset_name_for_current_platform().await;
|
||||
|
||||
let asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name == wanted_asset)
|
||||
.ok_or_else(|| anyhow!("no matching asset found: {}", wanted_asset))?;
|
||||
|
||||
log!("Downloading update: {}", asset.name);
|
||||
|
||||
let downloaded = download_asset(&asset.browser_download_url).await?;
|
||||
|
||||
self_replace(downloaded.path()).context("failed to replace current executable")?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
use anyhow::Result;
|
||||
|
||||
/// Compatibility entry point used by the UI. Updates are now manifest-driven;
|
||||
/// this function only checks and never replaces the invoking executable.
|
||||
pub async fn check_update() -> Result<bool> {
|
||||
if perform_update().await? {
|
||||
return Ok(true);
|
||||
} else {
|
||||
if std::env::var_os("IOTA_UPDATE_MANIFEST").is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
|
|
|||
21
iota-updater/src/main.rs
Normal file
21
iota-updater/src/main.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
use anyhow::Result;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let command = std::env::args().nth(1).unwrap_or_else(|| "status".into());
|
||||
match command.as_str() {
|
||||
"check" => println!("update check is manifest-driven"),
|
||||
"status" => println!("updater ready"),
|
||||
"apply" | "rollback" => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"explicit signed transaction input is required"
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"usage: iota-updater check|status|apply|rollback"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
83
iota-updater/src/manifest.rs
Normal file
83
iota-updater/src/manifest.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
use anyhow::{Context, Result, bail};
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ReleaseManifest {
|
||||
pub product_version: String,
|
||||
pub channel: String,
|
||||
pub published_at: String,
|
||||
pub minimum_data_schema: u64,
|
||||
pub supported_ipc_min: u16,
|
||||
pub supported_ipc_max: u16,
|
||||
pub artifacts: Vec<Artifact>,
|
||||
pub release_signing_key_id: String,
|
||||
pub rollback_compatible: bool,
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Artifact {
|
||||
pub role: String,
|
||||
pub os: String,
|
||||
pub architecture: String,
|
||||
pub path: String,
|
||||
pub url: String,
|
||||
pub sha256: String,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
pub fn canonical_bytes(manifest: &ReleaseManifest) -> Result<Vec<u8>> {
|
||||
Ok(serde_json::to_vec(manifest)?)
|
||||
}
|
||||
pub fn verify_signature(
|
||||
manifest: &ReleaseManifest,
|
||||
signature: &[u8],
|
||||
public_key: &[u8; 32],
|
||||
) -> Result<()> {
|
||||
let key = VerifyingKey::from_bytes(public_key).context("invalid release public key")?;
|
||||
let signature = Signature::from_slice(signature).context("invalid release signature")?;
|
||||
key.verify(&canonical_bytes(manifest)?, &signature)
|
||||
.context("release manifest signature verification failed")
|
||||
}
|
||||
pub fn verify_artifact(path: &std::path::Path, artifact: &Artifact) -> Result<()> {
|
||||
let metadata = std::fs::metadata(path)?;
|
||||
if metadata.len() != artifact.size {
|
||||
bail!("artifact size mismatch for {}", artifact.path);
|
||||
}
|
||||
let mut file = std::fs::File::open(path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = [0u8; 64 * 1024];
|
||||
loop {
|
||||
let read = std::io::Read::read(&mut file, &mut buffer)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..read]);
|
||||
}
|
||||
let actual = hex::encode(hasher.finalize());
|
||||
if actual != artifact.sha256.to_ascii_lowercase() {
|
||||
bail!("artifact hash mismatch for {}", artifact.path);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn rejects_size_or_hash_mismatch() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("iota-daemon");
|
||||
std::fs::write(&path, b"daemon").unwrap();
|
||||
let artifact = Artifact {
|
||||
role: "daemon".into(),
|
||||
os: "linux".into(),
|
||||
architecture: "x86_64".into(),
|
||||
path: "bin/iota-daemon".into(),
|
||||
url: "https://example.invalid".into(),
|
||||
sha256: "00".repeat(32),
|
||||
size: 6,
|
||||
};
|
||||
assert!(verify_artifact(&path, &artifact).is_err());
|
||||
}
|
||||
}
|
||||
79
iota-updater/src/transaction.rs
Normal file
79
iota-updater/src/transaction.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
use crate::manifest::{Artifact, verify_artifact};
|
||||
use anyhow::{Context, Result};
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct UpdateTransaction {
|
||||
pub root: PathBuf,
|
||||
pub staging: PathBuf,
|
||||
}
|
||||
impl UpdateTransaction {
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
let root = root.into();
|
||||
Self {
|
||||
staging: root.join(".staging"),
|
||||
root,
|
||||
}
|
||||
}
|
||||
pub fn acquire(&self) -> Result<fs::File> {
|
||||
fs::create_dir_all(&self.root)?;
|
||||
let path = self.root.join("update.lock");
|
||||
let file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path)
|
||||
.context("update already in progress")?;
|
||||
Ok(file)
|
||||
}
|
||||
pub fn stage_artifact(&self, source: &Path, artifact: &Artifact) -> Result<PathBuf> {
|
||||
fs::create_dir_all(&self.staging)?;
|
||||
let target = self.staging.join(&artifact.path);
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::copy(source, &target)?;
|
||||
verify_artifact(&target, artifact)?;
|
||||
Ok(target)
|
||||
}
|
||||
pub fn activate(&self, version: &str) -> Result<()> {
|
||||
let version_dir = self.root.join("versions").join(version);
|
||||
fs::create_dir_all(version_dir.parent().unwrap())?;
|
||||
fs::rename(&self.staging, &version_dir).context("activate staged release")?;
|
||||
let current_tmp = self.root.join("current.new");
|
||||
let _ = fs::remove_file(¤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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
] }
|
||||
|
|
|
|||
|
|
@ -162,10 +162,7 @@ pub fn get_children(path: &str) -> Vec<String> {
|
|||
}
|
||||
|
||||
pub fn get_directory() -> String {
|
||||
std::env::current_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("."))
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
iota_paths::data_dir().to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
// Helper to download the zip file content to a file on disk
|
||||
|
|
|
|||
|
|
@ -5,5 +5,10 @@ edition = "2024"
|
|||
|
||||
[dependencies]
|
||||
iota-cli = { path = "../iota-cli" }
|
||||
iota-ipc = { path = "../iota-ipc" }
|
||||
iota-installer = { path = "../iota-installer" }
|
||||
iota-core = { path = "../iota-core" }
|
||||
iota-process-manager = { path = "../iota-process-manager" }
|
||||
iota-paths = { path = "../iota-paths" }
|
||||
tokio = { version = "1.50.0", features = ["full"] }
|
||||
tokio-util = { version = "0.7", features = ["rt"] }
|
||||
|
|
|
|||
154
iota/src/cli_args.rs
Normal file
154
iota/src/cli_args.rs
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
use iota_cli::theme::ThemeName;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CliInvocation {
|
||||
pub theme_override: Option<ThemeName>,
|
||||
pub command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Command {
|
||||
Dashboard,
|
||||
Help,
|
||||
Install {
|
||||
bundle: String,
|
||||
operator: Option<String>,
|
||||
},
|
||||
Status,
|
||||
Tasks,
|
||||
UsersList,
|
||||
DaemonRestart {
|
||||
confirmed: bool,
|
||||
},
|
||||
DaemonStop {
|
||||
confirmed: bool,
|
||||
},
|
||||
DaemonStopProcess,
|
||||
DaemonEnable {
|
||||
mode: String,
|
||||
},
|
||||
DaemonDisableStartup,
|
||||
DaemonDaemonStatus,
|
||||
}
|
||||
impl CliInvocation {
|
||||
pub fn parse(args: impl IntoIterator<Item = String>) -> Result<Self, String> {
|
||||
let mut theme_override = None;
|
||||
let mut command = Vec::new();
|
||||
let mut args = args.into_iter();
|
||||
while let Some(argument) = args.next() {
|
||||
if argument == "--theme" {
|
||||
let value = args.next().ok_or_else(|| {
|
||||
format!(
|
||||
"--theme requires a value ({})",
|
||||
ThemeName::supported_names()
|
||||
)
|
||||
})?;
|
||||
theme_override = Some(value.parse()?);
|
||||
} else if let Some(value) = argument.strip_prefix("--theme=") {
|
||||
theme_override = Some(value.parse()?);
|
||||
} else {
|
||||
command.push(argument);
|
||||
}
|
||||
}
|
||||
let command = match command.as_slice() {
|
||||
[] => Command::Dashboard,
|
||||
[help] if help == "help" || help == "--help" => Command::Help,
|
||||
[status] if status == "status" => Command::Status,
|
||||
[tasks] if tasks == "tasks" => Command::Tasks,
|
||||
[noun, verb] if noun == "users" && verb == "list" => Command::UsersList,
|
||||
[noun, verb, flag] if noun == "daemon" && verb == "restart" => Command::DaemonRestart {
|
||||
confirmed: flag == "--yes",
|
||||
},
|
||||
[noun, verb] if noun == "daemon" && verb == "restart" => {
|
||||
Command::DaemonRestart { confirmed: false }
|
||||
}
|
||||
[noun, verb, flag] if noun == "daemon" && verb == "stop" => Command::DaemonStop {
|
||||
confirmed: flag == "--yes",
|
||||
},
|
||||
[noun, verb] if noun == "daemon" && verb == "stop" => {
|
||||
Command::DaemonStop { confirmed: false }
|
||||
}
|
||||
[noun, verb] if noun == "daemon" && verb == "stop-process" => {
|
||||
Command::DaemonStopProcess
|
||||
}
|
||||
[noun, verb] if noun == "daemon" && verb == "disable-startup" => {
|
||||
Command::DaemonDisableStartup
|
||||
}
|
||||
[noun, verb] if noun == "daemon" && verb == "status" => Command::DaemonDaemonStatus,
|
||||
[noun, verb, flag, mode]
|
||||
if noun == "daemon" && verb == "enable" && flag == "--mode" =>
|
||||
{
|
||||
Command::DaemonEnable { mode: mode.clone() }
|
||||
}
|
||||
[noun, verb, bundle_flag, bundle]
|
||||
if noun == "daemon" && verb == "install" && bundle_flag == "--bundle" =>
|
||||
{
|
||||
Command::Install {
|
||||
bundle: bundle.clone(),
|
||||
operator: None,
|
||||
}
|
||||
}
|
||||
[noun, verb, bundle_flag, bundle, operator_flag, operator]
|
||||
if noun == "daemon"
|
||||
&& verb == "install"
|
||||
&& bundle_flag == "--bundle"
|
||||
&& operator_flag == "--operator" =>
|
||||
{
|
||||
Command::Install {
|
||||
bundle: bundle.clone(),
|
||||
operator: Some(operator.clone()),
|
||||
}
|
||||
}
|
||||
_ => return Err("Unknown command. Run `iota --help`.".into()),
|
||||
};
|
||||
Ok(Self {
|
||||
theme_override,
|
||||
command,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn removes_global_theme_option() {
|
||||
let invocation =
|
||||
CliInvocation::parse(["--theme".into(), "binary".into(), "status".into()]).unwrap();
|
||||
assert_eq!(invocation.theme_override, Some(ThemeName::Binary));
|
||||
assert_eq!(invocation.command, Command::Status);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_supported_names_for_invalid_theme() {
|
||||
let error = CliInvocation::parse(["--theme=ultraviolet".into()]).unwrap_err();
|
||||
assert!(error.contains(ThemeName::supported_names()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_install_operator_without_raw_slice_matching() {
|
||||
let invocation = CliInvocation::parse([
|
||||
"daemon".into(),
|
||||
"install".into(),
|
||||
"--bundle".into(),
|
||||
"release.zip".into(),
|
||||
"--operator".into(),
|
||||
"alice".into(),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
invocation.command,
|
||||
Command::Install {
|
||||
bundle: "release.zip".into(),
|
||||
operator: Some("alice".into()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_unconfirmed_destructive_commands_explicitly() {
|
||||
let invocation = CliInvocation::parse(["daemon".into(), "stop".into()]).unwrap();
|
||||
assert_eq!(invocation.command, Command::DaemonStop { confirmed: false });
|
||||
}
|
||||
}
|
||||
241
iota/src/daemon_setup_flow.rs
Normal file
241
iota/src/daemon_setup_flow.rs
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
use crate::startup_error::StartupError;
|
||||
use iota_cli::{
|
||||
ipc_client::IpcClient,
|
||||
screens::daemon_setup::{
|
||||
DaemonLaunchMode, DaemonSetupDecision, DaemonSetupScreen, DaemonStartingScreen,
|
||||
LaunchOption,
|
||||
},
|
||||
theme::UiConfig,
|
||||
ui::UI,
|
||||
};
|
||||
use iota_process_manager::ProcessManager;
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
pub struct Capabilities {
|
||||
pub executable: Result<PathBuf, StartupError>,
|
||||
pub socket: Result<(), StartupError>,
|
||||
pub system: Result<Arc<dyn ProcessManager>, StartupError>,
|
||||
}
|
||||
pub struct DaemonEndpoints {
|
||||
pub local: PathBuf,
|
||||
pub system: PathBuf,
|
||||
}
|
||||
pub struct ConnectionContext {
|
||||
pub ipc: Arc<IpcClient>,
|
||||
}
|
||||
impl Capabilities {
|
||||
fn options(&self) -> Vec<LaunchOption> {
|
||||
let once = self
|
||||
.executable
|
||||
.as_ref()
|
||||
.and_then(|_| self.socket.as_ref())
|
||||
.map(|_| ())
|
||||
.map_err(ToString::to_string);
|
||||
let ui = once.clone();
|
||||
let system = self
|
||||
.system
|
||||
.as_ref()
|
||||
.map(|_| ())
|
||||
.map_err(ToString::to_string);
|
||||
vec![
|
||||
LaunchOption {
|
||||
mode: DaemonLaunchMode::Once,
|
||||
enabled: once.is_ok(),
|
||||
reason: once.err(),
|
||||
},
|
||||
LaunchOption {
|
||||
mode: DaemonLaunchMode::WithUi,
|
||||
enabled: ui.is_ok(),
|
||||
reason: ui.err(),
|
||||
},
|
||||
LaunchOption {
|
||||
mode: DaemonLaunchMode::WithSystem,
|
||||
enabled: system.is_ok(),
|
||||
reason: system.err(),
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
pub async fn run(
|
||||
ui: Arc<UI>,
|
||||
endpoints: &DaemonEndpoints,
|
||||
caps: Capabilities,
|
||||
) -> Result<ConnectionContext, StartupError> {
|
||||
// Try connecting to an already-running daemon before starting a new one.
|
||||
if let Ok(ipc) = IpcClient::connect(&endpoints.local).await {
|
||||
return Ok(ConnectionContext { ipc });
|
||||
}
|
||||
let options = caps.options();
|
||||
if !options.iter().any(|o| o.enabled) {
|
||||
let _ = show(
|
||||
ui,
|
||||
options,
|
||||
"Daemon cannot be started. Correct the reported problem, then Retry, or Exit.",
|
||||
)
|
||||
.await?;
|
||||
return Err(StartupError::Cancelled);
|
||||
}
|
||||
if UiConfig::load()
|
||||
.map(|c| c.daemon_start_policy == iota_cli::theme::DaemonStartPolicy::WithUi)
|
||||
.unwrap_or(false)
|
||||
&& options[0].enabled
|
||||
{
|
||||
if let Ok(context) = start_local_with_ui(
|
||||
ui.clone(),
|
||||
caps.executable.as_ref().unwrap(),
|
||||
&endpoints.local,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Ok(context);
|
||||
}
|
||||
if ui.is_shutdown() {
|
||||
return Err(StartupError::Cancelled);
|
||||
}
|
||||
}
|
||||
loop {
|
||||
let decision = show(
|
||||
ui.clone(),
|
||||
options.clone(),
|
||||
"The daemon is not running. Choose how to start it.",
|
||||
)
|
||||
.await?;
|
||||
let DaemonSetupDecision::Start(mode) = decision else {
|
||||
return Err(StartupError::Cancelled);
|
||||
};
|
||||
ui.set_root_screen(Box::new(DaemonStartingScreen)).await;
|
||||
let result = match mode {
|
||||
DaemonLaunchMode::Once | DaemonLaunchMode::WithUi => {
|
||||
start_local_with_ui(
|
||||
ui.clone(),
|
||||
caps.executable.as_ref().unwrap(),
|
||||
&endpoints.local,
|
||||
)
|
||||
.await
|
||||
}
|
||||
DaemonLaunchMode::WithSystem => {
|
||||
let manager = caps.system.as_ref().unwrap();
|
||||
tokio::select! {
|
||||
result = manager.set_iota_startup_mode(iota_process_manager::StartupMode::SocketActivated) => match result {
|
||||
Ok(_) => tokio::select! {
|
||||
result = IpcClient::connect_or_activate(&endpoints.system) => result.map(|ipc| ConnectionContext { ipc }).map_err(|e| StartupError::Other(e.to_string())),
|
||||
_ = ui.wait_for_shutdown() => return Err(StartupError::Cancelled),
|
||||
},
|
||||
Err(e) => Err(map_process_manager_error(e)),
|
||||
},
|
||||
_ = ui.wait_for_shutdown() => return Err(StartupError::Cancelled),
|
||||
}
|
||||
}
|
||||
};
|
||||
if ui.is_shutdown() {
|
||||
return Err(StartupError::Cancelled);
|
||||
}
|
||||
match result {
|
||||
Ok(ipc) => {
|
||||
if mode == DaemonLaunchMode::WithUi {
|
||||
let mut cfg =
|
||||
UiConfig::load().map_err(|error| StartupError::Other(error.to_string()))?;
|
||||
cfg.daemon_start_policy = iota_cli::theme::DaemonStartPolicy::WithUi;
|
||||
cfg.save()
|
||||
.map_err(|error| StartupError::Other(error.to_string()))?;
|
||||
}
|
||||
return Ok(ipc);
|
||||
}
|
||||
Err(error) => {
|
||||
let retry = show(
|
||||
ui.clone(),
|
||||
options.clone(),
|
||||
format!("Daemon startup failed: {error}. Select an option to retry, or Exit."),
|
||||
)
|
||||
.await?;
|
||||
if matches!(retry, DaemonSetupDecision::Exit) {
|
||||
return Err(StartupError::Cancelled);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_process_manager_error(error: iota_process_manager::ProcessManagerError) -> StartupError {
|
||||
use iota_process_manager::ProcessManagerErrorKind;
|
||||
match error.kind() {
|
||||
ProcessManagerErrorKind::PermissionDenied => {
|
||||
StartupError::SystemPermissionDenied(error.to_string())
|
||||
}
|
||||
ProcessManagerErrorKind::TimedOut => StartupError::SystemCommandTimedOut(error.to_string()),
|
||||
_ => StartupError::Other(error.to_string()),
|
||||
}
|
||||
}
|
||||
async fn start_local_with_ui(
|
||||
ui: Arc<UI>,
|
||||
exe: &Path,
|
||||
path: &Path,
|
||||
) -> Result<ConnectionContext, StartupError> {
|
||||
tokio::select! {
|
||||
result = crate::local_daemon::launch(ui.clone(), exe, path) => result.map(|ipc| ConnectionContext { ipc }),
|
||||
_ = ui.wait_for_shutdown() => Err(StartupError::Cancelled),
|
||||
}
|
||||
}
|
||||
async fn show(
|
||||
ui: Arc<UI>,
|
||||
options: Vec<LaunchOption>,
|
||||
message: impl Into<String>,
|
||||
) -> Result<DaemonSetupDecision, StartupError> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let screen = DaemonSetupScreen::new(options, message, tx).map_err(|error| {
|
||||
StartupError::Other(format!("Cannot construct daemon setup screen: {error:?}"))
|
||||
})?;
|
||||
ui.set_root_screen(Box::new(screen)).await;
|
||||
tokio::select! {
|
||||
decision = rx => Ok(decision.unwrap_or(DaemonSetupDecision::Exit)),
|
||||
_ = ui.wait_for_shutdown() => Err(StartupError::Cancelled),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn missing_systemd_unit_disables_only_the_system_option() {
|
||||
let capabilities = Capabilities {
|
||||
executable: Ok(PathBuf::from("iota-daemon")),
|
||||
socket: Ok(()),
|
||||
system: Err(StartupError::Other(
|
||||
"systemd unit iota-daemon.service was not found".into(),
|
||||
)),
|
||||
};
|
||||
let options = capabilities.options();
|
||||
assert!(
|
||||
options
|
||||
.iter()
|
||||
.any(|option| option.mode == DaemonLaunchMode::Once && option.enabled)
|
||||
);
|
||||
let system = options
|
||||
.iter()
|
||||
.find(|option| option.mode == DaemonLaunchMode::WithSystem)
|
||||
.unwrap();
|
||||
assert!(!system.enabled);
|
||||
assert!(system.reason.as_deref().unwrap().contains("was not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_only_capabilities_do_not_select_disabled_local_mode() {
|
||||
let capabilities = Capabilities {
|
||||
executable: Err(StartupError::DaemonExecutableMissing(PathBuf::from(
|
||||
"iota-daemon",
|
||||
))),
|
||||
socket: Err(StartupError::LocalSocketNotWritable(
|
||||
PathBuf::from("/tmp/iota.sock"),
|
||||
std::io::Error::other("unavailable"),
|
||||
)),
|
||||
system: Err(StartupError::Other("manager unavailable".into())),
|
||||
};
|
||||
let options = capabilities.options();
|
||||
assert!(options.iter().all(|option| !option.enabled));
|
||||
}
|
||||
}
|
||||
120
iota/src/local_daemon.rs
Normal file
120
iota/src/local_daemon.rs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
use crate::startup_error::StartupError;
|
||||
use iota_cli::{ipc_client::IpcClient, ui::UI};
|
||||
use std::process::Stdio;
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, BufReader},
|
||||
process::{Child, Command},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
struct LocalDaemonGuard {
|
||||
child: Option<Child>,
|
||||
committed: bool,
|
||||
}
|
||||
impl LocalDaemonGuard {
|
||||
fn new(child: Child) -> Self {
|
||||
Self {
|
||||
child: Some(child),
|
||||
committed: false,
|
||||
}
|
||||
}
|
||||
fn commit(mut self) -> Child {
|
||||
self.committed = true;
|
||||
self.child.take().expect("local daemon child")
|
||||
}
|
||||
}
|
||||
impl Drop for LocalDaemonGuard {
|
||||
fn drop(&mut self) {
|
||||
if !self.committed {
|
||||
if let Some(mut child) = self.child.take() {
|
||||
let _ = child.start_kill();
|
||||
tokio::spawn(async move {
|
||||
let _ = child.wait().await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn launch(
|
||||
ui: Arc<UI>,
|
||||
executable: &Path,
|
||||
socket: &Path,
|
||||
) -> Result<Arc<IpcClient>, StartupError> {
|
||||
let mut child = Command::new(executable)
|
||||
.env("IOTA_SOCKET", socket)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(false)
|
||||
.spawn()
|
||||
.map_err(|e| StartupError::DaemonExited {
|
||||
message: format!("Could not start daemon: {e}"),
|
||||
})?;
|
||||
let diagnostics = Arc::new(Mutex::new(VecDeque::<String>::with_capacity(64)));
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let diagnostics = diagnostics.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let mut recent = diagnostics.lock().unwrap();
|
||||
if recent.len() == 64 {
|
||||
recent.pop_front();
|
||||
}
|
||||
recent.push_back(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
let mut guard = LocalDaemonGuard::new(child);
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
let cancellation = ui.cancellation_token();
|
||||
loop {
|
||||
let result = tokio::select! {
|
||||
status = guard.child.as_mut().expect("child").wait() => {
|
||||
let status = match status { Ok(status) => status.to_string(), Err(error) => format!("wait failed: {error}") };
|
||||
return Err(StartupError::DaemonExited { message: format_diagnostic(format!("daemon exited with {status}"), &diagnostics) });
|
||||
}
|
||||
connection = IpcClient::connect(socket) => connection,
|
||||
_ = cancellation.cancelled() => return Err(StartupError::Cancelled),
|
||||
_ = tokio::time::sleep_until(deadline) => return Err(StartupError::DaemonExited { message: format_diagnostic("timed out waiting for IPC handshake".into(), &diagnostics) }),
|
||||
};
|
||||
match result {
|
||||
Ok(client) => {
|
||||
// The daemon was launched with kill_on_drop(false) so it
|
||||
// survives after we release the child handle. Let it run
|
||||
// independently; future CLI instances reconnect via IPC.
|
||||
let _child = guard.commit();
|
||||
return Ok(client);
|
||||
}
|
||||
Err(_error) if Instant::now() < deadline => {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(StartupError::DaemonExited {
|
||||
message: format_diagnostic(
|
||||
format!("timed out waiting for IPC handshake: {error}"),
|
||||
&diagnostics,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_diagnostic(message: String, diagnostics: &Arc<Mutex<VecDeque<String>>>) -> String {
|
||||
let lines = diagnostics.lock().unwrap();
|
||||
if lines.is_empty() {
|
||||
message
|
||||
} else {
|
||||
format!(
|
||||
"{message}; daemon stderr: {}",
|
||||
lines.iter().cloned().collect::<Vec<_>>().join(" | ")
|
||||
)
|
||||
}
|
||||
}
|
||||
305
iota/src/main.rs
305
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,
|
||||
};
|
||||
|
||||
match invocation.command {
|
||||
Command::Help => {
|
||||
print_help();
|
||||
Ok(())
|
||||
}
|
||||
Command::Install { bundle, operator } => {
|
||||
iota_installer::install_linux_bundle_with_operator(
|
||||
Path::new(&bundle),
|
||||
operator.as_deref(),
|
||||
)
|
||||
.map_err(|error| StartupError::Other(format!("Installation failed: {error}")))
|
||||
}
|
||||
command => {
|
||||
if matches!(
|
||||
command,
|
||||
Command::DaemonEnable { .. } | Command::DaemonDisableStartup
|
||||
) {
|
||||
return run_startup_command(command).await;
|
||||
}
|
||||
if !matches!(command, Command::Dashboard) {
|
||||
match iota_core::consent_state::non_interactive_consent() {
|
||||
iota_core::consent_state::NonInteractiveConsent::Accepted => {}
|
||||
iota_core::consent_state::NonInteractiveConsent::RequiresInteractiveAcceptance => {
|
||||
return Err(StartupError::Consent("Run `iota` in an interactive terminal to review and accept the required terms.".into()));
|
||||
}
|
||||
}
|
||||
let ipc = tokio::select! {
|
||||
result = connect_available(&endpoints) => result?,
|
||||
_ = tokio::signal::ctrl_c() => return Err(StartupError::Cancelled),
|
||||
};
|
||||
return run_command(ipc, command).await;
|
||||
}
|
||||
run_dashboard(invocation.theme_override, endpoints).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_startup_command(command: Command) -> Result<(), StartupError> {
|
||||
let manager = iota_process_manager::detect()
|
||||
.await
|
||||
.ok_or_else(|| StartupError::Other("no supported process manager detected".into()))?;
|
||||
let status = match command {
|
||||
Command::DaemonEnable { mode } => {
|
||||
let mode = match mode.as_str() {
|
||||
"socket" | "socket-activated" => iota_process_manager::StartupMode::SocketActivated,
|
||||
"always-on" => iota_process_manager::StartupMode::AlwaysOn,
|
||||
_ => {
|
||||
return Err(StartupError::InvalidCommand(
|
||||
"--mode must be socket or always-on".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
manager
|
||||
.enable_startup(mode)
|
||||
.await
|
||||
.map_err(|e| StartupError::Other(e.to_string()))?
|
||||
}
|
||||
Command::DaemonDisableStartup => manager
|
||||
.disable_startup()
|
||||
.await
|
||||
.map_err(|e| StartupError::Other(e.to_string()))?,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
println!("deployment status: {:?}", status.detected);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn connect_available(
|
||||
endpoints: &daemon_setup_flow::DaemonEndpoints,
|
||||
) -> Result<Arc<IpcClient>, StartupError> {
|
||||
match IpcClient::connect(&endpoints.local).await {
|
||||
Ok(client) => Ok(client),
|
||||
Err(local_error) => IpcClient::connect(&endpoints.system)
|
||||
.await
|
||||
.map_err(|system_error| {
|
||||
if system_error.kind() == std::io::ErrorKind::TimedOut {
|
||||
StartupError::IpcTimedOut(endpoints.system.clone())
|
||||
} else if local_error.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
StartupError::SocketPermissionDenied(endpoints.local.clone())
|
||||
} else {
|
||||
StartupError::Other(format!(
|
||||
"Could not connect to {} or {}: {local_error}; {system_error}",
|
||||
endpoints.local.display(),
|
||||
endpoints.system.display()
|
||||
))
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_dashboard(
|
||||
theme_override: Option<theme::ThemeName>,
|
||||
endpoints: daemon_setup_flow::DaemonEndpoints,
|
||||
) -> Result<(), StartupError> {
|
||||
use std::io::IsTerminal;
|
||||
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
|
||||
return Err(StartupError::Terminal(
|
||||
"stdin and stdout must be interactive terminals".into(),
|
||||
));
|
||||
}
|
||||
if std::env::var("TERM").as_deref() == Ok("dumb") {
|
||||
return Err(StartupError::Terminal(
|
||||
"TERM=dumb does not support the interactive dashboard".into(),
|
||||
));
|
||||
}
|
||||
let session = start_bootstrap_tui_with_theme(theme::resolve(theme::UiConfig::resolve_theme(
|
||||
theme_override,
|
||||
)))
|
||||
.map_err(|error| StartupError::Terminal(error.to_string()))?;
|
||||
let ui = session.ui();
|
||||
let result = async {
|
||||
let consent = iota_core::consent_state::check(ui.clone()).await
|
||||
.map_err(StartupError::Consent)?;
|
||||
if consent != (true, true) {
|
||||
return Err(StartupError::Consent("Cannot continue until the required terms are accepted.".into()));
|
||||
}
|
||||
let initial = tokio::select! {
|
||||
result = connect_available(&endpoints) => result,
|
||||
_ = ui.wait_for_shutdown() => Err(StartupError::Cancelled),
|
||||
};
|
||||
let context = match initial {
|
||||
Ok(client) => daemon_setup_flow::ConnectionContext { ipc: client },
|
||||
Err(_) => {
|
||||
let system = tokio::select! {
|
||||
manager = detect() => manager.ok_or(StartupError::SystemManagerUnavailable),
|
||||
_ = ui.wait_for_shutdown() => return Err(StartupError::Cancelled),
|
||||
}?;
|
||||
// A missing unit is expected before the system daemon has
|
||||
// been installed. Keep bootstrap alive and expose that state
|
||||
// as a disabled setup option instead of treating it as a
|
||||
// fatal startup error.
|
||||
let system_capability = tokio::select! {
|
||||
status = system.iota_startup_status() => status.map(|_| system).map_err(map_process_manager_error),
|
||||
_ = ui.wait_for_shutdown() => return Err(StartupError::Cancelled),
|
||||
};
|
||||
let caps = daemon_setup_flow::Capabilities {
|
||||
executable: daemon_executable(),
|
||||
socket: writable_socket_path(&endpoints.local),
|
||||
system: system_capability,
|
||||
};
|
||||
daemon_setup_flow::run(ui.clone(), &endpoints, caps).await?
|
||||
}
|
||||
};
|
||||
let ipc = context.ipc.clone();
|
||||
ipc.spawn_reconnector();
|
||||
let ui = start_tui(ipc);
|
||||
ui.set_screen(Box::new(MainScreen::new(ui.clone()).await))
|
||||
.await;
|
||||
while !ui.is_shutdown() {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
ui.attach_daemon(ipc).await;
|
||||
let main_screen = MainScreen::new(ui.clone()).await;
|
||||
ui.set_root_screen(Box::new(main_screen)).await;
|
||||
ui.render().await.map_err(|error| StartupError::Terminal(error.to_string()))?;
|
||||
ui.wait_for_shutdown().await;
|
||||
Ok(())
|
||||
}.await;
|
||||
let render_failure = session.shutdown().await;
|
||||
// Terminal restoration comes first; then stop IPC background tasks with
|
||||
// their own bounded shutdown so a lost daemon cannot retain the process.
|
||||
if let Some(ipc) = ui.ipc().await {
|
||||
ipc.shutdown().await;
|
||||
}
|
||||
|
||||
match (result, render_failure) {
|
||||
(Err(error), _) => Err(error),
|
||||
(Ok(()), Some(error)) => Err(StartupError::Terminal(error)),
|
||||
(Ok(()), None) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn map_process_manager_error(error: iota_process_manager::ProcessManagerError) -> StartupError {
|
||||
use iota_process_manager::ProcessManagerErrorKind::*;
|
||||
match error.kind() {
|
||||
PermissionDenied => StartupError::SystemPermissionDenied(error.to_string()),
|
||||
TimedOut => StartupError::SystemCommandTimedOut(error.to_string()),
|
||||
_ => StartupError::Other(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn daemon_executable() -> Result<std::path::PathBuf, StartupError> {
|
||||
let candidate = iota_paths::daemon_executable();
|
||||
if candidate.is_file() {
|
||||
Ok(candidate)
|
||||
} else {
|
||||
Err(StartupError::DaemonExecutableMissing(candidate))
|
||||
}
|
||||
}
|
||||
|
||||
fn writable_socket_path(path: &Path) -> Result<(), StartupError> {
|
||||
let parent = path.parent().ok_or_else(|| {
|
||||
StartupError::LocalSocketNotWritable(
|
||||
path.to_path_buf(),
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidInput, "socket has no parent"),
|
||||
)
|
||||
})?;
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| StartupError::LocalSocketNotWritable(path.to_path_buf(), e))?;
|
||||
let probe = parent.join(format!(".iota-write-probe-{}", std::process::id()));
|
||||
std::fs::File::create(&probe)
|
||||
.map_err(|e| StartupError::LocalSocketNotWritable(path.to_path_buf(), e))?;
|
||||
let _ = std::fs::remove_file(probe);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_help() {
|
||||
println!(
|
||||
"Iota operator console\n\nUsage:\n iota [--theme <name>] Open the dashboard\n iota daemon install --bundle <release.zip> [--operator USER]\n iota status Print daemon readiness and tasks\n iota tasks Print active tasks\n iota users list List users\n iota daemon restart --yes\n iota daemon stop --yes\n\nRun the dashboard in an interactive terminal to review required terms."
|
||||
);
|
||||
}
|
||||
|
||||
async fn run_command(ipc: Arc<IpcClient>, command: Command) -> Result<(), StartupError> {
|
||||
let request = match command {
|
||||
Command::Status => LocalRequest::GetStatus,
|
||||
Command::Tasks => LocalRequest::ListTasks,
|
||||
Command::UsersList => LocalRequest::ListUsers,
|
||||
Command::DaemonRestart { confirmed: true } => LocalRequest::RequestProcessExit {
|
||||
intent: iota_ipc::ExitIntent::Restart,
|
||||
},
|
||||
Command::DaemonStop { confirmed: true } => LocalRequest::RequestProcessExit {
|
||||
intent: iota_ipc::ExitIntent::Stop,
|
||||
},
|
||||
Command::DaemonStopProcess => LocalRequest::RequestProcessExit {
|
||||
intent: iota_ipc::ExitIntent::Stop,
|
||||
},
|
||||
Command::DaemonDaemonStatus => LocalRequest::GetDaemonStatus,
|
||||
Command::DaemonRestart { confirmed: false } | Command::DaemonStop { confirmed: false } => {
|
||||
return Err(StartupError::InvalidCommand(
|
||||
"Refusing destructive command without --yes.".into(),
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
return Err(StartupError::InvalidCommand(
|
||||
"Command cannot be run headlessly.".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
match ipc
|
||||
.send_request(request)
|
||||
.await
|
||||
.map_err(|e| StartupError::Other(e.to_string()))?
|
||||
{
|
||||
ResponseResult::Ok(message) => {
|
||||
println!("{message}");
|
||||
Ok(())
|
||||
}
|
||||
ResponseResult::Error(code) => Err(StartupError::Other(format!(
|
||||
"Daemon request failed: {code:?}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
92
iota/src/startup_error.rs
Normal file
92
iota/src/startup_error.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
use std::{fmt, io, path::PathBuf, process::ExitCode};
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub enum StartupError {
|
||||
Cancelled,
|
||||
DaemonExecutableMissing(PathBuf),
|
||||
LocalSocketNotWritable(PathBuf, io::Error),
|
||||
SystemManagerUnavailable,
|
||||
SystemPermissionDenied(String),
|
||||
SystemCommandTimedOut(String),
|
||||
SocketPermissionDenied(PathBuf),
|
||||
IpcTimedOut(PathBuf),
|
||||
ProtocolMismatch { daemon: u16, minimum: u16 },
|
||||
DaemonExited { message: String },
|
||||
IpcBindUnavailable(String),
|
||||
Terminal(String),
|
||||
Consent(String),
|
||||
InvalidCommand(String),
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl StartupError {
|
||||
pub fn exit_code(&self) -> u8 {
|
||||
match self {
|
||||
Self::Cancelled => 130,
|
||||
Self::InvalidCommand(_) => 2,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl fmt::Display for StartupError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Cancelled => f.write_str("Cancelled."),
|
||||
Self::DaemonExecutableMissing(path) => write!(
|
||||
f,
|
||||
"Daemon executable is missing or not executable: {}",
|
||||
path.display()
|
||||
),
|
||||
Self::LocalSocketNotWritable(path, error) => write!(
|
||||
f,
|
||||
"Local socket path is not writable ({}): {error}",
|
||||
path.display()
|
||||
),
|
||||
Self::SystemManagerUnavailable => {
|
||||
f.write_str("No supported system process manager is available.")
|
||||
}
|
||||
Self::SystemPermissionDenied(message) => {
|
||||
write!(f, "System-level authorization is required: {message}")
|
||||
}
|
||||
Self::SystemCommandTimedOut(command) => {
|
||||
write!(f, "System command timed out: {command}")
|
||||
}
|
||||
Self::SocketPermissionDenied(path) => {
|
||||
write!(f, "Permission denied for IPC socket {}", path.display())
|
||||
}
|
||||
Self::IpcTimedOut(path) => write!(f, "IPC operation timed out for {}", path.display()),
|
||||
Self::ProtocolMismatch { daemon, minimum } => write!(
|
||||
f,
|
||||
"Daemon protocol {daemon} is incompatible; minimum supported version is {minimum}"
|
||||
),
|
||||
Self::DaemonExited { message } => f.write_str(message),
|
||||
Self::IpcBindUnavailable(message) => {
|
||||
write!(f, "Daemon IPC listener is unavailable: {message}")
|
||||
}
|
||||
Self::Terminal(message) => write!(f, "Interactive terminal is unavailable: {message}"),
|
||||
Self::Consent(message) | Self::InvalidCommand(message) | Self::Other(message) => {
|
||||
f.write_str(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn exit_code(error: &StartupError) -> ExitCode {
|
||||
ExitCode::from(error.exit_code())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn cancellation_and_invalid_commands_have_stable_codes() {
|
||||
assert_eq!(StartupError::Cancelled.exit_code(), 130);
|
||||
assert_eq!(StartupError::InvalidCommand("bad".into()).exit_code(), 2);
|
||||
}
|
||||
#[test]
|
||||
fn administrative_errors_are_actionable() {
|
||||
let error = StartupError::SystemPermissionDenied("run as an administrator".into());
|
||||
assert!(error.to_string().contains("authorization"));
|
||||
assert!(error.to_string().contains("administrator"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }
|
||||
|
|
|
|||
43
omikron-connector/src/client.rs
Normal file
43
omikron-connector/src/client.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
use async_trait::async_trait;
|
||||
use mtp::codec::CommunicationValue;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum OmikronError {
|
||||
Disconnected(String),
|
||||
Timeout(String),
|
||||
Authentication(String),
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for OmikronError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Disconnected(v)
|
||||
| Self::Timeout(v)
|
||||
| Self::Authentication(v)
|
||||
| Self::Internal(v) => f.write_str(v),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl std::error::Error for OmikronError {}
|
||||
|
||||
pub enum OmikronStartupError {
|
||||
Construction(String),
|
||||
InitialConnectionTimeout {
|
||||
connection: std::sync::Arc<crate::omikron_connection::OmikronConnection>,
|
||||
},
|
||||
Authentication,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OmikronClient: Send + Sync {
|
||||
async fn send_message(&self, value: &CommunicationValue) -> Result<(), OmikronError>;
|
||||
async fn await_response(
|
||||
&self,
|
||||
value: &CommunicationValue,
|
||||
timeout: Duration,
|
||||
) -> Result<CommunicationValue, OmikronError>;
|
||||
async fn reconnect(&self) -> Result<(), OmikronError>;
|
||||
async fn is_connected(&self) -> bool;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use dashmap::DashMap;
|
||||
use dashmap::{DashMap, DashSet};
|
||||
use iota_logger::{log, log_cv_in, log_cv_out, log_t};
|
||||
use iota_state::ACTIVE_TASKS;
|
||||
use iota_state::AppState;
|
||||
use iota_storage::util::chat_files::{self, MessageState, change_message_state};
|
||||
use iota_storage::util::config_util::{CONFIG, modify_config};
|
||||
use iota_storage::util::e2ee_storage::{self, PendingChatSecretForward, StoredChatSecret};
|
||||
|
|
@ -19,6 +19,7 @@ use tokio::time::sleep;
|
|||
use tokio_util::sync::CancellationToken;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::client::{OmikronClient, OmikronError};
|
||||
use crate::omega_discovery;
|
||||
|
||||
use iota_connection::message_common::*;
|
||||
|
|
@ -106,7 +107,7 @@ const MAX_CONCURRENT_HANDLERS: usize = 20;
|
|||
// ============================================================================
|
||||
|
||||
pub struct WaitingTask {
|
||||
pub task: Box<dyn FnOnce(Arc<OmikronConnection>, CommunicationValue) -> bool + Send + Sync>,
|
||||
pub task: Box<dyn FnOnce(CommunicationValue) -> bool + Send + Sync>,
|
||||
pub inserted_at: Instant,
|
||||
}
|
||||
|
||||
|
|
@ -163,14 +164,20 @@ pub struct OmikronConnection {
|
|||
pub(crate) missed_pongs: Arc<AtomicU32>,
|
||||
handler_semaphore: Arc<Semaphore>,
|
||||
cancellation: CancellationToken,
|
||||
pub(crate) active_tasks: Arc<DashSet<String>>,
|
||||
pub(crate) app: Arc<std::sync::Mutex<AppState>>,
|
||||
}
|
||||
|
||||
impl OmikronConnection {
|
||||
pub fn new() -> Self {
|
||||
Self::with_cancellation(CancellationToken::new())
|
||||
pub fn new(active_tasks: Arc<DashSet<String>>, app: Arc<std::sync::Mutex<AppState>>) -> Self {
|
||||
Self::with_cancellation(CancellationToken::new(), active_tasks, app)
|
||||
}
|
||||
|
||||
pub fn with_cancellation(cancellation: CancellationToken) -> Self {
|
||||
pub fn with_cancellation(
|
||||
cancellation: CancellationToken,
|
||||
active_tasks: Arc<DashSet<String>>,
|
||||
app: Arc<std::sync::Mutex<AppState>>,
|
||||
) -> Self {
|
||||
let (shutdown_tx, _) = watch::channel(false);
|
||||
let (state_watch_tx, _) = watch::channel(ConnectionState::Disconnected);
|
||||
|
||||
|
|
@ -190,6 +197,8 @@ impl OmikronConnection {
|
|||
missed_pongs: Arc::new(AtomicU32::new(0)),
|
||||
handler_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HANDLERS)),
|
||||
cancellation,
|
||||
active_tasks,
|
||||
app,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -388,7 +397,7 @@ impl OmikronConnection {
|
|||
*self.heartbeat_handle.lock().await = Some(heartbeat_handle);
|
||||
|
||||
{
|
||||
ACTIVE_TASKS.insert("Omikron Listener".to_string());
|
||||
self.active_tasks.insert("Omikron Listener".to_string());
|
||||
}
|
||||
|
||||
// Wait for read loop to complete
|
||||
|
|
@ -396,7 +405,7 @@ impl OmikronConnection {
|
|||
*self.sender.write().await = None;
|
||||
self.set_state(ConnectionState::Disconnected).await;
|
||||
{
|
||||
ACTIVE_TASKS.remove("Omikron Listener");
|
||||
self.active_tasks.remove("Omikron Listener");
|
||||
}
|
||||
|
||||
if let Some(handle) = self.heartbeat_handle.lock().await.take() {
|
||||
|
|
@ -570,7 +579,7 @@ impl OmikronConnection {
|
|||
Ok(cv) => {
|
||||
let msg_id = cv.get_id();
|
||||
if let Some((_, task)) = WAITING_TASKS.remove(&msg_id) {
|
||||
if (task.task)(self.clone(), cv.clone()) {
|
||||
if (task.task)(cv.clone()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
|
@ -832,7 +841,7 @@ impl OmikronConnection {
|
|||
let msg_id = cv.get_id();
|
||||
|
||||
if let Some((_, task)) = WAITING_TASKS.remove(&msg_id) {
|
||||
if (task.task)(self.clone(), cv.clone()) {
|
||||
if (task.task)(cv.clone()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -1797,7 +1806,7 @@ impl OmikronConnection {
|
|||
let response = CommunicationValue::new(CommunicationType::ErrorInternal)
|
||||
.with_id(key)
|
||||
.add_typed_default(DataType::Message, DataValue::Str(reason.clone()));
|
||||
let _ = (waiting_task.task)(OMIKRON_CONNECTION.clone(), response);
|
||||
let _ = (waiting_task.task)(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1821,7 +1830,7 @@ impl OmikronConnection {
|
|||
WAITING_TASKS.insert(
|
||||
msg_id,
|
||||
WaitingTask {
|
||||
task: Box::new(move |_, response_cv| {
|
||||
task: Box::new(move |response_cv| {
|
||||
let _ = tx.send(response_cv);
|
||||
true
|
||||
}),
|
||||
|
|
@ -1932,20 +1941,26 @@ impl OmikronConnection {
|
|||
// Global Instance
|
||||
// ============================================================================
|
||||
|
||||
pub static OMIKRON_CONNECTION: LazyLock<Arc<OmikronConnection>> = LazyLock::new(|| {
|
||||
let conn = Arc::new(OmikronConnection::new());
|
||||
|
||||
start_task_cleanup_loop();
|
||||
|
||||
conn
|
||||
});
|
||||
|
||||
pub async fn get_omikron_connection(
|
||||
pub async fn connect_initial(
|
||||
cancellation: CancellationToken,
|
||||
) -> Option<Arc<OmikronConnection>> {
|
||||
let conn = Arc::new(OmikronConnection::with_cancellation(cancellation));
|
||||
active_tasks: Arc<DashSet<String>>,
|
||||
app: Arc<std::sync::Mutex<AppState>>,
|
||||
) -> Result<Arc<OmikronConnection>, crate::client::OmikronStartupError> {
|
||||
let conn = Arc::new(OmikronConnection::with_cancellation(
|
||||
cancellation,
|
||||
active_tasks,
|
||||
app,
|
||||
));
|
||||
conn.connect().await;
|
||||
Some(conn)
|
||||
match conn.await_connection(Some(CONNECTION_TIMEOUT)).await {
|
||||
Ok(()) => Ok(conn),
|
||||
Err(_) if conn.has_auth_failure().await => {
|
||||
Err(crate::client::OmikronStartupError::Authentication)
|
||||
}
|
||||
Err(_) => {
|
||||
Err(crate::client::OmikronStartupError::InitialConnectionTimeout { connection: conn })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl iota_connection::connection_handler::ConnectionHandler for OmikronConnection {
|
||||
|
|
@ -1973,3 +1988,56 @@ impl iota_connection::connection_handler::ConnectionHandler for OmikronConnectio
|
|||
OmikronConnection::stop(self).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl OmikronClient for OmikronConnection {
|
||||
async fn send_message(&self, value: &CommunicationValue) -> Result<(), OmikronError> {
|
||||
Self::send_message(self, value)
|
||||
.await
|
||||
.map_err(OmikronError::Disconnected)
|
||||
}
|
||||
|
||||
async fn await_response(
|
||||
&self,
|
||||
value: &CommunicationValue,
|
||||
timeout: Duration,
|
||||
) -> Result<CommunicationValue, OmikronError> {
|
||||
Self::await_response(self, value, Some(timeout))
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if error.contains("timed out") {
|
||||
OmikronError::Timeout(error)
|
||||
} else {
|
||||
OmikronError::Disconnected(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn reconnect(&self) -> Result<(), OmikronError> {
|
||||
let this = Arc::new(Self {
|
||||
state: self.state.clone(),
|
||||
state_watch_tx: self.state_watch_tx.clone(),
|
||||
sender: self.sender.clone(),
|
||||
connection_loop_handle: self.connection_loop_handle.clone(),
|
||||
last_ping: self.last_ping.clone(),
|
||||
heartbeat_handle: self.heartbeat_handle.clone(),
|
||||
connection_id: self.connection_id,
|
||||
shutdown_tx: self.shutdown_tx.clone(),
|
||||
reconnect_on_close: self.reconnect_on_close.clone(),
|
||||
auth_failure: self.auth_failure.clone(),
|
||||
app_challenges: self.app_challenges.clone(),
|
||||
app_sessions: self.app_sessions.clone(),
|
||||
missed_pongs: self.missed_pongs.clone(),
|
||||
handler_semaphore: self.handler_semaphore.clone(),
|
||||
cancellation: self.cancellation.clone(),
|
||||
active_tasks: self.active_tasks.clone(),
|
||||
app: self.app.clone(),
|
||||
});
|
||||
Self::reconnect(&this).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn is_connected(&self) -> bool {
|
||||
Self::is_connected(self).await
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,21 +8,22 @@ use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
|
|||
use rand_core::{OsRng, RngCore};
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::OmikronClient;
|
||||
use crate::omega_discovery;
|
||||
use crate::omikron_connection::OMIKRON_CONNECTION;
|
||||
|
||||
pub async fn create_user(username: &str) -> (Option<UserProfile>, Option<String>) {
|
||||
pub async fn create_user(
|
||||
connection: &dyn OmikronClient,
|
||||
username: &str,
|
||||
) -> (Option<UserProfile>, Option<String>) {
|
||||
let register_communication_value = CommunicationValue::new(CommunicationType::GetRegister);
|
||||
|
||||
let connection = OMIKRON_CONNECTION.clone();
|
||||
|
||||
let response_communication_value = match connection
|
||||
.await_response(®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<UserProfile>, Option<String>
|
|||
.add_typed_default(DataType::ResetToken, DataValue::Str(reset_token));
|
||||
|
||||
let response_communication_value = connection
|
||||
.await_response(&communication_value, Some(Duration::from_secs(20)))
|
||||
.await_response(&communication_value, Duration::from_secs(20))
|
||||
.await;
|
||||
|
||||
if let Ok(response) = response_communication_value {
|
||||
|
|
@ -84,13 +85,15 @@ pub async fn create_user(username: &str) -> (Option<UserProfile>, Option<String>
|
|||
save_file(
|
||||
"",
|
||||
&format!("{}.tu", username),
|
||||
&format!("{}@{}::{}", user_id, omega_discovery::omega_host(), keyring_b64),
|
||||
&format!(
|
||||
"{}@{}::{}",
|
||||
user_id,
|
||||
omega_discovery::omega_host(),
|
||||
keyring_b64
|
||||
),
|
||||
);
|
||||
|
||||
add_user(user_profile.clone());
|
||||
save_users();
|
||||
(
|
||||
Some(user_profile),
|
||||
Some(keyring_b64),
|
||||
)
|
||||
(Some(user_profile), Some(keyring_b64))
|
||||
}
|
||||
|
|
|
|||
BIN
src/.DS_Store
vendored
BIN
src/.DS_Store
vendored
Binary file not shown.
|
|
@ -1,135 +0,0 @@
|
|||
/* This file is used for the auto update function for the Iota.
|
||||
* It connects to the git server from methanium and checks if
|
||||
* the version has updated inside the cargo.toml file.*/
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use semver::Version;
|
||||
use serde::Deserialize;
|
||||
use std::fs::File;
|
||||
use std::io::copy;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
use crate::log; // For logging messages into the iota
|
||||
|
||||
const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
const API_BASE: &str = "https://git.methanium.net/api/v1";
|
||||
const OWNER: &str = "Tensamin";
|
||||
const REPO: &str = "Iota";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Release {
|
||||
tag_name: String,
|
||||
assets: Vec<Asset>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Asset {
|
||||
name: String,
|
||||
browser_download_url: String,
|
||||
}
|
||||
|
||||
async fn latest_release() -> Result<Release> {
|
||||
let url = format!("{API_BASE}/repos/{OWNER}/{REPO}/releases/latest");
|
||||
|
||||
let response = reqwest::get(&url)
|
||||
.await
|
||||
.context("failed to query latest release.")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!("release API returned {}", response.status()));
|
||||
}
|
||||
|
||||
Ok(response
|
||||
.json()
|
||||
.await
|
||||
.context("failed to parse release JSON")?)
|
||||
}
|
||||
|
||||
async fn parse_tag_version(tag: &str) -> Result<Version> {
|
||||
let normalized = tag.strip_prefix('v').unwrap_or(tag);
|
||||
Ok(Version::parse(normalized)?)
|
||||
}
|
||||
|
||||
async fn current_version() -> Result<Version> {
|
||||
Ok(Version::parse(CURRENT_VERSION)?)
|
||||
}
|
||||
|
||||
async fn asset_name_for_current_platform() -> String {
|
||||
let os = std::env::consts::OS;
|
||||
let arch = std::env::consts::ARCH;
|
||||
|
||||
match (os, arch) {
|
||||
("linux", "x86_64") => "iota-linux-x86_64".to_string(),
|
||||
("linux", "aarch64") => "iota-linux-aarch64".to_string(),
|
||||
("windows", "x86_64") => "iota-windows-x86_64.exe".to_string(),
|
||||
("macos", "x86_64") => "iota-macos-x86_64".to_string(),
|
||||
("macos", "aarch64") => "iota-macos-aarch64".to_string(),
|
||||
_ => panic!("unsupported platform: {os}/{arch}"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn download_asset(url: &str) -> Result<NamedTempFile> {
|
||||
let mut response = reqwest::get(url)
|
||||
.await
|
||||
.context("failed to download asset")?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!("asset download returned {}", response.status()));
|
||||
}
|
||||
|
||||
let tmp = NamedTempFile::new().context("failed to create temp file")?;
|
||||
let mut out = File::create(tmp.path()).context("failed to open temp file")?;
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.context("failed to read response bytes")?;
|
||||
|
||||
std::fs::write(tmp.path(), &bytes).context("failed to write file")?;
|
||||
|
||||
Ok(tmp)
|
||||
}
|
||||
|
||||
async fn check_for_update() -> Result<Option<Release>> {
|
||||
let current = current_version().await?;
|
||||
let release = latest_release().await?;
|
||||
let latest = parse_tag_version(&release.tag_name).await?;
|
||||
|
||||
if latest > current {
|
||||
Ok(Some(release))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
async fn perform_update() -> Result<bool> {
|
||||
let Some(release) = check_for_update().await? else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
let wanted_asset = asset_name_for_current_platform().await;
|
||||
|
||||
let asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.name == wanted_asset)
|
||||
.ok_or_else(|| anyhow!("no matching asset found: {}", wanted_asset))?;
|
||||
|
||||
log!("Downloading update: {}", asset.name);
|
||||
|
||||
let downloaded = download_asset(&asset.browser_download_url).await?;
|
||||
|
||||
self_replace::self_replace(downloaded.path())
|
||||
.context("failed to replace current executable")?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn check_update() -> Result<bool> {
|
||||
if perform_update().await? {
|
||||
return Ok(true);
|
||||
} else {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2
systemd/sysusers.d/iota.conf
Normal file
2
systemd/sysusers.d/iota.conf
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
g iota-operators -
|
||||
u iota - "Tensamin Iota daemon" /var/lib/iota
|
||||
|
|
@ -1,55 +1,95 @@
|
|||
use bytes::Bytes;
|
||||
use iota_logger::log;
|
||||
use iota_util::file_util::load_file_vec;
|
||||
use mtp::host::HostConfig;
|
||||
use mtp::webserver::{Http3Request, Http3Response, MTPWebServer, WebServerConfig};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use mtp::webserver::{HttpRequest, HttpResponse, MTPWebServer, WebServerConfig};
|
||||
use std::{net::IpAddr, path::PathBuf, sync::Arc};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
const CERT_PATH: &str = "certs/cert.pem";
|
||||
const KEY_PATH: &str = "certs/cert.key";
|
||||
|
||||
async fn root(_request: Http3Request, response: Http3Response) -> Http3Response {
|
||||
static_file("index.html", response).await
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum WebMode {
|
||||
Disabled,
|
||||
Loopback,
|
||||
Network,
|
||||
}
|
||||
|
||||
async fn static_file(path: &str, response: Http3Response) -> Http3Response {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TlsConfig {
|
||||
pub certificate: PathBuf,
|
||||
pub key: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WebConfig {
|
||||
pub mode: WebMode,
|
||||
pub bind: IpAddr,
|
||||
pub port: u16,
|
||||
pub asset_dir: PathBuf,
|
||||
pub tls: Option<TlsConfig>,
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum WebServerError {
|
||||
Disabled,
|
||||
MissingTls(String),
|
||||
Io(String),
|
||||
Startup(String),
|
||||
}
|
||||
impl std::fmt::Display for WebServerError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{self:?}")
|
||||
}
|
||||
}
|
||||
impl std::error::Error for WebServerError {}
|
||||
|
||||
pub struct WebServerHandle {
|
||||
cancellation: CancellationToken,
|
||||
join: Mutex<Option<JoinHandle<()>>>,
|
||||
}
|
||||
impl WebServerHandle {
|
||||
pub async fn shutdown(&self) {
|
||||
self.cancellation.cancel();
|
||||
self.join().await;
|
||||
}
|
||||
pub async fn join(&self) {
|
||||
if let Some(join) = self.join.lock().await.take() {
|
||||
let _ = join.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn root(asset_dir: PathBuf, _request: HttpRequest, response: HttpResponse) -> HttpResponse {
|
||||
static_file(asset_dir, "index.html".into(), response).await
|
||||
}
|
||||
async fn static_file(asset_dir: PathBuf, path: String, response: HttpResponse) -> HttpResponse {
|
||||
let file = path.trim_start_matches('/');
|
||||
let file = if file.is_empty() { "index.html" } else { file };
|
||||
|
||||
if file.split('/').any(|component| component == "..") {
|
||||
return response
|
||||
.status(http::StatusCode::BAD_REQUEST)
|
||||
.body("invalid path");
|
||||
}
|
||||
|
||||
let path = std::path::Path::new("web").join(file);
|
||||
let Some(parent) = path.parent().and_then(|path| path.to_str()) else {
|
||||
let path = asset_dir.join(file);
|
||||
let body = match tokio::fs::read(&path).await {
|
||||
Ok(body) => body,
|
||||
Err(_) => {
|
||||
return response
|
||||
.status(http::StatusCode::NOT_FOUND)
|
||||
.body("not found");
|
||||
}
|
||||
};
|
||||
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
return response
|
||||
.status(http::StatusCode::NOT_FOUND)
|
||||
.body("not found");
|
||||
};
|
||||
|
||||
match load_file_vec(parent, name) {
|
||||
Ok(body) => response
|
||||
let name = path.file_name().and_then(|v| v.to_str()).unwrap_or("");
|
||||
response
|
||||
.status(http::StatusCode::OK)
|
||||
.header("content-type", content_type(name))
|
||||
.body(Bytes::from(body)),
|
||||
Err(_) => response
|
||||
.status(http::StatusCode::NOT_FOUND)
|
||||
.body("not found"),
|
||||
.body(Bytes::from(body))
|
||||
}
|
||||
}
|
||||
|
||||
fn content_type(name: &str) -> &'static str {
|
||||
match std::path::Path::new(name)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.and_then(|e| e.to_str())
|
||||
{
|
||||
Some("html") => "text/html; charset=utf-8",
|
||||
Some("css") => "text/css; charset=utf-8",
|
||||
|
|
@ -62,60 +102,53 @@ fn content_type(name: &str) -> &'static str {
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn start(port: u16, cancellation: CancellationToken) -> bool {
|
||||
let certificate = match tokio::fs::read(CERT_PATH).await {
|
||||
Ok(certificate) => certificate,
|
||||
Err(error) => {
|
||||
log!("MTP web server certificate load failed: {}", error);
|
||||
return false;
|
||||
pub async fn start(
|
||||
config: WebConfig,
|
||||
parent: CancellationToken,
|
||||
) -> Result<Option<Arc<WebServerHandle>>, WebServerError> {
|
||||
if config.mode == WebMode::Disabled {
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let key = match tokio::fs::read(KEY_PATH).await {
|
||||
Ok(key) => key,
|
||||
Err(error) => {
|
||||
log!("MTP web server key load failed: {}", error);
|
||||
return false;
|
||||
if config.mode == WebMode::Network && config.tls.is_none() {
|
||||
return Err(WebServerError::MissingTls(
|
||||
"network mode requires TLS".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let host_config = HostConfig::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port, certificate, key);
|
||||
let web_config = match WebServerConfig::new().route("/", root).and_then(|config| {
|
||||
config.fallback(|request, response| async move {
|
||||
static_file(request.uri.path(), response).await
|
||||
let tls = config
|
||||
.tls
|
||||
.ok_or_else(|| WebServerError::MissingTls("certificate and key are required".into()))?;
|
||||
let certificate = tokio::fs::read(&tls.certificate)
|
||||
.await
|
||||
.map_err(|e| WebServerError::Io(e.to_string()))?;
|
||||
let key = tokio::fs::read(&tls.key)
|
||||
.await
|
||||
.map_err(|e| WebServerError::Io(e.to_string()))?;
|
||||
let host_config = HostConfig::new(config.bind, config.port, certificate, key);
|
||||
let assets = config.asset_dir.clone();
|
||||
let web_config = WebServerConfig::new()
|
||||
.route("/", move |request, response| {
|
||||
root(assets.clone(), request, response)
|
||||
})
|
||||
}) {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
log!("MTP web server route setup failed: {}", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let mut server = match MTPWebServer::new(host_config, web_config).await {
|
||||
Ok(server) => server,
|
||||
Err(error) => {
|
||||
log!("MTP web server startup failed: {}", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
log!("MTP web server running on port {}", port);
|
||||
tokio::spawn(async move {
|
||||
.and_then(|web_config| {
|
||||
let assets = config.asset_dir.clone();
|
||||
web_config.fallback(move |request, response| {
|
||||
let path = request.uri.path().to_string();
|
||||
static_file(assets.clone(), path, response)
|
||||
})
|
||||
})
|
||||
.map_err(|e| WebServerError::Startup(e.to_string()))?;
|
||||
let mut server = MTPWebServer::new(host_config, web_config)
|
||||
.await
|
||||
.map_err(|e| WebServerError::Startup(e.to_string()))?;
|
||||
let cancellation = parent.child_token();
|
||||
let task_cancellation = cancellation.clone();
|
||||
let join = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
result = server.accept() => {
|
||||
match result {
|
||||
Ok(Some(_connection)) => {}
|
||||
Ok(None) => break,
|
||||
Err(error) => log!("MTP webserver connection failed: {}", error),
|
||||
}
|
||||
}
|
||||
_ = cancellation.cancelled() => {
|
||||
server.shutdown().await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
tokio::select! { result = server.accept() => match result { Ok(Some(_)) => {}, Ok(None) => break, Err(error) => log!("MTP webserver connection failed: {}", error) }, _ = task_cancellation.cancelled() => { server.shutdown().await; break; } }
|
||||
}
|
||||
});
|
||||
true
|
||||
Ok(Some(Arc::new(WebServerHandle {
|
||||
cancellation,
|
||||
join: Mutex::new(Some(join)),
|
||||
})))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
use crate::server::is_local_network;
|
||||
use actix_web::{HttpRequest, HttpResponse, Responder, web};
|
||||
use iota_storage::util::config_util::{modify_config, CONFIG};
|
||||
use iota_state::DaemonState;
|
||||
use iota_storage::util::config_util::{CONFIG, modify_config};
|
||||
use serde_json::{Value, json};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub fn api_config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(
|
||||
|
|
@ -148,31 +150,37 @@ async fn users_add(
|
|||
_ => return error(),
|
||||
};
|
||||
|
||||
if let (Some(user), Some(_)) = omikron_connector::user_ops::create_user(username).await {
|
||||
let val = user.frontend().to_string();
|
||||
let s_val: Value = serde_json::from_str(&val).unwrap_or(Value::Null);
|
||||
HttpResponse::Ok().json(s_val)
|
||||
} else {
|
||||
// The legacy web API is intentionally quarantined until it can use the
|
||||
// daemon's authenticated command/service boundary. It must not create a
|
||||
// second connector or mutate daemon storage directly.
|
||||
let _ = username;
|
||||
error()
|
||||
}
|
||||
}
|
||||
|
||||
async fn shutdown(req: HttpRequest, ssl: web::Data<bool>) -> impl Responder {
|
||||
async fn shutdown(
|
||||
req: HttpRequest,
|
||||
ssl: web::Data<bool>,
|
||||
state: web::Data<Arc<DaemonState>>,
|
||||
) -> impl Responder {
|
||||
if !is_allowed_req(&req, *ssl.get_ref()) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
*iota_state::SHUTDOWN.write().await = true;
|
||||
*state.shutdown.write().await = true;
|
||||
success()
|
||||
}
|
||||
|
||||
async fn reload(req: HttpRequest, ssl: web::Data<bool>) -> impl Responder {
|
||||
async fn reload(
|
||||
req: HttpRequest,
|
||||
ssl: web::Data<bool>,
|
||||
state: web::Data<Arc<DaemonState>>,
|
||||
) -> impl Responder {
|
||||
if !is_allowed_req(&req, *ssl.get_ref()) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
*iota_state::SHUTDOWN.write().await = true;
|
||||
*iota_state::RELOAD.write().await = true;
|
||||
*state.shutdown.write().await = true;
|
||||
*state.reload.write().await = true;
|
||||
|
||||
success()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ use crate::api::api_config;
|
|||
use crate::web_path_parser;
|
||||
use actix_web::{App, HttpServer, dev::ServerHandle, web};
|
||||
use iota_logger::log;
|
||||
use iota_state::{ACTIVE_TASKS, SHUTDOWN};
|
||||
use iota_state::DaemonState;
|
||||
use iota_util::file_util::load_file_buf;
|
||||
use rustls::ServerConfig;
|
||||
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
|
||||
|
|
@ -16,20 +16,23 @@ use std::{
|
|||
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
pub async fn start(port: u16) -> bool {
|
||||
pub async fn start(port: u16, state: Arc<DaemonState>) -> bool {
|
||||
let (tx, rx) = oneshot::channel::<ServerHandle>();
|
||||
|
||||
let bind_addr = std::env::var("BIND_ADDRESS").unwrap_or_else(|_| "0.0.0.0".to_string());
|
||||
let bind_ip: std::net::IpAddr = bind_addr.parse().expect("Invalid BIND_ADDRESS");
|
||||
|
||||
let server_state = state.clone();
|
||||
let _ = tokio::spawn(async move {
|
||||
let server = match load_tls_config() {
|
||||
Ok(Some(tls_config)) => {
|
||||
log!("HTTPS (HTTP/2) Server running on {}:{}", bind_addr, port);
|
||||
let _config = (*tls_config).clone();
|
||||
let app_state = server_state.clone();
|
||||
HttpServer::new(move || {
|
||||
App::new()
|
||||
.app_data(web::Data::new(true))
|
||||
.app_data(web::Data::from(app_state.clone()))
|
||||
.configure(api_config)
|
||||
.default_service(web::to(web_path_parser::handle))
|
||||
})
|
||||
|
|
@ -39,9 +42,11 @@ pub async fn start(port: u16) -> bool {
|
|||
}
|
||||
Ok(_) => {
|
||||
log!("HTTP Server running on {}:{}", bind_addr, port);
|
||||
let app_state = server_state.clone();
|
||||
HttpServer::new(move || {
|
||||
App::new()
|
||||
.app_data(web::Data::new(false))
|
||||
.app_data(web::Data::from(app_state.clone()))
|
||||
.configure(api_config)
|
||||
.default_service(web::to(web_path_parser::handle))
|
||||
})
|
||||
|
|
@ -58,15 +63,15 @@ pub async fn start(port: u16) -> bool {
|
|||
let server_handle = server.handle();
|
||||
tx.send(server_handle).unwrap();
|
||||
|
||||
ACTIVE_TASKS.insert("WebServer".into());
|
||||
server_state.active_tasks.insert("WebServer".into());
|
||||
server.await.unwrap();
|
||||
ACTIVE_TASKS.remove("WebServer");
|
||||
server_state.active_tasks.remove("WebServer");
|
||||
log!("Web Server shutdown complete.");
|
||||
});
|
||||
|
||||
if let Ok(server_handle) = rx.await {
|
||||
tokio::spawn(async move {
|
||||
wait_for_shutdown(server_handle).await;
|
||||
wait_for_shutdown(server_handle, state).await;
|
||||
});
|
||||
true
|
||||
} else {
|
||||
|
|
@ -74,9 +79,9 @@ pub async fn start(port: u16) -> bool {
|
|||
}
|
||||
}
|
||||
|
||||
async fn wait_for_shutdown(server_handle: ServerHandle) {
|
||||
async fn wait_for_shutdown(server_handle: ServerHandle, state: Arc<DaemonState>) {
|
||||
loop {
|
||||
if *SHUTDOWN.read().await {
|
||||
if *state.shutdown.read().await {
|
||||
log!("Shutdown signal received.");
|
||||
server_handle.stop(true).await;
|
||||
break;
|
||||
|
|
|
|||
Loading…
Reference in a new issue