Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec3f5e6a6e |
||
|
|
dd69b5bd97 |
||
|
|
afc1832fb7 |
||
|
|
3bfec96848 |
||
|
|
29d46d1c95 |
||
|
|
4caa6bb3e9 |
||
|
|
8160f8d0cb |
||
|
ad8555bc6e |
|||
|
|
e1dd86ec02 |
||
|
7dc98ef29b |
|||
|
326ebf3b37 |
|||
|
5a31055d61 |
|||
|
45da43805a |
|||
| 4cac2d0151 | |||
| e4a33578eb | |||
| dde495ded9 | |||
| 7a0746893c | |||
| a8ea3fc9e5 | |||
| 27ef801226 | |||
| a3815b30c9 | |||
| e95974b082 | |||
| 4e430178a1 | |||
| 1cd067eb18 | |||
| 8b771ce351 | |||
| ddc2e05e67 | |||
| 50922fadfd | |||
| 5d1bc35659 | |||
| 2a6aa64795 | |||
| 0949bcc0b8 | |||
| 6dc4d70416 | |||
| 6b8d65cd4d | |||
|
d184fcb163 |
|||
|
6aef7d3c09 |
|||
|
1de479ce8d |
|||
|
baf191a40b |
|||
|
7399cf8fc3 |
|||
|
471f539116 |
|||
|
a97092d653 |
|||
|
14df716cf1 |
|||
|
6b4c144b73 |
|||
|
009173a97d |
|||
|
1744357350 |
|||
|
6a535099bb |
|||
|
f00992d4a5 |
|||
|
8cd1637dae |
|||
|
d9cfceacc7 |
|||
|
adfd1459b3 |
|||
|
|
3bc5cc959a | ||
|
|
8b158108bb | ||
|
|
56aad3a023 | ||
|
|
36a70e82a0 | ||
|
|
f82500ea7d | ||
|
|
3be1d9f308 | ||
|
|
9b20d159b5 | ||
|
|
b9e0df1394 | ||
|
|
accf36ac38 | ||
| ecc74d493f | |||
| 846746223e | |||
|
|
82cd1e26f0 | ||
| b3311aa456 | |||
|
|
1e0d2b9691 | ||
| f81e366a31 | |||
| 25caeb852e | |||
|
|
77711d7811 | ||
|
|
08d4817cea | ||
| caa0572c3a | |||
|
|
f304e1df65 | ||
|
|
ee0202d56a | ||
|
|
bc43ee43e4 | ||
|
|
5625c5db5f | ||
| f252724a43 | |||
| cf6201fe18 | |||
| af96da10a9 | |||
| d2c91a3f0d | |||
| c39fb8753b | |||
| e77ae0e1e4 | |||
| 47df5e0098 | |||
| ac9a7cea27 | |||
| ab82762831 | |||
| d0ca9f9c78 | |||
| fcaa7d1d44 | |||
| 3aafa364ef | |||
| 1373a4f284 | |||
| 07bd232c1c | |||
| 190926702b | |||
|
|
4690d2e408 | ||
|
|
d3722a917b | ||
|
|
25fc07d45d | ||
|
|
ec7db39c5d | ||
|
|
b2a22456ff | ||
| d517144562 | |||
| 9e948ce6cf | |||
|
|
a4ec766351 | ||
|
|
2b774a45f9 | ||
|
|
2193583f00 | ||
|
|
16749bafb3 | ||
|
|
b910178f35 | ||
|
|
e07ea9ea7e | ||
|
|
6b351ec9d2 | ||
| 63e68277d5 | |||
| 681db3f511 | |||
| c04e70ef85 | |||
| 9b99d95307 | |||
| 8941510c95 | |||
| 41deb0d6de | |||
|
|
4114cf6da7 | ||
|
|
9d7438d1cb | ||
| e18e8d1151 | |||
| b7f6ccf8a7 | |||
| 5d5585cb7f | |||
| 045e2ff15c | |||
| 83bd05b791 | |||
| f25815aa1b | |||
| 7313c4af99 | |||
| 0b3efa2f61 | |||
|
|
52c9c31c1a | ||
|
|
3ad5d2b3ab | ||
|
|
91d0e9ab76 | ||
|
|
e5360088eb | ||
|
|
3274471695 | ||
|
|
bfb238d7a0 | ||
|
|
7873f33d23 | ||
|
|
401d4890dc | ||
|
|
3b218411d0 | ||
|
|
39b6351c85 | ||
|
|
77e5072f20 | ||
|
|
caaf450487 | ||
|
|
a60ea3d5d2 | ||
|
|
eff1c019a8 | ||
|
|
17929bdad3 | ||
|
|
b37ec732f7 | ||
| 6247502807 | |||
|
|
3c11800af5 | ||
|
|
8df387bea5 | ||
| c6fa1a01da | |||
|
|
d79a625e32 | ||
|
|
1e714d1c2d | ||
|
|
17f1653f87 | ||
|
|
d7f79d2f6e | ||
|
|
c8a6d4104e | ||
|
|
1ea0b97f6d | ||
|
|
05763e7dd3 | ||
|
|
71ac8d97fa | ||
|
|
3cdf7c62d5 |
223 changed files with 37863 additions and 7152 deletions
2
.cargo/config.toml
Normal file
2
.cargo/config.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[env]
|
||||
MTP_TYPE_MAPS = { value = "mtp-type-maps/type-maps.yaml", relative = true }
|
||||
130
.forgejo/workflows/release.yml
Normal file
130
.forgejo/workflows/release.yml
Normal 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"
|
||||
'
|
||||
23
.github/workflows/build.yml
vendored
23
.github/workflows/build.yml
vendored
|
|
@ -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
7
.gitignore
vendored
|
|
@ -1,2 +1,9 @@
|
|||
target
|
||||
logs
|
||||
agreements
|
||||
languages/
|
||||
.envrc
|
||||
.direnv
|
||||
config.json
|
||||
*.mk
|
||||
*.sqlite*
|
||||
|
|
|
|||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal 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
3022
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
80
Cargo.toml
80
Cargo.toml
|
|
@ -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
25
LICENSE
|
|
@ -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.
|
||||
|
|
|
|||
62
README.md
62
README.md
|
|
@ -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
14
client/Cargo.toml
Normal 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"] }
|
||||
497
client/src/client_connection.rs
Normal file
497
client/src/client_connection.rs
Normal 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
2
client/src/lib.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
mod client_connection;
|
||||
pub use client_connection::ClientConnection;
|
||||
5236
communities/Cargo.lock
generated
Normal file
5236
communities/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
16
communities/Cargo.toml
Normal file
16
communities/Cargo.toml
Normal 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 = "*" }
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,121 +1,120 @@
|
|||
use crate::communities::{community::Community, interactables::interactable::Interactable};
|
||||
use async_trait::async_trait;
|
||||
use json::JsonValue;
|
||||
use std::any::Any;
|
||||
use std::sync::Arc;
|
||||
use ttp_core::CommunicationValue;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct Category {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
path: String,
|
||||
community: Arc<Community>,
|
||||
children: Vec<Arc<Box<dyn Interactable>>>,
|
||||
}
|
||||
impl Category {
|
||||
pub fn new() -> Category {
|
||||
Category {
|
||||
id: Uuid::new_v4(),
|
||||
name: String::new(),
|
||||
path: String::new(),
|
||||
community: Arc::new(Community::new()),
|
||||
children: Vec::new(),
|
||||
}
|
||||
}
|
||||
pub fn get_child(&self, path: String, name: String) -> Option<Arc<Box<dyn Interactable>>> {
|
||||
if path.is_empty() {
|
||||
self.children
|
||||
.iter()
|
||||
.find(|child| child.get_name() == &name)
|
||||
.cloned()
|
||||
} else {
|
||||
let sub_module = path.split("/").next().unwrap();
|
||||
let next = self
|
||||
.children
|
||||
.iter()
|
||||
.find(|child| child.get_name() == sub_module)
|
||||
.unwrap();
|
||||
if next.get_codec() == "category" {
|
||||
let next_cat = next.as_any().downcast_ref::<Category>().unwrap();
|
||||
next_cat.get_child(path, name)
|
||||
} else {
|
||||
Some(next.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn get_children(&self) -> Vec<Arc<Box<dyn Interactable>>> {
|
||||
self.children.iter().map(|child| child.clone()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interactable for Category {
|
||||
fn get_id(&self) -> &Uuid {
|
||||
&self.id
|
||||
}
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
fn get_codec(&self) -> String {
|
||||
"category".to_string()
|
||||
}
|
||||
fn set_name(&mut self, name: String) {
|
||||
self.name = name;
|
||||
}
|
||||
fn set_path(&mut self, path: String) {
|
||||
self.path = path;
|
||||
}
|
||||
fn get_community(&self) -> &Arc<Community> {
|
||||
&self.community
|
||||
}
|
||||
fn set_community(&mut self, community: Arc<Community>) {
|
||||
self.community = community;
|
||||
}
|
||||
fn get_name(&self) -> &String {
|
||||
&self.name
|
||||
}
|
||||
fn get_path(&self) -> &String {
|
||||
&self.path
|
||||
}
|
||||
fn get_total_path(&self) -> String {
|
||||
String::new() + &self.path + "/" + &self.name
|
||||
}
|
||||
fn get_data(&self) -> JsonValue {
|
||||
let mut v = JsonValue::new_object();
|
||||
for child in &self.children {
|
||||
let mut subject = JsonValue::new_object();
|
||||
subject["codec"] = JsonValue::String(child.get_codec());
|
||||
subject["data"] = child.get_data();
|
||||
v[child.get_name()] = subject;
|
||||
}
|
||||
v
|
||||
}
|
||||
async fn run_function(&self, _cv: CommunicationValue) -> CommunicationValue {
|
||||
CommunicationValue::new(CommunicationType::error)
|
||||
}
|
||||
fn to_json(&self) -> JsonValue {
|
||||
let mut v = JsonValue::new_object();
|
||||
v["children"] = JsonValue::new_array();
|
||||
for child in &self.children {
|
||||
let _ = v["children"].push(child.to_json());
|
||||
}
|
||||
v
|
||||
}
|
||||
fn load(
|
||||
&mut self,
|
||||
community: Arc<Community>,
|
||||
id: Uuid,
|
||||
path: String,
|
||||
name: String,
|
||||
_json: &JsonValue,
|
||||
) {
|
||||
self.community = community;
|
||||
self.id = id;
|
||||
self.name = name;
|
||||
self.path = path;
|
||||
}
|
||||
}
|
||||
use crate::communities::{community::Community, interactables::interactable::Interactable};
|
||||
use async_trait::async_trait;
|
||||
use json::JsonValue;
|
||||
use std::any::Any;
|
||||
use std::sync::Arc;
|
||||
use mtp::codec::CommunicationValue;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct Category {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
path: String,
|
||||
community: Arc<Community>,
|
||||
children: Vec<Arc<Box<dyn Interactable>>>,
|
||||
}
|
||||
impl Category {
|
||||
pub fn new() -> Category {
|
||||
Category {
|
||||
id: Uuid::new_v4(),
|
||||
name: String::new(),
|
||||
path: String::new(),
|
||||
community: Arc::new(Community::new()),
|
||||
children: Vec::new(),
|
||||
}
|
||||
}
|
||||
pub fn get_child(&self, path: String, name: String) -> Option<Arc<Box<dyn Interactable>>> {
|
||||
if path.is_empty() {
|
||||
self.children
|
||||
.iter()
|
||||
.find(|child| child.get_name() == &name)
|
||||
.cloned()
|
||||
} else {
|
||||
let sub_module = path.split('/').next()?;
|
||||
let next = self
|
||||
.children
|
||||
.iter()
|
||||
.find(|child| child.get_name() == sub_module)?;
|
||||
if next.get_codec() == "category" {
|
||||
let next_cat = next.as_any().downcast_ref::<Category>()?;
|
||||
next_cat.get_child(path, name)
|
||||
} else {
|
||||
Some(next.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn get_children(&self) -> Vec<Arc<Box<dyn Interactable>>> {
|
||||
self.children.iter().map(|child| child.clone()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interactable for Category {
|
||||
fn get_id(&self) -> &Uuid {
|
||||
&self.id
|
||||
}
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
fn get_codec(&self) -> String {
|
||||
"category".to_string()
|
||||
}
|
||||
fn set_name(&mut self, name: String) {
|
||||
self.name = name;
|
||||
}
|
||||
fn set_path(&mut self, path: String) {
|
||||
self.path = path;
|
||||
}
|
||||
fn get_community(&self) -> &Arc<Community> {
|
||||
&self.community
|
||||
}
|
||||
fn set_community(&mut self, community: Arc<Community>) {
|
||||
self.community = community;
|
||||
}
|
||||
fn get_name(&self) -> &String {
|
||||
&self.name
|
||||
}
|
||||
fn get_path(&self) -> &String {
|
||||
&self.path
|
||||
}
|
||||
fn get_total_path(&self) -> String {
|
||||
String::new() + &self.path + "/" + &self.name
|
||||
}
|
||||
fn get_data(&self) -> JsonValue {
|
||||
let mut v = JsonValue::new_object();
|
||||
for child in &self.children {
|
||||
let mut subject = JsonValue::new_object();
|
||||
subject["codec"] = JsonValue::String(child.get_codec());
|
||||
subject["data"] = child.get_data();
|
||||
v[child.get_name()] = subject;
|
||||
}
|
||||
v
|
||||
}
|
||||
async fn run_function(&self, _cv: CommunicationValue) -> CommunicationValue {
|
||||
CommunicationValue::new(CommunicationType::ErrorInternal)
|
||||
}
|
||||
fn to_json(&self) -> JsonValue {
|
||||
let mut v = JsonValue::new_object();
|
||||
v["children"] = JsonValue::new_array();
|
||||
for child in &self.children {
|
||||
let _ = v["children"].push(child.to_json());
|
||||
}
|
||||
v
|
||||
}
|
||||
fn load(
|
||||
&mut self,
|
||||
community: Arc<Community>,
|
||||
id: Uuid,
|
||||
path: String,
|
||||
name: String,
|
||||
_json: &JsonValue,
|
||||
) {
|
||||
self.community = community;
|
||||
self.id = id;
|
||||
self.name = name;
|
||||
self.path = path;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
@ -1,261 +1,278 @@
|
|||
use crate::{
|
||||
communities::{
|
||||
community::Community, community_connection::CommunityConnection,
|
||||
interactables::interactable::Interactable,
|
||||
},
|
||||
log,
|
||||
util::file_util::{get_children, load_file, save_file},
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use json::{JsonValue, array, object};
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
use std::{any::Any, collections::HashMap};
|
||||
use ttp_core::{CommunicationType, CommunicationValue, DataTypes};
|
||||
use uuid::Uuid;
|
||||
pub struct TextChat {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
path: String,
|
||||
community: Arc<Community>,
|
||||
}
|
||||
impl TextChat {
|
||||
pub fn new() -> TextChat {
|
||||
TextChat {
|
||||
id: Uuid::new_v4(),
|
||||
name: String::new(),
|
||||
path: String::new(),
|
||||
community: Arc::new(Community::new()),
|
||||
}
|
||||
}
|
||||
pub fn add_message(&self, send_time: u128, sender: i64, message: &str) {
|
||||
let user_dir = &format!(
|
||||
"communities/{}/interactables/{}/{}",
|
||||
self.get_community().get_name(),
|
||||
self.get_path(),
|
||||
self.get_name()
|
||||
);
|
||||
|
||||
if let Err(e) = fs::create_dir_all(user_dir) {
|
||||
log!("Failed to create chat directory: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut chunk_index = 0;
|
||||
let mut message_chunk = array![];
|
||||
|
||||
// find latest chunk not full (max 800 msgs)
|
||||
loop {
|
||||
let file_name = format!("msgs_{}.json", chunk_index);
|
||||
let file_content = load_file(&user_dir, &file_name);
|
||||
|
||||
if !file_content.is_empty() {
|
||||
if let Ok(current_chunk) = json::parse(&file_content) {
|
||||
if current_chunk.is_array() && current_chunk.len() < 800 {
|
||||
message_chunk = current_chunk;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
log!("Failed to parse existing JSON file: {}", file_name);
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
chunk_index += 1;
|
||||
if chunk_index > 1000 {
|
||||
log!("Too many message chunks. Aborting add.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let json_obj = object! {
|
||||
"timestamp" => send_time as i64,
|
||||
"content" => message,
|
||||
"sender" => sender.to_string(),
|
||||
};
|
||||
|
||||
if let Err(e) = message_chunk.push(json_obj) {
|
||||
log!("Failed to push new message into JSON array: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
let file_name = format!("msgs_{}.json", chunk_index);
|
||||
log!("Saving message to {}/{}", user_dir, file_name);
|
||||
save_file(&user_dir, &file_name, &message_chunk.dump());
|
||||
}
|
||||
pub fn get_messages(&self, loaded_messages: i64, amount: i64) -> JsonValue {
|
||||
let mut messages = array![];
|
||||
|
||||
let mut latest_chunk_index: i32 = -1;
|
||||
let files = get_children(&format!(
|
||||
"communities/{}/interactables/{}/{}",
|
||||
self.get_community().get_name(),
|
||||
self.get_path(),
|
||||
self.get_name()
|
||||
));
|
||||
|
||||
for entry in files {
|
||||
if let Some(num) = {
|
||||
entry
|
||||
.strip_prefix("msgs_")
|
||||
.and_then(|s| s.strip_suffix(".json"))
|
||||
} {
|
||||
if let Ok(index) = num.parse::<i32>() {
|
||||
if index > latest_chunk_index {
|
||||
latest_chunk_index = index;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if latest_chunk_index == -1 {
|
||||
return messages;
|
||||
}
|
||||
|
||||
let mut to_skip = loaded_messages;
|
||||
let mut needed = amount;
|
||||
|
||||
for chunk_index in (0..=latest_chunk_index).rev() {
|
||||
if needed == 0 {
|
||||
break;
|
||||
}
|
||||
let file_name = format!("msgs_{}.json", chunk_index);
|
||||
let file_content = load_file(
|
||||
&format!(
|
||||
"communities/{}/interactables/{}/{}",
|
||||
self.get_community().get_name(),
|
||||
self.get_path(),
|
||||
self.get_name()
|
||||
),
|
||||
&file_name,
|
||||
);
|
||||
if file_content.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(chunk) = json::parse(&file_content) {
|
||||
for i in (0..chunk.len()).rev() {
|
||||
if needed == 0 {
|
||||
break;
|
||||
}
|
||||
if to_skip > 0 {
|
||||
to_skip -= 1;
|
||||
continue;
|
||||
}
|
||||
messages.push(chunk[i].clone()).unwrap();
|
||||
needed -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
messages
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl Interactable for TextChat {
|
||||
fn get_id(&self) -> &Uuid {
|
||||
&self.id
|
||||
}
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
fn get_codec(&self) -> String {
|
||||
"text".to_string()
|
||||
}
|
||||
fn set_name(&mut self, name: String) {
|
||||
self.name = name;
|
||||
}
|
||||
fn set_path(&mut self, path: String) {
|
||||
self.path = path;
|
||||
}
|
||||
fn get_community(&self) -> &Arc<Community> {
|
||||
&self.community
|
||||
}
|
||||
fn set_community(&mut self, community: Arc<Community>) {
|
||||
self.community = community;
|
||||
}
|
||||
fn get_name(&self) -> &String {
|
||||
&self.name
|
||||
}
|
||||
fn get_path(&self) -> &String {
|
||||
&self.path
|
||||
}
|
||||
fn get_total_path(&self) -> String {
|
||||
String::new() + &self.path + "/" + &self.name
|
||||
}
|
||||
fn get_data(&self) -> JsonValue {
|
||||
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 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);
|
||||
}
|
||||
if cv.get_data(DataTypes::function).unwrap().as_str().unwrap() == "send_message" {
|
||||
let message = payload["message"].as_str().unwrap();
|
||||
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 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["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 connections: HashMap<i64, Vec<Arc<CommunityConnection>>> =
|
||||
self.get_community().get_connections().await.clone();
|
||||
|
||||
for con in connections.values() {
|
||||
for c in con {
|
||||
let cd: &Arc<CommunityConnection> = c;
|
||||
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());
|
||||
}
|
||||
CommunicationValue::new(CommunicationType::error).with_id(cv.get_id())
|
||||
}
|
||||
fn to_json(&self) -> JsonValue {
|
||||
JsonValue::new_object()
|
||||
}
|
||||
fn load(
|
||||
&mut self,
|
||||
community: Arc<Community>,
|
||||
id: Uuid,
|
||||
path: String,
|
||||
name: String,
|
||||
_json: &JsonValue,
|
||||
) {
|
||||
self.community = community;
|
||||
self.id = id;
|
||||
self.name = name;
|
||||
self.path = path;
|
||||
}
|
||||
}
|
||||
use crate::{
|
||||
communities::{
|
||||
community::Community, community_connection::CommunityConnection,
|
||||
interactables::interactable::Interactable,
|
||||
},
|
||||
log,
|
||||
util::file_util::{get_children, load_file, save_file},
|
||||
};
|
||||
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 mtp::codec::{CommunicationType, CommunicationValue, DataType};
|
||||
use uuid::Uuid;
|
||||
pub struct TextChat {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
path: String,
|
||||
community: Arc<Community>,
|
||||
}
|
||||
impl TextChat {
|
||||
pub fn new() -> TextChat {
|
||||
TextChat {
|
||||
id: Uuid::new_v4(),
|
||||
name: String::new(),
|
||||
path: String::new(),
|
||||
community: Arc::new(Community::new()),
|
||||
}
|
||||
}
|
||||
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(),
|
||||
self.get_path(),
|
||||
self.get_name()
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
let mut chunk_index = 0;
|
||||
let mut message_chunk = array![];
|
||||
|
||||
// find latest chunk not full (max 800 msgs)
|
||||
loop {
|
||||
let file_name = format!("msgs_{}.json", chunk_index);
|
||||
let file_content = load_file(&user_dir, &file_name);
|
||||
|
||||
if !file_content.is_empty() {
|
||||
if let Ok(current_chunk) = json::parse(&file_content) {
|
||||
if current_chunk.is_array() && current_chunk.len() < 800 {
|
||||
message_chunk = current_chunk;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
log!("Failed to parse existing JSON file: {}", file_name);
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
chunk_index += 1;
|
||||
if chunk_index > 1000 {
|
||||
log!("Too many message chunks. Aborting add.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let json_obj = object! {
|
||||
"timestamp" => send_time,
|
||||
"content" => message,
|
||||
"sender" => sender.to_string(),
|
||||
};
|
||||
|
||||
if let Err(e) = message_chunk.push(json_obj) {
|
||||
log!("Failed to push new message into JSON array: {}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
let file_name = format!("msgs_{}.json", chunk_index);
|
||||
log!("Saving message to {}/{}", user_dir, file_name);
|
||||
save_file(&user_dir, &file_name, &message_chunk.dump());
|
||||
}
|
||||
pub fn get_messages(&self, loaded_messages: i64, amount: i64) -> JsonValue {
|
||||
let mut messages = array![];
|
||||
|
||||
let mut latest_chunk_index: i32 = -1;
|
||||
let files = get_children(&format!(
|
||||
"communities/{}/interactables/{}/{}",
|
||||
self.get_community().get_name(),
|
||||
self.get_path(),
|
||||
self.get_name()
|
||||
));
|
||||
|
||||
for entry in files {
|
||||
if let Some(num) = {
|
||||
entry
|
||||
.strip_prefix("msgs_")
|
||||
.and_then(|s| s.strip_suffix(".json"))
|
||||
} {
|
||||
if let Ok(index) = num.parse::<i32>() {
|
||||
if index > latest_chunk_index {
|
||||
latest_chunk_index = index;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if latest_chunk_index == -1 {
|
||||
return messages;
|
||||
}
|
||||
|
||||
let mut to_skip = loaded_messages;
|
||||
let mut needed = amount;
|
||||
|
||||
for chunk_index in (0..=latest_chunk_index).rev() {
|
||||
if needed == 0 {
|
||||
break;
|
||||
}
|
||||
let file_name = format!("msgs_{}.json", chunk_index);
|
||||
let file_content = load_file(
|
||||
&format!(
|
||||
"communities/{}/interactables/{}/{}",
|
||||
self.get_community().get_name(),
|
||||
self.get_path(),
|
||||
self.get_name()
|
||||
),
|
||||
&file_name,
|
||||
);
|
||||
if file_content.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(chunk) = json::parse(&file_content) {
|
||||
for i in (0..chunk.len()).rev() {
|
||||
if needed == 0 {
|
||||
break;
|
||||
}
|
||||
if to_skip > 0 {
|
||||
to_skip -= 1;
|
||||
continue;
|
||||
}
|
||||
messages.push(chunk[i].clone()).unwrap();
|
||||
needed -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
messages
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl Interactable for TextChat {
|
||||
fn get_id(&self) -> &Uuid {
|
||||
&self.id
|
||||
}
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
fn get_codec(&self) -> String {
|
||||
"text".to_string()
|
||||
}
|
||||
fn set_name(&mut self, name: String) {
|
||||
self.name = name;
|
||||
}
|
||||
fn set_path(&mut self, path: String) {
|
||||
self.path = path;
|
||||
}
|
||||
fn get_community(&self) -> &Arc<Community> {
|
||||
&self.community
|
||||
}
|
||||
fn set_community(&mut self, community: Arc<Community>) {
|
||||
self.community = community;
|
||||
}
|
||||
fn get_name(&self) -> &String {
|
||||
&self.name
|
||||
}
|
||||
fn get_path(&self) -> &String {
|
||||
&self.path
|
||||
}
|
||||
fn get_total_path(&self) -> String {
|
||||
String::new() + &self.path + "/" + &self.name
|
||||
}
|
||||
fn get_data(&self) -> JsonValue {
|
||||
JsonValue::new_object()
|
||||
}
|
||||
async fn run_function(&self, cv: CommunicationValue) -> CommunicationValue {
|
||||
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_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(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();
|
||||
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(sender.to_string());
|
||||
distribution_payload["send_time"] =
|
||||
JsonValue::String(milliseconds_timestamp.to_string());
|
||||
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();
|
||||
|
||||
for con in connections.values() {
|
||||
for c in con {
|
||||
let cd: &Arc<CommunityConnection> = c;
|
||||
cd.send_message(&distribution).await;
|
||||
}
|
||||
}
|
||||
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::ErrorInternal).with_request_id(&cv)
|
||||
}
|
||||
fn to_json(&self) -> JsonValue {
|
||||
JsonValue::new_object()
|
||||
}
|
||||
fn load(
|
||||
&mut self,
|
||||
community: Arc<Community>,
|
||||
id: Uuid,
|
||||
path: String,
|
||||
name: String,
|
||||
_json: &JsonValue,
|
||||
) {
|
||||
self.community = community;
|
||||
self.id = id;
|
||||
self.name = name;
|
||||
self.path = path;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,187 +1,188 @@
|
|||
use crate::communities::{community::Community, interactables::interactable::Interactable};
|
||||
use async_trait::async_trait;
|
||||
use json::JsonValue;
|
||||
use std::sync::Arc;
|
||||
use std::{any::Any, sync::RwLock};
|
||||
use uuid::Uuid;
|
||||
pub enum CallUserState {
|
||||
Active,
|
||||
Muted,
|
||||
Deafed,
|
||||
}
|
||||
impl CallUserState {
|
||||
pub fn parse(state: &str) -> CallUserState {
|
||||
match state {
|
||||
"active" => CallUserState::Active,
|
||||
"muted" => CallUserState::Muted,
|
||||
"deafed" => CallUserState::Deafed,
|
||||
_ => CallUserState::Active,
|
||||
}
|
||||
}
|
||||
pub fn to_string(&self) -> String {
|
||||
match self {
|
||||
CallUserState::Active => "active".to_string(),
|
||||
CallUserState::Muted => "muted".to_string(),
|
||||
CallUserState::Deafed => "deafed".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CallUser {
|
||||
pub user_id: Uuid,
|
||||
pub user_state: CallUserState,
|
||||
pub streaming: bool,
|
||||
}
|
||||
|
||||
pub struct VoiceChat {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
path: String,
|
||||
community: Arc<Community>,
|
||||
users: RwLock<Vec<CallUser>>,
|
||||
}
|
||||
impl VoiceChat {
|
||||
pub fn new() -> VoiceChat {
|
||||
VoiceChat {
|
||||
id: Uuid::new_v4(),
|
||||
name: String::new(),
|
||||
path: String::new(),
|
||||
community: Arc::new(Community::new()),
|
||||
users: RwLock::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
pub fn update_user_state(
|
||||
self: Arc<Self>,
|
||||
user_id: Uuid,
|
||||
state: CallUserState,
|
||||
streaming: bool,
|
||||
) {
|
||||
if let Some(user) = self
|
||||
.users
|
||||
.write()
|
||||
.unwrap()
|
||||
.iter_mut()
|
||||
.find(|u| u.user_id == user_id)
|
||||
{
|
||||
user.user_state = state;
|
||||
user.streaming = streaming;
|
||||
}
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl Interactable for VoiceChat {
|
||||
fn get_id(&self) -> &Uuid {
|
||||
&self.id
|
||||
}
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
fn get_codec(&self) -> String {
|
||||
"voice".to_string()
|
||||
}
|
||||
fn set_name(&mut self, name: String) {
|
||||
self.name = name;
|
||||
}
|
||||
fn set_path(&mut self, path: String) {
|
||||
self.path = path;
|
||||
}
|
||||
fn get_community(&self) -> &Arc<Community> {
|
||||
&self.community
|
||||
}
|
||||
fn set_community(&mut self, community: Arc<Community>) {
|
||||
self.community = community;
|
||||
}
|
||||
fn get_name(&self) -> &String {
|
||||
&self.name
|
||||
}
|
||||
fn get_path(&self) -> &String {
|
||||
&self.path
|
||||
}
|
||||
fn get_total_path(&self) -> String {
|
||||
String::new() + &self.path + "/" + &self.name
|
||||
}
|
||||
fn get_data(&self) -> JsonValue {
|
||||
let mut data = JsonValue::new_object();
|
||||
let mut active_users = JsonValue::new_object();
|
||||
for user in self.users.read().unwrap().iter() {
|
||||
let mut user_data = JsonValue::new_object();
|
||||
let _ = user_data.insert("state", JsonValue::String(user.user_state.to_string()));
|
||||
let _ = user_data.insert("streaming", JsonValue::Boolean(user.streaming));
|
||||
let _ = active_users.insert(&user.user_id.to_string(), user_data);
|
||||
}
|
||||
let _ = data.insert("active_users", active_users);
|
||||
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();
|
||||
|
||||
if function == "get_call" {
|
||||
let sender_id = payload["sender_id"].as_str().unwrap();
|
||||
let message_id = payload["message"].as_str().unwrap();
|
||||
let send_time = payload["send_time"].as_str().unwrap();
|
||||
|
||||
let mut response_payload = JsonValue::new_object();
|
||||
response_payload["sender_id"] = JsonValue::String(sender_id.to_string());
|
||||
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);
|
||||
}
|
||||
|
||||
if function == "update_user_state" {
|
||||
let user_id = payload["user_id"].as_str().unwrap();
|
||||
let state = payload["state"].as_str().unwrap();
|
||||
let streaming = payload["streaming"].as_bool().unwrap();
|
||||
|
||||
if let Some(user) = self
|
||||
.users
|
||||
.write()
|
||||
.unwrap()
|
||||
.iter_mut()
|
||||
.find(|u| u.user_id == Uuid::parse_str(user_id).unwrap())
|
||||
{
|
||||
user.user_state = CallUserState::parse(state);
|
||||
user.streaming = streaming;
|
||||
}
|
||||
let mut response_payload = JsonValue::new_object();
|
||||
response_payload["user_id"] = JsonValue::String(user_id.to_string());
|
||||
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);
|
||||
}
|
||||
CommunicationValue::new(CommunicationType::error).with_id(cv.get_id())
|
||||
}
|
||||
|
||||
fn to_json(&self) -> JsonValue {
|
||||
let v = JsonValue::new_object();
|
||||
v
|
||||
}
|
||||
fn load(
|
||||
&mut self,
|
||||
community: Arc<Community>,
|
||||
id: Uuid,
|
||||
path: String,
|
||||
name: String,
|
||||
_json: &JsonValue,
|
||||
) {
|
||||
self.community = community;
|
||||
self.id = id;
|
||||
self.name = name;
|
||||
self.path = path;
|
||||
}
|
||||
}
|
||||
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;
|
||||
pub enum CallUserState {
|
||||
Active,
|
||||
Muted,
|
||||
Deafed,
|
||||
}
|
||||
impl CallUserState {
|
||||
pub fn parse(state: &str) -> CallUserState {
|
||||
match state {
|
||||
"active" => CallUserState::Active,
|
||||
"muted" => CallUserState::Muted,
|
||||
"deafed" => CallUserState::Deafed,
|
||||
_ => CallUserState::Active,
|
||||
}
|
||||
}
|
||||
pub fn to_string(&self) -> String {
|
||||
match self {
|
||||
CallUserState::Active => "active".to_string(),
|
||||
CallUserState::Muted => "muted".to_string(),
|
||||
CallUserState::Deafed => "deafed".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CallUser {
|
||||
pub user_id: Uuid,
|
||||
pub user_state: CallUserState,
|
||||
pub streaming: bool,
|
||||
}
|
||||
|
||||
pub struct VoiceChat {
|
||||
id: Uuid,
|
||||
name: String,
|
||||
path: String,
|
||||
community: Arc<Community>,
|
||||
users: RwLock<Vec<CallUser>>,
|
||||
}
|
||||
impl VoiceChat {
|
||||
pub fn new() -> VoiceChat {
|
||||
VoiceChat {
|
||||
id: Uuid::new_v4(),
|
||||
name: String::new(),
|
||||
path: String::new(),
|
||||
community: Arc::new(Community::new()),
|
||||
users: RwLock::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
pub fn update_user_state(
|
||||
self: Arc<Self>,
|
||||
user_id: Uuid,
|
||||
state: CallUserState,
|
||||
streaming: bool,
|
||||
) {
|
||||
if let Some(user) = self
|
||||
.users
|
||||
.write()
|
||||
.unwrap()
|
||||
.iter_mut()
|
||||
.find(|u| u.user_id == user_id)
|
||||
{
|
||||
user.user_state = state;
|
||||
user.streaming = streaming;
|
||||
}
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl Interactable for VoiceChat {
|
||||
fn get_id(&self) -> &Uuid {
|
||||
&self.id
|
||||
}
|
||||
fn as_any(&self) -> &dyn Any {
|
||||
self
|
||||
}
|
||||
fn as_any_mut(&mut self) -> &mut dyn Any {
|
||||
self
|
||||
}
|
||||
fn get_codec(&self) -> String {
|
||||
"voice".to_string()
|
||||
}
|
||||
fn set_name(&mut self, name: String) {
|
||||
self.name = name;
|
||||
}
|
||||
fn set_path(&mut self, path: String) {
|
||||
self.path = path;
|
||||
}
|
||||
fn get_community(&self) -> &Arc<Community> {
|
||||
&self.community
|
||||
}
|
||||
fn set_community(&mut self, community: Arc<Community>) {
|
||||
self.community = community;
|
||||
}
|
||||
fn get_name(&self) -> &String {
|
||||
&self.name
|
||||
}
|
||||
fn get_path(&self) -> &String {
|
||||
&self.path
|
||||
}
|
||||
fn get_total_path(&self) -> String {
|
||||
String::new() + &self.path + "/" + &self.name
|
||||
}
|
||||
fn get_data(&self) -> JsonValue {
|
||||
let mut data = JsonValue::new_object();
|
||||
let mut active_users = JsonValue::new_object();
|
||||
for user in self.users.read().unwrap().iter() {
|
||||
let mut user_data = JsonValue::new_object();
|
||||
let _ = user_data.insert("state", JsonValue::String(user.user_state.to_string()));
|
||||
let _ = user_data.insert("streaming", JsonValue::Boolean(user.streaming));
|
||||
let _ = active_users.insert(&user.user_id.to_string(), user_data);
|
||||
}
|
||||
let _ = data.insert("active_users", active_users);
|
||||
data
|
||||
}
|
||||
async fn run_function(&self, cv: CommunicationValue) -> CommunicationValue {
|
||||
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();
|
||||
let message_id = payload["message"].as_str().unwrap();
|
||||
let send_time = payload["send_time"].as_str().unwrap();
|
||||
|
||||
let mut response_payload = JsonValue::new_object();
|
||||
response_payload["sender_id"] = JsonValue::String(sender_id.to_string());
|
||||
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_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" {
|
||||
let user_id = payload["user_id"].as_str().unwrap();
|
||||
let state = payload["state"].as_str().unwrap();
|
||||
let streaming = payload["streaming"].as_bool().unwrap();
|
||||
|
||||
if let Some(user) = self
|
||||
.users
|
||||
.write()
|
||||
.unwrap()
|
||||
.iter_mut()
|
||||
.find(|u| u.user_id == Uuid::parse_str(user_id).unwrap())
|
||||
{
|
||||
user.user_state = CallUserState::parse(state);
|
||||
user.streaming = streaming;
|
||||
}
|
||||
let mut response_payload = JsonValue::new_object();
|
||||
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_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::ErrorInternal).with_request_id(&cv)
|
||||
}
|
||||
|
||||
fn to_json(&self) -> JsonValue {
|
||||
let v = JsonValue::new_object();
|
||||
v
|
||||
}
|
||||
fn load(
|
||||
&mut self,
|
||||
community: Arc<Community>,
|
||||
id: Uuid,
|
||||
path: String,
|
||||
name: String,
|
||||
_json: &JsonValue,
|
||||
) {
|
||||
self.community = community;
|
||||
self.id = id;
|
||||
self.name = name;
|
||||
self.path = path;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
pub mod community_manager;
|
||||
pub mod interactables {
|
||||
pub mod category;
|
||||
pub mod interactable;
|
||||
pub mod registry;
|
||||
pub mod text_chat;
|
||||
pub mod voice_chat;
|
||||
}
|
||||
pub mod community;
|
||||
pub mod community_connection;
|
||||
pub mod perms {
|
||||
pub mod permission;
|
||||
}
|
||||
pub mod community_manager;
|
||||
pub mod interactables {
|
||||
pub mod category;
|
||||
pub mod interactable;
|
||||
pub mod registry;
|
||||
pub mod text_chat;
|
||||
pub mod voice_chat;
|
||||
}
|
||||
pub mod community;
|
||||
pub mod community_connection;
|
||||
pub mod perms {
|
||||
pub mod permission;
|
||||
}
|
||||
|
|
@ -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
82
flake.lock
generated
Normal 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
275
flake.nix
Normal 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
7
iota-auth/Cargo.toml
Normal 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
1
iota-auth/src/lib.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -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
29
iota-cli/Cargo.toml
Normal 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
1
iota-cli/src/app_state.rs
Executable file
|
|
@ -0,0 +1 @@
|
|||
pub use iota_state::*;
|
||||
7
iota-cli/src/controls/action.rs
Normal file
7
iota-cli/src/controls/action.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ControlAction {
|
||||
FocusNext,
|
||||
FocusPrevious,
|
||||
Select,
|
||||
Activate,
|
||||
}
|
||||
78
iota-cli/src/controls/button.rs
Normal file
78
iota-cli/src/controls/button.rs
Normal 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
|
||||
}
|
||||
135
iota-cli/src/controls/checkbox_group.rs
Normal file
135
iota-cli/src/controls/checkbox_group.rs
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
use super::{choice::ChoiceVisualState, navigation::DisabledFocusPolicy};
|
||||
use std::{collections::HashSet, hash::Hash};
|
||||
|
||||
pub struct CheckboxItem<T> {
|
||||
pub value: T,
|
||||
pub label: String,
|
||||
pub description: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub disabled_reason: Option<String>,
|
||||
}
|
||||
pub struct CheckboxGroup<T: Clone + Eq + Hash> {
|
||||
items: Vec<CheckboxItem<T>>,
|
||||
selected: HashSet<T>,
|
||||
focused_index: usize,
|
||||
focus_policy: DisabledFocusPolicy,
|
||||
wrap_navigation: bool,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CheckboxGroupError {
|
||||
Empty,
|
||||
DuplicateValue,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CheckboxChange<T> {
|
||||
Selected(T),
|
||||
Deselected(T),
|
||||
IgnoredDisabled(T),
|
||||
NoItem,
|
||||
}
|
||||
impl<T: Clone + Eq + Hash> CheckboxGroup<T> {
|
||||
pub fn new(
|
||||
items: Vec<CheckboxItem<T>>,
|
||||
selected: impl IntoIterator<Item = T>,
|
||||
) -> Result<Self, CheckboxGroupError> {
|
||||
let mut values = HashSet::new();
|
||||
if items.iter().any(|item| !values.insert(item.value.clone())) {
|
||||
return Err(CheckboxGroupError::DuplicateValue);
|
||||
}
|
||||
let selected = selected
|
||||
.into_iter()
|
||||
.filter(|value| values.contains(value))
|
||||
.collect();
|
||||
let focused_index = items.iter().position(|item| item.enabled).unwrap_or(0);
|
||||
Ok(Self {
|
||||
items,
|
||||
selected,
|
||||
focused_index,
|
||||
focus_policy: DisabledFocusPolicy::Skip,
|
||||
wrap_navigation: true,
|
||||
})
|
||||
}
|
||||
pub fn items(&self) -> &[CheckboxItem<T>] {
|
||||
&self.items
|
||||
}
|
||||
pub fn selected(&self) -> &HashSet<T> {
|
||||
&self.selected
|
||||
}
|
||||
pub fn focused_item(&self) -> Option<&CheckboxItem<T>> {
|
||||
self.items.get(self.focused_index)
|
||||
}
|
||||
pub fn set_focus_policy(&mut self, policy: DisabledFocusPolicy) {
|
||||
self.focus_policy = policy;
|
||||
}
|
||||
pub fn set_wrap_navigation(&mut self, wrap: bool) {
|
||||
self.wrap_navigation = wrap;
|
||||
}
|
||||
pub fn focus_next(&mut self) {
|
||||
self.move_focus(true);
|
||||
}
|
||||
pub fn focus_previous(&mut self) {
|
||||
self.move_focus(false);
|
||||
}
|
||||
fn move_focus(&mut self, forward: bool) {
|
||||
if self.items.is_empty() {
|
||||
return;
|
||||
}
|
||||
for step in 1..=self.items.len() {
|
||||
let current = self.focused_index as isize;
|
||||
let delta = if forward {
|
||||
step as isize
|
||||
} else {
|
||||
-(step as isize)
|
||||
};
|
||||
let raw = current + delta;
|
||||
let next = if self.wrap_navigation {
|
||||
raw.rem_euclid(self.items.len() as isize) as usize
|
||||
} else if raw < 0 || raw >= self.items.len() as isize {
|
||||
return;
|
||||
} else {
|
||||
raw as usize
|
||||
};
|
||||
if self.focus_policy == DisabledFocusPolicy::Include || self.items[next].enabled {
|
||||
self.focused_index = next;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn toggle_focused(&mut self) -> CheckboxChange<T> {
|
||||
let Some(item) = self.items.get(self.focused_index) else {
|
||||
return CheckboxChange::NoItem;
|
||||
};
|
||||
let value = item.value.clone();
|
||||
if !item.enabled {
|
||||
return CheckboxChange::IgnoredDisabled(value);
|
||||
}
|
||||
if self.selected.remove(&value) {
|
||||
CheckboxChange::Deselected(value)
|
||||
} else {
|
||||
self.selected.insert(value.clone());
|
||||
CheckboxChange::Selected(value)
|
||||
}
|
||||
}
|
||||
pub fn set_enabled(&mut self, value: &T, enabled: bool) {
|
||||
if let Some(item) = self.items.iter_mut().find(|item| &item.value == value) {
|
||||
item.enabled = enabled;
|
||||
}
|
||||
}
|
||||
pub fn set_selected(&mut self, value: T, selected: bool) {
|
||||
if selected {
|
||||
self.selected.insert(value);
|
||||
} else {
|
||||
self.selected.remove(&value);
|
||||
}
|
||||
}
|
||||
pub fn visual_state(&self, value: &T) -> ChoiceVisualState {
|
||||
let item = self.items.iter().position(|item| &item.value == value);
|
||||
ChoiceVisualState {
|
||||
selected: self.selected.contains(value),
|
||||
focused: item == Some(self.focused_index),
|
||||
enabled: item
|
||||
.and_then(|index| self.items.get(index))
|
||||
.is_some_and(|item| item.enabled),
|
||||
}
|
||||
}
|
||||
}
|
||||
42
iota-cli/src/controls/choice.rs
Normal file
42
iota-cli/src/controls/choice.rs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
use crate::theme::ResolvedTheme;
|
||||
use ratatui::text::{Line, Span};
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ChoiceKind {
|
||||
Checkbox,
|
||||
Radio,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ChoiceVisualState {
|
||||
pub selected: bool,
|
||||
pub focused: bool,
|
||||
pub enabled: bool,
|
||||
}
|
||||
pub fn render_choice_line<'a>(
|
||||
label: &'a str,
|
||||
kind: ChoiceKind,
|
||||
state: ChoiceVisualState,
|
||||
theme: &'a ResolvedTheme,
|
||||
) -> Line<'a> {
|
||||
let item = match (state.selected, state.focused, state.enabled) {
|
||||
(_, true, false) => &theme.choices.focused_disabled,
|
||||
(true, false, false) => &theme.choices.selected_disabled,
|
||||
(false, false, false) => &theme.choices.disabled,
|
||||
(true, true, true) => &theme.choices.focused_selected,
|
||||
(true, false, true) => &theme.choices.selected,
|
||||
(false, true, true) => &theme.choices.focused,
|
||||
(false, false, true) => &theme.choices.normal,
|
||||
};
|
||||
let marker = match (kind, state.selected) {
|
||||
(ChoiceKind::Checkbox, false) => theme.markers.checkbox_unselected,
|
||||
(ChoiceKind::Checkbox, true) => theme.markers.checkbox_selected,
|
||||
(ChoiceKind::Radio, false) => theme.markers.radio_unselected,
|
||||
(ChoiceKind::Radio, true) => theme.markers.radio_selected,
|
||||
};
|
||||
Line::from(vec![
|
||||
Span::styled(item.prefix, item.label),
|
||||
Span::styled(marker, item.marker),
|
||||
Span::raw(" "),
|
||||
Span::styled(label, item.label),
|
||||
Span::styled(item.suffix, item.label),
|
||||
])
|
||||
}
|
||||
267
iota-cli/src/controls/dialog.rs
Normal file
267
iota-cli/src/controls/dialog.rs
Normal 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",
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
164
iota-cli/src/controls/header.rs
Normal file
164
iota-cli/src/controls/header.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
10
iota-cli/src/controls/mod.rs
Normal file
10
iota-cli/src/controls/mod.rs
Normal 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;
|
||||
6
iota-cli/src/controls/navigation.rs
Normal file
6
iota-cli/src/controls/navigation.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum DisabledFocusPolicy {
|
||||
Include,
|
||||
#[default]
|
||||
Skip,
|
||||
}
|
||||
60
iota-cli/src/controls/panel.rs
Normal file
60
iota-cli/src/controls/panel.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
194
iota-cli/src/controls/radio_group.rs
Normal file
194
iota-cli/src/controls/radio_group.rs
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
use super::{choice::ChoiceVisualState, navigation::DisabledFocusPolicy};
|
||||
|
||||
pub struct RadioItem<T> {
|
||||
pub value: T,
|
||||
pub label: String,
|
||||
pub description: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub disabled_reason: Option<String>,
|
||||
}
|
||||
pub struct RadioGroup<T: Clone + Eq> {
|
||||
items: Vec<RadioItem<T>>,
|
||||
selected: T,
|
||||
default: T,
|
||||
focused_index: usize,
|
||||
focus_policy: DisabledFocusPolicy,
|
||||
wrap_navigation: bool,
|
||||
disabled_selection_policy: DisabledSelectionPolicy,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RadioGroupError {
|
||||
Empty,
|
||||
DefaultMissing,
|
||||
DefaultDisabled,
|
||||
NoEnabledItems,
|
||||
SelectedItemDisabled,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RadioChange<T> {
|
||||
Changed { previous: T, selected: T },
|
||||
Unchanged(T),
|
||||
IgnoredDisabled(T),
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DisabledSelectionPolicy {
|
||||
UseConfiguredDefault,
|
||||
UseFirstEnabled,
|
||||
ReturnError,
|
||||
}
|
||||
impl<T: Clone + Eq> RadioGroup<T> {
|
||||
pub fn new(
|
||||
items: Vec<RadioItem<T>>,
|
||||
observed: Option<T>,
|
||||
default: T,
|
||||
) -> Result<Self, RadioGroupError> {
|
||||
if items.is_empty() {
|
||||
return Err(RadioGroupError::Empty);
|
||||
}
|
||||
let default_item = items
|
||||
.iter()
|
||||
.find(|item| item.value == default)
|
||||
.ok_or(RadioGroupError::DefaultMissing)?;
|
||||
if !default_item.enabled {
|
||||
return Err(RadioGroupError::DefaultDisabled);
|
||||
}
|
||||
let focused_index = items
|
||||
.iter()
|
||||
.position(|item| item.enabled)
|
||||
.ok_or(RadioGroupError::NoEnabledItems)?;
|
||||
let selected = observed
|
||||
.filter(|value| {
|
||||
items
|
||||
.iter()
|
||||
.any(|item| item.enabled && item.value == *value)
|
||||
})
|
||||
.unwrap_or_else(|| default.clone());
|
||||
Ok(Self {
|
||||
items,
|
||||
selected,
|
||||
default,
|
||||
focused_index,
|
||||
focus_policy: DisabledFocusPolicy::Skip,
|
||||
wrap_navigation: true,
|
||||
disabled_selection_policy: DisabledSelectionPolicy::UseConfiguredDefault,
|
||||
})
|
||||
}
|
||||
pub fn items(&self) -> &[RadioItem<T>] {
|
||||
&self.items
|
||||
}
|
||||
pub fn selected(&self) -> &T {
|
||||
&self.selected
|
||||
}
|
||||
pub fn focused_item(&self) -> &RadioItem<T> {
|
||||
&self.items[self.focused_index]
|
||||
}
|
||||
pub fn focus_next(&mut self) {
|
||||
self.move_focus(true);
|
||||
}
|
||||
pub fn focus_previous(&mut self) {
|
||||
self.move_focus(false);
|
||||
}
|
||||
fn move_focus(&mut self, forward: bool) {
|
||||
for step in 1..=self.items.len() {
|
||||
let raw = self.focused_index as isize
|
||||
+ if forward {
|
||||
step as isize
|
||||
} else {
|
||||
-(step as isize)
|
||||
};
|
||||
let next = if self.wrap_navigation {
|
||||
raw.rem_euclid(self.items.len() as isize) as usize
|
||||
} else if raw < 0 || raw >= self.items.len() as isize {
|
||||
return;
|
||||
} else {
|
||||
raw as usize
|
||||
};
|
||||
if self.focus_policy == DisabledFocusPolicy::Include || self.items[next].enabled {
|
||||
self.focused_index = next;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn select_focused(&mut self) -> RadioChange<T> {
|
||||
let item = self.focused_item();
|
||||
let enabled = item.enabled;
|
||||
let value = item.value.clone();
|
||||
if !enabled {
|
||||
return RadioChange::IgnoredDisabled(value);
|
||||
}
|
||||
if value == self.selected {
|
||||
RadioChange::Unchanged(self.selected.clone())
|
||||
} else {
|
||||
let previous = std::mem::replace(&mut self.selected, value);
|
||||
RadioChange::Changed {
|
||||
previous,
|
||||
selected: self.selected.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn visual_state(&self, value: &T) -> ChoiceVisualState {
|
||||
let item = self.items.iter().position(|item| &item.value == value);
|
||||
ChoiceVisualState {
|
||||
selected: &self.selected == value,
|
||||
focused: item == Some(self.focused_index),
|
||||
enabled: item
|
||||
.and_then(|index| self.items.get(index))
|
||||
.is_some_and(|item| item.enabled),
|
||||
}
|
||||
}
|
||||
pub fn set_disabled_selection_policy(&mut self, policy: DisabledSelectionPolicy) {
|
||||
self.disabled_selection_policy = policy;
|
||||
}
|
||||
pub fn set_focus_policy(&mut self, policy: DisabledFocusPolicy) {
|
||||
self.focus_policy = policy;
|
||||
}
|
||||
pub fn set_wrap_navigation(&mut self, wrap: bool) {
|
||||
self.wrap_navigation = wrap;
|
||||
}
|
||||
pub fn set_enabled(&mut self, value: &T, enabled: bool) -> Result<(), RadioGroupError> {
|
||||
let Some(index) = self.items.iter().position(|item| &item.value == value) else {
|
||||
return Ok(());
|
||||
};
|
||||
if self.items[index].enabled == enabled {
|
||||
return Ok(());
|
||||
}
|
||||
if !enabled
|
||||
&& self
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(other, item)| other == index || !item.enabled)
|
||||
{
|
||||
return Err(RadioGroupError::NoEnabledItems);
|
||||
}
|
||||
if !enabled && self.selected == *value {
|
||||
let replacement = match self.disabled_selection_policy {
|
||||
DisabledSelectionPolicy::UseConfiguredDefault if self.default != *value => self
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| item.enabled && item.value == self.default)
|
||||
.map(|item| item.value.clone()),
|
||||
DisabledSelectionPolicy::UseConfiguredDefault => None,
|
||||
DisabledSelectionPolicy::UseFirstEnabled => self
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find(|(other, item)| *other != index && item.enabled)
|
||||
.map(|(_, item)| item.value.clone()),
|
||||
DisabledSelectionPolicy::ReturnError => {
|
||||
return Err(RadioGroupError::SelectedItemDisabled);
|
||||
}
|
||||
};
|
||||
self.selected = replacement.ok_or(RadioGroupError::SelectedItemDisabled)?;
|
||||
}
|
||||
self.items[index].enabled = enabled;
|
||||
if !enabled && self.focused_index == index && self.focus_policy == DisabledFocusPolicy::Skip
|
||||
{
|
||||
self.focus_next();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn default(&self) -> &T {
|
||||
&self.default
|
||||
}
|
||||
}
|
||||
53
iota-cli/src/controls/scroll.rs
Normal file
53
iota-cli/src/controls/scroll.rs
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
519
iota-cli/src/elements/console_card.rs
Normal file
519
iota-cli/src/elements/console_card.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)]
|
||||
287
iota-cli/src/elements/graph_card.rs
Normal file
287
iota-cli/src/elements/graph_card.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 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,
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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(×tamp);
|
||||
}
|
||||
|
||||
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 block = Block::default()
|
||||
.title(self.build_title())
|
||||
.borders(self.borders)
|
||||
.border_style(if self.focused {
|
||||
Style::default().fg(Color::Yellow)
|
||||
} else {
|
||||
Style::default()
|
||||
});
|
||||
|
||||
let inner_area = block.inner(area);
|
||||
f.render_widget(block, area);
|
||||
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 {
|
||||
context.theme.logs.focused_border
|
||||
} else {
|
||||
context.theme.logs.border
|
||||
});
|
||||
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
|
||||
}
|
||||
219
iota-cli/src/help_overlay.rs
Normal file
219
iota-cli/src/help_overlay.rs
Normal 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;
|
||||
61
iota-cli/src/input_handler.rs
Normal file
61
iota-cli/src/input_handler.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
845
iota-cli/src/ipc_client.rs
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
52
iota-cli/src/layout/fit.rs
Normal file
52
iota-cli/src/layout/fit.rs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
use ratatui::layout::Rect;
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RequiredSize {
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FitLevel {
|
||||
Preferred,
|
||||
Compact,
|
||||
Fallback,
|
||||
}
|
||||
pub fn select_fit_level(area: Rect, preferred: RequiredSize, compact: RequiredSize) -> FitLevel {
|
||||
if area.width >= preferred.width && area.height >= preferred.height {
|
||||
FitLevel::Preferred
|
||||
} else if area.width >= compact.width && area.height >= compact.height {
|
||||
FitLevel::Compact
|
||||
} else {
|
||||
FitLevel::Fallback
|
||||
}
|
||||
}
|
||||
pub fn centered_rect(area: Rect, maximum: RequiredSize) -> Rect {
|
||||
let width = area.width.min(maximum.width);
|
||||
let height = area.height.min(maximum.height);
|
||||
Rect {
|
||||
x: area.x.saturating_add(area.width.saturating_sub(width) / 2),
|
||||
y: area
|
||||
.y
|
||||
.saturating_add(area.height.saturating_sub(height) / 2),
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
pub fn reserve_vertical(area: Rect, top: u16, bottom: u16) -> Option<Rect> {
|
||||
let height = area.height.checked_sub(top)?.checked_sub(bottom)?;
|
||||
Some(Rect {
|
||||
x: area.x,
|
||||
y: area.y.checked_add(top)?,
|
||||
width: area.width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
pub fn inset_checked(area: Rect, horizontal: u16, vertical: u16) -> Option<Rect> {
|
||||
let width = area.width.checked_sub(horizontal.checked_mul(2)?)?;
|
||||
let height = area.height.checked_sub(vertical.checked_mul(2)?)?;
|
||||
Some(Rect {
|
||||
x: area.x.checked_add(horizontal)?,
|
||||
y: area.y.checked_add(vertical)?,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
2
iota-cli/src/layout/mod.rs
Normal file
2
iota-cli/src/layout/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod fit;
|
||||
pub mod text_measure;
|
||||
10
iota-cli/src/layout/text_measure.rs
Normal file
10
iota-cli/src/layout/text_measure.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
use unicode_width::UnicodeWidthStr;
|
||||
pub fn wrapped_line_count(text: &str, width: u16) -> u16 {
|
||||
if width == 0 {
|
||||
return 0;
|
||||
}
|
||||
text.split('\n')
|
||||
.map(|line| (UnicodeWidthStr::width(line).max(1) + width as usize - 1) / width as usize)
|
||||
.sum::<usize>()
|
||||
.min(u16::MAX as usize) as u16
|
||||
}
|
||||
|
|
@ -5,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;
|
||||
128
iota-cli/src/notification.rs
Normal file
128
iota-cli/src/notification.rs
Normal 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(¬ification.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 = ¬ifications[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);
|
||||
}
|
||||
}
|
||||
6
iota-cli/src/render_context.rs
Normal file
6
iota-cli/src/render_context.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
use crate::theme::ResolvedTheme;
|
||||
|
||||
/// Immutable state shared by every component during one render pass.
|
||||
pub struct RenderContext<'a> {
|
||||
pub theme: &'a ResolvedTheme,
|
||||
}
|
||||
302
iota-cli/src/screens/daemon_setup.rs
Normal file
302
iota-cli/src/screens/daemon_setup.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
515
iota-cli/src/screens/main_screen.rs
Normal file
515
iota-cli/src/screens/main_screen.rs
Normal 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",
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
136
iota-cli/src/screens/metrics.rs
Normal file
136
iota-cli/src/screens/metrics.rs
Normal 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",
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
302
iota-cli/src/screens/overview.rs
Normal file
302
iota-cli/src/screens/overview.rs
Normal 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",
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
143
iota-cli/src/screens/screens.rs
Normal file
143
iota-cli/src/screens/screens.rs
Normal 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",
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
475
iota-cli/src/screens/settings.rs
Normal file
475
iota-cli/src/screens/settings.rs
Normal 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",
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +1,32 @@
|
|||
use crate::{
|
||||
gui::{
|
||||
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},
|
||||
controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line},
|
||||
interaction_result::InteractionResult,
|
||||
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 screen: FileViewer =
|
||||
FileViewer::new(terms_type.to_string(), &content);
|
||||
Box::new(screen) as Box<dyn Screen>
|
||||
});
|
||||
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,
|
||||
|
|
@ -1,28 +1,33 @@
|
|||
use crate::screens::terms_checker::UserChoice;
|
||||
use crate::{
|
||||
gui::{
|
||||
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},
|
||||
controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line},
|
||||
interaction_result::InteractionResult,
|
||||
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();
|
||||
Box::new(FileViewer::new(terms_type.to_string(), &content))
|
||||
as Box<dyn Screen>
|
||||
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,
|
||||
741
iota-cli/src/screens/users.rs
Normal file
741
iota-cli/src/screens/users.rs
Normal 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",
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
314
iota-cli/src/theme/config.rs
Normal file
314
iota-cli/src/theme/config.rs
Normal 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
83
iota-cli/src/theme/mod.rs
Normal 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
168
iota-cli/src/theme/model.rs
Normal 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
|
||||
}
|
||||
}
|
||||
47
iota-cli/src/theme/name.rs
Normal file
47
iota-cli/src/theme/name.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
use std::{fmt, str::FromStr};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ThemeName {
|
||||
Monospace,
|
||||
Binary,
|
||||
#[default]
|
||||
Ansi,
|
||||
Surface,
|
||||
}
|
||||
|
||||
impl ThemeName {
|
||||
pub const ALL: [Self; 4] = [Self::Monospace, Self::Binary, Self::Ansi, Self::Surface];
|
||||
|
||||
pub fn supported_names() -> &'static str {
|
||||
"monospace, binary, ansi, surface"
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ThemeName {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Monospace => "monospace",
|
||||
Self::Binary => "binary",
|
||||
Self::Ansi => "ansi",
|
||||
Self::Surface => "surface",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ThemeName {
|
||||
type Err = String;
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"monospace" => Ok(Self::Monospace),
|
||||
"binary" => Ok(Self::Binary),
|
||||
"ansi" => Ok(Self::Ansi),
|
||||
"surface" => Ok(Self::Surface),
|
||||
_ => Err(format!(
|
||||
"unknown theme `{value}`; supported themes: {}",
|
||||
Self::supported_names()
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
324
iota-cli/src/theme/presets.rs
Normal file
324
iota-cli/src/theme/presets.rs
Normal 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
0
iota-cli/src/tui.rs
Normal file
728
iota-cli/src/ui.rs
Normal file
728
iota-cli/src/ui.rs
Normal 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,
|
||||
¬ifications,
|
||||
context.theme,
|
||||
);
|
||||
}
|
||||
}
|
||||
})?;
|
||||
if let Ok(mut current) = self.hits.lock() {
|
||||
*current = hits;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -3,13 +3,20 @@ use ratatui::prelude::*;
|
|||
use ratatui::style::Style;
|
||||
use ratatui::widgets::Borders;
|
||||
|
||||
fn set_join_char(frame: &mut Frame, x: u16, y: u16, c: char) {
|
||||
frame
|
||||
.buffer_mut()
|
||||
.set_string(x, y, c.to_string(), Style::default());
|
||||
fn set_join_char(frame: &mut Frame, x: u16, y: u16, c: char, style: Style) {
|
||||
frame.buffer_mut().set_string(x, y, c.to_string(), style);
|
||||
}
|
||||
|
||||
pub fn draw_block_joins(frame: &mut Frame, area: Rect, borders: Borders, joins: Borders) {
|
||||
pub fn draw_block_joins(
|
||||
frame: &mut Frame,
|
||||
area: Rect,
|
||||
borders: Borders,
|
||||
joins: Borders,
|
||||
style: Style,
|
||||
) {
|
||||
if area.width == 0 || area.height == 0 {
|
||||
return;
|
||||
}
|
||||
let x0 = area.x;
|
||||
let y0 = area.y;
|
||||
let x1 = area.x + area.width - 1;
|
||||
|
|
@ -22,7 +29,7 @@ pub fn draw_block_joins(frame: &mut Frame, area: Rect, borders: Borders, joins:
|
|||
(false, true) => '┬',
|
||||
(false, false) => '┌',
|
||||
};
|
||||
set_join_char(frame, x0, y0, top_left);
|
||||
set_join_char(frame, x0, y0, top_left, style);
|
||||
}
|
||||
|
||||
if borders.contains(Borders::TOP) && borders.contains(Borders::RIGHT) {
|
||||
|
|
@ -32,7 +39,7 @@ pub fn draw_block_joins(frame: &mut Frame, area: Rect, borders: Borders, joins:
|
|||
(false, true) => '┬',
|
||||
(false, false) => '┐',
|
||||
};
|
||||
set_join_char(frame, x1, y0, top_right);
|
||||
set_join_char(frame, x1, y0, top_right, style);
|
||||
}
|
||||
|
||||
if borders.contains(Borders::BOTTOM) && borders.contains(Borders::LEFT) {
|
||||
|
|
@ -45,7 +52,7 @@ pub fn draw_block_joins(frame: &mut Frame, area: Rect, borders: Borders, joins:
|
|||
(false, true) => '┴',
|
||||
(false, false) => '└',
|
||||
};
|
||||
set_join_char(frame, x0, y1, bottom_left);
|
||||
set_join_char(frame, x0, y1, bottom_left, style);
|
||||
}
|
||||
|
||||
if borders.contains(Borders::BOTTOM) && borders.contains(Borders::RIGHT) {
|
||||
|
|
@ -58,6 +65,6 @@ pub fn draw_block_joins(frame: &mut Frame, area: Rect, borders: Borders, joins:
|
|||
(false, true) => '┴',
|
||||
(false, false) => '┘',
|
||||
};
|
||||
set_join_char(frame, x1, y1, bottom_right);
|
||||
set_join_char(frame, x1, y1, bottom_right, style);
|
||||
}
|
||||
}
|
||||
75
iota-cli/src/util/buttons.rs
Normal file
75
iota-cli/src/util/buttons.rs
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
15
iota-cli/tests/button_layout.rs
Normal file
15
iota-cli/tests/button_layout.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
use iota_cli::controls::button::{button_minimum_width, horizontal_button_widths};
|
||||
|
||||
#[test]
|
||||
fn width_allocation_handles_exact_spare_and_insufficient_space() {
|
||||
assert_eq!(horizontal_button_widths(7, &[3, 4]), Some(vec![3, 4]));
|
||||
assert_eq!(horizontal_button_widths(10, &[3, 4]), Some(vec![5, 5]));
|
||||
assert_eq!(horizontal_button_widths(6, &[3, 4]), None);
|
||||
assert_eq!(horizontal_button_widths(10, &[]), Some(Vec::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimum_width_uses_terminal_columns() {
|
||||
assert_eq!(button_minimum_width("é"), 3);
|
||||
assert_eq!(button_minimum_width("界"), 4);
|
||||
}
|
||||
71
iota-cli/tests/choice_rendering.rs
Normal file
71
iota-cli/tests/choice_rendering.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
use iota_cli::{
|
||||
controls::choice::{ChoiceKind, ChoiceVisualState, render_choice_line},
|
||||
theme::{ThemeName, resolve},
|
||||
};
|
||||
use ratatui::style::{Color, Modifier};
|
||||
|
||||
#[test]
|
||||
fn ansi_checkbox_matches_the_existing_focused_and_disabled_styles() {
|
||||
let theme = resolve(ThemeName::Ansi);
|
||||
let line = render_choice_line(
|
||||
"Terms",
|
||||
ChoiceKind::Checkbox,
|
||||
ChoiceVisualState {
|
||||
selected: false,
|
||||
focused: true,
|
||||
enabled: true,
|
||||
},
|
||||
&theme,
|
||||
);
|
||||
assert_eq!(
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>(),
|
||||
"[ ] Terms"
|
||||
);
|
||||
assert_eq!(line.spans[1].style.fg, Some(Color::Yellow));
|
||||
assert!(line.spans[1].style.add_modifier.contains(Modifier::BOLD));
|
||||
|
||||
let disabled = render_choice_line(
|
||||
"Terms",
|
||||
ChoiceKind::Checkbox,
|
||||
ChoiceVisualState {
|
||||
selected: false,
|
||||
focused: true,
|
||||
enabled: false,
|
||||
},
|
||||
&theme,
|
||||
);
|
||||
assert_eq!(disabled.spans[1].style.fg, Some(Color::DarkGray));
|
||||
assert_eq!(disabled.spans[3].style.fg, Some(Color::Red));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colourless_themes_keep_state_and_focus_visible() {
|
||||
for name in [ThemeName::Monospace, ThemeName::Binary] {
|
||||
let theme = resolve(name);
|
||||
let line = render_choice_line(
|
||||
"Mode",
|
||||
ChoiceKind::Radio,
|
||||
ChoiceVisualState {
|
||||
selected: true,
|
||||
focused: true,
|
||||
enabled: true,
|
||||
},
|
||||
&theme,
|
||||
);
|
||||
assert_eq!(
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>(),
|
||||
"> (x) Mode <"
|
||||
);
|
||||
assert!(
|
||||
line.spans
|
||||
.iter()
|
||||
.all(|span| span.style.fg.is_none() && span.style.bg.is_none())
|
||||
);
|
||||
}
|
||||
}
|
||||
109
iota-cli/tests/control_state.rs
Normal file
109
iota-cli/tests/control_state.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
use iota_cli::controls::{
|
||||
checkbox_group::{CheckboxChange, CheckboxGroup, CheckboxItem},
|
||||
navigation::DisabledFocusPolicy,
|
||||
radio_group::{DisabledSelectionPolicy, RadioChange, RadioGroup, RadioGroupError, RadioItem},
|
||||
};
|
||||
|
||||
fn checkbox(value: u8, enabled: bool) -> CheckboxItem<u8> {
|
||||
CheckboxItem {
|
||||
value,
|
||||
label: value.to_string(),
|
||||
description: None,
|
||||
enabled,
|
||||
disabled_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn radio(value: u8, enabled: bool) -> RadioItem<u8> {
|
||||
RadioItem {
|
||||
value,
|
||||
label: value.to_string(),
|
||||
description: None,
|
||||
enabled,
|
||||
disabled_reason: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkbox_selection_and_disabled_focus_are_independent() {
|
||||
let mut group = CheckboxGroup::new(
|
||||
vec![checkbox(1, true), checkbox(2, false), checkbox(3, true)],
|
||||
[1, 99],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
group.selected().iter().copied().collect::<Vec<_>>(),
|
||||
vec![1]
|
||||
);
|
||||
assert_eq!(group.toggle_focused(), CheckboxChange::Deselected(1));
|
||||
group.focus_next();
|
||||
assert_eq!(group.focused_item().unwrap().value, 3);
|
||||
group.set_focus_policy(DisabledFocusPolicy::Include);
|
||||
group.focus_previous();
|
||||
assert_eq!(group.focused_item().unwrap().value, 2);
|
||||
assert_eq!(group.toggle_focused(), CheckboxChange::IgnoredDisabled(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkbox_non_wrapping_navigation_stops_at_the_edge() {
|
||||
let mut group = CheckboxGroup::new(vec![checkbox(1, true), checkbox(2, true)], []).unwrap();
|
||||
group.set_wrap_navigation(false);
|
||||
group.focus_previous();
|
||||
assert_eq!(group.focused_item().unwrap().value, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radio_validates_default_and_preserves_one_selection() {
|
||||
assert!(matches!(
|
||||
RadioGroup::new(Vec::<RadioItem<u8>>::new(), None, 1),
|
||||
Err(RadioGroupError::Empty)
|
||||
));
|
||||
assert!(matches!(
|
||||
RadioGroup::new(vec![radio(1, true)], None, 2),
|
||||
Err(RadioGroupError::DefaultMissing)
|
||||
));
|
||||
assert!(matches!(
|
||||
RadioGroup::new(vec![radio(1, false)], None, 1),
|
||||
Err(RadioGroupError::DefaultDisabled)
|
||||
));
|
||||
|
||||
let mut group = RadioGroup::new(vec![radio(1, true), radio(2, true)], Some(2), 1).unwrap();
|
||||
assert_eq!(group.selected(), &2);
|
||||
group.focus_next();
|
||||
assert_eq!(group.selected(), &2);
|
||||
assert_eq!(group.select_focused(), RadioChange::Unchanged(2));
|
||||
group.focus_previous();
|
||||
assert_eq!(
|
||||
group.select_focused(),
|
||||
RadioChange::Changed {
|
||||
previous: 2,
|
||||
selected: 1
|
||||
}
|
||||
);
|
||||
assert_eq!(group.selected(), &1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groups_initially_focus_the_first_enabled_item() {
|
||||
let checkboxes = CheckboxGroup::new(vec![checkbox(1, false), checkbox(2, true)], []).unwrap();
|
||||
assert_eq!(checkboxes.focused_item().unwrap().value, 2);
|
||||
let radios = RadioGroup::new(vec![radio(1, false), radio(2, true)], None, 2).unwrap();
|
||||
assert_eq!(radios.focused_item().value, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_a_selected_radio_obeys_the_configured_policy() {
|
||||
let mut group = RadioGroup::new(vec![radio(1, true), radio(2, true)], Some(2), 1).unwrap();
|
||||
group.set_enabled(&2, false).unwrap();
|
||||
assert_eq!(group.selected(), &1);
|
||||
|
||||
group.set_enabled(&2, true).unwrap();
|
||||
group.focus_next();
|
||||
group.select_focused();
|
||||
group.set_disabled_selection_policy(DisabledSelectionPolicy::ReturnError);
|
||||
assert_eq!(
|
||||
group.set_enabled(&2, false),
|
||||
Err(RadioGroupError::SelectedItemDisabled)
|
||||
);
|
||||
assert_eq!(group.selected(), &2);
|
||||
}
|
||||
45
iota-cli/tests/layout_fit.rs
Normal file
45
iota-cli/tests/layout_fit.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
use iota_cli::layout::fit::{
|
||||
FitLevel, RequiredSize, centered_rect, inset_checked, reserve_vertical, select_fit_level,
|
||||
};
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
#[test]
|
||||
fn selects_fit_by_both_dimensions() {
|
||||
let preferred = RequiredSize {
|
||||
width: 80,
|
||||
height: 20,
|
||||
};
|
||||
let compact = RequiredSize {
|
||||
width: 50,
|
||||
height: 12,
|
||||
};
|
||||
assert_eq!(
|
||||
select_fit_level(Rect::new(0, 0, 80, 20), preferred, compact),
|
||||
FitLevel::Preferred
|
||||
);
|
||||
assert_eq!(
|
||||
select_fit_level(Rect::new(0, 0, 50, 12), preferred, compact),
|
||||
FitLevel::Compact
|
||||
);
|
||||
assert_eq!(
|
||||
select_fit_level(Rect::new(0, 0, 80, 11), preferred, compact),
|
||||
FitLevel::Fallback
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangle_helpers_do_not_underflow() {
|
||||
let zero = Rect::new(4, 5, 0, 0);
|
||||
assert_eq!(
|
||||
centered_rect(
|
||||
zero,
|
||||
RequiredSize {
|
||||
width: 10,
|
||||
height: 10
|
||||
}
|
||||
),
|
||||
zero
|
||||
);
|
||||
assert_eq!(reserve_vertical(zero, 1, 0), None);
|
||||
assert_eq!(inset_checked(zero, 1, 1), None);
|
||||
}
|
||||
78
iota-cli/tests/settings_snapshot.rs
Normal file
78
iota-cli/tests/settings_snapshot.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
12
iota-connection/Cargo.toml
Normal file
12
iota-connection/Cargo.toml
Normal 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"] }
|
||||
36
iota-connection/src/connection_handler.rs
Normal file
36
iota-connection/src/connection_handler.rs
Normal 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;
|
||||
}
|
||||
4
iota-connection/src/lib.rs
Normal file
4
iota-connection/src/lib.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
pub mod connection_handler;
|
||||
pub mod message_common;
|
||||
pub mod message_handlers;
|
||||
pub mod relay;
|
||||
156
iota-connection/src/message_common.rs
Normal file
156
iota-connection/src/message_common.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
1784
iota-connection/src/message_handlers.rs
Normal file
1784
iota-connection/src/message_handlers.rs
Normal file
File diff suppressed because it is too large
Load diff
383
iota-connection/src/relay.rs
Normal file
383
iota-connection/src/relay.rs
Normal 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
18
iota-core/Cargo.toml
Normal 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"] }
|
||||
|
|
@ -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.accepted_eula = true;
|
||||
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);
|
||||
}
|
||||
if matches!(result, UserChoice::AcceptAll) {
|
||||
state.accepted_tos = true;
|
||||
state.accepted_pp = true;
|
||||
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
1
iota-core/src/lib.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod consent_state;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
25
iota-daemon-lib/Cargo.toml
Normal file
25
iota-daemon-lib/Cargo.toml
Normal 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"
|
||||
590
iota-daemon-lib/src/command_router.rs
Normal file
590
iota-daemon-lib/src/command_router.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
305
iota-daemon-lib/src/daemon_state.rs
Normal file
305
iota-daemon-lib/src/daemon_state.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
37
iota-daemon-lib/src/deployment.rs
Normal file
37
iota-daemon-lib/src/deployment.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
use iota_ipc::{DeploymentMode, SupervisorKind};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct DeploymentContext {
|
||||
pub mode: DeploymentMode,
|
||||
pub supervisor: SupervisorKind,
|
||||
}
|
||||
|
||||
impl Default for DeploymentContext {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: DeploymentMode::External,
|
||||
supervisor: SupervisorKind::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_environment() -> DeploymentContext {
|
||||
let mut mode = match std::env::var("IOTA_DEPLOYMENT_MODE").ok().as_deref() {
|
||||
Some("session_child") => DeploymentMode::SessionChild,
|
||||
Some("ui_auto_start") => DeploymentMode::UiAutoStart,
|
||||
Some("user_service") => DeploymentMode::UserService,
|
||||
Some("system_socket_activated") => DeploymentMode::SystemSocketActivated,
|
||||
Some("system_always_on") => DeploymentMode::SystemAlwaysOn,
|
||||
_ => DeploymentMode::External,
|
||||
};
|
||||
if std::env::var("LISTEN_FDS").ok().as_deref() == Some("1") {
|
||||
mode = DeploymentMode::SystemSocketActivated;
|
||||
}
|
||||
let supervisor = match std::env::var("IOTA_SUPERVISOR").ok().as_deref() {
|
||||
Some("iota_ui") => SupervisorKind::IotaUi,
|
||||
Some("systemd") => SupervisorKind::Systemd,
|
||||
Some("external") => SupervisorKind::External,
|
||||
_ => SupervisorKind::None,
|
||||
};
|
||||
DeploymentContext { mode, supervisor }
|
||||
}
|
||||
745
iota-daemon-lib/src/ipc_server.rs
Normal file
745
iota-daemon-lib/src/ipc_server.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
14
iota-daemon-lib/src/lib.rs
Normal file
14
iota-daemon-lib/src/lib.rs
Normal 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
Loading…
Reference in a new issue