//! 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>> = 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) { 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, connecting: bool, pending_signer: Option, session: Option, /// Local address book (address -> label), persisted in the vault. book: AddressBook, /// Open "add to address book" editor: address + label being typed. book_edit: Option, /// 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, } /// An unlocked, connected session. struct Session { signer: Signer, relay: Arc, inbox: Inbox, my_address: String, queue: VecDeque, current: Option, polling: bool, responding: bool, /// A 401-triggered re-login is in flight; suppress further reauths. reconnecting: bool, status: Option, } /// 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), Connected(Result, String>), Tick, Polled(Result, 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), ToggleAutostart(bool), Approve, Deny, Responded { id: [u8; 32], decision: Decision, result: Result, }, /// 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, String>, }, /// Transparent re-login after a 401 (relay restarts drop all sessions). Reauthed(Result, String>), } // ---------------------------------------------------------------- polling fn subscription(state: &App) -> Subscription { let mut subs: Vec> = 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 { 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 { 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 { 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 { 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) -> Task { 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 { 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, String>) -> Task { 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!("
")); // Restore persisted history so it survives relay restarts. let mut inbox = Inbox::new(); let restored: Vec = 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 = s.inbox.history().into_iter().map(StoredItem::from).collect(); state.vault.set_history(items); } fn poll(state: &mut App) -> Task { 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, String>) -> Task { 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> = 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 { 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, String>) -> Task { 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, req: IncomingRequest) -> Task { 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, String>, ) -> Task { 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 { 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 { 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, ) -> Task { 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> = 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 = 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::()); } 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> = 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> = 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> = 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> { let known = book.label_of(addr).is_some(); let mut out: Vec> = 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> = 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> = 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> = 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 = 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> = 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> = 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| { 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); } }