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
|
||||
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]
|
||||
members = [
|
||||
"iota-storage",
|
||||
"iota-connection",
|
||||
[package]
|
||||
name = "iota"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
ttp-core = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-core" }
|
||||
ttp-native = { git = "https://github.com/Tensamin/TTP.git", package = "ttp-native" }
|
||||
|
||||
actix-web = { version = "4", features = ["rustls-0_23"] }
|
||||
actix-web-actors = "4"
|
||||
aes-gcm = "0.10.3"
|
||||
base64 = "0.22.1"
|
||||
crossterm = "*"
|
||||
futures = "*"
|
||||
futures-util = "*"
|
||||
hex = "*"
|
||||
hkdf = "0.12.4"
|
||||
hyper-util = { version = "*" }
|
||||
hyper = { version = "1.8.1", features = [
|
||||
"capi",
|
||||
"client",
|
||||
"iota-auth",
|
||||
"other-iota",
|
||||
"iota-updater",
|
||||
"iota-terms",
|
||||
"iota-state",
|
||||
"iota-cli",
|
||||
"iota",
|
||||
"iota-daemon",
|
||||
"iota-daemon-lib",
|
||||
"iota-ipc",
|
||||
"omikron-connector",
|
||||
"web-server",
|
||||
"web-ui",
|
||||
"iota-logger",
|
||||
"iota-util",
|
||||
"iota-process-manager",
|
||||
"iota-paths",
|
||||
"iota-installer",
|
||||
"iota-core",
|
||||
]
|
||||
resolver = "3"
|
||||
"full",
|
||||
"http1",
|
||||
"http2",
|
||||
"nightly",
|
||||
"server",
|
||||
] }
|
||||
json = "*"
|
||||
once_cell = "1.21.3"
|
||||
rand = "0.8"
|
||||
rand_core = { version = "0.6", features = ["getrandom", "std"] }
|
||||
reqwest = "0.13.2"
|
||||
rustls = { version = "0.23.37", features = ["aws-lc-rs"] }
|
||||
sha2 = "0.10.9"
|
||||
sysinfo = "0.38.3"
|
||||
tokio = { version = "1.50.0", features = ["full"] }
|
||||
tokio-tungstenite = { version = "*", features = ["native-tls"] }
|
||||
tungstenite = "*"
|
||||
uuid = { version = "*", features = ["v4"] }
|
||||
walkdir = "2.5.0"
|
||||
warp = "*"
|
||||
x448 = { version = "*" }
|
||||
rustls-pemfile = "2.2.0"
|
||||
async-trait = "0.1.89"
|
||||
zip = "6.0.0"
|
||||
pnet = "0.35.0"
|
||||
dashmap = "6.1.0"
|
||||
strum = "0.27.2"
|
||||
strum_macros = "0.27.2"
|
||||
ratatui = "0.30.0"
|
||||
open = "5.3.3"
|
||||
chrono = "0.4.43"
|
||||
serde_json = "1.0.149"
|
||||
rusqlite = "0.39.0"
|
||||
lazy_static = "1.5.0"
|
||||
|
|
|
|||
25
LICENSE
25
LICENSE
|
|
@ -1,15 +1,16 @@
|
|||
Copyright (c) 2025 Methanium
|
||||
|
||||
Copyright (c) [2025] [Methanium]
|
||||
All rights reserved.
|
||||
|
||||
No part of this software, source code, documentation, or
|
||||
associated materials may be copied, reproduced, modified,
|
||||
distributed, published, sublicensed, sold, or used to create
|
||||
derivative works without prior written permission from the
|
||||
copyright holder.
|
||||
This software is protected by copyright. Copying, editing,
|
||||
distributing, publicly performing, or any other use of this software
|
||||
or its components, in source or binary form, is strictly prohibited without the express
|
||||
written permission of the copyright holder.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY
|
||||
OF ANY KIND, EXPRESS OR IMPLIED. TO THE MAXIMUM
|
||||
EXTENT PERMITTED BY LAW, THE COPYRIGHT HOLDER SHALL
|
||||
NOT BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
|
||||
LIABILITY ARISING FROM THE SOFTWARE OR ITS USE.
|
||||
FUTURE LICENSE ACCEPTANCE:
|
||||
It is the copyright holder's intention to release this software in the future
|
||||
under a license yet to be defined, which will, among other things,
|
||||
allow private, non-commercial use. This statement does not constitute
|
||||
a current license grant and does not alter the above
|
||||
prohibition on use, copying, or modification. Until the formal
|
||||
publication of such a future license, all rights remain
|
||||
reserved.
|
||||
|
|
|
|||
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.
|
||||
|
||||
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
|
||||
COPY . .
|
||||
|
||||
RUN cargo build --release -p iota-daemon
|
||||
RUN cargo build --release
|
||||
|
||||
# Runtime stage
|
||||
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/*
|
||||
|
||||
COPY --from=builder /app/target/release/iota-daemon .
|
||||
|
||||
RUN useradd -r -s /bin/false iota && mkdir -p /run/iota && chown iota:iota /run/iota
|
||||
COPY --from=builder /app/target/release/iota .
|
||||
|
||||
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