[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",
|
"foldhash",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"impl-more 0.3.1",
|
"impl-more 0.3.2",
|
||||||
"itoa",
|
"itoa",
|
||||||
"language-tags",
|
"language-tags",
|
||||||
"log",
|
"log",
|
||||||
|
|
@ -407,7 +407,7 @@ checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.1",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1558,9 +1558,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "glob"
|
name = "glob"
|
||||||
version = "0.3.3"
|
version = "0.3.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
|
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
|
|
@ -1839,9 +1839,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.10.1"
|
version = "1.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
|
checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|
@ -2040,9 +2040,9 @@ checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "impl-more"
|
name = "impl-more"
|
||||||
version = "0.3.1"
|
version = "0.3.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "35a84fd5aa25fae5c0f4a33d9cac2ca017fc622cbd089be2229993514990f870"
|
checksum = "134d2c4324d61664107020b79019cf6a6aec153f0b79bc9619ee9e794a5fb021"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
|
|
@ -2090,6 +2090,11 @@ name = "iota"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"iota-cli",
|
"iota-cli",
|
||||||
|
"iota-core",
|
||||||
|
"iota-installer",
|
||||||
|
"iota-ipc",
|
||||||
|
"iota-paths",
|
||||||
|
"iota-process-manager",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
]
|
]
|
||||||
|
|
@ -2162,6 +2167,8 @@ dependencies = [
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"iota-ipc",
|
"iota-ipc",
|
||||||
"iota-logger",
|
"iota-logger",
|
||||||
|
"iota-paths",
|
||||||
|
"iota-process-manager",
|
||||||
"iota-state",
|
"iota-state",
|
||||||
"iota-storage",
|
"iota-storage",
|
||||||
"iota-terms",
|
"iota-terms",
|
||||||
|
|
@ -2180,15 +2187,19 @@ dependencies = [
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pemfile",
|
"rustls-pemfile",
|
||||||
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_yaml",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
"strum 0.27.2",
|
"strum 0.27.2",
|
||||||
"strum_macros 0.27.2",
|
"strum_macros 0.27.2",
|
||||||
"sysinfo",
|
"sysinfo",
|
||||||
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite",
|
"tokio-tungstenite",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tungstenite",
|
"tungstenite",
|
||||||
|
"unicode-width",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"warp",
|
"warp",
|
||||||
"x448",
|
"x448",
|
||||||
|
|
@ -2204,6 +2215,31 @@ dependencies = [
|
||||||
"mtp",
|
"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]]
|
[[package]]
|
||||||
name = "iota-daemon"
|
name = "iota-daemon"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -2211,6 +2247,7 @@ dependencies = [
|
||||||
"iota-daemon-lib",
|
"iota-daemon-lib",
|
||||||
"iota-ipc",
|
"iota-ipc",
|
||||||
"iota-logger",
|
"iota-logger",
|
||||||
|
"iota-paths",
|
||||||
"iota-state",
|
"iota-state",
|
||||||
"iota-storage",
|
"iota-storage",
|
||||||
"omikron-connector",
|
"omikron-connector",
|
||||||
|
|
@ -2223,6 +2260,7 @@ dependencies = [
|
||||||
name = "iota-daemon-lib"
|
name = "iota-daemon-lib"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
"iota-ipc",
|
"iota-ipc",
|
||||||
"iota-logger",
|
"iota-logger",
|
||||||
|
|
@ -2233,11 +2271,23 @@ dependencies = [
|
||||||
"mtp",
|
"mtp",
|
||||||
"omikron-connector",
|
"omikron-connector",
|
||||||
"sysinfo",
|
"sysinfo",
|
||||||
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iota-installer"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"iota-paths",
|
||||||
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
|
"zip",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iota-ipc"
|
name = "iota-ipc"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -2260,6 +2310,18 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iota-paths"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iota-process-manager"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iota-state"
|
name = "iota-state"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -2324,6 +2386,7 @@ dependencies = [
|
||||||
"aes-gcm",
|
"aes-gcm",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
"ed25519-dalek",
|
||||||
"hex",
|
"hex",
|
||||||
"hkdf 0.12.4",
|
"hkdf 0.12.4",
|
||||||
"iota-logger",
|
"iota-logger",
|
||||||
|
|
@ -2334,9 +2397,9 @@ dependencies = [
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"self-replace",
|
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
"sysinfo",
|
"sysinfo",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
|
@ -2353,6 +2416,7 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"hex",
|
"hex",
|
||||||
|
"iota-paths",
|
||||||
"mtp",
|
"mtp",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"sysinfo",
|
"sysinfo",
|
||||||
|
|
@ -2543,9 +2607,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.186"
|
version = "0.2.189"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libm"
|
name = "libm"
|
||||||
|
|
@ -2778,7 +2842,7 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp"
|
name = "mtp"
|
||||||
version = "0.2.0"
|
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 = [
|
dependencies = [
|
||||||
"mtp-client",
|
"mtp-client",
|
||||||
"mtp-codec",
|
"mtp-codec",
|
||||||
|
|
@ -2794,7 +2858,7 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-client"
|
name = "mtp-client"
|
||||||
version = "0.2.0"
|
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 = [
|
dependencies = [
|
||||||
"mtp-codec",
|
"mtp-codec",
|
||||||
"mtp-common",
|
"mtp-common",
|
||||||
|
|
@ -2807,7 +2871,7 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-codec"
|
name = "mtp-codec"
|
||||||
version = "0.2.0"
|
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 = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
|
|
@ -2820,7 +2884,7 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-common"
|
name = "mtp-common"
|
||||||
version = "0.2.0"
|
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 = [
|
dependencies = [
|
||||||
"quinn",
|
"quinn",
|
||||||
"rustls",
|
"rustls",
|
||||||
|
|
@ -2831,7 +2895,7 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-crypto"
|
name = "mtp-crypto"
|
||||||
version = "0.2.0"
|
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 = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"chacha20poly1305",
|
"chacha20poly1305",
|
||||||
|
|
@ -2853,7 +2917,7 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-files"
|
name = "mtp-files"
|
||||||
version = "0.2.0"
|
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 = [
|
dependencies = [
|
||||||
"mtp-crypto",
|
"mtp-crypto",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
|
|
@ -2864,7 +2928,7 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-host"
|
name = "mtp-host"
|
||||||
version = "0.2.0"
|
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 = [
|
dependencies = [
|
||||||
"mtp-codec",
|
"mtp-codec",
|
||||||
"mtp-common",
|
"mtp-common",
|
||||||
|
|
@ -2879,7 +2943,7 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-transport"
|
name = "mtp-transport"
|
||||||
version = "0.2.0"
|
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 = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"mtp-codec",
|
"mtp-codec",
|
||||||
|
|
@ -2897,7 +2961,7 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-type-map"
|
name = "mtp-type-map"
|
||||||
version = "0.2.0"
|
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 = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_yaml",
|
"serde_yaml",
|
||||||
|
|
@ -2906,7 +2970,7 @@ dependencies = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mtp-webserver"
|
name = "mtp-webserver"
|
||||||
version = "0.2.0"
|
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 = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|
@ -2914,6 +2978,9 @@ dependencies = [
|
||||||
"h3-quinn",
|
"h3-quinn",
|
||||||
"h3-webtransport",
|
"h3-webtransport",
|
||||||
"http 1.4.2",
|
"http 1.4.2",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
"mtp-codec",
|
"mtp-codec",
|
||||||
"mtp-common",
|
"mtp-common",
|
||||||
"mtp-crypto",
|
"mtp-crypto",
|
||||||
|
|
@ -2924,6 +2991,8 @@ dependencies = [
|
||||||
"rustls",
|
"rustls",
|
||||||
"thiserror 2.0.19",
|
"thiserror 2.0.19",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tokio-stream",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -3074,6 +3143,7 @@ dependencies = [
|
||||||
name = "omikron-connector"
|
name = "omikron-connector"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
"base64",
|
"base64",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
"hex",
|
"hex",
|
||||||
|
|
@ -3303,9 +3373,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pest"
|
name = "pest"
|
||||||
version = "2.8.7"
|
version = "2.8.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9"
|
checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
"ucd-trie",
|
"ucd-trie",
|
||||||
|
|
@ -3313,9 +3383,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pest_derive"
|
name = "pest_derive"
|
||||||
version = "2.8.7"
|
version = "2.8.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58"
|
checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pest",
|
"pest",
|
||||||
"pest_generator",
|
"pest_generator",
|
||||||
|
|
@ -3323,9 +3393,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pest_generator"
|
name = "pest_generator"
|
||||||
version = "2.8.7"
|
version = "2.8.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7"
|
checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pest",
|
"pest",
|
||||||
"pest_meta",
|
"pest_meta",
|
||||||
|
|
@ -3336,9 +3406,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pest_meta"
|
name = "pest_meta"
|
||||||
version = "2.8.7"
|
version = "2.8.8"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210"
|
checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pest",
|
"pest",
|
||||||
]
|
]
|
||||||
|
|
@ -4194,17 +4264,6 @@ dependencies = [
|
||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "semver"
|
name = "semver"
|
||||||
version = "1.0.28"
|
version = "1.0.28"
|
||||||
|
|
@ -4238,14 +4297,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.1",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.150"
|
version = "1.0.151"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"itoa",
|
"itoa",
|
||||||
"memchr",
|
"memchr",
|
||||||
|
|
@ -4585,9 +4644,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "3.0.1"
|
version = "3.0.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5edbec4ed188954a10c12c038215f8ce7606b2d5c973cd8dc43e8795065c5f2f"
|
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|
@ -4775,14 +4834,14 @@ checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 3.0.1",
|
"syn 3.0.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time"
|
name = "time"
|
||||||
version = "0.3.53"
|
version = "0.3.54"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50"
|
checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deranged",
|
"deranged",
|
||||||
"libc",
|
"libc",
|
||||||
|
|
@ -4802,9 +4861,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time-macros"
|
name = "time-macros"
|
||||||
version = "0.2.31"
|
version = "0.2.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f"
|
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"num-conv",
|
"num-conv",
|
||||||
"time-core",
|
"time-core",
|
||||||
|
|
@ -4883,6 +4942,17 @@ dependencies = [
|
||||||
"tokio",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-tungstenite"
|
name = "tokio-tungstenite"
|
||||||
version = "0.30.0"
|
version = "0.30.0"
|
||||||
|
|
@ -4899,14 +4969,15 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.19"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"libc",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
@ -5789,18 +5860,18 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.54"
|
version = "0.8.55"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
|
checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerocopy-derive",
|
"zerocopy-derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy-derive"
|
name = "zerocopy-derive"
|
||||||
version = "0.8.54"
|
version = "0.8.55"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
|
checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|
|
||||||
|
|
@ -18,5 +18,9 @@ members = [
|
||||||
"web-ui",
|
"web-ui",
|
||||||
"iota-logger",
|
"iota-logger",
|
||||||
"iota-util",
|
"iota-util",
|
||||||
|
"iota-process-manager",
|
||||||
|
"iota-paths",
|
||||||
|
"iota-installer",
|
||||||
|
"iota-core",
|
||||||
]
|
]
|
||||||
resolver = "3"
|
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.
|
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.
|
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_common::*;
|
||||||
use iota_connection::message_handlers;
|
use iota_connection::message_handlers;
|
||||||
use iota_logger::{log_cv_in, log_cv_out, log_t};
|
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::chat_files::{self, MessageState, change_message_state};
|
||||||
use iota_storage::util::config_util::CONFIG;
|
use iota_storage::util::config_util::CONFIG;
|
||||||
use iota_storage::util::e2ee_storage::{self, StoredChatSecret};
|
use iota_storage::util::e2ee_storage::{self, StoredChatSecret};
|
||||||
|
|
@ -31,6 +30,7 @@ pub struct ClientConnection {
|
||||||
shutdown_tx: Arc<Mutex<Option<watch::Sender<bool>>>>,
|
shutdown_tx: Arc<Mutex<Option<watch::Sender<bool>>>>,
|
||||||
pub waiting_tasks:
|
pub waiting_tasks:
|
||||||
DashMap<u32, Box<dyn Fn(Arc<ClientConnection>, CommunicationValue) -> bool + Send + Sync>>,
|
DashMap<u32, Box<dyn Fn(Arc<ClientConnection>, CommunicationValue) -> bool + Send + Sync>>,
|
||||||
|
shutdown: Arc<RwLock<bool>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ClientConnection {
|
impl ClientConnection {
|
||||||
|
|
@ -45,6 +45,7 @@ impl ClientConnection {
|
||||||
u32,
|
u32,
|
||||||
Box<dyn Fn(Arc<ClientConnection>, CommunicationValue) -> bool + Send + Sync>,
|
Box<dyn Fn(Arc<ClientConnection>, CommunicationValue) -> bool + Send + Sync>,
|
||||||
>,
|
>,
|
||||||
|
shutdown: Arc<RwLock<bool>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
sender,
|
sender,
|
||||||
|
|
@ -54,6 +55,7 @@ impl ClientConnection {
|
||||||
connection_id,
|
connection_id,
|
||||||
shutdown_tx,
|
shutdown_tx,
|
||||||
waiting_tasks,
|
waiting_tasks,
|
||||||
|
shutdown,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,7 +63,7 @@ impl ClientConnection {
|
||||||
let self_clone = self.clone();
|
let self_clone = self.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Ok(cv) = self_clone.receiver.receive().await {
|
while let Ok(cv) = self_clone.receiver.receive().await {
|
||||||
if *SHUTDOWN.read().await {
|
if *self_clone.shutdown.read().await {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,7 @@
|
||||||
SocketGroup = "iota";
|
SocketGroup = "iota";
|
||||||
Backlog = 5;
|
Backlog = 5;
|
||||||
RemoveOnStop = "true";
|
RemoveOnStop = "true";
|
||||||
|
NonBlocking = true;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -206,7 +207,7 @@
|
||||||
|
|
||||||
serviceConfig =
|
serviceConfig =
|
||||||
{
|
{
|
||||||
Type = "notify";
|
Type = "simple";
|
||||||
User = "iota";
|
User = "iota";
|
||||||
Group = "iota";
|
Group = "iota";
|
||||||
WorkingDirectory = cfg.dataDir;
|
WorkingDirectory = cfg.dataDir;
|
||||||
|
|
@ -258,6 +259,7 @@
|
||||||
Environment = [
|
Environment = [
|
||||||
"BIND_ADDRESS=${cfg.bindAddress}"
|
"BIND_ADDRESS=${cfg.bindAddress}"
|
||||||
"IOTA_SOCKET=/run/iota/iota.sock"
|
"IOTA_SOCKET=/run/iota/iota.sock"
|
||||||
|
"IOTA_DATA_DIR=${cfg.dataDir}"
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
// lib.optionalAttrs (cfg.environmentFiles != []) {
|
// lib.optionalAttrs (cfg.environmentFiles != []) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -19,6 +19,8 @@ iota-storage = { path = "../iota-storage", optional = true }
|
||||||
iota-terms = { path = "../iota-terms" }
|
iota-terms = { path = "../iota-terms" }
|
||||||
iota-util = { path = "../iota-util", optional = true }
|
iota-util = { path = "../iota-util", optional = true }
|
||||||
iota-ipc = { path = "../iota-ipc" }
|
iota-ipc = { path = "../iota-ipc" }
|
||||||
|
iota-process-manager = { path = "../iota-process-manager" }
|
||||||
|
iota-paths = { path = "../iota-paths" }
|
||||||
omikron-connector = { path = "../omikron-connector", optional = true }
|
omikron-connector = { path = "../omikron-connector", optional = true }
|
||||||
|
|
||||||
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", 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 = { version = "0.23.37", features = ["aws-lc-rs"] }
|
||||||
rustls-pemfile = "2.2.0"
|
rustls-pemfile = "2.2.0"
|
||||||
serde_json = "1.0.149"
|
serde_json = "1.0.149"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_yaml = "0.9"
|
||||||
sha2 = "0.10.9"
|
sha2 = "0.10.9"
|
||||||
strum = "0.27.2"
|
strum = "0.27.2"
|
||||||
strum_macros = "0.27.2"
|
strum_macros = "0.27.2"
|
||||||
|
|
@ -71,3 +75,7 @@ walkdir = "2.5.0"
|
||||||
warp = "*"
|
warp = "*"
|
||||||
x448 = { version = "*" }
|
x448 = { version = "*" }
|
||||||
zip = "6.0.0"
|
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::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
style::{Color, Style},
|
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{Block, Borders, Paragraph},
|
widgets::{Block, Borders, Paragraph},
|
||||||
};
|
};
|
||||||
|
|
@ -18,6 +17,7 @@ use crate::{
|
||||||
elements::elements::{Element, InteractableElement, JoinableElement},
|
elements::elements::{Element, InteractableElement, JoinableElement},
|
||||||
interaction_result::InteractionResult,
|
interaction_result::InteractionResult,
|
||||||
ipc_client::IpcClient,
|
ipc_client::IpcClient,
|
||||||
|
render_context::RenderContext,
|
||||||
util::borders::draw_block_joins,
|
util::borders::draw_block_joins,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -34,6 +34,7 @@ pub struct ConsoleCard {
|
||||||
cursor: Arc<Mutex<bool>>,
|
cursor: Arc<Mutex<bool>>,
|
||||||
last_swap: Arc<Mutex<Instant>>,
|
last_swap: Arc<Mutex<Instant>>,
|
||||||
pending_restore: Arc<Mutex<Option<String>>>,
|
pending_restore: Arc<Mutex<Option<String>>>,
|
||||||
|
pending_confirmation: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConsoleCard {
|
impl ConsoleCard {
|
||||||
|
|
@ -49,6 +50,7 @@ impl ConsoleCard {
|
||||||
cursor: Arc::new(Mutex::new(true)),
|
cursor: Arc::new(Mutex::new(true)),
|
||||||
last_swap: Arc::new(Mutex::new(Instant::now())),
|
last_swap: Arc::new(Mutex::new(Instant::now())),
|
||||||
pending_restore: Arc::new(Mutex::new(None)),
|
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_visible = self.cursor_visible();
|
||||||
let cursor_style = Style::default().fg(Color::White).bg(Color::DarkGray);
|
|
||||||
let mut spans = Vec::new();
|
let mut spans = Vec::new();
|
||||||
|
|
||||||
if self.content.is_empty() {
|
if self.content.is_empty() {
|
||||||
if self.focused {
|
if self.focused {
|
||||||
if cursor_visible {
|
if cursor_visible {
|
||||||
spans.push(Span::styled(" ", cursor_style));
|
Self::push_cursor(&mut spans, theme);
|
||||||
} else {
|
} else {
|
||||||
spans.push(Span::styled(" ", Style::default().fg(Color::White)));
|
spans.push(Span::styled(" ", theme.console.text));
|
||||||
}
|
}
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
"send command (/help for info)",
|
"send command (/help for info)",
|
||||||
Style::default().fg(Color::DarkGray),
|
theme.console.hint,
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
" send command (/help for info)",
|
" send command (/help for info)",
|
||||||
Style::default().fg(Color::DarkGray),
|
theme.console.hint,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return spans;
|
return spans;
|
||||||
|
|
@ -119,52 +120,64 @@ impl ConsoleCard {
|
||||||
if prefix_len > 0 && before.len() >= prefix_len {
|
if prefix_len > 0 && before.len() >= prefix_len {
|
||||||
let prefix = &before[..prefix_len];
|
let prefix = &before[..prefix_len];
|
||||||
let rest = &before[prefix_len..];
|
let rest = &before[prefix_len..];
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(prefix.to_string(), theme.console.prefix));
|
||||||
prefix.to_string(),
|
|
||||||
Self::style_for_part(true, false, false),
|
|
||||||
));
|
|
||||||
if !rest.is_empty() {
|
if !rest.is_empty() {
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(rest.to_string(), theme.console.text));
|
||||||
rest.to_string(),
|
|
||||||
Style::default().fg(Color::White),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
} else if !before.is_empty() {
|
} else if !before.is_empty() {
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(before.clone(), theme.console.text));
|
||||||
before.clone(),
|
|
||||||
Style::default().fg(Color::White),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if cursor_visible {
|
if cursor_visible {
|
||||||
spans.push(Span::styled(" ", cursor_style));
|
Self::push_cursor(&mut spans, theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
if !after.is_empty() {
|
if !after.is_empty() {
|
||||||
spans.push(Span::styled(after, Style::default().fg(Color::White)));
|
spans.push(Span::styled(after, theme.console.text));
|
||||||
}
|
}
|
||||||
|
|
||||||
spans
|
spans
|
||||||
}
|
}
|
||||||
|
|
||||||
fn style_for_part(is_prefix: bool, is_hint: bool, is_error: bool) -> Style {
|
fn push_cursor(spans: &mut Vec<Span<'static>>, theme: &crate::theme::ResolvedTheme) {
|
||||||
if is_error {
|
match &theme.console.cursor {
|
||||||
return Style::default().fg(Color::Red);
|
crate::theme::CursorPresentation::StyledCell(style) => {
|
||||||
|
spans.push(Span::styled(" ", *style))
|
||||||
|
}
|
||||||
|
crate::theme::CursorPresentation::Character { glyph, style } => {
|
||||||
|
spans.push(Span::styled(*glyph, *style))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if is_hint {
|
|
||||||
return Style::default().fg(Color::DarkGray);
|
|
||||||
}
|
|
||||||
|
|
||||||
if is_prefix {
|
|
||||||
return Style::default().fg(Color::DarkGray);
|
|
||||||
}
|
|
||||||
|
|
||||||
Style::default().fg(Color::White)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_cursor_spans(&self) -> Vec<Span<'static>> {
|
fn render_cursor_spans(&self, theme: &crate::theme::ResolvedTheme) -> Vec<Span<'static>> {
|
||||||
self.cursor_spans()
|
if let Some(command) = &self.pending_confirmation {
|
||||||
|
return vec![Span::styled(
|
||||||
|
format!("Confirm `{command}`? [y/N]"),
|
||||||
|
theme.console.confirmation,
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
self.cursor_spans(theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_destructive(command: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
command.trim_start_matches('/').trim(),
|
||||||
|
"restart" | "reload" | "stop" | "shutdown" | "regenerate keys"
|
||||||
|
) || command
|
||||||
|
.trim_start_matches('/')
|
||||||
|
.trim_start()
|
||||||
|
.starts_with("user remove ")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatch_command(&self, command: String) {
|
||||||
|
let ipc = self.ipc.clone();
|
||||||
|
let restore = self.pending_restore.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if ipc.send_command(0, command.clone()).await.is_err() {
|
||||||
|
*restore.lock().unwrap() = Some(command);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn move_cursor_left(&mut self) {
|
fn move_cursor_left(&mut self) {
|
||||||
|
|
@ -212,28 +225,34 @@ impl Element for ConsoleCard {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(&self, f: &mut Frame, r: Rect) {
|
fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) {
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.borders(self.borders)
|
.borders(self.borders)
|
||||||
.title(self.title.clone())
|
.title(self.title.clone())
|
||||||
.title_style(Style::default().fg(Color::White))
|
.title_style(context.theme.console.title)
|
||||||
.border_style(if self.focused {
|
.border_style(if self.focused {
|
||||||
Style::default().fg(Color::Yellow)
|
context.theme.console.focused_border
|
||||||
} else {
|
} else {
|
||||||
Style::default()
|
context.theme.console.border
|
||||||
})
|
})
|
||||||
.style(if self.focused {
|
.style(context.theme.console.text);
|
||||||
Style::default().fg(Color::White)
|
|
||||||
} else {
|
|
||||||
Style::default()
|
|
||||||
});
|
|
||||||
|
|
||||||
let spans = self.render_cursor_spans();
|
let spans = self.render_cursor_spans(context.theme);
|
||||||
let par = Paragraph::new(Line::from(spans))
|
let par = Paragraph::new(Line::from(spans))
|
||||||
.block(block)
|
.block(block)
|
||||||
.scroll((0, 0));
|
.scroll((0, 0));
|
||||||
f.render_widget(par, r);
|
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();
|
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 {
|
match key.code {
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
if self.content.is_empty() {
|
if self.content.is_empty() {
|
||||||
|
|
@ -294,16 +320,13 @@ impl InteractableElement for ConsoleCard {
|
||||||
}
|
}
|
||||||
|
|
||||||
let command = self.content.clone();
|
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.content.clear();
|
||||||
self.cursor_position = 0;
|
self.cursor_position = 0;
|
||||||
|
if Self::is_destructive(&command) {
|
||||||
|
self.pending_confirmation = Some(command);
|
||||||
|
} else {
|
||||||
|
self.dispatch_command(command);
|
||||||
|
}
|
||||||
InteractionResult::Handled
|
InteractionResult::Handled
|
||||||
}
|
}
|
||||||
KeyCode::Backspace => {
|
KeyCode::Backspace => {
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,16 @@ use std::any::Any;
|
||||||
use crossterm::event::KeyEvent;
|
use crossterm::event::KeyEvent;
|
||||||
use ratatui::{Frame, layout::Rect, widgets::Borders};
|
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)]
|
#[allow(unused)]
|
||||||
pub trait Element: Send + Sync + Any {
|
pub trait Element: Send + Sync + Any {
|
||||||
fn as_any(&self) -> &dyn Any;
|
fn as_any(&self) -> &dyn Any;
|
||||||
fn as_any_mut(&mut self) -> &mut 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)]
|
#[allow(unused)]
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ use iota_state::ClientState;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
style::{Color, Style},
|
|
||||||
widgets::{
|
widgets::{
|
||||||
Block, Borders,
|
Block, Borders,
|
||||||
canvas::{Canvas, Line},
|
canvas::{Canvas, Line},
|
||||||
|
|
@ -15,6 +14,7 @@ use ratatui::{
|
||||||
use crate::{
|
use crate::{
|
||||||
elements::elements::{Element, InteractableElement, JoinableElement},
|
elements::elements::{Element, InteractableElement, JoinableElement},
|
||||||
interaction_result::InteractionResult,
|
interaction_result::InteractionResult,
|
||||||
|
render_context::RenderContext,
|
||||||
ui::UI,
|
ui::UI,
|
||||||
util::borders::draw_block_joins,
|
util::borders::draw_block_joins,
|
||||||
};
|
};
|
||||||
|
|
@ -26,43 +26,30 @@ pub enum GRAPHS {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GRAPHS {
|
impl GRAPHS {
|
||||||
pub fn get_color(&self) -> Color {
|
pub fn get_color(&self, theme: &crate::theme::ResolvedTheme) -> ratatui::style::Color {
|
||||||
match self {
|
match self {
|
||||||
GRAPHS::Ram => Color::Blue,
|
GRAPHS::Ram => theme.graphs.ram,
|
||||||
GRAPHS::Cpu => Color::Red,
|
GRAPHS::Cpu => theme.graphs.cpu,
|
||||||
GRAPHS::Ping => Color::Green,
|
GRAPHS::Ping => theme.graphs.ping,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_graph(&self, state: &ClientState) -> Vec<(f64, f64)> {
|
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 {
|
match self {
|
||||||
GRAPHS::Ram => state
|
GRAPHS::Ram => state.with_width(28).ram.clone(),
|
||||||
.app
|
GRAPHS::Cpu => state.with_width(28).cpu.clone(),
|
||||||
.lock()
|
GRAPHS::Ping => state.with_width(28).ping.clone(),
|
||||||
.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(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_unit(&self) -> String {
|
pub fn get_unit(&self) -> String {
|
||||||
match self {
|
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::Cpu => "%".to_string(),
|
||||||
GRAPHS::Ping => "ms".to_string(),
|
GRAPHS::Ping => "ms".to_string(),
|
||||||
}
|
}
|
||||||
|
|
@ -111,19 +98,24 @@ impl Element for GraphCard {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(&self, f: &mut Frame, r: Rect) {
|
fn render(&self, f: &mut Frame, r: Rect, context: &RenderContext<'_>) {
|
||||||
if self.open {
|
if self.open {
|
||||||
let graph = self.graph_type.get_graph(&self.state);
|
let graph = self.graph_type.get_graph(&self.state);
|
||||||
let unit = self.graph_type.get_unit();
|
let unit = self.graph_type.get_unit();
|
||||||
let min_x = graph.first().map(|(x, _)| *x).unwrap_or(0.0);
|
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 = 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
|
let min_y = graph
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(_, y)| *y)
|
.map(|(_, y)| *y)
|
||||||
.filter(|y| *y > 0.0)
|
.filter(|y| *y > 0.0)
|
||||||
.min_by(|a, b| a.total_cmp(b))
|
.min_by(|a, b| a.total_cmp(b))
|
||||||
.unwrap_or(0.0);
|
.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()
|
let block = Block::default()
|
||||||
.title(format!(
|
.title(format!(
|
||||||
|
|
@ -136,15 +128,15 @@ impl Element for GraphCard {
|
||||||
))
|
))
|
||||||
.borders(self.borders)
|
.borders(self.borders)
|
||||||
.border_style(if self.focused {
|
.border_style(if self.focused {
|
||||||
Style::default().fg(Color::Yellow)
|
context.theme.graphs.focused_border
|
||||||
} else {
|
} else {
|
||||||
Style::default()
|
context.theme.graphs.border
|
||||||
});
|
});
|
||||||
|
|
||||||
let canvas = Canvas::default()
|
let canvas = Canvas::default()
|
||||||
.block(block)
|
.block(block)
|
||||||
.x_bounds([min_x, max_x])
|
.x_bounds([min_x, max_x])
|
||||||
.y_bounds([0.0, 100.0])
|
.y_bounds([0.0, y_upper])
|
||||||
.paint(|ctx| {
|
.paint(|ctx| {
|
||||||
for (x, y) in &graph {
|
for (x, y) in &graph {
|
||||||
ctx.draw(&Line {
|
ctx.draw(&Line {
|
||||||
|
|
@ -152,7 +144,7 @@ impl Element for GraphCard {
|
||||||
y1: 0.0,
|
y1: 0.0,
|
||||||
x2: *x,
|
x2: *x,
|
||||||
y2: *y,
|
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("")
|
.title("")
|
||||||
.borders(self.borders)
|
.borders(self.borders)
|
||||||
.border_style(if self.focused {
|
.border_style(if self.focused {
|
||||||
Style::default().fg(Color::Yellow)
|
context.theme.graphs.focused_border
|
||||||
} else {
|
} else {
|
||||||
Style::default()
|
context.theme.graphs.border
|
||||||
});
|
});
|
||||||
f.render_widget(block, r);
|
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::elements::elements::{Element, InteractableElement, JoinableElement};
|
||||||
use crate::interaction_result::InteractionResult;
|
|
||||||
use crate::util::borders::draw_block_joins;
|
use crate::util::borders::draw_block_joins;
|
||||||
|
use crate::{interaction_result::InteractionResult, render_context::RenderContext};
|
||||||
use crossterm::event::{KeyCode, KeyEvent};
|
use crossterm::event::{KeyCode, KeyEvent};
|
||||||
use iota_state::{ClientState, UiLogEntry};
|
use iota_state::{ClientState, UiLogEntry};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::Rect,
|
layout::Rect,
|
||||||
style::{Color, Style},
|
style::Style,
|
||||||
text::{Line, Span},
|
text::{Line, Span},
|
||||||
widgets::{Block, Borders, Paragraph},
|
widgets::{Block, Borders, Paragraph},
|
||||||
};
|
};
|
||||||
use std::any::Any;
|
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 {
|
pub struct LogCard {
|
||||||
state: ClientState,
|
state: ClientState,
|
||||||
|
|
@ -38,11 +76,10 @@ impl LogCard {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_logs(&self) -> Vec<UiLogEntry> {
|
fn get_logs(&self) -> Vec<UiLogEntry> {
|
||||||
let state = self
|
let state = match self.state.app.try_lock() {
|
||||||
.state
|
Ok(state) => state,
|
||||||
.app
|
Err(_) => return Vec::new(),
|
||||||
.lock()
|
};
|
||||||
.unwrap_or_else(|error| error.into_inner());
|
|
||||||
state
|
state
|
||||||
.get_logs()
|
.get_logs()
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -64,7 +101,7 @@ impl LogCard {
|
||||||
let mut last_boundary = 0usize;
|
let mut last_boundary = 0usize;
|
||||||
|
|
||||||
for (idx, ch) in s.char_indices() {
|
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 current_width + char_width > max_width {
|
||||||
if last_boundary == 0 {
|
if last_boundary == 0 {
|
||||||
return idx + ch.len_utf8();
|
return idx + ch.len_utf8();
|
||||||
|
|
@ -78,7 +115,7 @@ impl LogCard {
|
||||||
s.len()
|
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 mut result = Vec::new();
|
||||||
|
|
||||||
let timestamp = entry.format_timestamp();
|
let timestamp = entry.format_timestamp();
|
||||||
|
|
@ -141,29 +178,17 @@ impl LogCard {
|
||||||
line.push_str(×tamp);
|
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
|
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(
|
fn build_all_lines(
|
||||||
&self,
|
&self,
|
||||||
entries: Vec<UiLogEntry>,
|
entries: Vec<UiLogEntry>,
|
||||||
width: usize,
|
width: usize,
|
||||||
) -> Vec<(String, Color, bool)> {
|
) -> Vec<(String, LogSource, bool)> {
|
||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
|
|
@ -269,23 +294,33 @@ impl Element for LogCard {
|
||||||
self
|
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 entries = self.get_logs();
|
||||||
|
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.title(self.build_title())
|
.title(self.build_title())
|
||||||
.borders(self.borders)
|
.borders(self.borders)
|
||||||
.border_style(if self.focused {
|
.border_style(if self.focused {
|
||||||
Style::default().fg(Color::Yellow)
|
context.theme.logs.focused_border
|
||||||
} else {
|
} else {
|
||||||
Style::default()
|
context.theme.logs.border
|
||||||
});
|
});
|
||||||
|
|
||||||
let inner_area = block.inner(area);
|
let inner_area = block.inner(area);
|
||||||
f.render_widget(block, area);
|
f.render_widget(block, area);
|
||||||
|
|
||||||
if inner_area.width == 0 || inner_area.height == 0 {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -298,7 +333,7 @@ impl Element for LogCard {
|
||||||
|
|
||||||
let rendered_lines: Vec<Line> = visible_lines
|
let rendered_lines: Vec<Line> = visible_lines
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(line, prefix_color, is_error)| {
|
.map(|(line, source, is_error)| {
|
||||||
let mut spans = Vec::new();
|
let mut spans = Vec::new();
|
||||||
|
|
||||||
let (prefix, rest) = Self::split_line_prefix(line);
|
let (prefix, rest) = Self::split_line_prefix(line);
|
||||||
|
|
@ -306,24 +341,25 @@ impl Element for LogCard {
|
||||||
if !prefix.is_empty() {
|
if !prefix.is_empty() {
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
prefix.to_string(),
|
prefix.to_string(),
|
||||||
Style::default().fg(*prefix_color),
|
source.style(context.theme),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let (content, timestamp) = Self::split_timestamp_suffix(rest);
|
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() {
|
if !content.is_empty() {
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(content.to_string(), text_style));
|
||||||
content.to_string(),
|
|
||||||
Style::default().fg(text_color),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !timestamp.is_empty() {
|
if !timestamp.is_empty() {
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(
|
||||||
timestamp.to_string(),
|
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);
|
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 crossterm::event::{Event, KeyEvent, KeyEventKind, KeyModifiers, poll, read};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
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 {
|
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 {
|
loop {
|
||||||
if ui.is_shutdown() {
|
if ui.is_shutdown() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let event_result = tokio::task::spawn_blocking(|| {
|
tokio::select! {
|
||||||
if let Ok(true) = poll(Duration::from_millis(100)) {
|
event = rx.recv() => match event {
|
||||||
read().ok().and_then(|ev| match ev {
|
Some(Event::Key(key)) if key.kind == KeyEventKind::Press => handle_input(key, ui.clone()).await,
|
||||||
Event::Key(key) if key.kind == KeyEventKind::Press => Some(key),
|
Some(Event::Resize(_, _)) => ui.invalidate(),
|
||||||
_ => None,
|
Some(Event::Paste(text)) => ui.handle_paste(text).await,
|
||||||
})
|
Some(_) => {},
|
||||||
} else {
|
None => break,
|
||||||
None
|
},
|
||||||
}
|
_ = cancellation.cancelled() => break,
|
||||||
})
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
let result = match worker.await {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(error) if error.is_cancelled() => Ok(()),
|
||||||
|
Err(error) => Err(format!("input worker failed: {error}")),
|
||||||
|
};
|
||||||
|
if result.is_err() {
|
||||||
|
ui.request_shutdown();
|
||||||
|
}
|
||||||
|
result
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn handle_input(key: KeyEvent, ui: Arc<UI>) {
|
pub async fn handle_input(key: KeyEvent, ui: Arc<UI>) {
|
||||||
match (key.code, key.modifiers) {
|
if matches!(
|
||||||
(crossterm::event::KeyCode::Char('q'), KeyModifiers::CONTROL)
|
key.code,
|
||||||
| (crossterm::event::KeyCode::Char('c'), KeyModifiers::CONTROL) => {
|
crossterm::event::KeyCode::Char('q') | crossterm::event::KeyCode::Char('c')
|
||||||
ui.request_shutdown();
|
) && key.modifiers.contains(KeyModifiers::CONTROL)
|
||||||
}
|
{
|
||||||
(crossterm::event::KeyCode::Char('r'), KeyModifiers::CONTROL) => {
|
ui.request_shutdown();
|
||||||
let _ = ui.send_restart().await;
|
} else {
|
||||||
ui.request_shutdown();
|
ui.handle_input(key).await;
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
ui.handle_input(key).await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,25 @@
|
||||||
use iota_ipc::{
|
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,
|
RequestEnvelope, ResponseResult, read_msg, write_msg,
|
||||||
};
|
};
|
||||||
use iota_state::{ClientState, UiLogEntry};
|
use iota_state::{ClientState, UiLogEntry};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::io::Result;
|
use std::io::Result;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::{Arc, Mutex as StdMutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::net::UnixStream;
|
use tokio::net::UnixStream;
|
||||||
use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf};
|
use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf};
|
||||||
use tokio::sync::{Mutex, oneshot, watch};
|
use tokio::sync::{Mutex, oneshot, watch};
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
const INITIAL_BACKOFF: Duration = Duration::from_millis(200);
|
const INITIAL_BACKOFF: Duration = Duration::from_millis(200);
|
||||||
const MAX_BACKOFF: Duration = Duration::from_secs(10);
|
const MAX_BACKOFF: Duration = Duration::from_secs(10);
|
||||||
const MAX_RECONNECT_ATTEMPTS: u32 = 50;
|
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.
|
/// Connection state exposed to the UI.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
|
|
@ -24,96 +28,114 @@ pub enum IpcConnectionState {
|
||||||
Connected,
|
Connected,
|
||||||
Reconnecting { attempt: u32 },
|
Reconnecting { attempt: u32 },
|
||||||
Incompatible { message: String },
|
Incompatible { message: String },
|
||||||
|
Failed { message: String },
|
||||||
Disconnected,
|
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.
|
/// Pending request awaiting a response.
|
||||||
struct PendingRequest {
|
struct PendingRequest {
|
||||||
response_tx: oneshot::Sender<ResponseResult>,
|
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
|
/* The TUI owns this cache. IPC updates replace daemon snapshots and append
|
||||||
* logs, so rendering never reaches into daemon-owned storage or connections. */
|
* logs, so rendering never reaches into daemon-owned storage or connections. */
|
||||||
pub struct IpcClient {
|
pub struct IpcClient {
|
||||||
state: ClientState,
|
state: ClientState,
|
||||||
writer: Mutex<OwnedWriteHalf>,
|
writer: Mutex<Option<ActiveWriter>>,
|
||||||
|
next_generation: AtomicU64,
|
||||||
next_request_id: AtomicU64,
|
next_request_id: AtomicU64,
|
||||||
pending: Mutex<HashMap<u64, PendingRequest>>,
|
pending: Mutex<HashMap<u64, PendingRequest>>,
|
||||||
connection_state: watch::Sender<IpcConnectionState>,
|
connection_state: watch::Sender<IpcConnectionState>,
|
||||||
|
daemon_status: watch::Sender<DaemonStatus>,
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
|
reconnector_started: AtomicBool,
|
||||||
|
cancellation: CancellationToken,
|
||||||
|
background_tasks: StdMutex<Vec<JoinHandle<()>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IpcClient {
|
impl IpcClient {
|
||||||
pub async fn connect(path: impl AsRef<Path>) -> Result<Arc<Self>> {
|
pub async fn connect(path: impl AsRef<Path>) -> Result<Arc<Self>> {
|
||||||
let path = path.as_ref().to_path_buf();
|
let path = path.as_ref().to_path_buf();
|
||||||
let stream = Self::try_connect(&path).await?;
|
let deadline = tokio::time::Instant::now() + STARTUP_CONNECT_TIMEOUT;
|
||||||
let (mut reader, writer) = stream.into_split();
|
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 {
|
let client = Arc::new(Self {
|
||||||
state: ClientState::new(),
|
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),
|
next_request_id: AtomicU64::new(1),
|
||||||
pending: Mutex::new(HashMap::new()),
|
pending: Mutex::new(HashMap::new()),
|
||||||
connection_state: conn_state_tx,
|
connection_state: conn_state_tx,
|
||||||
|
daemon_status: daemon_status_tx,
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
|
reconnector_started: AtomicBool::new(false),
|
||||||
|
cancellation: CancellationToken::new(),
|
||||||
|
background_tasks: StdMutex::new(Vec::new()),
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Handshake: send Hello, read HelloAck ---
|
// Apply messages received while waiting for subscription confirmation
|
||||||
{
|
// before exposing the connection to the UI.
|
||||||
let mut w = client.writer.lock().await;
|
for message in negotiated.buffered_messages {
|
||||||
write_msg(
|
client.apply(message).await;
|
||||||
&mut *w,
|
|
||||||
&ClientMessage::Hello {
|
|
||||||
supported_versions: vec![MIN_PROTOCOL_VERSION, PROTOCOL_VERSION],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.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)
|
// Start reader task (continues reading after handshake)
|
||||||
let reader_client = client.clone();
|
let reader_client = client.clone();
|
||||||
tokio::spawn(async move {
|
let task = tokio::spawn(async move {
|
||||||
reader_client.read_loop(reader).await;
|
reader_client.read_loop(negotiated.reader, 1).await;
|
||||||
});
|
});
|
||||||
|
client.background_tasks.lock().unwrap().push(task);
|
||||||
// Subscribe to events
|
|
||||||
client
|
|
||||||
.send(ClientMessage::Subscribe {
|
|
||||||
log_classes: vec![],
|
|
||||||
metric_interval_ms: Some(500),
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(client)
|
Ok(client)
|
||||||
}
|
}
|
||||||
|
|
@ -121,165 +143,191 @@ impl IpcClient {
|
||||||
/// Try to connect with retries for socket activation.
|
/// Try to connect with retries for socket activation.
|
||||||
pub async fn connect_or_activate(path: impl AsRef<Path>) -> Result<Arc<Self>> {
|
pub async fn connect_or_activate(path: impl AsRef<Path>) -> Result<Arc<Self>> {
|
||||||
let path = path.as_ref().to_path_buf();
|
let path = path.as_ref().to_path_buf();
|
||||||
let max_attempts = 30;
|
let deadline = tokio::time::Instant::now() + STARTUP_CONNECT_TIMEOUT;
|
||||||
for attempt in 0..max_attempts {
|
let mut last_error = None;
|
||||||
match Self::connect(&path).await {
|
while tokio::time::Instant::now() < deadline {
|
||||||
|
match Self::connect_until(&path, deadline).await {
|
||||||
Ok(client) => return Ok(client),
|
Ok(client) => return Ok(client),
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
if attempt < max_attempts - 1 {
|
last_error = Some(error);
|
||||||
let delay = Duration::from_millis(100 + attempt as u64 * 100);
|
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||||
tokio::time::sleep(delay).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return Err(error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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> {
|
async fn connect_stream(path: &Path, deadline: tokio::time::Instant) -> Result<UnixStream> {
|
||||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
|
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 {
|
loop {
|
||||||
match UnixStream::connect(path).await {
|
match tokio::time::timeout_at(deadline, read_msg::<_, DaemonMessage>(&mut reader)).await
|
||||||
Ok(stream) => return Ok(stream),
|
{
|
||||||
Err(error) => {
|
Err(_) => {
|
||||||
if tokio::time::Instant::now() >= deadline {
|
return Err(std::io::Error::new(
|
||||||
return Err(error);
|
std::io::ErrorKind::TimedOut,
|
||||||
}
|
"Timed out waiting for IPC subscription acknowledgement",
|
||||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
));
|
||||||
}
|
}
|
||||||
|
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.
|
/// Start the reconnection actor.
|
||||||
pub fn spawn_reconnector(self: &Arc<Self>) {
|
pub fn spawn_reconnector(self: &Arc<Self>) {
|
||||||
|
if self.reconnector_started.swap(true, Ordering::AcqRel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let client = self.clone();
|
let client = self.clone();
|
||||||
tokio::spawn(async move {
|
let task = tokio::spawn(async move {
|
||||||
client.reconnection_loop().await;
|
client.reconnection_loop().await;
|
||||||
});
|
});
|
||||||
|
self.background_tasks.lock().unwrap().push(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn reconnection_loop(self: Arc<Self>) {
|
async fn reconnection_loop(self: Arc<Self>) {
|
||||||
let mut rx = self.connection_status();
|
let mut rx = self.connection_status();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Wait until the connection enters the Disconnected state.
|
while !matches!(*rx.borrow(), IpcConnectionState::Disconnected) {
|
||||||
loop {
|
if tokio::select! {
|
||||||
let disconnected = matches!(*rx.borrow(), IpcConnectionState::Disconnected);
|
changed = rx.changed() => changed.is_err(),
|
||||||
if disconnected {
|
_ = self.cancellation.cancelled() => true,
|
||||||
break;
|
} {
|
||||||
}
|
return;
|
||||||
if rx.changed().await.is_err() {
|
|
||||||
return; // sender dropped
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut backoff = INITIAL_BACKOFF;
|
let mut backoff = INITIAL_BACKOFF;
|
||||||
let mut attempt: u32 = 0;
|
for attempt in 1..=MAX_RECONNECT_ATTEMPTS {
|
||||||
|
if self.cancellation.is_cancelled() {
|
||||||
// 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(),
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let _ = self
|
let _ = self
|
||||||
.connection_state
|
.connection_state
|
||||||
.send(IpcConnectionState::Reconnecting { attempt });
|
.send(IpcConnectionState::Reconnecting { attempt });
|
||||||
|
tokio::select! {
|
||||||
match Self::try_connect(&self.path).await {
|
_ = tokio::time::sleep(backoff) => {},
|
||||||
Ok(stream) => {
|
_ = self.cancellation.cancelled() => return,
|
||||||
let (mut reader, writer) = stream.into_split();
|
}
|
||||||
*self.writer.lock().await = writer;
|
let deadline = tokio::time::Instant::now() + CONNECT_ATTEMPT_TIMEOUT;
|
||||||
|
let result = async {
|
||||||
// Re-handshake
|
let stream = Self::connect_stream(&self.path, deadline).await?;
|
||||||
{
|
Self::negotiate_stream(stream, deadline).await
|
||||||
let mut w = self.writer.lock().await;
|
}
|
||||||
if write_msg(
|
.await;
|
||||||
&mut *w,
|
match result {
|
||||||
&ClientMessage::Hello {
|
Ok(connection) => {
|
||||||
supported_versions: vec![
|
self.install_connection(connection).await;
|
||||||
MIN_PROTOCOL_VERSION,
|
|
||||||
PROTOCOL_VERSION,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
let _ = self
|
|
||||||
.connection_state
|
|
||||||
.send(IpcConnectionState::Disconnected);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read HelloAck
|
|
||||||
match read_msg::<_, DaemonMessage>(&mut reader).await {
|
|
||||||
Ok(DaemonMessage::HelloAck(ack)) => {
|
|
||||||
if ack.protocol_version < MIN_PROTOCOL_VERSION {
|
|
||||||
let _ = self.connection_state.send(
|
|
||||||
IpcConnectionState::Incompatible {
|
|
||||||
message: format!(
|
|
||||||
"Daemon protocol {} < required {}",
|
|
||||||
ack.protocol_version, MIN_PROTOCOL_VERSION
|
|
||||||
),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
let _ = self
|
|
||||||
.connection_state
|
|
||||||
.send(IpcConnectionState::Disconnected);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear pending requests with connection-lost errors
|
|
||||||
{
|
|
||||||
let mut pending = self.pending.lock().await;
|
|
||||||
for (_, request) in pending.drain() {
|
|
||||||
let _ = request.response_tx.send(
|
|
||||||
ResponseResult::Error(
|
|
||||||
iota_ipc::IpcErrorCode::Disconnected,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = self.connection_state.send(IpcConnectionState::Connected);
|
|
||||||
|
|
||||||
// Start new reader loop
|
|
||||||
let reader_client = self.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
reader_client.read_loop(reader).await;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Resubscribe
|
|
||||||
let _ = self
|
|
||||||
.send(ClientMessage::Subscribe {
|
|
||||||
log_classes: vec![],
|
|
||||||
metric_interval_ms: Some(500),
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Successfully reconnected; go back to waiting for
|
|
||||||
// the next disconnect.
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(error) if error.kind() == std::io::ErrorKind::Unsupported => {
|
||||||
|
let _ = self
|
||||||
|
.connection_state
|
||||||
|
.send(IpcConnectionState::Incompatible {
|
||||||
|
message: error.to_string(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(error) if attempt == MAX_RECONNECT_ATTEMPTS => {
|
||||||
|
let _ = self.connection_state.send(IpcConnectionState::Failed {
|
||||||
|
message: format!("Reconnect failed after {attempt} attempts: {error}"),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!(
|
||||||
|
"IPC reconnect attempt {attempt} to {} failed: kind={:?}, error={error}",
|
||||||
|
self.path.display(),
|
||||||
|
error.kind()
|
||||||
|
);
|
||||||
backoff = std::cmp::min(backoff * 2, MAX_BACKOFF);
|
backoff = std::cmp::min(backoff * 2, MAX_BACKOFF);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -287,16 +335,61 @@ impl IpcClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn read_loop(self: Arc<Self>, mut reader: OwnedReadHalf) {
|
async fn install_connection(self: &Arc<Self>, connection: NegotiatedConnection) {
|
||||||
loop {
|
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
|
||||||
match read_msg::<_, DaemonMessage>(&mut reader).await {
|
*self.writer.lock().await = Some(ActiveWriter {
|
||||||
Ok(message) => self.apply(message).await,
|
generation,
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => {
|
writer: connection.writer,
|
||||||
let _ = self.connection_state.send(IpcConnectionState::Disconnected);
|
});
|
||||||
break;
|
self.update_hello_ack(connection.ack);
|
||||||
|
for message in connection.buffered_messages {
|
||||||
|
self.apply(message).await;
|
||||||
|
}
|
||||||
|
let _ = self.connection_state.send(IpcConnectionState::Connected);
|
||||||
|
let client = self.clone();
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
client.read_loop(connection.reader, generation).await;
|
||||||
|
});
|
||||||
|
self.background_tasks.lock().unwrap().push(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fail_pending_requests(&self) {
|
||||||
|
let mut pending = self.pending.lock().await;
|
||||||
|
for (_, request) in pending.drain() {
|
||||||
|
let _ = request
|
||||||
|
.response_tx
|
||||||
|
.send(ResponseResult::Error(iota_ipc::IpcErrorCode::Disconnected));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mark_disconnected(&self, generation: u64) {
|
||||||
|
let removed = {
|
||||||
|
let mut writer = self.writer.lock().await;
|
||||||
|
match writer.as_ref() {
|
||||||
|
Some(active) if active.generation == generation => {
|
||||||
|
writer.take();
|
||||||
|
true
|
||||||
}
|
}
|
||||||
Err(_) => {
|
_ => false,
|
||||||
let _ = self.connection_state.send(IpcConnectionState::Disconnected);
|
}
|
||||||
|
};
|
||||||
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -315,6 +408,65 @@ impl IpcClient {
|
||||||
self.connection_state.borrow().clone()
|
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> {
|
pub async fn send_request(&self, request: LocalRequest) -> Result<ResponseResult> {
|
||||||
let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed);
|
let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed);
|
||||||
let (response_tx, response_rx) = oneshot::channel();
|
let (response_tx, response_rx) = oneshot::channel();
|
||||||
|
|
@ -329,14 +481,17 @@ impl IpcClient {
|
||||||
protocol_version: PROTOCOL_VERSION,
|
protocol_version: PROTOCOL_VERSION,
|
||||||
request,
|
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 {
|
match tokio::time::timeout(Duration::from_secs(30), response_rx).await {
|
||||||
Ok(Ok(result)) => Ok(result),
|
Ok(Ok(result)) => Ok(result),
|
||||||
Ok(Err(_)) => Ok(ResponseResult::Error(iota_ipc::IpcErrorCode::Disconnected)),
|
Ok(Err(_)) => Ok(ResponseResult::Error(iota_ipc::IpcErrorCode::Disconnected)),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
self.pending.lock().await.remove(&request_id);
|
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),
|
["user", "list"] => Some(LocalRequest::ListUsers),
|
||||||
["reconnect"] => Some(LocalRequest::ReconnectOmikron),
|
["reconnect"] => Some(LocalRequest::ReconnectOmikron),
|
||||||
["regenerate", "keys"] => Some(LocalRequest::RotateIotaIdentity),
|
["regenerate", "keys"] => Some(LocalRequest::RotateIotaIdentity),
|
||||||
["reload"] | ["restart"] => Some(LocalRequest::RestartDaemon),
|
["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit {
|
||||||
["shutdown"] | ["stop"] => Some(LocalRequest::StopDaemon),
|
intent: iota_ipc::ExitIntent::Restart,
|
||||||
|
}),
|
||||||
|
["shutdown"] | ["stop"] => Some(LocalRequest::RequestProcessExit {
|
||||||
|
intent: iota_ipc::ExitIntent::Stop,
|
||||||
|
}),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -371,11 +530,7 @@ impl IpcClient {
|
||||||
if trimmed == "ping" || trimmed.starts_with("ping ") {
|
if trimmed == "ping" || trimmed.starts_with("ping ") {
|
||||||
let seq = self.next_request_id.fetch_add(1, Ordering::Relaxed);
|
let seq = self.next_request_id.fetch_add(1, Ordering::Relaxed);
|
||||||
if let Err(e) = self.send(ClientMessage::Ping { seq }).await {
|
if let Err(e) = self.send(ClientMessage::Ping { seq }).await {
|
||||||
let mut state = self
|
let mut state = self.state.app.lock().await;
|
||||||
.state
|
|
||||||
.app
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|error| error.into_inner());
|
|
||||||
state.push_log(UiLogEntry {
|
state.push_log(UiLogEntry {
|
||||||
timestamp_ms: std::time::SystemTime::now()
|
timestamp_ms: std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
|
@ -387,11 +542,7 @@ impl IpcClient {
|
||||||
});
|
});
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
let mut state = self
|
let mut state = self.state.app.lock().await;
|
||||||
.state
|
|
||||||
.app
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|error| error.into_inner());
|
|
||||||
state.push_log(UiLogEntry {
|
state.push_log(UiLogEntry {
|
||||||
timestamp_ms: std::time::SystemTime::now()
|
timestamp_ms: std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
|
@ -407,14 +558,10 @@ impl IpcClient {
|
||||||
if let Some(request) = Self::parse_console_command(&line) {
|
if let Some(request) = Self::parse_console_command(&line) {
|
||||||
match self.send_request(request).await {
|
match self.send_request(request).await {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
let mut state = self
|
let mut state = self.state.app.lock().await;
|
||||||
.state
|
|
||||||
.app
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|error| error.into_inner());
|
|
||||||
let message = match &result {
|
let message = match &result {
|
||||||
ResponseResult::Ok(msg) => msg.clone(),
|
ResponseResult::Ok(msg) => msg.clone(),
|
||||||
ResponseResult::Error(code) => format!("Error: {:?}", code),
|
ResponseResult::Error(code) => Self::format_error(code).into(),
|
||||||
};
|
};
|
||||||
state.push_log(UiLogEntry {
|
state.push_log(UiLogEntry {
|
||||||
timestamp_ms: std::time::SystemTime::now()
|
timestamp_ms: std::time::SystemTime::now()
|
||||||
|
|
@ -428,11 +575,7 @@ impl IpcClient {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let mut state = self
|
let mut state = self.state.app.lock().await;
|
||||||
.state
|
|
||||||
.app
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|error| error.into_inner());
|
|
||||||
state.push_log(UiLogEntry {
|
state.push_log(UiLogEntry {
|
||||||
timestamp_ms: std::time::SystemTime::now()
|
timestamp_ms: std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
|
@ -446,19 +589,15 @@ impl IpcClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let mut state = self
|
let mut state = self.state.app.lock().await;
|
||||||
.state
|
|
||||||
.app
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|error| error.into_inner());
|
|
||||||
state.push_log(UiLogEntry {
|
state.push_log(UiLogEntry {
|
||||||
timestamp_ms: std::time::SystemTime::now()
|
timestamp_ms: std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.as_millis(),
|
.as_millis(),
|
||||||
sender: "Console".into(),
|
sender: "Console".into(),
|
||||||
message: if line.trim() == "help" {
|
message: if trimmed == "help" {
|
||||||
"Available commands: tasks, ping, user, reconnect, regenerate, restart, stop"
|
"Commands: status, tasks, ping, user add <name>, user remove <id>, user list, reconnect, regenerate keys, restart, stop"
|
||||||
.into()
|
.into()
|
||||||
} else {
|
} else {
|
||||||
format!("Unknown command: {}", line)
|
format!("Unknown command: {}", line)
|
||||||
|
|
@ -470,18 +609,43 @@ impl IpcClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn send(&self, message: ClientMessage) -> Result<()> {
|
async fn send(&self, message: ClientMessage) -> Result<()> {
|
||||||
let mut writer = self.writer.lock().await;
|
let deadline = tokio::time::Instant::now() + CONNECT_ATTEMPT_TIMEOUT;
|
||||||
write_msg(&mut *writer, &message).await
|
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) {
|
async fn apply(&self, message: DaemonMessage) {
|
||||||
match message {
|
match message {
|
||||||
DaemonMessage::LogEntry(entry) => {
|
DaemonMessage::LogEntry(entry) => {
|
||||||
let mut state = self
|
let mut state = self.state.app.lock().await;
|
||||||
.state
|
|
||||||
.app
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|error| error.into_inner());
|
|
||||||
state.push_log(UiLogEntry {
|
state.push_log(UiLogEntry {
|
||||||
timestamp_ms: entry.timestamp_ms,
|
timestamp_ms: entry.timestamp_ms,
|
||||||
sender: entry.sender,
|
sender: entry.sender,
|
||||||
|
|
@ -490,11 +654,16 @@ impl IpcClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
DaemonMessage::StateUpdate(snapshot) => {
|
DaemonMessage::StateUpdate(snapshot) => {
|
||||||
let mut state = self
|
// Never hold a watch borrow while sending to that same
|
||||||
.state
|
// channel: send waits for outstanding Ref guards.
|
||||||
.app
|
self.daemon_status.send_modify(|status| {
|
||||||
.lock()
|
status.startup_phase = Some(snapshot.startup_phase);
|
||||||
.unwrap_or_else(|error| error.into_inner());
|
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.cpu = snapshot.cpu;
|
||||||
state.ram = snapshot.ram;
|
state.ram = snapshot.ram;
|
||||||
state.ping = snapshot.ping;
|
state.ping = snapshot.ping;
|
||||||
|
|
@ -503,41 +672,21 @@ impl IpcClient {
|
||||||
state.sys_info = snapshot.sys_info;
|
state.sys_info = snapshot.sys_info;
|
||||||
}
|
}
|
||||||
DaemonMessage::MetricSample(sample) => {
|
DaemonMessage::MetricSample(sample) => {
|
||||||
let mut state = self
|
let mut state = self.state.app.lock().await;
|
||||||
.state
|
|
||||||
.app
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|error| error.into_inner());
|
|
||||||
if let Some(cpu) = sample.cpu {
|
if let Some(cpu) = sample.cpu {
|
||||||
let idx = state.cpu.len() as f64;
|
state.push_cpu((0.0, cpu));
|
||||||
state.cpu.push((idx, cpu));
|
|
||||||
if state.cpu.len() > iota_state::MAX_POINTS {
|
|
||||||
state.cpu.remove(0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if let Some(ram) = sample.ram {
|
if let Some(ram) = sample.ram {
|
||||||
let idx = state.ram.len() as f64;
|
state.push_ram((0.0, ram));
|
||||||
state.ram.push((idx, ram));
|
|
||||||
if state.ram.len() > iota_state::MAX_POINTS {
|
|
||||||
state.ram.remove(0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if let Some(ping) = sample.ping {
|
if let Some(ping) = sample.ping {
|
||||||
state.push_ping_val(ping);
|
state.push_ping_val(ping);
|
||||||
}
|
}
|
||||||
if let Some(net_up) = sample.net_up {
|
if let Some(net_up) = sample.net_up {
|
||||||
let idx = state.net_up.len() as f64;
|
state.push_net_up((0.0, net_up));
|
||||||
state.net_up.push((idx, net_up));
|
|
||||||
if state.net_up.len() > iota_state::MAX_POINTS {
|
|
||||||
state.net_up.remove(0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if let Some(net_down) = sample.net_down {
|
if let Some(net_down) = sample.net_down {
|
||||||
let idx = state.net_down.len() as f64;
|
state.push_net_down((0.0, net_down));
|
||||||
state.net_down.push((idx, net_down));
|
|
||||||
if state.net_down.len() > iota_state::MAX_POINTS {
|
|
||||||
state.net_down.remove(0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DaemonMessage::Response(response) => {
|
DaemonMessage::Response(response) => {
|
||||||
|
|
@ -545,14 +694,10 @@ impl IpcClient {
|
||||||
if let Some(request) = pending.remove(&response.request_id) {
|
if let Some(request) = pending.remove(&response.request_id) {
|
||||||
let _ = request.response_tx.send(response.result);
|
let _ = request.response_tx.send(response.result);
|
||||||
} else {
|
} else {
|
||||||
let mut state = self
|
let mut state = self.state.app.lock().await;
|
||||||
.state
|
|
||||||
.app
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|error| error.into_inner());
|
|
||||||
let message = match &response.result {
|
let message = match &response.result {
|
||||||
ResponseResult::Ok(msg) => msg.clone(),
|
ResponseResult::Ok(msg) => msg.clone(),
|
||||||
ResponseResult::Error(code) => format!("Error: {:?}", code),
|
ResponseResult::Error(code) => Self::format_error(code).into(),
|
||||||
};
|
};
|
||||||
state.push_log(UiLogEntry {
|
state.push_log(UiLogEntry {
|
||||||
timestamp_ms: std::time::SystemTime::now()
|
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::Pong { .. } => {}
|
||||||
DaemonMessage::LifecycleEvent(event) => match event {
|
DaemonMessage::LifecycleEvent(event) => match event {
|
||||||
iota_ipc::LifecycleEvent::Shutdown { reason } => {
|
iota_ipc::LifecycleEvent::Shutdown { reason } => {
|
||||||
let mut state = self
|
let mut state = self.state.app.lock().await;
|
||||||
.state
|
|
||||||
.app
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|error| error.into_inner());
|
|
||||||
state.push_log(UiLogEntry {
|
state.push_log(UiLogEntry {
|
||||||
timestamp_ms: std::time::SystemTime::now()
|
timestamp_ms: std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
|
@ -584,14 +726,19 @@ impl IpcClient {
|
||||||
is_error: true,
|
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 } => {
|
DaemonMessage::Gap { skipped } => {
|
||||||
let mut state = self
|
let mut state = self.state.app.lock().await;
|
||||||
.state
|
|
||||||
.app
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|error| error.into_inner());
|
|
||||||
state.push_log(UiLogEntry {
|
state.push_log(UiLogEntry {
|
||||||
timestamp_ms: std::time::SystemTime::now()
|
timestamp_ms: std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.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 log_card;
|
||||||
}
|
}
|
||||||
pub mod screens {
|
pub mod screens {
|
||||||
|
pub mod daemon_setup;
|
||||||
pub mod main_screen;
|
pub mod main_screen;
|
||||||
pub mod md_viewer;
|
pub mod md_viewer;
|
||||||
pub mod screens;
|
pub mod screens;
|
||||||
|
|
@ -17,7 +18,12 @@ pub mod util {
|
||||||
pub mod terms_focus;
|
pub mod terms_focus;
|
||||||
}
|
}
|
||||||
pub mod app_state;
|
pub mod app_state;
|
||||||
|
pub mod controls;
|
||||||
pub mod input_handler;
|
pub mod input_handler;
|
||||||
pub mod ipc_client;
|
|
||||||
pub mod interaction_result;
|
pub mod interaction_result;
|
||||||
|
pub mod ipc_client;
|
||||||
|
pub mod layout;
|
||||||
|
pub mod render_context;
|
||||||
|
pub mod theme;
|
||||||
pub mod ui;
|
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,
|
log_card::LogCard,
|
||||||
},
|
},
|
||||||
interaction_result::InteractionResult,
|
interaction_result::InteractionResult,
|
||||||
ipc_client::IpcConnectionState,
|
ipc_client::{DaemonStatus, IpcConnectionState},
|
||||||
|
render_context::RenderContext,
|
||||||
screens::screens::{NavDirection, Screen},
|
screens::screens::{NavDirection, Screen},
|
||||||
ui::UI,
|
ui::UI,
|
||||||
};
|
};
|
||||||
|
|
@ -27,6 +28,7 @@ pub struct MainScreen {
|
||||||
selected_coords: (usize, usize),
|
selected_coords: (usize, usize),
|
||||||
graphs_open: bool,
|
graphs_open: bool,
|
||||||
connection_status_rx: watch::Receiver<IpcConnectionState>,
|
connection_status_rx: watch::Receiver<IpcConnectionState>,
|
||||||
|
daemon_status_rx: watch::Receiver<DaemonStatus>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MainScreen {
|
impl MainScreen {
|
||||||
|
|
@ -39,10 +41,17 @@ impl MainScreen {
|
||||||
vec![Some(1), Some(4)],
|
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());
|
let mut log_card = LogCard::new(state.clone());
|
||||||
log_card.set_borders(Borders::TOP.union(Borders::RIGHT).union(Borders::LEFT));
|
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);
|
console_card.set_joins(Borders::TOP);
|
||||||
|
|
||||||
elements.push(Box::new(log_card));
|
elements.push(Box::new(log_card));
|
||||||
|
|
@ -61,7 +70,8 @@ impl MainScreen {
|
||||||
|
|
||||||
let graphs_open = true;
|
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 {
|
let mut screen = MainScreen {
|
||||||
elements,
|
elements,
|
||||||
|
|
@ -69,6 +79,7 @@ impl MainScreen {
|
||||||
selected_coords: (1, 0),
|
selected_coords: (1, 0),
|
||||||
graphs_open,
|
graphs_open,
|
||||||
connection_status_rx,
|
connection_status_rx,
|
||||||
|
daemon_status_rx,
|
||||||
};
|
};
|
||||||
screen.focus_current();
|
screen.focus_current();
|
||||||
screen
|
screen
|
||||||
|
|
@ -198,22 +209,46 @@ impl Screen for MainScreen {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(&self, f: &mut Frame, rect: Rect) {
|
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>) {
|
||||||
let status = self.connection_status_rx.borrow();
|
// A watch Ref blocks senders until it is dropped. Rendering may do
|
||||||
let status_text = match &*status {
|
// 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::Connected => "Connected".to_string(),
|
||||||
IpcConnectionState::Connecting => "Connecting...".to_string(),
|
IpcConnectionState::Connecting => "Connecting...".to_string(),
|
||||||
IpcConnectionState::Reconnecting { attempt } => {
|
IpcConnectionState::Reconnecting { attempt } => {
|
||||||
format!("Reconnecting (attempt {})...", attempt)
|
format!("Reconnecting (attempt {})...", attempt)
|
||||||
}
|
}
|
||||||
IpcConnectionState::Incompatible { message } => {
|
IpcConnectionState::Incompatible { message } => {
|
||||||
format!("Incompatible: {}", message)
|
format!("Incompatible protocol: {}", message)
|
||||||
|
}
|
||||||
|
IpcConnectionState::Failed { message } => {
|
||||||
|
format!("Connection failed: {}", message)
|
||||||
}
|
}
|
||||||
IpcConnectionState::Disconnected => "Disconnected".to_string(),
|
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()
|
let main_block = Block::default()
|
||||||
.title(format!("Main [{}]", status_text))
|
.title(format!(
|
||||||
.borders(Borders::ALL);
|
"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);
|
f.render_widget(main_block, rect);
|
||||||
|
|
||||||
let inner = rect.inner(Margin {
|
let inner = rect.inner(Margin {
|
||||||
|
|
@ -221,7 +256,11 @@ impl Screen for MainScreen {
|
||||||
horizontal: 1,
|
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 main_width = inner.width.saturating_sub(graphs_width);
|
||||||
|
|
||||||
let horizontal_chunks = Layout::default()
|
let horizontal_chunks = Layout::default()
|
||||||
|
|
@ -239,11 +278,11 @@ impl Screen for MainScreen {
|
||||||
Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(left_area);
|
Layout::vertical([Constraint::Min(0), Constraint::Length(3)]).split(left_area);
|
||||||
|
|
||||||
if let Some(log) = self.elements.get(0) {
|
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) {
|
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
|
let graph_elements: Vec<_> = self
|
||||||
|
|
@ -262,7 +301,7 @@ impl Screen for MainScreen {
|
||||||
.split(right_area);
|
.split(right_area);
|
||||||
|
|
||||||
for (el, area) in graph_elements.iter().zip(graph_chunks.iter()) {
|
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 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 {
|
pub struct FileViewer {
|
||||||
title: String,
|
title: String,
|
||||||
text: Vec<DisplayLine>,
|
content: String,
|
||||||
scroll: u16,
|
scroll: u16,
|
||||||
scroll_x: u16,
|
scroll_x: u16,
|
||||||
}
|
}
|
||||||
|
|
@ -24,8 +29,8 @@ impl Screen for FileViewer {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(&self, f: &mut Frame, rect: Rect) {
|
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>) {
|
||||||
self.draw(f, rect);
|
self.draw(f, rect, context.theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
|
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
|
||||||
|
|
@ -52,7 +57,7 @@ impl FileViewer {
|
||||||
pub fn new(title: String, content: &str) -> Self {
|
pub fn new(title: String, content: &str) -> Self {
|
||||||
Self {
|
Self {
|
||||||
title,
|
title,
|
||||||
text: parse_document(content.to_owned()),
|
content: content.to_owned(),
|
||||||
scroll: 0,
|
scroll: 0,
|
||||||
scroll_x: 0,
|
scroll_x: 0,
|
||||||
}
|
}
|
||||||
|
|
@ -62,7 +67,7 @@ impl FileViewer {
|
||||||
terminal
|
terminal
|
||||||
.draw(|f| {
|
.draw(|f| {
|
||||||
let area = f.area();
|
let area = f.area();
|
||||||
self.draw(f, area);
|
self.draw(f, area, &crate::theme::resolve(ThemeName::Ansi));
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|
@ -77,12 +82,13 @@ impl FileViewer {
|
||||||
}
|
}
|
||||||
terminal
|
terminal
|
||||||
}
|
}
|
||||||
fn draw(&self, f: &mut Frame, area: Rect) {
|
fn draw(&self, f: &mut Frame, area: Rect, theme: &ResolvedTheme) {
|
||||||
use ratatui::text::Text;
|
use ratatui::text::Text;
|
||||||
|
|
||||||
let mut rendered_lines = Vec::new();
|
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 {
|
if display_line.scrollable {
|
||||||
let content: String = display_line
|
let content: String = display_line
|
||||||
.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 lines_vec = Vec::new();
|
||||||
let mut in_code_block = false;
|
let mut in_code_block = false;
|
||||||
let liness: Vec<String> = input.lines().map(String::from).collect();
|
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 {
|
lines_vec.push(DisplayLine {
|
||||||
line: Line::from(Span::styled(
|
line: Line::from(Span::styled(
|
||||||
format!("────────{}────────", code),
|
format!("────────{}────────", code),
|
||||||
Style::default().fg(Color::DarkGray),
|
theme.markdown.divider,
|
||||||
)),
|
)),
|
||||||
scrollable: false,
|
scrollable: false,
|
||||||
});
|
});
|
||||||
|
|
@ -182,10 +188,7 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
|
||||||
|
|
||||||
if in_code_block {
|
if in_code_block {
|
||||||
lines_vec.push(DisplayLine {
|
lines_vec.push(DisplayLine {
|
||||||
line: Line::from(Span::styled(
|
line: Line::from(Span::styled(raw.to_string(), theme.markdown.code)),
|
||||||
raw.to_string(),
|
|
||||||
Style::default().fg(Color::Yellow),
|
|
||||||
)),
|
|
||||||
scrollable: false,
|
scrollable: false,
|
||||||
});
|
});
|
||||||
i += 1;
|
i += 1;
|
||||||
|
|
@ -195,9 +198,13 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
|
||||||
lines_vec.push(DisplayLine {
|
lines_vec.push(DisplayLine {
|
||||||
line: Line::from(Span::styled(
|
line: Line::from(Span::styled(
|
||||||
raw.trim_start_matches("### ").to_string(),
|
raw.trim_start_matches("### ").to_string(),
|
||||||
Style::default()
|
theme.apply_text_semantics(
|
||||||
.fg(Color::Cyan)
|
theme.markdown.heading,
|
||||||
.add_modifier(Modifier::BOLD),
|
TextSemantics {
|
||||||
|
bold: true,
|
||||||
|
underline: false,
|
||||||
|
},
|
||||||
|
),
|
||||||
)),
|
)),
|
||||||
scrollable: false,
|
scrollable: false,
|
||||||
});
|
});
|
||||||
|
|
@ -208,9 +215,13 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
|
||||||
lines_vec.push(DisplayLine {
|
lines_vec.push(DisplayLine {
|
||||||
line: Line::from(Span::styled(
|
line: Line::from(Span::styled(
|
||||||
raw.trim_start_matches("## ").to_string(),
|
raw.trim_start_matches("## ").to_string(),
|
||||||
Style::default()
|
theme.apply_text_semantics(
|
||||||
.fg(Color::LightCyan)
|
theme.markdown.heading,
|
||||||
.add_modifier(Modifier::BOLD),
|
TextSemantics {
|
||||||
|
bold: true,
|
||||||
|
underline: false,
|
||||||
|
},
|
||||||
|
),
|
||||||
)),
|
)),
|
||||||
scrollable: false,
|
scrollable: false,
|
||||||
});
|
});
|
||||||
|
|
@ -221,9 +232,13 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
|
||||||
lines_vec.push(DisplayLine {
|
lines_vec.push(DisplayLine {
|
||||||
line: Line::from(Span::styled(
|
line: Line::from(Span::styled(
|
||||||
raw.trim_start_matches("# ").to_string(),
|
raw.trim_start_matches("# ").to_string(),
|
||||||
Style::default()
|
theme.apply_text_semantics(
|
||||||
.fg(Color::Gray)
|
theme.markdown.heading,
|
||||||
.add_modifier(Modifier::BOLD),
|
TextSemantics {
|
||||||
|
bold: true,
|
||||||
|
underline: false,
|
||||||
|
},
|
||||||
|
),
|
||||||
)),
|
)),
|
||||||
scrollable: 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<_>>());
|
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;
|
i = j;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
lines_vec.push(DisplayLine {
|
lines_vec.push(DisplayLine {
|
||||||
line: Line::from(parse_inline(raw.as_str())),
|
line: Line::from(parse_inline(raw.as_str(), theme)),
|
||||||
scrollable: false,
|
scrollable: false,
|
||||||
});
|
});
|
||||||
i += 1;
|
i += 1;
|
||||||
|
|
@ -269,7 +284,7 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
|
||||||
lines_vec
|
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 spans = Vec::new();
|
||||||
let mut buf = String::new();
|
let mut buf = String::new();
|
||||||
|
|
||||||
|
|
@ -294,7 +309,11 @@ fn parse_inline(input: &str) -> Vec<Span<'static>> {
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(kind) = toggle {
|
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 {
|
match kind {
|
||||||
"bold" => bold = !bold,
|
"bold" => bold = !bold,
|
||||||
|
|
@ -308,24 +327,21 @@ fn parse_inline(input: &str) -> Vec<Span<'static>> {
|
||||||
buf.push(c);
|
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
|
spans
|
||||||
}
|
}
|
||||||
|
|
||||||
fn current_style(bold: bool, underline: bool, code: bool) -> Style {
|
fn current_style(bold: bool, underline: bool, code: bool, theme: &ResolvedTheme) -> Style {
|
||||||
let mut style = Style::default();
|
let base = if code {
|
||||||
|
theme.markdown.code
|
||||||
if bold {
|
} else {
|
||||||
style = style.add_modifier(Modifier::BOLD);
|
theme.markdown.normal
|
||||||
}
|
};
|
||||||
if underline {
|
theme.apply_text_semantics(base, TextSemantics { bold, underline })
|
||||||
style = style.add_modifier(Modifier::UNDERLINED);
|
|
||||||
}
|
|
||||||
if code {
|
|
||||||
style = style.fg(Color::Yellow);
|
|
||||||
}
|
|
||||||
|
|
||||||
style
|
|
||||||
}
|
}
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct DisplayLine {
|
pub struct DisplayLine {
|
||||||
|
|
@ -333,7 +349,7 @@ pub struct DisplayLine {
|
||||||
scrollable: bool,
|
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 {
|
if table.len() < 2 {
|
||||||
return vec![];
|
return vec![];
|
||||||
}
|
}
|
||||||
|
|
@ -377,7 +393,7 @@ fn table_to_lines(table: Vec<Vec<String>>) -> Vec<DisplayLine> {
|
||||||
.join("─┼─");
|
.join("─┼─");
|
||||||
|
|
||||||
lines.push(DisplayLine {
|
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,
|
scrollable: true,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -403,11 +419,15 @@ fn table_to_lines(table: Vec<Vec<String>>) -> Vec<DisplayLine> {
|
||||||
}
|
}
|
||||||
|
|
||||||
let style = if row_idx == 0 {
|
let style = if row_idx == 0 {
|
||||||
Style::default()
|
theme.apply_text_semantics(
|
||||||
.fg(Color::Cyan)
|
theme.markdown.table_header,
|
||||||
.add_modifier(Modifier::BOLD)
|
TextSemantics {
|
||||||
|
bold: true,
|
||||||
|
underline: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
Style::default().fg(Color::Green)
|
theme.markdown.table_text
|
||||||
};
|
};
|
||||||
|
|
||||||
lines.push(DisplayLine {
|
lines.push(DisplayLine {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ use std::any::Any;
|
||||||
use crossterm::event::KeyEvent;
|
use crossterm::event::KeyEvent;
|
||||||
use ratatui::{Frame, layout::Rect};
|
use ratatui::{Frame, layout::Rect};
|
||||||
|
|
||||||
use crate::interaction_result::InteractionResult;
|
use crate::{interaction_result::InteractionResult, render_context::RenderContext};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum NavDirection {
|
pub enum NavDirection {
|
||||||
|
|
@ -20,6 +20,6 @@ pub trait Screen: Send + Sync + Any {
|
||||||
fn as_any(&self) -> &dyn Any;
|
fn as_any(&self) -> &dyn Any;
|
||||||
fn as_any_mut(&mut self) -> &mut 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;
|
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,19 @@
|
||||||
use crate::{
|
use crate::{
|
||||||
|
controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line},
|
||||||
interaction_result::InteractionResult,
|
interaction_result::InteractionResult,
|
||||||
|
render_context::RenderContext,
|
||||||
screens::{md_viewer::FileViewer, screens::Screen},
|
screens::{md_viewer::FileViewer, screens::Screen},
|
||||||
ui::UI,
|
util::{buttons::draw_buttons, terms_focus::Focus},
|
||||||
util::{
|
|
||||||
buttons::{checkbox, draw_buttons},
|
|
||||||
terms_focus::Focus,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
use crossterm::event::{KeyCode, KeyEvent};
|
use crossterm::event::{KeyCode, KeyEvent};
|
||||||
use iota_terms::{TermsType, get_link, get_terms};
|
use iota_terms::{TermsType, get_link, get_terms};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||||
style::{Color, Style},
|
|
||||||
text::{Line, Span, Text},
|
text::{Line, Span, Text},
|
||||||
widgets::{Block, Borders, Paragraph},
|
widgets::{Block, Borders, Paragraph},
|
||||||
};
|
};
|
||||||
use std::{any::Any, pin::Pin, sync::Arc};
|
use std::{any::Any, pin::Pin};
|
||||||
use tokio::sync::oneshot;
|
use tokio::sync::oneshot;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
|
@ -26,9 +23,7 @@ pub enum UserChoice {
|
||||||
AcceptAll,
|
AcceptAll,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)] // ui is unused
|
|
||||||
pub struct TermsCheckerScreen {
|
pub struct TermsCheckerScreen {
|
||||||
_ui: Arc<UI>,
|
|
||||||
sender: Option<oneshot::Sender<UserChoice>>,
|
sender: Option<oneshot::Sender<UserChoice>>,
|
||||||
|
|
||||||
eula: bool,
|
eula: bool,
|
||||||
|
|
@ -39,9 +34,8 @@ pub struct TermsCheckerScreen {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
Self {
|
||||||
_ui: ui,
|
|
||||||
sender,
|
sender,
|
||||||
eula: false,
|
eula: false,
|
||||||
tos: false,
|
tos: false,
|
||||||
|
|
@ -59,7 +53,7 @@ impl Screen for TermsCheckerScreen {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render(&self, f: &mut Frame, size: Rect) {
|
fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>) {
|
||||||
let mut needed_height = 5;
|
let mut needed_height = 5;
|
||||||
|
|
||||||
if size.height < 6 || size.width < 27 {
|
if size.height < 6 || size.width < 27 {
|
||||||
|
|
@ -169,9 +163,36 @@ impl Screen for TermsCheckerScreen {
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
let mut text_lines = vec![
|
let mut text_lines = vec![
|
||||||
checkbox(eula_text, self.eula, self.focus == Focus::Eula, true),
|
render_choice_line(
|
||||||
checkbox(tos_text, self.tos, self.focus == Focus::Tos, self.eula),
|
eula_text,
|
||||||
checkbox(pp_text, self.pp, self.focus == Focus::Pp, self.eula),
|
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(""),
|
||||||
Line::from("¹ Necessary– required to run the program"),
|
Line::from("¹ Necessary– required to run the program"),
|
||||||
Line::from("² Optional – required only for Tensamin services"),
|
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 {
|
if size.width < 60 || size.height < needed_height as u16 {
|
||||||
let width_style = if size.width > 76 {
|
let width_style = if size.width > 76 {
|
||||||
Style::default().fg(Color::Green)
|
context.theme.status.success
|
||||||
} else if size.width >= 60 {
|
} else if size.width >= 60 {
|
||||||
Style::default().fg(Color::Yellow)
|
context.theme.status.warning
|
||||||
} else {
|
} else {
|
||||||
Style::default().fg(Color::Red)
|
context.theme.status.error
|
||||||
};
|
};
|
||||||
|
|
||||||
let height_style = if size.height > 19 {
|
let height_style = if size.height > 19 {
|
||||||
Style::default().fg(Color::Green)
|
context.theme.status.success
|
||||||
} else if size.height >= 13 {
|
} else if size.height >= 13 {
|
||||||
Style::default().fg(Color::Yellow)
|
context.theme.status.warning
|
||||||
} else {
|
} else {
|
||||||
Style::default().fg(Color::Red)
|
context.theme.status.error
|
||||||
};
|
};
|
||||||
|
|
||||||
let warning_text = Text::from(vec![
|
let warning_text = Text::from(vec![
|
||||||
|
|
@ -245,6 +266,7 @@ impl Screen for TermsCheckerScreen {
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
|
context.theme,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
use crate::screens::terms_checker::UserChoice;
|
use crate::screens::terms_checker::UserChoice;
|
||||||
use crate::{
|
use crate::{
|
||||||
|
controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line},
|
||||||
interaction_result::InteractionResult,
|
interaction_result::InteractionResult,
|
||||||
|
render_context::RenderContext,
|
||||||
screens::{md_viewer::FileViewer, screens::Screen},
|
screens::{md_viewer::FileViewer, screens::Screen},
|
||||||
util::{
|
util::{buttons::draw_buttons, terms_focus::Focus},
|
||||||
buttons::{checkbox, draw_buttons},
|
|
||||||
terms_focus::Focus,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
use chrono::{Local, TimeZone, Utc};
|
use chrono::{Local, TimeZone, Utc};
|
||||||
use crossterm::event::{KeyCode, KeyEvent};
|
use crossterm::event::{KeyCode, KeyEvent};
|
||||||
|
|
@ -13,7 +12,6 @@ use iota_terms::{Doc, TermsType, get_newest_link, get_terms};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||||
style::{Color, Style},
|
|
||||||
text::{Line, Span, Text},
|
text::{Line, Span, Text},
|
||||||
widgets::{Block, Borders, Paragraph},
|
widgets::{Block, Borders, Paragraph},
|
||||||
};
|
};
|
||||||
|
|
@ -123,7 +121,19 @@ impl Screen for TermsUpdaterScreen {
|
||||||
self
|
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;
|
let mut needed_height = 5;
|
||||||
|
|
||||||
if size.height < 6 || size.width < 27 {
|
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 {
|
if size.width < 60 || size.height < needed_height as u16 {
|
||||||
let width_style = if size.width > 76 {
|
let width_style = if size.width > 76 {
|
||||||
Style::default().fg(Color::Green)
|
context.theme.status.success
|
||||||
} else if size.width >= 60 {
|
} else if size.width >= 60 {
|
||||||
Style::default().fg(Color::Yellow)
|
context.theme.status.warning
|
||||||
} else {
|
} else {
|
||||||
Style::default().fg(Color::Red)
|
context.theme.status.error
|
||||||
};
|
};
|
||||||
|
|
||||||
let height_style = if size.height > 20 {
|
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) {
|
} else if size.height >= (header_lines as u16 + 10) {
|
||||||
Style::default().fg(Color::Yellow)
|
context.theme.status.warning
|
||||||
} else {
|
} else {
|
||||||
Style::default().fg(Color::Red)
|
context.theme.status.error
|
||||||
};
|
};
|
||||||
|
|
||||||
let warning_text = Text::from(vec![
|
let warning_text = Text::from(vec![
|
||||||
|
|
@ -579,6 +589,7 @@ impl Screen for TermsUpdaterScreen {
|
||||||
self.update_needed,
|
self.update_needed,
|
||||||
downgrade_scenario,
|
downgrade_scenario,
|
||||||
self.pp_needed || self.tos_needed,
|
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::{
|
use crate::{
|
||||||
input_handler::setup_input_handler, interaction_result::InteractionResult,
|
input_handler::setup_input_handler,
|
||||||
ipc_client::IpcClient, screens::screens::Screen,
|
interaction_result::InteractionResult,
|
||||||
|
ipc_client::IpcClient,
|
||||||
|
render_context::RenderContext,
|
||||||
|
screens::screens::Screen,
|
||||||
|
theme::{self, ResolvedTheme, ThemeName},
|
||||||
};
|
};
|
||||||
use crossterm::event::KeyEvent;
|
use crossterm::event::KeyEvent;
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
use ratatui::{Terminal, backend::CrosstermBackend, init};
|
use ratatui::{Terminal, backend::CrosstermBackend};
|
||||||
use std::{
|
use std::{
|
||||||
collections::VecDeque,
|
io,
|
||||||
io::Stdout,
|
io::Stdout,
|
||||||
|
panic::PanicHookInfo,
|
||||||
sync::{
|
sync::{
|
||||||
Arc, Mutex,
|
Arc, Mutex,
|
||||||
atomic::{AtomicBool, Ordering},
|
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
|
/// UI state and rendering
|
||||||
|
|
||||||
pub static FPS: Lazy<RwLock<(f64, f64)>> = Lazy::new(|| RwLock::new((0.0, 0.0)));
|
pub static FPS: Lazy<RwLock<(f64, f64)>> = Lazy::new(|| RwLock::new((0.0, 0.0)));
|
||||||
|
|
||||||
pub struct UI {
|
pub struct UI {
|
||||||
ipc: Arc<IpcClient>,
|
ipc: RwLock<Option<Arc<IpcClient>>>,
|
||||||
shutdown: AtomicBool,
|
shutdown_on_empty: bool,
|
||||||
|
cancellation: CancellationToken,
|
||||||
pub terminal: Arc<Mutex<Terminal<CrosstermBackend<Stdout>>>>,
|
pub terminal: Arc<Mutex<Terminal<CrosstermBackend<Stdout>>>>,
|
||||||
screen_stack: Arc<RwLock<Vec<Box<dyn Screen>>>>,
|
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> {
|
pub fn start_tui(ipc: Arc<IpcClient>) -> io::Result<TuiSession> {
|
||||||
let ui = Arc::new(UI::new(ipc));
|
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();
|
let uic = ui.clone();
|
||||||
tokio::spawn(async move {
|
let renderer_task = tokio::spawn(async move {
|
||||||
let mut last_render = Instant::now();
|
let cancellation = uic.cancellation_token();
|
||||||
|
let result: io::Result<()> = loop {
|
||||||
let mut fps_samples: VecDeque<f64> = VecDeque::with_capacity(20);
|
tokio::select! {
|
||||||
let mut skip_samples: VecDeque<u16> = VecDeque::with_capacity(20);
|
_ = cancellation.cancelled() => break Ok(()),
|
||||||
|
_ = uic.invalidation.notified() => { if !uic.is_shutdown() { uic.render().await?; } },
|
||||||
let mut fps_sum = 0.0;
|
_ = tokio::time::sleep(std::time::Duration::from_millis(250)) => { if !uic.is_shutdown() { uic.render().await?; } },
|
||||||
let mut skip_sum: u32 = 0;
|
|
||||||
|
|
||||||
let mut skipped = 0;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
if uic.is_shutdown() {
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
if skipped > 5 {
|
if let Err(error) = &result {
|
||||||
uic.render().await;
|
*uic.failure.lock().unwrap() = Some(error.to_string());
|
||||||
|
uic.request_shutdown();
|
||||||
skip_samples.push_back(skipped);
|
|
||||||
skip_sum += skipped as u32;
|
|
||||||
|
|
||||||
if skip_samples.len() > 20 {
|
|
||||||
if let Some(old) = skip_samples.pop_front() {
|
|
||||||
skip_sum -= old as u32;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
skipped = 0;
|
|
||||||
|
|
||||||
let elapsed = last_render.elapsed().as_secs_f64();
|
|
||||||
if elapsed > 0.0 {
|
|
||||||
let fps = 1.0 / elapsed;
|
|
||||||
|
|
||||||
fps_samples.push_back(fps);
|
|
||||||
fps_sum += fps;
|
|
||||||
|
|
||||||
if fps_samples.len() > 20 {
|
|
||||||
if let Some(old) = fps_samples.pop_front() {
|
|
||||||
fps_sum -= old;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let avg_fps = if !fps_samples.is_empty() {
|
|
||||||
fps_sum / fps_samples.len() as f64
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
|
|
||||||
let avg_skips_percentage = if !skip_samples.is_empty() {
|
|
||||||
let avg_skipped = skip_sum as f64 / skip_samples.len() as f64;
|
|
||||||
let total_iterations = avg_skipped + 1.0;
|
|
||||||
(avg_skipped / total_iterations) * 100.0
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
|
|
||||||
*FPS.write().await = (avg_fps, avg_skips_percentage);
|
|
||||||
|
|
||||||
last_render = Instant::now();
|
|
||||||
} else {
|
|
||||||
skipped += 1;
|
|
||||||
}
|
|
||||||
tokio::time::sleep(Duration::from_millis(16)).await;
|
|
||||||
}
|
}
|
||||||
ratatui::restore();
|
result
|
||||||
});
|
});
|
||||||
setup_input_handler(ui.clone());
|
let input_task = setup_input_handler(ui.clone());
|
||||||
ui
|
// 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 {
|
impl UI {
|
||||||
pub fn new(ipc: Arc<IpcClient>) -> Self {
|
pub(crate) fn new(
|
||||||
let terminal = init();
|
ipc: Option<Arc<IpcClient>>,
|
||||||
Self {
|
shutdown_on_empty: bool,
|
||||||
ipc,
|
theme: ResolvedTheme,
|
||||||
shutdown: AtomicBool::new(false),
|
) -> 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)),
|
terminal: Arc::new(Mutex::new(terminal)),
|
||||||
screen_stack: Arc::new(RwLock::new(Vec::new())),
|
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> {
|
pub async fn ipc(&self) -> Option<Arc<IpcClient>> {
|
||||||
self.ipc.clone()
|
self.ipc.read().await.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn client_state(&self) -> iota_state::ClientState {
|
pub async fn client_state(&self) -> Option<iota_state::ClientState> {
|
||||||
self.ipc.state()
|
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 {
|
pub fn is_shutdown(&self) -> bool {
|
||||||
self.shutdown.load(Ordering::Relaxed)
|
self.cancellation.is_cancelled()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn request_shutdown(&self) {
|
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<()> {
|
/// Lets bootstrap operations race their work against Ctrl+C without
|
||||||
self.ipc.send_command(0, "restart".into()).await
|
/// 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>) {
|
pub async fn set_screen(&self, screen: Box<dyn Screen>) {
|
||||||
self.screen_stack.write().await.push(screen);
|
self.screen_stack.write().await.push(screen);
|
||||||
|
self.invalidate();
|
||||||
}
|
}
|
||||||
pub async fn replace_screen(&self, screen: Box<dyn Screen>) {
|
pub async fn replace_screen(&self, screen: Box<dyn Screen>) {
|
||||||
let mut stack = self.screen_stack.write().await;
|
let mut stack = self.screen_stack.write().await;
|
||||||
stack.pop();
|
stack.clear();
|
||||||
stack.push(screen);
|
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) {
|
pub async fn handle_input(self: Arc<Self>, key_event: KeyEvent) {
|
||||||
let result = {
|
let result = {
|
||||||
|
|
@ -155,30 +273,41 @@ impl UI {
|
||||||
}
|
}
|
||||||
InteractionResult::OpenFutureScreen { screen: fut } => {
|
InteractionResult::OpenFutureScreen { screen: fut } => {
|
||||||
let ui = self.clone();
|
let ui = self.clone();
|
||||||
let screen = fut.await;
|
tokio::select! {
|
||||||
ui.set_screen(screen).await;
|
screen = fut => ui.set_screen(screen).await,
|
||||||
|
_ = ui.cancellation.cancelled() => return,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
InteractionResult::CloseScreen => {
|
InteractionResult::CloseScreen => {
|
||||||
let mut stack = self.screen_stack.write().await;
|
let mut stack = self.screen_stack.write().await;
|
||||||
stack.pop();
|
stack.pop();
|
||||||
|
|
||||||
if stack.is_empty() {
|
if stack.is_empty() && self.shutdown_on_empty {
|
||||||
self.request_shutdown();
|
self.request_shutdown();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
InteractionResult::Handled => {}
|
InteractionResult::Handled => {}
|
||||||
InteractionResult::Unhandled => {}
|
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() {
|
if let Some(screen) = self.screen_stack.read().await.last() {
|
||||||
let mut terminal = self.terminal.lock().unwrap();
|
let mut terminal = self
|
||||||
terminal
|
.terminal
|
||||||
.draw(|f| {
|
.lock()
|
||||||
screen.render(f, f.area());
|
.map_err(|_| io::Error::other("terminal mutex poisoned"))?;
|
||||||
})
|
terminal.draw(|f| {
|
||||||
.unwrap();
|
screen.render(f, f.area(), &context);
|
||||||
|
})?;
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,20 @@ use ratatui::prelude::*;
|
||||||
use ratatui::style::Style;
|
use ratatui::style::Style;
|
||||||
use ratatui::widgets::Borders;
|
use ratatui::widgets::Borders;
|
||||||
|
|
||||||
fn set_join_char(frame: &mut Frame, x: u16, y: u16, c: char) {
|
fn set_join_char(frame: &mut Frame, x: u16, y: u16, c: char, style: Style) {
|
||||||
frame
|
frame.buffer_mut().set_string(x, y, c.to_string(), style);
|
||||||
.buffer_mut()
|
|
||||||
.set_string(x, y, c.to_string(), Style::default());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 x0 = area.x;
|
||||||
let y0 = area.y;
|
let y0 = area.y;
|
||||||
let x1 = area.x + area.width - 1;
|
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, true) => '┬',
|
||||||
(false, false) => '┌',
|
(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) {
|
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, true) => '┬',
|
||||||
(false, false) => '┐',
|
(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) {
|
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, true) => '┴',
|
||||||
(false, false) => '└',
|
(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) {
|
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, true) => '┴',
|
||||||
(false, false) => '┘',
|
(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::{
|
use ratatui::layout::Rect;
|
||||||
layout::{Alignment, Rect},
|
|
||||||
style::{Color, Modifier, Style},
|
use crate::{
|
||||||
text::{Line, Span},
|
controls::button::{
|
||||||
widgets::{Block, Borders, Paragraph},
|
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(
|
pub fn draw_buttons(
|
||||||
f: &mut ratatui::Frame,
|
frame: &mut ratatui::Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
current_focus: Focus,
|
current_focus: Focus,
|
||||||
state: (bool, bool),
|
state: (bool, bool),
|
||||||
update_needed: bool,
|
update_needed: bool,
|
||||||
downgrade_scenario: bool,
|
downgrade_scenario: bool,
|
||||||
tos_or_privacy: bool,
|
tos_or_privacy: bool,
|
||||||
|
theme: &ResolvedTheme,
|
||||||
) {
|
) {
|
||||||
let cancel_text = if update_needed {
|
let cancel_text = if update_needed {
|
||||||
"[Q] Quit"
|
"[Q] Quit"
|
||||||
|
|
@ -69,107 +36,40 @@ pub fn draw_buttons(
|
||||||
buttons.push(("Continue with Tensamin Services", Focus::ContinueAll));
|
buttons.push(("Continue with Tensamin Services", Focus::ContinueAll));
|
||||||
}
|
}
|
||||||
|
|
||||||
let padding = 2;
|
let minimums = buttons
|
||||||
let min_widths: Vec<u16> = buttons
|
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(label, _)| label.len() as u16 + padding)
|
.map(|(label, _)| button_minimum_width(label))
|
||||||
.collect();
|
.collect::<Vec<_>>();
|
||||||
|
let Some(widths) = horizontal_button_widths(area.width, &minimums) else {
|
||||||
let widths = compute_widths(area.width, &min_widths);
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
let mut x = area.x;
|
let mut x = area.x;
|
||||||
|
|
||||||
for ((label, focus), width) in buttons.iter().zip(widths) {
|
for ((label, focus), width) in buttons.iter().zip(widths) {
|
||||||
let chunk = Rect {
|
let button_area = Rect {
|
||||||
x,
|
x,
|
||||||
y: area.y,
|
y: area.y,
|
||||||
width,
|
width,
|
||||||
height: area.height,
|
height: area.height,
|
||||||
};
|
};
|
||||||
x += width;
|
x = x.saturating_add(width);
|
||||||
|
|
||||||
let is_focused = current_focus == *focus;
|
let (intent, enabled) = match focus {
|
||||||
|
Focus::Cancel => (ButtonIntent::Cancel, true),
|
||||||
let style = match focus {
|
Focus::Continue => (ButtonIntent::Primary, state.0),
|
||||||
Focus::Cancel => {
|
Focus::ContinueAll => (ButtonIntent::Primary, state.1),
|
||||||
if is_focused {
|
_ => (ButtonIntent::Neutral, false),
|
||||||
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),
|
|
||||||
};
|
};
|
||||||
|
render_button(
|
||||||
draw_button(f, chunk, label, style);
|
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"
|
name = "iota-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
autobins = false
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
iota-cli = { path = "../iota-cli" }
|
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();
|
let mut state = ConsentState::load_state();
|
||||||
|
|
||||||
ensure_initial_consent(ui.clone(), &mut state).await?;
|
ensure_initial_consent(ui.clone(), &mut state).await?;
|
||||||
if ensure_updates(ui, &mut state).await.is_err() {
|
// A mandatory document update is a hard bootstrap gate. In particular,
|
||||||
// We don't stop the program if updates fail, as long as we have initial consent
|
// refusing it must not allow service setup or daemon access to continue.
|
||||||
};
|
ensure_updates(ui, &mut state).await?;
|
||||||
|
|
||||||
state = state.sanitize();
|
state = state.sanitize();
|
||||||
state.save_state();
|
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))
|
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> {
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,7 +50,7 @@ async fn ensure_initial_consent(ui: Arc<UI>, state: &mut ConsentState) -> Result
|
||||||
|
|
||||||
let (tx, rx) = oneshot::channel();
|
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;
|
.await;
|
||||||
|
|
||||||
let result = rx.await.unwrap_or(UserChoice::Deny);
|
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 pnet::datalink::NetworkInterface;
|
||||||
use tokio::time::{Duration, sleep};
|
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::screens::main_screen::MainScreen;
|
||||||
use iota_cli::{ipc_client::IpcClient, ui::start_tui};
|
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::users::user_manager;
|
||||||
use iota_storage::util::config_util::CONFIG;
|
use iota_storage::util::config_util::CONFIG;
|
||||||
use iota_util::file_util::{download_and_extract_zip, has_dir};
|
use iota_util::file_util::{download_and_extract_zip, has_dir};
|
||||||
use omikron_connector as omikron;
|
use std::sync::Arc;
|
||||||
use omikron_connector::omikron_connection::OMIKRON_CONNECTION;
|
|
||||||
|
|
||||||
#[tokio::main(flavor = "multi_thread", worker_threads = 16)]
|
#[tokio::main(flavor = "multi_thread", worker_threads = 16)]
|
||||||
#[allow(unused_must_use, dead_code, unused_assignments)]
|
#[allow(unused_must_use, dead_code, unused_assignments)]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
while *RELOAD.read().await {
|
let state = Arc::new(DaemonState::new());
|
||||||
*RELOAD.write().await = false;
|
|
||||||
*SHUTDOWN.write().await = false;
|
while *state.reload.read().await {
|
||||||
|
*state.reload.write().await = false;
|
||||||
|
*state.shutdown.write().await = false;
|
||||||
|
|
||||||
let ipc = IpcClient::connect("/run/iota/iota.sock")
|
let ipc = IpcClient::connect("/run/iota/iota.sock")
|
||||||
.await
|
.await
|
||||||
.expect("iota-daemon must be running before starting iota-core");
|
.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 {
|
let (eula, tos_pp) = match consent_state::check(ui.clone()).await {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
*SHUTDOWN.write().await = true;
|
*state.shutdown.write().await = true;
|
||||||
loop {
|
loop {
|
||||||
if ACTIVE_TASKS.is_empty() {
|
if state.active_tasks.is_empty() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
sleep(Duration::from_millis(100)).await;
|
sleep(Duration::from_millis(100)).await;
|
||||||
|
|
@ -43,9 +45,9 @@ async fn main() {
|
||||||
};
|
};
|
||||||
|
|
||||||
if !eula {
|
if !eula {
|
||||||
*SHUTDOWN.write().await = true;
|
*state.shutdown.write().await = true;
|
||||||
loop {
|
loop {
|
||||||
if ACTIVE_TASKS.is_empty() {
|
if state.active_tasks.is_empty() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
sleep(Duration::from_millis(100)).await;
|
sleep(Duration::from_millis(100)).await;
|
||||||
|
|
@ -55,9 +57,9 @@ async fn main() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if !tos_pp {
|
if !tos_pp {
|
||||||
*SHUTDOWN.write().await = true;
|
*state.shutdown.write().await = true;
|
||||||
loop {
|
loop {
|
||||||
if ACTIVE_TASKS.is_empty() {
|
if state.active_tasks.is_empty() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
sleep(Duration::from_millis(100)).await;
|
sleep(Duration::from_millis(100)).await;
|
||||||
|
|
@ -70,7 +72,7 @@ async fn main() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
check_update();
|
check_update();
|
||||||
iota_state::setup();
|
iota_state::setup(&state);
|
||||||
|
|
||||||
let main_screen = MainScreen::new(ui.clone()).await;
|
let main_screen = MainScreen::new(ui.clone()).await;
|
||||||
ui.set_screen(Box::new(main_screen)).await;
|
ui.set_screen(Box::new(main_screen)).await;
|
||||||
|
|
@ -88,7 +90,7 @@ async fn main() {
|
||||||
iota_storage::util::config_util::load_config();
|
iota_storage::util::config_util::load_config();
|
||||||
|
|
||||||
// USER MANAGEMENT
|
// USER MANAGEMENT
|
||||||
if let Err(_) = user_manager::load_users().await {
|
if let Err(_) = user_manager::load_users_sync() {
|
||||||
log_t!("user_load_failed");
|
log_t!("user_load_failed");
|
||||||
}
|
}
|
||||||
if let Err(e) = iota_storage::util::settings::migrate_legacy_files() {
|
if let Err(e) = iota_storage::util::settings::migrate_legacy_files() {
|
||||||
|
|
@ -153,29 +155,17 @@ async fn main() {
|
||||||
if !web_server::start(port).await {
|
if !web_server::start(port).await {
|
||||||
log!("Failed to start the MTP web server on port {}", port);
|
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");
|
log_t!("setup_completed");
|
||||||
loop {
|
loop {
|
||||||
if *SHUTDOWN.read().await {
|
if *state.shutdown.read().await {
|
||||||
break;
|
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;
|
sleep(Duration::from_millis(500)).await;
|
||||||
}
|
}
|
||||||
if *RELOAD.read().await {
|
if *state.reload.read().await {
|
||||||
loop {
|
loop {
|
||||||
if ACTIVE_TASKS.is_empty() {
|
if state.active_tasks.is_empty() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
sleep(Duration::from_secs(1)).await;
|
sleep(Duration::from_secs(1)).await;
|
||||||
|
|
@ -184,9 +174,8 @@ async fn main() {
|
||||||
user_manager::clear();
|
user_manager::clear();
|
||||||
// Commhnities have not been implemented yet.
|
// Commhnities have not been implemented yet.
|
||||||
/*community_manager::clear();*/
|
/*community_manager::clear();*/
|
||||||
*APP_STATE.lock().unwrap() = AppState::new();
|
*state.app.lock().unwrap() = AppState::new();
|
||||||
}
|
}
|
||||||
ui.terminal.lock().unwrap().clear();
|
let _ = session.shutdown().await;
|
||||||
ui.terminal.lock().unwrap().flush();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
async-trait = "0.1.89"
|
||||||
iota-ipc = { path = "../iota-ipc" }
|
iota-ipc = { path = "../iota-ipc" }
|
||||||
iota-logger = { path = "../iota-logger" }
|
iota-logger = { path = "../iota-logger" }
|
||||||
iota-state = { path = "../iota-state" }
|
iota-state = { path = "../iota-state" }
|
||||||
|
|
@ -17,3 +18,6 @@ sysinfo = "0.38.3"
|
||||||
tokio = { version = "1.50.0", features = ["full"] }
|
tokio = { version = "1.50.0", features = ["full"] }
|
||||||
tokio-util = { version = "0.7", features = ["rt"] }
|
tokio-util = { version = "0.7", features = ["rt"] }
|
||||||
uuid = { version = "*", features = ["v4"] }
|
uuid = { version = "*", features = ["v4"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3"
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,34 @@
|
||||||
use crate::DaemonRuntime;
|
use crate::{DaemonRuntime, DaemonServices};
|
||||||
use iota_ipc::{
|
use iota_ipc::{ExitIntent, IpcErrorCode, LocalRequest, ResponseEnvelope, ResponseResult};
|
||||||
IpcErrorCode, LocalRequest, ResponseEnvelope, ResponseResult,
|
|
||||||
};
|
|
||||||
use iota_logger::{log, log_command};
|
use iota_logger::{log, log_command};
|
||||||
use iota_storage::users::user_manager;
|
use iota_storage::users::user_manager;
|
||||||
use iota_storage::util::config_util::modify_config;
|
use iota_storage::util::config_util::modify_config;
|
||||||
use mtp::codec::{CommunicationType, CommunicationValue};
|
use mtp::codec::{CommunicationType, CommunicationValue};
|
||||||
use omikron_connector::omikron_connection::OMIKRON_CONNECTION;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::daemon_state::ShutdownReason;
|
use crate::daemon_state::{ShutdownReason, StartupPhase};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct CommandRouter {
|
pub struct CommandRouter {
|
||||||
runtime: Arc<DaemonRuntime>,
|
runtime: Arc<DaemonRuntime>,
|
||||||
|
services: Arc<DaemonServices>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommandRouter {
|
impl CommandRouter {
|
||||||
pub fn new(runtime: Arc<DaemonRuntime>) -> Self {
|
pub fn new(runtime: Arc<DaemonRuntime>, services: Arc<DaemonServices>) -> Self {
|
||||||
Self { runtime }
|
Self { runtime, services }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn route(&self, request_id: u64, request: LocalRequest) -> ResponseEnvelope {
|
pub async fn route(&self, request_id: u64, request: LocalRequest) -> ResponseEnvelope {
|
||||||
log_command!("{:?}", request);
|
log_command!("{:?}", request);
|
||||||
let result = self.execute(request).await;
|
let result = self.execute(request).await;
|
||||||
ResponseEnvelope {
|
ResponseEnvelope { request_id, result }
|
||||||
request_id,
|
|
||||||
result,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a legacy console command string into a typed request.
|
/// Parse a legacy console command string into a typed request.
|
||||||
pub fn parse_console_command(line: &str) -> Option<LocalRequest> {
|
pub fn parse_console_command(line: &str) -> Option<LocalRequest> {
|
||||||
let parts: Vec<&str> = line
|
let parts: Vec<&str> = line.trim_start_matches('/').split_whitespace().collect();
|
||||||
.trim_start_matches('/')
|
|
||||||
.split_whitespace()
|
|
||||||
.collect();
|
|
||||||
match parts.as_slice() {
|
match parts.as_slice() {
|
||||||
["help"] => None,
|
["help"] => None,
|
||||||
["tasks"] => Some(LocalRequest::ListTasks),
|
["tasks"] => Some(LocalRequest::ListTasks),
|
||||||
|
|
@ -53,13 +45,33 @@ impl CommandRouter {
|
||||||
["user", "list"] => Some(LocalRequest::ListUsers),
|
["user", "list"] => Some(LocalRequest::ListUsers),
|
||||||
["reconnect"] => Some(LocalRequest::ReconnectOmikron),
|
["reconnect"] => Some(LocalRequest::ReconnectOmikron),
|
||||||
["regenerate", "keys"] => Some(LocalRequest::RotateIotaIdentity),
|
["regenerate", "keys"] => Some(LocalRequest::RotateIotaIdentity),
|
||||||
["reload"] | ["restart"] => Some(LocalRequest::RestartDaemon),
|
["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit {
|
||||||
["shutdown"] | ["stop"] => Some(LocalRequest::StopDaemon),
|
intent: ExitIntent::Restart,
|
||||||
|
}),
|
||||||
|
["shutdown"] | ["stop"] => Some(LocalRequest::RequestProcessExit {
|
||||||
|
intent: ExitIntent::Stop,
|
||||||
|
}),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute(&self, request: LocalRequest) -> ResponseResult {
|
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 {
|
match request {
|
||||||
LocalRequest::GetStatus => {
|
LocalRequest::GetStatus => {
|
||||||
let phase = self.runtime.current_startup_phase();
|
let phase = self.runtime.current_startup_phase();
|
||||||
|
|
@ -95,10 +107,13 @@ impl CommandRouter {
|
||||||
ResponseResult::Ok(users.join("\n"))
|
ResponseResult::Ok(users.join("\n"))
|
||||||
}
|
}
|
||||||
LocalRequest::CreateUser { username } => {
|
LocalRequest::CreateUser { username } => {
|
||||||
match omikron_connector::user_ops::create_user(&username).await {
|
match omikron_connector::user_ops::create_user(
|
||||||
(Some(user), _) => {
|
self.services.omikron.as_ref(),
|
||||||
ResponseResult::Ok(format!("Created user {}", user.user_id))
|
&username,
|
||||||
}
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
(Some(user), _) => ResponseResult::Ok(format!("Created user {}", user.user_id)),
|
||||||
_ => ResponseResult::Error(IpcErrorCode::StorageFailure),
|
_ => ResponseResult::Error(IpcErrorCode::StorageFailure),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -109,24 +124,46 @@ impl CommandRouter {
|
||||||
};
|
};
|
||||||
let message = CommunicationValue::new(CommunicationType::DeleteUser)
|
let message = CommunicationValue::new(CommunicationType::DeleteUser)
|
||||||
.with_sender(user.user_id as u64);
|
.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);
|
return ResponseResult::Error(IpcErrorCode::OmikronUnavailable);
|
||||||
}
|
}
|
||||||
user_manager::remove_user(user.user_id);
|
user_manager::remove_user(user.user_id);
|
||||||
ResponseResult::Ok(format!("Removed user {}", user.user_id))
|
ResponseResult::Ok(format!("Removed user {}", user.user_id))
|
||||||
}
|
}
|
||||||
LocalRequest::ReconnectOmikron => {
|
LocalRequest::ReconnectOmikron => match self.services.omikron.reconnect().await {
|
||||||
OMIKRON_CONNECTION.reconnect().await;
|
Ok(()) => ResponseResult::Ok("Reconnected to Omikron server".into()),
|
||||||
ResponseResult::Ok("Reconnected to Omikron server".into())
|
Err(_) => ResponseResult::Error(IpcErrorCode::OmikronUnavailable),
|
||||||
}
|
},
|
||||||
LocalRequest::RotateIotaIdentity => {
|
LocalRequest::RotateIotaIdentity => {
|
||||||
modify_config(|config| {
|
modify_config(|config| {
|
||||||
config.public_key = None;
|
config.public_key = None;
|
||||||
config.private_key = None;
|
config.private_key = None;
|
||||||
config.iota_id = None;
|
config.iota_id = None;
|
||||||
});
|
});
|
||||||
OMIKRON_CONNECTION.reconnect().await;
|
match self.services.omikron.reconnect().await {
|
||||||
ResponseResult::Ok("Key pair regenerated and Omikron reconnection requested".into())
|
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 => {
|
LocalRequest::RestartDaemon => {
|
||||||
self.runtime.shutdown(ShutdownReason::Restart);
|
self.runtime.shutdown(ShutdownReason::Restart);
|
||||||
|
|
@ -140,10 +177,12 @@ impl CommandRouter {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn ping(&self, seconds: u64) -> Result<String, String> {
|
pub async fn ping(&self, seconds: u64) -> Result<String, String> {
|
||||||
let response = OMIKRON_CONNECTION
|
let response = self
|
||||||
|
.services
|
||||||
|
.omikron
|
||||||
.await_response(
|
.await_response(
|
||||||
&CommunicationValue::new(CommunicationType::Ping),
|
&CommunicationValue::new(CommunicationType::Ping),
|
||||||
Some(Duration::from_secs(seconds)),
|
Duration::from_secs(seconds),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
match response {
|
match response {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
|
use crate::TaskRegistry;
|
||||||
use iota_ipc::StateSnapshot;
|
use iota_ipc::StateSnapshot;
|
||||||
use iota_state::DaemonState;
|
use iota_state::DaemonState;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
use sysinfo::{RefreshKind, System};
|
use sysinfo::{RefreshKind, System};
|
||||||
use tokio::sync::watch;
|
use tokio::sync::watch;
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
@ -58,8 +61,18 @@ pub struct DaemonRuntime {
|
||||||
pub state: Arc<DaemonState>,
|
pub state: Arc<DaemonState>,
|
||||||
pub cancellation: CancellationToken,
|
pub cancellation: CancellationToken,
|
||||||
pub shutdown_tx: watch::Sender<Option<ShutdownReason>>,
|
pub shutdown_tx: watch::Sender<Option<ShutdownReason>>,
|
||||||
|
shutdown_rx: watch::Receiver<Option<ShutdownReason>>,
|
||||||
pub startup_phase: watch::Sender<StartupPhase>,
|
pub startup_phase: watch::Sender<StartupPhase>,
|
||||||
pub degraded_reason: watch::Sender<Option<String>>,
|
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 {
|
impl Clone for DaemonRuntime {
|
||||||
|
|
@ -68,8 +81,18 @@ impl Clone for DaemonRuntime {
|
||||||
state: self.state.clone(),
|
state: self.state.clone(),
|
||||||
cancellation: self.cancellation.clone(),
|
cancellation: self.cancellation.clone(),
|
||||||
shutdown_tx: self.shutdown_tx.clone(),
|
shutdown_tx: self.shutdown_tx.clone(),
|
||||||
|
shutdown_rx: self.shutdown_rx.clone(),
|
||||||
startup_phase: self.startup_phase.clone(),
|
startup_phase: self.startup_phase.clone(),
|
||||||
degraded_reason: self.degraded_reason.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 {
|
impl DaemonRuntime {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let (shutdown_tx, _) = watch::channel(None);
|
let (shutdown_tx, shutdown_rx) = watch::channel(None);
|
||||||
let (startup_phase, _) = watch::channel(StartupPhase::Starting);
|
let (startup_phase, startup_phase_rx) = watch::channel(StartupPhase::Starting);
|
||||||
let (degraded_reason, _) = watch::channel(None);
|
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 {
|
Self {
|
||||||
state: Arc::new(DaemonState::new()),
|
state: Arc::new(DaemonState::new()),
|
||||||
cancellation: CancellationToken::new(),
|
cancellation: CancellationToken::new(),
|
||||||
shutdown_tx,
|
shutdown_tx,
|
||||||
|
shutdown_rx,
|
||||||
startup_phase,
|
startup_phase,
|
||||||
degraded_reason,
|
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) {
|
pub fn shutdown(&self, reason: ShutdownReason) {
|
||||||
self.cancellation.cancel();
|
if self.shutdown_tx.borrow().is_none() {
|
||||||
let _ = self.shutdown_tx.send(Some(reason));
|
let _ = self.shutdown_tx.send(Some(reason));
|
||||||
|
self.cancellation.cancel();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn shutdown_reason(&self) -> Option<ShutdownReason> {
|
pub fn shutdown_reason(&self) -> Option<ShutdownReason> {
|
||||||
|
|
@ -109,6 +147,27 @@ impl DaemonRuntime {
|
||||||
|
|
||||||
pub fn set_startup_phase(&self, phase: StartupPhase) {
|
pub fn set_startup_phase(&self, phase: StartupPhase) {
|
||||||
let _ = self.startup_phase.send(phase);
|
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 {
|
pub fn current_startup_phase(&self) -> StartupPhase {
|
||||||
|
|
@ -117,7 +176,62 @@ impl DaemonRuntime {
|
||||||
|
|
||||||
pub fn mark_degraded(&self, reason: String) {
|
pub fn mark_degraded(&self, reason: String) {
|
||||||
let _ = self.degraded_reason.send(Some(reason.clone()));
|
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 {
|
pub fn snapshot(&self) -> StateSnapshot {
|
||||||
|
|
@ -133,42 +247,51 @@ impl DaemonRuntime {
|
||||||
net_up: state.net_up.clone(),
|
net_up: state.net_up.clone(),
|
||||||
net_down: state.net_down.clone(),
|
net_down: state.net_down.clone(),
|
||||||
sys_info: state.sys_info.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();
|
let runtime = self.clone();
|
||||||
tokio::spawn(async move {
|
self.tasks
|
||||||
runtime.state.active_tasks.insert("System monitor".into());
|
.spawn_tracked("system-monitor", async move {
|
||||||
let mut system = System::new_with_specifics(RefreshKind::everything());
|
runtime.state.active_tasks.insert("System monitor".into());
|
||||||
let mut counter = 0.0;
|
let mut system = System::new_with_specifics(RefreshKind::everything());
|
||||||
loop {
|
let mut counter = 0.0;
|
||||||
if runtime.is_shutting_down() {
|
loop {
|
||||||
break;
|
if runtime.is_shutting_down() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
system.refresh_cpu_all();
|
||||||
|
system.refresh_memory();
|
||||||
|
let cpu = system.global_cpu_usage() as f64;
|
||||||
|
let total_memory = system.total_memory();
|
||||||
|
let ram = if total_memory == 0 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
system.used_memory() as f64 / total_memory as f64 * 100.0
|
||||||
|
};
|
||||||
|
{
|
||||||
|
let mut state = runtime
|
||||||
|
.state
|
||||||
|
.app
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|error| error.into_inner());
|
||||||
|
state.push_cpu((counter, cpu));
|
||||||
|
state.push_ram((counter, ram));
|
||||||
|
state.sys_info = format!("CPU: {cpu:.1}% RAM: {ram:.1}%");
|
||||||
|
}
|
||||||
|
counter += 1.0;
|
||||||
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||||
}
|
}
|
||||||
system.refresh_cpu_all();
|
runtime.state.active_tasks.remove("System monitor");
|
||||||
system.refresh_memory();
|
Ok(())
|
||||||
let cpu = system.global_cpu_usage() as f64;
|
})
|
||||||
let total_memory = system.total_memory();
|
.await;
|
||||||
let ram = if total_memory == 0 {
|
|
||||||
0.0
|
|
||||||
} else {
|
|
||||||
system.used_memory() as f64 / total_memory as f64 * 100.0
|
|
||||||
};
|
|
||||||
{
|
|
||||||
let mut state = runtime
|
|
||||||
.state
|
|
||||||
.app
|
|
||||||
.lock()
|
|
||||||
.unwrap_or_else(|error| error.into_inner());
|
|
||||||
state.push_cpu((counter, cpu));
|
|
||||||
state.push_ram((counter, ram));
|
|
||||||
state.sys_info = format!("CPU: {cpu:.1}% RAM: {ram:.1}%");
|
|
||||||
}
|
|
||||||
counter += 1.0;
|
|
||||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
||||||
}
|
|
||||||
runtime.state.active_tasks.remove("System monitor");
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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::{
|
use iota_ipc::{
|
||||||
ClientMessage, DaemonMessage, HelloAck, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION, read_msg,
|
ClientMessage, DaemonMessage, HelloAck, MIN_PROTOCOL_VERSION, PROTOCOL_VERSION, read_msg,
|
||||||
write_msg,
|
write_msg,
|
||||||
};
|
};
|
||||||
use iota_logger::log;
|
use iota_logger::log;
|
||||||
use std::io::Result;
|
use std::io::Result;
|
||||||
|
use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
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::net::{UnixListener, UnixStream};
|
||||||
use tokio::sync::{broadcast, mpsc, watch};
|
use tokio::sync::{broadcast, mpsc, watch};
|
||||||
|
use tokio::time::timeout;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Per-client outbound queue capacity.
|
/// Per-client outbound queue capacity.
|
||||||
const CLIENT_CHANNEL_SIZE: usize = 256;
|
const CLIENT_CHANNEL_SIZE: usize = 256;
|
||||||
|
|
||||||
/// Maximum handshake retries before giving up.
|
/// 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 {
|
pub struct IpcServer {
|
||||||
path: PathBuf,
|
listener: UnixListener,
|
||||||
runtime: Arc<DaemonRuntime>,
|
runtime: Arc<DaemonRuntime>,
|
||||||
|
services: Arc<DaemonServices>,
|
||||||
log_tx: broadcast::Sender<DaemonMessage>,
|
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 {
|
impl IpcServer {
|
||||||
pub fn new(
|
pub async fn bind(
|
||||||
path: impl Into<PathBuf>,
|
path: impl Into<PathBuf>,
|
||||||
runtime: Arc<DaemonRuntime>,
|
runtime: Arc<DaemonRuntime>,
|
||||||
|
services: Arc<DaemonServices>,
|
||||||
log_tx: broadcast::Sender<DaemonMessage>,
|
log_tx: broadcast::Sender<DaemonMessage>,
|
||||||
state_rx: watch::Sender<iota_ipc::StateSnapshot>,
|
state_rx: watch::Receiver<iota_ipc::StateSnapshot>,
|
||||||
) -> Self {
|
) -> Result<Self> {
|
||||||
Self {
|
let path = path.into();
|
||||||
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?;
|
|
||||||
}
|
|
||||||
let listener = match activated_listener()? {
|
let listener = match activated_listener()? {
|
||||||
Some(listener) => listener,
|
Some(listener) => listener,
|
||||||
None => {
|
None => {
|
||||||
remove_stale_socket(&self.path).await?;
|
if let Some(parent) = path.parent() {
|
||||||
UnixListener::bind(&self.path)?
|
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 {
|
loop {
|
||||||
let (stream, _addr) = listener.accept().await?;
|
let (stream, _addr) = self.listener.accept().await?;
|
||||||
|
eprintln!("IPC client accepted");
|
||||||
let runtime = self.runtime.clone();
|
let runtime = self.runtime.clone();
|
||||||
|
let services = self.services.clone();
|
||||||
let log_tx = self.log_tx.clone();
|
let log_tx = self.log_tx.clone();
|
||||||
let state_rx = self.state_rx.clone();
|
let state_rx = self.state_rx.clone();
|
||||||
|
let instance_id = self.instance_id.clone();
|
||||||
tokio::spawn(async move {
|
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}");
|
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()) {
|
if listen_fds != Some(1) || listen_pid != Some(std::process::id()) {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let listener = unsafe { std::os::unix::net::UnixListener::from_raw_fd(3) };
|
// SAFETY: systemd transfers ownership of the activated descriptor to us.
|
||||||
UnixListener::from_std(listener).map(Some)
|
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<()> {
|
async fn remove_stale_socket(path: &Path) -> Result<()> {
|
||||||
match tokio::fs::symlink_metadata(path).await {
|
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) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
Err(error) => Err(error),
|
Err(error) => Err(error),
|
||||||
}
|
}
|
||||||
|
|
@ -93,10 +197,10 @@ async fn remove_stale_socket(path: &Path) -> Result<()> {
|
||||||
struct PeerIdentity {
|
struct PeerIdentity {
|
||||||
pid: i32,
|
pid: i32,
|
||||||
uid: u32,
|
uid: u32,
|
||||||
gid: u32,
|
_gid: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn peer_credentials(stream: &UnixStream) -> PeerIdentity {
|
fn peer_credentials(stream: &UnixStream) -> Result<PeerIdentity> {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
use std::os::unix::io::AsRawFd;
|
use std::os::unix::io::AsRawFd;
|
||||||
|
|
@ -104,70 +208,109 @@ fn peer_credentials(stream: &UnixStream) -> PeerIdentity {
|
||||||
let mut cred: libc::ucred = std::mem::zeroed();
|
let mut cred: libc::ucred = std::mem::zeroed();
|
||||||
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
|
let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
|
||||||
let fd = stream.as_raw_fd();
|
let fd = stream.as_raw_fd();
|
||||||
libc::getsockopt(
|
if libc::getsockopt(
|
||||||
fd,
|
fd,
|
||||||
libc::SOL_SOCKET,
|
libc::SOL_SOCKET,
|
||||||
libc::SO_PEERCRED,
|
libc::SO_PEERCRED,
|
||||||
&mut cred as *mut _ as *mut libc::c_void,
|
&mut cred as *mut _ as *mut libc::c_void,
|
||||||
&mut len,
|
&mut len,
|
||||||
);
|
) != 0
|
||||||
PeerIdentity {
|
{
|
||||||
|
return Err(std::io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(PeerIdentity {
|
||||||
pid: cred.pid,
|
pid: cred.pid,
|
||||||
uid: cred.uid,
|
uid: cred.uid,
|
||||||
gid: cred.gid,
|
_gid: cred.gid,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
{
|
{
|
||||||
PeerIdentity {
|
Ok(PeerIdentity {
|
||||||
pid: 0,
|
pid: 0,
|
||||||
uid: 0,
|
uid: 0,
|
||||||
gid: 0,
|
_gid: 0,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_client(
|
async fn handle_client(
|
||||||
stream: UnixStream,
|
stream: UnixStream,
|
||||||
runtime: Arc<DaemonRuntime>,
|
runtime: Arc<DaemonRuntime>,
|
||||||
|
services: Arc<DaemonServices>,
|
||||||
log_tx: broadcast::Sender<DaemonMessage>,
|
log_tx: broadcast::Sender<DaemonMessage>,
|
||||||
_state_rx: watch::Sender<iota_ipc::StateSnapshot>,
|
mut state_rx: watch::Receiver<iota_ipc::StateSnapshot>,
|
||||||
|
instance_id: String,
|
||||||
) -> Result<()> {
|
) -> 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();
|
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);
|
let (directed_tx, directed_rx) = mpsc::channel::<DaemonMessage>(CLIENT_CHANNEL_SIZE);
|
||||||
|
eprintln!("IPC handshake started (pid={}, uid={})", peer.pid, peer.uid);
|
||||||
|
|
||||||
// --- Handshake ---
|
// --- Handshake ---
|
||||||
let mut negotiated_version: Option<u16> = None;
|
let mut negotiated_version: Option<u16> = None;
|
||||||
for _ in 0..MAX_HANDSHAKE_RETRIES {
|
for _ in 0..MAX_HANDSHAKE_RETRIES {
|
||||||
match read_msg::<_, ClientMessage>(&mut reader).await {
|
match timeout(
|
||||||
Ok(ClientMessage::Hello { supported_versions }) => {
|
std::time::Duration::from_secs(15),
|
||||||
let version = supported_versions
|
read_msg::<_, ClientMessage>(&mut reader),
|
||||||
.iter()
|
)
|
||||||
.copied()
|
.await
|
||||||
.find(|v| *v >= MIN_PROTOCOL_VERSION && *v <= PROTOCOL_VERSION)
|
{
|
||||||
.unwrap_or(PROTOCOL_VERSION);
|
Err(_) => {
|
||||||
negotiated_version = Some(version);
|
|
||||||
let instance_id = Uuid::new_v4().to_string();
|
|
||||||
let ack = DaemonMessage::HelloAck(HelloAck {
|
|
||||||
protocol_version: version,
|
|
||||||
daemon_version: env!("CARGO_PKG_VERSION").to_string(),
|
|
||||||
instance_id,
|
|
||||||
startup_phase: runtime.current_startup_phase().into(),
|
|
||||||
capabilities: vec!["commands".into(), "metrics".into(), "logs".into()],
|
|
||||||
});
|
|
||||||
write_msg(&mut writer, &ack).await?;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Ok(_) => {
|
|
||||||
// Unexpected first message — send error and close.
|
|
||||||
return Err(std::io::Error::new(
|
return Err(std::io::Error::new(
|
||||||
std::io::ErrorKind::InvalidData,
|
std::io::ErrorKind::TimedOut,
|
||||||
"Expected Hello as first message",
|
"IPC Hello timed out",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Err(e) => return Err(e),
|
Ok(result) => match result {
|
||||||
|
Ok(ClientMessage::Hello { supported_versions }) => {
|
||||||
|
let version = supported_versions
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|v| *v >= MIN_PROTOCOL_VERSION && *v <= PROTOCOL_VERSION)
|
||||||
|
.max()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Unsupported,
|
||||||
|
"No compatible IPC protocol version",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
negotiated_version = Some(version);
|
||||||
|
let ack = DaemonMessage::HelloAck(HelloAck {
|
||||||
|
protocol_version: version,
|
||||||
|
daemon_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
instance_id: instance_id.clone(),
|
||||||
|
startup_phase: runtime.current_startup_phase().into(),
|
||||||
|
capabilities: vec!["commands".into(), "metrics".into(), "logs".into()],
|
||||||
|
lifecycle: *runtime.lifecycle.borrow(),
|
||||||
|
health: runtime.overall_health(),
|
||||||
|
deployment_mode: from_environment().mode,
|
||||||
|
supervisor: from_environment().supervisor,
|
||||||
|
});
|
||||||
|
write_client_message(&mut writer, &ack).await?;
|
||||||
|
eprintln!(
|
||||||
|
"IPC handshake acknowledged (pid={}, uid={})",
|
||||||
|
peer.pid, peer.uid
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
// Unexpected first message — send error and close.
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"Expected Hello as first message",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _version = negotiated_version.ok_or_else(|| {
|
let _version = negotiated_version.ok_or_else(|| {
|
||||||
|
|
@ -182,9 +325,9 @@ async fn handle_client(
|
||||||
|
|
||||||
// --- Writer task: merge directed responses + shared log events ---
|
// --- Writer task: merge directed responses + shared log events ---
|
||||||
let mut log_rx = log_tx.subscribe();
|
let mut log_rx = log_tx.subscribe();
|
||||||
let directed_for_writer = directed_tx.clone();
|
|
||||||
let writer_task = {
|
let writer_task = {
|
||||||
let runtime = runtime.clone();
|
let runtime = runtime.clone();
|
||||||
|
let session_cancellation = session_cancellation.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut directed_rx = directed_rx;
|
let mut directed_rx = directed_rx;
|
||||||
loop {
|
loop {
|
||||||
|
|
@ -193,7 +336,9 @@ async fn handle_client(
|
||||||
msg = directed_rx.recv() => {
|
msg = directed_rx.recv() => {
|
||||||
match msg {
|
match msg {
|
||||||
Some(message) => {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -204,36 +349,91 @@ async fn handle_client(
|
||||||
result = log_rx.recv() => {
|
result = log_rx.recv() => {
|
||||||
match result {
|
match result {
|
||||||
Ok(message) => {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
||||||
let _ = directed_for_writer.send(DaemonMessage::Gap { skipped }).await;
|
if write_client_message(&mut writer, &DaemonMessage::Gap { skipped }).await.is_err()
|
||||||
// Then send current snapshot for resync
|
|| write_client_message(&mut writer, &DaemonMessage::StateUpdate(runtime.snapshot())).await.is_err()
|
||||||
let _ = directed_for_writer.send(
|
{
|
||||||
DaemonMessage::StateUpdate(runtime.snapshot())
|
session_cancellation.cancel();
|
||||||
).await;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(broadcast::error::RecvError::Closed) => 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 ---
|
// --- Reader loop ---
|
||||||
let router = CommandRouter::new(runtime.clone());
|
let router = CommandRouter::new(runtime.clone(), services);
|
||||||
loop {
|
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)) => {
|
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;
|
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 { .. }) => {
|
Ok(ClientMessage::Subscribe { .. }) => {
|
||||||
let snapshot = DaemonMessage::StateUpdate(runtime.snapshot());
|
let snapshot = DaemonMessage::StateUpdate(runtime.snapshot());
|
||||||
let _ = directed_tx.send(snapshot).await;
|
let _ = directed_tx.send(snapshot).await;
|
||||||
|
let _ = directed_tx.send(DaemonMessage::Subscribed).await;
|
||||||
}
|
}
|
||||||
Ok(ClientMessage::Ping { seq }) => {
|
Ok(ClientMessage::Ping { seq }) => {
|
||||||
let _ = directed_tx.send(DaemonMessage::Pong { seq }).await;
|
let _ = directed_tx.send(DaemonMessage::Pong { seq }).await;
|
||||||
|
|
@ -250,6 +450,7 @@ async fn handle_client(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
session_cancellation.cancel();
|
||||||
writer_task.abort();
|
writer_task.abort();
|
||||||
log!(
|
log!(
|
||||||
"IPC client disconnected (pid={}, uid={})",
|
"IPC client disconnected (pid={}, uid={})",
|
||||||
|
|
@ -258,3 +459,25 @@ async fn handle_client(
|
||||||
);
|
);
|
||||||
Ok(())
|
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 command_router;
|
||||||
pub mod daemon_state;
|
pub mod daemon_state;
|
||||||
|
pub mod deployment;
|
||||||
pub mod ipc_server;
|
pub mod ipc_server;
|
||||||
pub mod log_broadcaster;
|
pub mod log_broadcaster;
|
||||||
|
pub mod services;
|
||||||
|
pub mod task_registry;
|
||||||
|
|
||||||
pub use command_router::CommandRouter;
|
pub use command_router::CommandRouter;
|
||||||
pub use daemon_state::{DaemonRuntime, ShutdownReason, StartupPhase};
|
pub use daemon_state::{DaemonRuntime, ShutdownReason, StartupPhase};
|
||||||
pub use ipc_server::IpcServer;
|
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-ipc = { path = "../iota-ipc" }
|
||||||
iota-logger = { path = "../iota-logger" }
|
iota-logger = { path = "../iota-logger" }
|
||||||
iota-state = { path = "../iota-state" }
|
iota-state = { path = "../iota-state" }
|
||||||
|
iota-paths = { path = "../iota-paths" }
|
||||||
iota-storage = { path = "../iota-storage" }
|
iota-storage = { path = "../iota-storage" }
|
||||||
omikron-connector = { path = "../omikron-connector" }
|
omikron-connector = { path = "../omikron-connector" }
|
||||||
web-server = { path = "../web-server" }
|
web-server = { path = "../web-server" }
|
||||||
|
|
|
||||||
|
|
@ -1,94 +1,198 @@
|
||||||
use iota_daemon_lib::{DaemonRuntime, IpcServer, ShutdownReason, StartupPhase, log_broadcaster};
|
use iota_daemon_lib::{
|
||||||
use iota_logger::{self as logger, log, log_t};
|
DaemonRuntime, DaemonServices, IpcServer, ShutdownReason, StartupPhase, log_broadcaster,
|
||||||
|
};
|
||||||
|
use iota_logger::{self as logger, log};
|
||||||
use iota_storage::users::user_manager;
|
use iota_storage::users::user_manager;
|
||||||
use iota_storage::util::config_util::CONFIG;
|
use iota_storage::util::config_util::CONFIG;
|
||||||
use std::path::PathBuf;
|
use std::process::ExitCode;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::{broadcast, watch};
|
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")]
|
#[tokio::main(flavor = "multi_thread")]
|
||||||
async fn main() {
|
async fn main() -> ExitCode {
|
||||||
logger::startup();
|
logger::startup();
|
||||||
iota_storage::util::config_util::load_config();
|
iota_storage::util::config_util::load_config();
|
||||||
|
|
||||||
let runtime = Arc::new(DaemonRuntime::new());
|
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 ---
|
// --- IPC infrastructure ---
|
||||||
let (log_tx, _) = broadcast::channel(512);
|
let (log_tx, _) = broadcast::channel(512);
|
||||||
log_broadcaster::spawn(log_tx.clone());
|
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::LoadingUsers);
|
||||||
runtime.set_startup_phase(StartupPhase::StartingServices);
|
if tokio::task::spawn_blocking(user_manager::load_users_sync)
|
||||||
let ipc_server = IpcServer::new(
|
.await
|
||||||
socket_path(),
|
.ok()
|
||||||
runtime.clone(),
|
.and_then(Result::ok)
|
||||||
log_tx.clone(),
|
.is_none()
|
||||||
state_tx.clone(),
|
{
|
||||||
);
|
runtime.set_component_failed(
|
||||||
tokio::spawn(async move {
|
iota_ipc::ComponentId::Storage,
|
||||||
if let Err(error) = ipc_server.run().await {
|
"user storage failed to load".into(),
|
||||||
eprintln!("iota-daemon IPC server failed: {error}");
|
);
|
||||||
|
} else {
|
||||||
|
runtime.set_component_healthy(iota_ipc::ComponentId::Storage, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind before migration and service startup: a successful bind is the
|
||||||
|
// readiness boundary visible to clients and socket activation.
|
||||||
|
let socket = 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) => {
|
||||||
log!("iota-daemon IPC server started");
|
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 ---
|
// --- System monitor ---
|
||||||
runtime.spawn_system_monitor();
|
runtime.spawn_system_monitor().await;
|
||||||
|
|
||||||
// --- State update publisher (watch-based, no full broadcast per tick) ---
|
// --- State update publisher (watch-based, no full broadcast per tick) ---
|
||||||
let state_publisher = runtime.clone();
|
let state_publisher = runtime.clone();
|
||||||
tokio::spawn(async move {
|
runtime
|
||||||
loop {
|
.tasks
|
||||||
if state_publisher.is_shutting_down() {
|
.spawn_tracked("state-publisher", async move {
|
||||||
break;
|
loop {
|
||||||
|
if state_publisher.is_shutting_down() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let snapshot = state_publisher.snapshot();
|
||||||
|
let _ = state_tx.send(snapshot);
|
||||||
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||||
}
|
}
|
||||||
let snapshot = state_publisher.snapshot();
|
Ok(())
|
||||||
let _ = state_tx.send(snapshot);
|
})
|
||||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
.await;
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Web server ---
|
// --- Web server ---
|
||||||
let port = CONFIG.load().port;
|
let web = CONFIG.load().web.clone();
|
||||||
if !web_server::start(port, runtime.cancellation.clone()).await {
|
let web_config = web_server::WebConfig {
|
||||||
log!("Failed to start the MTP web server on port {}", port);
|
mode: match web.mode {
|
||||||
runtime.mark_degraded("MTP web server failed to start".into());
|
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,
|
||||||
// --- Omikron connection ---
|
},
|
||||||
let omikron_result =
|
bind: web
|
||||||
omikron_connector::omikron_connection::get_omikron_connection(runtime.cancellation.clone())
|
.bind
|
||||||
.await;
|
.parse()
|
||||||
if omikron_result.is_none() {
|
.unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)),
|
||||||
runtime.mark_degraded("Omikron connection unavailable".into());
|
port: web.port,
|
||||||
|
asset_dir: std::path::PathBuf::from(web.asset_dir),
|
||||||
|
tls: web
|
||||||
|
.certificate
|
||||||
|
.zip(web.key)
|
||||||
|
.map(|(certificate, key)| web_server::TlsConfig {
|
||||||
|
certificate: certificate.into(),
|
||||||
|
key: key.into(),
|
||||||
|
}),
|
||||||
|
required: web.required,
|
||||||
|
};
|
||||||
|
match web_server::start(web_config, runtime.cancellation.clone()).await {
|
||||||
|
Ok(None) => {
|
||||||
|
runtime.set_component_healthy(iota_ipc::ComponentId::Web, Some("disabled".into()))
|
||||||
|
}
|
||||||
|
Ok(Some(handle)) => {
|
||||||
|
runtime.set_component_healthy(iota_ipc::ComponentId::Web, None);
|
||||||
|
runtime
|
||||||
|
.tasks
|
||||||
|
.spawn_tracked("web-server", async move {
|
||||||
|
handle.join().await;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Err(error) if web.required => {
|
||||||
|
runtime.set_component_failed(iota_ipc::ComponentId::Web, error.to_string());
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
runtime.set_component_degraded(iota_ipc::ComponentId::Web, error.to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime.set_startup_phase(StartupPhase::Ready);
|
runtime.set_startup_phase(StartupPhase::Ready);
|
||||||
log!("iota-daemon started (phase: Ready)");
|
log!("iota-daemon started (phase: Ready)");
|
||||||
|
|
||||||
// --- Main lifecycle loop ---
|
// --- 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);
|
let reason = runtime.shutdown_reason().unwrap_or(ShutdownReason::Stop);
|
||||||
log!("iota-daemon shutting down (reason: {:?})", reason);
|
log!("iota-daemon shutting down (reason: {:?})", reason);
|
||||||
runtime.set_startup_phase(StartupPhase::Stopping);
|
runtime.set_startup_phase(StartupPhase::Stopping);
|
||||||
|
|
||||||
// Wait a moment for in-flight operations to complete
|
let _ = runtime
|
||||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
.tasks
|
||||||
|
.join_with_timeout(Duration::from_secs(5))
|
||||||
|
.await;
|
||||||
|
|
||||||
let exit_code = reason.exit_code();
|
let exit_code = reason.exit_code();
|
||||||
log!("iota-daemon exited (code: {})", 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 mod transport;
|
||||||
|
|
||||||
pub use protocol::{
|
pub use protocol::{
|
||||||
ClientMessage, DaemonMessage, HelloAck, LogEntry, StateSnapshot, MetricSample,
|
ClientMessage, ComponentHealth, ComponentId, ConnectionStatus, DaemonMessage, DeploymentMode,
|
||||||
RequestEnvelope, ResponseEnvelope, ResponseResult, LocalRequest, IpcErrorCode,
|
ExitIntent, HealthStatus, HelloAck, IpcErrorCode, LifecycleEvent, LifecyclePhase, LocalRequest,
|
||||||
ConnectionStatus, StartupPhase, LifecycleEvent,
|
LogEntry, MetricSample, RequestEnvelope, ResponseEnvelope, ResponseResult, StartupPhase,
|
||||||
|
StateSnapshot, SupervisorKind,
|
||||||
};
|
};
|
||||||
pub use transport::{read_msg, write_msg};
|
pub use transport::{read_msg, write_msg};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,17 @@ use serde::{Deserialize, Serialize};
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
|
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
|
||||||
pub enum ClientMessage {
|
pub enum ClientMessage {
|
||||||
Hello { supported_versions: Vec<u16> },
|
Hello {
|
||||||
Subscribe { log_classes: Vec<String>, metric_interval_ms: Option<u64> },
|
supported_versions: Vec<u16>,
|
||||||
|
},
|
||||||
|
Subscribe {
|
||||||
|
log_classes: Vec<String>,
|
||||||
|
metric_interval_ms: Option<u64>,
|
||||||
|
},
|
||||||
Request(RequestEnvelope),
|
Request(RequestEnvelope),
|
||||||
Ping { seq: u64 },
|
Ping {
|
||||||
|
seq: u64,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
|
@ -26,14 +33,31 @@ pub enum LocalRequest {
|
||||||
GetStatus,
|
GetStatus,
|
||||||
ListTasks,
|
ListTasks,
|
||||||
ListUsers,
|
ListUsers,
|
||||||
CreateUser { username: String },
|
CreateUser {
|
||||||
RemoveUser { user_id: i64 },
|
username: String,
|
||||||
|
},
|
||||||
|
RemoveUser {
|
||||||
|
user_id: i64,
|
||||||
|
},
|
||||||
ReconnectOmikron,
|
ReconnectOmikron,
|
||||||
RotateIotaIdentity,
|
RotateIotaIdentity,
|
||||||
|
RequestProcessExit {
|
||||||
|
intent: ExitIntent,
|
||||||
|
},
|
||||||
|
GetDaemonStatus,
|
||||||
|
#[serde(skip)]
|
||||||
RestartDaemon,
|
RestartDaemon,
|
||||||
|
#[serde(skip)]
|
||||||
StopDaemon,
|
StopDaemon,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ExitIntent {
|
||||||
|
Stop,
|
||||||
|
Restart,
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Daemon → Client
|
// Daemon → Client
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -42,13 +66,19 @@ pub enum LocalRequest {
|
||||||
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
|
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
|
||||||
pub enum DaemonMessage {
|
pub enum DaemonMessage {
|
||||||
HelloAck(HelloAck),
|
HelloAck(HelloAck),
|
||||||
|
/// Confirms that the server has installed this connection's subscription.
|
||||||
|
Subscribed,
|
||||||
LogEntry(LogEntry),
|
LogEntry(LogEntry),
|
||||||
StateUpdate(StateSnapshot),
|
StateUpdate(StateSnapshot),
|
||||||
MetricSample(MetricSample),
|
MetricSample(MetricSample),
|
||||||
Response(ResponseEnvelope),
|
Response(ResponseEnvelope),
|
||||||
Pong { seq: u64 },
|
Pong {
|
||||||
|
seq: u64,
|
||||||
|
},
|
||||||
LifecycleEvent(LifecycleEvent),
|
LifecycleEvent(LifecycleEvent),
|
||||||
Gap { skipped: u64 },
|
Gap {
|
||||||
|
skipped: u64,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
|
@ -58,6 +88,14 @@ pub struct HelloAck {
|
||||||
pub instance_id: String,
|
pub instance_id: String,
|
||||||
pub startup_phase: StartupPhase,
|
pub startup_phase: StartupPhase,
|
||||||
pub capabilities: Vec<String>,
|
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)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
|
@ -84,6 +122,10 @@ pub enum IpcErrorCode {
|
||||||
UnsupportedVersion,
|
UnsupportedVersion,
|
||||||
NotReady,
|
NotReady,
|
||||||
Disconnected,
|
Disconnected,
|
||||||
|
Timeout,
|
||||||
|
Cancelled,
|
||||||
|
Unauthorized,
|
||||||
|
InternalFailure,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
|
@ -114,6 +156,69 @@ pub enum StartupPhase {
|
||||||
Stopping,
|
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
|
// Shared types
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -134,6 +239,18 @@ pub struct StateSnapshot {
|
||||||
pub net_up: Vec<(f64, f64)>,
|
pub net_up: Vec<(f64, f64)>,
|
||||||
pub net_down: Vec<(f64, f64)>,
|
pub net_down: Vec<(f64, f64)>,
|
||||||
pub sys_info: String,
|
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)]
|
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||||
|
|
|
||||||
|
|
@ -59,42 +59,52 @@ impl LanguagePack {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_language(&mut self, language: &str) {
|
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");
|
// The daemon can initialize the logger before iota-core has created
|
||||||
let frontend_messages = parse(&frontend_messages).unwrap();
|
// the generated language files. Create the built-in pack on demand.
|
||||||
for (key, value) in frontend_messages.entries() {
|
if language == "en_INT"
|
||||||
self.language
|
&& files
|
||||||
.insert(key.to_string(), value.as_str().unwrap().to_string());
|
.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 loaded = files
|
||||||
let omikron_messages = parse(&omikron_messages).unwrap();
|
.iter()
|
||||||
for (key, value) in omikron_messages.entries() {
|
.all(|file| self.load_file(&format!("languages/{language}/"), file));
|
||||||
self.language
|
|
||||||
.insert(key.to_string(), value.as_str().unwrap().to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let button_texts = file_util::load_file(&path, "buttons.json");
|
// A process may have been interrupted while an older version was
|
||||||
let button_texts = parse(&button_texts).unwrap();
|
// writing a language file. Regenerate the default pack once in that
|
||||||
for (key, value) in button_texts.entries() {
|
// case, and still leave custom language packs non-fatal.
|
||||||
self.language
|
if !loaded && language == "en_INT" {
|
||||||
.insert(key.to_string(), value.as_str().unwrap().to_string());
|
self.language.clear();
|
||||||
|
let _ = crate::language_creator::create_languages();
|
||||||
|
for file in files {
|
||||||
|
let _ = self.load_file(&format!("languages/{language}/"), file);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let debug_messages = file_util::load_file(&path, "debug.json");
|
fn load_file(&mut self, path: &str, file: &str) -> bool {
|
||||||
let debug_messages = parse(&debug_messages).unwrap();
|
let contents = file_util::load_file(path, file);
|
||||||
for (key, value) in debug_messages.entries() {
|
let Ok(messages) = parse(&contents) else {
|
||||||
self.language
|
return false;
|
||||||
.insert(key.to_string(), value.as_str().unwrap().to_string());
|
};
|
||||||
}
|
|
||||||
|
|
||||||
let general_messages = file_util::load_file(&path, "general.json");
|
for (key, value) in messages.entries() {
|
||||||
let general_messages = parse(&general_messages).unwrap();
|
if let Some(value) = value.as_str() {
|
||||||
for (key, value) in general_messages.entries() {
|
self.language.insert(key.to_string(), value.to_string());
|
||||||
self.language
|
}
|
||||||
.insert(key.to_string(), value.as_str().unwrap().to_string());
|
|
||||||
}
|
}
|
||||||
|
true
|
||||||
}
|
}
|
||||||
pub fn get_translation(&self, key: &str) -> String {
|
pub fn get_translation(&self, key: &str) -> String {
|
||||||
match self.language.get(key) {
|
match self.language.get(key) {
|
||||||
|
|
|
||||||
|
|
@ -97,15 +97,17 @@ pub fn startup() {
|
||||||
format!("{} ", msg.prefix)
|
format!("{} ", msg.prefix)
|
||||||
};
|
};
|
||||||
|
|
||||||
let _ = writeln!(
|
let line1 = format!(
|
||||||
file,
|
|
||||||
"{} {}{}",
|
"{} {}{}",
|
||||||
fixed_box(&msg.timestamp_ms.to_string(), 13),
|
fixed_box(&msg.timestamp_ms.to_string(), 13),
|
||||||
prefix,
|
prefix,
|
||||||
resolved_message
|
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 {
|
let entry = UiLogEntry {
|
||||||
timestamp_ms: msg.timestamp_ms,
|
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;
|
use std::time::Duration;
|
||||||
#[cfg(feature = "legacy-globals")]
|
#[cfg(feature = "legacy-globals")]
|
||||||
use sysinfo::{RefreshKind, System};
|
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,
|
/* Process-owned daemon state and TUI-local state must be separate because IPC,
|
||||||
* rather than shared memory, is the boundary between the two binaries. */
|
* 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. */
|
* with the daemon and is populated from daemon IPC messages. */
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ClientState {
|
pub struct ClientState {
|
||||||
pub app: Arc<Mutex<AppState>>,
|
pub app: Arc<TokioMutex<AppState>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ClientState {
|
impl ClientState {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
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_up: Vec<(f64, f64)>,
|
||||||
pub net_down: Vec<(f64, f64)>,
|
pub net_down: Vec<(f64, f64)>,
|
||||||
pub sys_info: String,
|
pub sys_info: String,
|
||||||
|
next_sample_id: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
|
|
@ -106,6 +107,7 @@ impl AppState {
|
||||||
net_up: Vec::new(),
|
net_up: Vec::new(),
|
||||||
net_down: Vec::new(),
|
net_down: Vec::new(),
|
||||||
sys_info: String::from("Loading..."),
|
sys_info: String::from("Loading..."),
|
||||||
|
next_sample_id: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,40 +123,51 @@ impl AppState {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn push_cpu(&mut self, pt: (f64, f64)) {
|
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 {
|
if self.cpu.len() > MAX_POINTS {
|
||||||
self.cpu.remove(0);
|
self.cpu.remove(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn push_ram(&mut self, pt: (f64, f64)) {
|
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 {
|
if self.ram.len() > MAX_POINTS {
|
||||||
self.ram.remove(0);
|
self.ram.remove(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn push_ping_val(&mut self, pt: f64) {
|
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 {
|
if self.ping.len() > MAX_POINTS {
|
||||||
self.ping.remove(0);
|
self.ping.remove(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn push_net_up(&mut self, pt: (f64, f64)) {
|
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 {
|
if self.net_up.len() > MAX_POINTS {
|
||||||
self.net_up.remove(0);
|
self.net_up.remove(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn push_net_down(&mut self, pt: (f64, f64)) {
|
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 {
|
if self.net_down.len() > MAX_POINTS {
|
||||||
self.net_down.remove(0);
|
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 {
|
pub fn to_json(&self) -> JsonValue {
|
||||||
object! {
|
object! {
|
||||||
"cpu" => self.cpu.iter().map(|(_, y)| *y).collect::<Vec<f64>>(),
|
"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());
|
pub static ACTIVE_TASKS: Lazy<DashSet<String>> = Lazy::new(|| DashSet::new());
|
||||||
|
|
||||||
#[cfg(feature = "legacy-globals")]
|
#[cfg(feature = "legacy-globals")]
|
||||||
pub fn setup() {
|
pub fn setup(state: &DaemonState) {
|
||||||
ACTIVE_TASKS.insert("System info loader".to_string());
|
state.active_tasks.insert("System info loader".to_string());
|
||||||
|
let state = state.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut sys = System::new_with_specifics(RefreshKind::everything());
|
let mut sys = System::new_with_specifics(RefreshKind::everything());
|
||||||
let mut last_total_received = 0u64;
|
let mut last_total_received = 0u64;
|
||||||
let mut last_total_transmitted = 0u64;
|
let mut last_total_transmitted = 0u64;
|
||||||
let mut counter = 0.0;
|
let mut counter = 0.0;
|
||||||
loop {
|
loop {
|
||||||
if *SHUTDOWN.read().await {
|
if *state.shutdown.read().await {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
sys.refresh_all();
|
sys.refresh_all();
|
||||||
|
|
@ -258,7 +272,7 @@ pub fn setup() {
|
||||||
let net_up = delta_transmitted as f64;
|
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_cpu((counter, tcpu as f64));
|
||||||
st.push_ram((counter, ram));
|
st.push_ram((counter, ram));
|
||||||
st.push_net_down((counter, net_down));
|
st.push_net_down((counter, net_down));
|
||||||
|
|
@ -274,6 +288,21 @@ pub fn setup() {
|
||||||
thread::sleep(Duration::from_millis(5));
|
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.
|
// 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.
|
// 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.
|
// If we need to migrate from a legacy users.json file, we can do so here.
|
||||||
let content = load_file("", "users.json");
|
let content = load_file("", "users.json");
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ pub struct IotaConfig {
|
||||||
pub iota_id: Option<u64>,
|
pub iota_id: Option<u64>,
|
||||||
#[serde(default = "default_port")]
|
#[serde(default = "default_port")]
|
||||||
pub port: u16,
|
pub port: u16,
|
||||||
|
#[serde(default)]
|
||||||
|
pub web: WebSettings,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub omikron_host: Option<String>,
|
pub omikron_host: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
|
@ -27,6 +29,54 @@ pub struct IotaConfig {
|
||||||
pub read_receipts_enabled: bool,
|
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 {
|
const fn default_port() -> u16 {
|
||||||
1984
|
1984
|
||||||
}
|
}
|
||||||
|
|
@ -40,6 +90,7 @@ impl Default for IotaConfig {
|
||||||
Self {
|
Self {
|
||||||
iota_id: None,
|
iota_id: None,
|
||||||
port: default_port(),
|
port: default_port(),
|
||||||
|
web: WebSettings::default(),
|
||||||
omikron_host: None,
|
omikron_host: None,
|
||||||
omikron_port: None,
|
omikron_port: None,
|
||||||
keyring: None,
|
keyring: None,
|
||||||
|
|
|
||||||
|
|
@ -29,4 +29,5 @@ serde = "1.0.228"
|
||||||
tempfile = "3.27.0"
|
tempfile = "3.27.0"
|
||||||
anyhow = "1.0.102"
|
anyhow = "1.0.102"
|
||||||
semver = "1.0.28"
|
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.
|
pub mod manifest;
|
||||||
* It connects to the git server from methanium and checks if
|
pub mod transaction;
|
||||||
* the version has updated inside the cargo.toml file.
|
|
||||||
* It is made by Yolokit and pasted in by AlexEmmet */
|
|
||||||
|
|
||||||
use anyhow::{Context, Result, anyhow};
|
use anyhow::Result;
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/// 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> {
|
pub async fn check_update() -> Result<bool> {
|
||||||
if perform_update().await? {
|
if std::env::var_os("IOTA_UPDATE_MANIFEST").is_none() {
|
||||||
return Ok(true);
|
|
||||||
} else {
|
|
||||||
return Ok(false);
|
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"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
iota-paths = { path = "../iota-paths" }
|
||||||
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = [
|
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", features = [
|
||||||
"crypto"
|
"crypto"
|
||||||
] }
|
] }
|
||||||
|
|
|
||||||
|
|
@ -162,10 +162,7 @@ pub fn get_children(path: &str) -> Vec<String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_directory() -> String {
|
pub fn get_directory() -> String {
|
||||||
std::env::current_dir()
|
iota_paths::data_dir().to_string_lossy().to_string()
|
||||||
.unwrap_or_else(|_| PathBuf::from("."))
|
|
||||||
.to_string_lossy()
|
|
||||||
.to_string()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to download the zip file content to a file on disk
|
// Helper to download the zip file content to a file on disk
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,10 @@ edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
iota-cli = { path = "../iota-cli" }
|
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 = { version = "1.50.0", features = ["full"] }
|
||||||
tokio-util = { version = "0.7", features = ["rt"] }
|
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(" | ")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
307
iota/src/main.rs
307
iota/src/main.rs
|
|
@ -1,31 +1,292 @@
|
||||||
use iota_cli::{ipc_client::IpcClient, screens::main_screen::MainScreen, ui::start_tui};
|
use iota_cli::{
|
||||||
use std::path::PathBuf;
|
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 {
|
mod cli_args;
|
||||||
std::env::var_os("IOTA_SOCKET")
|
mod daemon_setup_flow;
|
||||||
.map(PathBuf::from)
|
mod local_daemon;
|
||||||
.unwrap_or_else(|| PathBuf::from("/run/iota/iota.sock"))
|
mod startup_error;
|
||||||
}
|
|
||||||
|
use cli_args::{CliInvocation, Command};
|
||||||
|
use startup_error::StartupError;
|
||||||
|
|
||||||
#[tokio::main(flavor = "multi_thread")]
|
#[tokio::main(flavor = "multi_thread")]
|
||||||
async fn main() {
|
async fn main() -> ExitCode {
|
||||||
let path = socket_path();
|
match run().await {
|
||||||
let ipc = match IpcClient::connect_or_activate(&path).await {
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
Ok(client) => client,
|
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
eprintln!(
|
if !matches!(error, StartupError::Cancelled) {
|
||||||
"Cannot connect to iota-daemon at {}: {error}",
|
eprintln!("{error}");
|
||||||
path.display()
|
}
|
||||||
);
|
startup_error::exit_code(&error)
|
||||||
eprintln!("Ensure iota-daemon.socket is enabled or iota-daemon is running.");
|
|
||||||
std::process::exit(1);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run() -> Result<(), StartupError> {
|
||||||
|
let invocation =
|
||||||
|
CliInvocation::parse(std::env::args().skip(1)).map_err(StartupError::InvalidCommand)?;
|
||||||
|
let mut endpoints_iter = iota_paths::daemon_endpoints().into_iter();
|
||||||
|
let local_endpoint = endpoints_iter
|
||||||
|
.next()
|
||||||
|
.expect("path layer always returns an endpoint");
|
||||||
|
let system_endpoint = endpoints_iter
|
||||||
|
.next()
|
||||||
|
.unwrap_or_else(|| local_endpoint.clone());
|
||||||
|
let endpoints = daemon_setup_flow::DaemonEndpoints {
|
||||||
|
local: local_endpoint,
|
||||||
|
system: system_endpoint,
|
||||||
};
|
};
|
||||||
ipc.spawn_reconnector();
|
|
||||||
let ui = start_tui(ipc);
|
match invocation.command {
|
||||||
ui.set_screen(Box::new(MainScreen::new(ui.clone()).await))
|
Command::Help => {
|
||||||
.await;
|
print_help();
|
||||||
while !ui.is_shutdown() {
|
Ok(())
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
}
|
||||||
|
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();
|
||||||
|
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"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
async-trait = "0.1.89"
|
||||||
iota-connection = { path = "../iota-connection" }
|
iota-connection = { path = "../iota-connection" }
|
||||||
iota-logger = { path = "../iota-logger" }
|
iota-logger = { path = "../iota-logger" }
|
||||||
iota-state = { path = "../iota-state" }
|
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 omega_discovery;
|
||||||
pub mod omikron_connection;
|
pub mod omikron_connection;
|
||||||
pub mod ping_pong_task;
|
pub mod ping_pong_task;
|
||||||
pub mod user_ops;
|
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_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::chat_files::{self, MessageState, change_message_state};
|
||||||
use iota_storage::util::config_util::{CONFIG, modify_config};
|
use iota_storage::util::config_util::{CONFIG, modify_config};
|
||||||
use iota_storage::util::e2ee_storage::{self, PendingChatSecretForward, StoredChatSecret};
|
use iota_storage::util::e2ee_storage::{self, PendingChatSecretForward, StoredChatSecret};
|
||||||
|
|
@ -19,6 +19,7 @@ use tokio::time::sleep;
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::client::{OmikronClient, OmikronError};
|
||||||
use crate::omega_discovery;
|
use crate::omega_discovery;
|
||||||
|
|
||||||
use iota_connection::message_common::*;
|
use iota_connection::message_common::*;
|
||||||
|
|
@ -106,7 +107,7 @@ const MAX_CONCURRENT_HANDLERS: usize = 20;
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
pub struct WaitingTask {
|
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,
|
pub inserted_at: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -163,14 +164,20 @@ pub struct OmikronConnection {
|
||||||
pub(crate) missed_pongs: Arc<AtomicU32>,
|
pub(crate) missed_pongs: Arc<AtomicU32>,
|
||||||
handler_semaphore: Arc<Semaphore>,
|
handler_semaphore: Arc<Semaphore>,
|
||||||
cancellation: CancellationToken,
|
cancellation: CancellationToken,
|
||||||
|
pub(crate) active_tasks: Arc<DashSet<String>>,
|
||||||
|
pub(crate) app: Arc<std::sync::Mutex<AppState>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OmikronConnection {
|
impl OmikronConnection {
|
||||||
pub fn new() -> Self {
|
pub fn new(active_tasks: Arc<DashSet<String>>, app: Arc<std::sync::Mutex<AppState>>) -> Self {
|
||||||
Self::with_cancellation(CancellationToken::new())
|
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 (shutdown_tx, _) = watch::channel(false);
|
||||||
let (state_watch_tx, _) = watch::channel(ConnectionState::Disconnected);
|
let (state_watch_tx, _) = watch::channel(ConnectionState::Disconnected);
|
||||||
|
|
||||||
|
|
@ -190,6 +197,8 @@ impl OmikronConnection {
|
||||||
missed_pongs: Arc::new(AtomicU32::new(0)),
|
missed_pongs: Arc::new(AtomicU32::new(0)),
|
||||||
handler_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HANDLERS)),
|
handler_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HANDLERS)),
|
||||||
cancellation,
|
cancellation,
|
||||||
|
active_tasks,
|
||||||
|
app,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -388,7 +397,7 @@ impl OmikronConnection {
|
||||||
*self.heartbeat_handle.lock().await = Some(heartbeat_handle);
|
*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
|
// Wait for read loop to complete
|
||||||
|
|
@ -396,7 +405,7 @@ impl OmikronConnection {
|
||||||
*self.sender.write().await = None;
|
*self.sender.write().await = None;
|
||||||
self.set_state(ConnectionState::Disconnected).await;
|
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() {
|
if let Some(handle) = self.heartbeat_handle.lock().await.take() {
|
||||||
|
|
@ -570,7 +579,7 @@ impl OmikronConnection {
|
||||||
Ok(cv) => {
|
Ok(cv) => {
|
||||||
let msg_id = cv.get_id();
|
let msg_id = cv.get_id();
|
||||||
if let Some((_, task)) = WAITING_TASKS.remove(&msg_id) {
|
if let Some((_, task)) = WAITING_TASKS.remove(&msg_id) {
|
||||||
if (task.task)(self.clone(), cv.clone()) {
|
if (task.task)(cv.clone()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -832,7 +841,7 @@ impl OmikronConnection {
|
||||||
let msg_id = cv.get_id();
|
let msg_id = cv.get_id();
|
||||||
|
|
||||||
if let Some((_, task)) = WAITING_TASKS.remove(&msg_id) {
|
if let Some((_, task)) = WAITING_TASKS.remove(&msg_id) {
|
||||||
if (task.task)(self.clone(), cv.clone()) {
|
if (task.task)(cv.clone()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1797,7 +1806,7 @@ impl OmikronConnection {
|
||||||
let response = CommunicationValue::new(CommunicationType::ErrorInternal)
|
let response = CommunicationValue::new(CommunicationType::ErrorInternal)
|
||||||
.with_id(key)
|
.with_id(key)
|
||||||
.add_typed_default(DataType::Message, DataValue::Str(reason.clone()));
|
.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(
|
WAITING_TASKS.insert(
|
||||||
msg_id,
|
msg_id,
|
||||||
WaitingTask {
|
WaitingTask {
|
||||||
task: Box::new(move |_, response_cv| {
|
task: Box::new(move |response_cv| {
|
||||||
let _ = tx.send(response_cv);
|
let _ = tx.send(response_cv);
|
||||||
true
|
true
|
||||||
}),
|
}),
|
||||||
|
|
@ -1932,20 +1941,26 @@ impl OmikronConnection {
|
||||||
// Global Instance
|
// Global Instance
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
pub static OMIKRON_CONNECTION: LazyLock<Arc<OmikronConnection>> = LazyLock::new(|| {
|
pub async fn connect_initial(
|
||||||
let conn = Arc::new(OmikronConnection::new());
|
|
||||||
|
|
||||||
start_task_cleanup_loop();
|
|
||||||
|
|
||||||
conn
|
|
||||||
});
|
|
||||||
|
|
||||||
pub async fn get_omikron_connection(
|
|
||||||
cancellation: CancellationToken,
|
cancellation: CancellationToken,
|
||||||
) -> Option<Arc<OmikronConnection>> {
|
active_tasks: Arc<DashSet<String>>,
|
||||||
let conn = Arc::new(OmikronConnection::with_cancellation(cancellation));
|
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;
|
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 {
|
impl iota_connection::connection_handler::ConnectionHandler for OmikronConnection {
|
||||||
|
|
@ -1973,3 +1988,56 @@ impl iota_connection::connection_handler::ConnectionHandler for OmikronConnectio
|
||||||
OmikronConnection::stop(self).await
|
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 crate::omikron_connection::OmikronConnection;
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use iota_state::APP_STATE;
|
|
||||||
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
|
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
|
||||||
use std::sync::atomic::Ordering;
|
|
||||||
use std::sync::LazyLock;
|
use std::sync::LazyLock;
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tokio::time::Duration;
|
use tokio::time::Duration;
|
||||||
|
|
||||||
|
|
@ -23,7 +22,9 @@ impl OmikronConnection {
|
||||||
.with_id(id)
|
.with_id(id)
|
||||||
.add_typed_default(
|
.add_typed_default(
|
||||||
DataType::LastPing,
|
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;
|
let _ = self.send_message(&ping_message).await;
|
||||||
|
|
@ -37,7 +38,7 @@ impl OmikronConnection {
|
||||||
if let Some((_, send_time)) = PING_TIMES.remove(&id) {
|
if let Some((_, send_time)) = PING_TIMES.remove(&id) {
|
||||||
let ping_ms = Instant::now().duration_since(send_time).as_millis() as i64;
|
let ping_ms = Instant::now().duration_since(send_time).as_millis() as i64;
|
||||||
*self.last_ping.lock().await = ping_ms;
|
*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 rand_core::{OsRng, RngCore};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::OmikronClient;
|
||||||
use crate::omega_discovery;
|
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 register_communication_value = CommunicationValue::new(CommunicationType::GetRegister);
|
||||||
|
|
||||||
let connection = OMIKRON_CONNECTION.clone();
|
|
||||||
|
|
||||||
let response_communication_value = match connection
|
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
|
.await
|
||||||
{
|
{
|
||||||
Ok(communication_value) => communication_value,
|
Ok(communication_value) => communication_value,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log_t!("User creation: {}", e);
|
log_t!("User creation: {}", e.to_string());
|
||||||
return (None, None);
|
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));
|
.add_typed_default(DataType::ResetToken, DataValue::Str(reset_token));
|
||||||
|
|
||||||
let response_communication_value = connection
|
let response_communication_value = connection
|
||||||
.await_response(&communication_value, Some(Duration::from_secs(20)))
|
.await_response(&communication_value, Duration::from_secs(20))
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
if let Ok(response) = response_communication_value {
|
if let Ok(response) = response_communication_value {
|
||||||
|
|
@ -84,13 +85,15 @@ pub async fn create_user(username: &str) -> (Option<UserProfile>, Option<String>
|
||||||
save_file(
|
save_file(
|
||||||
"",
|
"",
|
||||||
&format!("{}.tu", username),
|
&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());
|
add_user(user_profile.clone());
|
||||||
save_users();
|
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
|
Requires=iota-daemon.socket
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=notify
|
Type=simple
|
||||||
ExecStart=/usr/bin/iota-daemon
|
ExecStart=/usr/local/lib/iota/iota-daemon
|
||||||
|
User=iota
|
||||||
|
Group=iota
|
||||||
|
StateDirectory=iota
|
||||||
|
StateDirectoryMode=0750
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5s
|
RestartSec=5s
|
||||||
RuntimeDirectory=iota
|
|
||||||
RuntimeDirectoryMode=0750
|
|
||||||
Environment=IOTA_SOCKET=/run/iota/iota.sock
|
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)
|
# Exit code 75 = restart requested (daemon-specific convention)
|
||||||
RestartPreventExitStatus=0
|
RestartPreventExitStatus=0
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,12 @@ Description=Tensamin Iota daemon IPC socket
|
||||||
ListenStream=/run/iota/iota.sock
|
ListenStream=/run/iota/iota.sock
|
||||||
SocketMode=0660
|
SocketMode=0660
|
||||||
SocketUser=iota
|
SocketUser=iota
|
||||||
SocketGroup=iota
|
DirectoryMode=0755
|
||||||
|
SocketGroup=iota-operators
|
||||||
Backlog=5
|
Backlog=5
|
||||||
RemoveOnStop=true
|
RemoveOnStop=true
|
||||||
|
NonBlocking=true
|
||||||
|
# Enabling this socket starts the daemon on demand when a client connects.
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=sockets.target
|
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 bytes::Bytes;
|
||||||
use iota_logger::log;
|
use iota_logger::log;
|
||||||
use iota_util::file_util::load_file_vec;
|
|
||||||
use mtp::host::HostConfig;
|
use mtp::host::HostConfig;
|
||||||
use mtp::webserver::{Http3Request, Http3Response, MTPWebServer, WebServerConfig};
|
use mtp::webserver::{HttpRequest, HttpResponse, MTPWebServer, WebServerConfig};
|
||||||
use std::net::{IpAddr, Ipv4Addr};
|
use std::{net::IpAddr, path::PathBuf, sync::Arc};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
const CERT_PATH: &str = "certs/cert.pem";
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
const KEY_PATH: &str = "certs/cert.key";
|
pub enum WebMode {
|
||||||
|
Disabled,
|
||||||
async fn root(_request: Http3Request, response: Http3Response) -> Http3Response {
|
Loopback,
|
||||||
static_file("index.html", response).await
|
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 = path.trim_start_matches('/');
|
||||||
let file = if file.is_empty() { "index.html" } else { file };
|
let file = if file.is_empty() { "index.html" } else { file };
|
||||||
|
|
||||||
if file.split('/').any(|component| component == "..") {
|
if file.split('/').any(|component| component == "..") {
|
||||||
return response
|
return response
|
||||||
.status(http::StatusCode::BAD_REQUEST)
|
.status(http::StatusCode::BAD_REQUEST)
|
||||||
.body("invalid path");
|
.body("invalid path");
|
||||||
}
|
}
|
||||||
|
let path = asset_dir.join(file);
|
||||||
let path = std::path::Path::new("web").join(file);
|
let body = match tokio::fs::read(&path).await {
|
||||||
let Some(parent) = path.parent().and_then(|path| path.to_str()) else {
|
Ok(body) => body,
|
||||||
return response
|
Err(_) => {
|
||||||
.status(http::StatusCode::NOT_FOUND)
|
return response
|
||||||
.body("not found");
|
.status(http::StatusCode::NOT_FOUND)
|
||||||
|
.body("not found");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
let name = path.file_name().and_then(|v| v.to_str()).unwrap_or("");
|
||||||
return response
|
response
|
||||||
.status(http::StatusCode::NOT_FOUND)
|
.status(http::StatusCode::OK)
|
||||||
.body("not found");
|
.header("content-type", content_type(name))
|
||||||
};
|
.body(Bytes::from(body))
|
||||||
|
|
||||||
match load_file_vec(parent, name) {
|
|
||||||
Ok(body) => response
|
|
||||||
.status(http::StatusCode::OK)
|
|
||||||
.header("content-type", content_type(name))
|
|
||||||
.body(Bytes::from(body)),
|
|
||||||
Err(_) => response
|
|
||||||
.status(http::StatusCode::NOT_FOUND)
|
|
||||||
.body("not found"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn content_type(name: &str) -> &'static str {
|
fn content_type(name: &str) -> &'static str {
|
||||||
match std::path::Path::new(name)
|
match std::path::Path::new(name)
|
||||||
.extension()
|
.extension()
|
||||||
.and_then(|ext| ext.to_str())
|
.and_then(|e| e.to_str())
|
||||||
{
|
{
|
||||||
Some("html") => "text/html; charset=utf-8",
|
Some("html") => "text/html; charset=utf-8",
|
||||||
Some("css") => "text/css; 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 {
|
pub async fn start(
|
||||||
let certificate = match tokio::fs::read(CERT_PATH).await {
|
config: WebConfig,
|
||||||
Ok(certificate) => certificate,
|
parent: CancellationToken,
|
||||||
Err(error) => {
|
) -> Result<Option<Arc<WebServerHandle>>, WebServerError> {
|
||||||
log!("MTP web server certificate load failed: {}", error);
|
if config.mode == WebMode::Disabled {
|
||||||
return false;
|
return Ok(None);
|
||||||
}
|
}
|
||||||
};
|
if config.mode == WebMode::Network && config.tls.is_none() {
|
||||||
let key = match tokio::fs::read(KEY_PATH).await {
|
return Err(WebServerError::MissingTls(
|
||||||
Ok(key) => key,
|
"network mode requires TLS".into(),
|
||||||
Err(error) => {
|
));
|
||||||
log!("MTP web server key load failed: {}", error);
|
}
|
||||||
return false;
|
let tls = config
|
||||||
}
|
.tls
|
||||||
};
|
.ok_or_else(|| WebServerError::MissingTls("certificate and key are required".into()))?;
|
||||||
|
let certificate = tokio::fs::read(&tls.certificate)
|
||||||
let host_config = HostConfig::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port, certificate, key);
|
.await
|
||||||
let web_config = match WebServerConfig::new().route("/", root).and_then(|config| {
|
.map_err(|e| WebServerError::Io(e.to_string()))?;
|
||||||
config.fallback(|request, response| async move {
|
let key = tokio::fs::read(&tls.key)
|
||||||
static_file(request.uri.path(), response).await
|
.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)
|
||||||
})
|
})
|
||||||
}) {
|
.and_then(|web_config| {
|
||||||
Ok(config) => config,
|
let assets = config.asset_dir.clone();
|
||||||
Err(error) => {
|
web_config.fallback(move |request, response| {
|
||||||
log!("MTP web server route setup failed: {}", error);
|
let path = request.uri.path().to_string();
|
||||||
return false;
|
static_file(assets.clone(), path, response)
|
||||||
}
|
})
|
||||||
};
|
})
|
||||||
|
.map_err(|e| WebServerError::Startup(e.to_string()))?;
|
||||||
let mut server = match MTPWebServer::new(host_config, web_config).await {
|
let mut server = MTPWebServer::new(host_config, web_config)
|
||||||
Ok(server) => server,
|
.await
|
||||||
Err(error) => {
|
.map_err(|e| WebServerError::Startup(e.to_string()))?;
|
||||||
log!("MTP web server startup failed: {}", error);
|
let cancellation = parent.child_token();
|
||||||
return false;
|
let task_cancellation = cancellation.clone();
|
||||||
}
|
let join = tokio::spawn(async move {
|
||||||
};
|
|
||||||
|
|
||||||
log!("MTP web server running on port {}", port);
|
|
||||||
tokio::spawn(async move {
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
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; } }
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
true
|
Ok(Some(Arc::new(WebServerHandle {
|
||||||
|
cancellation,
|
||||||
|
join: Mutex::new(Some(join)),
|
||||||
|
})))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
use crate::server::is_local_network;
|
use crate::server::is_local_network;
|
||||||
use actix_web::{HttpRequest, HttpResponse, Responder, web};
|
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 serde_json::{Value, json};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub fn api_config(cfg: &mut web::ServiceConfig) {
|
pub fn api_config(cfg: &mut web::ServiceConfig) {
|
||||||
cfg.service(
|
cfg.service(
|
||||||
|
|
@ -148,31 +150,37 @@ async fn users_add(
|
||||||
_ => return error(),
|
_ => return error(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let (Some(user), Some(_)) = omikron_connector::user_ops::create_user(username).await {
|
// The legacy web API is intentionally quarantined until it can use the
|
||||||
let val = user.frontend().to_string();
|
// daemon's authenticated command/service boundary. It must not create a
|
||||||
let s_val: Value = serde_json::from_str(&val).unwrap_or(Value::Null);
|
// second connector or mutate daemon storage directly.
|
||||||
HttpResponse::Ok().json(s_val)
|
let _ = username;
|
||||||
} else {
|
error()
|
||||||
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()) {
|
if !is_allowed_req(&req, *ssl.get_ref()) {
|
||||||
return forbidden();
|
return forbidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
*iota_state::SHUTDOWN.write().await = true;
|
*state.shutdown.write().await = true;
|
||||||
success()
|
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()) {
|
if !is_allowed_req(&req, *ssl.get_ref()) {
|
||||||
return forbidden();
|
return forbidden();
|
||||||
}
|
}
|
||||||
|
|
||||||
*iota_state::SHUTDOWN.write().await = true;
|
*state.shutdown.write().await = true;
|
||||||
*iota_state::RELOAD.write().await = true;
|
*state.reload.write().await = true;
|
||||||
|
|
||||||
success()
|
success()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ use crate::api::api_config;
|
||||||
use crate::web_path_parser;
|
use crate::web_path_parser;
|
||||||
use actix_web::{App, HttpServer, dev::ServerHandle, web};
|
use actix_web::{App, HttpServer, dev::ServerHandle, web};
|
||||||
use iota_logger::log;
|
use iota_logger::log;
|
||||||
use iota_state::{ACTIVE_TASKS, SHUTDOWN};
|
use iota_state::DaemonState;
|
||||||
use iota_util::file_util::load_file_buf;
|
use iota_util::file_util::load_file_buf;
|
||||||
use rustls::ServerConfig;
|
use rustls::ServerConfig;
|
||||||
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
|
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
|
||||||
|
|
@ -16,20 +16,23 @@ use std::{
|
||||||
|
|
||||||
use tokio::sync::oneshot;
|
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 (tx, rx) = oneshot::channel::<ServerHandle>();
|
||||||
|
|
||||||
let bind_addr = std::env::var("BIND_ADDRESS").unwrap_or_else(|_| "0.0.0.0".to_string());
|
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 bind_ip: std::net::IpAddr = bind_addr.parse().expect("Invalid BIND_ADDRESS");
|
||||||
|
|
||||||
|
let server_state = state.clone();
|
||||||
let _ = tokio::spawn(async move {
|
let _ = tokio::spawn(async move {
|
||||||
let server = match load_tls_config() {
|
let server = match load_tls_config() {
|
||||||
Ok(Some(tls_config)) => {
|
Ok(Some(tls_config)) => {
|
||||||
log!("HTTPS (HTTP/2) Server running on {}:{}", bind_addr, port);
|
log!("HTTPS (HTTP/2) Server running on {}:{}", bind_addr, port);
|
||||||
let _config = (*tls_config).clone();
|
let _config = (*tls_config).clone();
|
||||||
|
let app_state = server_state.clone();
|
||||||
HttpServer::new(move || {
|
HttpServer::new(move || {
|
||||||
App::new()
|
App::new()
|
||||||
.app_data(web::Data::new(true))
|
.app_data(web::Data::new(true))
|
||||||
|
.app_data(web::Data::from(app_state.clone()))
|
||||||
.configure(api_config)
|
.configure(api_config)
|
||||||
.default_service(web::to(web_path_parser::handle))
|
.default_service(web::to(web_path_parser::handle))
|
||||||
})
|
})
|
||||||
|
|
@ -39,9 +42,11 @@ pub async fn start(port: u16) -> bool {
|
||||||
}
|
}
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
log!("HTTP Server running on {}:{}", bind_addr, port);
|
log!("HTTP Server running on {}:{}", bind_addr, port);
|
||||||
|
let app_state = server_state.clone();
|
||||||
HttpServer::new(move || {
|
HttpServer::new(move || {
|
||||||
App::new()
|
App::new()
|
||||||
.app_data(web::Data::new(false))
|
.app_data(web::Data::new(false))
|
||||||
|
.app_data(web::Data::from(app_state.clone()))
|
||||||
.configure(api_config)
|
.configure(api_config)
|
||||||
.default_service(web::to(web_path_parser::handle))
|
.default_service(web::to(web_path_parser::handle))
|
||||||
})
|
})
|
||||||
|
|
@ -58,15 +63,15 @@ pub async fn start(port: u16) -> bool {
|
||||||
let server_handle = server.handle();
|
let server_handle = server.handle();
|
||||||
tx.send(server_handle).unwrap();
|
tx.send(server_handle).unwrap();
|
||||||
|
|
||||||
ACTIVE_TASKS.insert("WebServer".into());
|
server_state.active_tasks.insert("WebServer".into());
|
||||||
server.await.unwrap();
|
server.await.unwrap();
|
||||||
ACTIVE_TASKS.remove("WebServer");
|
server_state.active_tasks.remove("WebServer");
|
||||||
log!("Web Server shutdown complete.");
|
log!("Web Server shutdown complete.");
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Ok(server_handle) = rx.await {
|
if let Ok(server_handle) = rx.await {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
wait_for_shutdown(server_handle).await;
|
wait_for_shutdown(server_handle, state).await;
|
||||||
});
|
});
|
||||||
true
|
true
|
||||||
} else {
|
} 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 {
|
loop {
|
||||||
if *SHUTDOWN.read().await {
|
if *state.shutdown.read().await {
|
||||||
log!("Shutdown signal received.");
|
log!("Shutdown signal received.");
|
||||||
server_handle.stop(true).await;
|
server_handle.stop(true).await;
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue