diff --git a/crates/wallet-app/src/commands.rs b/crates/wallet-app/src/commands.rs index bbc9d7d..0697b0e 100644 --- a/crates/wallet-app/src/commands.rs +++ b/crates/wallet-app/src/commands.rs @@ -10,6 +10,7 @@ use std::time::Instant; use wallet_core::{ChainAdapter, SignedBy, TransferIntent, TxStatus}; use wallet_data::substrate::SubstrateAdapter; +use wallet_entities::Settings; use tauri::State; use wallet_core::backup::BackupChallenge; @@ -646,3 +647,40 @@ pub async fn reversible_pending( let adapter = adapter_for(&state, &chain)?; adapter.pending_reversible(me).await.map_err(chain_error) } + +#[tauri::command] +pub fn settings_get(state: State<'_, AppState>) -> Result { + state + .settings + .load() + .map_err(|e| WalletError::Internal(e.to_string())) +} + +/// Persist settings and apply the ones that take effect immediately. A +/// network the registry does not know is refused; an idle lock under +/// thirty seconds is refused, since it would lock mid-signature. +#[tauri::command] +pub fn settings_set( + state: State<'_, AppState>, + settings: Settings, +) -> Result { + if state.profiles.get(&settings.network).is_none() { + return Err(WalletError::InvalidInput(format!( + "unknown network {}", + settings.network.0 + ))); + } + if settings.idle_lock_seconds < 30 { + return Err(WalletError::InvalidInput( + "the idle lock must be at least 30 seconds".into(), + )); + } + state + .settings + .save(&settings) + .map_err(|e| WalletError::Internal(e.to_string()))?; + state + .session + .set_idle_interval(std::time::Duration::from_secs(settings.idle_lock_seconds)); + Ok(settings) +} diff --git a/crates/wallet-app/src/lib.rs b/crates/wallet-app/src/lib.rs index 4513738..c48606c 100644 --- a/crates/wallet-app/src/lib.rs +++ b/crates/wallet-app/src/lib.rs @@ -57,6 +57,8 @@ pub fn run() { commands::transfer_discard, commands::reversible_cancel_prepare, commands::reversible_pending, + commands::settings_get, + commands::settings_set, ]) .run(tauri::generate_context!()) .expect("the Tauri runtime failed to start"); diff --git a/crates/wallet-app/src/state.rs b/crates/wallet-app/src/state.rs index 2b042ea..9113a61 100644 --- a/crates/wallet-app/src/state.rs +++ b/crates/wallet-app/src/state.rs @@ -12,6 +12,7 @@ use wallet_core::backup::BackupChallenge; use wallet_core::profiles::ProfileRegistry; use wallet_core::session::Session; use wallet_data::keystore::FileKeystore; +use wallet_data::settings::SettingsStore; use wallet_data::substrate::ChainManager; use wallet_entities::ChainId; use wallet_entities::SignatureScheme; @@ -25,9 +26,6 @@ pub struct PendingCreation { pub scheme: SignatureScheme, } -/// Until the settings store exists (#25), the idle interval is fixed. -pub const DEFAULT_IDLE_LOCK: Duration = Duration::from_secs(10 * 60); - pub struct AppState { pub keystore: FileKeystore, pub pending_creation: Mutex>, @@ -44,6 +42,7 @@ pub struct AppState { /// A submit consumes one; a new prepare for the same purpose replaces /// nothing, it just adds, and stale ones are refused by the chain. pub prepared: Mutex>, + pub settings: SettingsStore, } impl AppState { @@ -65,6 +64,12 @@ impl AppState { None => FileKeystore::default_dir() .map(|d| d.parent().map(|p| p.join("profiles.toml")).unwrap_or(d)), }; + let settings = SettingsStore::at( + FileKeystore::default_dir() + .and_then(|d| d.parent().map(|p| p.join("settings.json"))) + .unwrap_or_else(|| std::path::PathBuf::from("settings.json")), + ); + let loaded = settings.load()?; let mut profiles = ProfileRegistry::shipped()?; if let Some(path) = override_path { if path.exists() { @@ -76,11 +81,12 @@ impl AppState { Ok(Self { keystore: FileKeystore::in_dir(dir), pending_creation: Mutex::new(None), - session: Session::new(DEFAULT_IDLE_LOCK), + session: Session::new(Duration::from_secs(loaded.idle_lock_seconds.max(30))), profiles, chains: Mutex::new(HashMap::new()), balance_watchers: Mutex::new(HashMap::new()), prepared: Mutex::new(HashMap::new()), + settings, }) } } diff --git a/crates/wallet-core/src/session.rs b/crates/wallet-core/src/session.rs index fa1f929..657e58f 100644 --- a/crates/wallet-core/src/session.rs +++ b/crates/wallet-core/src/session.rs @@ -51,20 +51,25 @@ pub enum SessionError { } pub struct Session { - idle: Duration, + idle: Mutex, inner: Mutex>, } impl Session { pub fn new(idle: Duration) -> Self { Self { - idle, + idle: Mutex::new(idle), inner: Mutex::new(None), } } pub fn idle_interval(&self) -> Duration { - self.idle + *self.idle.lock().unwrap_or_else(|p| p.into_inner()) + } + + /// Change the idle interval; applies from the next check. + pub fn set_idle_interval(&self, idle: Duration) { + *self.idle.lock().unwrap_or_else(|p| p.into_inner()) = idle; } /// Open a wallet. Anything previously open is dropped first. The @@ -99,7 +104,7 @@ impl Session { .as_ref() .map(|u| now.saturating_duration_since(u.last_used)); match idle_for { - Some(d) if d >= self.idle => { + Some(d) if d >= self.idle_interval() => { *guard = None; Ok(true) } @@ -131,7 +136,7 @@ impl Session { }) .collect(), locks_in: Some( - self.idle + self.idle_interval() .saturating_sub(now.saturating_duration_since(u.last_used)) .as_secs(), ), diff --git a/crates/wallet-data/src/lib.rs b/crates/wallet-data/src/lib.rs index be3dba1..f8c7555 100644 --- a/crates/wallet-data/src/lib.rs +++ b/crates/wallet-data/src/lib.rs @@ -7,4 +7,5 @@ pub mod keys; pub mod keystore; pub mod mnemonic; +pub mod settings; pub mod substrate; diff --git a/crates/wallet-data/src/settings.rs b/crates/wallet-data/src/settings.rs new file mode 100644 index 0000000..db27203 --- /dev/null +++ b/crates/wallet-data/src/settings.rs @@ -0,0 +1,91 @@ +//! Settings on disk: a small JSON file in the app-data directory, written +//! atomically. Nothing secret lives here; a stranger reading it learns which +//! network the person uses and how long the wallet stays unlocked. + +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use wallet_entities::Settings; + +#[derive(Debug, thiserror::Error)] +pub enum SettingsError { + #[error("settings file: {0}")] + Io(String), + #[error("settings file is not valid: {0}")] + Format(String), +} + +#[derive(Debug, Clone)] +pub struct SettingsStore { + path: PathBuf, +} + +impl SettingsStore { + pub fn at(path: impl Into) -> Self { + Self { path: path.into() } + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// Defaults when the file is missing; an unreadable or malformed file + /// is an error rather than silently reset, because losing a setting is + /// a thing a person should hear about. + pub fn load(&self) -> Result { + match fs::read(&self.path) { + Ok(bytes) => { + serde_json::from_slice(&bytes).map_err(|e| SettingsError::Format(e.to_string())) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Settings::default()), + Err(e) => Err(SettingsError::Io(format!("{}: {e}", self.path.display()))), + } + } + + pub fn save(&self, settings: &Settings) -> Result<(), SettingsError> { + if let Some(dir) = self.path.parent() { + fs::create_dir_all(dir) + .map_err(|e| SettingsError::Io(format!("{}: {e}", dir.display())))?; + } + let text = serde_json::to_vec_pretty(settings) + .map_err(|e| SettingsError::Format(e.to_string()))?; + let dir = self.path.parent().unwrap_or(Path::new(".")); + let mut tmp = tempfile::Builder::new() + .prefix(".settings-") + .suffix(".tmp") + .tempfile_in(dir) + .map_err(|e| SettingsError::Io(e.to_string()))?; + tmp.write_all(&text) + .map_err(|e| SettingsError::Io(e.to_string()))?; + tmp.as_file() + .sync_all() + .map_err(|e| SettingsError::Io(e.to_string()))?; + tmp.persist(&self.path) + .map_err(|e| SettingsError::Io(format!("{}: {}", self.path.display(), e.error)))?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wallet_entities::ChainId; + + #[test] + fn defaults_then_round_trips_then_refuses_garbage() { + let dir = tempfile::tempdir().unwrap(); + let store = SettingsStore::at(dir.path().join("settings.json")); + let s = store.load().unwrap(); + assert_eq!(s, Settings::default()); + let changed = Settings { + network: ChainId("planck".into()), + idle_lock_seconds: 120, + show_testnets: true, + }; + store.save(&changed).unwrap(); + assert_eq!(store.load().unwrap(), changed); + fs::write(store.path(), b"{ not json").unwrap(); + assert!(matches!(store.load(), Err(SettingsError::Format(_)))); + } +} diff --git a/crates/wallet-entities/src/lib.rs b/crates/wallet-entities/src/lib.rs index c66c10d..71c97da 100644 --- a/crates/wallet-entities/src/lib.rs +++ b/crates/wallet-entities/src/lib.rs @@ -6,6 +6,11 @@ //! chain is a [`ChainId`] and a profile somewhere in `wallet-core`; an asset is //! a [`AssetId`]; a quantity is an [`Amount`] of integer base units. Floats do //! not appear, and never will: a wallet that rounds is a wallet that lies. +//! +//! Integers wider than 32 bits are typed `number` on the TypeScript side: +//! they cross the IPC as JSON numbers, which `bigint` cannot be serialised +//! to, and none of them (block heights, seconds, nonces) approaches 2^53. +//! Amounts are the exception and travel as strings. use serde::{Deserialize, Serialize}; use ts_rs::TS; @@ -68,9 +73,12 @@ pub struct ChainStatus { pub endpoint: Option, pub spec_version: Option, pub transaction_version: Option, + #[ts(type = "number")] pub best_number: Option, + #[ts(type = "number")] pub finalized_number: Option, /// Seconds since the last best head arrived; `None` when never. + #[ts(type = "number")] pub head_age_seconds: Option, /// The last error, when disconnected. pub last_error: Option, @@ -83,7 +91,9 @@ pub struct ChainStatus { #[ts(export)] pub struct HighSecurityInfo { pub guardian: String, + #[ts(type = "number")] pub delay_blocks: Option, + #[ts(type = "number")] pub delay_ms: Option, } @@ -101,12 +111,15 @@ pub struct AccountBalance { pub spendable: Amount, /// Free plus reserved. pub total: Amount, + #[ts(type = "number")] pub nonce: u64, pub high_security: Option, /// The best block the numbers were read at. + #[ts(type = "number")] pub at_block: u64, /// The chain's finalized height at the time, so the UI can say how /// many confirmations deep `at_block` is. + #[ts(type = "number")] pub finalized_block: Option, } @@ -139,7 +152,9 @@ pub struct PendingReversible { pub amount: Amount, /// When it executes, as the scheduler records it: a block number, or a /// timestamp in milliseconds, whichever the delay was given in. + #[ts(type = "number")] pub execute_at_block: Option, + #[ts(type = "number")] pub execute_at_ms: Option, /// Whether the open wallet's account may cancel it (sender for a /// one-time delay, guardian for a high-security account). @@ -156,8 +171,10 @@ pub struct PreparedTransferInfo { pub from: String, pub summary: CallSummary, pub fee: Amount, + #[ts(type = "number")] pub nonce: u64, /// Blocks the transaction stays valid for from the block it was built at. + #[ts(type = "number")] pub valid_for_blocks: u64, } @@ -358,6 +375,7 @@ pub struct WalletSummary { pub address: String, pub kind: WalletKind, /// Unix seconds; the file's `created_at`. + #[ts(type = "number")] pub created_at: i64, } @@ -395,9 +413,34 @@ pub struct SessionStatusInfo { pub locked: bool, pub wallet: Option, pub accounts: Vec, + #[ts(type = "number")] pub locks_in_seconds: Option, } +/// Persisted preferences. Nothing here is secret. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(default)] +#[ts(export)] +pub struct Settings { + /// The chain the UI is showing. + pub network: ChainId, + /// Idle time before the session locks itself. + #[ts(type = "number")] + pub idle_lock_seconds: u64, + /// Whether testnet profiles are offered in the switcher. + pub show_testnets: bool, +} + +impl Default for Settings { + fn default() -> Self { + Self { + network: ChainId("quantus".into()), + idle_lock_seconds: 10 * 60, + show_testnets: false, + } + } +} + /// What the app tells the UI about itself at startup. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] #[ts(export)] diff --git a/ui/src/App.tsx b/ui/src/App.tsx index c6a62d9..9756506 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,16 +1,35 @@ -import { useAppInfo } from './api/hooks' +import { HashRouter, Route, Routes } from 'react-router-dom' +import { useSession, useSessionLockedListener } from './api/hooks' +import { LockScreen } from './components/LockScreen' +import { Sidebar } from './components/Sidebar' +import { StatusBar } from './components/StatusBar' +import { Accounts } from './routes/Accounts' +import { Placeholder } from './routes/Placeholder' +import { SettingsRoute } from './routes/Settings' // The webview renders; it never holds a key, decodes a transaction, or does // arithmetic on an amount. Everything it shows came from a Rust command. export default function App() { - const info = useAppInfo() - + useSessionLockedListener() + const session = useSession() + if (session.isPending) return
starting…
+ if (session.isError) return
{session.error.message}
+ if (session.data.locked) return return ( -
-

blackbeard wallet

- {info.isPending &&

starting…

} - {info.isError &&

{info.error.message}

} - {info.isSuccess &&

v{info.data.version}

} -
+ +
+ +
+ + } /> + } /> + } /> + } /> + } /> + +
+ +
+
) } diff --git a/ui/src/api/events.ts b/ui/src/api/events.ts new file mode 100644 index 0000000..63ca92e --- /dev/null +++ b/ui/src/api/events.ts @@ -0,0 +1,20 @@ +// Events Rust emits. One subscription helper per event so a screen never +// spells an event name itself. + +import { listen, type UnlistenFn } from '@tauri-apps/api/event' +import type { AccountBalance } from './generated/AccountBalance' +import type { TxStatusInfo } from './generated/TxStatusInfo' + +export function onSessionLocked(handler: () => void): Promise { + return listen('session:locked', () => handler()) +} + +export function onBalancesUpdated( + handler: (balances: AccountBalance[]) => void, +): Promise { + return listen('balances:updated', (e) => handler(e.payload)) +} + +export function onTxStatus(handler: (status: TxStatusInfo) => void): Promise { + return listen('tx:status', (e) => handler(e.payload)) +} diff --git a/ui/src/api/generated/AccountBalance.ts b/ui/src/api/generated/AccountBalance.ts index ae6eb1a..b4d9ad7 100644 --- a/ui/src/api/generated/AccountBalance.ts +++ b/ui/src/api/generated/AccountBalance.ts @@ -14,13 +14,13 @@ spendable: Amount, /** * Free plus reserved. */ -total: Amount, nonce: bigint, high_security: HighSecurityInfo | null, +total: Amount, nonce: number, high_security: HighSecurityInfo | null, /** * The best block the numbers were read at. */ -at_block: bigint, +at_block: number, /** * The chain's finalized height at the time, so the UI can say how * many confirmations deep `at_block` is. */ -finalized_block: bigint | null, }; +finalized_block: number, }; diff --git a/ui/src/api/generated/ChainStatus.ts b/ui/src/api/generated/ChainStatus.ts index 17c9eb7..031c69d 100644 --- a/ui/src/api/generated/ChainStatus.ts +++ b/ui/src/api/generated/ChainStatus.ts @@ -9,11 +9,11 @@ export type ChainStatus = { chain: ChainId, connected: boolean, /** * The endpoint currently in use, or the last one tried. */ -endpoint: string | null, spec_version: number | null, transaction_version: number | null, best_number: bigint | null, finalized_number: bigint | null, +endpoint: string | null, spec_version: number | null, transaction_version: number | null, best_number: number, finalized_number: number, /** * Seconds since the last best head arrived; `None` when never. */ -head_age_seconds: bigint | null, +head_age_seconds: number, /** * The last error, when disconnected. */ diff --git a/ui/src/api/generated/HighSecurityInfo.ts b/ui/src/api/generated/HighSecurityInfo.ts index fa94918..7d5930f 100644 --- a/ui/src/api/generated/HighSecurityInfo.ts +++ b/ui/src/api/generated/HighSecurityInfo.ts @@ -3,4 +3,4 @@ /** * A high-security account's guardian and cancellation delay. */ -export type HighSecurityInfo = { guardian: string, delay_blocks: bigint | null, delay_ms: bigint | null, }; +export type HighSecurityInfo = { guardian: string, delay_blocks: number, delay_ms: number, }; diff --git a/ui/src/api/generated/PendingReversible.ts b/ui/src/api/generated/PendingReversible.ts index a67bcbc..3c3cda4 100644 --- a/ui/src/api/generated/PendingReversible.ts +++ b/ui/src/api/generated/PendingReversible.ts @@ -9,7 +9,7 @@ export type PendingReversible = { tx_id: string, from: string, to: string, guard * When it executes, as the scheduler records it: a block number, or a * timestamp in milliseconds, whichever the delay was given in. */ -execute_at_block: bigint | null, execute_at_ms: bigint | null, +execute_at_block: number, execute_at_ms: number, /** * Whether the open wallet's account may cancel it (sender for a * one-time delay, guardian for a high-security account). diff --git a/ui/src/api/generated/PreparedTransferInfo.ts b/ui/src/api/generated/PreparedTransferInfo.ts index ff2f0e6..86c106b 100644 --- a/ui/src/api/generated/PreparedTransferInfo.ts +++ b/ui/src/api/generated/PreparedTransferInfo.ts @@ -7,8 +7,8 @@ import type { ChainId } from "./ChainId"; * A transaction prepared for review: what will be signed, decoded from * the bytes, and what it costs. `id` is handed back to submit it. */ -export type PreparedTransferInfo = { id: string, chain: ChainId, from: string, summary: CallSummary, fee: Amount, nonce: bigint, +export type PreparedTransferInfo = { id: string, chain: ChainId, from: string, summary: CallSummary, fee: Amount, nonce: number, /** * Blocks the transaction stays valid for from the block it was built at. */ -valid_for_blocks: bigint, }; +valid_for_blocks: number, }; diff --git a/ui/src/api/generated/SessionStatusInfo.ts b/ui/src/api/generated/SessionStatusInfo.ts index 0f8f59c..f62a6f9 100644 --- a/ui/src/api/generated/SessionStatusInfo.ts +++ b/ui/src/api/generated/SessionStatusInfo.ts @@ -6,4 +6,4 @@ import type { WalletSummary } from "./WalletSummary"; * Whether a wallet is open, which one, and how long until the idle rule * locks it again. */ -export type SessionStatusInfo = { locked: boolean, wallet: WalletSummary | null, accounts: Array, locks_in_seconds: bigint | null, }; +export type SessionStatusInfo = { locked: boolean, wallet: WalletSummary | null, accounts: Array, locks_in_seconds: number, }; diff --git a/ui/src/api/generated/Settings.ts b/ui/src/api/generated/Settings.ts new file mode 100644 index 0000000..f49e722 --- /dev/null +++ b/ui/src/api/generated/Settings.ts @@ -0,0 +1,19 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ChainId } from "./ChainId"; + +/** + * Persisted preferences. Nothing here is secret. + */ +export type Settings = { +/** + * The chain the UI is showing. + */ +network: ChainId, +/** + * Idle time before the session locks itself. + */ +idle_lock_seconds: number, +/** + * Whether testnet profiles are offered in the switcher. + */ +show_testnets: boolean, }; diff --git a/ui/src/api/generated/WalletSummary.ts b/ui/src/api/generated/WalletSummary.ts index fbadb5d..ed66399 100644 --- a/ui/src/api/generated/WalletSummary.ts +++ b/ui/src/api/generated/WalletSummary.ts @@ -8,4 +8,4 @@ export type WalletSummary = { name: string, address: string, kind: WalletKind, /** * Unix seconds; the file's `created_at`. */ -created_at: bigint, }; +created_at: number, }; diff --git a/ui/src/api/hooks.ts b/ui/src/api/hooks.ts index 5c05e84..4e13051 100644 --- a/ui/src/api/hooks.ts +++ b/ui/src/api/hooks.ts @@ -1,13 +1,121 @@ // React Query wrappers around the command client. Server state (what Rust // knows) lives in the query cache; component state stays local. -import { useQuery } from '@tanstack/react-query' -import { appInfo } from './wallet' +import { useEffect } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import type { ChainId } from './generated/ChainId' +import type { Settings } from './generated/Settings' +import { onBalancesUpdated, onSessionLocked } from './events' +import { + appInfo, + balances, + balancesWatch, + chainConnect, + chainStatus, + chainsList, + lock, + sessionStatus, + settingsGet, + settingsSet, + unlock, + walletsList, +} from './wallet' export const queryKeys = { appInfo: ['app-info'] as const, + settings: ['settings'] as const, + chains: ['chains'] as const, + session: ['session'] as const, + wallets: ['wallets'] as const, + chainStatus: (chain: ChainId) => ['chain-status', chain] as const, + balances: (chain: ChainId) => ['balances', chain] as const, } export function useAppInfo() { return useQuery({ queryKey: queryKeys.appInfo, queryFn: appInfo, staleTime: Infinity }) } + +export function useSettings() { + return useQuery({ queryKey: queryKeys.settings, queryFn: settingsGet, staleTime: Infinity }) +} + +export function useSetSettings() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (s: Settings) => settingsSet(s), + onSuccess: (s) => qc.setQueryData(queryKeys.settings, s), + }) +} + +export function useChains() { + return useQuery({ queryKey: queryKeys.chains, queryFn: chainsList, staleTime: Infinity }) +} + +export function useWallets() { + return useQuery({ queryKey: queryKeys.wallets, queryFn: walletsList }) +} + +export function useSession() { + return useQuery({ queryKey: queryKeys.session, queryFn: sessionStatus, refetchInterval: 15_000 }) +} + +export function useUnlock() { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ name, password }: { name: string; password: string }) => unlock(name, password), + onSuccess: (s) => qc.setQueryData(queryKeys.session, s), + }) +} + +export function useLock() { + const qc = useQueryClient() + return useMutation({ + mutationFn: lock, + onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.session }), + }) +} + +/// Connect the chain (idempotent on the Rust side) and poll its status. +export function useChainStatus(chain: ChainId | undefined) { + return useQuery({ + queryKey: chain ? queryKeys.chainStatus(chain) : ['chain-status', 'none'], + queryFn: () => chainConnect(chain!).then(() => chainStatus(chain!)), + enabled: !!chain, + refetchInterval: 5_000, + }) +} + +/// Balances of the open wallet on `chain`, refreshed by Rust on every head. +export function useBalances(chain: ChainId | undefined, unlocked: boolean) { + const qc = useQueryClient() + useEffect(() => { + if (!chain || !unlocked) return + let stop: (() => void) | undefined + balancesWatch(chain).catch(() => undefined) + onBalancesUpdated((b) => { + if (b.length > 0 && b[0].chain === chain) qc.setQueryData(queryKeys.balances(chain), b) + }).then((un) => { + stop = un + }) + return () => stop?.() + }, [chain, unlocked, qc]) + return useQuery({ + queryKey: chain ? queryKeys.balances(chain) : ['balances', 'none'], + queryFn: () => balances(chain!), + enabled: !!chain && unlocked, + retry: 2, + retryDelay: 1_500, + }) +} + +/// Drop the session in the cache when Rust says the idle rule fired. +export function useSessionLockedListener() { + const qc = useQueryClient() + useEffect(() => { + let stop: (() => void) | undefined + onSessionLocked(() => qc.invalidateQueries({ queryKey: queryKeys.session })).then((un) => { + stop = un + }) + return () => stop?.() + }, [qc]) +} diff --git a/ui/src/api/wallet.ts b/ui/src/api/wallet.ts index da46216..54522b6 100644 --- a/ui/src/api/wallet.ts +++ b/ui/src/api/wallet.ts @@ -13,6 +13,7 @@ import type { PendingReversible } from './generated/PendingReversible' import type { PreparedTransferInfo } from './generated/PreparedTransferInfo' import type { CreationStart } from './generated/CreationStart' import type { SessionStatusInfo } from './generated/SessionStatusInfo' +import type { Settings } from './generated/Settings' import type { SignatureScheme } from './generated/SignatureScheme' import type { WalletSummary } from './generated/WalletSummary' import type { WalletError as WalletErrorShape } from './generated/WalletError' @@ -169,3 +170,11 @@ export function reversibleCancelPrepare( ): Promise { return command('reversible_cancel_prepare', { chain, from, txId }) } + +export function settingsGet(): Promise { + return command('settings_get') +} + +export function settingsSet(settings: Settings): Promise { + return command('settings_set', { settings }) +} diff --git a/ui/src/components/ChainCaveat.tsx b/ui/src/components/ChainCaveat.tsx new file mode 100644 index 0000000..c6f10cf --- /dev/null +++ b/ui/src/components/ChainCaveat.tsx @@ -0,0 +1,14 @@ +import { useChains, useSettings } from '../api/hooks' + +/** A one-line warning above testnet content: this is not money. */ +export function ChainCaveat() { + const settings = useSettings() + const chains = useChains() + const profile = chains.data?.find((c) => c.id === settings.data?.network) + if (!profile || profile.kind === 'mainnet') return null + return ( +

