Compare commits

...
Author SHA1 Message Date
Alex Emmet
ec3f5e6a6e
[Fix] Durability 2026-08-30 21:28:17 +02:00
Alex Emmet
dd69b5bd97
[Fix] Connections 2026-08-30 19:18:01 +02:00
Alex Emmet
afc1832fb7
[Fix] Connectivity 2026-08-29 12:49:10 +02:00
Alex Emmet
3bfec96848
[Add] timestamping 2026-08-28 17:00:44 +02:00
Alex Emmet
29d46d1c95
[Add] Replyjumps 2026-08-28 16:21:11 +02:00
Alex Emmet
4caa6bb3e9
Merge remote-tracking branch 'refs/remotes/origin/main' 2026-08-28 13:25:15 +02:00
Alex Emmet
8160f8d0cb
[Fix] Stability 2026-08-28 13:22:59 +02:00
ad8555bc6e
[Updt] Mtp 0.3.0 2026-08-20 17:05:43 +02:00
Alex Emmet
e1dd86ec02
[WIP] 0.3.0 mtp update 2026-08-18 22:39:02 +02:00
7dc98ef29b
[Fix] User deletion & migration 2026-08-09 02:51:47 +02:00
326ebf3b37
[Fix] Replies 2026-08-08 02:09:38 +02:00
5a31055d61
Merge branch 'main' of ssh://git.methanium.net/tensamin/iota 2026-08-07 23:53:17 +02:00
45da43805a
[Fix] User States 2026-08-07 23:53:12 +02:00
4cac2d0151 Merge pull request 'Update Rust crate sysinfo to 0.39.0' (#19) from renovate/sysinfo-0.x into main 2026-08-06 21:03:18 +02:00
e4a33578eb Update Rust crate sysinfo to 0.39.0
Some checks failed
renovate/artifacts Artifact file update failure
renovate/stability-days Updates have met minimum release age requirement
2026-08-06 21:03:15 +02:00
dde495ded9 Merge pull request 'Update Rust crate strum to 0.28.0' (#17) from renovate/strum-0.x into main 2026-08-06 21:03:00 +02:00
7a0746893c Update Rust crate strum to 0.28.0
Some checks failed
renovate/artifacts Artifact file update failure
renovate/stability-days Updates have met minimum release age requirement
2026-08-06 21:02:54 +02:00
a8ea3fc9e5 Merge pull request 'Update Rust crate strum_macros to 0.28.0' (#18) from renovate/strum_macros-0.x into main 2026-08-06 20:02:43 +02:00
27ef801226 Merge pull request 'Update Rust crate sha2 to 0.11.0' (#16) from renovate/sha2-0.x into main 2026-08-06 20:02:40 +02:00
a3815b30c9 Update Rust crate strum_macros to 0.28.0
Some checks failed
renovate/artifacts Artifact file update failure
renovate/stability-days Updates have met minimum release age requirement
2026-08-06 19:02:08 +02:00
e95974b082 Update Rust crate sha2 to 0.11.0
Some checks failed
renovate/artifacts Artifact file update failure
renovate/stability-days Updates have met minimum release age requirement
2026-08-06 18:03:06 +02:00
4e430178a1 Merge pull request 'Update Rust crate open to v5.4.1' (#9) from renovate/open-5.x-lockfile into main 2026-08-06 17:01:55 +02:00
1cd067eb18 Merge pull request 'Update Rust crate rustls to v0.23.43' (#10) from renovate/rustls-0.x-lockfile into main 2026-08-06 17:01:55 +02:00
8b771ce351 Merge pull request 'Update Rust crate rusqlite to 0.40.0' (#15) from renovate/rusqlite-0.x into main 2026-08-06 17:01:54 +02:00
ddc2e05e67 Merge pull request 'Update Rust crate clap to v4.6.5' (#8) from renovate/clap-4.x-lockfile into main 2026-08-06 17:01:53 +02:00
50922fadfd Update Rust crate clap to v4.6.6 2026-08-06 17:01:51 +02:00
5d1bc35659 Update Rust crate rusqlite to 0.40.0
Some checks failed
renovate/artifacts Artifact file update failure
2026-08-06 01:03:38 +02:00
2a6aa64795 Update Rust crate rustls to v0.23.43
Some checks failed
renovate/artifacts Artifact file update failure
2026-08-05 23:01:48 +02:00
0949bcc0b8 Update Rust crate open to v5.4.1
Some checks failed
renovate/artifacts Artifact file update failure
2026-08-05 22:01:43 +02:00
6dc4d70416 Merge pull request 'Configure Renovate' (#5) from renovate/configure into main
Reviewed-on: #5
2026-08-05 20:16:50 +02:00
6b8d65cd4d Add renovate.json 2026-08-05 20:15:42 +02:00
d184fcb163
(feat): update mtp 2026-07-29 02:37:18 +02:00
6aef7d3c09
(fix): connections (again) 2026-07-28 23:53:39 +02:00
1de479ce8d
(feat): update mtp 2026-07-28 21:05:25 +02:00
baf191a40b
Merge branch 'main' of ssh://git.methanium.net/tensamin/iota 2026-07-28 02:20:37 +02:00
7399cf8fc3
[Imp] UI & UX 2026-07-28 02:20:15 +02:00
471f539116
(fix): fix connection issues 2026-07-28 01:02:04 +02:00
a97092d653
(feat): update mtp 2026-07-27 22:54:33 +02:00
14df716cf1
[Fix] Stability 2026-07-27 20:37:33 +02:00
6b4c144b73
Merge branch 'main' of ssh://git.methanium.net/tensamin/iota 2026-07-25 22:59:35 +02:00
009173a97d
caching 2026-07-25 22:59:25 +02:00
1744357350
(feat): improve config stuff 2026-07-25 22:55:02 +02:00
6a535099bb
[Wip] CLI & Daemon 2026-07-25 18:23:36 +02:00
f00992d4a5
Remove another weird test 2026-07-25 14:31:36 +02:00
8cd1637dae
Removed one test 2026-07-25 13:58:42 +02:00
d9cfceacc7
(feat): improve iota thingy 2026-07-25 13:44:48 +02:00
adfd1459b3
(chore): update license 2026-07-25 13:10:44 +02:00
Alex-Emmet
3bc5cc959a [WIP] paths 2026-07-24 01:36:14 +02:00
Alex-Emmet
8b158108bb [WIP] Daemon & CLI 2026-07-23 23:13:02 +02:00
Alex-Emmet
56aad3a023 [Fix] IPC, cli, daemon 2026-07-21 23:05:34 +02:00
Alex Emmet
36a70e82a0 [Feat] Split Daemon & TUI 2026-07-20 23:40:33 +02:00
Alex Emmet
f82500ea7d [Upd] mtp update 2026-07-20 15:28:15 +02:00
Alex Emmet
3be1d9f308 [Mig] storage to SQLite pool
[Clean] split message handler dispatch
2026-07-08 23:40:46 +02:00
Alex Emmet
9b20d159b5 Typemap 2026-07-07 23:37:13 +02:00
Alex Emmet
b9e0df1394 types 2026-07-06 23:50:10 +02:00
Alex Emmet
accf36ac38 Merge origin/main 2026-07-06 21:43:15 +02:00
ecc74d493f Merge remote-tracking branch 'refs/remotes/origin/main' 2026-07-06 20:51:08 +02:00
846746223e (feat): more call stuff 2026-07-06 20:50:59 +02:00
Alex Emmet
82cd1e26f0 Merge origin/main 2026-07-06 20:30:17 +02:00
b3311aa456 (feat): update types 2026-07-06 20:05:41 +02:00
Alex Emmet
1e0d2b9691 Merge origin/main 2026-07-06 18:54:20 +02:00
f81e366a31 (feat): chat crypto migrations 2026-07-06 18:50:01 +02:00
25caeb852e (feat): more crypto migration 2026-07-06 00:13:41 +02:00
Alex Emmet
77711d7811 [Upd] MTP Cargo Update 2026-07-05 22:38:25 +02:00
Alex Emmet
08d4817cea Merge origin/main 2026-07-05 21:46:30 +02:00
caa0572c3a (feat): crypto migrations 2026-07-05 21:45:44 +02:00
Alex Emmet
f304e1df65 [Fix]Connection Stability 2026-07-04 23:02:01 +02:00
Alex Emmet
ee0202d56a [COMPLETE] MTP-MIGRATION [Some errors to be found] 2026-07-04 02:40:57 +02:00
Alex Emmet
bc43ee43e4 [WIP] MTP migration 2026-07-03 20:17:19 +02:00
Alex Emmet
5625c5db5f Updated Crypto to use MTP-Crypto 2026-07-03 06:28:06 +02:00
f252724a43 (fix): workflow 2026-06-28 22:10:08 +02:00
cf6201fe18 (fix): workflow 2026-06-28 21:55:24 +02:00
af96da10a9 (fix): add docker 2026-06-28 21:29:20 +02:00
d2c91a3f0d (fix): add docker 2026-06-28 21:28:27 +02:00
c39fb8753b (fix): docker login 2026-06-28 21:27:46 +02:00
e77ae0e1e4 (fix): workflow 2026-06-28 21:25:39 +02:00
47df5e0098 (fix): docker login workflow 2026-06-28 21:23:51 +02:00
ac9a7cea27 (wip): workflow fix 2026-06-28 21:18:21 +02:00
ab82762831 (fix): workflow debugging 2026-06-28 20:56:04 +02:00
d0ca9f9c78 (fix): try weird workflow fix 2026-06-28 20:45:08 +02:00
fcaa7d1d44 (fix): replace docker action 2026-06-28 20:42:30 +02:00
3aafa364ef (fix): add docker 2026-06-28 20:33:42 +02:00
1373a4f284 (fix): workflow 2026-06-28 20:28:54 +02:00
07bd232c1c (fix): workflow 2026-06-28 20:27:34 +02:00
190926702b (feat): update workflow 2026-06-28 20:23:36 +02:00
Alex Emmet
4690d2e408 merge 2026-06-28 13:49:51 +02:00
Alex Emmet
d3722a917b removed agreements 2026-06-28 13:48:57 +02:00
Alex Emmet
25fc07d45d removed agreements 2026-06-28 13:47:25 +02:00
Alex Emmet
ec7db39c5d Initial MTP Migration [Broken] 2026-06-28 13:45:58 +02:00
Alex Emmet
b2a22456ff Initial MTP Migration [Broken] 2026-06-28 13:44:58 +02:00
d517144562 Persist ttyd iota using tmux 2026-06-04 22:39:14 +02:00
9e948ce6cf Update nix flake, update config options 2026-06-04 12:25:28 +02:00
Alex Emmet
a4ec766351 Message States, Tauri, Global and Local Settings 2026-06-03 20:55:15 +02:00
Alex Emmet
2b774a45f9 [Add] user Deletion 2026-05-23 19:11:40 +02:00
Alex Emmet
2193583f00 Key Regeneration 2026-05-23 02:43:40 +02:00
Alex Emmet
16749bafb3 Changes for Dev 2026-05-22 17:40:38 +02:00
Alex Emmet
b910178f35 Reconnection 2026-05-21 23:26:16 +02:00
Alex Emmet
e07ea9ea7e Reconnection 2026-05-21 22:17:26 +02:00
Alex Emmet
6b351ec9d2 Metrics, Tool config ... 2026-05-21 21:37:30 +02:00
63e68277d5 [Chore] Update crates 2026-05-20 21:21:50 +02:00
681db3f511 [Chore] Update flake 2026-05-20 21:11:00 +02:00
c04e70ef85 [Fix] use working directory instead of executable directory 2026-05-18 21:25:21 +02:00
9b99d95307 Updated flake 2026-05-18 20:47:27 +02:00
8941510c95 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-05-17 22:22:23 +02:00
41deb0d6de [Fix] systemd stuff fr this time 2026-05-17 22:22:03 +02:00
Alex Emmet
4114cf6da7 notifications 2026-05-17 21:48:01 +02:00
Alex Emmet
9d7438d1cb [Add] Push notifications 2026-05-17 21:35:22 +02:00
e18e8d1151 [Fix] tmux based systemd service 2026-05-17 20:21:58 +02:00
b7f6ccf8a7 [Add] debugging to systemd nix service 2026-05-17 20:06:08 +02:00
5d5585cb7f Updated systemd stuff 2026-05-17 19:43:55 +02:00
045e2ff15c [Add] systemd log wrapper to nix flake 2026-05-17 19:30:11 +02:00
83bd05b791 [Add] nix dev shell 2026-05-17 13:44:17 +02:00
f25815aa1b Added tppBind 2026-05-16 19:01:54 +02:00
7313c4af99 [Fix] Nix Flake 2026-05-16 17:14:36 +02:00
0b3efa2f61 [Add] Nix Flake 2026-05-16 16:28:29 +02:00
Alex Emmet
52c9c31c1a [Add] Parrallel message sending 2026-05-09 14:51:56 +02:00
Alex Emmet
3ad5d2b3ab [Add] Correct Client connection (still a lot todo) 2026-05-07 09:29:38 +02:00
Alex Emmet
91d0e9ab76 [Add] Some more split 2026-05-04 22:08:14 +02:00
Alex Emmet
e5360088eb [Fix] Split 2026-05-04 17:04:58 +02:00
Alex Emmet
3274471695 [Fix] Now uses tensamin.net 2026-05-03 21:58:53 +02:00
Alex Emmet
bfb238d7a0 [Fix] Spelling, Offline Legal 2026-05-03 15:01:47 +02:00
Alex Emmet
7873f33d23 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-04-29 21:16:36 +02:00
Alex Emmet
401d4890dc [Clean & Fix] 2026-04-29 21:15:37 +02:00
Alex Emmet
3b218411d0 [Add] App storage 2026-04-14 21:37:51 +02:00
Jonathan Wanke
39b6351c85 Added allows for unused things. 2026-04-10 00:36:46 +02:00
Jonathan Wanke
77e5072f20 Proper user error handling. 2026-04-10 00:14:41 +02:00
Alex Emmet
caaf450487 [Clean] 2026-04-08 14:06:38 +02:00
Alex Emmet
a60ea3d5d2 [Add] Correct impl of Iota updater & Move of language creation in main 2026-04-08 11:46:26 +02:00
Alex Emmet
eff1c019a8 [Add] Iota updater now with it's own crate 2026-04-08 11:44:47 +02:00
Alex Emmet
17929bdad3 [Clean] 2026-04-08 11:41:03 +02:00
Alex Emmet
b37ec732f7 [Fix] moved language to logging 2026-04-08 11:36:42 +02:00
6247502807 Merge pull request '[Fix] Local & Remote message handling is now propper' (#4) from Split into main
Reviewed-on: #4
2026-04-08 09:22:00 +02:00
Alex Emmet
3c11800af5 [Fix] Local & Remote message handling is now propper 2026-04-08 09:19:45 +02:00
Alex Emmet
8df387bea5 [Add] Auto updates 2026-04-07 23:39:37 +02:00
c6fa1a01da Merge pull request 'Split' (#3) from Split into main
Reviewed-on: #3
2026-04-07 22:28:03 +02:00
Alex Emmet
d79a625e32 [Fix] Dockerfile Iota bin was named wrong 2026-04-06 00:37:01 +02:00
Alex Emmet
1e714d1c2d [Fix] correct ChatpartnerID in Message_state 2026-04-06 00:11:24 +02:00
Alex Emmet
17f1653f87 [Add] New Client identification Logic 2026-04-05 23:41:57 +02:00
Alex Emmet
d7f79d2f6e [Fix] Changed TTP to fetch from git.methanium.net 2026-04-05 21:42:02 +02:00
Alex Emmet
c8a6d4104e ... 2026-04-05 00:27:04 +02:00
Alex Emmet
1ea0b97f6d ... 2026-04-05 00:03:19 +02:00
Alex Emmet
05763e7dd3 ... 2026-04-04 22:22:46 +02:00
Alex Emmet
71ac8d97fa ... 2026-04-04 21:43:54 +02:00
Alex Emmet
3cdf7c62d5 [Add] basic split 2026-04-04 19:46:28 +02:00
223 changed files with 37863 additions and 7152 deletions

2
.cargo/config.toml Normal file
View file

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

View file

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

View file

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

7
.gitignore vendored
View file

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

3
.gitmodules vendored Normal file
View file

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

3022
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

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

25
LICENSE
View file

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

View file

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

14
client/Cargo.toml Normal file
View file

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

View file

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

2
client/src/lib.rs Normal file
View file

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

5236
communities/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

16
communities/Cargo.toml Normal file
View file

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

View file

@ -40,7 +40,7 @@ impl Community {
let mut buf = [0u8; 56];
let mut rng = OsRng;
rng.fill_bytes(&mut buf);
let private_key = Secret::from_bytes(&buf).unwrap();
let private_key = Secret::from(buf);
let public_key = PublicKey::from(&private_key);
Community {
name: String::new(),
@ -58,7 +58,7 @@ impl Community {
let mut buf = [0u8; 56];
let mut rng = OsRng;
rng.fill_bytes(&mut buf);
let private_key = Secret::from_bytes(&buf).unwrap();
let private_key = Secret::from(buf);
let public_key = PublicKey::from(&private_key);
let c = Community {
name,
@ -125,7 +125,7 @@ impl Community {
self.members.clone()
}
pub fn get_private_key(&self) -> Secret {
Secret::from_bytes(self.private_key.as_bytes()).unwrap()
Secret::from(*self.private_key.as_bytes())
}
pub fn get_public_key(&self) -> &PublicKey {
&self.public_key
@ -210,7 +210,7 @@ impl Community {
for interactable in target_interactables.iter() {
if interactable.get_name() == name {
if interactable.get_codec() == "category" {
return CommunicationValue::new(CommunicationType::error);
return CommunicationValue::new(CommunicationType::ErrorInternal);
} else {
// cannot move a value of type dyn Interactable the size of dyn Interactable cannot be statically determined (rustc E0161)
return interactable.run_function(cv.clone()).await;
@ -222,21 +222,23 @@ impl Community {
for interactable in target_interactables.iter() {
if interactable.get_name() == name {
if interactable.get_codec() == "category" {
let category: &Category =
interactable.as_any().downcast_ref::<Category>().unwrap();
let Some(category) = interactable.as_any().downcast_ref::<Category>() else {
return CommunicationValue::new(CommunicationType::ErrorInternal);
};
// cannot move a value of type dyn Interactable the size of dyn Interactable cannot be statically determined (rustc E0161)
return category
.get_child(path.to_string(), name.to_string())
.unwrap()
.ok_or(CommunicationValue::new(CommunicationType::ErrorInternal))
.unwrap_or_else(|error| return error)
.run_function(cv.clone())
.await;
} else {
return CommunicationValue::new(CommunicationType::error);
return CommunicationValue::new(CommunicationType::ErrorInternal);
}
}
}
}
CommunicationValue::new(CommunicationType::add_conversation)
CommunicationValue::new(CommunicationType::AddConversation)
}
pub async fn save(&self) {
@ -267,7 +269,7 @@ impl Community {
let mut data = JsonValue::new_object();
let mut permissions = JsonValue::new_array();
for perm in self.permissions.get(user).unwrap() {
for perm in self.permissions.get(user).into_iter().flatten() {
if let Ok(_) = permissions.push(perm.to_string()) {}
}
@ -287,10 +289,10 @@ impl Community {
}
pub async fn load(name: &String) -> Option<Arc<Community>> {
let file_contents = file_util::load_file(&format!("communities/{}/", name), "config.json");
let json_content = json::parse(&file_contents).unwrap();
let json_content = json::parse(&file_contents).ok()?;
let user_data = file_util::load_file(&format!("communities/{}/", name), "users.json");
let user_json: JsonValue = json::parse(&user_data).unwrap();
let user_json: JsonValue = json::parse(&user_data).ok()?;
let mut users = Vec::new();
let mut permissions: HashMap<i64, Vec<Permission>> = HashMap::new();
@ -318,25 +320,17 @@ pub async fn load(name: &String) -> Option<Arc<Community>> {
};
let community = Community {
name: json_content["name"].as_str().unwrap().to_string(),
name: json_content["name"].as_str()?.to_string(),
owner_id: Arc::new(RwLock::new(json_content["owner_id"].as_i64().unwrap_or(0))),
members: users,
roles,
permissions,
private_key: Secret::from_bytes(
&STANDARD
.decode(json_content["private_key"].as_str().unwrap())
.unwrap(),
)
.unwrap(),
public_key: PublicKey::from(
&Secret::from_bytes(
&STANDARD
.decode(json_content["private_key"].as_str().unwrap())
.unwrap(),
)
.unwrap(),
),
&STANDARD.decode(json_content["private_key"].as_str()?).ok()?,
)?,
public_key: PublicKey::from(&Secret::from_bytes(
&STANDARD.decode(json_content["private_key"].as_str()?).ok()?,
)?),
interactables: Arc::new(RwLock::new(Vec::new())),
connections: Arc::new(RwLock::new(HashMap::new())),
};
@ -346,7 +340,7 @@ pub async fn load(name: &String) -> Option<Arc<Community>> {
file_util::get_children(&format!("communities/{}/interactables/", name));
for file in interactable_files {
if file.contains(".json") {
let name = file.split('.').next().unwrap().to_string();
let Some(name) = file.split('.').next().map(str::to_string) else { continue };
let interactable: Box<dyn Interactable> =
registry::load(comarc.clone(), String::new(), name).await;
comarc.add_interactable(Arc::new(interactable)).await;

View file

@ -21,6 +21,20 @@ use tungstenite::Message;
use tungstenite::Utf8Bytes;
use uuid::Uuid;
use x448::PublicKey;
trait CommunicationResponseExt {
fn with_request_id(self, request: &CommunicationValue) -> Self;
}
impl CommunicationResponseExt for CommunicationValue {
fn with_request_id(mut self, request: &CommunicationValue) -> Self {
self = self.without_id();
if let Some(id) = request.id() {
self = self.with_id(id);
}
self
}
}
pub struct CommunityConnection {
pub sender: Arc<RwLock<SplitSink<WebSocketStream<TokioIo<Upgraded>>, Message>>>,
pub receiver: Arc<RwLock<SplitStream<WebSocketStream<TokioIo<Upgraded>>>>>,
@ -30,7 +44,6 @@ pub struct CommunityConnection {
challenged: Arc<RwLock<bool>>,
challenge: Arc<RwLock<String>>,
auth: Arc<RwLock<Option<AuthUser>>>,
pub ping: Arc<RwLock<i64>>,
}
impl CommunityConnection {
pub fn new(
@ -47,13 +60,14 @@ impl CommunityConnection {
challenged: Arc::new(RwLock::new(false)),
challenge: Arc::new(RwLock::new(String::new())),
auth: Arc::new(RwLock::new(None)),
ping: Arc::new(RwLock::new(-1)),
})
}
pub async fn send_message(&self, message: &CommunicationValue) {
let mut sender = self.sender.write().await; // Access the SplitSink
let message_text = Message::Text(Utf8Bytes::from(message.to_json().to_string()));
sender.send(message_text).await.unwrap(); // Send the message via the SplitSink
if let Err(error) = sender.send(message_text).await {
log::error!("failed to send community message: {error}");
}
}
pub async fn get_community(&self) -> Option<Arc<Community>> {
self.community.read().await.clone()
@ -70,12 +84,12 @@ impl CommunityConnection {
let user_id = self.get_user_id().await;
cv = cv.with_sender(user_id);
if cv.is_type(CommunicationType::identification) && !self.is_identified().await {
if cv.is_type(CommunicationType::Identification) && !self.is_identified().await {
self.handle_identification(cv).await;
return;
}
if cv.is_type(CommunicationType::challenge_response) && !self.is_identified().await {
if cv.is_type(CommunicationType::ChallengeResponse) && !self.is_identified().await {
self.handle_challenge_response(cv).await;
return;
}
@ -84,30 +98,26 @@ impl CommunityConnection {
return;
}
if cv.is_type(CommunicationType::ping) {
self.handle_ping(cv).await;
return;
}
if cv.is_type(CommunicationType::client_changed) {
if cv.is_type(CommunicationType::ClientChanged) {
//self.handle_client_changed(cv).await;
return;
}
if cv.is_type(CommunicationType::function) {
if cv.is_type(CommunicationType::Function) {
self.handle_function(cv).await;
return;
}
}
async fn handle_function(&self, cv: CommunicationValue) {
let name = cv.get_data(DataTypes::name).unwrap().as_str().unwrap();
let path = cv.get_data(DataTypes::path).unwrap().as_str().unwrap();
let function = cv.get_data(DataTypes::function).unwrap().as_str().unwrap();
let Some(name) = cv.get_data(DataType::Name).as_str() else { return };
let Some(path) = cv.get_data(DataType::Path).as_str() else { return };
let Some(function) = cv.get_data(DataType::Function).as_str() else { return };
let result = self
.get_community()
.await
.unwrap()
.ok_or(())
.unwrap_or_else(|_| return)
.run_function(self.get_user_id().await, name, path, function, &cv)
.await;
@ -115,13 +125,13 @@ impl CommunityConnection {
}
async fn handle_identification(&self, cv: CommunicationValue) {
let user_id = cv
.get_data(DataTypes::user_id)
.get_data(DataType::UserId)
.unwrap_or(&JsonValue::Number(Number::from(0)))
.as_i64()
.unwrap_or(0);
let Some(user) = get_user(user_id) else {
self.send_error_response(&cv.get_id(), CommunicationType::error_invalid_user_id)
self.send_error_response(&cv, CommunicationType::ErrorInvalidUserId)
.await;
return;
};
@ -151,7 +161,7 @@ impl CommunityConnection {
let user_public_key_bytes = match STANDARD.decode(&user.public_key) {
Ok(bytes) => bytes,
Err(_) => {
self.send_error_response(&cv.get_id(), CommunicationType::error_invalid_user_id)
self.send_error_response(&cv, CommunicationType::ErrorInvalidUserId)
.await;
return;
}
@ -160,14 +170,14 @@ impl CommunityConnection {
let user_pub_key: PublicKey = match PublicKey::from_bytes(&user_public_key_bytes) {
Some(key) => key,
__ => {
self.send_error_response(&cv.get_id(), CommunicationType::error_invalid_user_id)
self.send_error_response(&cv, CommunicationType::ErrorInvalidUserId)
.await;
return;
}
};
let Some(community) = self.community.read().await.clone() else {
self.send_error_response(&cv.get_id(), CommunicationType::error)
self.send_error_response(&cv, CommunicationType::ErrorInternal)
.await;
return;
};
@ -178,7 +188,7 @@ impl CommunityConnection {
let shared_secret = match community_private_key.to_diffie_hellman(&user_pub_key) {
Some(secret) => secret,
_ => {
self.send_error_response(&cv.get_id(), CommunicationType::error)
self.send_error_response(&cv, CommunicationType::ErrorInternal)
.await;
return;
}
@ -200,7 +210,7 @@ impl CommunityConnection {
let encrypted_challenge = match cipher.encrypt(nonce, challenge_str.as_bytes()) {
Ok(data) => data,
Err(_) => {
self.send_error_response(&cv.get_id(), CommunicationType::error)
self.send_error_response(&cv, CommunicationType::ErrorInternal)
.await;
return;
}
@ -209,21 +219,21 @@ impl CommunityConnection {
let mut encrypted_out = nonce_bytes.to_vec();
encrypted_out.extend(encrypted_challenge);
let response = CommunicationValue::new(CommunicationType::challenge)
let response = CommunicationValue::new(CommunicationType::Challenge)
.add_data_str(
DataTypes::public_key,
DataType::PublicKey,
STANDARD.encode(community_public_key.as_bytes()),
)
.add_data_str(DataTypes::challenge, STANDARD.encode(&encrypted_out))
.with_id(cv.get_id());
.add_data_str(DataType::Challenge, STANDARD.encode(&encrypted_out))
.with_request_id(&cv);
self.send_message(&response).await;
}
async fn handle_challenge_response(self: Arc<Self>, cv: CommunicationValue) {
let client_challenge_response_b64 = match cv.get_data(DataTypes::challenge) {
let client_challenge_response_b64 = match cv.get_data(DataType::Challenge) {
Some(data) => data.to_string(),
_ => {
self.send_error_response(&cv.get_id(), CommunicationType::error)
self.send_error_response(&cv, CommunicationType::ErrorInvalidData)
.await;
return;
}
@ -232,38 +242,38 @@ impl CommunityConnection {
let challenge_response_bytes = match STANDARD.decode(&client_challenge_response_b64) {
Ok(bytes) => bytes,
Err(_) => {
self.send_error_response(&cv.get_id(), CommunicationType::error)
self.send_error_response(&cv, CommunicationType::ErrorInvalidData)
.await;
return;
}
};
if challenge_response_bytes.len() < 12 {
self.send_error_response(&cv.get_id(), CommunicationType::error)
self.send_error_response(&cv, CommunicationType::ErrorInvalidData)
.await;
return;
}
let Some(user) = self.auth.read().await.clone() else {
self.send_error_response(&cv.get_id(), CommunicationType::error)
self.send_error_response(&cv, CommunicationType::ErrorInternal)
.await;
return;
};
let Some(user_pub_bytes) = STANDARD.decode(&user.public_key).ok() else {
self.send_error_response(&cv.get_id(), CommunicationType::error)
self.send_error_response(&cv, CommunicationType::ErrorInvalidData)
.await;
return;
};
let Some(user_pub_key) = PublicKey::from_bytes(&user_pub_bytes) else {
self.send_error_response(&cv.get_id(), CommunicationType::error)
self.send_error_response(&cv, CommunicationType::ErrorInvalidPublicKey)
.await;
return;
};
let Some(community) = self.community.read().await.clone() else {
self.send_error_response(&cv.get_id(), CommunicationType::error)
self.send_error_response(&cv, CommunicationType::ErrorInternal)
.await;
return;
};
@ -273,7 +283,7 @@ impl CommunityConnection {
let shared_secret = match community_private_key.to_diffie_hellman(&user_pub_key) {
Some(secret) => secret,
_ => {
self.send_error_response(&cv.get_id(), CommunicationType::error)
self.send_error_response(&cv, CommunicationType::ErrorInternal)
.await;
return;
}
@ -297,7 +307,7 @@ impl CommunityConnection {
let decrypted_bytes = match cipher.decrypt(nonce, ciphertext) {
Ok(pt) => pt,
Err(_) => {
self.send_error_response(&cv.get_id(), CommunicationType::error)
self.send_error_response(&cv, CommunicationType::ErrorInvalidChallenge)
.await;
return;
}
@ -306,7 +316,7 @@ impl CommunityConnection {
let client_response = match String::from_utf8(decrypted_bytes) {
Ok(str) => str,
Err(_) => {
self.send_error_response(&cv.get_id(), CommunicationType::error)
self.send_error_response(&cv, CommunicationType::ErrorInvalidData)
.await;
return;
}
@ -315,7 +325,7 @@ impl CommunityConnection {
let expected_challenge = self.challenge.read().await.clone();
if client_response != expected_challenge {
self.send_error_response(&cv.get_id(), CommunicationType::error)
self.send_error_response(&cv, CommunicationType::ErrorInvalidChallenge)
.await;
self.close().await;
return;
@ -327,7 +337,7 @@ impl CommunityConnection {
}
let Some(community) = self.community.read().await.clone() else {
self.send_error_response(&cv.get_id(), CommunicationType::error)
self.send_error_response(&cv, CommunicationType::ErrorInternal)
.await;
return;
};
@ -335,15 +345,15 @@ impl CommunityConnection {
let user_id = self.get_user_id().await;
if user_id == 0 {
self.send_error_response(&cv.get_id(), CommunicationType::error)
self.send_error_response(&cv, CommunicationType::ErrorInvalidUserId)
.await;
return;
}
arc.add_connection(self.clone()).await;
let response = CommunicationValue::new(CommunicationType::identification_response)
.add_data(DataTypes::interactables, {
let response = CommunicationValue::new(CommunicationType::IdentificationResponse)
.add_data(DataType::Interactables, {
let a: Vec<Arc<Box<dyn Interactable>>> = arc.get_interactables(user_id).await;
let mut c: JsonValue = JsonValue::new_object();
for b in a {
@ -354,13 +364,17 @@ impl CommunityConnection {
}
c
})
.with_id(cv.get_id());
.with_request_id(&cv);
self.send_message(&response).await;
}
async fn send_error_response(&self, message_id: &Uuid, error_type: CommunicationType) {
let error = CommunicationValue::new(error_type).with_id(*message_id);
async fn send_error_response(
&self,
request: &CommunicationValue,
error_type: CommunicationType,
) {
let error = CommunicationValue::new(error_type).with_request_id(request);
self.send_message(&error).await;
}
pub async fn close(&self) {
@ -370,27 +384,11 @@ impl CommunityConnection {
pub async fn handle_close(self: Arc<Self>) {
if self.is_identified().await {
if self.get_user_id().await != 0 {
self.community
.read()
.await
.as_ref()
.unwrap()
.remove_connection(self.clone())
.await;
if let Some(community) = self.community.read().await.as_ref() {
community.remove_connection(self.clone()).await;
}
}
}
}
async fn handle_ping(&self, cv: CommunicationValue) {
if let Some(last_ping) = cv.get_data(DataTypes::last_ping) {
if let Ok(ping_val) = last_ping.to_string().parse::<i64>() {
let mut ping_guard = self.ping.write().await;
*ping_guard = ping_val;
}
}
let response = CommunicationValue::new(CommunicationType::pong).with_id(cv.get_id());
self.send_message(&response).await;
}
}

View file

@ -3,7 +3,7 @@ use async_trait::async_trait;
use json::JsonValue;
use std::any::Any;
use std::sync::Arc;
use ttp_core::CommunicationValue;
use mtp::codec::CommunicationValue;
use uuid::Uuid;
pub struct Category {
@ -30,14 +30,13 @@ impl Category {
.find(|child| child.get_name() == &name)
.cloned()
} else {
let sub_module = path.split("/").next().unwrap();
let sub_module = path.split('/').next()?;
let next = self
.children
.iter()
.find(|child| child.get_name() == sub_module)
.unwrap();
.find(|child| child.get_name() == sub_module)?;
if next.get_codec() == "category" {
let next_cat = next.as_any().downcast_ref::<Category>().unwrap();
let next_cat = next.as_any().downcast_ref::<Category>()?;
next_cat.get_child(path, name)
} else {
Some(next.clone())
@ -95,7 +94,7 @@ impl Interactable for Category {
v
}
async fn run_function(&self, _cv: CommunicationValue) -> CommunicationValue {
CommunicationValue::new(CommunicationType::error)
CommunicationValue::new(CommunicationType::ErrorInternal)
}
fn to_json(&self) -> JsonValue {
let mut v = JsonValue::new_object();

View file

@ -3,7 +3,7 @@ use async_trait::async_trait;
use json::JsonValue;
use std::any::Any;
use std::sync::Arc;
use ttp_core::CommunicationValue;
use mtp::codec::CommunicationValue;
use uuid::Uuid;
pub type InteractableFactory = fn() -> Box<dyn Interactable>;

View file

@ -8,10 +8,12 @@ use crate::{
};
use async_trait::async_trait;
use json::{JsonValue, array, object};
use iota_util::mtp_compat::{OptionalDataValueExt, RequiredCommunicationFields};
use std::fs;
use std::path::Path;
use std::sync::Arc;
use std::{any::Any, collections::HashMap};
use ttp_core::{CommunicationType, CommunicationValue, DataTypes};
use mtp::codec::{CommunicationType, CommunicationValue, DataType};
use uuid::Uuid;
pub struct TextChat {
id: Uuid,
@ -29,6 +31,10 @@ impl TextChat {
}
}
pub fn add_message(&self, send_time: u128, sender: i64, message: &str) {
let Ok(send_time) = i64::try_from(send_time) else {
log!("Message timestamp exceeds local storage range");
return;
};
let user_dir = &format!(
"communities/{}/interactables/{}/{}",
self.get_community().get_name(),
@ -36,7 +42,9 @@ impl TextChat {
self.get_name()
);
if let Err(e) = fs::create_dir_all(user_dir) {
let working_dir = iota_util::file_util::get_directory();
let full_dir = Path::new(&working_dir).join(user_dir);
if let Err(e) = fs::create_dir_all(&full_dir) {
log!("Failed to create chat directory: {}", e);
return;
}
@ -70,7 +78,7 @@ impl TextChat {
}
let json_obj = object! {
"timestamp" => send_time as i64,
"timestamp" => send_time,
"content" => message,
"sender" => sender.to_string(),
};
@ -190,39 +198,48 @@ impl Interactable for TextChat {
JsonValue::new_object()
}
async fn run_function(&self, cv: CommunicationValue) -> CommunicationValue {
let payload = cv.get_data(DataTypes::payload).as_container().unwrap();
if cv.get_data(DataTypes::function).as_str().unwrap() == "get_messages" {
let amount = payload.get(DataTypes::amount).as_i64().unwrap();
let payload = cv.get_data(DataType::Payload).as_container().unwrap();
if cv.get_data(DataType::Function).as_str().unwrap() == "get_messages" {
let amount = payload.get(DataType::Amount).as_i64().unwrap();
let loaded_messages = payload["loaded_messages"].as_i64().unwrap();
let messages = self.get_messages(loaded_messages, amount).clone();
let mut payload = JsonValue::new_object();
payload["messages"] = messages;
return CommunicationValue::new(CommunicationType::function)
.with_id(cv.get_id())
.add_data_str(DataTypes::name, self.name.clone())
.add_data_str(DataTypes::path, self.path.clone())
.add_data_str(DataTypes::result, "message_chunk".to_string())
.add_data(DataTypes::payload, payload);
return CommunicationValue::new(CommunicationType::Function)
.with_request_id(&cv)
.add_data_str(DataType::Name, self.name.clone())
.add_data_str(DataType::Path, self.path.clone())
.add_data_str(DataType::Result, "message_chunk".to_string())
.add_data(DataType::Payload, payload);
}
if cv.get_data(DataTypes::function).unwrap().as_str().unwrap() == "send_message" {
if cv.get_data(DataType::Function).unwrap().as_str().unwrap() == "send_message" {
let message = payload["message"].as_str().unwrap();
let sender = match cv.require_sender() {
Ok(sender) => sender,
Err(_) => return CommunicationValue::new(CommunicationType::ErrorInvalidData)
.with_request_id(&cv),
};
let milliseconds_timestamp: u128 = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis();
self.add_message(milliseconds_timestamp, cv.get_sender(), message);
let Ok(sender) = i64::try_from(sender) else {
return CommunicationValue::new(CommunicationType::ErrorInvalidData)
.with_request_id(&cv);
};
self.add_message(milliseconds_timestamp, sender, message);
let mut distribution_payload = JsonValue::new_object();
distribution_payload["message"] = JsonValue::String(message.to_string());
distribution_payload["sender_id"] = JsonValue::String(cv.get_sender().to_string());
distribution_payload["sender_id"] = JsonValue::String(sender.to_string());
distribution_payload["send_time"] =
JsonValue::String(milliseconds_timestamp.to_string());
let distribution = CommunicationValue::new(CommunicationType::update)
.with_id(cv.get_id())
.add_data_str(DataTypes::name, self.name.clone())
.add_data_str(DataTypes::path, self.path.clone())
.add_data_str(DataTypes::result, "message_live".to_string())
.add_data(DataTypes::payload, distribution_payload);
let distribution = CommunicationValue::new(CommunicationType::Update)
.with_request_id(&cv)
.add_data_str(DataType::Name, self.name.clone())
.add_data_str(DataType::Path, self.path.clone())
.add_data_str(DataType::Result, "message_live".to_string())
.add_data(DataType::Payload, distribution_payload);
let connections: HashMap<i64, Vec<Arc<CommunityConnection>>> =
self.get_community().get_connections().await.clone();
@ -233,14 +250,14 @@ impl Interactable for TextChat {
cd.send_message(&distribution).await;
}
}
return CommunicationValue::new(CommunicationType::function)
.with_id(cv.get_id())
.add_data_str(DataTypes::name, self.name.clone())
.add_data_str(DataTypes::path, self.path.clone())
.add_data_str(DataTypes::result, "message_received".to_string())
.add_data(DataTypes::payload, JsonValue::new_object());
return CommunicationValue::new(CommunicationType::Function)
.with_request_id(&cv)
.add_data_str(DataType::Name, self.name.clone())
.add_data_str(DataType::Path, self.path.clone())
.add_data_str(DataType::Result, "message_received".to_string())
.add_data(DataType::Payload, JsonValue::new_object());
}
CommunicationValue::new(CommunicationType::error).with_id(cv.get_id())
CommunicationValue::new(CommunicationType::ErrorInternal).with_request_id(&cv)
}
fn to_json(&self) -> JsonValue {
JsonValue::new_object()

View file

@ -1,6 +1,7 @@
use crate::communities::{community::Community, interactables::interactable::Interactable};
use async_trait::async_trait;
use json::JsonValue;
use iota_util::mtp_compat::OptionalDataValueExt;
use std::sync::Arc;
use std::{any::Any, sync::RwLock};
use uuid::Uuid;
@ -116,8 +117,8 @@ impl Interactable for VoiceChat {
data
}
async fn run_function(&self, cv: CommunicationValue) -> CommunicationValue {
let payload = cv.get_data(DataTypes::payload).unwrap();
let function = cv.get_data(DataTypes::function).unwrap().as_str().unwrap();
let payload = cv.get_data(DataType::Payload).unwrap();
let function = cv.get_data(DataType::Function).unwrap().as_str().unwrap();
if function == "get_call" {
let sender_id = payload["sender_id"].as_str().unwrap();
@ -129,12 +130,12 @@ impl Interactable for VoiceChat {
response_payload["message"] = JsonValue::String(message_id.to_string());
response_payload["send_time"] = JsonValue::String(send_time.to_string());
return CommunicationValue::new(CommunicationType::function)
.with_id(cv.get_id())
.add_data_str(DataTypes::name, self.name.clone())
.add_data_str(DataTypes::path, self.path.clone())
.add_data_str(DataTypes::result, "getting_call".to_string())
.add_data(DataTypes::payload, response_payload);
return CommunicationValue::new(CommunicationType::Function)
.with_request_id(&cv)
.add_data_str(DataType::Name, self.name.clone())
.add_data_str(DataType::Path, self.path.clone())
.add_data_str(DataType::Result, "getting_call".to_string())
.add_data(DataType::Payload, response_payload);
}
if function == "update_user_state" {
@ -153,18 +154,18 @@ impl Interactable for VoiceChat {
user.streaming = streaming;
}
let mut response_payload = JsonValue::new_object();
response_payload["user_id"] = JsonValue::String(user_id.to_string());
response_payload["user_id"] = JsonValue::Number(user_id);
response_payload["state"] = JsonValue::String(state.to_string());
response_payload["streaming"] = JsonValue::Boolean(streaming);
return CommunicationValue::new(CommunicationType::update)
.with_id(cv.get_id())
.add_data_str(DataTypes::name, self.name.clone())
.add_data_str(DataTypes::path, self.path.clone())
.add_data_str(DataTypes::result, "user_changed".to_string())
.add_data(DataTypes::payload, response_payload);
return CommunicationValue::new(CommunicationType::Update)
.with_request_id(&cv)
.add_data_str(DataType::Name, self.name.clone())
.add_data_str(DataType::Path, self.path.clone())
.add_data_str(DataType::Result, "user_changed".to_string())
.add_data(DataType::Payload, response_payload);
}
CommunicationValue::new(CommunicationType::error).with_id(cv.get_id())
CommunicationValue::new(CommunicationType::ErrorInternal).with_request_id(&cv)
}
fn to_json(&self) -> JsonValue {

View file

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

82
flake.lock generated Normal file
View file

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

275
flake.nix Normal file
View file

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

7
iota-auth/Cargo.toml Normal file
View file

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

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

@ -0,0 +1 @@

View file

@ -1,6 +1,6 @@
use json::JsonValue;
use crate::util::file_util::load_file;
use iota_iota_util::file_util::load_file;
// NOT USED AT MOMENT
pub fn is_private_key_valid(user_id: &i64, key_hash: &str) -> bool {
let file_contents = load_file("", "users.json");

29
iota-cli/Cargo.toml Normal file
View file

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

1
iota-cli/src/app_state.rs Executable file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,96 +1,110 @@
use crate::APP_STATE;
use crate::gui::elements::elements::{Element, InteractableElement, JoinableElement};
use crate::gui::interaction_result::InteractionResult;
use crate::gui::util::borders::draw_block_joins;
use crate::util::logger::PrintType;
use crossterm::event::{KeyCode, KeyEvent};
use crate::elements::elements::{Element, InteractableElement, JoinableElement};
use crate::util::borders::draw_block_joins;
use crate::{interaction_result::InteractionResult, render_context::RenderContext};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use iota_state::{ClientState, UiLogEntry};
use ratatui::{
Frame,
layout::Rect,
style::{Color, Style},
style::Style,
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use std::any::Any;
use std::time::{SystemTime, UNIX_EPOCH};
use std::{
any::Any,
sync::atomic::{AtomicUsize, Ordering},
};
use unicode_width::UnicodeWidthChar;
#[derive(Clone, Debug)]
pub struct UiLogEntry {
pub timestamp_ms: u128,
pub sender: PrintType,
pub message: String,
pub is_error: bool,
#[derive(Clone, Copy)]
enum LogSource {
Call,
Client,
Iota,
Omikron,
Omega,
Command,
Other,
}
impl UiLogEntry {
pub fn format_timestamp(&self) -> String {
let secs = (self.timestamp_ms / 1000) as i64;
let hours = (secs / 3600) % 24;
let minutes = (secs / 60) % 60;
let seconds = secs % 60;
format!("{:02}:{:02}:{:02}", hours, minutes, seconds)
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,
}
}
impl From<LogEntry> for UiLogEntry {
fn from(entry: LogEntry) -> Self {
Self {
timestamp_ms: entry.timestamp_ms,
sender: entry.sender,
message: entry.message,
is_error: entry.is_error,
}
}
}
#[derive(Clone, Debug)]
pub struct LogEntry {
pub timestamp_ms: u128,
pub sender: PrintType,
pub message: String,
pub is_error: bool,
}
impl LogEntry {
pub fn new(sender: PrintType, message: String, is_error: bool) -> Self {
Self {
timestamp_ms: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis(),
sender,
message,
is_error,
fn style(self, theme: &crate::theme::ResolvedTheme) -> Style {
match self {
Self::Call => theme.logs.call,
Self::Client => theme.logs.client,
Self::Iota => theme.logs.iota,
Self::Omikron => theme.logs.omikron,
Self::Omega => theme.logs.omega,
Self::Command => theme.logs.command,
Self::Other => theme.logs.other,
}
}
}
pub struct LogCard {
state: ClientState,
focused: bool,
selected: bool,
scroll_offset: usize,
last_total_lines: usize,
last_visible_height: usize,
last_total_lines: AtomicUsize,
last_visible_height: AtomicUsize,
last_width: AtomicUsize,
filter: String,
filtering: bool,
pub borders: Borders,
pub joins: Borders,
}
impl LogCard {
pub fn new() -> Self {
pub fn new(state: ClientState) -> Self {
Self {
state,
focused: false,
selected: false,
scroll_offset: 0,
last_total_lines: 0,
last_visible_height: 10,
last_total_lines: AtomicUsize::new(0),
last_visible_height: AtomicUsize::new(1),
last_width: AtomicUsize::new(1),
filter: String::new(),
filtering: false,
borders: Borders::ALL,
joins: Borders::NONE,
}
}
fn get_logs(&self) -> Vec<UiLogEntry> {
let state = APP_STATE.lock().unwrap();
state.get_logs().iter().cloned().collect()
let state = match self.state.app.try_lock() {
Ok(state) => state,
Err(_) => return Vec::new(),
};
let needle = self.filter.to_ascii_lowercase();
state
.get_logs()
.iter()
.filter(|entry| {
needle.is_empty()
|| entry.sender.to_ascii_lowercase().contains(&needle)
|| entry.message.to_ascii_lowercase().contains(&needle)
})
.map(|e| UiLogEntry {
timestamp_ms: e.timestamp_ms,
sender: e.sender.clone(),
message: e.message.clone(),
is_error: e.is_error,
})
.collect()
}
fn find_split_point(s: &str, max_width: usize) -> usize {
@ -102,7 +116,7 @@ impl LogCard {
let mut last_boundary = 0usize;
for (idx, ch) in s.char_indices() {
let char_width = if ch.is_ascii() { 1 } else { 2 };
let char_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if current_width + char_width > max_width {
if last_boundary == 0 {
return idx + ch.len_utf8();
@ -116,7 +130,7 @@ impl LogCard {
s.len()
}
fn wrap_entry(entry: &UiLogEntry, available_width: usize) -> Vec<(String, Color, bool)> {
fn wrap_entry(entry: &UiLogEntry, available_width: usize) -> Vec<(String, LogSource, bool)> {
let mut result = Vec::new();
let timestamp = entry.format_timestamp();
@ -179,7 +193,7 @@ impl LogCard {
line.push_str(&timestamp);
}
result.push((line, entry.sender.prefix_color(), entry.is_error));
result.push((line, LogSource::from_sender(&entry.sender), entry.is_error));
}
result
@ -189,7 +203,7 @@ impl LogCard {
&self,
entries: Vec<UiLogEntry>,
width: usize,
) -> Vec<(String, Color, bool)> {
) -> Vec<(String, LogSource, bool)> {
let mut lines = Vec::new();
for entry in entries {
@ -215,11 +229,13 @@ impl LogCard {
}
fn get_title_hints(&self) -> (bool, bool) {
if self.last_total_lines == 0 || self.last_total_lines <= self.last_visible_height {
let total_lines = self.last_total_lines.load(Ordering::Relaxed);
let visible_height = self.last_visible_height.load(Ordering::Relaxed);
if total_lines == 0 || total_lines <= visible_height {
return (false, false);
}
let max_offset = self.last_total_lines - self.last_visible_height;
let max_offset = total_lines - visible_height;
let can_scroll_up = self.scroll_offset < max_offset;
let can_scroll_down = self.scroll_offset > 0;
@ -227,6 +243,12 @@ impl LogCard {
}
fn build_title(&self) -> String {
if self.filtering {
return format!("Logs filter: {}_", self.filter);
}
if !self.filter.is_empty() {
return format!("Logs [filter: {}]", self.filter);
}
if !self.focused {
return "Logs".to_string();
}
@ -251,7 +273,8 @@ impl LogCard {
fn scroll_up(&mut self) {
let max_offset = self
.last_total_lines
.saturating_sub(self.last_visible_height);
.load(Ordering::Relaxed)
.saturating_sub(self.last_visible_height.load(Ordering::Relaxed));
self.scroll_offset = (self.scroll_offset + 1).min(max_offset);
}
@ -295,61 +318,92 @@ impl Element for LogCard {
self
}
fn render(&self, f: &mut Frame, area: Rect) {
fn render(&self, f: &mut Frame, area: Rect, context: &RenderContext<'_>) {
let entries = self.get_logs();
let inner_area = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
crate::controls::panel::render_panel(
f,
area,
&self.build_title(),
self.focused,
context.theme,
)
} else {
let block = Block::default()
.title(self.build_title())
.borders(self.borders)
.border_style(if self.focused {
Style::default().fg(Color::Yellow)
context.theme.logs.focused_border
} else {
Style::default()
context.theme.logs.border
});
let inner_area = block.inner(area);
let inner = block.inner(area);
f.render_widget(block, area);
inner
};
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;
}
let all_lines = self.build_all_lines(entries, inner_area.width as usize);
let total_lines = all_lines.len();
let visible_height = inner_area.height as usize;
self.last_width
.store(inner_area.width as usize, Ordering::Relaxed);
self.last_total_lines.store(total_lines, Ordering::Relaxed);
self.last_visible_height
.store(visible_height, Ordering::Relaxed);
let (start, end) = self.calculate_view_window(total_lines, visible_height);
let visible_lines = &all_lines[start..end];
let rendered_lines: Vec<Line> = visible_lines
.iter()
.map(|(line, prefix_color, is_error)| {
.map(|(line, source, is_error)| {
let mut spans = Vec::new();
let (prefix, rest) = Self::split_line_prefix(line);
let prefix = if matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
""
} else {
prefix
};
if !prefix.is_empty() {
spans.push(Span::styled(
prefix.to_string(),
Style::default().fg(*prefix_color),
source.style(context.theme),
));
}
let (content, timestamp) = Self::split_timestamp_suffix(rest);
let text_color = if *is_error { Color::Red } else { Color::White };
let text_style = if *is_error {
context.theme.logs.error
} else {
context.theme.logs.text
};
if !content.is_empty() {
spans.push(Span::styled(
content.to_string(),
Style::default().fg(text_color),
));
spans.push(Span::styled(content.to_string(), text_style));
}
if !timestamp.is_empty() {
spans.push(Span::styled(
timestamp.to_string(),
Style::default().fg(Color::DarkGray),
context.theme.logs.timestamp,
));
}
@ -367,7 +421,19 @@ impl Element for LogCard {
f.render_widget(Paragraph::new(line.clone()), line_area);
}
draw_block_joins(f, area, self.borders, self.joins);
if !matches!(context.theme.chrome, crate::theme::ChromeMode::Surfaces) {
draw_block_joins(
f,
area,
self.borders,
self.joins,
if self.focused {
context.theme.borders.focused
} else {
context.theme.borders.normal
},
);
}
}
}
@ -415,14 +481,44 @@ impl InteractableElement for LogCard {
}
fn interact(&mut self, key: KeyEvent) -> InteractionResult {
if self.filtering {
match key.code {
KeyCode::Esc => {
self.filtering = false;
self.filter.clear();
}
KeyCode::Enter => self.filtering = false,
KeyCode::Backspace => {
self.filter.pop();
}
KeyCode::Char(c)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
self.filter.push(c);
}
_ => {}
}
self.scroll_offset = 0;
return InteractionResult::Handled;
}
let entries = self.get_logs();
let estimated_width = 80usize;
let all_lines = self.build_all_lines(entries, estimated_width);
let width = self.last_width.load(Ordering::Relaxed).max(1);
let all_lines = self.build_all_lines(entries, width);
self.last_total_lines = all_lines.len();
let visible_height = self.last_visible_height.max(1);
self.last_total_lines
.store(all_lines.len(), Ordering::Relaxed);
let total_lines = all_lines.len();
let visible_height = self.last_visible_height.load(Ordering::Relaxed).max(1);
match key.code {
KeyCode::Char('/') => {
self.filtering = true;
self.filter.clear();
self.scroll_offset = 0;
InteractionResult::Handled
}
KeyCode::Enter | KeyCode::Char(' ') => {
self.selected = !self.selected;
InteractionResult::Handled
@ -456,8 +552,8 @@ impl InteractableElement for LogCard {
InteractionResult::Handled
}
KeyCode::Home => {
if self.last_total_lines > visible_height {
self.scroll_offset = self.last_total_lines - visible_height;
if total_lines > visible_height {
self.scroll_offset = total_lines - visible_height;
}
InteractionResult::Handled
}

View file

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

View file

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

View file

@ -2,7 +2,7 @@ use std::fmt::{Debug, Formatter};
use std::future::Future;
use std::pin::Pin;
use crate::gui::screens::screens::Screen;
use crate::screens::screens::{Screen, UiEvent};
#[allow(unused)]
pub enum InteractionResult {
@ -13,6 +13,9 @@ pub enum InteractionResult {
OpenFutureScreen {
screen: Pin<Box<dyn Future<Output = Box<dyn Screen>> + Send>>,
},
AppTask {
task: Pin<Box<dyn Future<Output = UiEvent> + Send>>,
},
Handled,
Unhandled,
}
@ -22,6 +25,7 @@ impl Debug for InteractionResult {
match self {
InteractionResult::OpenScreen { screen: _ } => write!(f, "OpenScreen"),
InteractionResult::OpenFutureScreen { screen: _ } => write!(f, "OpenFutureScreen"),
InteractionResult::AppTask { task: _ } => write!(f, "AppTask"),
InteractionResult::CloseScreen => write!(f, "CloseScreen"),
InteractionResult::Handled => write!(f, "Handled"),
InteractionResult::Unhandled => write!(f, "Unhandled"),
@ -36,6 +40,9 @@ impl PartialEq for InteractionResult {
InteractionResult::OpenScreen { screen: _ },
InteractionResult::OpenScreen { screen: _ },
) => true,
(InteractionResult::AppTask { task: _ }, InteractionResult::AppTask { task: _ }) => {
true
}
(
InteractionResult::OpenFutureScreen { screen: _ },
InteractionResult::OpenFutureScreen { screen: _ },

845
iota-cli/src/ipc_client.rs Normal file
View file

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

View file

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

View file

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

View file

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

View file

@ -5,16 +5,31 @@ pub mod elements {
pub mod log_card;
}
pub mod screens {
pub mod daemon_setup;
pub mod main_screen;
pub mod md_viewer;
pub mod metrics;
pub mod overview;
pub mod screens;
pub mod settings;
pub mod terms_checker;
pub mod terms_updater;
pub mod users;
}
pub mod util {
pub mod borders;
pub mod buttons;
pub mod terms_focus;
}
pub mod app_state;
pub mod controls;
pub mod help_overlay;
pub mod input_handler;
pub mod interaction_result;
pub mod ipc_client;
pub mod layout;
pub mod notification;
pub mod render_context;
pub mod theme;
pub mod ui;
pub use ui::TuiSession;

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
use crossterm::event::{self, Event, KeyCode, KeyEvent};
use crossterm::event::{self, Event, KeyCode};
use ratatui::{
DefaultTerminal,
prelude::*,
@ -7,11 +7,16 @@ use ratatui::{
};
use std::{any::Any, time::Duration};
use crate::gui::{interaction_result::InteractionResult, screens::screens::Screen};
use crate::{
interaction_result::InteractionResult,
render_context::RenderContext,
screens::screens::{HitMap, Screen, UiEvent},
theme::{ResolvedTheme, TextSemantics, ThemeName},
};
pub struct FileViewer {
title: String,
text: Vec<DisplayLine>,
content: String,
scroll: u16,
scroll_x: u16,
}
@ -24,11 +29,14 @@ impl Screen for FileViewer {
self
}
fn render(&self, f: &mut Frame, rect: Rect) {
self.draw(f, rect);
fn render(&self, f: &mut Frame, rect: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
self.draw(f, rect, context.theme);
}
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
let UiEvent::Key(event) = event else {
return InteractionResult::Unhandled;
};
match event.code {
KeyCode::Char('q') | KeyCode::Esc => {
return InteractionResult::CloseScreen;
@ -52,7 +60,7 @@ impl FileViewer {
pub fn new(title: String, content: &str) -> Self {
Self {
title,
text: parse_document(content.to_owned()),
content: content.to_owned(),
scroll: 0,
scroll_x: 0,
}
@ -62,7 +70,7 @@ impl FileViewer {
terminal
.draw(|f| {
let area = f.area();
self.draw(f, area);
self.draw(f, area, &crate::theme::resolve(ThemeName::Ansi));
})
.unwrap();
@ -77,12 +85,13 @@ impl FileViewer {
}
terminal
}
fn draw(&self, f: &mut Frame, area: Rect) {
fn draw(&self, f: &mut Frame, area: Rect, theme: &ResolvedTheme) {
use ratatui::text::Text;
let mut rendered_lines = Vec::new();
let text = parse_document(&self.content, theme);
for display_line in &self.text {
for display_line in &text {
if display_line.scrollable {
let content: String = display_line
.line
@ -153,7 +162,7 @@ impl FileViewer {
}
}
}
fn parse_document(input: String) -> Vec<DisplayLine> {
fn parse_document(input: &str, theme: &ResolvedTheme) -> Vec<DisplayLine> {
let mut lines_vec = Vec::new();
let mut in_code_block = false;
let liness: Vec<String> = input.lines().map(String::from).collect();
@ -172,7 +181,7 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
lines_vec.push(DisplayLine {
line: Line::from(Span::styled(
format!("────────{}────────", code),
Style::default().fg(Color::DarkGray),
theme.markdown.divider,
)),
scrollable: false,
});
@ -182,10 +191,7 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
if in_code_block {
lines_vec.push(DisplayLine {
line: Line::from(Span::styled(
raw.to_string(),
Style::default().fg(Color::Yellow),
)),
line: Line::from(Span::styled(raw.to_string(), theme.markdown.code)),
scrollable: false,
});
i += 1;
@ -195,9 +201,13 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
lines_vec.push(DisplayLine {
line: Line::from(Span::styled(
raw.trim_start_matches("### ").to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
theme.apply_text_semantics(
theme.markdown.heading,
TextSemantics {
bold: true,
underline: false,
},
),
)),
scrollable: false,
});
@ -208,9 +218,13 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
lines_vec.push(DisplayLine {
line: Line::from(Span::styled(
raw.trim_start_matches("## ").to_string(),
Style::default()
.fg(Color::LightCyan)
.add_modifier(Modifier::BOLD),
theme.apply_text_semantics(
theme.markdown.heading,
TextSemantics {
bold: true,
underline: false,
},
),
)),
scrollable: false,
});
@ -221,9 +235,13 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
lines_vec.push(DisplayLine {
line: Line::from(Span::styled(
raw.trim_start_matches("# ").to_string(),
Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::BOLD),
theme.apply_text_semantics(
theme.markdown.heading,
TextSemantics {
bold: true,
underline: false,
},
),
)),
scrollable: false,
});
@ -254,13 +272,13 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
}
let table = parse_table(&table_lines.iter().map(|s| s.as_str()).collect::<Vec<_>>());
lines_vec.extend(table_to_lines(table));
lines_vec.extend(table_to_lines(table, theme));
i = j;
continue;
}
lines_vec.push(DisplayLine {
line: Line::from(parse_inline(raw.as_str())),
line: Line::from(parse_inline(raw.as_str(), theme)),
scrollable: false,
});
i += 1;
@ -269,7 +287,7 @@ fn parse_document(input: String) -> Vec<DisplayLine> {
lines_vec
}
fn parse_inline(input: &str) -> Vec<Span<'static>> {
fn parse_inline(input: &str, theme: &ResolvedTheme) -> Vec<Span<'static>> {
let mut spans = Vec::new();
let mut buf = String::new();
@ -294,7 +312,11 @@ fn parse_inline(input: &str) -> Vec<Span<'static>> {
};
if let Some(kind) = toggle {
flush_span(&mut spans, &mut buf, current_style(bold, underline, code));
flush_span(
&mut spans,
&mut buf,
current_style(bold, underline, code, theme),
);
match kind {
"bold" => bold = !bold,
@ -308,24 +330,21 @@ fn parse_inline(input: &str) -> Vec<Span<'static>> {
buf.push(c);
}
flush_span(&mut spans, &mut buf, current_style(bold, underline, code));
flush_span(
&mut spans,
&mut buf,
current_style(bold, underline, code, theme),
);
spans
}
fn current_style(bold: bool, underline: bool, code: bool) -> Style {
let mut style = Style::default();
if bold {
style = style.add_modifier(Modifier::BOLD);
}
if underline {
style = style.add_modifier(Modifier::UNDERLINED);
}
if code {
style = style.fg(Color::Yellow);
}
style
fn current_style(bold: bool, underline: bool, code: bool, theme: &ResolvedTheme) -> Style {
let base = if code {
theme.markdown.code
} else {
theme.markdown.normal
};
theme.apply_text_semantics(base, TextSemantics { bold, underline })
}
#[derive(Clone)]
pub struct DisplayLine {
@ -333,7 +352,7 @@ pub struct DisplayLine {
scrollable: bool,
}
fn table_to_lines(table: Vec<Vec<String>>) -> Vec<DisplayLine> {
fn table_to_lines(table: Vec<Vec<String>>, theme: &ResolvedTheme) -> Vec<DisplayLine> {
if table.len() < 2 {
return vec![];
}
@ -377,7 +396,7 @@ fn table_to_lines(table: Vec<Vec<String>>) -> Vec<DisplayLine> {
.join("─┼─");
lines.push(DisplayLine {
line: Line::from(Span::styled(divider, Style::default().fg(Color::DarkGray))),
line: Line::from(Span::styled(divider, theme.markdown.divider)),
scrollable: true,
});
continue;
@ -403,11 +422,15 @@ fn table_to_lines(table: Vec<Vec<String>>) -> Vec<DisplayLine> {
}
let style = if row_idx == 0 {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
theme.apply_text_semantics(
theme.markdown.table_header,
TextSemantics {
bold: true,
underline: false,
},
)
} else {
Style::default().fg(Color::Green)
theme.markdown.table_text
};
lines.push(DisplayLine {

View file

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

View file

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

View file

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

View file

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

View file

@ -1,29 +1,32 @@
use crate::{
gui::{
controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line},
interaction_result::InteractionResult,
screens::{md_viewer::FileViewer, screens::Screen},
ui::UI,
},
terms::{
buttons::{checkbox, draw_buttons},
consent_state::UserChoice,
focus::Focus,
terms_getter::{Type, get_link, get_terms},
render_context::RenderContext,
screens::{
md_viewer::FileViewer,
screens::{HitMap, Screen, UiEvent},
},
util::{buttons::draw_buttons, terms_focus::Focus},
};
use crossterm::event::{KeyCode, KeyEvent};
use crossterm::event::KeyCode;
use iota_terms::{TermsType, get_link, get_terms};
use ratatui::{
Frame,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Paragraph},
};
use std::{any::Any, pin::Pin, sync::Arc};
use std::{any::Any, pin::Pin};
use tokio::sync::oneshot;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UserChoice {
Deny,
AcceptEULA,
AcceptAll,
}
pub struct TermsCheckerScreen {
ui: Arc<UI>,
sender: Option<oneshot::Sender<UserChoice>>,
eula: bool,
@ -34,9 +37,8 @@ pub struct TermsCheckerScreen {
}
impl TermsCheckerScreen {
pub fn new(ui: Arc<UI>, sender: Option<oneshot::Sender<UserChoice>>) -> Self {
pub fn new(sender: Option<oneshot::Sender<UserChoice>>) -> Self {
Self {
ui,
sender,
eula: false,
tos: false,
@ -54,7 +56,7 @@ impl Screen for TermsCheckerScreen {
self
}
fn render(&self, f: &mut Frame, size: Rect) {
fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
let mut needed_height = 5;
if size.height < 6 || size.width < 27 {
@ -92,19 +94,19 @@ impl Screen for TermsCheckerScreen {
height: content_height,
});
let eula_text = if size.width < 70 {
"EULA ¹ (https://legal.tensamin.net/eula/)"
"EULA ¹ (https://legal.methanium.net/tensamin/eula)"
} else {
"End User Licence Agreement ¹ (https://legal.tensamin.net/eula/)"
"End User Licence Agreement ¹ (https://legal.methanium.net/tensamin/eula)"
};
let tos_text = if size.width < 72 {
"ToS ² (https://legal.tensamin.net/terms-of-service/)"
"ToS ² (https://legal.methanium.net/tensamin/terms-of-service)"
} else {
"Terms of Service ² (https://legal.tensamin.net/terms-of-service/)"
"Terms of Service ² (https://legal.methanium.net/tensamin/terms-of-service)"
};
let pp_text = if size.width < 68 {
"PP ² (https://legal.tensamin.net/privacy-policy/)"
"PP ² (https://legal.methanium.net/tensamin/privacy-policy)"
} else {
"Privacy Policy ² (https://legal.tensamin.net/privacy-policy/)"
"Privacy Policy ² (https://legal.methanium.net/tensamin/privacy-policy)"
};
let (mut optional_lines, agree_lines): (Vec<i16>, Vec<&str>) = if size.width > 143 {
@ -164,9 +166,36 @@ impl Screen for TermsCheckerScreen {
)
};
let mut text_lines = vec![
checkbox(eula_text, self.eula, self.focus == Focus::Eula, true),
checkbox(tos_text, self.tos, self.focus == Focus::Tos, self.eula),
checkbox(pp_text, self.pp, self.focus == Focus::Pp, self.eula),
render_choice_line(
eula_text,
ChoiceKind::Checkbox,
ChoiceVisualState {
selected: self.eula,
focused: self.focus == Focus::Eula,
enabled: true,
},
context.theme,
),
render_choice_line(
tos_text,
ChoiceKind::Checkbox,
ChoiceVisualState {
selected: self.tos,
focused: self.focus == Focus::Tos,
enabled: self.eula,
},
context.theme,
),
render_choice_line(
pp_text,
ChoiceKind::Checkbox,
ChoiceVisualState {
selected: self.pp,
focused: self.focus == Focus::Pp,
enabled: self.eula,
},
context.theme,
),
Line::from(""),
Line::from("¹ Necessary required to run the program"),
Line::from("² Optional required only for Tensamin services"),
@ -186,19 +215,19 @@ impl Screen for TermsCheckerScreen {
if size.width < 60 || size.height < needed_height as u16 {
let width_style = if size.width > 76 {
Style::default().fg(Color::Green)
context.theme.status.success
} else if size.width >= 60 {
Style::default().fg(Color::Yellow)
context.theme.status.warning
} else {
Style::default().fg(Color::Red)
context.theme.status.error
};
let height_style = if size.height > 19 {
Style::default().fg(Color::Green)
context.theme.status.success
} else if size.height >= 13 {
Style::default().fg(Color::Yellow)
context.theme.status.warning
} else {
Style::default().fg(Color::Red)
context.theme.status.error
};
let warning_text = Text::from(vec![
@ -240,10 +269,14 @@ impl Screen for TermsCheckerScreen {
true,
false,
true,
context.theme,
);
}
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
let UiEvent::Key(event) = event else {
return InteractionResult::Unhandled;
};
let mut possible_states = vec![Focus::Eula, Focus::Tos, Focus::Pp, Focus::Cancel];
if self.eula {
@ -276,19 +309,27 @@ impl Screen for TermsCheckerScreen {
}
KeyCode::Char('o') | KeyCode::Char('O') => {
let terms_type = match self.focus {
Focus::Eula => Some(Type::EULA),
Focus::Tos => Some(Type::TOS),
Focus::Pp => Some(Type::PP),
Focus::Eula => Some(TermsType::EULA),
Focus::Tos => Some(TermsType::TOS),
Focus::Pp => Some(TermsType::PP),
_ => None,
};
if let Some(terms_type) = terms_type {
let fut: Pin<Box<dyn Future<Output = Box<dyn Screen>> + Send>> =
Box::pin(async move {
let content = get_terms(terms_type.clone()).await.unwrap();
let fut: Pin<Box<dyn Future<Output = Box<dyn Screen>> + Send>> = Box::pin(
async move {
if let Some(content) = get_terms(terms_type.clone()).await {
let screen: FileViewer =
FileViewer::new(terms_type.to_string(), &content);
Box::new(screen) as Box<dyn Screen>
});
} else {
let screen: FileViewer = FileViewer::new(
"Error".to_string(),
"Could not connect to the legal endpoint to fetch the document. Please check your internet connection.",
);
Box::new(screen) as Box<dyn Screen>
}
},
);
InteractionResult::OpenFutureScreen { screen: fut }
} else {
InteractionResult::Unhandled
@ -296,15 +337,15 @@ impl Screen for TermsCheckerScreen {
}
KeyCode::Char('l') | KeyCode::Char('L') => match self.focus {
Focus::Eula => {
let _ = open::that(get_link(Type::EULA));
let _ = open::that(get_link(TermsType::EULA));
InteractionResult::Handled
}
Focus::Tos => {
let _ = open::that(get_link(Type::TOS));
let _ = open::that(get_link(TermsType::TOS));
InteractionResult::Handled
}
Focus::Pp => {
let _ = open::that(get_link(Type::PP));
let _ = open::that(get_link(TermsType::PP));
InteractionResult::Handled
}
_ => InteractionResult::Unhandled,

View file

@ -1,28 +1,33 @@
use crate::screens::terms_checker::UserChoice;
use crate::{
gui::{
controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line},
interaction_result::InteractionResult,
screens::{md_viewer::FileViewer, screens::Screen},
},
terms::{
buttons::{checkbox, draw_buttons},
consent_state::{UpdateDecision, UserChoice},
doc::Doc,
focus::Focus,
terms_getter::{Type, get_newest_link, get_terms},
render_context::RenderContext,
screens::{
md_viewer::FileViewer,
screens::{HitMap, Screen, UiEvent},
},
util::{buttons::draw_buttons, terms_focus::Focus},
};
use chrono::{Local, TimeZone, Utc};
use crossterm::event::{KeyCode, KeyEvent};
use crossterm::event::KeyCode;
use iota_terms::{Doc, TermsType, get_newest_link, get_terms};
use ratatui::{
Frame,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Paragraph},
};
use std::any::Any;
use tokio::sync::oneshot;
#[derive(Debug, Clone)]
pub enum UpdateDecision {
NoChange,
Future { newest: Doc },
Forced(Doc),
}
pub struct TermsUpdaterScreen {
sender: Option<oneshot::Sender<UserChoice>>,
@ -119,7 +124,19 @@ impl Screen for TermsUpdaterScreen {
self
}
fn render(&self, f: &mut Frame, size: Rect) {
fn render(&self, f: &mut Frame, size: Rect, context: &RenderContext<'_>, _hits: &mut HitMap) {
let checkbox = |label, selected, focused, enabled| {
render_choice_line(
label,
ChoiceKind::Checkbox,
ChoiceVisualState {
selected,
focused,
enabled,
},
context.theme,
)
};
let mut needed_height = 5;
if size.height < 6 || size.width < 27 {
@ -184,7 +201,7 @@ impl Screen for TermsUpdaterScreen {
if self.eula_future {
if size.width < 80 {
text_lines.push(checkbox(
"EULA ¹³ (https://legal.tensamin.net/eula/newest/)",
"EULA ¹³ (https://legal.methanium.net/tensamin/eula)",
self.eula,
self.focus == Focus::Eula,
true,
@ -197,7 +214,7 @@ impl Screen for TermsUpdaterScreen {
text_lines.push(Line::from(format!(" Goes into effect on {}", date)));
} else {
text_lines.push(checkbox(
"End User Licence Agreement ¹³ (https://legal.tensamin.net/eula/newest/)",
"End User Licence Agreement ¹³ (https://legal.methanium.net/tensamin/eula)",
self.eula,
self.focus == Focus::Eula,
true,
@ -212,14 +229,14 @@ impl Screen for TermsUpdaterScreen {
} else {
if size.width < 80 {
text_lines.push(checkbox(
"EULA ¹ (https://legal.tensamin.net/eula/newest/)",
"EULA ¹ (https://legal.methanium.net/tensamin/eula)",
self.eula,
self.focus == Focus::Eula,
true,
));
} else {
text_lines.push(checkbox(
"End User Licence Agreement ¹ (https://legal.tensamin.net/eula/newest/)",
"End User Licence Agreement ¹ (https://legal.methanium.net/tensamin/eula)",
self.eula,
self.focus == Focus::Eula,
true,
@ -233,7 +250,7 @@ impl Screen for TermsUpdaterScreen {
if self.tos_future {
if size.width < 80 {
text_lines.push(checkbox(
"ToS ²³ (https://legal.tensamin.net/tos/newest/)",
"ToS ²³ (https://legal.methanium.net/tensamin/terms-of-service)",
self.tos,
self.focus == Focus::Tos,
self.eula,
@ -246,7 +263,7 @@ impl Screen for TermsUpdaterScreen {
text_lines.push(Line::from(format!(" Goes into effect on {}", date)));
} else {
text_lines.push(checkbox(
"Terms of Service ²³ (https://legal.tensamin.net/terms-of-service/newest/)",
"Terms of Service ²³ (https://legal.methanium.net/tensamin/terms-of-service)",
self.tos,
self.focus == Focus::Tos,
self.eula,
@ -261,14 +278,14 @@ impl Screen for TermsUpdaterScreen {
} else {
if size.width < 80 {
text_lines.push(checkbox(
"ToS ² (https://legal.tensamin.net/tos/newest/)",
"ToS ² (https://legal.methanium.net/tensamin/terms-of-service)",
self.tos,
self.focus == Focus::Tos,
self.eula,
));
} else {
text_lines.push(checkbox(
"Terms of Service ² (https://legal.tensamin.net/terms-of-service/newest/)",
"Terms of Service ² (https://legal.methanium.net/tensamin/terms-of-service)",
self.tos,
self.focus == Focus::Tos,
self.eula,
@ -282,7 +299,7 @@ impl Screen for TermsUpdaterScreen {
if self.pp_future {
if size.width < 80 {
text_lines.push(checkbox(
"PP ²³ (https://legal.tensamin.net/privacy-policy/newest/)",
"PP ²³ (https://legal.methanium.net/tensamin/privacy-policy)",
self.pp,
self.focus == Focus::Pp,
self.eula,
@ -295,7 +312,7 @@ impl Screen for TermsUpdaterScreen {
text_lines.push(Line::from(format!(" Goes into effect on {}", date)));
} else {
text_lines.push(checkbox(
"Privacy Policy ²³ (https://legal.tensamin.net/privacy-policy/newest/)",
"Privacy Policy ²³ (https://legal.methanium.net/tensamin/privacy-policy)",
self.pp,
self.focus == Focus::Pp,
self.eula,
@ -310,14 +327,14 @@ impl Screen for TermsUpdaterScreen {
} else {
if size.width < 80 {
text_lines.push(checkbox(
"PP ² (https://legal.tensamin.net/privacy-policy/newest/)",
"PP ² (https://legal.methanium.net/tensamin/privacy-policy)",
self.pp,
self.focus == Focus::Pp,
self.eula,
));
} else {
text_lines.push(checkbox(
"Privacy Policy ² (https://legal.tensamin.net/privacy-policy/newest/)",
"Privacy Policy ² (https://legal.methanium.net/tensamin/privacy-policy)",
self.pp,
self.focus == Focus::Pp,
self.eula,
@ -519,19 +536,19 @@ impl Screen for TermsUpdaterScreen {
};
if size.width < 60 || size.height < needed_height as u16 {
let width_style = if size.width > 76 {
Style::default().fg(Color::Green)
context.theme.status.success
} else if size.width >= 60 {
Style::default().fg(Color::Yellow)
context.theme.status.warning
} else {
Style::default().fg(Color::Red)
context.theme.status.error
};
let height_style = if size.height > 20 {
Style::default().fg(Color::Green)
context.theme.status.success
} else if size.height >= (header_lines as u16 + 10) {
Style::default().fg(Color::Yellow)
context.theme.status.warning
} else {
Style::default().fg(Color::Red)
context.theme.status.error
};
let warning_text = Text::from(vec![
@ -575,10 +592,14 @@ impl Screen for TermsUpdaterScreen {
self.update_needed,
downgrade_scenario,
self.pp_needed || self.tos_needed,
context.theme,
);
}
fn handle_input(&mut self, event: KeyEvent) -> InteractionResult {
fn handle_event(&mut self, event: UiEvent) -> InteractionResult {
let UiEvent::Key(event) = event else {
return InteractionResult::Unhandled;
};
let mut possible_states = Vec::new();
if self.eula_needed {
@ -644,17 +665,23 @@ impl Screen for TermsUpdaterScreen {
},
KeyCode::Char('o') | KeyCode::Char('O') => {
let terms_type = match self.focus {
Focus::Eula => Some(Type::EULA),
Focus::Tos => Some(Type::TOS),
Focus::Pp => Some(Type::PP),
Focus::Eula => Some(TermsType::EULA),
Focus::Tos => Some(TermsType::TOS),
Focus::Pp => Some(TermsType::PP),
_ => None,
};
if let Some(terms_type) = terms_type {
let fut = Box::pin(async move {
let content = get_terms(terms_type.clone()).await.unwrap();
if let Some(content) = get_terms(terms_type.clone()).await {
Box::new(FileViewer::new(terms_type.to_string(), &content))
as Box<dyn Screen>
} else {
Box::new(FileViewer::new(
"Error".to_string(),
"Could not connect to the legal endpoint to fetch the document. Please check your internet connection.",
)) as Box<dyn Screen>
}
});
return InteractionResult::OpenFutureScreen { screen: fut };
@ -664,15 +691,15 @@ impl Screen for TermsUpdaterScreen {
}
KeyCode::Char('l') | KeyCode::Char('L') => match self.focus {
Focus::Eula => {
let _ = open::that(get_newest_link(Type::EULA));
let _ = open::that(get_newest_link(TermsType::EULA));
InteractionResult::Handled
}
Focus::Tos => {
let _ = open::that(get_newest_link(Type::TOS));
let _ = open::that(get_newest_link(TermsType::TOS));
InteractionResult::Handled
}
Focus::Pp => {
let _ = open::that(get_newest_link(Type::PP));
let _ = open::that(get_newest_link(TermsType::PP));
InteractionResult::Handled
}
_ => InteractionResult::Unhandled,

View file

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

View file

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

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

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

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

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

View file

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

View file

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

0
iota-cli/src/tui.rs Normal file
View file

728
iota-cli/src/ui.rs Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

18
iota-core/Cargo.toml Normal file
View file

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

View file

@ -1,71 +1,79 @@
use tokio::sync::oneshot;
use crate::{
gui::{
screens::{terms_checker::TermsCheckerScreen, terms_updater::TermsUpdaterScreen},
ui::UI,
},
terms::{
doc::Doc,
terms_getter::{Type, get_current_docs, get_newest_docs},
},
util::file_util::{load_file, save_file},
};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
pub async fn check(ui: Arc<UI>) -> (bool, bool) {
use iota_cli::screens::terms_checker::{TermsCheckerScreen, UserChoice};
use iota_cli::ui::UI;
use iota_terms::{Doc, TermsType as Type, get_current_docs};
use iota_util::file_util::{load_file, save_file};
use tokio::sync::oneshot;
pub async fn check(ui: Arc<UI>) -> Result<(bool, bool), String> {
let mut state = ConsentState::load_state();
if ensure_initial_consent(ui.clone(), &mut state)
.await
.is_err()
{
return (false, false);
}
if ensure_updates(ui, &mut state).await.is_err() {
return (false, false);
};
ensure_initial_consent(ui.clone(), &mut state).await?;
/*
* The raw legal endpoint exposes only the current document. Restore this
* flow when it provides future versions that users can accept early.
*/
// ensure_updates(ui, &mut state).await?;
state = state.sanitize();
state.save_state();
(state.accepted_eula, state.accepted_tos && state.accepted_pp)
Ok((state.accepted_eula, state.accepted_tos && state.accepted_pp))
}
async fn ensure_initial_consent(ui: Arc<UI>, state: &mut ConsentState) -> Result<(), ()> {
if state.accepted_eula {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NonInteractiveConsent {
Accepted,
RequiresInteractiveAcceptance,
}
pub fn non_interactive_consent() -> NonInteractiveConsent {
let state = ConsentState::load_state();
if state.accepted_eula && state.accepted_tos && state.accepted_pp {
NonInteractiveConsent::Accepted
} else {
NonInteractiveConsent::RequiresInteractiveAcceptance
}
}
async fn ensure_initial_consent(ui: Arc<UI>, state: &mut ConsentState) -> Result<(), String> {
if state.accepted_eula && state.accepted_tos && state.accepted_pp {
return Ok(());
}
let (current_eula, current_tos, current_privacy) = get_current_docs().await.ok_or_else(|| {
"Could not connect to the legal endpoint to fetch the current agreements. Please check your internet connection.".to_string()
})?;
let (tx, rx) = oneshot::channel();
ui.set_screen(Box::new(TermsCheckerScreen::new(ui.clone(), Some(tx))))
ui.set_screen(Box::new(TermsCheckerScreen::new(Some(tx))))
.await;
let result = rx.await.unwrap_or(UserChoice::Deny);
match result {
UserChoice::AcceptEULA | UserChoice::AcceptAll => {
if let Some((eula, tos, privacy)) = get_current_docs().await {
state.accepted_eula = true;
state.eula = Some(eula);
state.eula = Some(current_eula);
if matches!(result, UserChoice::AcceptAll) {
state.accepted_tos = true;
state.accepted_pp = true;
state.tos = Some(tos);
state.privacy = Some(privacy);
}
state.tos = Some(current_tos);
state.privacy = Some(current_privacy);
}
let _ = &state.save_state();
Ok(())
}
UserChoice::Deny => Err(()),
UserChoice::Deny => Ok(()),
}
}
async fn ensure_updates(ui: Arc<UI>, state: &mut ConsentState) -> Result<(), ()> {
/*
async fn ensure_updates(ui: Arc<UI>, state: &mut ConsentState) -> Result<(), String> {
let Some((eula_update, tos_update, privacy_update)) = get_updates().await else {
return Ok(());
};
@ -96,7 +104,9 @@ async fn ensure_updates(ui: Arc<UI>, state: &mut ConsentState) -> Result<(), ()>
UserChoice::AcceptEULA => {
state.accepted_eula = true;
}
UserChoice::Deny => return Err(()),
UserChoice::Deny => {
return Err("Consent update was denied for a mandatory document.".to_string());
}
}
} else {
apply_future_updates(state, result, eula_update, tos_update, privacy_update);
@ -105,6 +115,8 @@ async fn ensure_updates(ui: Arc<UI>, state: &mut ConsentState) -> Result<(), ()>
state.save_state();
Ok(())
}
*/
/*
fn apply_future_updates(
state: &mut ConsentState,
result: UserChoice,
@ -215,20 +227,7 @@ async fn get_updates() -> Option<(
None
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum UserChoice {
Deny,
AcceptEULA,
AcceptAll,
}
#[derive(Debug, Clone, PartialEq)]
pub enum UpdateDecision {
NoChange,
Future { newest: Doc },
Forced(Doc),
}
*/
#[derive(Debug, Clone)]
pub struct ConsentState {
@ -303,7 +302,7 @@ impl ConsentState {
if let Some(eula) = &self.eula {
file_out.push_str(&format!("\
\n\"EULA=true\" indicates that you read, understood and accepted Tensamin's End User Licence agreement. You can find our EULA at https://legal.tensamin.net/eula/\
\n\"EULA=true\" indicates that you read, understood and accepted Tensamin's End User Licence agreement. You can find our EULA at https://legal.methanium.net/tensamin/eula\
\nEULA={}\
\nEULA-VERSION={}\
\nEULA-HASH={}\
@ -313,7 +312,7 @@ impl ConsentState {
&& let Some(tos) = &self.tos
{
file_out.push_str(&format!("\
\n\"Terms-of-Service=true\" indicates that you read, understood and accepted Tensamin's Terms of Service. You can find our Terms of Serivce at https://legal.tensamin.net/tos/\
\n\"Terms-of-Service=true\" indicates that you read, understood and accepted Tensamin's Terms of Service. You can find our Terms of Serivce at https://legal.methanium.net/tensamin/terms-of-service\
\nTerms-of-Service={}\
\nTerms-of-Service-VERSION={}\
\nTerms-of-Service-HASH={}\
@ -323,7 +322,7 @@ impl ConsentState {
&& let Some(pp) = &self.privacy
{
file_out.push_str(&format!("\
\n\"Privacy-Policy=true\" indicates that you read, understood and accepted Tensamin's Privacy Policy. You can find our Privacy Policy at https://legal.tensamin.net/privacy/\
\n\"Privacy-Policy=true\" indicates that you read, understood and accepted Tensamin's Privacy Policy. You can find our Privacy Policy at https://legal.methanium.net/tensamin/privacy-policy\
\nPrivacy-Policy={}\
\nPrivacy-Policy-VERSION={}\
\nPrivacy-Policy-HASH={}\
@ -331,7 +330,7 @@ impl ConsentState {
}
} else {
file_out.push_str("\
\n\"EULA=true\" indicates that you read, understood and accepted Tensamin's End User Licence Agreement. You can find Tensamin's EULA at https://legal.tensamin.net/eula/\
\n\"EULA=true\" indicates that you read, understood and accepted Tensamin's End User Licence Agreement. You can find Tensamin's EULA at https://legal.methanium.net/tensamin/eula\
\nEULA=false\
");
}

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

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

View file

@ -1,55 +1,62 @@
use dashmap::DashSet;
use once_cell::sync::Lazy;
mod consent_state;
use iota_updater::check_update;
use pnet::datalink::NetworkInterface;
use std::sync::Arc;
use std::sync::LazyLock;
use std::sync::Mutex;
use tokio::sync::RwLock;
use tokio::time::{Duration, sleep};
mod auth;
mod gui;
mod langu;
mod omikron;
mod terms;
mod users;
mod util;
use iota_state::{AppState, DaemonState};
use crate::gui::app_state;
use crate::gui::app_state::AppState;
use crate::gui::screens::main_screen::MainScreen;
use crate::gui::ui::start_tui;
use crate::langu::language_creator;
use crate::omikron::omikron_connection::OmikronConnection;
use crate::terms::consent_state;
use crate::users::user_manager;
use crate::util::config_util::CONFIG;
use crate::util::file_util::download_and_extract_zip;
use crate::util::file_util::has_dir;
use crate::util::logger;
pub static APP_STATE: LazyLock<Arc<Mutex<AppState>>> =
LazyLock::new(|| Arc::new(Mutex::new(AppState::new())));
pub static SHUTDOWN: Lazy<RwLock<bool>> = Lazy::new(|| RwLock::new(false));
pub static RELOAD: Lazy<RwLock<bool>> = Lazy::new(|| RwLock::new(true));
pub static ACTIVE_TASKS: Lazy<DashSet<String>> = Lazy::new(|| DashSet::new());
use iota_cli::screens::main_screen::MainScreen;
use iota_cli::{ipc_client::IpcClient, ui::start_tui};
use iota_logger::{self as logger, language_creator};
use iota_logger::{log, log_t};
use iota_storage::users::user_manager;
use iota_storage::util::config_util::CONFIG;
use iota_util::file_util::{download_and_extract_zip, has_dir};
use std::sync::Arc;
#[tokio::main(flavor = "multi_thread", worker_threads = 16)]
#[allow(unused_must_use, dead_code)]
#[allow(unused_must_use, dead_code, unused_assignments)]
async fn main() {
while *RELOAD.read().await {
*RELOAD.write().await = false;
*SHUTDOWN.write().await = false;
let state = Arc::new(DaemonState::new());
let ui = start_tui();
while *state.reload.read().await {
*state.reload.write().await = false;
*state.shutdown.write().await = false;
let (eula, tos_pp) = consent_state::check(ui.clone()).await;
let socket = match iota_paths::IotaPaths::resolve(iota_paths::Scope::User)
.expect("resolve Iota user paths")
.ipc_endpoint
{
iota_paths::IpcEndpoint::UnixSocket(path) => path,
iota_paths::IpcEndpoint::WindowsPipe(_) => {
panic!("Windows IPC client transport is not implemented yet")
}
};
let ipc = IpcClient::connect(socket)
.await
.expect("iota-daemon must be running before starting iota-core");
let session = start_tui(ipc).expect("interactive terminal initialization failed");
let ui = session.ui();
let (eula, tos_pp) = match consent_state::check(ui.clone()).await {
Ok(v) => v,
Err(e) => {
*state.shutdown.write().await = true;
loop {
if state.active_tasks.is_empty() {
break;
}
sleep(Duration::from_millis(100)).await;
}
println!("{}", e);
return;
}
};
if !eula {
*SHUTDOWN.write().await = true;
*state.shutdown.write().await = true;
loop {
if ACTIVE_TASKS.is_empty() {
if state.active_tasks.is_empty() {
break;
}
sleep(Duration::from_millis(100)).await;
@ -59,9 +66,9 @@ async fn main() {
return;
}
if !tos_pp {
*SHUTDOWN.write().await = true;
*state.shutdown.write().await = true;
loop {
if ACTIVE_TASKS.is_empty() {
if state.active_tasks.is_empty() {
break;
}
sleep(Duration::from_millis(100)).await;
@ -73,7 +80,8 @@ async fn main() {
println!("You can find this at 'agreements'!");
return;
}
app_state::setup();
check_update();
iota_state::setup(&state);
let main_screen = MainScreen::new(ui.clone()).await;
ui.set_screen(Box::new(main_screen)).await;
@ -88,12 +96,15 @@ async fn main() {
logger::startup();
// BASIC CONFIGURATION
&CONFIG.write().await.load();
iota_storage::util::config_util::load_config();
// USER MANAGEMENT
if let Err(_) = user_manager::load_users().await {
if let Err(_) = user_manager::load_users_sync() {
log_t!("user_load_failed");
}
if let Err(e) = iota_storage::util::settings::migrate_legacy_files() {
log!("Failed to migrate legacy settings: {}", e);
}
let mut sb = "".to_string();
@ -108,7 +119,11 @@ async fn main() {
}
log!(
"IOTA ID: {}",
CONFIG.read().await.get_iota_id().to_string()
CONFIG
.load()
.iota_id
.map(|id| id.to_string())
.unwrap_or_else(|| "N/A".to_string())
);
log!("User IDS: {}", sb);
@ -127,29 +142,18 @@ async fn main() {
sb1 = sb1 + ",";
}
log!("Community IDS: {}", sb1); */
let port = CONFIG.read().await.get_port();
let mut ip = "0.0.0.0".to_string();
let port = CONFIG.load().port;
let mut _ip = "0.0.0.0".to_string();
for iface in pnet::datalink::interfaces() {
let iface: NetworkInterface = iface;
if iface.ips.len() > 0 {
let ipsv = format!("{}", iface.ips[0]);
let ips: &str = ipsv.split('/').next().unwrap_or("");
if format!("{}", ips).starts_with("10.") || format!("{}", ips).starts_with("192.") {
ip = ips.to_string();
_ip = ips.to_string();
}
}
}
/* Community port activation is used for activating the port for communities.
* Code is currently commented because communities have not been implemented yet.
if start(port).await {
log_t!("community_active", ip, port.to_string());
} else {
if port < 1024 {
log_t!("community_start_error_admin", port.to_string());
} else {
log_t!("community_start_error", port.to_string());
}
} */
if !has_dir("web") {
download_and_extract_zip(
"https://omega.tensamin.net/api/download/iota_frontend",
@ -157,30 +161,30 @@ async fn main() {
)
.await;
}
let _ = omikron::omikron_connection::get_omikron_connection().await;
if !web_server::start(port).await {
log!("Failed to start the MTP web server on port {}", port);
}
log_t!("setup_completed");
loop {
if *SHUTDOWN.read().await {
if *state.shutdown.read().await {
break;
}
sleep(Duration::from_millis(100)).await;
sleep(Duration::from_millis(500)).await;
}
if *RELOAD.read().await {
if *state.reload.read().await {
loop {
if ACTIVE_TASKS.is_empty() {
if state.active_tasks.is_empty() {
break;
}
sleep(Duration::from_secs(1)).await;
}
&CONFIG.write().await.clear();
iota_storage::util::config_util::clear_config();
user_manager::clear();
// Commhnities have not been implemented yet.
/*community_manager::clear();*/
*APP_STATE.lock().unwrap() = AppState::new();
*state.app.lock().unwrap() = AppState::new();
}
ui.terminal.lock().unwrap().clear();
ui.terminal.lock().unwrap().flush();
let _ = session.shutdown().await;
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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