niko_trust_gui/src/main.rs
Niko Marmeladkov 6278321873
niko_trust_gui: TCE protocol client with iced GUI
- 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)
2026-08-22 23:20:41 +03:00

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);
}
}