+ {profile.display_name} is a {profile.kind}: its {profile.token_symbol} has no value. +

+ ) +} diff --git a/ui/src/components/LockScreen.tsx b/ui/src/components/LockScreen.tsx new file mode 100644 index 0000000..03b9fee --- /dev/null +++ b/ui/src/components/LockScreen.tsx @@ -0,0 +1,80 @@ +import { useState, type FormEvent } from 'react' +import { useUnlock, useWallets } from '../api/hooks' + +/** + * Fronts everything while the session is locked. Picks a wallet file and + * takes a password; the password goes to Rust and nowhere else. + */ +export function LockScreen() { + const wallets = useWallets() + const unlock = useUnlock() + const [name, setName] = useState('') + const [password, setPassword] = useState('') + + const list = wallets.data ?? [] + const selected = name || list[0]?.name || '' + + function submit(e: FormEvent) { + e.preventDefault() + if (!selected) return + unlock.mutate({ name: selected, password }, { onSettled: () => setPassword('') }) + } + + return ( +
+
+
+

Unlock

+ blackbeard wallet +
+
+ {wallets.isPending &&

Reading wallets…

} + {wallets.isError &&

{wallets.error.message}

} + {wallets.isSuccess && list.length === 0 && ( +

+ No wallets yet. Creating and importing arrive with the onboarding flow; until then, + place a quantus-cli wallet file in the keystore directory. +

+ )} + {list.length > 0 && ( + <> +
+ + +
+
+ + setPassword(e.target.value)} + /> +
+ {unlock.isError &&

{unlock.error.message}

} +
+ +
+ + )} +
+
+
+ ) +} diff --git a/ui/src/components/NetworkSwitcher.tsx b/ui/src/components/NetworkSwitcher.tsx new file mode 100644 index 0000000..2022cd1 --- /dev/null +++ b/ui/src/components/NetworkSwitcher.tsx @@ -0,0 +1,36 @@ +import { useChains, useSetSettings, useSettings } from '../api/hooks' + +/** + * Mainnet and testnets are separated and labelled, as on the observer: a + * balance on a testnet is not money, and the chrome should say so before + * the number does. Testnets only appear when settings allow them. + */ +export function NetworkSwitcher() { + const chains = useChains() + const settings = useSettings() + const set = useSetSettings() + if (!chains.data || !settings.data) return null + const showTestnets = settings.data.show_testnets + const visible = chains.data.filter((c) => c.kind === 'mainnet' || showTestnets) + const current = settings.data.network + return ( +
+ + +
+ ) +} diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx new file mode 100644 index 0000000..904dda6 --- /dev/null +++ b/ui/src/components/Sidebar.tsx @@ -0,0 +1,55 @@ +import { NavLink } from 'react-router-dom' +import { useLock, useSession } from '../api/hooks' +import { NetworkSwitcher } from './NetworkSwitcher' + +const SECTIONS = [ + { to: '/', label: 'Accounts' }, + { to: '/send', label: 'Send' }, + { to: '/receive', label: 'Receive' }, + { to: '/history', label: 'History' }, + { to: '/settings', label: 'Settings' }, +] + +export function Sidebar() { + const session = useSession() + const lock = useLock() + return ( + + ) +} diff --git a/ui/src/components/StatusBar.tsx b/ui/src/components/StatusBar.tsx new file mode 100644 index 0000000..481473b --- /dev/null +++ b/ui/src/components/StatusBar.tsx @@ -0,0 +1,34 @@ +import { useChainStatus, useChains, useSettings } from '../api/hooks' + +export function StatusBar() { + const settings = useSettings() + const chains = useChains() + const status = useChainStatus(settings.data?.network) + const profile = chains.data?.find((c) => c.id === settings.data?.network) + const s = status.data + return ( +
+ + + {profile?.display_name ?? '…'} + {profile && profile.kind !== 'mainnet' && ( + <> + {' '} + {profile.kind} + + )} + + {s?.connected ? ( + <> + {s.endpoint} + + best #{s.best_number ?? '…'} · final #{s.finalized_number ?? '…'} + + spec {s.spec_version} + + ) : ( + {s?.last_error ?? 'connecting…'} + )} +
+ ) +} diff --git a/ui/src/index.css b/ui/src/index.css index fa6277c..893ebcc 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -1,12 +1,303 @@ -:root { - color-scheme: light dark; - font-family: system-ui, sans-serif; -} +@import './tokens.css'; +* { + box-sizing: border-box; +} +html, +body, +#root { + height: 100%; +} body { margin: 0; + background: radial-gradient(120% 60% at 50% -10%, var(--glow), transparent 60%), var(--surface-0); + background-attachment: fixed; + color: var(--text-primary); + font-family: var(--font-body); + font-size: 15px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + /* A desktop app, not a page: no text selection on chrome, no rubber-band. */ + overscroll-behavior: none; +} +a { + color: inherit; + text-decoration: none; +} +:focus-visible { + outline: 2px solid var(--data-bright); + outline-offset: 2px; + border-radius: 2px; +} +button, +input, +select { + font: inherit; + color: inherit; } -main { - padding: 2rem; +/* ---- type ---------------------------------------------------------------- */ +.display { + font-family: var(--font-display); + font-weight: 700; + letter-spacing: 0.02em; +} +.eyebrow { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--text-muted); +} +.numeral { + font-family: var(--font-mono); + font-variant-numeric: tabular-nums; +} +.mono { + font-family: var(--font-mono); + font-size: 13px; + overflow-wrap: anywhere; +} +.muted { + color: var(--text-muted); +} +.good { + color: var(--good); +} +.warn { + color: var(--warn); +} +.bad { + color: var(--accent); +} + +/* ---- shell --------------------------------------------------------------- */ +.shell { + display: grid; + grid-template-columns: 220px 1fr; + grid-template-rows: 1fr auto; + height: 100%; +} +.sidebar { + grid-row: 1 / span 2; + display: flex; + flex-direction: column; + gap: 18px; + padding: 22px 16px; + background: var(--surface-1); + border-right: var(--rule); + user-select: none; +} +.mark-name { + font-family: var(--font-display); + font-size: 22px; + font-weight: 700; + letter-spacing: 0.04em; + line-height: 1; +} +.mark-name span { + color: var(--data); +} +.mark-tagline { + font-size: 11px; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--text-muted); + margin-top: 6px; +} +.sections { + display: flex; + flex-direction: column; + gap: 2px; +} +.section-link { + padding: 8px 10px; + border-radius: 3px; + color: var(--text-secondary); +} +.section-link:hover { + background: var(--surface-2); + color: var(--text-primary); +} +.section-link[aria-current='page'] { + background: var(--data-wash); + color: var(--text-primary); + box-shadow: inset 2px 0 0 var(--data); +} +.sidebar-foot { + margin-top: auto; + display: flex; + flex-direction: column; + gap: 10px; +} +.main { + padding: 24px 28px 40px; + overflow: auto; +} +.statusbar { + grid-column: 2; + display: flex; + gap: 18px; + align-items: center; + padding: 6px 16px; + border-top: var(--rule); + background: var(--surface-1); + font-size: 12px; + color: var(--text-secondary); +} +.dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--text-muted); + margin-right: 6px; + vertical-align: middle; +} +.dot.on { + background: var(--good); + box-shadow: 0 0 0 3px var(--good-halo); +} +.dot.off { + background: var(--accent); +} + +/* ---- panels and forms ---------------------------------------------------- */ +.panel { + background: var(--surface-1); + border: var(--rule); + box-shadow: var(--shadow); + margin-bottom: 18px; +} +.panel-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + padding: 12px 18px; + border-bottom: var(--rule); +} +.panel-title { + font-family: var(--font-display); + font-size: 16px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + margin: 0; +} +.panel-body { + padding: 16px 18px; +} +.field { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 14px; +} +.field label { + font-size: 12px; + color: var(--text-secondary); +} +.input, +.select { + padding: 8px 10px; + background: var(--surface-0); + border: 1px solid var(--border-strong); + border-radius: 3px; + min-width: 0; +} +.input.mono { + font-size: 13px; +} +.button { + padding: 8px 14px; + border-radius: 3px; + border: 1px solid var(--border-strong); + background: var(--surface-2); + cursor: pointer; +} +.button:hover { + background: var(--surface-3); +} +.button.primary { + background: var(--data); + border-color: var(--data); + color: #14110d; + font-weight: 600; +} +.button.primary:hover { + background: var(--data-bright); +} +.button.danger { + border-color: var(--accent); + color: var(--accent); +} +.button:disabled { + opacity: 0.5; + cursor: default; +} +.row { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} +.error { + color: var(--accent); + background: var(--accent-wash); + border: 1px solid var(--accent); + padding: 8px 12px; + border-radius: 3px; + margin: 10px 0; +} +.chip { + display: inline-block; + font-size: 11px; + letter-spacing: 0.08em; + text-transform: uppercase; + padding: 2px 8px; + border-radius: 999px; + background: var(--data-chip); + color: var(--text-primary); +} +.chip.testnet { + border: 1px dashed var(--warn-border); + background: transparent; + color: var(--warn); +} + +/* ---- lock screen --------------------------------------------------------- */ +.lock { + height: 100%; + display: grid; + place-items: center; + padding: 24px; +} +.lock .panel { + width: min(440px, 100%); +} + +/* ---- tables -------------------------------------------------------------- */ +.table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} +.table th { + text-align: left; + font-weight: 600; + font-size: 11px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--text-muted); + padding: 8px 12px; + border-bottom: var(--rule); +} +.table td { + padding: 10px 12px; + border-bottom: var(--rule); + vertical-align: top; +} +.table td.num { + text-align: right; } diff --git a/ui/src/lib/format.ts b/ui/src/lib/format.ts new file mode 100644 index 0000000..d724abb --- /dev/null +++ b/ui/src/lib/format.ts @@ -0,0 +1,9 @@ +// Rust renders amounts exactly; this only inserts thousands separators in +// the whole part for reading. No arithmetic happens here. +export function formatAmount(a: { raw: string; decimals: number }): string { + const raw = a.raw.padStart(a.decimals + 1, '0') + const whole = raw.slice(0, raw.length - a.decimals) + const frac = raw.slice(raw.length - a.decimals).replace(/0+$/, '') + const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',') + return frac ? `${grouped}.${frac}` : grouped +} diff --git a/ui/src/lib/theme.ts b/ui/src/lib/theme.ts new file mode 100644 index 0000000..bea44c5 --- /dev/null +++ b/ui/src/lib/theme.ts @@ -0,0 +1,29 @@ +// Theme preference, applied before first paint from main.tsx. "auto" follows +// the system; the attribute is always a concrete value so CSS stays to one +// definition per palette. + +export type ThemePreference = 'auto' | 'dark' | 'light' +const KEY = 'blackbeard.theme' + +export function storedTheme(): ThemePreference { + try { + const v = localStorage.getItem(KEY) + return v === 'dark' || v === 'light' ? v : 'auto' + } catch { + return 'auto' + } +} + +export function applyTheme(pref: ThemePreference) { + const system = window.matchMedia?.('(prefers-color-scheme: light)').matches ? 'light' : 'dark' + document.documentElement.setAttribute('data-theme', pref === 'auto' ? system : pref) +} + +export function setTheme(pref: ThemePreference) { + try { + localStorage.setItem(KEY, pref) + } catch { + // A private window; the preference lasts the session. + } + applyTheme(pref) +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index a3caedf..e88a760 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -2,8 +2,13 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import App from './App.tsx' +import { applyTheme, storedTheme } from './lib/theme' import './index.css' +// Resolve the theme before the first paint; there is no inline script +// because the CSP allows none. +applyTheme(storedTheme()) + const queryClient = new QueryClient({ defaultOptions: { queries: { diff --git a/ui/src/routes/Accounts.tsx b/ui/src/routes/Accounts.tsx new file mode 100644 index 0000000..76c6923 --- /dev/null +++ b/ui/src/routes/Accounts.tsx @@ -0,0 +1,72 @@ +import { useBalances, useSession, useSettings } from '../api/hooks' +import { ChainCaveat } from '../components/ChainCaveat' +import { formatAmount } from '../lib/format' + +/** + * The open wallet's accounts on the selected network, with balances read + * at the best block and refreshed by Rust on every head. Confirmations are + * the distance to the finalized height. + */ +export function Accounts() { + const settings = useSettings() + const session = useSession() + const unlocked = !!session.data && !session.data.locked + const balances = useBalances(settings.data?.network, unlocked) + return ( + <> + +
+
+

Accounts

+ {balances.data?.[0] && ( + + at block {balances.data[0].at_block} + {balances.data[0].finalized_block != null && ( + <> + {' '} + ·{' '} + + {Math.max(0, balances.data[0].at_block - balances.data[0].finalized_block)} + {' '} + from final + + )} + + )} +
+ {balances.isPending &&

Reading balances from the chain…

} + {balances.isError &&

{balances.error.message}

} + {balances.isSuccess && ( + + + + + + + + + + + + {balances.data.map((b, i) => ( + + + + + + + + ))} + +
AddressSchemeSpendableTotalNotes
{b.address}{session.data?.accounts[i]?.scheme ?? ''} + {b.spendable.raw === '0' ? '0' : formatAmount(b.spendable)} {b.symbol} + + {formatAmount(b.total)} {b.symbol} + + {b.high_security ? 'high-security account: transfers are delayed' : ''} +
+ )} +
+ + ) +} diff --git a/ui/src/routes/Placeholder.tsx b/ui/src/routes/Placeholder.tsx new file mode 100644 index 0000000..09e403a --- /dev/null +++ b/ui/src/routes/Placeholder.tsx @@ -0,0 +1,10 @@ +export function Placeholder({ title, issue }: { title: string; issue: number }) { + return ( +
+
+

{title}

+
+

Arrives with issue #{issue}.

+
+ ) +} diff --git a/ui/src/routes/Settings.tsx b/ui/src/routes/Settings.tsx new file mode 100644 index 0000000..53f7904 --- /dev/null +++ b/ui/src/routes/Settings.tsx @@ -0,0 +1,67 @@ +import { useState } from 'react' +import { useAppInfo, useSetSettings, useSettings } from '../api/hooks' +import { setTheme, storedTheme, type ThemePreference } from '../lib/theme' + +export function SettingsRoute() { + const settings = useSettings() + const set = useSetSettings() + const info = useAppInfo() + const [theme, setThemeState] = useState(storedTheme()) + if (!settings.data) return null + const s = settings.data + return ( +
+
+

Settings

+ {info.data && v{info.data.version}} +
+
+
+ + + set.mutate({ + ...s, + idle_lock_seconds: Math.max(1, Number(e.target.value)) * 60, + }) + } + /> +
+
+ +
+
+ + +
+ {set.isError &&

{set.error.message}

} +
+
+ ) +} diff --git a/ui/src/tokens.css b/ui/src/tokens.css new file mode 100644 index 0000000..03049d6 --- /dev/null +++ b/ui/src/tokens.css @@ -0,0 +1,65 @@ +/* + * Design tokens, lifted from blackbeard.observer's design system so the wallet + * and the scoreboard read as one family. Two palettes, both selected rather + * than computed from each other; see the observer's index.css for the + * reasoning and the validator commands behind every value. The wallet adds + * nothing to the palette: identity is bronze, alarm is crimson, and a balance + * is a numeral in tabular figures. + */ +:root, +:root[data-theme='dark'] { + color-scheme: dark; + --surface-0: #0d0b09; + --surface-1: #14110d; + --surface-2: #1c1813; + --surface-3: #262019; + --border: #2f2719; + --border-strong: #453923; + --text-primary: #f2ece0; + --text-secondary: #a2937c; + --text-muted: #7a6c59; + --data: #bd8829; + --data-bright: #e0a63a; + --data-wash: rgba(189, 136, 41, 0.1); + --accent: #d8453a; + --accent-bright: #f05a4c; + --accent-wash: rgba(216, 69, 58, 0.1); + --good: #3f9d76; + --warn: #d9a441; + --font-display: 'Iowan Old Style', 'Palatino Linotype', Palatino, 'Book Antiqua', Georgia, serif; + --font-body: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + --font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace; + --glow: rgba(189, 136, 41, 0.08); + --good-halo: rgba(63, 157, 118, 0.18); + --data-chip: rgba(189, 136, 41, 0.18); + --warn-border: rgba(217, 164, 65, 0.4); + --failed-chip: rgba(216, 69, 58, 0.12); + --rule: 1px solid var(--border); + --shadow: 0 1px 0 rgba(255, 255, 255, 0.03) inset; +} +:root[data-theme='light'] { + color-scheme: light; + --surface-0: #fbf8f2; + --surface-1: #f5f1e8; + --surface-2: #ece7da; + --surface-3: #e2dbc9; + --border: #d9d0ba; + --border-strong: #b8ab8d; + --text-primary: #1b1710; + --text-secondary: #5b5142; + --text-muted: #7c7263; + --data: #a8741c; + --data-bright: #8a5f10; + --data-wash: rgba(168, 116, 28, 0.12); + --accent: #c3382d; + --accent-bright: #a82c20; + --accent-wash: rgba(195, 56, 45, 0.1); + --good: #0f8a58; + --warn: #b8651a; + --glow: rgba(168, 116, 28, 0.09); + --good-halo: rgba(15, 138, 88, 0.16); + --data-chip: rgba(168, 116, 28, 0.16); + --warn-border: rgba(184, 101, 26, 0.45); + --failed-chip: rgba(195, 56, 45, 0.09); + --shadow: 0 1px 0 rgba(27, 23, 16, 0.04) inset; +}