Compare commits
232 changed files with 7152 additions and 40515 deletions
|
|
@ -1,2 +0,0 @@
|
||||||
[env]
|
|
||||||
MTP_TYPE_MAPS = { value = "mtp-type-maps/type-maps.yaml", relative = true }
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
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
Normal file
23
.github/workflows/build.yml
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
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,9 +1,2 @@
|
||||||
target
|
target
|
||||||
logs
|
logs
|
||||||
agreements
|
|
||||||
languages/
|
|
||||||
.envrc
|
|
||||||
.direnv
|
|
||||||
config.json
|
|
||||||
*.mk
|
|
||||||
*.sqlite*
|
|
||||||
|
|
|
||||||
3
.gitmodules
vendored
3
.gitmodules
vendored
|
|
@ -1,3 +0,0 @@
|
||||||
[submodule "mtp-type-maps"]
|
|
||||||
path = mtp-type-maps
|
|
||||||
url = ssh://git@git.methanium.net/tensamin/mtp-type-maps
|
|
||||||
3020
Cargo.lock
generated
3020
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
80
Cargo.toml
80
Cargo.toml
|
|
@ -1,26 +1,56 @@
|
||||||
[workspace]
|
[package]
|
||||||
members = [
|
name = "iota"
|
||||||
"iota-storage",
|
version = "0.1.0"
|
||||||
"iota-connection",
|
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",
|
||||||
"client",
|
"client",
|
||||||
"iota-auth",
|
"full",
|
||||||
"other-iota",
|
"http1",
|
||||||
"iota-updater",
|
"http2",
|
||||||
"iota-terms",
|
"nightly",
|
||||||
"iota-state",
|
"server",
|
||||||
"iota-cli",
|
] }
|
||||||
"iota",
|
json = "*"
|
||||||
"iota-daemon",
|
once_cell = "1.21.3"
|
||||||
"iota-daemon-lib",
|
rand = "0.8"
|
||||||
"iota-ipc",
|
rand_core = { version = "0.6", features = ["getrandom", "std"] }
|
||||||
"omikron-connector",
|
reqwest = "0.13.2"
|
||||||
"web-server",
|
rustls = { version = "0.23.37", features = ["aws-lc-rs"] }
|
||||||
"web-ui",
|
sha2 = "0.10.9"
|
||||||
"iota-logger",
|
sysinfo = "0.38.3"
|
||||||
"iota-util",
|
tokio = { version = "1.50.0", features = ["full"] }
|
||||||
"iota-process-manager",
|
tokio-tungstenite = { version = "*", features = ["native-tls"] }
|
||||||
"iota-paths",
|
tungstenite = "*"
|
||||||
"iota-installer",
|
uuid = { version = "*", features = ["v4"] }
|
||||||
"iota-core",
|
walkdir = "2.5.0"
|
||||||
]
|
warp = "*"
|
||||||
resolver = "3"
|
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"
|
||||||
|
|
|
||||||
25
LICENSE
25
LICENSE
|
|
@ -1,15 +1,16 @@
|
||||||
Copyright (c) 2025 Methanium
|
Copyright (c) [2025] [Methanium]
|
||||||
|
|
||||||
All rights reserved.
|
All rights reserved.
|
||||||
|
|
||||||
No part of this software, source code, documentation, or
|
This software is protected by copyright. Copying, editing,
|
||||||
associated materials may be copied, reproduced, modified,
|
distributing, publicly performing, or any other use of this software
|
||||||
distributed, published, sublicensed, sold, or used to create
|
or its components, in source or binary form, is strictly prohibited without the express
|
||||||
derivative works without prior written permission from the
|
written permission of the copyright holder.
|
||||||
copyright holder.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY
|
FUTURE LICENSE ACCEPTANCE:
|
||||||
OF ANY KIND, EXPRESS OR IMPLIED. TO THE MAXIMUM
|
It is the copyright holder's intention to release this software in the future
|
||||||
EXTENT PERMITTED BY LAW, THE COPYRIGHT HOLDER SHALL
|
under a license yet to be defined, which will, among other things,
|
||||||
NOT BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
|
allow private, non-commercial use. This statement does not constitute
|
||||||
LIABILITY ARISING FROM THE SOFTWARE OR ITS USE.
|
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.
|
||||||
|
|
|
||||||
62
README.md
62
README.md
|
|
@ -4,65 +4,3 @@ A lightweight, Rust-based TUI and service orchestrator for Tensamin IOTA.
|
||||||
Iota manages users and stores their messages and communities. It can be run in a centralised, decentralised or hybrid mode.
|
Iota manages users and stores their messages and communities. It can be run in a centralised, decentralised or hybrid mode.
|
||||||
|
|
||||||
The Iota is a work in progress.
|
The Iota is a work in progress.
|
||||||
|
|
||||||
## Terminal themes
|
|
||||||
|
|
||||||
The TUI defaults to the ANSI theme. Select a theme for one invocation with `--theme`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
iota --theme monospace
|
|
||||||
iota --theme binary status
|
|
||||||
```
|
|
||||||
|
|
||||||
The available names are `monospace`, `binary`, `ansi`, and `surface`. Theme selection uses this precedence: `--theme`, `IOTA_THEME`, then `ui.yaml` in Iota's configuration directory. For example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
IOTA_THEME=surface iota
|
|
||||||
```
|
|
||||||
|
|
||||||
On Linux, the configuration file defaults to `~/.config/iota/ui.yaml` (or `$XDG_CONFIG_HOME/iota/ui.yaml` when set):
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
theme: surface
|
|
||||||
```
|
|
||||||
|
|
||||||
An invalid `ui.yaml` value is reported and Iota falls back to ANSI so the TUI can still start.
|
|
||||||
|
|
||||||
## 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`.
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
[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"] }
|
|
||||||
|
|
@ -1,497 +0,0 @@
|
||||||
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()
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
mod client_connection;
|
|
||||||
pub use client_connection::ClientConnection;
|
|
||||||
5236
communities/Cargo.lock
generated
5236
communities/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,16 +0,0 @@
|
||||||
[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 = "*" }
|
|
||||||
|
|
@ -4,7 +4,7 @@ FROM rust:latest AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN cargo build --release -p iota-daemon
|
RUN cargo build --release
|
||||||
|
|
||||||
# Runtime stage
|
# Runtime stage
|
||||||
FROM debian:sid
|
FROM debian:sid
|
||||||
|
|
@ -13,10 +13,8 @@ WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=builder /app/target/release/iota-daemon .
|
COPY --from=builder /app/target/release/iota .
|
||||||
|
|
||||||
RUN useradd -r -s /bin/false iota && mkdir -p /run/iota && chown iota:iota /run/iota
|
|
||||||
|
|
||||||
EXPOSE 1984
|
EXPOSE 1984
|
||||||
|
|
||||||
CMD ["./iota-daemon"]
|
CMD ["./iota"]
|
||||||
82
flake.lock
generated
82
flake.lock
generated
|
|
@ -1,82 +0,0 @@
|
||||||
{
|
|
||||||
"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
275
flake.nix
|
|
@ -1,275 +0,0 @@
|
||||||
{
|
|
||||||
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];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "iota-auth"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
json = "*"
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
[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 +0,0 @@
|
||||||
pub use iota_state::*;
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum ControlAction {
|
|
||||||
FocusNext,
|
|
||||||
FocusPrevious,
|
|
||||||
Select,
|
|
||||||
Activate,
|
|
||||||
}
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
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),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
@ -1,267 +0,0 @@
|
||||||
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",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,164 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
||||||
pub enum DisabledFocusPolicy {
|
|
||||||
Include,
|
|
||||||
#[default]
|
|
||||||
Skip,
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,194 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,519 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,287 +0,0 @@
|
||||||
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,219 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,845 +0,0 @@
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
pub mod fit;
|
|
||||||
pub mod text_measure;
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
use crate::theme::ResolvedTheme;
|
|
||||||
|
|
||||||
/// Immutable state shared by every component during one render pass.
|
|
||||||
pub struct RenderContext<'a> {
|
|
||||||
pub theme: &'a ResolvedTheme,
|
|
||||||
}
|
|
||||||
|
|
@ -1,302 +0,0 @@
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,515 +0,0 @@
|
||||||
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,136 +0,0 @@
|
||||||
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",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,302 +0,0 @@
|
||||||
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",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,143 +0,0 @@
|
||||||
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",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,475 +0,0 @@
|
||||||
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,741 +0,0 @@
|
||||||
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",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,314 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,168 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
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()
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,324 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,728 +0,0 @@
|
||||||
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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
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())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
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"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
[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"] }
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
pub mod connection_handler;
|
|
||||||
pub mod message_common;
|
|
||||||
pub mod message_handlers;
|
|
||||||
pub mod relay;
|
|
||||||
|
|
@ -1,156 +0,0 @@
|
||||||
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
|
|
||||||
use mtp::type_map::TypeMap;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
pub use iota_util::mtp_compat::{MtpFieldError, OptionalDataValueExt, RequiredCommunicationFields};
|
|
||||||
|
|
||||||
pub trait CommunicationResponseExt {
|
|
||||||
fn with_request_id(self, request: &CommunicationValue) -> Self;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CommunicationResponseExt for CommunicationValue {
|
|
||||||
fn with_request_id(mut self, request: &CommunicationValue) -> Self {
|
|
||||||
self = self.without_id();
|
|
||||||
if let Some(id) = request.id() {
|
|
||||||
self = self.with_id(id);
|
|
||||||
}
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn typed_container(items: Vec<(DataType, DataValue)>) -> DataValue {
|
|
||||||
use mtp::type_map::{DataTypeId, TypeMap};
|
|
||||||
let tm = TypeMap::latest();
|
|
||||||
DataValue::Container(
|
|
||||||
items
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|(dt, dv)| tm.data_id_enum(dt).map(|id| (DataTypeId(id), dv)))
|
|
||||||
.collect(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn data_string(cv: &CommunicationValue, dt: DataType) -> Option<String> {
|
|
||||||
cv.get_data(dt)
|
|
||||||
.and_then(DataValue::as_str)
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.or_else(|| {
|
|
||||||
cv.get_data(dt)
|
|
||||||
.and_then(DataValue::as_number)
|
|
||||||
.map(|n| n.to_string())
|
|
||||||
})
|
|
||||||
.or_else(|| {
|
|
||||||
cv.get_data(dt)
|
|
||||||
.and_then(DataValue::as_signed_number)
|
|
||||||
.map(|n| n.to_string())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn data_i64(cv: &CommunicationValue, dt: DataType) -> Option<i64> {
|
|
||||||
cv.get_data(dt)
|
|
||||||
.and_then(DataValue::as_number)
|
|
||||||
.and_then(|n| i64::try_from(n).ok())
|
|
||||||
.or_else(|| {
|
|
||||||
cv.get_data(dt)
|
|
||||||
.and_then(DataValue::as_signed_number)
|
|
||||||
.and_then(|n| i64::try_from(n).ok())
|
|
||||||
})
|
|
||||||
.or_else(|| {
|
|
||||||
cv.get_data(dt)
|
|
||||||
.and_then(DataValue::as_str)
|
|
||||||
.and_then(|s| s.parse::<i64>().ok())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ChatSecretRecipient {
|
|
||||||
pub user_id: String,
|
|
||||||
pub encrypted_secret: Vec<u8>,
|
|
||||||
pub kem_ciphertext: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn recipient_from_value(value: &DataValue) -> Option<ChatSecretRecipient> {
|
|
||||||
let tm = TypeMap::latest();
|
|
||||||
let user_id = value
|
|
||||||
.get_field(DataType::UserId.try_to_id(&tm)?)?
|
|
||||||
.as_str()
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.or_else(|| {
|
|
||||||
value
|
|
||||||
.get_field(DataType::UserId.try_to_id(&tm)?)?
|
|
||||||
.as_number()
|
|
||||||
.map(|n| n.to_string())
|
|
||||||
})?;
|
|
||||||
let encrypted_secret = value
|
|
||||||
.get_field(DataType::EncryptedSecret.try_to_id(&tm)?)?
|
|
||||||
.as_bytes()?;
|
|
||||||
let kem_ciphertext = value
|
|
||||||
.get_field(DataType::KemCiphertext.try_to_id(&tm)?)?
|
|
||||||
.as_bytes()?;
|
|
||||||
|
|
||||||
Some(ChatSecretRecipient {
|
|
||||||
user_id,
|
|
||||||
encrypted_secret,
|
|
||||||
kem_ciphertext,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn chat_secret_recipients(cv: &CommunicationValue) -> Option<Vec<ChatSecretRecipient>> {
|
|
||||||
let recipients = cv.get_data(DataType::Recipients)?.as_array()?;
|
|
||||||
let parsed = recipients
|
|
||||||
.iter()
|
|
||||||
.map(recipient_from_value)
|
|
||||||
.collect::<Option<Vec<_>>>()?;
|
|
||||||
|
|
||||||
if parsed.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(parsed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn now_millis_i64() -> i64 {
|
|
||||||
let millis = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_millis();
|
|
||||||
i64::try_from(millis).unwrap_or(i64::MAX)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn error_response(request: &CommunicationValue, ty: CommunicationType) -> CommunicationValue {
|
|
||||||
let mut response = CommunicationValue::new(ty).without_id();
|
|
||||||
if let Some(id) = request.id() {
|
|
||||||
response = response.with_id(id);
|
|
||||||
}
|
|
||||||
if let Some(sender) = request.sender() {
|
|
||||||
response = response.with_receiver(sender);
|
|
||||||
}
|
|
||||||
response
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::error_response;
|
|
||||||
use mtp::codec::{CommunicationType, CommunicationValue};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn error_response_preserves_an_absent_request_id() {
|
|
||||||
let request = CommunicationValue::new(CommunicationType::GetChats)
|
|
||||||
.without_id()
|
|
||||||
.with_sender(42);
|
|
||||||
let response = error_response(&request, CommunicationType::ErrorInvalidData);
|
|
||||||
|
|
||||||
assert_eq!(response.id(), None);
|
|
||||||
assert_eq!(response.receiver(), Some(42));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn error_response_copies_an_existing_request_id() {
|
|
||||||
let request = CommunicationValue::new(CommunicationType::GetChats)
|
|
||||||
.with_id(7)
|
|
||||||
.with_sender(42);
|
|
||||||
let response = error_response(&request, CommunicationType::ErrorInvalidData);
|
|
||||||
|
|
||||||
assert_eq!(response.id(), Some(7));
|
|
||||||
assert_eq!(response.receiver(), Some(42));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,383 +0,0 @@
|
||||||
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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
[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 +0,0 @@
|
||||||
pub mod consent_state;
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
[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"
|
|
||||||
|
|
@ -1,596 +0,0 @@
|
||||||
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(iota_storage::storage_error::StorageError::PendingRelayOwnershipUnknown) => {
|
|
||||||
log!(
|
|
||||||
"User data purge is waiting for pending relay ownership classification for {user_id}"
|
|
||||||
);
|
|
||||||
ResponseResult::Error(IpcErrorCode::StorageFailure)
|
|
||||||
}
|
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,305 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
use iota_ipc::{DeploymentMode, SupervisorKind};
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
|
||||||
pub struct DeploymentContext {
|
|
||||||
pub mode: DeploymentMode,
|
|
||||||
pub supervisor: SupervisorKind,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for DeploymentContext {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
mode: DeploymentMode::External,
|
|
||||||
supervisor: SupervisorKind::None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn from_environment() -> DeploymentContext {
|
|
||||||
let mut mode = match std::env::var("IOTA_DEPLOYMENT_MODE").ok().as_deref() {
|
|
||||||
Some("session_child") => DeploymentMode::SessionChild,
|
|
||||||
Some("ui_auto_start") => DeploymentMode::UiAutoStart,
|
|
||||||
Some("user_service") => DeploymentMode::UserService,
|
|
||||||
Some("system_socket_activated") => DeploymentMode::SystemSocketActivated,
|
|
||||||
Some("system_always_on") => DeploymentMode::SystemAlwaysOn,
|
|
||||||
_ => DeploymentMode::External,
|
|
||||||
};
|
|
||||||
if std::env::var("LISTEN_FDS").ok().as_deref() == Some("1") {
|
|
||||||
mode = DeploymentMode::SystemSocketActivated;
|
|
||||||
}
|
|
||||||
let supervisor = match std::env::var("IOTA_SUPERVISOR").ok().as_deref() {
|
|
||||||
Some("iota_ui") => SupervisorKind::IotaUi,
|
|
||||||
Some("systemd") => SupervisorKind::Systemd,
|
|
||||||
Some("external") => SupervisorKind::External,
|
|
||||||
_ => SupervisorKind::None,
|
|
||||||
};
|
|
||||||
DeploymentContext { mode, supervisor }
|
|
||||||
}
|
|
||||||
|
|
@ -1,745 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
use crate::log_buffer::LogBuffer;
|
|
||||||
use iota_ipc::{DaemonMessage, LogEntry};
|
|
||||||
use iota_logger::subscribe;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use tokio::sync::broadcast;
|
|
||||||
|
|
||||||
/* The daemon adapts logger output to the wire protocol so the logger stays
|
|
||||||
* independent from both the socket implementation and TUI state. */
|
|
||||||
pub fn spawn(message_tx: broadcast::Sender<DaemonMessage>, buffer: Arc<Mutex<LogBuffer>>) {
|
|
||||||
let Some(mut logs) = subscribe() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
tokio::spawn(async move {
|
|
||||||
while let Ok(entry) = logs.recv().await {
|
|
||||||
let entry = LogEntry {
|
|
||||||
timestamp_ms: entry.timestamp_ms,
|
|
||||||
sender: entry.sender,
|
|
||||||
message: entry.message,
|
|
||||||
is_error: entry.is_error,
|
|
||||||
};
|
|
||||||
if let Ok(mut buf) = buffer.lock() {
|
|
||||||
buf.push(entry.clone());
|
|
||||||
}
|
|
||||||
let _ = message_tx.send(DaemonMessage::LogEntry(entry));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
use iota_ipc::LogEntry;
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
|
|
||||||
pub struct LogBuffer {
|
|
||||||
entries: VecDeque<LogEntry>,
|
|
||||||
capacity: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LogBuffer {
|
|
||||||
pub fn new(capacity: usize) -> Self {
|
|
||||||
Self {
|
|
||||||
entries: VecDeque::with_capacity(capacity),
|
|
||||||
capacity,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn push(&mut self, entry: LogEntry) {
|
|
||||||
if self.entries.len() == self.capacity {
|
|
||||||
self.entries.pop_front();
|
|
||||||
}
|
|
||||||
self.entries.push_back(entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn recent(&self, limit: usize) -> Vec<LogEntry> {
|
|
||||||
let _len = self.entries.len();
|
|
||||||
self.entries
|
|
||||||
.iter()
|
|
||||||
.rev()
|
|
||||||
.take(limit)
|
|
||||||
.cloned()
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.into_iter()
|
|
||||||
.rev()
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
use async_trait::async_trait;
|
|
||||||
use mtp::codec::CommunicationValue;
|
|
||||||
use omikron_connector::{OmikronClient, OmikronConnection, OmikronError};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct UserService;
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct ConfigService;
|
|
||||||
|
|
||||||
pub struct DaemonServices {
|
|
||||||
pub omikron: Arc<dyn OmikronClient>,
|
|
||||||
pub users: Arc<UserService>,
|
|
||||||
pub config: Arc<ConfigService>,
|
|
||||||
pub active: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DaemonServices {
|
|
||||||
pub fn new(omikron: Arc<OmikronConnection>) -> Arc<Self> {
|
|
||||||
Arc::new(Self {
|
|
||||||
omikron,
|
|
||||||
users: Arc::new(UserService),
|
|
||||||
config: Arc::new(ConfigService),
|
|
||||||
active: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Services used while the daemon is awaiting terms acceptance. They can
|
|
||||||
/// never initiate a connection; the command router exposes status only.
|
|
||||||
pub fn inactive() -> Arc<Self> {
|
|
||||||
Arc::new(Self {
|
|
||||||
omikron: Arc::new(InactiveOmikron),
|
|
||||||
users: Arc::new(UserService),
|
|
||||||
config: Arc::new(ConfigService),
|
|
||||||
active: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct InactiveOmikron;
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl OmikronClient for InactiveOmikron {
|
|
||||||
async fn send_message(&self, _: &CommunicationValue) -> Result<(), OmikronError> {
|
|
||||||
Err(OmikronError::Disconnected(
|
|
||||||
"terms have not been accepted".into(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
async fn await_response(
|
|
||||||
&self,
|
|
||||||
_: &CommunicationValue,
|
|
||||||
_: Duration,
|
|
||||||
) -> Result<CommunicationValue, OmikronError> {
|
|
||||||
Err(OmikronError::Disconnected(
|
|
||||||
"terms have not been accepted".into(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
async fn reconnect(&self) -> Result<(), OmikronError> {
|
|
||||||
Err(OmikronError::Disconnected(
|
|
||||||
"terms have not been accepted".into(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
async fn rotate_identity(&self) -> Result<(), OmikronError> {
|
|
||||||
Err(OmikronError::Disconnected(
|
|
||||||
"terms have not been accepted".into(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
async fn is_connected(&self) -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
|
||||||
use tokio::sync::Mutex;
|
|
||||||
use tokio::task::JoinSet;
|
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
|
||||||
pub struct TaskRegistry {
|
|
||||||
tasks: Arc<Mutex<JoinSet<(String, Result<(), String>)>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TaskRegistry {
|
|
||||||
pub async fn spawn_tracked<F>(&self, name: impl Into<String>, future: F)
|
|
||||||
where
|
|
||||||
F: std::future::Future<Output = Result<(), String>> + Send + 'static,
|
|
||||||
{
|
|
||||||
let name = name.into();
|
|
||||||
self.tasks
|
|
||||||
.lock()
|
|
||||||
.await
|
|
||||||
.spawn(async move { (name, future.await) });
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn join_with_timeout(&self, timeout: Duration) -> Vec<String> {
|
|
||||||
let mut tasks = self.tasks.lock().await;
|
|
||||||
let mut failures = Vec::new();
|
|
||||||
let deadline = tokio::time::Instant::now() + timeout;
|
|
||||||
while !tasks.is_empty() {
|
|
||||||
match tokio::time::timeout_at(deadline, tasks.join_next()).await {
|
|
||||||
Ok(Some(Ok((name, Err(error))))) => failures.push(format!("{name}: {error}")),
|
|
||||||
Ok(Some(Ok((_, Ok(()))))) | Ok(Some(Err(_))) => {}
|
|
||||||
Ok(None) => break,
|
|
||||||
Err(_) => {
|
|
||||||
tasks.abort_all();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
failures
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
use async_trait::async_trait;
|
|
||||||
use iota_daemon_lib::log_buffer::LogBuffer;
|
|
||||||
use iota_daemon_lib::{CommandRouter, DaemonRuntime, DaemonServices, IpcRole, PeerContext};
|
|
||||||
use iota_ipc::{IpcErrorCode, LocalRequest, ResponseResult};
|
|
||||||
use mtp::codec::CommunicationValue;
|
|
||||||
use omikron_connector::{OmikronClient, OmikronError};
|
|
||||||
use std::sync::{
|
|
||||||
Arc, Mutex,
|
|
||||||
atomic::{AtomicUsize, Ordering},
|
|
||||||
};
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
struct FakeOmikron {
|
|
||||||
reconnects: AtomicUsize,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn admin_peer() -> PeerContext {
|
|
||||||
PeerContext {
|
|
||||||
pid: 1,
|
|
||||||
uid: 0,
|
|
||||||
role: IpcRole::Admin,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_peer() -> PeerContext {
|
|
||||||
PeerContext {
|
|
||||||
pid: 2,
|
|
||||||
uid: 1000,
|
|
||||||
role: IpcRole::Read,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[async_trait]
|
|
||||||
impl OmikronClient for FakeOmikron {
|
|
||||||
async fn send_message(&self, _: &CommunicationValue) -> Result<(), OmikronError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
async fn await_response(
|
|
||||||
&self,
|
|
||||||
_: &CommunicationValue,
|
|
||||||
_: Duration,
|
|
||||||
) -> Result<CommunicationValue, OmikronError> {
|
|
||||||
Err(OmikronError::Disconnected("fake".into()))
|
|
||||||
}
|
|
||||||
async fn reconnect(&self) -> Result<(), OmikronError> {
|
|
||||||
self.reconnects.fetch_add(1, Ordering::SeqCst);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
async fn rotate_identity(&self) -> Result<(), OmikronError> {
|
|
||||||
self.reconnects.fetch_add(1, Ordering::SeqCst);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
async fn is_connected(&self) -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn reconnect_uses_the_injected_client() {
|
|
||||||
let fake = Arc::new(FakeOmikron {
|
|
||||||
reconnects: AtomicUsize::new(0),
|
|
||||||
});
|
|
||||||
let services = Arc::new(DaemonServices {
|
|
||||||
omikron: fake.clone(),
|
|
||||||
users: Default::default(),
|
|
||||||
config: Default::default(),
|
|
||||||
active: true,
|
|
||||||
});
|
|
||||||
let router = CommandRouter::new(
|
|
||||||
Arc::new(DaemonRuntime::new()),
|
|
||||||
services,
|
|
||||||
Arc::new(Mutex::new(LogBuffer::new(100))),
|
|
||||||
);
|
|
||||||
assert!(matches!(
|
|
||||||
router
|
|
||||||
.route(&admin_peer(), 1, LocalRequest::ReconnectOmikron)
|
|
||||||
.await
|
|
||||||
.result,
|
|
||||||
ResponseResult::Ok(_)
|
|
||||||
));
|
|
||||||
assert_eq!(fake.reconnects.load(Ordering::SeqCst), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn identity_rotation_is_available_while_omikron_is_offline() {
|
|
||||||
let fake = Arc::new(FakeOmikron {
|
|
||||||
reconnects: AtomicUsize::new(0),
|
|
||||||
});
|
|
||||||
let services = Arc::new(DaemonServices {
|
|
||||||
omikron: fake.clone(),
|
|
||||||
users: Default::default(),
|
|
||||||
config: Default::default(),
|
|
||||||
active: true,
|
|
||||||
});
|
|
||||||
let router = CommandRouter::new(
|
|
||||||
Arc::new(DaemonRuntime::new()),
|
|
||||||
services,
|
|
||||||
Arc::new(Mutex::new(LogBuffer::new(100))),
|
|
||||||
);
|
|
||||||
assert!(matches!(
|
|
||||||
router
|
|
||||||
.route(&admin_peer(), 1, LocalRequest::RotateIotaIdentity)
|
|
||||||
.await
|
|
||||||
.result,
|
|
||||||
ResponseResult::Ok(_)
|
|
||||||
));
|
|
||||||
assert_eq!(fake.reconnects.load(Ordering::SeqCst), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn read_role_cannot_execute_an_administrative_request() {
|
|
||||||
let fake = Arc::new(FakeOmikron {
|
|
||||||
reconnects: AtomicUsize::new(0),
|
|
||||||
});
|
|
||||||
let services = Arc::new(DaemonServices {
|
|
||||||
omikron: fake.clone(),
|
|
||||||
users: Default::default(),
|
|
||||||
config: Default::default(),
|
|
||||||
active: true,
|
|
||||||
});
|
|
||||||
let router = CommandRouter::new(
|
|
||||||
Arc::new(DaemonRuntime::new()),
|
|
||||||
services,
|
|
||||||
Arc::new(Mutex::new(LogBuffer::new(100))),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert!(matches!(
|
|
||||||
router
|
|
||||||
.route(
|
|
||||||
&read_peer(),
|
|
||||||
9,
|
|
||||||
LocalRequest::SetConfig {
|
|
||||||
key: "port".into(),
|
|
||||||
value: "1984".into(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.result,
|
|
||||||
ResponseResult::Error(IpcErrorCode::Unauthorized)
|
|
||||||
));
|
|
||||||
assert_eq!(fake.reconnects.load(Ordering::SeqCst), 0);
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
use iota_daemon_lib::{DaemonRuntime, StartupPhase};
|
|
||||||
use iota_ipc::{ComponentId, HealthStatus, LifecyclePhase};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn component_failures_are_independent_and_recovery_is_scoped() {
|
|
||||||
let runtime = DaemonRuntime::new();
|
|
||||||
runtime.set_component_degraded(ComponentId::Omikron, "offline".into());
|
|
||||||
runtime.set_component_failed(ComponentId::Web, "bind failed".into());
|
|
||||||
runtime.set_startup_phase(StartupPhase::Ready);
|
|
||||||
let snapshot = runtime.snapshot();
|
|
||||||
assert_eq!(snapshot.lifecycle, LifecyclePhase::Ready);
|
|
||||||
assert_eq!(snapshot.overall_health, HealthStatus::Degraded);
|
|
||||||
assert_eq!(
|
|
||||||
snapshot.components[&ComponentId::Omikron].status,
|
|
||||||
HealthStatus::Degraded
|
|
||||||
);
|
|
||||||
runtime.set_component_healthy(ComponentId::Web, None);
|
|
||||||
assert_eq!(
|
|
||||||
runtime.snapshot().components[&ComponentId::Omikron].status,
|
|
||||||
HealthStatus::Degraded
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn critical_failure_is_failed_but_optional_degradation_is_not() {
|
|
||||||
let runtime = DaemonRuntime::new();
|
|
||||||
runtime.set_component_failed(ComponentId::Storage, "database unavailable".into());
|
|
||||||
assert_eq!(runtime.snapshot().overall_health, HealthStatus::Failed);
|
|
||||||
}
|
|
||||||
|
|
@ -1,252 +0,0 @@
|
||||||
use async_trait::async_trait;
|
|
||||||
use iota_daemon_lib::{DaemonRuntime, DaemonServices, IpcServer};
|
|
||||||
use iota_ipc::{
|
|
||||||
ClientMessage, DaemonMessage, ExitIntent, IpcErrorCode, LocalRequest, PROTOCOL_VERSION,
|
|
||||||
RequestEnvelope, ResponseResult, read_msg, write_msg,
|
|
||||||
};
|
|
||||||
use iota_storage::util::config_util::{self, IotaConfig};
|
|
||||||
use mtp::codec::CommunicationValue;
|
|
||||||
use omikron_connector::{OmikronClient, OmikronError};
|
|
||||||
use std::path::Path;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
|
||||||
use tokio::net::UnixStream;
|
|
||||||
use tokio::sync::{broadcast, watch};
|
|
||||||
|
|
||||||
struct ConfigRestore(Arc<IotaConfig>);
|
|
||||||
|
|
||||||
impl Drop for ConfigRestore {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
config_util::CONFIG.store(self.0.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_client_limit(limit: usize) -> ConfigRestore {
|
|
||||||
let previous = config_util::CONFIG.load_full();
|
|
||||||
let mut config = (*previous).clone();
|
|
||||||
config.max_ipc_clients = limit;
|
|
||||||
config_util::CONFIG.store(Arc::new(config));
|
|
||||||
ConfigRestore(previous)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn start_server(
|
|
||||||
path: &Path,
|
|
||||||
services: Arc<DaemonServices>,
|
|
||||||
) -> (Arc<DaemonRuntime>, tokio::task::JoinHandle<()>) {
|
|
||||||
let runtime = Arc::new(DaemonRuntime::new());
|
|
||||||
let (log_tx, _) = broadcast::channel(32);
|
|
||||||
let log_buffer = Arc::new(std::sync::Mutex::new(
|
|
||||||
iota_daemon_lib::log_buffer::LogBuffer::new(32),
|
|
||||||
));
|
|
||||||
let (_, state_rx) = watch::channel(runtime.snapshot());
|
|
||||||
let server = IpcServer::bind(
|
|
||||||
path.to_owned(),
|
|
||||||
runtime.clone(),
|
|
||||||
services,
|
|
||||||
log_tx,
|
|
||||||
log_buffer,
|
|
||||||
state_rx,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("IPC server binds");
|
|
||||||
let task = tokio::spawn(async move {
|
|
||||||
let _ = server.serve().await;
|
|
||||||
});
|
|
||||||
(runtime, task)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn try_connect_and_await_hello(path: &Path) -> std::io::Result<UnixStream> {
|
|
||||||
let mut stream = UnixStream::connect(path).await?;
|
|
||||||
write_msg(
|
|
||||||
&mut stream,
|
|
||||||
&ClientMessage::Hello {
|
|
||||||
supported_versions: vec![PROTOCOL_VERSION],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let message: DaemonMessage = read_msg(&mut stream).await?;
|
|
||||||
if !matches!(message, DaemonMessage::HelloAck(_)) {
|
|
||||||
return Err(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidData,
|
|
||||||
"expected HelloAck",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(stream)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn connect_and_await_hello(path: &Path) -> UnixStream {
|
|
||||||
try_connect_and_await_hello(path)
|
|
||||||
.await
|
|
||||||
.expect("IPC connection completes the Hello exchange")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn active_client_limit_rejects_excess_clients_and_releases_permits() {
|
|
||||||
let _config = set_client_limit(1);
|
|
||||||
let directory = tempfile::tempdir().unwrap();
|
|
||||||
let socket = directory.path().join("ipc.sock");
|
|
||||||
let (_runtime, server_task) = start_server(&socket, DaemonServices::inactive()).await;
|
|
||||||
|
|
||||||
let first = connect_and_await_hello(&socket).await;
|
|
||||||
let mut rejected = UnixStream::connect(&socket)
|
|
||||||
.await
|
|
||||||
.expect("second connection reaches the Unix listener");
|
|
||||||
let rejected_result = tokio::time::timeout(
|
|
||||||
Duration::from_secs(2),
|
|
||||||
read_msg::<_, DaemonMessage>(&mut rejected),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("rejected client is closed promptly");
|
|
||||||
assert!(rejected_result.is_err());
|
|
||||||
|
|
||||||
drop(first);
|
|
||||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
|
|
||||||
let _released = loop {
|
|
||||||
match try_connect_and_await_hello(&socket).await {
|
|
||||||
Ok(stream) => break stream,
|
|
||||||
Err(_error) if tokio::time::Instant::now() < deadline => {
|
|
||||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
||||||
}
|
|
||||||
Err(error) => panic!("client permit was not released: {error}"),
|
|
||||||
}
|
|
||||||
};
|
|
||||||
server_task.abort();
|
|
||||||
let _ = server_task.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn request_with_version_different_from_hello_is_rejected() {
|
|
||||||
let directory = tempfile::tempdir().unwrap();
|
|
||||||
let socket = directory.path().join("ipc.sock");
|
|
||||||
let (_runtime, server_task) = start_server(&socket, DaemonServices::inactive()).await;
|
|
||||||
let mut stream = connect_and_await_hello(&socket).await;
|
|
||||||
|
|
||||||
write_msg(
|
|
||||||
&mut stream,
|
|
||||||
&ClientMessage::Request(RequestEnvelope {
|
|
||||||
request_id: 7,
|
|
||||||
protocol_version: PROTOCOL_VERSION + 1,
|
|
||||||
request: LocalRequest::GetStatus,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("request sends");
|
|
||||||
|
|
||||||
let response = loop {
|
|
||||||
match read_msg::<_, DaemonMessage>(&mut stream)
|
|
||||||
.await
|
|
||||||
.expect("daemon response arrives")
|
|
||||||
{
|
|
||||||
DaemonMessage::Response(response) => break response,
|
|
||||||
_ => continue,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
assert_eq!(response.request_id, 7);
|
|
||||||
assert!(matches!(
|
|
||||||
response.result,
|
|
||||||
ResponseResult::Error(IpcErrorCode::UnsupportedVersion)
|
|
||||||
));
|
|
||||||
|
|
||||||
drop(stream);
|
|
||||||
server_task.abort();
|
|
||||||
let _ = server_task.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TestOmikron;
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl OmikronClient for TestOmikron {
|
|
||||||
async fn send_message(&self, _: &CommunicationValue) -> Result<(), OmikronError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn await_response(
|
|
||||||
&self,
|
|
||||||
_: &CommunicationValue,
|
|
||||||
_: Duration,
|
|
||||||
) -> Result<CommunicationValue, OmikronError> {
|
|
||||||
Err(OmikronError::Disconnected("test client".into()))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn reconnect(&self) -> Result<(), OmikronError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn rotate_identity(&self) -> Result<(), OmikronError> {
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn is_connected(&self) -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn active_services() -> Arc<DaemonServices> {
|
|
||||||
Arc::new(DaemonServices {
|
|
||||||
omikron: Arc::new(TestOmikron),
|
|
||||||
users: Default::default(),
|
|
||||||
config: Default::default(),
|
|
||||||
active: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn shutdown_delivers_response_and_lifecycle_event_before_eof() {
|
|
||||||
let directory = tempfile::tempdir().unwrap();
|
|
||||||
let socket = directory.path().join("ipc.sock");
|
|
||||||
let (runtime, server_task) = start_server(&socket, active_services()).await;
|
|
||||||
let mut stream = connect_and_await_hello(&socket).await;
|
|
||||||
|
|
||||||
write_msg(
|
|
||||||
&mut stream,
|
|
||||||
&ClientMessage::Request(RequestEnvelope {
|
|
||||||
request_id: 8,
|
|
||||||
protocol_version: PROTOCOL_VERSION,
|
|
||||||
request: LocalRequest::RequestProcessExit {
|
|
||||||
intent: ExitIntent::Stop,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("shutdown request sends");
|
|
||||||
|
|
||||||
let mut response_seen = false;
|
|
||||||
let mut lifecycle_seen = false;
|
|
||||||
for _ in 0..4 {
|
|
||||||
match tokio::time::timeout(
|
|
||||||
Duration::from_secs(2),
|
|
||||||
read_msg::<_, DaemonMessage>(&mut stream),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("shutdown message arrives")
|
|
||||||
.expect("shutdown stream remains readable")
|
|
||||||
{
|
|
||||||
DaemonMessage::Response(response) => {
|
|
||||||
assert_eq!(response.request_id, 8);
|
|
||||||
assert!(matches!(response.result, ResponseResult::Ok(_)));
|
|
||||||
response_seen = true;
|
|
||||||
}
|
|
||||||
DaemonMessage::LifecycleEvent(iota_ipc::LifecycleEvent::Shutdown { .. }) => {
|
|
||||||
lifecycle_seen = true;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
if response_seen && lifecycle_seen {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assert!(response_seen);
|
|
||||||
assert!(lifecycle_seen);
|
|
||||||
let eof = tokio::time::timeout(
|
|
||||||
Duration::from_secs(2),
|
|
||||||
read_msg::<_, DaemonMessage>(&mut stream),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("shutdown connection closes after flush");
|
|
||||||
assert!(eof.is_err());
|
|
||||||
assert!(runtime.is_shutting_down());
|
|
||||||
|
|
||||||
server_task.abort();
|
|
||||||
let _ = server_task.await;
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
use iota_daemon_lib::{DaemonRuntime, ShutdownReason};
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn shutdown_reason_is_first_write_wins_and_tasks_join() {
|
|
||||||
let runtime = DaemonRuntime::new();
|
|
||||||
runtime.shutdown(ShutdownReason::Fatal("first".into()));
|
|
||||||
runtime.shutdown(ShutdownReason::Restart);
|
|
||||||
assert_eq!(
|
|
||||||
runtime.shutdown_reason(),
|
|
||||||
Some(ShutdownReason::Fatal("first".into()))
|
|
||||||
);
|
|
||||||
runtime.tasks.spawn_tracked("quick", async { Ok(()) }).await;
|
|
||||||
assert!(
|
|
||||||
runtime
|
|
||||||
.tasks
|
|
||||||
.join_with_timeout(Duration::from_millis(100))
|
|
||||||
.await
|
|
||||||
.is_empty()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn long_task_is_aborted_at_join_timeout() {
|
|
||||||
let runtime = DaemonRuntime::new();
|
|
||||||
runtime
|
|
||||||
.tasks
|
|
||||||
.spawn_tracked("slow", async {
|
|
||||||
tokio::time::sleep(Duration::from_secs(10)).await;
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
assert!(
|
|
||||||
runtime
|
|
||||||
.tasks
|
|
||||||
.join_with_timeout(Duration::from_millis(10))
|
|
||||||
.await
|
|
||||||
.is_empty()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "iota-daemon"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
iota-daemon-lib = { path = "../iota-daemon-lib" }
|
|
||||||
iota-ipc = { path = "../iota-ipc" }
|
|
||||||
iota-logger = { path = "../iota-logger" }
|
|
||||||
iota-paths = { path = "../iota-paths" }
|
|
||||||
iota-storage = { path = "../iota-storage" }
|
|
||||||
iota-util = { path = "../iota-util" }
|
|
||||||
iota-terms = { path = "../iota-terms" }
|
|
||||||
omikron-connector = { path = "../omikron-connector" }
|
|
||||||
web-server = { path = "../web-server" }
|
|
||||||
tokio = { version = "1.50.0", features = ["full"] }
|
|
||||||
|
|
@ -1,473 +0,0 @@
|
||||||
use iota_daemon_lib::{
|
|
||||||
DaemonRuntime, DaemonServices, IpcServer, ShutdownReason, StartupPhase, log_broadcaster,
|
|
||||||
log_buffer::LogBuffer,
|
|
||||||
};
|
|
||||||
use iota_logger::{self as logger, log};
|
|
||||||
use iota_storage::users::user_manager;
|
|
||||||
use iota_storage::util::config_util::CONFIG;
|
|
||||||
use std::process::ExitCode;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::time::Duration;
|
|
||||||
use tokio::sync::{broadcast, watch};
|
|
||||||
|
|
||||||
const MESSAGE_RETENTION_INTERVAL: Duration = Duration::from_secs(60);
|
|
||||||
const SYNC_COMPACTION_INTERVAL: Duration = Duration::from_secs(60 * 60);
|
|
||||||
#[tokio::main(flavor = "multi_thread")]
|
|
||||||
async fn main() -> ExitCode {
|
|
||||||
let scope = match std::env::var("IOTA_DEPLOYMENT_MODE").ok().as_deref() {
|
|
||||||
Some("system_socket_activated") | Some("system_always_on") => iota_paths::Scope::System,
|
|
||||||
_ => iota_paths::Scope::User,
|
|
||||||
};
|
|
||||||
let paths = match iota_paths::IotaPaths::resolve(scope) {
|
|
||||||
Ok(paths) => paths,
|
|
||||||
Err(error) => {
|
|
||||||
eprintln!("Cannot resolve Iota paths: {error}");
|
|
||||||
return ExitCode::FAILURE;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// Bind a deliberately dormant IPC daemon before terms are accepted. This
|
|
||||||
// makes socket activation and `iota terms accept --system` usable, while
|
|
||||||
// the router exposes status only and the inactive service cannot connect.
|
|
||||||
if !iota_terms::consent::load(&paths.state_dir).has_all_required() {
|
|
||||||
let socket = match &paths.ipc_endpoint {
|
|
||||||
iota_paths::IpcEndpoint::UnixSocket(path) => path.clone(),
|
|
||||||
iota_paths::IpcEndpoint::WindowsPipe(_) => {
|
|
||||||
eprintln!("Windows named-pipe daemon transport is not implemented yet");
|
|
||||||
return ExitCode::FAILURE;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let runtime = Arc::new(DaemonRuntime::new());
|
|
||||||
let (log_tx, _) = broadcast::channel(64);
|
|
||||||
let log_buffer = Arc::new(Mutex::new(LogBuffer::new(64)));
|
|
||||||
let (_, state_rx) = watch::channel(runtime.snapshot());
|
|
||||||
let server = match IpcServer::bind(
|
|
||||||
socket,
|
|
||||||
runtime.clone(),
|
|
||||||
DaemonServices::inactive(),
|
|
||||||
log_tx,
|
|
||||||
log_buffer,
|
|
||||||
state_rx,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(server) => server,
|
|
||||||
Err(error) => {
|
|
||||||
eprintln!("Cannot bind dormant daemon IPC socket: {error}");
|
|
||||||
return ExitCode::FAILURE;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let _ = server.serve().await;
|
|
||||||
});
|
|
||||||
eprintln!(
|
|
||||||
"Iota daemon is awaiting terms acceptance. Run `iota terms accept{}` in an interactive terminal.",
|
|
||||||
if paths.scope == iota_paths::Scope::System {
|
|
||||||
" --system"
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
}
|
|
||||||
);
|
|
||||||
loop {
|
|
||||||
tokio::select! {
|
|
||||||
_ = tokio::time::sleep(Duration::from_secs(1)) => {
|
|
||||||
if iota_terms::consent::load(&paths.state_dir).has_all_required() {
|
|
||||||
// systemd restarts this daemon; a locally-launched daemon can
|
|
||||||
// simply be started again after accepting the documents.
|
|
||||||
return ExitCode::from(75);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = tokio::signal::ctrl_c() => return ExitCode::SUCCESS,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Err(error) = paths.migrate_legacy_layout() {
|
|
||||||
eprintln!("Cannot migrate legacy Iota layout: {error}");
|
|
||||||
return ExitCode::FAILURE;
|
|
||||||
}
|
|
||||||
if let Err(error) = paths.prepare_writable_directories() {
|
|
||||||
eprintln!("Cannot prepare Iota directories: {error}");
|
|
||||||
return ExitCode::FAILURE;
|
|
||||||
}
|
|
||||||
iota_util::file_util::configure_storage_directory(paths.storage_dir.clone());
|
|
||||||
iota_storage::util::config_util::configure_config_path(paths.config_file.clone());
|
|
||||||
iota_storage::util::config_util::load_config_from(&paths.config_file);
|
|
||||||
omikron_connector::omikron_connection::configure_identity_path(paths.keyring_file());
|
|
||||||
match paths.scope {
|
|
||||||
iota_paths::Scope::User => logger::startup_with_log_dir(Some(paths.log_dir.clone())),
|
|
||||||
iota_paths::Scope::System => logger::startup_with_log_dir(None),
|
|
||||||
}
|
|
||||||
|
|
||||||
let runtime = Arc::new(DaemonRuntime::new());
|
|
||||||
// --- IPC infrastructure ---
|
|
||||||
let (log_tx, _) = broadcast::channel(512);
|
|
||||||
let log_buffer = Arc::new(Mutex::new(LogBuffer::new(1024)));
|
|
||||||
log_broadcaster::spawn(log_tx.clone(), log_buffer.clone());
|
|
||||||
let (state_tx, state_rx) = watch::channel(runtime.snapshot());
|
|
||||||
|
|
||||||
runtime.set_startup_phase(StartupPhase::LoadingUsers);
|
|
||||||
let storage_error = match iota_storage::util::db::verify_and_backup_database() {
|
|
||||||
Ok(()) => tokio::task::spawn_blocking(user_manager::load_users_sync)
|
|
||||||
.await
|
|
||||||
.map_err(|error| format!("user storage task failed: {error}"))
|
|
||||||
.and_then(|result| result.map_err(|error| error.to_string()))
|
|
||||||
.err(),
|
|
||||||
Err(error) => Some(error.to_string()),
|
|
||||||
};
|
|
||||||
if let Some(error) = storage_error {
|
|
||||||
runtime.set_component_failed(
|
|
||||||
iota_ipc::ComponentId::Storage,
|
|
||||||
format!("user storage failed to load: {error}"),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
runtime.set_component_healthy(iota_ipc::ComponentId::Storage, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bind before migration and service startup: a successful bind is the
|
|
||||||
// readiness boundary visible to clients and socket activation.
|
|
||||||
let socket = match &paths.ipc_endpoint {
|
|
||||||
iota_paths::IpcEndpoint::UnixSocket(path) => path.clone(),
|
|
||||||
iota_paths::IpcEndpoint::WindowsPipe(_) => {
|
|
||||||
eprintln!("Windows named-pipe daemon transport is not implemented yet");
|
|
||||||
return ExitCode::FAILURE;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let omikron = match omikron_connector::omikron_connection::connect_initial(
|
|
||||||
runtime.cancellation.clone(),
|
|
||||||
runtime.state.active_tasks.clone(),
|
|
||||||
runtime.state.app.clone(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(connection) => connection,
|
|
||||||
Err(omikron_connector::OmikronStartupError::InitialConnectionTimeout { connection }) => {
|
|
||||||
runtime.set_component_degraded(
|
|
||||||
iota_ipc::ComponentId::Omikron,
|
|
||||||
"Omikron connection unavailable; retrying".into(),
|
|
||||||
);
|
|
||||||
connection
|
|
||||||
}
|
|
||||||
Err(omikron_connector::OmikronStartupError::Authentication { connection }) => {
|
|
||||||
runtime.set_component_failed(
|
|
||||||
iota_ipc::ComponentId::Omikron,
|
|
||||||
"Omikron authentication failed; regenerate the Iota identity to register again"
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
// Keep IPC alive: identity rotation is the supported recovery
|
|
||||||
// action and must remain available after authentication fails.
|
|
||||||
connection
|
|
||||||
}
|
|
||||||
Err(omikron_connector::OmikronStartupError::Construction(error)) => {
|
|
||||||
eprintln!("Cannot construct Omikron connection: {error}");
|
|
||||||
return ExitCode::FAILURE;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let omikron_health = omikron.clone();
|
|
||||||
let omikron_reconcile = omikron.clone();
|
|
||||||
let services = DaemonServices::new(omikron);
|
|
||||||
let health_runtime = runtime.clone();
|
|
||||||
runtime
|
|
||||||
.tasks
|
|
||||||
.spawn_tracked("omikron-health", async move {
|
|
||||||
let mut states = omikron_health.connection_state();
|
|
||||||
loop {
|
|
||||||
let state = *states.borrow();
|
|
||||||
match state {
|
|
||||||
omikron_connector::omikron_connection::ConnectionState::Connected {
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
let ping_ms = *omikron_health.last_ping.lock().await;
|
|
||||||
let message = if ping_ms >= 0 {
|
|
||||||
format!("connected (RTT: {ping_ms} ms)")
|
|
||||||
} else {
|
|
||||||
"connected (waiting for RTT sample)".into()
|
|
||||||
};
|
|
||||||
health_runtime
|
|
||||||
.set_component_healthy(iota_ipc::ComponentId::Omikron, Some(message));
|
|
||||||
}
|
|
||||||
omikron_connector::omikron_connection::ConnectionState::Connecting => {
|
|
||||||
health_runtime.set_component_degraded(
|
|
||||||
iota_ipc::ComponentId::Omikron,
|
|
||||||
"connecting to Omikron".into(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
omikron_connector::omikron_connection::ConnectionState::Disconnected => {
|
|
||||||
let message = omikron_health
|
|
||||||
.get_auth_failure()
|
|
||||||
.await
|
|
||||||
.unwrap_or_else(|| "disconnected; retrying".into());
|
|
||||||
if omikron_health.has_auth_failure().await {
|
|
||||||
health_runtime
|
|
||||||
.set_component_failed(iota_ipc::ComponentId::Omikron, message);
|
|
||||||
} else {
|
|
||||||
health_runtime
|
|
||||||
.set_component_degraded(iota_ipc::ComponentId::Omikron, message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tokio::select! {
|
|
||||||
changed = states.changed() => if changed.is_err() { break },
|
|
||||||
// RTT is updated by MTP's heartbeat independently of a
|
|
||||||
// connection-state transition, so periodically refresh
|
|
||||||
// the component detail while connected.
|
|
||||||
_ = tokio::time::sleep(Duration::from_secs(1)) => {},
|
|
||||||
_ = health_runtime.cancellation.cancelled() => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
runtime
|
|
||||||
.tasks
|
|
||||||
.spawn_tracked("user-lifecycle-reconciliation", async move {
|
|
||||||
let mut states = omikron_reconcile.connection_state();
|
|
||||||
loop {
|
|
||||||
if matches!(
|
|
||||||
*states.borrow(),
|
|
||||||
omikron_connector::omikron_connection::ConnectionState::Connected { .. }
|
|
||||||
) {
|
|
||||||
omikron_connector::user_ops::reconcile_managed_users(
|
|
||||||
omikron_reconcile.as_ref(),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
tokio::select! {
|
|
||||||
changed = states.changed() => if changed.is_err() { break },
|
|
||||||
_ = tokio::time::sleep(Duration::from_secs(30)) => {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
let ipc_server = match IpcServer::bind(
|
|
||||||
socket.clone(),
|
|
||||||
runtime.clone(),
|
|
||||||
services,
|
|
||||||
log_tx.clone(),
|
|
||||||
log_buffer.clone(),
|
|
||||||
state_rx,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(server) => server,
|
|
||||||
Err(error) => {
|
|
||||||
eprintln!("Cannot bind daemon IPC socket: {error}");
|
|
||||||
return ExitCode::FAILURE;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
eprintln!("iota-daemon IPC listener ready at {}", socket.display());
|
|
||||||
runtime.set_component_healthy(iota_ipc::ComponentId::Ipc, None);
|
|
||||||
let listener_runtime = runtime.clone();
|
|
||||||
runtime
|
|
||||||
.tasks
|
|
||||||
.spawn_tracked("ipc-server", async move {
|
|
||||||
if let Err(error) = ipc_server.serve().await {
|
|
||||||
eprintln!("iota-daemon IPC server failed: {error}");
|
|
||||||
listener_runtime.shutdown(ShutdownReason::Fatal(format!(
|
|
||||||
"IPC listener stopped: {error}"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
log!("iota-daemon IPC server ready");
|
|
||||||
|
|
||||||
log!(
|
|
||||||
"iota-daemon paths (scope={:?}): config={} state={} storage={} identity={} cache={} log={} asset={} ipc={}",
|
|
||||||
paths.scope,
|
|
||||||
paths.config_file.display(),
|
|
||||||
paths.state_dir.display(),
|
|
||||||
paths.storage_dir.display(),
|
|
||||||
paths.identity_dir.display(),
|
|
||||||
paths.cache_dir.display(),
|
|
||||||
paths.log_dir.display(),
|
|
||||||
paths.asset_dir.display(),
|
|
||||||
match &paths.ipc_endpoint {
|
|
||||||
iota_paths::IpcEndpoint::UnixSocket(p) => p.display().to_string(),
|
|
||||||
iota_paths::IpcEndpoint::WindowsPipe(n) => n.clone(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
runtime.set_startup_phase(StartupPhase::StartingServices);
|
|
||||||
|
|
||||||
// --- System monitor ---
|
|
||||||
runtime.spawn_system_monitor().await;
|
|
||||||
|
|
||||||
// --- State update publisher (watch-based, no full broadcast per tick) ---
|
|
||||||
let state_publisher = runtime.clone();
|
|
||||||
runtime
|
|
||||||
.tasks
|
|
||||||
.spawn_tracked("state-publisher", async move {
|
|
||||||
loop {
|
|
||||||
if state_publisher.is_shutting_down() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let snapshot = state_publisher.snapshot();
|
|
||||||
let _ = state_tx.send(snapshot);
|
|
||||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// --- Web server ---
|
|
||||||
let web = CONFIG.load().web.clone();
|
|
||||||
let web_config = web_server::WebConfig {
|
|
||||||
mode: match web.mode {
|
|
||||||
iota_storage::util::config_util::WebMode::Disabled => web_server::WebMode::Disabled,
|
|
||||||
iota_storage::util::config_util::WebMode::Loopback => web_server::WebMode::Loopback,
|
|
||||||
iota_storage::util::config_util::WebMode::Network => web_server::WebMode::Network,
|
|
||||||
},
|
|
||||||
bind: web
|
|
||||||
.bind
|
|
||||||
.parse()
|
|
||||||
.unwrap_or(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)),
|
|
||||||
port: web.port,
|
|
||||||
asset_dir: resolve_config_path(&paths.config_file, &web.asset_dir, &paths.asset_dir),
|
|
||||||
tls: web
|
|
||||||
.certificate
|
|
||||||
.zip(web.key)
|
|
||||||
.map(|(certificate, key)| web_server::TlsConfig {
|
|
||||||
certificate: resolve_config_path(
|
|
||||||
&paths.config_file,
|
|
||||||
&certificate,
|
|
||||||
&paths.config_dir,
|
|
||||||
),
|
|
||||||
key: resolve_config_path(&paths.config_file, &key, &paths.config_dir),
|
|
||||||
}),
|
|
||||||
required: web.required,
|
|
||||||
};
|
|
||||||
match web_server::start(web_config, runtime.cancellation.clone()).await {
|
|
||||||
Ok(None) => {
|
|
||||||
runtime.set_component_healthy(iota_ipc::ComponentId::Web, Some("disabled".into()))
|
|
||||||
}
|
|
||||||
Ok(Some(handle)) => {
|
|
||||||
runtime.set_component_healthy(iota_ipc::ComponentId::Web, None);
|
|
||||||
runtime
|
|
||||||
.tasks
|
|
||||||
.spawn_tracked("web-server", async move {
|
|
||||||
handle.join().await;
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
Err(error) if web.required => {
|
|
||||||
runtime.set_component_failed(iota_ipc::ComponentId::Web, error.to_string());
|
|
||||||
}
|
|
||||||
Err(error) => {
|
|
||||||
runtime.set_component_degraded(iota_ipc::ComponentId::Web, error.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
runtime.set_startup_phase(StartupPhase::Ready);
|
|
||||||
log!("iota-daemon started (phase: Ready)");
|
|
||||||
|
|
||||||
let retention_runtime = runtime.clone();
|
|
||||||
runtime
|
|
||||||
.tasks
|
|
||||||
.spawn_tracked("message-retention", async move {
|
|
||||||
loop {
|
|
||||||
let purge = tokio::task::spawn_blocking(|| {
|
|
||||||
iota_storage::util::message_retention::purge_expired_messages(
|
|
||||||
iota_storage::util::sync::now_millis(),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
match purge {
|
|
||||||
Ok(Ok(result)) if result.deleted_messages > 0 => {
|
|
||||||
log!("purged {} expired messages", result.deleted_messages);
|
|
||||||
}
|
|
||||||
Ok(Ok(_)) => {}
|
|
||||||
Ok(Err(error)) => log!("message retention cleanup failed: {}", error),
|
|
||||||
Err(error) => log!("message retention task failed: {}", error),
|
|
||||||
}
|
|
||||||
tokio::select! {
|
|
||||||
_ = tokio::time::sleep(MESSAGE_RETENTION_INTERVAL) => {},
|
|
||||||
_ = retention_runtime.cancellation.cancelled() => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let compaction_runtime = runtime.clone();
|
|
||||||
runtime
|
|
||||||
.tasks
|
|
||||||
.spawn_tracked("sync-compaction", async move {
|
|
||||||
loop {
|
|
||||||
let compact =
|
|
||||||
tokio::task::spawn_blocking(iota_storage::util::sync::compact_all_sync_state)
|
|
||||||
.await;
|
|
||||||
match compact {
|
|
||||||
Ok(Ok(result))
|
|
||||||
if result.removed_events > 0 || result.removed_blob_tombstones > 0 =>
|
|
||||||
{
|
|
||||||
log!(
|
|
||||||
"compacted {} sync events and {} blob tombstones",
|
|
||||||
result.removed_events,
|
|
||||||
result.removed_blob_tombstones
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(Ok(_)) => {}
|
|
||||||
Ok(Err(error)) => log!("sync compaction failed: {}", error),
|
|
||||||
Err(error) => log!("sync compaction task failed: {}", error),
|
|
||||||
}
|
|
||||||
tokio::select! {
|
|
||||||
_ = tokio::time::sleep(SYNC_COMPACTION_INTERVAL) => {},
|
|
||||||
_ = compaction_runtime.cancellation.cancelled() => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// --- Main lifecycle loop ---
|
|
||||||
let signal = async {
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
let mut term =
|
|
||||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
|
||||||
.expect("SIGTERM handler");
|
|
||||||
tokio::select! { _ = tokio::signal::ctrl_c() => ShutdownReason::Stop, _ = term.recv() => ShutdownReason::Stop }
|
|
||||||
}
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
{
|
|
||||||
let _ = tokio::signal::ctrl_c().await;
|
|
||||||
ShutdownReason::Stop
|
|
||||||
}
|
|
||||||
};
|
|
||||||
tokio::select! {
|
|
||||||
_ = runtime.cancellation.cancelled() => {},
|
|
||||||
reason = signal => runtime.shutdown(reason),
|
|
||||||
}
|
|
||||||
|
|
||||||
let reason = runtime.shutdown_reason().unwrap_or(ShutdownReason::Stop);
|
|
||||||
log!("iota-daemon shutting down (reason: {:?})", reason);
|
|
||||||
runtime.set_startup_phase(StartupPhase::Stopping);
|
|
||||||
|
|
||||||
let _ = runtime
|
|
||||||
.tasks
|
|
||||||
.join_with_timeout(Duration::from_secs(5))
|
|
||||||
.await;
|
|
||||||
|
|
||||||
let exit_code = reason.exit_code();
|
|
||||||
log!("iota-daemon exited (code: {})", exit_code);
|
|
||||||
ExitCode::from(exit_code as u8)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn resolve_config_path(
|
|
||||||
config_file: &std::path::Path,
|
|
||||||
value: &str,
|
|
||||||
default: &std::path::Path,
|
|
||||||
) -> std::path::PathBuf {
|
|
||||||
if value.is_empty() {
|
|
||||||
return default.to_path_buf();
|
|
||||||
}
|
|
||||||
let path = std::path::PathBuf::from(value);
|
|
||||||
if path.is_absolute() {
|
|
||||||
path
|
|
||||||
} else {
|
|
||||||
config_file
|
|
||||||
.parent()
|
|
||||||
.expect("absolute configuration file has a parent")
|
|
||||||
.join(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "iota-installer"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
anyhow = "1"
|
|
||||||
tempfile = "3"
|
|
||||||
zip = "6"
|
|
||||||
serde_json = "1"
|
|
||||||
iota-paths = { path = "../iota-paths" }
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
use anyhow::{Context, Result, bail};
|
|
||||||
use std::{fs, io, path::Path, process::Command};
|
|
||||||
use tempfile::tempdir;
|
|
||||||
use zip::ZipArchive;
|
|
||||||
|
|
||||||
const REQUIRED: &[&str] = &[
|
|
||||||
"bin/iota",
|
|
||||||
"bin/iota-daemon",
|
|
||||||
"bin/iota-updater",
|
|
||||||
"systemd/iota-daemon.service",
|
|
||||||
"systemd/iota-daemon.socket",
|
|
||||||
"systemd/sysusers.d/iota.conf",
|
|
||||||
"systemd/iota-update.service",
|
|
||||||
"systemd/iota-update.timer",
|
|
||||||
"manifest.json",
|
|
||||||
];
|
|
||||||
|
|
||||||
pub fn install_linux_bundle(bundle: &Path) -> Result<()> {
|
|
||||||
install_linux_bundle_with_operator(bundle, None)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn bootstrap_linux_bundle(bundle: &Path, operator: Option<&str>) -> Result<()> {
|
|
||||||
install_linux_bundle_with_operator(bundle, operator)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn install_linux_bundle_with_operator(bundle: &Path, operator: Option<&str>) -> Result<()> {
|
|
||||||
if std::env::consts::OS != "linux" {
|
|
||||||
bail!("Linux systemd bundles are not supported on this platform");
|
|
||||||
}
|
|
||||||
let staging = tempdir().context("create installer staging directory")?;
|
|
||||||
let file = fs::File::open(bundle).context("open release bundle")?;
|
|
||||||
let mut archive = ZipArchive::new(file).context("read release bundle")?;
|
|
||||||
for name in REQUIRED {
|
|
||||||
let mut entry = archive
|
|
||||||
.by_name(name)
|
|
||||||
.with_context(|| format!("bundle is missing {name}"))?;
|
|
||||||
let output = staging.path().join(name);
|
|
||||||
if let Some(parent) = output.parent() {
|
|
||||||
fs::create_dir_all(parent)?;
|
|
||||||
}
|
|
||||||
let mut out = fs::File::create(&output)?;
|
|
||||||
io::copy(&mut entry, &mut out)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
install(
|
|
||||||
&staging.path().join("bin/iota"),
|
|
||||||
&format!(
|
|
||||||
"{}/versions/{}/bin/iota",
|
|
||||||
iota_paths::install_root().display(),
|
|
||||||
product_version(staging.path())
|
|
||||||
),
|
|
||||||
"0755",
|
|
||||||
)?;
|
|
||||||
install(
|
|
||||||
&staging.path().join("bin/iota-daemon"),
|
|
||||||
&format!(
|
|
||||||
"{}/versions/{}/bin/iota-daemon",
|
|
||||||
iota_paths::install_root().display(),
|
|
||||||
product_version(staging.path())
|
|
||||||
),
|
|
||||||
"0755",
|
|
||||||
)?;
|
|
||||||
let version_dir = format!(
|
|
||||||
"{}/versions/{}",
|
|
||||||
iota_paths::install_root().display(),
|
|
||||||
product_version(staging.path())
|
|
||||||
);
|
|
||||||
if !Path::new(&format!("{version_dir}/bin/iota-daemon")).is_file() {
|
|
||||||
bail!("installed daemon executable is missing: {version_dir}/bin/iota-daemon");
|
|
||||||
}
|
|
||||||
install(
|
|
||||||
&staging.path().join("bin/iota-updater"),
|
|
||||||
&format!(
|
|
||||||
"{}/versions/{}/bin/iota-updater",
|
|
||||||
iota_paths::install_root().display(),
|
|
||||||
product_version(staging.path())
|
|
||||||
),
|
|
||||||
"0755",
|
|
||||||
)?;
|
|
||||||
for unit in [
|
|
||||||
"iota-daemon.service",
|
|
||||||
"iota-daemon.socket",
|
|
||||||
"iota-update.service",
|
|
||||||
"iota-update.timer",
|
|
||||||
] {
|
|
||||||
install(
|
|
||||||
&staging.path().join("systemd").join(unit),
|
|
||||||
&format!("/usr/local/lib/systemd/system/{unit}"),
|
|
||||||
"0644",
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
install(
|
|
||||||
&staging.path().join("systemd/sysusers.d/iota.conf"),
|
|
||||||
"/etc/sysusers.d/iota.conf",
|
|
||||||
"0644",
|
|
||||||
)?;
|
|
||||||
run(
|
|
||||||
"ln",
|
|
||||||
&[
|
|
||||||
"-sfn",
|
|
||||||
&version_dir,
|
|
||||||
&iota_paths::current_version_link().to_string_lossy(),
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
run(
|
|
||||||
"ln",
|
|
||||||
&[
|
|
||||||
"-sfn",
|
|
||||||
&format!("{}/current/bin/iota", iota_paths::install_root().display()),
|
|
||||||
"/usr/local/bin/iota",
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
run(
|
|
||||||
"ln",
|
|
||||||
&[
|
|
||||||
"-sfn",
|
|
||||||
&format!(
|
|
||||||
"{}/current/bin/iota-daemon",
|
|
||||||
iota_paths::install_root().display()
|
|
||||||
),
|
|
||||||
"/usr/local/libexec/iota/iota-daemon",
|
|
||||||
],
|
|
||||||
)?;
|
|
||||||
run("systemd-sysusers", &[])?;
|
|
||||||
for directory in ["/var/lib/iota", "/var/cache/iota", "/var/log/iota"] {
|
|
||||||
run(
|
|
||||||
"install",
|
|
||||||
&["-d", "-m", "0750", "-o", "iota", "-g", "iota", directory],
|
|
||||||
)?;
|
|
||||||
}
|
|
||||||
if let Some(operator) = operator {
|
|
||||||
run("usermod", &["-aG", "iota-operators", operator])?;
|
|
||||||
} else {
|
|
||||||
eprintln!("To grant socket access, run: usermod -aG iota-operators USER");
|
|
||||||
eprintln!(
|
|
||||||
"A new login session is required before supplementary group membership is visible."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
run("systemctl", &["daemon-reload"])?;
|
|
||||||
run("systemctl", &["enable", "--now", "iota-daemon.socket"])?;
|
|
||||||
run("systemctl", &["is-active", "iota-daemon.socket"])?;
|
|
||||||
run("systemctl", &["is-enabled", "iota-daemon.socket"])?;
|
|
||||||
let socket = iota_paths::socket_path(iota_paths::Scope::System);
|
|
||||||
if !socket.exists() {
|
|
||||||
bail!(
|
|
||||||
"systemd socket is active but {} was not created",
|
|
||||||
socket.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn product_version(staging: &Path) -> String {
|
|
||||||
fs::read_to_string(staging.join("manifest.json"))
|
|
||||||
.ok()
|
|
||||||
.and_then(|value| serde_json::from_str::<serde_json::Value>(&value).ok())
|
|
||||||
.and_then(|value| {
|
|
||||||
value
|
|
||||||
.get("product_version")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(str::to_owned)
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| "unversioned".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn install(source: &Path, destination: &str, mode: &str) -> Result<()> {
|
|
||||||
run(
|
|
||||||
"install",
|
|
||||||
&["-D", "-m", mode, &source.to_string_lossy(), destination],
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run(program: &str, args: &[&str]) -> Result<()> {
|
|
||||||
let status = Command::new(program)
|
|
||||||
.args(args)
|
|
||||||
.status()
|
|
||||||
.with_context(|| format!("run {program}"))?;
|
|
||||||
if status.success() {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
bail!("{program} failed; run the installer as root")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "iota-ipc"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
|
||||||
serde_json = "1"
|
|
||||||
tokio = { version = "1.53.1", features = ["io-util", "macros", "rt"] }
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
pub mod protocol;
|
|
||||||
pub mod text_commands;
|
|
||||||
pub mod transport;
|
|
||||||
|
|
||||||
pub use protocol::{
|
|
||||||
ClientMessage, CommunitySummary, ComponentHealth, ComponentId, ComponentStatusResponse,
|
|
||||||
ConfigResponse, ConnectionStatus, DaemonMessage, DaemonStatusResponse, DeploymentMode,
|
|
||||||
ExitIntent, HealthStatus, HelloAck, IpcErrorCode, IpcRole, LifecycleEvent, LifecyclePhase,
|
|
||||||
LocalRequest, LocalUserState, LogEntriesResponse, LogEntry, MetricSample,
|
|
||||||
OmikronStatusResponse, RequestEnvelope, ResponseEnvelope, ResponsePayload, ResponseResult,
|
|
||||||
SecretString, StartupPhase, StateSnapshot, StatusResponse, SupervisorKind, TaskSummary,
|
|
||||||
UpdateStatusResponse, UserDetailResponse, UserSummary,
|
|
||||||
};
|
|
||||||
pub use transport::{MAX_MESSAGE_SIZE, read_msg, write_msg};
|
|
||||||
|
|
||||||
/// Current IPC protocol version.
|
|
||||||
pub const PROTOCOL_VERSION: u16 = 2;
|
|
||||||
/// Minimum protocol version this daemon understands.
|
|
||||||
pub const MIN_PROTOCOL_VERSION: u16 = 2;
|
|
||||||
|
|
@ -1,515 +0,0 @@
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// IPC credentials are supplied by the interactive CLI, never a daemon-side
|
|
||||||
/// path lookup. Debug is deliberately redacted because command routing logs
|
|
||||||
/// the request value.
|
|
||||||
#[derive(Clone, Deserialize, Serialize)]
|
|
||||||
pub struct SecretString(pub String);
|
|
||||||
|
|
||||||
impl std::fmt::Debug for SecretString {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
f.write_str("<redacted>")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Client → Daemon
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
|
|
||||||
pub enum ClientMessage {
|
|
||||||
Hello {
|
|
||||||
supported_versions: Vec<u16>,
|
|
||||||
},
|
|
||||||
Subscribe {
|
|
||||||
log_classes: Vec<String>,
|
|
||||||
metric_interval_ms: Option<u64>,
|
|
||||||
},
|
|
||||||
Request(RequestEnvelope),
|
|
||||||
Ping {
|
|
||||||
seq: u64,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
pub struct RequestEnvelope {
|
|
||||||
pub request_id: u64,
|
|
||||||
pub protocol_version: u16,
|
|
||||||
pub request: LocalRequest,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
|
|
||||||
pub enum LocalRequest {
|
|
||||||
GetStatus,
|
|
||||||
ListTasks,
|
|
||||||
ListUsers,
|
|
||||||
CreateUser {
|
|
||||||
username: String,
|
|
||||||
},
|
|
||||||
AttachUserFromTu {
|
|
||||||
credential: SecretString,
|
|
||||||
},
|
|
||||||
PurgeUserData {
|
|
||||||
user_id: i64,
|
|
||||||
},
|
|
||||||
ReleaseUser {
|
|
||||||
user_id: i64,
|
|
||||||
},
|
|
||||||
CompleteDeleteUser {
|
|
||||||
user_id: i64,
|
|
||||||
credential: Option<SecretString>,
|
|
||||||
},
|
|
||||||
/// Retained only to return an actionable deprecation error to old IPC
|
|
||||||
/// clients. It must never select lifecycle semantics implicitly.
|
|
||||||
RemoveUser {
|
|
||||||
user_id: i64,
|
|
||||||
},
|
|
||||||
ReconnectOmikron,
|
|
||||||
RotateIotaIdentity,
|
|
||||||
RequestProcessExit {
|
|
||||||
intent: ExitIntent,
|
|
||||||
},
|
|
||||||
GetDaemonStatus,
|
|
||||||
#[serde(skip)]
|
|
||||||
RestartDaemon,
|
|
||||||
#[serde(skip)]
|
|
||||||
StopDaemon,
|
|
||||||
GetConfig,
|
|
||||||
SetConfig {
|
|
||||||
key: String,
|
|
||||||
value: String,
|
|
||||||
},
|
|
||||||
ReloadConfig,
|
|
||||||
GetOmikronStatus,
|
|
||||||
ListComponents,
|
|
||||||
GetUser {
|
|
||||||
user_id: i64,
|
|
||||||
},
|
|
||||||
ImportUser {
|
|
||||||
username: String,
|
|
||||||
},
|
|
||||||
GetLogs {
|
|
||||||
limit: usize,
|
|
||||||
},
|
|
||||||
CheckUpdate,
|
|
||||||
ListCommunities,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub enum IpcRole {
|
|
||||||
Read,
|
|
||||||
Operate,
|
|
||||||
Admin,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IpcRole {
|
|
||||||
pub fn allows(self, required: IpcRole) -> bool {
|
|
||||||
matches!(
|
|
||||||
(self, required),
|
|
||||||
(IpcRole::Admin, _)
|
|
||||||
| (IpcRole::Operate, IpcRole::Operate | IpcRole::Read)
|
|
||||||
| (IpcRole::Read, IpcRole::Read)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LocalRequest {
|
|
||||||
/// Return the minimum authenticated local role required to execute a
|
|
||||||
/// request. New request variants must be assigned explicitly here.
|
|
||||||
pub fn required_role(&self) -> IpcRole {
|
|
||||||
match self {
|
|
||||||
Self::GetStatus
|
|
||||||
| Self::ListTasks
|
|
||||||
| Self::ListUsers
|
|
||||||
| Self::GetDaemonStatus
|
|
||||||
| Self::GetOmikronStatus
|
|
||||||
| Self::ListComponents
|
|
||||||
| Self::GetUser { .. }
|
|
||||||
| Self::GetLogs { .. }
|
|
||||||
| Self::CheckUpdate
|
|
||||||
| Self::ListCommunities => IpcRole::Read,
|
|
||||||
|
|
||||||
Self::ReconnectOmikron | Self::ReloadConfig => IpcRole::Operate,
|
|
||||||
|
|
||||||
Self::CreateUser { .. }
|
|
||||||
| Self::AttachUserFromTu { .. }
|
|
||||||
| Self::PurgeUserData { .. }
|
|
||||||
| Self::ReleaseUser { .. }
|
|
||||||
| Self::CompleteDeleteUser { .. }
|
|
||||||
| Self::RemoveUser { .. }
|
|
||||||
| Self::RotateIotaIdentity
|
|
||||||
| Self::RequestProcessExit { .. }
|
|
||||||
| Self::RestartDaemon
|
|
||||||
| Self::StopDaemon
|
|
||||||
| Self::GetConfig
|
|
||||||
| Self::SetConfig { .. }
|
|
||||||
| Self::ImportUser { .. } => IpcRole::Admin,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum ExitIntent {
|
|
||||||
Stop,
|
|
||||||
Restart,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Daemon → Client
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
|
|
||||||
pub enum DaemonMessage {
|
|
||||||
HelloAck(HelloAck),
|
|
||||||
/// Confirms that the server has installed this connection's subscription.
|
|
||||||
Subscribed,
|
|
||||||
LogEntry(LogEntry),
|
|
||||||
StateUpdate(StateSnapshot),
|
|
||||||
MetricSample(MetricSample),
|
|
||||||
Response(ResponseEnvelope),
|
|
||||||
Pong {
|
|
||||||
seq: u64,
|
|
||||||
},
|
|
||||||
LifecycleEvent(LifecycleEvent),
|
|
||||||
Gap {
|
|
||||||
skipped: u64,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
pub struct HelloAck {
|
|
||||||
pub protocol_version: u16,
|
|
||||||
pub daemon_version: String,
|
|
||||||
pub instance_id: String,
|
|
||||||
pub startup_phase: StartupPhase,
|
|
||||||
pub capabilities: Vec<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub lifecycle: LifecyclePhase,
|
|
||||||
#[serde(default)]
|
|
||||||
pub health: HealthStatus,
|
|
||||||
#[serde(default)]
|
|
||||||
pub deployment_mode: DeploymentMode,
|
|
||||||
#[serde(default)]
|
|
||||||
pub supervisor: SupervisorKind,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
pub struct ResponseEnvelope {
|
|
||||||
pub request_id: u64,
|
|
||||||
pub result: ResponseResult,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
|
|
||||||
pub enum ResponseResult {
|
|
||||||
Ok(ResponsePayload),
|
|
||||||
Error(IpcErrorCode),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
|
|
||||||
pub enum ResponsePayload {
|
|
||||||
Status(StatusResponse),
|
|
||||||
Tasks(Vec<TaskSummary>),
|
|
||||||
Users(Vec<UserSummary>),
|
|
||||||
UserCreated {
|
|
||||||
user_id: i64,
|
|
||||||
username: String,
|
|
||||||
},
|
|
||||||
/// Retained only for wire compatibility. New lifecycle code never emits it.
|
|
||||||
UserRemoved {
|
|
||||||
user_id: i64,
|
|
||||||
},
|
|
||||||
UserDataPurged {
|
|
||||||
user_id: i64,
|
|
||||||
},
|
|
||||||
Acknowledged {
|
|
||||||
message: String,
|
|
||||||
},
|
|
||||||
DaemonStatus(DaemonStatusResponse),
|
|
||||||
Config(ConfigResponse),
|
|
||||||
OmikronStatus(OmikronStatusResponse),
|
|
||||||
Components(Vec<ComponentStatusResponse>),
|
|
||||||
UserDetail(UserDetailResponse),
|
|
||||||
LogEntries(LogEntriesResponse),
|
|
||||||
UpdateStatus(UpdateStatusResponse),
|
|
||||||
Communities(Vec<CommunitySummary>),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
pub struct ConfigResponse {
|
|
||||||
pub yaml: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
pub struct OmikronStatusResponse {
|
|
||||||
pub connected: bool,
|
|
||||||
pub iota_id: Option<u64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
pub struct ComponentStatusResponse {
|
|
||||||
pub id: ComponentId,
|
|
||||||
pub status: HealthStatus,
|
|
||||||
pub message: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
pub struct UserDetailResponse {
|
|
||||||
pub user_id: i64,
|
|
||||||
pub username: String,
|
|
||||||
pub display_name: Option<String>,
|
|
||||||
pub created_at: i64,
|
|
||||||
pub trusted_apps: Vec<String>,
|
|
||||||
pub state: LocalUserState,
|
|
||||||
pub data_present: bool,
|
|
||||||
pub credential_present: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
pub struct LogEntriesResponse {
|
|
||||||
pub entries: Vec<LogEntry>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
pub struct UpdateStatusResponse {
|
|
||||||
pub available: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
pub struct CommunitySummary {
|
|
||||||
pub name: String,
|
|
||||||
pub title: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
pub struct StatusResponse {
|
|
||||||
pub phase: String,
|
|
||||||
pub tasks: Vec<String>,
|
|
||||||
pub degraded_reason: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
pub struct TaskSummary {
|
|
||||||
pub name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
pub struct UserSummary {
|
|
||||||
pub user_id: i64,
|
|
||||||
pub username: String,
|
|
||||||
pub state: LocalUserState,
|
|
||||||
pub data_present: bool,
|
|
||||||
pub credential_present: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum LocalUserState {
|
|
||||||
Managed,
|
|
||||||
Released,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
pub struct DaemonStatusResponse {
|
|
||||||
pub formatted: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum IpcErrorCode {
|
|
||||||
InvalidRequest,
|
|
||||||
NotFound,
|
|
||||||
Conflict,
|
|
||||||
StorageFailure,
|
|
||||||
OmikronUnavailable,
|
|
||||||
UnsupportedVersion,
|
|
||||||
NotReady,
|
|
||||||
Disconnected,
|
|
||||||
Timeout,
|
|
||||||
Cancelled,
|
|
||||||
Unauthorized,
|
|
||||||
InternalFailure,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for IpcErrorCode {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
f.write_str(match self {
|
|
||||||
Self::InvalidRequest => "the daemon rejected the request",
|
|
||||||
Self::NotFound => "the requested resource was not found",
|
|
||||||
Self::Conflict => "the request conflicts with current daemon state",
|
|
||||||
Self::StorageFailure => "the daemon could not access local storage",
|
|
||||||
Self::OmikronUnavailable => "Omikron is unavailable",
|
|
||||||
Self::UnsupportedVersion => "the client and daemon protocol versions are incompatible",
|
|
||||||
Self::NotReady => "the daemon is not ready yet",
|
|
||||||
Self::Disconnected => "the daemon connection was lost",
|
|
||||||
Self::Timeout => "the daemon did not respond in time",
|
|
||||||
Self::Cancelled => "the daemon cancelled the request",
|
|
||||||
Self::Unauthorized => {
|
|
||||||
"the daemon denied this operation because the IPC account lacks the required role"
|
|
||||||
}
|
|
||||||
Self::InternalFailure => "the daemon encountered an internal failure",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod error_tests {
|
|
||||||
use super::IpcErrorCode;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn error_codes_have_operator_facing_messages() {
|
|
||||||
assert_eq!(
|
|
||||||
IpcErrorCode::NotReady.to_string(),
|
|
||||||
"the daemon is not ready yet"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!IpcErrorCode::InternalFailure
|
|
||||||
.to_string()
|
|
||||||
.contains("InternalFailure")
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
IpcErrorCode::Unauthorized
|
|
||||||
.to_string()
|
|
||||||
.contains("required role")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
|
|
||||||
pub enum LifecycleEvent {
|
|
||||||
StateChanged(ConnectionStatus),
|
|
||||||
Shutdown { reason: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum ConnectionStatus {
|
|
||||||
Connected,
|
|
||||||
Reconnecting,
|
|
||||||
Degraded,
|
|
||||||
Disconnected,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum StartupPhase {
|
|
||||||
Starting,
|
|
||||||
MigratingStorage,
|
|
||||||
LoadingUsers,
|
|
||||||
StartingServices,
|
|
||||||
Ready,
|
|
||||||
Degraded,
|
|
||||||
Stopping,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum LifecyclePhase {
|
|
||||||
#[default]
|
|
||||||
Starting,
|
|
||||||
Ready,
|
|
||||||
Stopping,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum HealthStatus {
|
|
||||||
#[default]
|
|
||||||
Healthy,
|
|
||||||
Degraded,
|
|
||||||
Failed,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum ComponentId {
|
|
||||||
Storage,
|
|
||||||
Ipc,
|
|
||||||
Omikron,
|
|
||||||
Web,
|
|
||||||
Updater,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum DeploymentMode {
|
|
||||||
SessionChild,
|
|
||||||
UiAutoStart,
|
|
||||||
UserService,
|
|
||||||
SystemSocketActivated,
|
|
||||||
SystemAlwaysOn,
|
|
||||||
#[default]
|
|
||||||
External,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
pub enum SupervisorKind {
|
|
||||||
#[default]
|
|
||||||
None,
|
|
||||||
IotaUi,
|
|
||||||
Systemd,
|
|
||||||
External,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
||||||
pub struct ComponentHealth {
|
|
||||||
pub status: HealthStatus,
|
|
||||||
pub message: Option<String>,
|
|
||||||
pub changed_at_ms: u128,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for StartupPhase {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::Starting
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Shared types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
pub struct LogEntry {
|
|
||||||
pub timestamp_ms: u128,
|
|
||||||
pub sender: String,
|
|
||||||
pub message: String,
|
|
||||||
pub is_error: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
|
||||||
pub struct StateSnapshot {
|
|
||||||
pub cpu: Vec<(f64, f64)>,
|
|
||||||
pub ram: Vec<(f64, f64)>,
|
|
||||||
pub ping: Vec<(f64, f64)>,
|
|
||||||
pub net_up: Vec<(f64, f64)>,
|
|
||||||
pub net_down: Vec<(f64, f64)>,
|
|
||||||
pub sys_info: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub startup_phase: StartupPhase,
|
|
||||||
#[serde(default)]
|
|
||||||
pub degraded_reason: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub lifecycle: LifecyclePhase,
|
|
||||||
#[serde(default)]
|
|
||||||
pub startup_step: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub overall_health: HealthStatus,
|
|
||||||
#[serde(default)]
|
|
||||||
pub components: std::collections::BTreeMap<ComponentId, ComponentHealth>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
|
||||||
pub struct MetricSample {
|
|
||||||
pub cpu: Option<f64>,
|
|
||||||
pub ram: Option<f64>,
|
|
||||||
pub ping: Option<f64>,
|
|
||||||
pub net_up: Option<f64>,
|
|
||||||
pub net_down: Option<f64>,
|
|
||||||
}
|
|
||||||
|
|
@ -1,334 +0,0 @@
|
||||||
use crate::LocalRequest;
|
|
||||||
|
|
||||||
pub const COMMANDS: &[&str] = &[
|
|
||||||
"status",
|
|
||||||
"tasks",
|
|
||||||
"users list",
|
|
||||||
"users show ",
|
|
||||||
"users add ",
|
|
||||||
"users remove ",
|
|
||||||
"users import ",
|
|
||||||
"omikron status",
|
|
||||||
"reconnect",
|
|
||||||
"identity rotate",
|
|
||||||
"daemon status",
|
|
||||||
"config get",
|
|
||||||
"config set ",
|
|
||||||
"config reload",
|
|
||||||
"health",
|
|
||||||
"components",
|
|
||||||
"logs",
|
|
||||||
"update check",
|
|
||||||
"community list",
|
|
||||||
"restart",
|
|
||||||
"stop",
|
|
||||||
];
|
|
||||||
|
|
||||||
pub fn completions(prefix: &str) -> Vec<&'static str> {
|
|
||||||
let normalized = prefix.trim_start_matches('/');
|
|
||||||
COMMANDS
|
|
||||||
.iter()
|
|
||||||
.copied()
|
|
||||||
.filter(|command| command.starts_with(normalized))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn validation_error(line: &str) -> Option<String> {
|
|
||||||
let normalized = line.trim_start_matches('/').trim();
|
|
||||||
if normalized == "help" || parse(normalized).is_some() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(format!(
|
|
||||||
"Unknown command `{normalized}`. Use /help or Tab completion."
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse a text command string into a typed IPC request.
|
|
||||||
///
|
|
||||||
/// Both the CLI console and the TUI command palette use this single parser.
|
|
||||||
/// Commands are case-insensitive and support an optional leading `/`.
|
|
||||||
pub fn parse(line: &str) -> Option<LocalRequest> {
|
|
||||||
let parts: Vec<&str> = line.trim_start_matches('/').split_whitespace().collect();
|
|
||||||
match parts.as_slice() {
|
|
||||||
["help"] => None,
|
|
||||||
["status"] => Some(LocalRequest::GetStatus),
|
|
||||||
["tasks"] => Some(LocalRequest::ListTasks),
|
|
||||||
["users"] | ["user", "list"] | ["users", "list"] => Some(LocalRequest::ListUsers),
|
|
||||||
["user" | "users", "show", id_str] => {
|
|
||||||
let user_id = id_str.parse::<i64>().ok()?;
|
|
||||||
Some(LocalRequest::GetUser { user_id })
|
|
||||||
}
|
|
||||||
["user" | "users", "add", username] => Some(LocalRequest::CreateUser {
|
|
||||||
username: username.to_string(),
|
|
||||||
}),
|
|
||||||
["user" | "users", "remove", id_str] => {
|
|
||||||
let user_id = id_str.parse::<i64>().ok()?;
|
|
||||||
Some(LocalRequest::RemoveUser { user_id })
|
|
||||||
}
|
|
||||||
["user" | "users", "import", username] => Some(LocalRequest::ImportUser {
|
|
||||||
username: username.to_string(),
|
|
||||||
}),
|
|
||||||
["reconnect"] => Some(LocalRequest::ReconnectOmikron),
|
|
||||||
["regenerate", "keys"] | ["identity", "rotate"] => Some(LocalRequest::RotateIotaIdentity),
|
|
||||||
["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit {
|
|
||||||
intent: crate::ExitIntent::Restart,
|
|
||||||
}),
|
|
||||||
["shutdown"] | ["stop"] => Some(LocalRequest::RequestProcessExit {
|
|
||||||
intent: crate::ExitIntent::Stop,
|
|
||||||
}),
|
|
||||||
["daemon", "status"] => Some(LocalRequest::GetDaemonStatus),
|
|
||||||
["config", "get"] => Some(LocalRequest::GetConfig),
|
|
||||||
["config", "set", key, value] => Some(LocalRequest::SetConfig {
|
|
||||||
key: key.to_string(),
|
|
||||||
value: value.to_string(),
|
|
||||||
}),
|
|
||||||
["config", "reload"] => Some(LocalRequest::ReloadConfig),
|
|
||||||
["omikron", "status"] => Some(LocalRequest::GetOmikronStatus),
|
|
||||||
["health"] => Some(LocalRequest::ListComponents),
|
|
||||||
["components"] => Some(LocalRequest::ListComponents),
|
|
||||||
["logs"] => Some(LocalRequest::GetLogs { limit: 100 }),
|
|
||||||
["update", "check"] => Some(LocalRequest::CheckUpdate),
|
|
||||||
["community", "list"] | ["communities"] => Some(LocalRequest::ListCommunities),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_status() {
|
|
||||||
assert!(matches!(parse("status"), Some(LocalRequest::GetStatus)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_tasks() {
|
|
||||||
assert!(matches!(parse("tasks"), Some(LocalRequest::ListTasks)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_user_list_shortcuts() {
|
|
||||||
assert!(matches!(parse("users"), Some(LocalRequest::ListUsers)));
|
|
||||||
assert!(matches!(parse("user list"), Some(LocalRequest::ListUsers)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_user_add() {
|
|
||||||
let req = parse("user add alice").unwrap();
|
|
||||||
match req {
|
|
||||||
LocalRequest::CreateUser { username } => assert_eq!(username, "alice"),
|
|
||||||
_ => panic!("expected CreateUser"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn accepts_the_headless_cli_user_vocabulary() {
|
|
||||||
assert!(matches!(parse("users list"), Some(LocalRequest::ListUsers)));
|
|
||||||
assert!(matches!(
|
|
||||||
parse("users add alice"),
|
|
||||||
Some(LocalRequest::CreateUser { .. })
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
parse("users remove 42"),
|
|
||||||
Some(LocalRequest::RemoveUser { user_id: 42 })
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
parse("identity rotate"),
|
|
||||||
Some(LocalRequest::RotateIotaIdentity)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_user_remove_by_id() {
|
|
||||||
let req = parse("user remove 42").unwrap();
|
|
||||||
match req {
|
|
||||||
LocalRequest::RemoveUser { user_id } => assert_eq!(user_id, 42),
|
|
||||||
_ => panic!("expected RemoveUser"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn user_remove_requires_numeric_id() {
|
|
||||||
assert!(parse("user remove alice").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_reconnect() {
|
|
||||||
assert!(matches!(
|
|
||||||
parse("reconnect"),
|
|
||||||
Some(LocalRequest::ReconnectOmikron)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_regenerate_keys() {
|
|
||||||
assert!(matches!(
|
|
||||||
parse("regenerate keys"),
|
|
||||||
Some(LocalRequest::RotateIotaIdentity)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_restart_aliases() {
|
|
||||||
assert!(matches!(
|
|
||||||
parse("restart"),
|
|
||||||
Some(LocalRequest::RequestProcessExit { .. })
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
parse("reload"),
|
|
||||||
Some(LocalRequest::RequestProcessExit { .. })
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_stop_aliases() {
|
|
||||||
assert!(matches!(
|
|
||||||
parse("stop"),
|
|
||||||
Some(LocalRequest::RequestProcessExit { .. })
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
parse("shutdown"),
|
|
||||||
Some(LocalRequest::RequestProcessExit { .. })
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_daemon_status() {
|
|
||||||
assert!(matches!(
|
|
||||||
parse("daemon status"),
|
|
||||||
Some(LocalRequest::GetDaemonStatus)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_config_get() {
|
|
||||||
assert!(matches!(parse("config get"), Some(LocalRequest::GetConfig)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_config_reload() {
|
|
||||||
assert!(matches!(
|
|
||||||
parse("config reload"),
|
|
||||||
Some(LocalRequest::ReloadConfig)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_omikron_status() {
|
|
||||||
assert!(matches!(
|
|
||||||
parse("omikron status"),
|
|
||||||
Some(LocalRequest::GetOmikronStatus)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_components() {
|
|
||||||
assert!(matches!(
|
|
||||||
parse("components"),
|
|
||||||
Some(LocalRequest::ListComponents)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_health() {
|
|
||||||
assert!(matches!(
|
|
||||||
parse("health"),
|
|
||||||
Some(LocalRequest::ListComponents)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_users_show() {
|
|
||||||
let req = parse("users show 42").unwrap();
|
|
||||||
match req {
|
|
||||||
LocalRequest::GetUser { user_id } => assert_eq!(user_id, 42),
|
|
||||||
_ => panic!("expected GetUser"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn user_show_requires_numeric_id() {
|
|
||||||
assert!(parse("users show alice").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_config_set() {
|
|
||||||
let req = parse("config set port 8080").unwrap();
|
|
||||||
match req {
|
|
||||||
LocalRequest::SetConfig { key, value } => {
|
|
||||||
assert_eq!(key, "port");
|
|
||||||
assert_eq!(value, "8080");
|
|
||||||
}
|
|
||||||
_ => panic!("expected SetConfig"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_logs() {
|
|
||||||
assert!(matches!(parse("logs"), Some(LocalRequest::GetLogs { .. })));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_update_check() {
|
|
||||||
assert!(matches!(
|
|
||||||
parse("update check"),
|
|
||||||
Some(LocalRequest::CheckUpdate)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_community_list() {
|
|
||||||
assert!(matches!(
|
|
||||||
parse("community list"),
|
|
||||||
Some(LocalRequest::ListCommunities)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_communities_alias() {
|
|
||||||
assert!(matches!(
|
|
||||||
parse("communities"),
|
|
||||||
Some(LocalRequest::ListCommunities)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_users_import() {
|
|
||||||
let req = parse("users import alice").unwrap();
|
|
||||||
match req {
|
|
||||||
LocalRequest::ImportUser { username } => assert_eq!(username, "alice"),
|
|
||||||
_ => panic!("expected ImportUser"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parses_with_slash_prefix() {
|
|
||||||
assert!(matches!(parse("/status"), Some(LocalRequest::GetStatus)));
|
|
||||||
assert!(matches!(parse("/tasks"), Some(LocalRequest::ListTasks)));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unknown_returns_none() {
|
|
||||||
assert!(parse("nonexistent").is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn completion_is_prefix_based_and_deterministic() {
|
|
||||||
assert_eq!(completions("identity r"), vec!["identity rotate"]);
|
|
||||||
assert_eq!(completions("/users a"), vec!["users add "]);
|
|
||||||
assert!(completions("definitely-unknown").is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn validation_distinguishes_help_and_unknown_commands() {
|
|
||||||
assert_eq!(validation_error("/help"), None);
|
|
||||||
assert!(validation_error("status").is_none());
|
|
||||||
assert!(
|
|
||||||
validation_error("statuz")
|
|
||||||
.unwrap()
|
|
||||||
.contains("Unknown command")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,96 +0,0 @@
|
||||||
use serde::Serialize;
|
|
||||||
use serde::de::DeserializeOwned;
|
|
||||||
use std::io::{Error, ErrorKind, Result};
|
|
||||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
|
||||||
|
|
||||||
/// Maximum encoded payload size for a single IPC frame.
|
|
||||||
///
|
|
||||||
/// This is a wire-level contract shared by both sides of the connection.
|
|
||||||
pub const MAX_MESSAGE_SIZE: usize = 1024 * 1024;
|
|
||||||
|
|
||||||
/* Length-prefixing preserves message boundaries on a byte stream and bounds
|
|
||||||
* allocations before JSON is deserialized. */
|
|
||||||
pub async fn write_msg<W, T>(writer: &mut W, message: &T) -> Result<()>
|
|
||||||
where
|
|
||||||
W: AsyncWrite + Unpin,
|
|
||||||
T: Serialize,
|
|
||||||
{
|
|
||||||
let payload =
|
|
||||||
serde_json::to_vec(message).map_err(|error| Error::new(ErrorKind::InvalidData, error))?;
|
|
||||||
if payload.len() > MAX_MESSAGE_SIZE {
|
|
||||||
return Err(Error::new(
|
|
||||||
ErrorKind::InvalidData,
|
|
||||||
"IPC message exceeds limit",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let len = u32::try_from(payload.len())
|
|
||||||
.map_err(|_| Error::new(ErrorKind::InvalidData, "IPC message is too large"))?;
|
|
||||||
writer.write_u32(len).await?;
|
|
||||||
writer.write_all(&payload).await?;
|
|
||||||
writer.flush().await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn read_msg<R, T>(reader: &mut R) -> Result<T>
|
|
||||||
where
|
|
||||||
R: AsyncRead + Unpin,
|
|
||||||
T: DeserializeOwned,
|
|
||||||
{
|
|
||||||
let len = reader.read_u32().await? as usize;
|
|
||||||
if len > MAX_MESSAGE_SIZE {
|
|
||||||
return Err(Error::new(
|
|
||||||
ErrorKind::InvalidData,
|
|
||||||
"IPC message exceeds limit",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let mut payload = vec![0; len];
|
|
||||||
reader.read_exact(&mut payload).await?;
|
|
||||||
serde_json::from_slice(&payload).map_err(|error| Error::new(ErrorKind::InvalidData, error))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::{MAX_MESSAGE_SIZE, read_msg, write_msg};
|
|
||||||
use crate::protocol::{ClientMessage, LocalRequest, RequestEnvelope};
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn round_trips_framed_messages() {
|
|
||||||
let (mut writer, mut reader) = tokio::io::duplex(1024);
|
|
||||||
let message = ClientMessage::Request(RequestEnvelope {
|
|
||||||
request_id: 4,
|
|
||||||
protocol_version: 2,
|
|
||||||
request: LocalRequest::GetStatus,
|
|
||||||
});
|
|
||||||
write_msg(&mut writer, &message)
|
|
||||||
.await
|
|
||||||
.expect("write succeeds");
|
|
||||||
let received: ClientMessage = read_msg(&mut reader).await.expect("read succeeds");
|
|
||||||
assert!(matches!(received, ClientMessage::Request(req) if req.request_id == 4));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn write_rejects_message_above_frame_limit() {
|
|
||||||
let (mut writer, _reader) = tokio::io::duplex(MAX_MESSAGE_SIZE + 16);
|
|
||||||
let message = "x".repeat(MAX_MESSAGE_SIZE + 1);
|
|
||||||
|
|
||||||
let error = write_msg(&mut writer, &message)
|
|
||||||
.await
|
|
||||||
.expect_err("oversized payload must be rejected before framing");
|
|
||||||
|
|
||||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
|
|
||||||
assert!(error.to_string().contains("exceeds limit"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn read_rejects_frame_above_limit_before_allocating_payload() {
|
|
||||||
let (mut writer, mut reader) = tokio::io::duplex(16);
|
|
||||||
tokio::io::AsyncWriteExt::write_u32(&mut writer, (MAX_MESSAGE_SIZE + 1) as u32)
|
|
||||||
.await
|
|
||||||
.expect("length prefix write succeeds");
|
|
||||||
|
|
||||||
let error = read_msg::<_, ClientMessage>(&mut reader)
|
|
||||||
.await
|
|
||||||
.expect_err("oversized frame must be rejected");
|
|
||||||
|
|
||||||
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "iota-logger"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
iota-paths = { path = "../iota-paths" }
|
|
||||||
iota-state = { path = "../iota-state" }
|
|
||||||
iota-util = { path = "../iota-util" }
|
|
||||||
|
|
||||||
mtp = { git = "https://git.methanium.net/Methanium/mtp.git" }
|
|
||||||
|
|
||||||
ratatui = "0.30.0"
|
|
||||||
json = "0.12.4"
|
|
||||||
once_cell = "1.21.4"
|
|
||||||
tokio = { version = "1.50.0", features = ["sync"] }
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "iota-paths"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
@ -1,569 +0,0 @@
|
||||||
//! Platform and deployment aware locations used by Iota.
|
|
||||||
//!
|
|
||||||
//! This module deliberately keeps environment handling in one place. In
|
|
||||||
//! particular, an override is never interpreted relative to the process
|
|
||||||
//! working directory.
|
|
||||||
use std::env;
|
|
||||||
use std::fmt;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
||||||
pub enum Scope {
|
|
||||||
User,
|
|
||||||
System,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compatibility name retained for callers which have not yet been migrated.
|
|
||||||
pub type SocketScope = Scope;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
||||||
pub enum IpcEndpoint {
|
|
||||||
UnixSocket(PathBuf),
|
|
||||||
WindowsPipe(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum PathError {
|
|
||||||
MissingPlatformDirectory(&'static str),
|
|
||||||
MissingRequiredOverride(&'static str),
|
|
||||||
EmptyOverride(&'static str),
|
|
||||||
RelativeOverride {
|
|
||||||
variable: &'static str,
|
|
||||||
value: PathBuf,
|
|
||||||
},
|
|
||||||
InvalidPipeName(String),
|
|
||||||
UnsupportedScope,
|
|
||||||
}
|
|
||||||
impl fmt::Display for PathError {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::MissingPlatformDirectory(name) => write!(f, "missing platform directory: {name}"),
|
|
||||||
Self::MissingRequiredOverride(name) => write!(f, "{name} must be set"),
|
|
||||||
Self::EmptyOverride(name) => write!(f, "{name} must not be empty"),
|
|
||||||
Self::RelativeOverride { variable, value } => {
|
|
||||||
write!(f, "{variable} must be absolute, got {}", value.display())
|
|
||||||
}
|
|
||||||
Self::InvalidPipeName(name) => write!(f, "invalid Windows pipe name: {name}"),
|
|
||||||
Self::UnsupportedScope => write!(f, "this path scope is unsupported on this platform"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl std::error::Error for PathError {}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct IotaPaths {
|
|
||||||
pub scope: Scope,
|
|
||||||
pub config_dir: PathBuf,
|
|
||||||
pub config_file: PathBuf,
|
|
||||||
pub state_dir: PathBuf,
|
|
||||||
pub storage_dir: PathBuf,
|
|
||||||
pub identity_dir: PathBuf,
|
|
||||||
pub cache_dir: PathBuf,
|
|
||||||
pub runtime_dir: Option<PathBuf>,
|
|
||||||
pub log_dir: PathBuf,
|
|
||||||
/// Directory containing static web assets (not its parent).
|
|
||||||
pub asset_dir: PathBuf,
|
|
||||||
pub install_root: PathBuf,
|
|
||||||
pub ipc_endpoint: IpcEndpoint,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl IotaPaths {
|
|
||||||
pub fn resolve(scope: Scope) -> Result<Self, PathError> {
|
|
||||||
let defaults = Defaults::for_scope(scope)?;
|
|
||||||
let data_root = if scope == Scope::User {
|
|
||||||
absolute_env("IOTA_DATA_ROOT")?
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
let config_dir = override_first(&["IOTA_CONFIG_DIR"])?
|
|
||||||
.or_else(|| data_root.as_ref().map(|root| root.join("config")))
|
|
||||||
.unwrap_or(defaults.config_dir);
|
|
||||||
// IOTA_DATA_DIR is intentionally only a compatibility alias. Parse it
|
|
||||||
// exactly like every other override; do not hide an invalid value.
|
|
||||||
let state_dir = override_first(&["IOTA_STATE_DIR", "IOTA_DATA_DIR"])?
|
|
||||||
.or_else(|| data_root.as_ref().map(|root| root.join("state")))
|
|
||||||
.unwrap_or(defaults.state_dir);
|
|
||||||
let cache_dir = override_first(&["IOTA_CACHE_DIR"])?
|
|
||||||
.or_else(|| data_root.as_ref().map(|root| root.join("cache")))
|
|
||||||
.unwrap_or(defaults.cache_dir);
|
|
||||||
let runtime_dir = override_first(&["IOTA_RUNTIME_DIR"])?
|
|
||||||
.or_else(|| data_root.as_ref().map(|root| root.join("runtime")))
|
|
||||||
.or(defaults.runtime_dir);
|
|
||||||
let log_dir = override_first(&["IOTA_LOG_DIR"])?
|
|
||||||
.or_else(|| data_root.as_ref().map(|root| root.join("logs")))
|
|
||||||
.unwrap_or(defaults.log_dir);
|
|
||||||
let asset_dir = override_first(&["IOTA_ASSET_DIR", "IOTA_WEB_ASSET_DIR"])?
|
|
||||||
.or_else(|| data_root.as_ref().map(|root| root.join("web")))
|
|
||||||
.unwrap_or(defaults.asset_dir);
|
|
||||||
let install_root = override_first(&["IOTA_INSTALL_ROOT"])?
|
|
||||||
.or_else(|| data_root.as_ref().map(|root| root.join("bin")))
|
|
||||||
.unwrap_or(defaults.install_root);
|
|
||||||
let config_file = override_first(&["IOTA_CONFIG_FILE"])?
|
|
||||||
.unwrap_or_else(|| config_dir.join("config.yaml"));
|
|
||||||
let ipc_endpoint = resolve_ipc(scope, defaults.ipc_endpoint, data_root.as_deref())?;
|
|
||||||
let runtime_dir = runtime_dir.or_else(|| match &ipc_endpoint {
|
|
||||||
IpcEndpoint::UnixSocket(path) => path.parent().map(Path::to_path_buf),
|
|
||||||
IpcEndpoint::WindowsPipe(_) => None,
|
|
||||||
});
|
|
||||||
Ok(Self {
|
|
||||||
scope,
|
|
||||||
config_dir,
|
|
||||||
config_file,
|
|
||||||
storage_dir: state_dir.join("storage"),
|
|
||||||
identity_dir: state_dir.join("identity"),
|
|
||||||
state_dir,
|
|
||||||
cache_dir,
|
|
||||||
runtime_dir,
|
|
||||||
log_dir,
|
|
||||||
asset_dir,
|
|
||||||
install_root,
|
|
||||||
ipc_endpoint,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn database_file(&self) -> PathBuf {
|
|
||||||
self.storage_dir.join("messages.sqlite3")
|
|
||||||
}
|
|
||||||
pub fn keyring_file(&self) -> PathBuf {
|
|
||||||
self.identity_dir.join("iota.mk")
|
|
||||||
}
|
|
||||||
pub fn update_staging_dir(&self) -> PathBuf {
|
|
||||||
self.cache_dir.join("updates/staging")
|
|
||||||
}
|
|
||||||
pub fn update_status_file(&self) -> PathBuf {
|
|
||||||
self.state_dir.join("update-status.json")
|
|
||||||
}
|
|
||||||
pub fn update_lock_file(&self) -> Result<PathBuf, PathError> {
|
|
||||||
self.runtime_dir
|
|
||||||
.as_ref()
|
|
||||||
.map(|p| p.join("update.lock"))
|
|
||||||
.ok_or(PathError::MissingPlatformDirectory("runtime directory"))
|
|
||||||
}
|
|
||||||
pub fn daemon_lock_file(&self) -> Result<PathBuf, PathError> {
|
|
||||||
self.runtime_dir
|
|
||||||
.as_ref()
|
|
||||||
.map(|p| p.join("daemon.lock"))
|
|
||||||
.ok_or(PathError::MissingPlatformDirectory("runtime directory"))
|
|
||||||
}
|
|
||||||
pub fn prepare_writable_directories(&self) -> std::io::Result<()> {
|
|
||||||
for directory in [
|
|
||||||
&self.state_dir,
|
|
||||||
&self.storage_dir,
|
|
||||||
&self.identity_dir,
|
|
||||||
&self.cache_dir,
|
|
||||||
&self.log_dir,
|
|
||||||
] {
|
|
||||||
create_directory(directory, self.scope == Scope::User).map_err(|error| {
|
|
||||||
std::io::Error::new(
|
|
||||||
error.kind(),
|
|
||||||
format!("cannot prepare {}: {error}", directory.display()),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
if let Some(runtime) = &self.runtime_dir {
|
|
||||||
create_directory(runtime, self.scope == Scope::User).map_err(|error| {
|
|
||||||
std::io::Error::new(
|
|
||||||
error.kind(),
|
|
||||||
format!("cannot prepare {}: {error}", runtime.display()),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Move the pre-v2 resources that were all placed directly below the
|
|
||||||
/// state root. This is deliberately idempotent: an existing destination
|
|
||||||
/// is never overwritten and the marker is only written after the moves.
|
|
||||||
pub fn migrate_legacy_layout(&self) -> std::io::Result<()> {
|
|
||||||
let marker = self.state_dir.join("path-layout-v2.json");
|
|
||||||
if marker.exists() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
move_if_absent(&self.state_dir.join("config.yaml"), &self.config_file)?;
|
|
||||||
move_if_absent(&self.state_dir.join("certs"), &self.config_dir.join("tls"))?;
|
|
||||||
for suffix in [
|
|
||||||
"messages.sqlite3",
|
|
||||||
"messages.sqlite3-wal",
|
|
||||||
"messages.sqlite3-shm",
|
|
||||||
] {
|
|
||||||
move_if_absent(&self.state_dir.join(suffix), &self.storage_dir.join(suffix))?;
|
|
||||||
}
|
|
||||||
for name in ["users", "communities"] {
|
|
||||||
move_if_absent(&self.state_dir.join(name), &self.storage_dir.join(name))?;
|
|
||||||
}
|
|
||||||
move_if_absent(&self.state_dir.join("iota.mk"), &self.keyring_file())?;
|
|
||||||
move_if_absent(
|
|
||||||
&self.state_dir.join("update-staging"),
|
|
||||||
&self.update_staging_dir(),
|
|
||||||
)?;
|
|
||||||
// Runtime objects must not survive a layout migration or reboot.
|
|
||||||
for name in ["update.lock", "iota.sock", "iota.sock.lock"] {
|
|
||||||
let path = self.state_dir.join(name);
|
|
||||||
if path.is_file() || path.is_symlink() {
|
|
||||||
let _ = std::fs::remove_file(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
std::fs::create_dir_all(&self.state_dir)?;
|
|
||||||
std::fs::write(marker, "{\"version\":2}\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn move_if_absent(source: &Path, destination: &Path) -> std::io::Result<()> {
|
|
||||||
if !source.exists() || destination.exists() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let metadata = std::fs::symlink_metadata(source)?;
|
|
||||||
if metadata.file_type().is_symlink() {
|
|
||||||
return Err(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::InvalidInput,
|
|
||||||
format!("refusing symlink migration source {}", source.display()),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if let Some(parent) = destination.parent() {
|
|
||||||
std::fs::create_dir_all(parent)?;
|
|
||||||
}
|
|
||||||
match std::fs::rename(source, destination) {
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(error) if error.raw_os_error() == Some(libc_exdev()) => {
|
|
||||||
copy_recursively(source, destination)?;
|
|
||||||
if source.is_dir() {
|
|
||||||
std::fs::remove_dir_all(source)
|
|
||||||
} else {
|
|
||||||
std::fs::remove_file(source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(error) => Err(error),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// EXDEV is stable on Unix. A literal is used on non-Unix where the fallback
|
|
||||||
// copy is harmlessly skipped because rename normally remains on one volume.
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn libc_exdev() -> i32 {
|
|
||||||
18
|
|
||||||
}
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
fn libc_exdev() -> i32 {
|
|
||||||
-1
|
|
||||||
}
|
|
||||||
fn copy_recursively(source: &Path, destination: &Path) -> std::io::Result<()> {
|
|
||||||
if source.is_dir() {
|
|
||||||
std::fs::create_dir_all(destination)?;
|
|
||||||
for entry in std::fs::read_dir(source)? {
|
|
||||||
let entry = entry?;
|
|
||||||
copy_recursively(&entry.path(), &destination.join(entry.file_name()))?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
std::fs::copy(source, destination).map(|_| ())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Defaults {
|
|
||||||
config_dir: PathBuf,
|
|
||||||
state_dir: PathBuf,
|
|
||||||
cache_dir: PathBuf,
|
|
||||||
runtime_dir: Option<PathBuf>,
|
|
||||||
log_dir: PathBuf,
|
|
||||||
asset_dir: PathBuf,
|
|
||||||
install_root: PathBuf,
|
|
||||||
ipc_endpoint: Option<IpcEndpoint>,
|
|
||||||
}
|
|
||||||
impl Defaults {
|
|
||||||
fn for_scope(scope: Scope) -> Result<Self, PathError> {
|
|
||||||
match scope {
|
|
||||||
Scope::System => {
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
Ok(Self {
|
|
||||||
config_dir: "/etc/iota".into(),
|
|
||||||
state_dir: "/var/lib/iota".into(),
|
|
||||||
cache_dir: "/var/cache/iota".into(),
|
|
||||||
runtime_dir: Some("/run/iota".into()),
|
|
||||||
log_dir: "/var/log/iota".into(),
|
|
||||||
asset_dir: "/usr/local/share/iota/web".into(),
|
|
||||||
install_root: "/usr/local/libexec/iota".into(),
|
|
||||||
ipc_endpoint: Some(IpcEndpoint::UnixSocket("/run/iota/iota.sock".into())),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
{
|
|
||||||
Err(PathError::UnsupportedScope)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Scope::User => user_defaults(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
fn user_defaults() -> Result<Defaults, PathError> {
|
|
||||||
let home =
|
|
||||||
absolute_env("HOME")?.ok_or(PathError::MissingPlatformDirectory("home directory"))?;
|
|
||||||
let config_base = xdg_or_home("XDG_CONFIG_HOME", &home, ".config")?;
|
|
||||||
let state_base = xdg_or_home("XDG_STATE_HOME", &home, ".local/state")?;
|
|
||||||
let cache_base = xdg_or_home("XDG_CACHE_HOME", &home, ".cache")?;
|
|
||||||
let data_base = xdg_or_home("XDG_DATA_HOME", &home, ".local/share")?;
|
|
||||||
Ok(Defaults {
|
|
||||||
config_dir: config_base.join("iota"),
|
|
||||||
state_dir: state_base.join("iota"),
|
|
||||||
cache_dir: cache_base.join("iota"),
|
|
||||||
runtime_dir: None,
|
|
||||||
log_dir: state_base.join("iota/logs"),
|
|
||||||
asset_dir: data_base.join("iota/web"),
|
|
||||||
install_root: data_base.join("iota/bin"),
|
|
||||||
ipc_endpoint: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn user_defaults() -> Result<Defaults, PathError> {
|
|
||||||
let config = absolute_env("APPDATA")?
|
|
||||||
.ok_or(PathError::MissingPlatformDirectory("Roaming AppData"))?
|
|
||||||
.join("Tensamin/Iota/config");
|
|
||||||
let local = absolute_env("LOCALAPPDATA")?
|
|
||||||
.ok_or(PathError::MissingPlatformDirectory("Local AppData"))?
|
|
||||||
.join("Tensamin/Iota");
|
|
||||||
Ok(Defaults {
|
|
||||||
config_dir: config,
|
|
||||||
state_dir: local.join("state"),
|
|
||||||
cache_dir: local.join("cache"),
|
|
||||||
runtime_dir: None,
|
|
||||||
log_dir: local.join("logs"),
|
|
||||||
asset_dir: local.join("data"),
|
|
||||||
install_root: local.join("bin"),
|
|
||||||
ipc_endpoint: Some(IpcEndpoint::WindowsPipe(
|
|
||||||
r"\\.\pipe\Tensamin.Iota.User".into(),
|
|
||||||
)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn xdg_or_home(variable: &'static str, home: &Path, fallback: &str) -> Result<PathBuf, PathError> {
|
|
||||||
Ok(absolute_env(variable)?.unwrap_or_else(|| home.join(fallback)))
|
|
||||||
}
|
|
||||||
fn absolute_env(name: &'static str) -> Result<Option<PathBuf>, PathError> {
|
|
||||||
let Some(value) = env::var_os(name) else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
if value.is_empty() {
|
|
||||||
return Err(PathError::EmptyOverride(name));
|
|
||||||
}
|
|
||||||
let path = PathBuf::from(value);
|
|
||||||
if !path.is_absolute() {
|
|
||||||
return Err(PathError::RelativeOverride {
|
|
||||||
variable: name,
|
|
||||||
value: path,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(Some(path))
|
|
||||||
}
|
|
||||||
fn override_first(names: &[&'static str]) -> Result<Option<PathBuf>, PathError> {
|
|
||||||
for name in names {
|
|
||||||
if let Some(value) = absolute_env(name)? {
|
|
||||||
return Ok(Some(value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
fn resolve_ipc(
|
|
||||||
scope: Scope,
|
|
||||||
default: Option<IpcEndpoint>,
|
|
||||||
data_root: Option<&Path>,
|
|
||||||
) -> Result<IpcEndpoint, PathError> {
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
if let Some(path) = absolute_env("IOTA_SOCKET")? {
|
|
||||||
return Ok(IpcEndpoint::UnixSocket(path));
|
|
||||||
}
|
|
||||||
if let Some(root) = data_root {
|
|
||||||
return Ok(IpcEndpoint::UnixSocket(root.join("runtime/iota.sock")));
|
|
||||||
}
|
|
||||||
if scope == Scope::User {
|
|
||||||
return Err(PathError::MissingRequiredOverride("IOTA_SOCKET"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
if let Some(name) = env::var_os("IOTA_PIPE") {
|
|
||||||
let name = name.to_string_lossy().into_owned();
|
|
||||||
if !name.starts_with(r"\\.\pipe\") {
|
|
||||||
return Err(PathError::InvalidPipeName(name));
|
|
||||||
}
|
|
||||||
return Ok(IpcEndpoint::WindowsPipe(name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
default.ok_or(PathError::UnsupportedScope)
|
|
||||||
}
|
|
||||||
fn create_directory(path: &Path, private: bool) -> std::io::Result<()> {
|
|
||||||
std::fs::create_dir_all(path)?;
|
|
||||||
#[cfg(unix)]
|
|
||||||
if private {
|
|
||||||
use std::os::unix::fs::PermissionsExt;
|
|
||||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compatibility helpers. New code should resolve IotaPaths once and pass it
|
|
||||||
// to its dependencies instead of calling these independently.
|
|
||||||
pub fn data_dir() -> PathBuf {
|
|
||||||
IotaPaths::resolve(Scope::User)
|
|
||||||
.expect("resolve Iota user paths")
|
|
||||||
.state_dir
|
|
||||||
}
|
|
||||||
pub fn config_dir() -> PathBuf {
|
|
||||||
IotaPaths::resolve(Scope::User)
|
|
||||||
.expect("resolve Iota user paths")
|
|
||||||
.config_dir
|
|
||||||
}
|
|
||||||
pub fn socket_override() -> Option<PathBuf> {
|
|
||||||
absolute_env("IOTA_SOCKET").ok().flatten()
|
|
||||||
}
|
|
||||||
pub fn socket_path(scope: SocketScope) -> PathBuf {
|
|
||||||
match IotaPaths::resolve(scope)
|
|
||||||
.expect("resolve Iota paths")
|
|
||||||
.ipc_endpoint
|
|
||||||
{
|
|
||||||
IpcEndpoint::UnixSocket(path) => path,
|
|
||||||
IpcEndpoint::WindowsPipe(_) => panic!("Windows IPC endpoint is not a filesystem path"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn socket_lock_path(scope: SocketScope) -> PathBuf {
|
|
||||||
IotaPaths::resolve(scope)
|
|
||||||
.expect("resolve Iota paths")
|
|
||||||
.daemon_lock_file()
|
|
||||||
.expect("runtime directory")
|
|
||||||
}
|
|
||||||
/// The compatibility installation helpers describe the machine installation,
|
|
||||||
/// not a user's data directory. Per-user launchers should keep an
|
|
||||||
/// `IotaPaths` instance and use its `install_root` directly.
|
|
||||||
pub fn install_root() -> PathBuf {
|
|
||||||
IotaPaths::resolve(Scope::System)
|
|
||||||
.expect("resolve Iota system paths")
|
|
||||||
.install_root
|
|
||||||
}
|
|
||||||
pub fn versions_dir() -> PathBuf {
|
|
||||||
install_root().join("versions")
|
|
||||||
}
|
|
||||||
pub fn current_version_link() -> PathBuf {
|
|
||||||
install_root().join("current")
|
|
||||||
}
|
|
||||||
pub fn updater_lock_path() -> PathBuf {
|
|
||||||
IotaPaths::resolve(Scope::User)
|
|
||||||
.expect("resolve Iota user paths")
|
|
||||||
.update_lock_file()
|
|
||||||
.expect("runtime directory")
|
|
||||||
}
|
|
||||||
pub fn updater_status_path() -> PathBuf {
|
|
||||||
IotaPaths::resolve(Scope::User)
|
|
||||||
.expect("resolve Iota user paths")
|
|
||||||
.update_status_file()
|
|
||||||
}
|
|
||||||
pub fn updater_staging_dir() -> PathBuf {
|
|
||||||
IotaPaths::resolve(Scope::User)
|
|
||||||
.expect("resolve Iota user paths")
|
|
||||||
.update_staging_dir()
|
|
||||||
}
|
|
||||||
pub fn web_asset_dir() -> PathBuf {
|
|
||||||
IotaPaths::resolve(Scope::User)
|
|
||||||
.expect("resolve Iota user paths")
|
|
||||||
.asset_dir
|
|
||||||
}
|
|
||||||
pub fn daemon_executable() -> PathBuf {
|
|
||||||
absolute_env("IOTA_DAEMON_PATH")
|
|
||||||
.expect("valid IOTA_DAEMON_PATH")
|
|
||||||
.unwrap_or_else(|| install_root().join("current/bin/iota-daemon"))
|
|
||||||
}
|
|
||||||
pub fn updater_executable() -> PathBuf {
|
|
||||||
absolute_env("IOTA_UPDATER_PATH")
|
|
||||||
.expect("valid IOTA_UPDATER_PATH")
|
|
||||||
.unwrap_or_else(|| install_root().join("current/bin/iota-updater"))
|
|
||||||
}
|
|
||||||
pub fn daemon_endpoints() -> Vec<PathBuf> {
|
|
||||||
vec![socket_path(Scope::User), socket_path(Scope::System)]
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::sync::{
|
|
||||||
Mutex,
|
|
||||||
atomic::{AtomicU64, Ordering},
|
|
||||||
};
|
|
||||||
|
|
||||||
static TEST_ID: AtomicU64 = AtomicU64::new(0);
|
|
||||||
static ENVIRONMENT: Mutex<()> = Mutex::new(());
|
|
||||||
#[test]
|
|
||||||
fn system_layout_is_fhs() {
|
|
||||||
let p = IotaPaths::resolve(Scope::System).unwrap();
|
|
||||||
assert_eq!(p.config_file, PathBuf::from("/etc/iota/config.yaml"));
|
|
||||||
assert_eq!(
|
|
||||||
p.database_file(),
|
|
||||||
PathBuf::from("/var/lib/iota/storage/messages.sqlite3")
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
p.update_staging_dir(),
|
|
||||||
PathBuf::from("/var/cache/iota/updates/staging")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn migration_moves_state_resources_without_overwriting_destination() {
|
|
||||||
let root = std::env::temp_dir().join(format!(
|
|
||||||
"iota-paths-test-{}-{}",
|
|
||||||
std::process::id(),
|
|
||||||
TEST_ID.fetch_add(1, Ordering::Relaxed)
|
|
||||||
));
|
|
||||||
let state = root.join("state");
|
|
||||||
let config = root.join("config");
|
|
||||||
let paths = IotaPaths {
|
|
||||||
scope: Scope::User,
|
|
||||||
config_dir: config.clone(),
|
|
||||||
config_file: config.join("config.yaml"),
|
|
||||||
storage_dir: state.join("storage"),
|
|
||||||
identity_dir: state.join("identity"),
|
|
||||||
cache_dir: root.join("cache"),
|
|
||||||
runtime_dir: Some(root.join("runtime")),
|
|
||||||
log_dir: state.join("logs"),
|
|
||||||
asset_dir: root.join("data/web"),
|
|
||||||
install_root: root.join("bin"),
|
|
||||||
ipc_endpoint: IpcEndpoint::UnixSocket(root.join("runtime/iota.sock")),
|
|
||||||
state_dir: state.clone(),
|
|
||||||
};
|
|
||||||
std::fs::create_dir_all(state.join("users")).unwrap();
|
|
||||||
std::fs::write(state.join("messages.sqlite3"), b"db").unwrap();
|
|
||||||
std::fs::write(state.join("config.yaml"), b"web: {}\n").unwrap();
|
|
||||||
paths.migrate_legacy_layout().unwrap();
|
|
||||||
assert!(paths.database_file().is_file());
|
|
||||||
assert!(paths.storage_dir.join("users").is_dir());
|
|
||||||
assert!(paths.config_file.is_file());
|
|
||||||
assert!(state.join("path-layout-v2.json").is_file());
|
|
||||||
let _ = std::fs::remove_dir_all(root);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn data_root_keeps_unmanaged_user_paths_together() {
|
|
||||||
let _guard = ENVIRONMENT.lock().unwrap();
|
|
||||||
let root = std::env::temp_dir().join(format!(
|
|
||||||
"iota-data-root-test-{}-{}",
|
|
||||||
std::process::id(),
|
|
||||||
TEST_ID.fetch_add(1, Ordering::Relaxed)
|
|
||||||
));
|
|
||||||
unsafe {
|
|
||||||
std::env::set_var("IOTA_DATA_ROOT", &root);
|
|
||||||
}
|
|
||||||
let paths = IotaPaths::resolve(Scope::User).unwrap();
|
|
||||||
unsafe {
|
|
||||||
std::env::remove_var("IOTA_DATA_ROOT");
|
|
||||||
}
|
|
||||||
|
|
||||||
assert_eq!(paths.config_file, root.join("config/config.yaml"));
|
|
||||||
assert_eq!(paths.state_dir, root.join("state"));
|
|
||||||
assert_eq!(paths.cache_dir, root.join("cache"));
|
|
||||||
assert_eq!(paths.log_dir, root.join("logs"));
|
|
||||||
assert_eq!(paths.runtime_dir, Some(root.join("runtime")));
|
|
||||||
assert_eq!(
|
|
||||||
paths.ipc_endpoint,
|
|
||||||
IpcEndpoint::UnixSocket(root.join("runtime/iota.sock"))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "iota-process-manager"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
async-trait = "0.1"
|
|
||||||
tokio = { version = "1.50", features = ["process", "time", "io-util", "macros", "rt"] }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
libc = "0.2"
|
|
||||||
tempfile = "3"
|
|
||||||
|
|
@ -1,628 +0,0 @@
|
||||||
use async_trait::async_trait;
|
|
||||||
use std::{
|
|
||||||
fmt::{Display, Formatter},
|
|
||||||
sync::Arc,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub const PROCESS_MANAGER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
|
||||||
pub struct UnitStatus {
|
|
||||||
pub active: bool,
|
|
||||||
pub enabled: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub enum StartupMode {
|
|
||||||
AlwaysOn,
|
|
||||||
SocketActivated,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub enum ProcessAction {
|
|
||||||
Start,
|
|
||||||
Stop,
|
|
||||||
Restart,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub enum DetectedStartupMode {
|
|
||||||
AlwaysOn,
|
|
||||||
SocketActivated,
|
|
||||||
Disabled,
|
|
||||||
Conflicting,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct DaemonStartupStatus {
|
|
||||||
pub service: UnitStatus,
|
|
||||||
pub socket: UnitStatus,
|
|
||||||
pub detected: DetectedStartupMode,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DaemonStartupStatus {
|
|
||||||
pub fn classify(service: UnitStatus, socket: UnitStatus) -> Self {
|
|
||||||
let detected = match (service.enabled, socket.enabled) {
|
|
||||||
(true, false) => DetectedStartupMode::AlwaysOn,
|
|
||||||
(false, true) => DetectedStartupMode::SocketActivated,
|
|
||||||
(false, false) => DetectedStartupMode::Disabled,
|
|
||||||
(true, true) => DetectedStartupMode::Conflicting,
|
|
||||||
};
|
|
||||||
Self {
|
|
||||||
service,
|
|
||||||
socket,
|
|
||||||
detected,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
||||||
pub enum ProcessManagerErrorKind {
|
|
||||||
CommandUnavailable,
|
|
||||||
PermissionDenied,
|
|
||||||
UnitMissing,
|
|
||||||
CommandFailed,
|
|
||||||
ParseFailed,
|
|
||||||
VerificationFailed,
|
|
||||||
TimedOut,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct ProcessManagerError {
|
|
||||||
pub kind: ProcessManagerErrorKind,
|
|
||||||
message: String,
|
|
||||||
}
|
|
||||||
impl ProcessManagerError {
|
|
||||||
pub fn new(kind: ProcessManagerErrorKind, message: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
kind,
|
|
||||||
message: message.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pub fn kind(&self) -> ProcessManagerErrorKind {
|
|
||||||
self.kind
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl Display for ProcessManagerError {
|
|
||||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
|
||||||
f.write_str(&self.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
impl std::error::Error for ProcessManagerError {}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct CommandOutput {
|
|
||||||
pub success: bool,
|
|
||||||
pub stdout: String,
|
|
||||||
pub stderr: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait CommandExecutor: Send + Sync {
|
|
||||||
async fn output(
|
|
||||||
&self,
|
|
||||||
program: &str,
|
|
||||||
args: &[&str],
|
|
||||||
) -> Result<CommandOutput, ProcessManagerError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait ProcessManager: Send + Sync {
|
|
||||||
fn name(&self) -> &'static str;
|
|
||||||
async fn unit_status(&self, unit: &str) -> Result<UnitStatus, ProcessManagerError>;
|
|
||||||
async fn set_iota_startup_mode(
|
|
||||||
&self,
|
|
||||||
mode: StartupMode,
|
|
||||||
) -> Result<DaemonStartupStatus, ProcessManagerError>;
|
|
||||||
async fn iota_startup_status(&self) -> Result<DaemonStartupStatus, ProcessManagerError> {
|
|
||||||
Ok(DaemonStartupStatus::classify(
|
|
||||||
self.unit_status("iota-daemon.service").await?,
|
|
||||||
self.unit_status("iota-daemon.socket").await?,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
async fn enable_startup(
|
|
||||||
&self,
|
|
||||||
mode: StartupMode,
|
|
||||||
) -> Result<DaemonStartupStatus, ProcessManagerError> {
|
|
||||||
self.set_iota_startup_mode(mode).await
|
|
||||||
}
|
|
||||||
async fn disable_startup(&self) -> Result<DaemonStartupStatus, ProcessManagerError> {
|
|
||||||
self.set_iota_startup_mode(StartupMode::SocketActivated)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
async fn process_action(
|
|
||||||
&self,
|
|
||||||
action: ProcessAction,
|
|
||||||
) -> Result<DaemonStartupStatus, ProcessManagerError> {
|
|
||||||
let unit = "iota-daemon.service";
|
|
||||||
match action {
|
|
||||||
ProcessAction::Start => self.unit_action(&["start", unit]).await?,
|
|
||||||
ProcessAction::Stop => self.unit_action(&["stop", unit]).await?,
|
|
||||||
ProcessAction::Restart => self.unit_action(&["restart", unit]).await?,
|
|
||||||
}
|
|
||||||
self.iota_startup_status().await
|
|
||||||
}
|
|
||||||
async fn unit_action(&self, _action: &[&str]) -> Result<(), ProcessManagerError> {
|
|
||||||
Err(ProcessManagerError::new(
|
|
||||||
ProcessManagerErrorKind::CommandFailed,
|
|
||||||
"process actions are unsupported",
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn detect() -> Option<Arc<dyn ProcessManager>> {
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
{
|
|
||||||
systemd::SystemdManager::detect()
|
|
||||||
.await
|
|
||||||
.map(|m| Arc::new(m) as Arc<dyn ProcessManager>)
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "linux"))]
|
|
||||||
{
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
|
||||||
mod systemd {
|
|
||||||
use super::*;
|
|
||||||
use std::{path::Path, process::Stdio};
|
|
||||||
use tokio::{io::AsyncRead, process::Command, time::timeout};
|
|
||||||
|
|
||||||
const SERVICE: &str = "iota-daemon.service";
|
|
||||||
const SOCKET: &str = "iota-daemon.socket";
|
|
||||||
const COMMON: [&str; 2] = ["--no-pager", "--no-ask-password"];
|
|
||||||
const MAX_COMMAND_OUTPUT_BYTES: usize = 1024 * 1024;
|
|
||||||
|
|
||||||
pub struct RealExecutor;
|
|
||||||
|
|
||||||
async fn read_bounded<R>(reader: R) -> std::io::Result<Vec<u8>>
|
|
||||||
where
|
|
||||||
R: AsyncRead + Unpin,
|
|
||||||
{
|
|
||||||
use tokio::io::AsyncReadExt;
|
|
||||||
|
|
||||||
let mut output = Vec::new();
|
|
||||||
reader
|
|
||||||
.take((MAX_COMMAND_OUTPUT_BYTES + 1) as u64)
|
|
||||||
.read_to_end(&mut output)
|
|
||||||
.await?;
|
|
||||||
if output.len() > MAX_COMMAND_OUTPUT_BYTES {
|
|
||||||
output.truncate(MAX_COMMAND_OUTPUT_BYTES);
|
|
||||||
}
|
|
||||||
Ok(output)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn collect_output(
|
|
||||||
stdout: tokio::process::ChildStdout,
|
|
||||||
stderr: tokio::process::ChildStderr,
|
|
||||||
) -> Result<(Vec<u8>, Vec<u8>), ProcessManagerError> {
|
|
||||||
let (stdout_result, stderr_result) =
|
|
||||||
tokio::join!(read_bounded(stdout), read_bounded(stderr));
|
|
||||||
let stdout = stdout_result.map_err(|error| {
|
|
||||||
ProcessManagerError::new(
|
|
||||||
ProcessManagerErrorKind::CommandFailed,
|
|
||||||
format!("stdout read failed: {error}"),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let stderr = stderr_result.map_err(|error| {
|
|
||||||
ProcessManagerError::new(
|
|
||||||
ProcessManagerErrorKind::CommandFailed,
|
|
||||||
format!("stderr read failed: {error}"),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
Ok((stdout, stderr))
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RealExecutor {
|
|
||||||
async fn output_with_timeout(
|
|
||||||
&self,
|
|
||||||
program: &str,
|
|
||||||
args: &[&str],
|
|
||||||
process_timeout: std::time::Duration,
|
|
||||||
) -> Result<CommandOutput, ProcessManagerError> {
|
|
||||||
let mut child = Command::new(program)
|
|
||||||
.args(args)
|
|
||||||
.stdin(Stdio::null())
|
|
||||||
.stdout(Stdio::piped())
|
|
||||||
.stderr(Stdio::piped())
|
|
||||||
.kill_on_drop(true)
|
|
||||||
.spawn()
|
|
||||||
.map_err(|e| {
|
|
||||||
ProcessManagerError::new(
|
|
||||||
ProcessManagerErrorKind::CommandUnavailable,
|
|
||||||
format!("Could not run {program}: {e}"),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let stdout = child.stdout.take().ok_or_else(|| {
|
|
||||||
ProcessManagerError::new(
|
|
||||||
ProcessManagerErrorKind::CommandFailed,
|
|
||||||
"command stdout pipe was not created",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let stderr = child.stderr.take().ok_or_else(|| {
|
|
||||||
ProcessManagerError::new(
|
|
||||||
ProcessManagerErrorKind::CommandFailed,
|
|
||||||
"command stderr pipe was not created",
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let output_task = tokio::spawn(collect_output(stdout, stderr));
|
|
||||||
|
|
||||||
let status = match timeout(process_timeout, child.wait()).await {
|
|
||||||
Ok(result) => result.map_err(|e| {
|
|
||||||
ProcessManagerError::new(ProcessManagerErrorKind::CommandFailed, e.to_string())
|
|
||||||
})?,
|
|
||||||
Err(_) => {
|
|
||||||
// Keep the Child alive across the timeout. Explicitly
|
|
||||||
// terminate it and await wait() so the OS child is
|
|
||||||
// reaped before reporting the timeout.
|
|
||||||
let kill_error = child.start_kill().err();
|
|
||||||
let wait_error = child.wait().await.err();
|
|
||||||
output_task.abort();
|
|
||||||
let _ = output_task.await;
|
|
||||||
|
|
||||||
if let Some(error) = wait_error {
|
|
||||||
return Err(ProcessManagerError::new(
|
|
||||||
ProcessManagerErrorKind::CommandFailed,
|
|
||||||
format!("{program} timed out and could not be reaped: {error}"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let termination_detail = kill_error
|
|
||||||
.map(|error| format!("; termination request reported: {error}"))
|
|
||||||
.unwrap_or_default();
|
|
||||||
return Err(ProcessManagerError::new(
|
|
||||||
ProcessManagerErrorKind::TimedOut,
|
|
||||||
format!(
|
|
||||||
"{program} timed out after {} seconds{termination_detail}",
|
|
||||||
process_timeout.as_secs(),
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let (stdout, stderr) = output_task.await.map_err(|error| {
|
|
||||||
ProcessManagerError::new(
|
|
||||||
ProcessManagerErrorKind::CommandFailed,
|
|
||||||
format!("command output task failed: {error}"),
|
|
||||||
)
|
|
||||||
})??;
|
|
||||||
Ok(CommandOutput {
|
|
||||||
success: status.success(),
|
|
||||||
stdout: String::from_utf8_lossy(&stdout).into_owned(),
|
|
||||||
stderr: String::from_utf8_lossy(&stderr).into_owned(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl CommandExecutor for RealExecutor {
|
|
||||||
async fn output(
|
|
||||||
&self,
|
|
||||||
program: &str,
|
|
||||||
args: &[&str],
|
|
||||||
) -> Result<CommandOutput, ProcessManagerError> {
|
|
||||||
self.output_with_timeout(program, args, PROCESS_MANAGER_TIMEOUT)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct SystemdManager {
|
|
||||||
executor: Arc<dyn CommandExecutor>,
|
|
||||||
service: &'static str,
|
|
||||||
socket: &'static str,
|
|
||||||
}
|
|
||||||
impl SystemdManager {
|
|
||||||
pub async fn detect() -> Option<Self> {
|
|
||||||
if !Path::new("/run/systemd/system").is_dir() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let executor: Arc<dyn CommandExecutor> = Arc::new(RealExecutor);
|
|
||||||
let mut manager = executor
|
|
||||||
.output("systemctl", &["--version", &COMMON[0], &COMMON[1]])
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.filter(|r| r.success)
|
|
||||||
.map(|_| Self {
|
|
||||||
executor,
|
|
||||||
service: SERVICE,
|
|
||||||
socket: SOCKET,
|
|
||||||
})?;
|
|
||||||
if manager.status("iota.service").await.is_ok() {
|
|
||||||
manager.service = "iota.service";
|
|
||||||
manager.socket = "iota.socket";
|
|
||||||
}
|
|
||||||
Some(manager)
|
|
||||||
}
|
|
||||||
#[cfg(test)]
|
|
||||||
pub fn with_executor(executor: Arc<dyn CommandExecutor>) -> Self {
|
|
||||||
Self {
|
|
||||||
executor,
|
|
||||||
service: SERVICE,
|
|
||||||
socket: SOCKET,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async fn run(&self, action: &[&str]) -> Result<(), ProcessManagerError> {
|
|
||||||
let mut args = COMMON.to_vec();
|
|
||||||
args.extend_from_slice(action);
|
|
||||||
let output = self.executor.output("systemctl", &args).await?;
|
|
||||||
if output.success {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let detail = if output.stderr.trim().is_empty() {
|
|
||||||
output.stdout.trim()
|
|
||||||
} else {
|
|
||||||
output.stderr.trim()
|
|
||||||
};
|
|
||||||
let kind = if detail.to_ascii_lowercase().contains("access denied")
|
|
||||||
|| detail.to_ascii_lowercase().contains("permission denied")
|
|
||||||
{
|
|
||||||
ProcessManagerErrorKind::PermissionDenied
|
|
||||||
} else {
|
|
||||||
ProcessManagerErrorKind::CommandFailed
|
|
||||||
};
|
|
||||||
Err(ProcessManagerError::new(
|
|
||||||
kind,
|
|
||||||
if detail.is_empty() {
|
|
||||||
format!("systemctl {} failed", action.join(" "))
|
|
||||||
} else {
|
|
||||||
detail.to_owned()
|
|
||||||
},
|
|
||||||
))
|
|
||||||
}
|
|
||||||
async fn status(&self, unit: &str) -> Result<UnitStatus, ProcessManagerError> {
|
|
||||||
let mut args = COMMON.to_vec();
|
|
||||||
args.extend_from_slice(&[
|
|
||||||
"show",
|
|
||||||
"--property=LoadState",
|
|
||||||
"--property=ActiveState",
|
|
||||||
"--property=UnitFileState",
|
|
||||||
"--value",
|
|
||||||
unit,
|
|
||||||
]);
|
|
||||||
let output = self.executor.output("systemctl", &args).await?;
|
|
||||||
if !output.success {
|
|
||||||
let detail = if output.stderr.trim().is_empty() {
|
|
||||||
output.stdout.trim()
|
|
||||||
} else {
|
|
||||||
output.stderr.trim()
|
|
||||||
};
|
|
||||||
let kind = if detail.to_ascii_lowercase().contains("denied") {
|
|
||||||
ProcessManagerErrorKind::PermissionDenied
|
|
||||||
} else {
|
|
||||||
ProcessManagerErrorKind::CommandFailed
|
|
||||||
};
|
|
||||||
return Err(ProcessManagerError::new(
|
|
||||||
kind,
|
|
||||||
format!("systemctl could not inspect {unit}: {detail}"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let values: Vec<_> = output.stdout.lines().map(str::trim).collect();
|
|
||||||
if values.len() < 3 {
|
|
||||||
return Err(ProcessManagerError::new(
|
|
||||||
ProcessManagerErrorKind::ParseFailed,
|
|
||||||
format!("systemctl returned incomplete state for {unit}"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if values[0] == "not-found" {
|
|
||||||
return Err(ProcessManagerError::new(
|
|
||||||
ProcessManagerErrorKind::UnitMissing,
|
|
||||||
format!("systemd unit {unit} was not found"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if values[0] != "loaded" {
|
|
||||||
return Err(ProcessManagerError::new(
|
|
||||||
ProcessManagerErrorKind::ParseFailed,
|
|
||||||
format!("unsupported LoadState `{}` for {unit}", values[0]),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let active = match values[1] {
|
|
||||||
"active" => true,
|
|
||||||
"inactive" | "failed" | "activating" | "deactivating" | "reloading" => false,
|
|
||||||
v => {
|
|
||||||
return Err(ProcessManagerError::new(
|
|
||||||
ProcessManagerErrorKind::ParseFailed,
|
|
||||||
format!("unsupported ActiveState `{v}` for {unit}"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let enabled = match values[2] {
|
|
||||||
"enabled" | "enabled-runtime" => true,
|
|
||||||
"disabled" | "static" | "indirect" | "masked" | "generated" | "transient" => false,
|
|
||||||
v => {
|
|
||||||
return Err(ProcessManagerError::new(
|
|
||||||
ProcessManagerErrorKind::ParseFailed,
|
|
||||||
format!("unsupported UnitFileState `{v}` for {unit}"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok(UnitStatus { active, enabled })
|
|
||||||
}
|
|
||||||
async fn verify(
|
|
||||||
&self,
|
|
||||||
expected: DetectedStartupMode,
|
|
||||||
) -> Result<DaemonStartupStatus, ProcessManagerError> {
|
|
||||||
let status = self.iota_startup_status().await?;
|
|
||||||
if status.detected == expected {
|
|
||||||
Ok(status)
|
|
||||||
} else {
|
|
||||||
Err(ProcessManagerError::new(
|
|
||||||
ProcessManagerErrorKind::VerificationFailed,
|
|
||||||
format!(
|
|
||||||
"systemd reported {:?} after applying {:?}",
|
|
||||||
status.detected, expected
|
|
||||||
),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[async_trait]
|
|
||||||
impl ProcessManager for SystemdManager {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"systemd"
|
|
||||||
}
|
|
||||||
async fn unit_status(&self, unit: &str) -> Result<UnitStatus, ProcessManagerError> {
|
|
||||||
self.status(unit).await
|
|
||||||
}
|
|
||||||
async fn iota_startup_status(&self) -> Result<DaemonStartupStatus, ProcessManagerError> {
|
|
||||||
Ok(DaemonStartupStatus::classify(
|
|
||||||
self.status(self.service).await?,
|
|
||||||
self.status(self.socket).await?,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
async fn set_iota_startup_mode(
|
|
||||||
&self,
|
|
||||||
mode: StartupMode,
|
|
||||||
) -> Result<DaemonStartupStatus, ProcessManagerError> {
|
|
||||||
match mode {
|
|
||||||
StartupMode::AlwaysOn => {
|
|
||||||
self.run(&["disable", self.socket]).await?;
|
|
||||||
self.run(&["enable", "--now", self.service]).await?;
|
|
||||||
self.verify(DetectedStartupMode::AlwaysOn).await
|
|
||||||
}
|
|
||||||
StartupMode::SocketActivated => {
|
|
||||||
self.run(&["disable", "--now", self.service]).await?;
|
|
||||||
self.run(&["enable", "--now", self.socket]).await?;
|
|
||||||
self.verify(DetectedStartupMode::SocketActivated).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async fn unit_action(&self, action: &[&str]) -> Result<(), ProcessManagerError> {
|
|
||||||
self.run(action).await
|
|
||||||
}
|
|
||||||
async fn process_action(
|
|
||||||
&self,
|
|
||||||
action: ProcessAction,
|
|
||||||
) -> Result<DaemonStartupStatus, ProcessManagerError> {
|
|
||||||
let verb = match action {
|
|
||||||
ProcessAction::Start => "start",
|
|
||||||
ProcessAction::Stop => "stop",
|
|
||||||
ProcessAction::Restart => "restart",
|
|
||||||
};
|
|
||||||
self.run(&[verb, self.service]).await?;
|
|
||||||
self.iota_startup_status().await
|
|
||||||
}
|
|
||||||
async fn disable_startup(&self) -> Result<DaemonStartupStatus, ProcessManagerError> {
|
|
||||||
self.run(&["disable", "--now", self.service]).await?;
|
|
||||||
self.run(&["disable", "--now", self.socket]).await?;
|
|
||||||
self.verify(DetectedStartupMode::Disabled).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::sync::Mutex;
|
|
||||||
|
|
||||||
struct Fake {
|
|
||||||
calls: Mutex<Vec<Vec<String>>>,
|
|
||||||
results: Mutex<Vec<CommandOutput>>,
|
|
||||||
}
|
|
||||||
#[async_trait]
|
|
||||||
impl CommandExecutor for Fake {
|
|
||||||
async fn output(
|
|
||||||
&self,
|
|
||||||
_: &str,
|
|
||||||
args: &[&str],
|
|
||||||
) -> Result<CommandOutput, ProcessManagerError> {
|
|
||||||
self.calls
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.push(args.iter().map(|arg| (*arg).to_owned()).collect());
|
|
||||||
Ok(self.results.lock().unwrap().remove(0))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn ok(stdout: &str) -> CommandOutput {
|
|
||||||
CommandOutput {
|
|
||||||
success: true,
|
|
||||||
stdout: stdout.into(),
|
|
||||||
stderr: String::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn every_systemctl_operation_disables_interactive_features() {
|
|
||||||
let fake = Arc::new(Fake {
|
|
||||||
calls: Mutex::new(Vec::new()),
|
|
||||||
results: Mutex::new(vec![ok("loaded\nactive\nenabled\n")]),
|
|
||||||
});
|
|
||||||
let manager = SystemdManager::with_executor(fake.clone());
|
|
||||||
manager.unit_status(SERVICE).await.unwrap();
|
|
||||||
let call = &fake.calls.lock().unwrap()[0];
|
|
||||||
assert!(call.contains(&"--no-pager".into()));
|
|
||||||
assert!(call.contains(&"--no-ask-password".into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn timed_out_real_child_is_terminated_and_reaped() {
|
|
||||||
use std::fs;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
let directory = tempfile::tempdir().unwrap();
|
|
||||||
let pid_file = directory.path().join("child.pid");
|
|
||||||
let script = format!(
|
|
||||||
"printf '%s' \"$$\" > '{}'; exec sleep 60",
|
|
||||||
pid_file.display()
|
|
||||||
);
|
|
||||||
let executor = RealExecutor;
|
|
||||||
let task = tokio::spawn(async move {
|
|
||||||
executor
|
|
||||||
.output_with_timeout("sh", &["-c", &script], Duration::from_millis(50))
|
|
||||||
.await
|
|
||||||
});
|
|
||||||
|
|
||||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
|
|
||||||
let pid = loop {
|
|
||||||
if let Ok(contents) = fs::read_to_string(&pid_file) {
|
|
||||||
if let Ok(pid) = contents.parse::<libc::pid_t>() {
|
|
||||||
break pid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(tokio::time::Instant::now() < deadline);
|
|
||||||
tokio::task::yield_now().await;
|
|
||||||
};
|
|
||||||
|
|
||||||
let result = task.await.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
result.unwrap_err().kind(),
|
|
||||||
ProcessManagerErrorKind::TimedOut
|
|
||||||
);
|
|
||||||
assert!(!std::path::Path::new(&format!("/proc/{pid}")).exists());
|
|
||||||
|
|
||||||
let mut status = 0;
|
|
||||||
let wait_result = unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) };
|
|
||||||
assert_eq!(wait_result, -1);
|
|
||||||
assert_eq!(
|
|
||||||
std::io::Error::last_os_error().raw_os_error(),
|
|
||||||
Some(libc::ECHILD)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
#[test]
|
|
||||||
fn modes_distinct() {
|
|
||||||
assert_ne!(StartupMode::AlwaysOn, StartupMode::SocketActivated);
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BlockingExecutor;
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl CommandExecutor for BlockingExecutor {
|
|
||||||
async fn output(&self, _: &str, _: &[&str]) -> Result<CommandOutput, ProcessManagerError> {
|
|
||||||
std::future::pending().await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn executor_future_can_be_cancelled_without_blocking_runtime() {
|
|
||||||
let result = tokio::time::timeout(
|
|
||||||
std::time::Duration::from_millis(20),
|
|
||||||
BlockingExecutor.output("systemctl", &["show"]),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert!(result.is_err());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
[package]
|
|
||||||
name = "iota-state"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2024"
|
|
||||||
|
|
||||||
[features]
|
|
||||||
default = ["legacy-globals"]
|
|
||||||
legacy-globals = []
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
dashmap = "6.1.0"
|
|
||||||
once_cell = "1.21.3"
|
|
||||||
tokio = { version = "1.50.0", features = ["full"] }
|
|
||||||
json = "*"
|
|
||||||
sysinfo = "0.38.0"
|
|
||||||
|
|
@ -1,308 +0,0 @@
|
||||||
use dashmap::DashSet;
|
|
||||||
use json::{JsonValue, object};
|
|
||||||
#[cfg(feature = "legacy-globals")]
|
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
use std::collections::VecDeque;
|
|
||||||
#[cfg(feature = "legacy-globals")]
|
|
||||||
use std::sync::LazyLock;
|
|
||||||
use std::sync::{Arc, Mutex, atomic::AtomicBool};
|
|
||||||
#[cfg(feature = "legacy-globals")]
|
|
||||||
use std::thread;
|
|
||||||
#[cfg(feature = "legacy-globals")]
|
|
||||||
use std::time::Duration;
|
|
||||||
#[cfg(feature = "legacy-globals")]
|
|
||||||
use sysinfo::{RefreshKind, System};
|
|
||||||
use tokio::sync::{Mutex as TokioMutex, RwLock};
|
|
||||||
|
|
||||||
/* Process-owned daemon state and TUI-local state must be separate because IPC,
|
|
||||||
* rather than shared memory, is the boundary between the two binaries. */
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct DaemonState {
|
|
||||||
pub app: Arc<Mutex<AppState>>,
|
|
||||||
pub shutdown: Arc<RwLock<bool>>,
|
|
||||||
pub reload: Arc<RwLock<bool>>,
|
|
||||||
pub active_tasks: Arc<DashSet<String>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DaemonState {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
app: Arc::new(Mutex::new(AppState::new())),
|
|
||||||
shutdown: Arc::new(RwLock::new(false)),
|
|
||||||
reload: Arc::new(RwLock::new(false)),
|
|
||||||
active_tasks: Arc::new(DashSet::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for DaemonState {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The TUI keeps only the daemon data it renders. This state is never shared
|
|
||||||
* with the daemon and is populated from daemon IPC messages. */
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct ClientState {
|
|
||||||
pub app: Arc<TokioMutex<AppState>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ClientState {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
app: Arc::new(TokioMutex::new(AppState::new())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for ClientState {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub const MAX_POINTS: usize = 1000;
|
|
||||||
pub const MAX_LOGS: usize = 100;
|
|
||||||
|
|
||||||
pub static UNIQUE: AtomicBool = AtomicBool::new(true);
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct UiLogEntry {
|
|
||||||
pub timestamp_ms: u128,
|
|
||||||
pub sender: String,
|
|
||||||
pub message: String,
|
|
||||||
pub is_error: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct AppState {
|
|
||||||
pub logs: VecDeque<UiLogEntry>,
|
|
||||||
pub cpu: Vec<(f64, f64)>,
|
|
||||||
pub ram: Vec<(f64, f64)>,
|
|
||||||
pub ping: Vec<(f64, f64)>,
|
|
||||||
pub net_up: Vec<(f64, f64)>,
|
|
||||||
pub net_down: Vec<(f64, f64)>,
|
|
||||||
pub sys_info: String,
|
|
||||||
next_sample_id: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppState {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
logs: VecDeque::new(),
|
|
||||||
cpu: Vec::new(),
|
|
||||||
ram: Vec::new(),
|
|
||||||
ping: Vec::new(),
|
|
||||||
net_up: Vec::new(),
|
|
||||||
net_down: Vec::new(),
|
|
||||||
sys_info: String::from("Loading..."),
|
|
||||||
next_sample_id: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn push_log(&mut self, msg: UiLogEntry) {
|
|
||||||
if self.logs.len() >= MAX_LOGS {
|
|
||||||
self.logs.pop_front();
|
|
||||||
}
|
|
||||||
self.logs.push_back(msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_logs(&self) -> &VecDeque<UiLogEntry> {
|
|
||||||
&self.logs
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn push_cpu(&mut self, pt: (f64, f64)) {
|
|
||||||
let x = self.next_sample();
|
|
||||||
self.cpu.push((x, pt.1));
|
|
||||||
if self.cpu.len() > MAX_POINTS {
|
|
||||||
self.cpu.remove(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn push_ram(&mut self, pt: (f64, f64)) {
|
|
||||||
let x = self.next_sample();
|
|
||||||
self.ram.push((x, pt.1));
|
|
||||||
if self.ram.len() > MAX_POINTS {
|
|
||||||
self.ram.remove(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn push_ping_val(&mut self, pt: f64) {
|
|
||||||
let x = self.next_sample();
|
|
||||||
self.ping.push((x, pt));
|
|
||||||
if self.ping.len() > MAX_POINTS {
|
|
||||||
self.ping.remove(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn push_net_up(&mut self, pt: (f64, f64)) {
|
|
||||||
let x = self.next_sample();
|
|
||||||
self.net_up.push((x, pt.1));
|
|
||||||
if self.net_up.len() > MAX_POINTS {
|
|
||||||
self.net_up.remove(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn push_net_down(&mut self, pt: (f64, f64)) {
|
|
||||||
let x = self.next_sample();
|
|
||||||
self.net_down.push((x, pt.1));
|
|
||||||
if self.net_down.len() > MAX_POINTS {
|
|
||||||
self.net_down.remove(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn next_sample(&mut self) -> f64 {
|
|
||||||
let value = self.next_sample_id as f64;
|
|
||||||
self.next_sample_id = self.next_sample_id.saturating_add(1);
|
|
||||||
value
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn to_json(&self) -> JsonValue {
|
|
||||||
object! {
|
|
||||||
"cpu" => self.cpu.iter().map(|(_, y)| *y).collect::<Vec<f64>>(),
|
|
||||||
"ram" => self.ram.iter().map(|(_, y)| *y).collect::<Vec<f64>>(),
|
|
||||||
"ping" => self.ping.iter().map(|(_, y)| *y).collect::<Vec<f64>>(),
|
|
||||||
"net_up" => self.net_up.iter().map(|(_, y)| *y).collect::<Vec<f64>>(),
|
|
||||||
"net_down" => self.net_down.iter().map(|(_, y)| *y).collect::<Vec<f64>>(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_width(&self, width: u16) -> Self {
|
|
||||||
let mut new = self.clone();
|
|
||||||
new.cpu = Self::downsample_to_fit_width(&new.cpu, width);
|
|
||||||
new.ram = Self::downsample_to_fit_width(&new.ram, width);
|
|
||||||
new.ping = Self::downsample_to_fit_width(&new.ping, width);
|
|
||||||
new.net_up = Self::downsample_to_fit_width(&new.net_up, width);
|
|
||||||
new.net_down = Self::downsample_to_fit_width(&new.net_down, width);
|
|
||||||
new
|
|
||||||
}
|
|
||||||
|
|
||||||
fn downsample_to_fit_width(data: &[(f64, f64)], width: u16) -> Vec<(f64, f64)> {
|
|
||||||
let width_usize = (width as usize) * 2;
|
|
||||||
let len = data.len();
|
|
||||||
|
|
||||||
if len >= width_usize {
|
|
||||||
data[len - width_usize..].to_vec()
|
|
||||||
} else {
|
|
||||||
let mut result = Vec::with_capacity(width_usize);
|
|
||||||
|
|
||||||
let dx = 1.0;
|
|
||||||
let pad_len = width_usize - len;
|
|
||||||
|
|
||||||
let start_x = data
|
|
||||||
.first()
|
|
||||||
.map(|(x, _)| x - (dx * pad_len as f64))
|
|
||||||
.unwrap_or(0.0);
|
|
||||||
|
|
||||||
for i in 0..pad_len {
|
|
||||||
result.push((start_x + i as f64 * dx, -1.0));
|
|
||||||
}
|
|
||||||
|
|
||||||
result.extend_from_slice(data);
|
|
||||||
result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "legacy-globals")]
|
|
||||||
#[deprecated(note = "use DaemonState or ClientState")]
|
|
||||||
pub static APP_STATE: LazyLock<Arc<Mutex<AppState>>> =
|
|
||||||
LazyLock::new(|| Arc::new(Mutex::new(AppState::new())));
|
|
||||||
|
|
||||||
#[cfg(feature = "legacy-globals")]
|
|
||||||
#[deprecated(note = "use DaemonState")]
|
|
||||||
pub static SHUTDOWN: Lazy<RwLock<bool>> = Lazy::new(|| RwLock::new(false));
|
|
||||||
#[cfg(feature = "legacy-globals")]
|
|
||||||
#[deprecated(note = "use DaemonState")]
|
|
||||||
pub static RELOAD: Lazy<RwLock<bool>> = Lazy::new(|| RwLock::new(true));
|
|
||||||
#[cfg(feature = "legacy-globals")]
|
|
||||||
#[deprecated(note = "use DaemonState")]
|
|
||||||
pub static ACTIVE_TASKS: Lazy<DashSet<String>> = Lazy::new(|| DashSet::new());
|
|
||||||
|
|
||||||
#[cfg(feature = "legacy-globals")]
|
|
||||||
pub fn setup(state: &DaemonState) {
|
|
||||||
state.active_tasks.insert("System info loader".to_string());
|
|
||||||
let state = state.clone();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let mut sys = System::new_with_specifics(RefreshKind::everything());
|
|
||||||
let mut last_total_received = 0u64;
|
|
||||||
let mut last_total_transmitted = 0u64;
|
|
||||||
let mut counter = 0.0;
|
|
||||||
loop {
|
|
||||||
if *state.shutdown.read().await {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
sys.refresh_all();
|
|
||||||
|
|
||||||
let mut tcpu = 0;
|
|
||||||
for cpu in sys.cpus() {
|
|
||||||
tcpu += cpu.cpu_usage() as i64;
|
|
||||||
tcpu /= 2;
|
|
||||||
}
|
|
||||||
let ram = (sys.used_memory() as f64 / sys.total_memory() as f64) * 100.0;
|
|
||||||
|
|
||||||
let total_received = 0u64;
|
|
||||||
let total_transmitted = 0u64;
|
|
||||||
|
|
||||||
let delta_received = if last_total_received == 0 {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
total_received.saturating_sub(last_total_received)
|
|
||||||
};
|
|
||||||
let delta_transmitted = if last_total_transmitted == 0 {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
total_transmitted.saturating_sub(last_total_transmitted)
|
|
||||||
};
|
|
||||||
last_total_received = total_received;
|
|
||||||
last_total_transmitted = total_transmitted;
|
|
||||||
|
|
||||||
let net_down = delta_received as f64;
|
|
||||||
let net_up = delta_transmitted as f64;
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut st = state.app.lock().unwrap();
|
|
||||||
st.push_cpu((counter, tcpu as f64));
|
|
||||||
st.push_ram((counter, ram));
|
|
||||||
st.push_net_down((counter, net_down));
|
|
||||||
st.push_net_up((counter, net_up));
|
|
||||||
|
|
||||||
st.sys_info = format!("NetDown: {} NetUp: {}", delta_received, delta_transmitted);
|
|
||||||
}
|
|
||||||
|
|
||||||
counter += 1.0;
|
|
||||||
if counter > 30.0 {
|
|
||||||
thread::sleep(Duration::from_millis(500));
|
|
||||||
} else {
|
|
||||||
thread::sleep(Duration::from_millis(5));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
state.active_tasks.remove("System info loader");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn metric_coordinates_remain_monotonic_after_history_rollover() {
|
|
||||||
let mut state = AppState::new();
|
|
||||||
for value in 0..(MAX_POINTS + 25) {
|
|
||||||
state.push_ping_val(value as f64);
|
|
||||||
}
|
|
||||||
assert_eq!(state.ping.len(), MAX_POINTS);
|
|
||||||
assert!(state.ping.windows(2).all(|pair| pair[0].0 < pair[1].0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue