- Byte-exact TCE codec (encoder/decoder, canonical numbers, vectors-tested) - Ed25519 identities with bech32m trust1… addresses - Relay client: challenge/auth handshake, request feed, responses, transparent re-auth on 401 - iced GUI (Material-dark, Solana-style palette): tab shell, approval prompts with native toasts on Linux, address book, history, settings, password change - Portable single-file vault (identity + book + history + settings) - Live 2FA roundtrip examples (send_2fa, await_2fa)
1741 lines
57 KiB
Rust
1741 lines
57 KiB
Rust
//! Niko Trust — iced desktop app.
|
|
//!
|
|
//! Screens:
|
|
//! - Lock: enter the password (or create a fresh identity on first run) and
|
|
//! the relay URL, then connect.
|
|
//! - Main: polls the relay, surfaces approval requests (native notification +
|
|
//! an in-app card), records history.
|
|
//!
|
|
//! Built only with the `gui` feature (the crate default). The core library
|
|
//! stays UI-free via `--no-default-features`.
|
|
|
|
#[cfg(feature = "gui")]
|
|
mod gui {
|
|
use std::collections::VecDeque;
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Duration;
|
|
|
|
use iced::futures::{channel as fchannel, SinkExt, Stream, StreamExt};
|
|
use iced::widget::{button, container, scrollable, text, Checkbox, Column, Container, Row, Space, TextInput};
|
|
use iced::widget::{checkbox, text_input};
|
|
use iced::{alignment, border, Color, Element, Length, Size, Subscription, Task, Theme};
|
|
|
|
use niko_trust_gui::book::{short_addr, AddressBook};
|
|
use niko_trust_gui::inbox::{outcome_label, HistoryItem, Inbox, Outcome};
|
|
use niko_trust_gui::keyring::{decrypt_seed, encrypt_seed};
|
|
use niko_trust_gui::notify::{self, ApprovalNotification, PromptOutcome};
|
|
use niko_trust_gui::protocol::Decision;
|
|
use niko_trust_gui::relay::{IncomingRequest, IncomingResponse, Relay};
|
|
use niko_trust_gui::signer::Signer;
|
|
use niko_trust_gui::vault::{StoredItem, Vault};
|
|
|
|
const APP_NAME: &str = "Niko Trust";
|
|
const DEFAULT_RELAY_URL: &str = "https://trust.n1ko.dev";
|
|
const POLL_INTERVAL: Duration = Duration::from_secs(5);
|
|
const NATIVE_PROMPT_TIMEOUT: Duration = Duration::from_secs(120);
|
|
const MIN_PASSWORD_LEN: usize = 8;
|
|
|
|
/// Hands the native-notification outcome to the UI event loop. Only one prompt
|
|
/// is active at a time, so a single slot is enough; the subscription takes it.
|
|
static PROMPT_RX: Mutex<Option<fchannel::mpsc::Receiver<PromptOutcome>>> = Mutex::new(None);
|
|
|
|
pub fn run() -> iced::Result {
|
|
iced::application(boot, update, view)
|
|
.title(APP_NAME)
|
|
.theme(theme_of)
|
|
.window(window_settings())
|
|
.subscription(subscription)
|
|
.run()
|
|
}
|
|
|
|
fn boot() -> (App, Task<Message>) {
|
|
let vault = Vault::load();
|
|
let book = AddressBook::from_entries(vault.address_book().clone());
|
|
let relay_url = if vault.relay_url().is_empty() {
|
|
DEFAULT_RELAY_URL.to_string()
|
|
} else {
|
|
vault.relay_url().to_string()
|
|
};
|
|
let has_identity = vault.identity_blob().is_some();
|
|
let app = App {
|
|
vault,
|
|
has_identity,
|
|
password: String::new(),
|
|
confirm: String::new(),
|
|
relay_url,
|
|
lock_error: None,
|
|
connecting: false,
|
|
pending_signer: None,
|
|
session: None,
|
|
book,
|
|
book_edit: None,
|
|
screen: Screen::Requests,
|
|
new_password: String::new(),
|
|
new_confirm: String::new(),
|
|
autostart_enabled: autostart_now(),
|
|
};
|
|
(app, Task::none())
|
|
}
|
|
|
|
/// Current autostart registration state (false on any error).
|
|
fn autostart_now() -> bool {
|
|
std::env::current_exe()
|
|
.ok()
|
|
.and_then(|exe| niko_trust_gui::autostart::Autostart::new(&exe).ok())
|
|
.and_then(|a| a.is_enabled().ok())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn window_settings() -> iced::window::Settings {
|
|
iced::window::Settings {
|
|
size: Size::new(460.0, 720.0),
|
|
min_size: Some(Size::new(400.0, 520.0)),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// The three tabs of the main shell.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum Screen {
|
|
Requests,
|
|
Book,
|
|
Settings,
|
|
}
|
|
|
|
struct App {
|
|
/// Single portable state file (identity + book + history + settings).
|
|
vault: Vault,
|
|
has_identity: bool,
|
|
password: String,
|
|
confirm: String,
|
|
relay_url: String,
|
|
lock_error: Option<String>,
|
|
connecting: bool,
|
|
pending_signer: Option<Signer>,
|
|
session: Option<Session>,
|
|
/// Local address book (address -> label), persisted in the vault.
|
|
book: AddressBook,
|
|
/// Open "add to address book" editor: address + label being typed.
|
|
book_edit: Option<BookEdit>,
|
|
/// Which tab is open (Requests is the main view).
|
|
screen: Screen,
|
|
new_password: String,
|
|
new_confirm: String,
|
|
/// Cached autostart state (filesystem/registry is not polled per frame).
|
|
autostart_enabled: bool,
|
|
}
|
|
|
|
/// The add-to-address-book editor state. An empty `addr` means a brand-new
|
|
/// entry (the address itself is being typed).
|
|
struct BookEdit {
|
|
addr: String,
|
|
label: String,
|
|
error: Option<String>,
|
|
}
|
|
|
|
/// An unlocked, connected session.
|
|
struct Session {
|
|
signer: Signer,
|
|
relay: Arc<Relay>,
|
|
inbox: Inbox,
|
|
my_address: String,
|
|
queue: VecDeque<IncomingRequest>,
|
|
current: Option<Prompt>,
|
|
polling: bool,
|
|
responding: bool,
|
|
/// A 401-triggered re-login is in flight; suppress further reauths.
|
|
reconnecting: bool,
|
|
status: Option<String>,
|
|
}
|
|
|
|
/// The approval request currently awaiting the user's decision.
|
|
struct Prompt {
|
|
req: IncomingRequest,
|
|
native_shown: bool,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
enum Message {
|
|
PasswordChanged(String),
|
|
ConfirmChanged(String),
|
|
RelayUrlChanged(String),
|
|
Unlock,
|
|
Create,
|
|
/// Fresh identity generated + encrypted blob ready to store in the vault.
|
|
IdentityReady(Result<(Signer, serde_json::Value), String>),
|
|
Unlocked(Result<Signer, String>),
|
|
Connected(Result<Arc<Relay>, String>),
|
|
Tick,
|
|
Polled(Result<Vec<IncomingRequest>, String>),
|
|
PromptOutcome(PromptOutcome),
|
|
CopyText(String),
|
|
BookAdd(String),
|
|
BookLabel(String),
|
|
/// Typing the address of a brand-new entry.
|
|
BookAddr(String),
|
|
BookSave,
|
|
BookCancel,
|
|
BookDelete(String),
|
|
ShowTab(Screen),
|
|
NewPasswordChanged(String),
|
|
NewConfirmChanged(String),
|
|
ChangePassword,
|
|
PasswordUpdated(Result<serde_json::Value, String>),
|
|
ToggleAutostart(bool),
|
|
Approve,
|
|
Deny,
|
|
Responded {
|
|
id: [u8; 32],
|
|
decision: Decision,
|
|
result: Result<String, String>,
|
|
},
|
|
/// First time we see a request: check the relay for an existing response
|
|
/// before surfacing it (state is rebuilt from the relay after a restart).
|
|
AnswerChecked {
|
|
req: IncomingRequest,
|
|
result: Result<Vec<IncomingResponse>, String>,
|
|
},
|
|
/// Transparent re-login after a 401 (relay restarts drop all sessions).
|
|
Reauthed(Result<Arc<Relay>, String>),
|
|
}
|
|
|
|
// ---------------------------------------------------------------- polling
|
|
|
|
fn subscription(state: &App) -> Subscription<Message> {
|
|
let mut subs: Vec<Subscription<Message>> = Vec::new();
|
|
if let Some(s) = &state.session {
|
|
subs.push(iced::time::every(POLL_INTERVAL).map(|_| Message::Tick));
|
|
if !s.responding {
|
|
if let Some(p) = &s.current {
|
|
if p.native_shown {
|
|
subs.push(Subscription::run_with(p.req.request_id, |_| prompt_stream()));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Subscription::batch(subs)
|
|
}
|
|
|
|
/// Reads the native-notification outcome off the shared channel.
|
|
fn prompt_stream() -> impl Stream<Item = Message> {
|
|
iced::stream::channel(1, async |mut sender| {
|
|
let rx = PROMPT_RX.lock().expect("prompt channel lock").take();
|
|
if let Some(mut rx) = rx {
|
|
while let Some(outcome) = rx.next().await {
|
|
if sender.send(Message::PromptOutcome(outcome)).await.is_err() {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------- update
|
|
|
|
fn update(state: &mut App, msg: Message) -> Task<Message> {
|
|
match msg {
|
|
Message::PasswordChanged(v) => {
|
|
state.password = v;
|
|
Task::none()
|
|
}
|
|
Message::ConfirmChanged(v) => {
|
|
state.confirm = v;
|
|
Task::none()
|
|
}
|
|
Message::RelayUrlChanged(v) => {
|
|
state.relay_url = v;
|
|
Task::none()
|
|
}
|
|
Message::Unlock => unlock(state),
|
|
Message::Create => create(state),
|
|
Message::IdentityReady(r) => match r {
|
|
Ok((signer, blob)) => {
|
|
state.vault.set_identity_blob(blob);
|
|
state.has_identity = true;
|
|
unlocked(state, Ok(signer))
|
|
}
|
|
Err(e) => {
|
|
state.connecting = false;
|
|
state.lock_error = Some(format!("create failed: {e}"));
|
|
Task::none()
|
|
}
|
|
},
|
|
Message::Unlocked(r) => unlocked(state, r),
|
|
Message::Connected(r) => connected(state, r),
|
|
Message::Tick => poll(state),
|
|
Message::Polled(r) => polled(state, r),
|
|
Message::AnswerChecked { req, result } => answer_checked(state, req, result),
|
|
Message::Reauthed(r) => reauthed(state, r),
|
|
Message::PromptOutcome(o) => prompt_outcome(state, o),
|
|
Message::CopyText(t) => iced::clipboard::write(t),
|
|
Message::BookAdd(addr) => {
|
|
state.book_edit = Some(BookEdit { addr, label: String::new(), error: None });
|
|
Task::none()
|
|
}
|
|
Message::BookLabel(l) => {
|
|
if let Some(e) = state.book_edit.as_mut() {
|
|
e.label = l;
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::BookAddr(a) => {
|
|
if let Some(e) = state.book_edit.as_mut() {
|
|
e.addr = a;
|
|
e.error = None;
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::BookSave => {
|
|
let Some(e) = state.book_edit.take() else { return Task::none() };
|
|
let label = e.label.trim().to_string();
|
|
if e.addr.trim().is_empty() && !label.is_empty() {
|
|
// New entry without an address: put the editor back.
|
|
let mut edit = e;
|
|
edit.error = Some("address is required".to_string());
|
|
state.book_edit = Some(edit);
|
|
return Task::none();
|
|
}
|
|
if !e.addr.trim().is_empty() && !label.is_empty() {
|
|
// Validate the address before storing it.
|
|
if let Err(err) = niko_trust_gui::address::Address::parse(e.addr.trim()) {
|
|
let mut edit = e;
|
|
edit.error = Some(format!("invalid address: {err}"));
|
|
state.book_edit = Some(edit);
|
|
return Task::none();
|
|
}
|
|
}
|
|
state.book.set_label(e.addr.trim(), &label);
|
|
state
|
|
.vault
|
|
.set_address_book(state.book.entries().clone());
|
|
if let Some(s) = state.session.as_mut() {
|
|
s.status = Some(if label.is_empty() {
|
|
"address book entry removed".to_string()
|
|
} else {
|
|
format!("saved \"{}\" to address book", label)
|
|
});
|
|
}
|
|
Task::none()
|
|
}
|
|
Message::BookCancel => {
|
|
state.book_edit = None;
|
|
Task::none()
|
|
}
|
|
Message::BookDelete(addr) => {
|
|
state.book.set_label(&addr, "");
|
|
state.vault.set_address_book(state.book.entries().clone());
|
|
Task::none()
|
|
}
|
|
Message::ShowTab(tab) => {
|
|
// Persist relay edits when leaving the Settings tab.
|
|
if state.screen == Screen::Settings && tab != Screen::Settings {
|
|
state.vault.set_relay_url(state.relay_url.trim().to_string());
|
|
}
|
|
state.screen = tab;
|
|
state.book_edit = None;
|
|
Task::none()
|
|
}
|
|
Message::NewPasswordChanged(v) => {
|
|
state.new_password = v;
|
|
Task::none()
|
|
}
|
|
Message::NewConfirmChanged(v) => {
|
|
state.new_confirm = v;
|
|
Task::none()
|
|
}
|
|
Message::ChangePassword => {
|
|
let Some(s) = state.session.as_ref() else { return Task::none() };
|
|
if state.new_password.len() < MIN_PASSWORD_LEN {
|
|
if let Some(s) = state.session.as_mut() {
|
|
s.status =
|
|
Some(format!("password must be at least {MIN_PASSWORD_LEN} characters"));
|
|
}
|
|
return Task::none();
|
|
}
|
|
if state.new_password != state.new_confirm {
|
|
if let Some(s) = state.session.as_mut() {
|
|
s.status = Some("passwords do not match".into());
|
|
}
|
|
return Task::none();
|
|
}
|
|
let seed = s.signer.seed();
|
|
let pass = state.new_password.clone();
|
|
Task::perform(
|
|
async move { encrypt_seed(&pass, &seed).map_err(|e| e.to_string()) },
|
|
Message::PasswordUpdated,
|
|
)
|
|
}
|
|
Message::PasswordUpdated(r) => match r {
|
|
Ok(blob) => {
|
|
state.vault.set_identity_blob(blob);
|
|
state.new_password.clear();
|
|
state.new_confirm.clear();
|
|
if let Some(s) = state.session.as_mut() {
|
|
s.status = Some("password updated".to_string());
|
|
}
|
|
Task::none()
|
|
}
|
|
Err(e) => {
|
|
if let Some(s) = state.session.as_mut() {
|
|
s.status = Some(format!("password update failed: {e}"));
|
|
}
|
|
Task::none()
|
|
}
|
|
},
|
|
Message::ToggleAutostart(on) => {
|
|
let res = std::env::current_exe()
|
|
.ok()
|
|
.and_then(|exe| niko_trust_gui::autostart::Autostart::new(&exe).ok())
|
|
.map(|a| if on { a.enable() } else { a.disable() });
|
|
state.autostart_enabled = match res {
|
|
Some(Ok(())) => on,
|
|
_ => state.autostart_enabled,
|
|
};
|
|
Task::none()
|
|
}
|
|
Message::Approve => decide(state, Decision::Allow),
|
|
Message::Deny => decide(state, Decision::Deny),
|
|
Message::Responded { id, decision, result } => responded(state, id, decision, result),
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- unlock
|
|
|
|
fn unlock(state: &mut App) -> Task<Message> {
|
|
if state.password.is_empty() {
|
|
state.lock_error = Some("enter your password".into());
|
|
return Task::none();
|
|
}
|
|
state.lock_error = None;
|
|
state.connecting = true;
|
|
let Some(blob) = state.vault.identity_blob().cloned() else {
|
|
state.connecting = false;
|
|
state.lock_error = Some("no identity in vault".into());
|
|
return Task::none();
|
|
};
|
|
let password = state.password.clone();
|
|
Task::perform(
|
|
async move {
|
|
decrypt_seed(&password, &blob)
|
|
.map_err(|e| e.to_string())
|
|
.and_then(|seed| Signer::from_seed(seed).map_err(|e| e.to_string()))
|
|
},
|
|
Message::Unlocked,
|
|
)
|
|
}
|
|
|
|
fn create(state: &mut App) -> Task<Message> {
|
|
if state.password.len() < MIN_PASSWORD_LEN {
|
|
state.lock_error =
|
|
Some(format!("password must be at least {MIN_PASSWORD_LEN} characters"));
|
|
return Task::none();
|
|
}
|
|
if state.password != state.confirm {
|
|
state.lock_error = Some("passwords do not match".into());
|
|
return Task::none();
|
|
}
|
|
state.lock_error = None;
|
|
state.connecting = true;
|
|
let password = state.password.clone();
|
|
Task::perform(
|
|
async move {
|
|
let signer = Signer::generate().map_err(|e| e.to_string())?;
|
|
let blob = encrypt_seed(&password, &signer.seed()).map_err(|e| e.to_string())?;
|
|
Ok((signer, blob))
|
|
},
|
|
Message::IdentityReady,
|
|
)
|
|
}
|
|
|
|
fn unlocked(state: &mut App, r: Result<Signer, String>) -> Task<Message> {
|
|
match r {
|
|
Ok(signer) => {
|
|
state.vault.set_relay_url(state.relay_url.trim().to_string());
|
|
state.pending_signer = Some(signer.clone());
|
|
connect_task(signer, state.relay_url.trim().to_string())
|
|
}
|
|
Err(e) => {
|
|
state.connecting = false;
|
|
state.lock_error = Some(format!("unlock failed: {e}"));
|
|
Task::none()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn connect_task(signer: Signer, url: String) -> Task<Message> {
|
|
Task::perform(
|
|
async move {
|
|
Relay::auth(&url, &signer, "*")
|
|
.map(Arc::new)
|
|
.map_err(|e| e.to_string())
|
|
},
|
|
Message::Connected,
|
|
)
|
|
}
|
|
|
|
fn connected(state: &mut App, r: Result<Arc<Relay>, String>) -> Task<Message> {
|
|
state.connecting = false;
|
|
match r {
|
|
Ok(relay) => {
|
|
let Some(signer) = state.pending_signer.take() else {
|
|
return Task::none();
|
|
};
|
|
let my_address = signer
|
|
.address()
|
|
.map(|a| format!("{a}"))
|
|
.unwrap_or_else(|e| format!("<address error: {e}>"));
|
|
// Restore persisted history so it survives relay restarts.
|
|
let mut inbox = Inbox::new();
|
|
let restored: Vec<HistoryItem> = state
|
|
.vault
|
|
.history()
|
|
.iter()
|
|
.filter_map(|s| HistoryItem::try_from(s).ok())
|
|
.collect();
|
|
inbox.restore(restored);
|
|
state.session = Some(Session {
|
|
signer,
|
|
relay,
|
|
inbox,
|
|
my_address,
|
|
queue: VecDeque::new(),
|
|
current: None,
|
|
polling: false,
|
|
responding: false,
|
|
reconnecting: false,
|
|
status: None,
|
|
});
|
|
Task::none()
|
|
}
|
|
Err(e) => {
|
|
state.lock_error = Some(format!("relay connect failed: {e}"));
|
|
Task::none()
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- poll
|
|
|
|
/// Persists the current history into the vault (called after any change).
|
|
fn snapshot_history(state: &mut App) {
|
|
let Some(s) = state.session.as_ref() else { return };
|
|
let items: Vec<StoredItem> =
|
|
s.inbox.history().into_iter().map(StoredItem::from).collect();
|
|
state.vault.set_history(items);
|
|
}
|
|
|
|
fn poll(state: &mut App) -> Task<Message> {
|
|
let Some(s) = state.session.as_mut() else {
|
|
return Task::none();
|
|
};
|
|
if s.polling || s.responding {
|
|
return Task::none();
|
|
}
|
|
s.polling = true;
|
|
let relay = s.relay.clone();
|
|
let signer = s.signer.clone();
|
|
Task::perform(
|
|
async move { relay.fetch_requests(&signer).map_err(|e| e.to_string()) },
|
|
Message::Polled,
|
|
)
|
|
}
|
|
|
|
fn polled(state: &mut App, r: Result<Vec<IncomingRequest>, String>) -> Task<Message> {
|
|
let Some(s) = state.session.as_mut() else {
|
|
return Task::none();
|
|
};
|
|
s.polling = false;
|
|
match r {
|
|
Ok(list) => {
|
|
let now = now_secs();
|
|
let fresh = s.inbox.observe(now, &list);
|
|
let n = fresh.len();
|
|
s.status = Some(if n == 0 {
|
|
"poll ok".to_string()
|
|
} else {
|
|
format!("checking {n} new request{}…", if n == 1 { "" } else { "s" })
|
|
});
|
|
let relay = s.relay.clone();
|
|
let tasks: Vec<Task<Message>> = fresh
|
|
.into_iter()
|
|
.map(|req| check_answered(relay.clone(), req.clone()))
|
|
.collect();
|
|
advance(s);
|
|
if n > 0 {
|
|
snapshot_history(state);
|
|
}
|
|
Task::batch(tasks)
|
|
}
|
|
Err(e) => {
|
|
s.status = Some(format!("poll failed: {e}"));
|
|
if is_auth_error(&e) {
|
|
return reauth(s);
|
|
}
|
|
Task::none()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Relay sessions are in-memory server-side: a relay restart invalidates every
|
|
/// token, so a 401 means "log in again", not "give up".
|
|
fn is_auth_error(e: &str) -> bool {
|
|
e.contains("401") || e.to_lowercase().contains("unauthorized")
|
|
}
|
|
|
|
/// Re-runs the challenge/assert handshake and swaps the relay client.
|
|
fn reauth(s: &mut Session) -> Task<Message> {
|
|
if s.reconnecting {
|
|
return Task::none();
|
|
}
|
|
s.reconnecting = true;
|
|
let base = s.relay.base_url().to_string();
|
|
let signer = s.signer.clone();
|
|
Task::perform(
|
|
async move { Relay::auth(&base, &signer, "*").map(Arc::new).map_err(|e| e.to_string()) },
|
|
Message::Reauthed,
|
|
)
|
|
}
|
|
|
|
fn reauthed(state: &mut App, r: Result<Arc<Relay>, String>) -> Task<Message> {
|
|
let Some(s) = state.session.as_mut() else {
|
|
return Task::none();
|
|
};
|
|
s.reconnecting = false;
|
|
match r {
|
|
Ok(relay) => {
|
|
s.relay = relay;
|
|
s.status = Some("re-authenticated".to_string());
|
|
poll(state)
|
|
}
|
|
Err(e) => {
|
|
s.status = Some(format!("re-auth failed: {e}"));
|
|
Task::none()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Before surfacing a request, ask the relay whether it was already answered
|
|
/// (the inbox is in-memory, so after a restart answered requests would
|
|
/// otherwise reappear as pending and refuse re-answering with 422).
|
|
fn check_answered(relay: std::sync::Arc<Relay>, req: IncomingRequest) -> Task<Message> {
|
|
Task::perform(
|
|
async move {
|
|
let rid = hex::encode(req.request_id);
|
|
let res = relay.fetch_responses(&rid).map_err(|e| e.to_string());
|
|
(req, res)
|
|
},
|
|
|(req, result)| Message::AnswerChecked { req, result },
|
|
)
|
|
}
|
|
|
|
fn answer_checked(
|
|
state: &mut App,
|
|
req: IncomingRequest,
|
|
result: Result<Vec<IncomingResponse>, String>,
|
|
) -> Task<Message> {
|
|
let id = req.request_id;
|
|
let snapshot;
|
|
{
|
|
let Some(s) = state.session.as_mut() else {
|
|
return Task::none();
|
|
};
|
|
let already_visible = s.current.as_ref().is_some_and(|p| p.req.request_id == id)
|
|
|| s.queue.iter().any(|q| q.request_id == id);
|
|
match result {
|
|
Ok(list) => {
|
|
let mine = list
|
|
.into_iter()
|
|
.find(|r| r.response.responder == s.signer.pubkey());
|
|
match mine {
|
|
Some(r) => {
|
|
let decision = r.response.decision;
|
|
s.inbox.set_outcome(&id, Outcome::from(decision));
|
|
s.queue.retain(|q| q.request_id != id);
|
|
if s.current.as_ref().is_some_and(|p| p.req.request_id == id) {
|
|
s.current = None;
|
|
advance(s);
|
|
}
|
|
let label = outcome_label(decision.into());
|
|
s.status = Some(format!("request already {label}"));
|
|
}
|
|
None => {
|
|
// Genuinely unanswered: surface it now, but only when
|
|
// its window is still open (stale stay history-only).
|
|
if !already_visible
|
|
&& s.inbox.outcome_of(&id) == Some(Outcome::Pending)
|
|
{
|
|
s.queue.push_back(req);
|
|
}
|
|
let n = s.queue.len();
|
|
s.status = Some(match n {
|
|
1 => "1 new request".to_string(),
|
|
k => format!("{k} queued requests"),
|
|
});
|
|
advance(s);
|
|
}
|
|
}
|
|
snapshot = true;
|
|
}
|
|
Err(e) => {
|
|
// Can't tell; keep it visible rather than silently dropping.
|
|
if !already_visible {
|
|
s.queue.push_back(req);
|
|
}
|
|
s.status = Some(format!("answer check failed: {e}"));
|
|
advance(s);
|
|
snapshot = false;
|
|
}
|
|
}
|
|
}
|
|
if snapshot {
|
|
snapshot_history(state);
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
// ---------------------------------------------------------------- prompt
|
|
|
|
fn prompt_outcome(state: &mut App, o: PromptOutcome) -> Task<Message> {
|
|
match o {
|
|
PromptOutcome::Approved => decide(state, Decision::Allow),
|
|
PromptOutcome::Denied => decide(state, Decision::Deny),
|
|
PromptOutcome::Ignored => {
|
|
// Native toast dismissed: keep the in-app card as the fallback.
|
|
if let Some(s) = state.session.as_mut() {
|
|
if let Some(p) = s.current.as_mut() {
|
|
p.native_shown = false;
|
|
}
|
|
}
|
|
Task::none()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn decide(state: &mut App, decision: Decision) -> Task<Message> {
|
|
let Some(s) = state.session.as_mut() else {
|
|
return Task::none();
|
|
};
|
|
if s.responding {
|
|
return Task::none();
|
|
}
|
|
let Some(req) = s.current.as_ref().map(|p| p.req.clone()) else {
|
|
return Task::none();
|
|
};
|
|
s.responding = true;
|
|
let relay = s.relay.clone();
|
|
let signer = s.signer.clone();
|
|
Task::perform(
|
|
async move {
|
|
let res = relay.respond(&signer, &req.request, &req.tce, decision);
|
|
(req.request_id, decision, res.map_err(|e| e.to_string()))
|
|
},
|
|
|(id, d, res)| Message::Responded { id, decision: d, result: res },
|
|
)
|
|
}
|
|
|
|
fn responded(
|
|
state: &mut App,
|
|
id: [u8; 32],
|
|
decision: Decision,
|
|
result: Result<String, String>,
|
|
) -> Task<Message> {
|
|
let mut snapshot = false;
|
|
{
|
|
let Some(s) = state.session.as_mut() else {
|
|
return Task::none();
|
|
};
|
|
s.responding = false;
|
|
match result {
|
|
Ok(object_id) => {
|
|
s.inbox.set_outcome(&id, decision.into());
|
|
s.current = None;
|
|
s.status =
|
|
Some(format!("response {} stored ({object_id})", decision.as_str()));
|
|
advance(s);
|
|
snapshot = true;
|
|
}
|
|
Err(e) => {
|
|
// The server refuses a second answer (one response per
|
|
// request): our local state is behind, reconcile from relay.
|
|
if e.contains("already answered") {
|
|
if let Some(p) = s.current.as_ref().filter(|p| p.req.request_id == id) {
|
|
let req = p.req.clone();
|
|
s.status = Some("reconciling with relay…".to_string());
|
|
return check_answered(s.relay.clone(), req);
|
|
}
|
|
s.status = Some("already answered elsewhere".to_string());
|
|
return Task::none();
|
|
}
|
|
if is_auth_error(&e) {
|
|
// Keep the card up; after re-login the user can answer.
|
|
s.status = Some("session expired, re-authenticating…".to_string());
|
|
return reauth(s);
|
|
}
|
|
let expired = s.current.as_ref().is_some_and(|p| {
|
|
!niko_trust_gui::inbox::in_window(
|
|
p.req.request.created_at,
|
|
p.req.request.expires_at,
|
|
now_secs(),
|
|
)
|
|
});
|
|
s.status = Some(format!("response failed: {e}"));
|
|
if expired {
|
|
s.inbox.set_outcome(&id, Outcome::TimedOut);
|
|
s.current = None;
|
|
advance(s);
|
|
snapshot = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if snapshot {
|
|
snapshot_history(state);
|
|
}
|
|
Task::none()
|
|
}
|
|
|
|
/// Makes the next queued request the active prompt (with a native toast when
|
|
/// the platform supports one).
|
|
fn advance(s: &mut Session) {
|
|
if s.current.is_some() {
|
|
return;
|
|
}
|
|
if let Some(req) = s.queue.pop_front() {
|
|
let native_shown = notify::interactive_supported();
|
|
if native_shown {
|
|
fire_native(&req);
|
|
}
|
|
s.current = Some(Prompt { req, native_shown });
|
|
}
|
|
}
|
|
|
|
fn fire_native(req: &IncomingRequest) {
|
|
let n = ApprovalNotification {
|
|
sender: format!("{}", req.sender),
|
|
action: req.request.action.clone(),
|
|
message: req.request.message.clone(),
|
|
};
|
|
let (tx, rx) = fchannel::mpsc::channel(1);
|
|
*PROMPT_RX.lock().expect("prompt channel lock") = Some(rx);
|
|
std::thread::spawn(move || {
|
|
let handle = notify::show(n);
|
|
let outcome = handle.wait_timeout(NATIVE_PROMPT_TIMEOUT);
|
|
let mut tx = tx;
|
|
let _ = tx.try_send(outcome);
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------- view
|
|
|
|
fn view<'a>(state: &'a App) -> Element<'a, Message> {
|
|
match &state.session {
|
|
None => view_locked(state),
|
|
Some(_) => view_main(state),
|
|
}
|
|
}
|
|
|
|
fn view_locked<'a>(state: &'a App) -> Element<'a, Message> {
|
|
let mut items: Vec<Element<Message>> = Vec::new();
|
|
items.push(
|
|
Row::with_children(vec![
|
|
text("Niko").size(32).color(c(COL_TEXT)).into(),
|
|
text("Trust").size(32).color(c(COL_ACCENT)).into(),
|
|
])
|
|
.into(),
|
|
);
|
|
items.push(
|
|
text(if state.has_identity {
|
|
"Unlock your identity."
|
|
} else {
|
|
"Create a new identity with a password."
|
|
})
|
|
.size(14)
|
|
.color(label_color())
|
|
.into(),
|
|
);
|
|
items.push(Space::new().height(24).into());
|
|
|
|
items.push(text("Relay").size(13).color(label_color()).into());
|
|
items.push(
|
|
TextInput::new("https://trust.n1ko.dev", &state.relay_url)
|
|
.on_input(Message::RelayUrlChanged)
|
|
.width(Length::Fill)
|
|
.padding(10).style(input_style)
|
|
.into(),
|
|
);
|
|
items.push(Space::new().height(14).into());
|
|
|
|
items.push(text("Password").size(13).color(label_color()).into());
|
|
items.push(
|
|
TextInput::new("password", &state.password)
|
|
.secure(true)
|
|
.on_input(Message::PasswordChanged)
|
|
.on_submit(if state.has_identity {
|
|
Message::Unlock
|
|
} else {
|
|
Message::Create
|
|
})
|
|
.width(Length::Fill)
|
|
.padding(10).style(input_style)
|
|
.into(),
|
|
);
|
|
|
|
if !state.has_identity {
|
|
items.push(Space::new().height(14).into());
|
|
items.push(text("Confirm password").size(13).color(label_color()).into());
|
|
items.push(
|
|
TextInput::new("password again", &state.confirm)
|
|
.secure(true)
|
|
.on_input(Message::ConfirmChanged)
|
|
.on_submit(Message::Create)
|
|
.width(Length::Fill)
|
|
.padding(10)
|
|
.into(),
|
|
);
|
|
}
|
|
|
|
items.push(Space::new().height(24).into());
|
|
let (label, msg) = if state.has_identity {
|
|
("Unlock", Message::Unlock)
|
|
} else {
|
|
("Create identity", Message::Create)
|
|
};
|
|
items.push(
|
|
button(text(label))
|
|
.style(btn_primary)
|
|
.on_press_maybe((!state.connecting).then_some(msg))
|
|
.width(Length::Fill)
|
|
.padding(12)
|
|
.into(),
|
|
);
|
|
|
|
if state.connecting {
|
|
items.push(Space::new().height(10).into());
|
|
items.push(
|
|
text("Connecting…")
|
|
.size(13)
|
|
.color(label_color())
|
|
.into(),
|
|
);
|
|
}
|
|
if let Some(e) = &state.lock_error {
|
|
items.push(Space::new().height(10).into());
|
|
items.push(text(e.clone()).size(13).color(error_color()).into());
|
|
}
|
|
|
|
let col = Column::with_children(items).spacing(10);
|
|
let card = Container::new(col)
|
|
.width(400)
|
|
.padding(28)
|
|
.style(card_style);
|
|
Container::new(card)
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.center_x(Length::Fill)
|
|
.center_y(Length::Fill)
|
|
.into()
|
|
}
|
|
|
|
/// Main shell: fixed top bar, scrollable content, fixed bottom tab bar.
|
|
/// Everything is capped to a phone-like column and centered on wide screens.
|
|
fn view_main<'a>(state: &'a App) -> Element<'a, Message> {
|
|
let s = state.session.as_ref().expect("view_main without session");
|
|
|
|
let content: Element<Message> = match state.screen {
|
|
Screen::Requests => view_requests(state),
|
|
Screen::Book => view_book_list(state),
|
|
Screen::Settings => view_settings(state),
|
|
};
|
|
|
|
let shell = Column::with_children(vec![
|
|
top_bar(s.status.as_ref()),
|
|
divider().into(),
|
|
Container::new(content)
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.padding(iced::Padding { top: 14.0, right: 6.0, bottom: 12.0, left: 6.0 })
|
|
.into(),
|
|
tab_bar(state.screen),
|
|
])
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.padding(12);
|
|
|
|
// Fixed-width column…
|
|
let capped =
|
|
Container::new(shell).width(APP_MAX_WIDTH as f32).height(Length::Fill);
|
|
// …centered by a full-size outer container.
|
|
Container::new(capped)
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.center_x(Length::Fill)
|
|
.into()
|
|
}
|
|
|
|
/// Maximum width of the app column (px). Keeps the layout compact on
|
|
/// large monitors instead of stretching edge to edge.
|
|
const APP_MAX_WIDTH: u32 = 540;
|
|
|
|
/// Brand + live status pill.
|
|
fn top_bar<'a>(status: Option<&String>) -> Element<'a, Message> {
|
|
Row::with_children(vec![
|
|
Row::with_children(vec![
|
|
text("niko").size(19).color(c(COL_TEXT)).into(),
|
|
text("trust").size(19).color(c(COL_ACCENT)).into(),
|
|
])
|
|
.align_y(alignment::Vertical::Center)
|
|
.into(),
|
|
Space::new().width(Length::Fill).into(),
|
|
status_pill(status),
|
|
])
|
|
.align_y(alignment::Vertical::Center)
|
|
.width(Length::Fill)
|
|
.padding([4, 4])
|
|
.into()
|
|
}
|
|
|
|
/// One horizontal hairline.
|
|
fn divider() -> Container<'static, Message> {
|
|
Container::new(Space::new().height(1))
|
|
.width(Length::Fill)
|
|
.style(|_t| container::Style {
|
|
background: Some(c(COL_BORDER).into()),
|
|
..Default::default()
|
|
})
|
|
}
|
|
|
|
/// A small colored dot + short status text (truncated).
|
|
fn status_pill<'a>(status: Option<&String>) -> Element<'a, Message> {
|
|
let text_s = status.map(String::as_str).unwrap_or("online");
|
|
let lower = text_s.to_lowercase();
|
|
let color = if ["failed", "error", "refus", "timeout", "expired"]
|
|
.iter()
|
|
.any(|w| lower.contains(w))
|
|
{
|
|
COL_RED
|
|
} else if ["re-auth", "reconcil", "checking", "waiting"].iter().any(|w| lower.contains(w)) {
|
|
COL_AMBER
|
|
} else {
|
|
COL_GREEN
|
|
};
|
|
let mut label = text_s.to_string();
|
|
if label.chars().count() > 34 {
|
|
label = format!("{}…", label.chars().take(34).collect::<String>());
|
|
}
|
|
|
|
Row::with_children(vec![
|
|
dot(color),
|
|
Space::new().width(7).into(),
|
|
text(label).size(11).color(label_color()).into(),
|
|
])
|
|
.align_y(alignment::Vertical::Center)
|
|
.into()
|
|
}
|
|
|
|
/// A filled circle of the given color.
|
|
fn dot(color: u32) -> Element<'static, Message> {
|
|
Container::new(Space::new().width(8).height(8))
|
|
.style(move |_t| container::Style {
|
|
background: Some(c(color).into()),
|
|
border: border(Color::TRANSPARENT, 0.0, 4.0),
|
|
..Default::default()
|
|
})
|
|
.into()
|
|
}
|
|
|
|
/// Bottom navigation dock with an active segment highlight.
|
|
fn tab_bar(active: Screen) -> Element<'static, Message> {
|
|
let tab = |label: &'static str, sc: Screen| {
|
|
button(
|
|
text(label)
|
|
.size(13)
|
|
.width(Length::Fill)
|
|
.align_x(alignment::Horizontal::Center),
|
|
)
|
|
.style(move |t, st| btn_tab(sc == active, t, st))
|
|
.on_press(Message::ShowTab(sc))
|
|
.width(Length::Fill)
|
|
.padding([9, 0])
|
|
};
|
|
let tabs: Vec<Element<Message>> = vec![
|
|
tab("Requests", Screen::Requests).into(),
|
|
Space::new().width(6).into(),
|
|
tab("Contacts", Screen::Book).into(),
|
|
Space::new().width(6).into(),
|
|
tab("Settings", Screen::Settings).into(),
|
|
];
|
|
Container::new(Row::with_children(tabs).width(Length::Fill))
|
|
.width(Length::Fill)
|
|
.padding([6, 6])
|
|
.style(|_t| container::Style {
|
|
background: Some(c(COL_SURFACE).into()),
|
|
border: border(c(COL_BORDER), 1.0, 14.0),
|
|
..Default::default()
|
|
})
|
|
.into()
|
|
}
|
|
|
|
fn btn_tab(is_active: bool, _t: &Theme, s: button::Status) -> button::Style {
|
|
let pressed = matches!(s, button::Status::Pressed);
|
|
if is_active {
|
|
let bg = match s {
|
|
button::Status::Pressed => c(0x7A34D9),
|
|
button::Status::Hovered => c(0x8A3DF0),
|
|
_ => c(COL_ACCENT),
|
|
};
|
|
button::Style {
|
|
background: Some(bg.into()),
|
|
text_color: c(0xFFFFFF),
|
|
border: border(c(COL_ACCENT), 0.0, 9.0),
|
|
..Default::default()
|
|
}
|
|
} else {
|
|
button::Style {
|
|
background: Some(c(if pressed { 0x181D26 } else { 0x12151D }).into()),
|
|
text_color: c(if matches!(s, button::Status::Hovered) { COL_TEXT } else { 0x767E92 }),
|
|
border: border(Color::TRANSPARENT, 0.0, 9.0),
|
|
..Default::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Requests tab: identity card, active prompt, history.
|
|
fn view_requests<'a>(state: &'a App) -> Element<'a, Message> {
|
|
let s = state.session.as_ref().expect("view_requests without session");
|
|
let mut items: Vec<Element<Message>> = Vec::new();
|
|
|
|
// Identity card.
|
|
items.push(
|
|
Container::new(
|
|
Column::with_children(vec![
|
|
text("MY ADDRESS").size(10).color(label_color()).into(),
|
|
Row::with_children(vec![
|
|
text(short_addr(&s.my_address)).size(15).color(c(COL_TEXT)).into(),
|
|
Space::new().width(Length::Fill).into(),
|
|
button(text("copy").size(12))
|
|
.style(btn_secondary)
|
|
.on_press(Message::CopyText(s.my_address.clone()))
|
|
.padding([4, 12])
|
|
.into(),
|
|
])
|
|
.align_y(alignment::Vertical::Center)
|
|
.width(Length::Fill)
|
|
.into(),
|
|
])
|
|
.spacing(5),
|
|
)
|
|
.width(Length::Fill)
|
|
.padding(14)
|
|
.style(card_style)
|
|
.into(),
|
|
);
|
|
|
|
if let Some(edit) = &state.book_edit {
|
|
items.push(Space::new().height(12).into());
|
|
items.push(view_book_editor(state, edit));
|
|
}
|
|
|
|
items.push(Space::new().height(12).into());
|
|
|
|
if let Some(p) = &s.current {
|
|
items.push(view_prompt(state, p));
|
|
items.push(Space::new().height(16).into());
|
|
}
|
|
|
|
items.push(
|
|
Row::with_children(vec![
|
|
text("History").size(15).color(c(COL_TEXT)).into(),
|
|
Space::new().width(Length::Fill).into(),
|
|
text(format!("{} total", s.inbox.history().len()))
|
|
.size(11)
|
|
.color(label_color())
|
|
.into(),
|
|
])
|
|
.align_y(alignment::Vertical::Center)
|
|
.into(),
|
|
);
|
|
items.push(Space::new().height(8).into());
|
|
items.push(view_history(state));
|
|
|
|
scrollable(Column::with_children(items).spacing(0).width(Length::Fill))
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.style(scroll_style)
|
|
.into()
|
|
}
|
|
|
|
|
|
/// Settings screen: relay URL, autostart, password change.
|
|
fn view_settings<'a>(state: &'a App) -> Element<'a, Message> {
|
|
let can_change = state.session.is_some()
|
|
&& state.new_password.len() >= MIN_PASSWORD_LEN
|
|
&& !state.new_confirm.is_empty();
|
|
|
|
let items: Vec<Element<Message>> = vec![
|
|
text("Relay").size(13).color(label_color()).into(),
|
|
TextInput::new("https://trust.n1ko.dev", &state.relay_url)
|
|
.on_input(Message::RelayUrlChanged)
|
|
.width(Length::Fill)
|
|
.padding(10).style(input_style)
|
|
.into(),
|
|
Space::new().height(16).into(),
|
|
Checkbox::new(state.autostart_enabled)
|
|
.label("Launch at startup")
|
|
.on_toggle(Message::ToggleAutostart)
|
|
.size(18)
|
|
.style(checkbox_style)
|
|
.into(),
|
|
Space::new().height(24).into(),
|
|
text("Change password").size(16).into(),
|
|
TextInput::new("new password", &state.new_password)
|
|
.secure(true)
|
|
.on_input(Message::NewPasswordChanged)
|
|
.width(Length::Fill)
|
|
.padding(10).style(input_style)
|
|
.into(),
|
|
Space::new().height(8).into(),
|
|
TextInput::new("repeat new password", &state.new_confirm)
|
|
.secure(true)
|
|
.on_input(Message::NewConfirmChanged)
|
|
.on_submit(Message::ChangePassword)
|
|
.width(Length::Fill)
|
|
.padding(10).style(input_style)
|
|
.into(),
|
|
Space::new().height(12).into(),
|
|
button(text("Update password"))
|
|
.style(btn_primary)
|
|
.on_press_maybe(can_change.then_some(Message::ChangePassword))
|
|
.width(Length::Fill)
|
|
.padding(12)
|
|
.into(),
|
|
];
|
|
|
|
scrollable(Column::with_children(items).spacing(8).width(Length::Fill)).style(scroll_style)
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.into()
|
|
}
|
|
|
|
/// A clickable address chip: pressing it copies the *full* address. Unknown
|
|
/// addresses get an "add" button that opens the address-book editor.
|
|
fn addr_line<'a>(
|
|
book: &'a AddressBook,
|
|
addr: &str,
|
|
size: f32,
|
|
) -> Vec<Element<'a, Message>> {
|
|
let known = book.label_of(addr).is_some();
|
|
let mut out: Vec<Element<Message>> = Vec::new();
|
|
out.push(
|
|
button(text(book.display(addr)).size(size))
|
|
.style(btn_text)
|
|
.on_press(Message::CopyText(addr.to_string()))
|
|
.padding(0)
|
|
.into(),
|
|
);
|
|
if !known {
|
|
out.push(Space::new().width(4).into());
|
|
out.push(
|
|
button(text("+book").size(size - 1.0))
|
|
.style(btn_secondary)
|
|
.on_press(Message::BookAdd(addr.to_string()))
|
|
.padding([2, 8])
|
|
.into(),
|
|
);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Inline editor for a label of one address. An empty `addr` means a new
|
|
/// entry: the address itself becomes editable.
|
|
fn view_book_editor<'a>(_state: &'a App, e: &'a BookEdit) -> Element<'a, Message> {
|
|
let mut body: Vec<Element<Message>> = vec![text("Address book").size(14).into()];
|
|
if e.addr.is_empty() {
|
|
body.push(
|
|
TextInput::new("trust1… address", &e.addr)
|
|
.on_input(Message::BookAddr)
|
|
.width(Length::Fill)
|
|
.padding(8).style(input_style)
|
|
.into(),
|
|
);
|
|
} else {
|
|
body.push(text(short_addr(&e.addr)).size(12).color(label_color()).into());
|
|
}
|
|
body.push(
|
|
TextInput::new("label", &e.label)
|
|
.on_input(Message::BookLabel)
|
|
.on_submit(Message::BookSave)
|
|
.width(Length::Fill)
|
|
.padding(8)
|
|
.into(),
|
|
);
|
|
if let Some(err) = &e.error {
|
|
body.push(text(err.clone()).size(12).color(error_color()).into());
|
|
}
|
|
body.push(
|
|
Row::with_children(vec![
|
|
button(text(if e.label.trim().is_empty() && !e.addr.is_empty() {
|
|
"Remove"
|
|
} else {
|
|
"Save"
|
|
}))
|
|
.style(btn_primary)
|
|
.on_press(Message::BookSave)
|
|
.padding([6, 16])
|
|
.into(),
|
|
Space::new().width(8).into(),
|
|
button(text("Cancel"))
|
|
.style(btn_secondary)
|
|
.on_press(Message::BookCancel)
|
|
.padding([6, 16])
|
|
.into(),
|
|
])
|
|
.align_y(alignment::Vertical::Center)
|
|
.into(),
|
|
);
|
|
|
|
Container::new(Column::with_children(body).spacing(8))
|
|
.width(Length::Fill)
|
|
.padding(14)
|
|
.style(card_style)
|
|
.into()
|
|
}
|
|
|
|
/// Full address-book screen: browse, copy, edit and delete entries.
|
|
fn view_book_list<'a>(state: &'a App) -> Element<'a, Message> {
|
|
let mut items: Vec<Element<Message>> = Vec::new();
|
|
|
|
if let Some(edit) = &state.book_edit {
|
|
items.push(view_book_editor(state, edit));
|
|
items.push(Space::new().height(10).into());
|
|
}
|
|
|
|
let entries: Vec<(&String, &String)> = state.book.entries().iter().collect();
|
|
if entries.is_empty() && state.book_edit.is_none() {
|
|
items.push(
|
|
text("No entries yet.")
|
|
.size(13)
|
|
.color(c(0x6B7280))
|
|
.into(),
|
|
);
|
|
}
|
|
for (addr, label) in entries {
|
|
items.push(
|
|
Row::with_children(vec![
|
|
Column::with_children(vec![
|
|
text(label.clone()).size(13).into(),
|
|
text(short_addr(addr)).size(11).color(label_color()).into(),
|
|
])
|
|
.spacing(2)
|
|
.width(Length::Fill)
|
|
.into(),
|
|
button(text("copy"))
|
|
.style(btn_secondary)
|
|
.on_press(Message::CopyText(addr.clone()))
|
|
.padding([4, 10])
|
|
.into(),
|
|
Space::new().width(6).into(),
|
|
button(text("edit"))
|
|
.style(btn_secondary)
|
|
.on_press(Message::BookAdd(addr.clone()))
|
|
.padding([4, 10])
|
|
.into(),
|
|
Space::new().width(6).into(),
|
|
button(text("del"))
|
|
.style(btn_danger)
|
|
.on_press(Message::BookDelete(addr.clone()))
|
|
.padding([4, 10])
|
|
.into(),
|
|
])
|
|
.align_y(alignment::Vertical::Center)
|
|
.into(),
|
|
);
|
|
}
|
|
|
|
items.push(Space::new().height(10).into());
|
|
items.push(
|
|
button(text("+ Add entry"))
|
|
.style(btn_secondary)
|
|
.on_press(Message::BookAdd(String::new()))
|
|
.padding([6, 14])
|
|
.into(),
|
|
);
|
|
|
|
scrollable(Column::with_children(items).spacing(8).width(Length::Fill)).style(scroll_style)
|
|
.width(Length::Fill)
|
|
.height(Length::Fill)
|
|
.into()
|
|
}
|
|
|
|
fn view_prompt<'a>(state: &'a App, p: &'a Prompt) -> Element<'a, Message> {
|
|
let s = state.session.as_ref().expect("view_prompt without session");
|
|
let actions = if s.responding {
|
|
Row::with_children(vec![text("Submitting…").size(14).into()])
|
|
} else {
|
|
Row::with_children(vec![
|
|
button(text("Approve"))
|
|
.style(btn_success)
|
|
.on_press(Message::Approve)
|
|
.padding(10)
|
|
.into(),
|
|
Space::new().width(8).into(),
|
|
button(text("Deny"))
|
|
.style(btn_danger)
|
|
.on_press(Message::Deny)
|
|
.padding(10)
|
|
.into(),
|
|
])
|
|
};
|
|
|
|
let sender_addr = p.req.sender.to_string();
|
|
let mut from_children: Vec<Element<Message>> =
|
|
vec![text("from").size(13).color(label_color()).into()];
|
|
from_children.push(Space::new().width(4).into());
|
|
from_children.extend(addr_line(&state.book, sender_addr, 13.0));
|
|
let from_line: Element<Message> =
|
|
Row::with_children(from_children).align_y(alignment::Vertical::Center).into();
|
|
|
|
let body = Column::with_children(vec![
|
|
text("Approval requested").size(18).color(c(COL_ACCENT)).into(),
|
|
from_line,
|
|
text(p.req.request.action.clone()).size(16).into(),
|
|
text(p.req.request.message.clone()).size(14).into(),
|
|
text(expires_in(p)).size(12).color(label_color()).into(),
|
|
Space::new().height(4).into(),
|
|
actions.into(),
|
|
])
|
|
.spacing(8);
|
|
|
|
Container::new(body)
|
|
.width(Length::Fill)
|
|
.padding(18)
|
|
.style(|_t| container::Style {
|
|
background: Some(c(COL_SURFACE).into()),
|
|
border: border(Color::from_rgba(0.60, 0.27, 1.0, 0.55), 1.2, 16.0),
|
|
..Default::default()
|
|
})
|
|
.into()
|
|
}
|
|
|
|
// ---------------------------------------------------------------- theme
|
|
//
|
|
// Dark "crypto" look: deep blue-black surfaces, violet accent, mint green for
|
|
// confirmations. All widget styles are explicit, so the look is stable.
|
|
|
|
fn c(hex: u32) -> Color {
|
|
Color::from_rgb8((hex >> 16) as u8, ((hex >> 8) & 0xff) as u8, (hex & 0xff) as u8)
|
|
}
|
|
|
|
const COL_BG: u32 = 0x0A0C12; // app background
|
|
const COL_SURFACE: u32 = 0x12151D; // cards
|
|
const COL_FIELD: u32 = 0x0E1118; // inputs
|
|
const COL_BORDER: u32 = 0x242A38;
|
|
const COL_TEXT: u32 = 0xE8EBF2;
|
|
const COL_MUTED: u32 = 0x8B93A7;
|
|
const COL_ACCENT: u32 = 0x9945FF; // violet (Solana-ish)
|
|
const COL_GREEN: u32 = 0x14F195; // mint (Solana-ish)
|
|
const COL_RED: u32 = 0xE5484D;
|
|
const COL_AMBER: u32 = 0xFFC24B;
|
|
|
|
fn border(color: Color, width: f32, radius: f32) -> border::Border {
|
|
border::Border { color, width, radius: radius.into() }
|
|
}
|
|
|
|
/// The application theme handed to iced (covers widgets without custom styles).
|
|
fn app_theme() -> Theme {
|
|
Theme::custom(
|
|
"niko-dark",
|
|
iced::theme::Palette {
|
|
background: c(COL_BG),
|
|
text: c(COL_TEXT),
|
|
primary: c(COL_ACCENT),
|
|
success: c(COL_GREEN),
|
|
warning: c(COL_AMBER),
|
|
danger: c(COL_RED),
|
|
},
|
|
)
|
|
}
|
|
|
|
/// `.theme` wants a plain function of the app state.
|
|
fn theme_of(_state: &App) -> Theme {
|
|
app_theme()
|
|
}
|
|
|
|
fn btn_primary(_t: &Theme, s: button::Status) -> button::Style {
|
|
let bg = match s {
|
|
button::Status::Active => c(COL_ACCENT),
|
|
button::Status::Hovered => c(0x8A3DF0),
|
|
button::Status::Pressed => c(0x7A34D9),
|
|
button::Status::Disabled => c(0x251E3A),
|
|
};
|
|
let txt = if matches!(s, button::Status::Disabled) { c(0x6B6580) } else { c(0xFFFFFF) };
|
|
button::Style {
|
|
background: Some(bg.into()),
|
|
text_color: txt,
|
|
border: border(c(COL_ACCENT), 0.0, 10.0),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn btn_secondary(_t: &Theme, s: button::Status) -> button::Style {
|
|
let hovered = matches!(s, button::Status::Hovered);
|
|
let pressed = matches!(s, button::Status::Pressed);
|
|
button::Style {
|
|
background: Some(c(if pressed { 0x1B2029 } else if hovered { 0x181D26 } else { COL_SURFACE }).into()),
|
|
text_color: c(if pressed || hovered { COL_TEXT } else { 0xC6CCDA }),
|
|
border: border(c(if hovered { 0x39415A } else { COL_BORDER }), 1.0, 10.0),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn btn_success(_t: &Theme, s: button::Status) -> button::Style {
|
|
let bg = match s {
|
|
button::Status::Active => c(COL_GREEN),
|
|
button::Status::Hovered => c(0x3BF5A7),
|
|
button::Status::Pressed => c(0x0ED183),
|
|
button::Status::Disabled => c(0x143328),
|
|
};
|
|
let txt = if matches!(s, button::Status::Disabled) { c(0x4E7A66) } else { c(0x05130C) };
|
|
button::Style {
|
|
background: Some(bg.into()),
|
|
text_color: txt,
|
|
border: border(c(COL_GREEN), 0.0, 10.0),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn btn_danger(_t: &Theme, s: button::Status) -> button::Style {
|
|
let bg = match s {
|
|
button::Status::Active => c(COL_RED),
|
|
button::Status::Hovered => c(0xF2555A),
|
|
button::Status::Pressed => c(0xC93B40),
|
|
button::Status::Disabled => c(0x33191B),
|
|
};
|
|
let txt = if matches!(s, button::Status::Disabled) { c(0x8A5558) } else { c(0xFFFFFF) };
|
|
button::Style {
|
|
background: Some(bg.into()),
|
|
text_color: txt,
|
|
border: border(c(COL_RED), 0.0, 10.0),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn btn_text(_t: &Theme, s: button::Status) -> button::Style {
|
|
button::Style {
|
|
background: None,
|
|
text_color: c(match s {
|
|
button::Status::Hovered | button::Status::Pressed => COL_TEXT,
|
|
button::Status::Disabled => 0x5A6070,
|
|
_ => COL_MUTED,
|
|
}),
|
|
border: border(Color::TRANSPARENT, 0.0, 8.0),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn card_style(_theme: &Theme) -> container::Style {
|
|
container::Style {
|
|
background: Some(c(COL_SURFACE).into()),
|
|
border: border(c(COL_BORDER), 1.0, 14.0),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn input_style(_t: &Theme, s: text_input::Status) -> text_input::Style {
|
|
let bd = match s {
|
|
text_input::Status::Focused { .. } => border(c(COL_ACCENT), 1.4, 10.0),
|
|
text_input::Status::Hovered => border(c(0x333B50), 1.0, 10.0),
|
|
_ => border(c(COL_BORDER), 1.0, 10.0),
|
|
};
|
|
text_input::Style {
|
|
background: c(COL_FIELD).into(),
|
|
border: bd,
|
|
icon: c(COL_MUTED),
|
|
placeholder: c(0x5F6779),
|
|
value: c(COL_TEXT),
|
|
selection: Color::from_rgba(0.60, 0.27, 1.0, 0.35),
|
|
}
|
|
}
|
|
|
|
fn scroll_style(_t: &Theme, _s: scrollable::Status) -> scrollable::Style {
|
|
let rail = |scroller: Color| scrollable::Rail {
|
|
background: None,
|
|
border: border(Color::TRANSPARENT, 0.0, 4.0),
|
|
scroller: scrollable::Scroller {
|
|
background: scroller.into(),
|
|
border: border(Color::TRANSPARENT, 0.0, 4.0),
|
|
},
|
|
};
|
|
scrollable::Style {
|
|
container: container::Style::default(),
|
|
vertical_rail: rail(c(0x2B3242)),
|
|
horizontal_rail: rail(c(0x2B3242)),
|
|
gap: None,
|
|
auto_scroll: scrollable::AutoScroll {
|
|
background: c(0x1B2029).into(),
|
|
border: border(c(COL_BORDER), 1.0, 8.0),
|
|
shadow: iced::Shadow::default(),
|
|
icon: c(COL_TEXT),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn checkbox_style(_t: &Theme, s: checkbox::Status) -> checkbox::Style {
|
|
let checked = match s {
|
|
checkbox::Status::Active { is_checked } => is_checked,
|
|
checkbox::Status::Hovered { is_checked } => is_checked,
|
|
checkbox::Status::Disabled { is_checked } => is_checked,
|
|
};
|
|
let hovered = matches!(s, checkbox::Status::Hovered { .. });
|
|
checkbox::Style {
|
|
background: c(if checked { COL_ACCENT } else { COL_FIELD }).into(),
|
|
icon_color: c(0xFFFFFF),
|
|
border: border(
|
|
c(if checked { COL_ACCENT } else if hovered { 0x39415A } else { COL_BORDER }),
|
|
1.2,
|
|
6.0,
|
|
),
|
|
text_color: None,
|
|
}
|
|
}
|
|
|
|
fn label_color() -> Color {
|
|
c(COL_MUTED)
|
|
}
|
|
|
|
fn error_color() -> Color {
|
|
c(COL_RED)
|
|
}
|
|
|
|
fn view_history<'a>(state: &'a App) -> Element<'a, Message> {
|
|
let s = state.session.as_ref().expect("view_history without session");
|
|
let items = s.inbox.history();
|
|
if items.is_empty() {
|
|
return Container::new(
|
|
text("No requests yet.").size(13).color(c(0x6B7280)),
|
|
)
|
|
.width(Length::Fill)
|
|
.padding(14)
|
|
.style(|_t| container::Style {
|
|
background: Some(c(COL_SURFACE).into()),
|
|
border: border(c(COL_BORDER), 1.0, 10.0),
|
|
..Default::default()
|
|
})
|
|
.into();
|
|
}
|
|
let now = now_secs();
|
|
let rows: Vec<Element<Message>> = items
|
|
.iter()
|
|
.map(|h| {
|
|
let sender_addr = h.sender.to_string();
|
|
|
|
Row::with_children(vec![
|
|
dot(outcome_dot(h.outcome)),
|
|
Space::new().width(10).into(),
|
|
Column::with_children(vec![
|
|
Row::with_children(vec![
|
|
text(h.action.clone()).size(13).color(c(COL_TEXT)).into(),
|
|
text(format!(" · {}", outcome_label(h.outcome)))
|
|
.size(11)
|
|
.color(outcome_color(h.outcome))
|
|
.into(),
|
|
])
|
|
.align_y(alignment::Vertical::Center)
|
|
.into(),
|
|
Row::with_children({
|
|
let mut ch: Vec<Element<Message>> =
|
|
vec![text("from").size(11).color(c(0x6B7280)).into()];
|
|
ch.push(Space::new().width(3).into());
|
|
ch.extend(addr_line(&state.book, sender_addr, 11.0));
|
|
ch
|
|
})
|
|
.align_y(alignment::Vertical::Center)
|
|
.into(),
|
|
])
|
|
.spacing(2)
|
|
.width(Length::Fill)
|
|
.into(),
|
|
Space::new().width(8).into(),
|
|
text(rel_time(now.saturating_sub(h.created_at)))
|
|
.size(11)
|
|
.color(c(0x6B7280))
|
|
.into(),
|
|
])
|
|
.align_y(alignment::Vertical::Center)
|
|
.into()
|
|
})
|
|
.map(|row: Element<Message>| {
|
|
Container::new(row)
|
|
.width(Length::Fill)
|
|
.padding([10, 12])
|
|
.style(|_t| container::Style {
|
|
background: Some(c(COL_SURFACE).into()),
|
|
border: border(c(COL_BORDER), 1.0, 10.0),
|
|
..Default::default()
|
|
})
|
|
.into()
|
|
})
|
|
.collect();
|
|
Column::with_children(rows).spacing(6).into()
|
|
}
|
|
|
|
/// Dot color per outcome (slightly deeper than the label colors for contrast).
|
|
fn outcome_dot(o: Outcome) -> u32 {
|
|
match o {
|
|
Outcome::Pending => COL_AMBER,
|
|
Outcome::Approved => COL_GREEN,
|
|
Outcome::Denied => COL_RED,
|
|
Outcome::TimedOut | Outcome::Stale => 0x4B5263,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- helpers
|
|
|
|
fn now_secs() -> u64 {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
/// `seconds` as a compact human duration: "42s", "5m 3s", "2h 7m".
|
|
fn rel_time(secs: u64) -> String {
|
|
if secs < 60 {
|
|
format!("{secs}s")
|
|
} else if secs < 3600 {
|
|
format!("{}m {}s", secs / 60, secs % 60)
|
|
} else {
|
|
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
|
|
}
|
|
}
|
|
|
|
fn expires_in(p: &Prompt) -> String {
|
|
let now = now_secs();
|
|
if p.req.request.expires_at > now {
|
|
format!("expires in {}", rel_time(p.req.request.expires_at - now))
|
|
} else {
|
|
"expired".to_string()
|
|
}
|
|
}
|
|
|
|
fn outcome_color(o: Outcome) -> Color {
|
|
match o {
|
|
Outcome::Pending => c(COL_AMBER),
|
|
Outcome::Approved => c(COL_GREEN),
|
|
Outcome::Denied => c(COL_RED),
|
|
Outcome::TimedOut | Outcome::Stale => c(0x6B7280),
|
|
}
|
|
}
|
|
} // mod gui
|
|
|
|
fn main() {
|
|
#[cfg(feature = "gui")]
|
|
gui::run().unwrap_or_else(|e| {
|
|
eprintln!("niko-trust-gui: {e}");
|
|
std::process::exit(1);
|
|
});
|
|
#[cfg(not(feature = "gui"))]
|
|
{
|
|
eprintln!("niko-trust-gui: built without the \"gui\" feature");
|
|
std::process::exit(1);
|
|
}
|
|
}
|