diff --git a/Cargo.lock b/Cargo.lock index f69ee2215a..6abb469d2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3245,6 +3245,7 @@ dependencies = [ "bitcoin-encrypted-backup", "bitcoin_hashes 0.12.0", "chrono", + "crossbeam", "dirs", "email_address", "flate2", diff --git a/contrib/release/release.sh b/contrib/release/release.sh index 57187f8daf..e18c7e7ac4 100755 --- a/contrib/release/release.sh +++ b/contrib/release/release.sh @@ -195,6 +195,9 @@ else ) fi -find "$RELEASE_DIR" -type f ! -name "$LIANA_PREFIX-shasums.txt" -exec sha256sum {} + | sed "s|$RELEASE_DIR/||" | tee "$RELEASE_DIR/$LIANA_PREFIX-shasums.txt" +find "$RELEASE_DIR" -type f ! -name "$LIANA_PREFIX-shasums.txt" -exec sha256sum {} + \ + | sed "s|$RELEASE_DIR/||" \ + | LC_ALL=C sort -k2,2 \ + | tee "$RELEASE_DIR/$LIANA_PREFIX-shasums.txt" set +ex diff --git a/liana-business/FLOW.md b/liana-business/FLOW.md index b273ca15bd..7e4ce86666 100644 --- a/liana-business/FLOW.md +++ b/liana-business/FLOW.md @@ -222,7 +222,7 @@ State::update() (state/update.rs) | | LoginResendToken, LoginSendAuthCode, Logout | +---------------------+----------------------------------------------------------+ | Account Select | AccountSelectConnect, AccountSelectDelete, | -| | AccountSelectNewEmail | +| | AccountSelectNewEmail, AccountSelectSwitchSignet | +---------------------+----------------------------------------------------------+ | Org Management | OrgSelected, OrgWalletSelected, OrgCreateNewWallet, | | | OrgSelectUpdateSearchFilter | diff --git a/liana-business/business-installer/src/debug/descriptor_registration.rs b/liana-business/business-installer/src/debug/descriptor_registration.rs new file mode 100644 index 0000000000..d497e4f03c --- /dev/null +++ b/liana-business/business-installer/src/debug/descriptor_registration.rs @@ -0,0 +1,202 @@ +//! Final wallet-descriptor registration step: `registration_view` plus +//! the per-device registration modal. + +use std::sync::OnceLock; + +use async_hwi::DeviceKind; +use iced::widget::Column; +use liana_gui::debug::{installer_chrome, installer_with_modal, DebugMessage, DebugPageEntry}; +use liana_ui::widget::Element; +use miniscript::bitcoin::bip32::Fingerprint; + +use crate::state::{ + views::registration::{RegistrationModalState, RegistrationModalStep}, + State, View, +}; +use crate::views::registration::{ + modal::registration_modal_view, registration_key_entry, registration_view_with_cards, +}; +use crate::views::registration_view; + +use super::{build_state, StateCell}; + +const REGISTRATION_PATH: &str = "business_installer::views::registration::registration_view"; +const REGISTRATION_MODAL_PATH: &str = + "business_installer::views::registration::modal::registration_modal_view"; + +pub static ENTRY_REGISTRATION: DebugPageEntry = DebugPageEntry { + view: render_registration, +}; +pub static ENTRY_REGISTRATION_WITH_DEVICES: DebugPageEntry = DebugPageEntry { + view: render_registration_with_devices, +}; +pub static ENTRY_REGISTRATION_MODAL_REGISTERING: DebugPageEntry = DebugPageEntry { + view: render_registration_modal_registering, +}; +pub static ENTRY_REGISTRATION_MODAL_CONFIRM_COLDCARD: DebugPageEntry = DebugPageEntry { + view: render_registration_modal_confirm_coldcard, +}; +pub static ENTRY_REGISTRATION_MODAL_ERROR: DebugPageEntry = DebugPageEntry { + view: render_registration_modal_error, +}; + +// ---- registration view -------------------------------------------------- + +fn shared_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| StateCell(build_state(|_| {}))).0 +} + +fn render_registration() -> Element<'static, DebugMessage> { + let body = registration_view(shared_state()).map(|_| ()); + installer_chrome("Business installer: registration", REGISTRATION_PATH, body) +} + +fn registration_with_devices_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_state(|s| { + s.current_view = View::Registration; + })) + }) + .0 +} + +fn render_registration_with_devices() -> Element<'static, DebugMessage> { + let variants: Vec> = vec![ + // kind = Some, alias filled: clickable "Register" with kind+fingerprint. + registration_key_entry( + Fingerprint::from([0xAA; 4]), + Some(DeviceKind::BitBox02), + true, + "Alice".to_string(), + ), + // kind = Some, alias empty: clickable "Register" with only kind+fingerprint. + registration_key_entry( + Fingerprint::from([0xBB; 4]), + Some(DeviceKind::Ledger), + true, + String::new(), + ), + // kind = None, connected = true, alias filled: "Device not supported or locked". + registration_key_entry( + Fingerprint::from([0xCC; 4]), + None, + true, + "Bobjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjjj".to_string(), + ), + // kind = None, connected = true, alias empty. + registration_key_entry(Fingerprint::from([0xDD; 4]), None, true, String::new()), + // kind = None, connected = false, alias filled: "Connect the associated device to register". + registration_key_entry( + Fingerprint::from([0xEE; 4]), + None, + false, + "Carol".to_string(), + ), + // kind = None, connected = false, alias empty. + registration_key_entry(Fingerprint::from([0xFF; 4]), None, false, String::new()), + ]; + let cards: Element<'_, crate::state::message::Msg> = Column::with_children(variants) + .spacing(10) + .padding([0, 20]) + .into(); + let body = + registration_view_with_cards(registration_with_devices_state(), cards, true).map(|_| ()); + installer_chrome( + "Business installer: registration (all key_card variants)", + REGISTRATION_PATH, + body, + ) +} + +// ---- registration modal ------------------------------------------------- + +fn registration_modal_state_with(step: RegistrationModalStep, error: Option) -> State { + build_state(|s| { + s.current_view = View::Registration; + s.views.registration.modal = Some(RegistrationModalState { + fingerprint: Fingerprint::from([0xAA; 4]), + device_kind: Some(async_hwi::DeviceKind::Ledger), + step, + error, + }); + }) +} + +fn registration_modal_registering_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(registration_modal_state_with( + RegistrationModalStep::Registering, + None, + )) + }) + .0 +} + +fn registration_modal_error_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(registration_modal_state_with( + RegistrationModalStep::Error, + Some("Could not reach the device. Reconnect and retry.".to_string()), + )) + }) + .0 +} + +fn registration_modal_confirm_coldcard_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_state(|s| { + s.current_view = View::Registration; + // Coldcard doesn't ack the registration on the wire, so the + // wizard pops a dedicated Yes/No confirmation step instead of + // moving straight to "Registered". + s.views.registration.modal = Some(RegistrationModalState { + fingerprint: Fingerprint::from([0xCC; 4]), + device_kind: Some(async_hwi::DeviceKind::Coldcard), + step: RegistrationModalStep::ConfirmColdcard { + hmac: None, + wallet_name: "Acme treasury".to_string(), + }, + error: None, + }); + })) + }) + .0 +} + +fn render_registration_modal_registering() -> Element<'static, DebugMessage> { + let body = registration_modal_view(registration_modal_registering_state()) + .expect("modal state set") + .map(|_| ()); + installer_with_modal( + "Business installer: registration modal (registering)", + REGISTRATION_MODAL_PATH, + body, + ) +} + +fn render_registration_modal_error() -> Element<'static, DebugMessage> { + let body = registration_modal_view(registration_modal_error_state()) + .expect("modal state set") + .map(|_| ()); + installer_with_modal( + "Business installer: registration modal (error)", + REGISTRATION_MODAL_PATH, + body, + ) +} + +fn render_registration_modal_confirm_coldcard() -> Element<'static, DebugMessage> { + let body = registration_modal_view(registration_modal_confirm_coldcard_state()) + .expect("modal state set") + .map(|_| ()); + installer_with_modal( + "Business installer: registration modal (Coldcard Yes/No confirmation)", + REGISTRATION_MODAL_PATH, + body, + ) +} diff --git a/liana-business/business-installer/src/debug/key_registration.rs b/liana-business/business-installer/src/debug/key_registration.rs new file mode 100644 index 0000000000..549a39cc1f --- /dev/null +++ b/liana-business/business-installer/src/debug/key_registration.rs @@ -0,0 +1,685 @@ +//! Per-key xpub fetching: `xpub_view` + xpub-entry modal (the "Set +//! Keys" wizard step where each key registers an xpub on a device). + +use std::sync::{Arc, Mutex, OnceLock}; + +use async_hwi::service::{SigningDevice, UnsupportedReason}; +use async_hwi::{DeviceKind, Version}; +use liana_connect::ws_business::{KeyIdentity, UserRole}; +use liana_gui::debug::{installer_chrome, installer_with_modal, DebugMessage, DebugPageEntry}; +use liana_ui::widget::Element; +use miniscript::bitcoin::bip32::{ChildNumber, Fingerprint}; +use miniscript::bitcoin::Network; + +use crate::state::message::Message as InstallerMessage; +use crate::state::{ + views::xpub::{ModalStep as XpubModalStep, XpubEntryModalState}, + State, View, +}; +use crate::views::xpub::modal::{details_view, xpub_modal_view}; +use crate::views::xpub_view; + +use super::{add_sample_org_and_wallet, build_state, sample_xpub, StateCell}; + +// ---- mock device helpers ------------------------------------------------ +// +// The xpub modal renders devices via `state.hw.list()` → +// `async_hwi::service::SigningDevice`. The `Locked` and `Unsupported` +// variants have public, struct-literal-constructible fields, so we can +// fabricate them. The `Supported` variant wraps a private `SupportedDevice` +// whose fields aren't `pub`, so we can't reach that branch from outside +// async-hwi — that case stays unmocked here. + +fn insert_locked(s: &mut State, kind: DeviceKind, pairing_code: Option<&str>, id: &str) { + let device: SigningDevice = SigningDevice::Locked { + id: id.to_string(), + device: Arc::new(Mutex::new(None)), + pairing_code: pairing_code.map(|s| s.to_string()), + kind, + }; + s.hw.devices + .lock() + .expect("poisoned") + .insert(id.to_string(), device); +} + +fn insert_unsupported( + s: &mut State, + kind: DeviceKind, + version: Option, + reason: UnsupportedReason, + id: &str, +) { + let device: SigningDevice = SigningDevice::Unsupported { + id: id.to_string(), + kind, + version, + reason, + }; + s.hw.devices + .lock() + .expect("poisoned") + .insert(id.to_string(), device); +} + +fn make_version(major: u32, minor: u32, patch: u32) -> Version { + Version { + major, + minor, + patch, + prerelease: None, + } +} + +const XPUB_VIEW_PATH: &str = "business_installer::views::xpub::view::xpub_view"; +const XPUB_MODAL_PATH: &str = "business_installer::views::xpub::modal::xpub_modal_view"; + +pub static ENTRY_XPUB: DebugPageEntry = DebugPageEntry { view: render_xpub }; +pub static ENTRY_XPUB_PARTIAL: DebugPageEntry = DebugPageEntry { + view: render_xpub_partial, +}; +pub static ENTRY_XPUB_ALL_SET: DebugPageEntry = DebugPageEntry { + view: render_xpub_all_set, +}; +pub static ENTRY_XPUB_PARTICIPANT_NO_KEYS: DebugPageEntry = DebugPageEntry { + view: render_xpub_participant_no_keys, +}; +pub static ENTRY_XPUB_WS_ADMIN: DebugPageEntry = DebugPageEntry { + view: render_xpub_ws_admin, +}; +pub static ENTRY_XPUB_MODAL_SELECT: DebugPageEntry = DebugPageEntry { + view: render_xpub_modal_select, +}; +pub static ENTRY_XPUB_MODAL_SELECT_OPTIONS_EXPANDED: DebugPageEntry = DebugPageEntry { + view: render_xpub_modal_select_options_expanded, +}; +pub static ENTRY_XPUB_MODAL_SELECT_PASTE_EXPANDED: DebugPageEntry = DebugPageEntry { + view: render_xpub_modal_select_paste_expanded, +}; +pub static ENTRY_XPUB_MODAL_SELECT_PASTE_COLLAPSED: DebugPageEntry = DebugPageEntry { + view: render_xpub_modal_select_paste_collapsed, +}; +pub static ENTRY_XPUB_MODAL_SELECT_WITH_CURRENT_XPUB: DebugPageEntry = DebugPageEntry { + view: render_xpub_modal_select_with_current_xpub, +}; +pub static ENTRY_XPUB_MODAL_SELECT_ONE_DEVICE_OPTIONS_EXPANDED: DebugPageEntry = DebugPageEntry { + view: render_xpub_modal_select_one_device_options_expanded, +}; +pub static ENTRY_XPUB_MODAL_SELECT_MULTIPLE_DEVICES: DebugPageEntry = DebugPageEntry { + view: render_xpub_modal_select_multiple_devices, +}; +pub static ENTRY_XPUB_MODAL_DETAILS: DebugPageEntry = DebugPageEntry { + view: render_xpub_modal_details, +}; +pub static ENTRY_XPUB_MODAL_DETAILS_FETCHING: DebugPageEntry = DebugPageEntry { + view: render_xpub_modal_details_fetching, +}; +pub static ENTRY_XPUB_MODAL_DETAILS_FETCH_ERROR: DebugPageEntry = DebugPageEntry { + view: render_xpub_modal_details_fetch_error, +}; +pub static ENTRY_XPUB_MODAL_DETAILS_FETCH_SUCCESS: DebugPageEntry = DebugPageEntry { + view: render_xpub_modal_details_fetch_success, +}; +pub static ENTRY_XPUB_MODAL_DETAILS_WRONG_NETWORK: DebugPageEntry = DebugPageEntry { + view: render_xpub_modal_details_wrong_network, +}; +pub static ENTRY_XPUB_MODAL_DETAILS_ACCOUNT_5: DebugPageEntry = DebugPageEntry { + view: render_xpub_modal_details_account_5, +}; + +// ---- xpub view ---------------------------------------------------------- + +fn shared_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| StateCell(build_state(|_| {}))).0 +} + +fn render_xpub() -> Element<'static, DebugMessage> { + let body = xpub_view(shared_state()).map(|_| ()); + installer_chrome( + "Business installer — xpub (all keys unset)", + XPUB_VIEW_PATH, + body, + ) +} + +fn xpub_partial_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_state(|s| { + s.current_view = View::Xpub; + s.views + .login + .on_update_email("alice@example.com".to_string()); + // Mirror `xpub_all_set_state`: align Alice's identity with the + // first seeded key so `owned_keys` is non-empty. + if let Some(k) = s.app.keys.values_mut().next() { + k.identity = KeyIdentity::Email("alice@example.com".to_string()); + } + // Set the xpub on the first key only — the remaining seeded + // keys (Bob, Alice) stay unset, so `all_keys_set` is false and + // the cards render a mixed-status list. + if let Some(k) = s.app.keys.values_mut().next() { + k.xpub = Some(sample_xpub()); + } + })) + }) + .0 +} + +fn render_xpub_partial() -> Element<'static, DebugMessage> { + let body = xpub_view(xpub_partial_state()).map(|_| ()); + installer_chrome( + "Business installer — xpub (one key set, others unset)", + XPUB_VIEW_PATH, + body, + ) +} + +fn xpub_all_set_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_state(|s| { + s.current_view = View::Xpub; + s.views + .login + .on_update_email("alice@example.com".to_string()); + for key in s.app.keys.values_mut() { + key.xpub = Some(sample_xpub()); + } + // Make alice's identity match an existing key so owned_keys is + // non-empty. + if let Some(k) = s.app.keys.values_mut().next() { + k.identity = KeyIdentity::Email("alice@example.com".to_string()); + } + })) + }) + .0 +} + +fn render_xpub_all_set() -> Element<'static, DebugMessage> { + let body = xpub_view(xpub_all_set_state()).map(|_| ()); + installer_chrome( + "Business installer — xpub (all keys set, waiting)", + XPUB_VIEW_PATH, + body, + ) +} + +fn xpub_participant_no_keys_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_state(|s| { + s.current_view = View::Xpub; + s.app.current_user_role = Some(UserRole::Participant); + s.views + .login + .on_update_email("nobody@example.com".to_string()); + })) + }) + .0 +} + +fn render_xpub_participant_no_keys() -> Element<'static, DebugMessage> { + let body = xpub_view(xpub_participant_no_keys_state()).map(|_| ()); + installer_chrome( + "Business installer — xpub (participant, no owned keys)", + XPUB_VIEW_PATH, + body, + ) +} + +fn xpub_ws_admin_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_state(|s| { + s.current_view = View::Xpub; + s.app.current_user_role = Some(UserRole::WizardSardineAdmin); + s.views + .login + .on_update_email("admin@wizardsardine.com".to_string()); + add_sample_org_and_wallet(s); + })) + }) + .0 +} + +fn render_xpub_ws_admin() -> Element<'static, DebugMessage> { + let body = xpub_view(xpub_ws_admin_state()).map(|_| ()); + installer_chrome( + "Business installer — xpub (WS admin, breadcrumb)", + XPUB_VIEW_PATH, + body, + ) +} + +// ---- xpub modal --------------------------------------------------------- + +fn xpub_modal_state_with_step(step: XpubModalStep) -> State { + build_state(|s| { + s.current_view = View::Xpub; + let mut modal = XpubEntryModalState::new( + 1, + "Bobxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string(), + None, + Network::Bitcoin, + ); + modal.step = step; + s.views.xpub.modal = Some(modal); + }) +} + +fn xpub_modal_select_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| StateCell(xpub_modal_state_with_step(XpubModalStep::Select))) + .0 +} + +fn xpub_modal_details_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| StateCell(xpub_modal_state_with_step(XpubModalStep::Details))) + .0 +} + +fn render_xpub_modal_select() -> Element<'static, DebugMessage> { + let body = xpub_modal_view(xpub_modal_select_state()) + .expect("modal state set") + .map(|_| ()); + installer_with_modal( + "Business installer — xpub modal (select source)", + XPUB_MODAL_PATH, + body, + ) +} + +fn render_xpub_modal_details() -> Element<'static, DebugMessage> { + let modal = xpub_modal_details_state() + .views + .xpub + .modal + .as_ref() + .expect("modal set"); + let body = details_view(modal).map(|_| ()); + installer_with_modal( + "Business installer: xpub modal (details)", + XPUB_MODAL_PATH, + body, + ) +} + +// ---- Select-step variants ----------------------------------------------- + +fn build_select_state( + setup_modal: impl FnOnce(&mut XpubEntryModalState), + setup_state: impl FnOnce(&mut State), +) -> State { + build_state(|s| { + s.current_view = View::Xpub; + let mut modal = XpubEntryModalState::new( + 1, + "Bobtttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttttt".to_string(), + None, + Network::Bitcoin, + ); + modal.step = XpubModalStep::Select; + setup_modal(&mut modal); + s.views.xpub.modal = Some(modal); + setup_state(s); + }) +} + +fn xpub_modal_select_options_expanded_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_select_state( + |modal| modal.options_collapsed = false, + |_| {}, + )) + }) + .0 +} + +fn render_xpub_modal_select_options_expanded() -> Element<'static, DebugMessage> { + let body = xpub_modal_view(xpub_modal_select_options_expanded_state()) + .expect("modal state set") + .map(|_| ()); + installer_with_modal( + "Business installer — xpub modal (no devices, other options expanded)", + XPUB_MODAL_PATH, + body, + ) +} + +fn xpub_modal_select_paste_expanded_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_select_state( + |modal| { + modal.options_collapsed = false; + // The paste input only renders for WalletManager — set on + // the State below. + modal.paste_expanded = true; + }, + |s| { + s.app.current_user_role = Some(liana_connect::ws_business::UserRole::WalletManager); + }, + )) + }) + .0 +} + +fn render_xpub_modal_select_paste_expanded() -> Element<'static, DebugMessage> { + let body = xpub_modal_view(xpub_modal_select_paste_expanded_state()) + .expect("modal state set") + .map(|_| ()); + installer_with_modal( + "Business installer — xpub modal (wallet manager, paste expanded)", + XPUB_MODAL_PATH, + body, + ) +} + +fn xpub_modal_select_paste_collapsed_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_select_state( + |modal| { + modal.options_collapsed = false; + // Paste card visible, but its input row stays collapsed — + // only the "Paste an extended public key" button shows + // until the user clicks it. + modal.paste_expanded = false; + }, + |s| { + s.app.current_user_role = Some(liana_connect::ws_business::UserRole::WalletManager); + }, + )) + }) + .0 +} + +fn render_xpub_modal_select_paste_collapsed() -> Element<'static, DebugMessage> { + let body = xpub_modal_view(xpub_modal_select_paste_collapsed_state()) + .expect("modal state set") + .map(|_| ()); + installer_with_modal( + "Business installer — xpub modal (wallet manager, paste card collapsed)", + XPUB_MODAL_PATH, + body, + ) +} + +fn xpub_modal_select_with_current_xpub_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_state(|s| { + s.current_view = View::Xpub; + // Pre-populate the modal with `current_xpub` so the "this key + // already has an xpub" banner + Clear button surface. + let mut modal = XpubEntryModalState::new( + 1, + "Bobuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu".to_string(), + Some(sample_xpub()), + Network::Bitcoin, + ); + modal.step = XpubModalStep::Select; + s.views.xpub.modal = Some(modal); + })) + }) + .0 +} + +fn render_xpub_modal_select_with_current_xpub() -> Element<'static, DebugMessage> { + let body = xpub_modal_view(xpub_modal_select_with_current_xpub_state()) + .expect("modal state set") + .map(|_| ()); + installer_with_modal( + "Business installer — xpub modal (already has xpub: replace banner + Clear)", + XPUB_MODAL_PATH, + body, + ) +} + +fn xpub_modal_select_one_device_options_expanded_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_select_state( + |modal| modal.options_collapsed = false, + |s| insert_locked(s, DeviceKind::BitBox02, Some("DEF-456-UVW"), "bb02-1"), + )) + }) + .0 +} + +fn render_xpub_modal_select_one_device_options_expanded() -> Element<'static, DebugMessage> { + let body = xpub_modal_view(xpub_modal_select_one_device_options_expanded_state()) + .expect("modal state set") + .map(|_| ()); + installer_with_modal( + "Business installer — xpub modal (one locked device + other options expanded)", + XPUB_MODAL_PATH, + body, + ) +} + +fn xpub_modal_select_multiple_devices_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_select_state( + |_| {}, + |s| { + insert_locked(s, DeviceKind::BitBox02, Some("ABC-123-XYZ"), "bb02-locked"); + insert_locked(s, DeviceKind::Jade, None, "jade-locked"); + insert_unsupported( + s, + DeviceKind::Coldcard, + Some(make_version(5, 0, 0)), + UnsupportedReason::Version { + minimal_supported_version: "5.5.0", + }, + "coldcard-version", + ); + insert_unsupported( + s, + DeviceKind::Jade, + Some(make_version(1, 0, 30)), + UnsupportedReason::Version { + minimal_supported_version: "1.0.31", + }, + "jade-version", + ); + insert_unsupported( + s, + DeviceKind::Ledger, + Some(make_version(2, 1, 3)), + UnsupportedReason::NotPartOfWallet(Fingerprint::from([0xCC; 4])), + "ledger-not-part-of-wallet", + ); + insert_unsupported( + s, + DeviceKind::BitBox02, + Some(make_version(9, 14, 0)), + UnsupportedReason::WrongNetwork, + "bb02-wrong-network", + ); + insert_unsupported( + s, + DeviceKind::Specter, + None, + UnsupportedReason::Method("get_extended_pubkey"), + "specter-method", + ); + insert_unsupported( + s, + DeviceKind::Ledger, + Some(make_version(2, 1, 3)), + UnsupportedReason::AppIsNotOpen, + "ledger-app-not-open", + ); + }, + )) + }) + .0 +} + +fn render_xpub_modal_select_multiple_devices() -> Element<'static, DebugMessage> { + let body = xpub_modal_view(xpub_modal_select_multiple_devices_state()) + .expect("modal state set") + .map(|_| ()); + installer_with_modal( + "Business installer: xpub modal (Select, all device-state variants)", + XPUB_MODAL_PATH, + body, + ) +} + +// ---- Details-step variants ---------------------------------------------- +// +// The Details step is reached after the user selects a hardware device on +// the Select step. Account picker visibility, retry button, and the xpub +// preview are all driven by `processing` / `fetch_error` / `xpub_input`. + +fn build_details_state( + network: Network, + setup_modal: impl FnOnce(&mut XpubEntryModalState), +) -> State { + build_state(|s| { + s.current_view = View::Xpub; + let mut modal = XpubEntryModalState::new(1, "Boboooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo".to_string(), None, network); + modal.step = XpubModalStep::Details; + setup_modal(&mut modal); + s.views.xpub.modal = Some(modal); + }) +} + +fn xpub_modal_details_fetching_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_details_state(Network::Bitcoin, |modal| { + // Mirrors `select_device`: processing flips to true, account + // picker becomes a static label. + modal.processing = true; + })) + }) + .0 +} + +fn render_xpub_modal_details_fetching() -> Element<'static, DebugMessage> { + let modal = xpub_modal_details_fetching_state() + .views + .xpub + .modal + .as_ref() + .expect("modal set"); + let body = details_view(modal).map(|_| ()); + installer_with_modal( + "Business installer: xpub details (fetching from device)", + XPUB_MODAL_PATH, + body, + ) +} + +fn xpub_modal_details_fetch_error_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_details_state(Network::Bitcoin, |modal| { + modal.fetch_error = Some("Could not reach device. Reconnect and retry.".to_string()); + })) + }) + .0 +} + +fn render_xpub_modal_details_fetch_error() -> Element<'static, DebugMessage> { + let modal = xpub_modal_details_fetch_error_state() + .views + .xpub + .modal + .as_ref() + .expect("modal set"); + let body = details_view(modal).map(|_| ()); + installer_with_modal( + "Business installer: xpub details (fetch error + Retry)", + XPUB_MODAL_PATH, + body, + ) +} + +fn xpub_modal_details_fetch_success_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_details_state(Network::Testnet, |modal| { + // Sample xpub is a tpub, so the Testnet network state validates + // it cleanly — mirrors the post-fetch "ready to save" UX. + modal.xpub_input = sample_xpub().to_string(); + })) + }) + .0 +} + +fn render_xpub_modal_details_fetch_success() -> Element<'static, DebugMessage> { + let modal = xpub_modal_details_fetch_success_state() + .views + .xpub + .modal + .as_ref() + .expect("modal set"); + let body = details_view(modal).map(|_| ()); + installer_with_modal( + "Business installer: xpub details (fetch success, save enabled)", + XPUB_MODAL_PATH, + body, + ) +} + +fn xpub_modal_details_wrong_network_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_details_state(Network::Bitcoin, |modal| { + // Bitcoin-network state rejects the testnet sample xpub → + // validate() emits "Extended public key is not valid for bitcoin". + modal.xpub_input = sample_xpub().to_string(); + })) + }) + .0 +} + +fn render_xpub_modal_details_wrong_network() -> Element<'static, DebugMessage> { + let modal = xpub_modal_details_wrong_network_state() + .views + .xpub + .modal + .as_ref() + .expect("modal set"); + let body = details_view(modal).map(|_| ()); + installer_with_modal( + "Business installer: xpub details (wrong-network validation error)", + XPUB_MODAL_PATH, + body, + ) +} + +fn xpub_modal_details_account_5_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_details_state(Network::Bitcoin, |modal| { + modal.selected_account = + ChildNumber::from_hardened_idx(5).expect("hardcoded valid account index"); + })) + }) + .0 +} + +fn render_xpub_modal_details_account_5() -> Element<'static, DebugMessage> { + let modal = xpub_modal_details_account_5_state() + .views + .xpub + .modal + .as_ref() + .expect("modal set"); + let body = details_view(modal).map(|_| ()); + installer_with_modal( + "Business installer: xpub details (account #5 selected)", + XPUB_MODAL_PATH, + body, + ) +} diff --git a/liana-business/business-installer/src/debug/keys.rs b/liana-business/business-installer/src/debug/keys.rs new file mode 100644 index 0000000000..e130bb2630 --- /dev/null +++ b/liana-business/business-installer/src/debug/keys.rs @@ -0,0 +1,484 @@ +//! Keys metadata management — `keys_view` + the edit-key modal. + +use std::sync::OnceLock; + +use liana_connect::ws_business::{Key, KeyIdentity, KeyType as WsKeyType}; +use liana_gui::debug::{installer_chrome, installer_with_modal, DebugMessage, DebugPageEntry}; +use liana_ui::widget::Element; + +use crate::state::{views::keys::EditKeyModalState, State, View}; +use crate::views::keys::modal::key_modal_view; +use crate::views::keys_view; + +use super::{add_sample_org_and_wallet, build_state, StateCell}; + +/// Append a Cosigner and a SafetyNet entry to the default key list so the +/// `keys_view` rows exercise every `KeyType` variant (Internal/External +/// already come from `seed_test_data`). +fn extend_with_cosigner_and_safety_net(s: &mut State) { + let cosigner_id = s.app.next_key_id; + s.app.keys.insert( + cosigner_id, + Key { + id: cosigner_id, + alias: "Provider cosigner".to_string(), + description: String::new(), + identity: KeyIdentity::Token("TKN-COSIG-1234".to_string()), + key_type: WsKeyType::Cosigner, + xpub: None, + xpub_source: None, + xpub_device_kind: None, + xpub_device_version: None, + xpub_file_name: None, + last_edited: None, + last_editor: None, + }, + ); + let safety_id = cosigner_id + 1; + s.app.keys.insert( + safety_id, + Key { + id: safety_id, + alias: "Safety net".to_string(), + description: String::new(), + identity: KeyIdentity::Token("TKN-SAFETY-9999".to_string()), + key_type: WsKeyType::SafetyNet, + xpub: None, + xpub_source: None, + xpub_device_kind: None, + xpub_device_version: None, + xpub_file_name: None, + last_edited: None, + last_editor: None, + }, + ); + s.app.next_key_id = safety_id + 1; +} + +/// Build a long key list (mix of types, alphabetised aliases) so the +/// `keys_view` rows overflow the viewport and the inner scroll surfaces. +fn extend_with_many_keys(s: &mut State) { + s.app.keys.clear(); + let mut next = 0u8; + let entries: &[(WsKeyType, &str, KeyIdentity)] = &[ + ( + WsKeyType::Internal, + "Walaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa let manager", + KeyIdentity::Email("owner@example.com".to_string()), + ), + ( + WsKeyType::External, + "Aliceeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + KeyIdentity::Email("alice@example.com".to_string()), + ), + ( + WsKeyType::External, + "Bobiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii", + KeyIdentity::Email("bob@example.com".to_string()), + ), + ( + WsKeyType::External, + "Carol", + KeyIdentity::Email("carol@example.com".to_string()), + ), + ( + WsKeyType::External, + "Dave", + KeyIdentity::Email("dave@example.com".to_string()), + ), + ( + WsKeyType::External, + "Eve", + KeyIdentity::Email("eve@example.com".to_string()), + ), + ( + WsKeyType::External, + "Frank", + KeyIdentity::Email("frank@example.com".to_string()), + ), + ( + WsKeyType::External, + "Grace", + KeyIdentity::Email("grace@example.com".to_string()), + ), + ( + WsKeyType::Cosigner, + "Provider cosigner", + KeyIdentity::Token("TKN-COSIG-1234".to_string()), + ), + ( + WsKeyType::SafetyNet, + "Safety net", + KeyIdentity::Token("TKN-SAFETY-9999".to_string()), + ), + ]; + for (key_type, alias, identity) in entries { + s.app.keys.insert( + next, + Key { + id: next, + alias: (*alias).to_string(), + description: String::new(), + identity: identity.clone(), + key_type: *key_type, + xpub: None, + xpub_source: None, + xpub_device_kind: None, + xpub_device_version: None, + xpub_file_name: None, + last_edited: None, + last_editor: None, + }, + ); + next += 1; + } + s.app.next_key_id = next; +} + +const KEYS_PATH: &str = "business_installer::views::keys::keys_view"; +const KEY_MODAL_PATH: &str = "business_installer::views::keys::modal::edit_key_modal_view"; + +pub static ENTRY_KEYS_EMPTY: DebugPageEntry = DebugPageEntry { + view: render_keys_empty, +}; +pub static ENTRY_KEYS_WITH_BREADCRUMB: DebugPageEntry = DebugPageEntry { + view: render_keys_with_breadcrumb, +}; +pub static ENTRY_KEYS_MANY: DebugPageEntry = DebugPageEntry { + view: render_keys_many, +}; +pub static ENTRY_KEY_MODAL_NEW_EMPTY: DebugPageEntry = DebugPageEntry { + view: render_key_modal_new_empty, +}; +pub static ENTRY_KEY_MODAL_NEW_ALIASED: DebugPageEntry = DebugPageEntry { + view: render_key_modal_new_aliased, +}; +pub static ENTRY_KEY_MODAL_EXTERNAL: DebugPageEntry = DebugPageEntry { + view: render_key_modal_external, +}; +pub static ENTRY_KEY_MODAL_INTERNAL: DebugPageEntry = DebugPageEntry { + view: render_key_modal_internal, +}; +pub static ENTRY_KEY_MODAL_COSIGNER: DebugPageEntry = DebugPageEntry { + view: render_key_modal_cosigner, +}; +pub static ENTRY_KEY_MODAL_SAFETY_NET: DebugPageEntry = DebugPageEntry { + view: render_key_modal_safety_net, +}; +pub static ENTRY_KEY_MODAL_INVALID_EMAIL: DebugPageEntry = DebugPageEntry { + view: render_key_modal_invalid_email, +}; +pub static ENTRY_KEY_MODAL_EMPTY_ALIAS: DebugPageEntry = DebugPageEntry { + view: render_key_modal_empty_alias, +}; + +// ---- keys view ---------------------------------------------------------- + +fn keys_empty_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_state(|s| { + s.current_view = View::Keys; + s.app.keys.clear(); + })) + }) + .0 +} + +fn render_keys_empty() -> Element<'static, DebugMessage> { + let body = keys_view(keys_empty_state()).map(|_| ()); + installer_chrome("Business installer — keys (empty)", KEYS_PATH, body) +} + +fn keys_with_breadcrumb_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_state(|s| { + s.current_view = View::Keys; + add_sample_org_and_wallet(s); + extend_with_cosigner_and_safety_net(s); + })) + }) + .0 +} + +fn render_keys_with_breadcrumb() -> Element<'static, DebugMessage> { + let body = keys_view(keys_with_breadcrumb_state()).map(|_| ()); + installer_chrome( + "Business installer — keys (with org/wallet breadcrumb)", + KEYS_PATH, + body, + ) +} + +fn keys_many_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_state(|s| { + s.current_view = View::Keys; + extend_with_many_keys(s); + })) + }) + .0 +} + +fn render_keys_many() -> Element<'static, DebugMessage> { + let body = keys_view(keys_many_state()).map(|_| ()); + installer_chrome( + "Business installer — keys (many entries, overflows viewport)", + KEYS_PATH, + body, + ) +} + +// ---- edit-key modal ---------------------------------------------------- + +#[allow(clippy::too_many_arguments)] +fn build_key_modal_state( + is_new: bool, + key_type: WsKeyType, + alias: &str, + description: &str, + email: &str, + token: &str, + token_warning: Option<&'static str>, +) -> State { + build_state(|s| { + s.current_view = View::Keys; + s.views.keys.edit_key_modal = Some(EditKeyModalState { + key_id: if is_new { 99 } else { 1 }, + alias: alias.to_string(), + description: description.to_string(), + key_type, + is_new, + email: email.to_string(), + token: token.to_string(), + token_warning, + }); + }) +} + +fn key_modal_new_empty_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_key_modal_state( + true, + WsKeyType::External, + "", + "", + "", + "", + None, + )) + }) + .0 +} + +fn render_key_modal_new_empty() -> Element<'static, DebugMessage> { + let body = key_modal_view(key_modal_new_empty_state()) + .expect("modal state set above") + .map(|_| ()); + installer_with_modal( + "Business installer — edit-key (new, empty)", + KEY_MODAL_PATH, + body, + ) +} + +fn key_modal_new_aliased_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_key_modal_state( + true, + WsKeyType::External, + "Cosigner key", + "", + "carol@example.com", + "", + None, + )) + }) + .0 +} + +fn render_key_modal_new_aliased() -> Element<'static, DebugMessage> { + let body = key_modal_view(key_modal_new_aliased_state()) + .expect("modal state set above") + .map(|_| ()); + installer_with_modal( + "Business installer — edit-key (new, aliased)", + KEY_MODAL_PATH, + body, + ) +} + +fn key_modal_external_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_key_modal_state( + false, + WsKeyType::External, + "Bobaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "", + "bob@example.com", + "", + None, + )) + }) + .0 +} + +fn render_key_modal_external() -> Element<'static, DebugMessage> { + let body = key_modal_view(key_modal_external_state()) + .expect("modal state set above") + .map(|_| ()); + installer_with_modal( + "Business installer — edit-key (existing, External)", + KEY_MODAL_PATH, + body, + ) +} + +fn key_modal_internal_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_key_modal_state( + false, + WsKeyType::Internal, + "Waaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaallet manager", + "", + "owner@example.com", + "", + None, + )) + }) + .0 +} + +fn render_key_modal_internal() -> Element<'static, DebugMessage> { + let body = key_modal_view(key_modal_internal_state()) + .expect("modal state set above") + .map(|_| ()); + installer_with_modal( + "Business installer — edit-key (existing, Internal)", + KEY_MODAL_PATH, + body, + ) +} + +fn key_modal_cosigner_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_key_modal_state( + true, + WsKeyType::Cosigner, + "Cosigner", + "", + "", + // The "TKN-..." string isn't a valid `liana_connect` token, so + // production's `on_key_update_token` would have stamped this + // warning. Mirror that here instead of leaving the input + // silently green. + "TKN-1234-5678", + Some("Invalid token!"), + )) + }) + .0 +} + +fn render_key_modal_cosigner() -> Element<'static, DebugMessage> { + let body = key_modal_view(key_modal_cosigner_state()) + .expect("modal state set above") + .map(|_| ()); + installer_with_modal( + "Business installer — edit-key (Cosigner with token)", + KEY_MODAL_PATH, + body, + ) +} + +fn key_modal_safety_net_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_key_modal_state( + true, + WsKeyType::SafetyNet, + "Safety net", + "", + "", + "TKN-SAFETY-9999", + // Same as the Cosigner variant — placeholder string, so the + // real-flow token-format check would have rejected it. + Some("Invalid token!"), + )) + }) + .0 +} + +fn render_key_modal_safety_net() -> Element<'static, DebugMessage> { + let body = key_modal_view(key_modal_safety_net_state()) + .expect("modal state set above") + .map(|_| ()); + installer_with_modal( + "Business installer — edit-key (SafetyNet with token)", + KEY_MODAL_PATH, + body, + ) +} + +fn key_modal_invalid_email_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_key_modal_state( + true, + WsKeyType::External, + "Bad email", + "", + "not-an-email", + "", + None, + )) + }) + .0 +} + +fn render_key_modal_invalid_email() -> Element<'static, DebugMessage> { + let body = key_modal_view(key_modal_invalid_email_state()) + .expect("modal state set above") + .map(|_| ()); + installer_with_modal( + "Business installer — edit-key (invalid email)", + KEY_MODAL_PATH, + body, + ) +} + +fn key_modal_empty_alias_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_key_modal_state( + true, + WsKeyType::External, + // Alias deliberately empty: email is filled so the + // alias-required validation is the only thing missing. + // (Description has no input in the modal, so it stays empty.) + "", + "", + "carol@example.com", + "", + None, + )) + }) + .0 +} + +fn render_key_modal_empty_alias() -> Element<'static, DebugMessage> { + let body = key_modal_view(key_modal_empty_alias_state()) + .expect("modal state set above") + .map(|_| ()); + installer_with_modal( + "Business installer — edit-key (empty alias only)", + KEY_MODAL_PATH, + body, + ) +} diff --git a/liana-business/business-installer/src/debug/login.rs b/liana-business/business-installer/src/debug/login.rs new file mode 100644 index 0000000000..1a271990c3 --- /dev/null +++ b/liana-business/business-installer/src/debug/login.rs @@ -0,0 +1,285 @@ +//! Loading + login pages (account select / email / code). + +use std::sync::OnceLock; + +use liana_gui::debug::{installer_chrome, DebugMessage, DebugPageEntry}; +use liana_ui::widget::Element; + +use crate::state::{ + views::login::{CachedAccount, Login, LoginState}, + State, View, +}; +use crate::views; + +use super::{build_state, stub_tokens, StateCell}; + +const LOADING_PATH: &str = "business_installer::views::loading::loading_view"; +const ACCOUNT_SELECT_PATH: &str = + "business_installer::views::login::account_select::account_select_view"; +const EMAIL_PATH: &str = "business_installer::views::login::email::login_email_view"; +const CODE_PATH: &str = "business_installer::views::login::code::login_code_view"; + +pub static ENTRY_LOADING_OK: DebugPageEntry = DebugPageEntry { + view: render_loading_ok, +}; +pub static ENTRY_LOADING_ERROR: DebugPageEntry = DebugPageEntry { + view: render_loading_error, +}; +pub static ENTRY_ACCOUNT_SELECT: DebugPageEntry = DebugPageEntry { + view: render_account_select, +}; +pub static ENTRY_ACCOUNT_SELECT_MANY: DebugPageEntry = DebugPageEntry { + view: render_account_select_many, +}; +pub static ENTRY_ACCOUNT_SELECT_PROCESSING: DebugPageEntry = DebugPageEntry { + view: render_account_select_processing, +}; +pub static ENTRY_EMAIL_EMPTY: DebugPageEntry = DebugPageEntry { + view: render_email_empty, +}; +pub static ENTRY_EMAIL: DebugPageEntry = DebugPageEntry { view: render_email }; +pub static ENTRY_EMAIL_INVALID: DebugPageEntry = DebugPageEntry { + view: render_email_invalid, +}; +pub static ENTRY_CODE_EMPTY: DebugPageEntry = DebugPageEntry { + view: render_code_empty, +}; +pub static ENTRY_CODE: DebugPageEntry = DebugPageEntry { view: render_code }; +pub static ENTRY_CODE_INVALID: DebugPageEntry = DebugPageEntry { + view: render_code_invalid, +}; + +// ---- loading ------------------------------------------------------------- + +fn render_loading_ok() -> Element<'static, DebugMessage> { + let body = views::loading_view(false).map(|_| ()); + installer_chrome("Business installer — loading", LOADING_PATH, body) +} + +fn render_loading_error() -> Element<'static, DebugMessage> { + let body = views::loading_view(true).map(|_| ()); + installer_chrome("Business installer — loading (error)", LOADING_PATH, body) +} + +// ---- account select ------------------------------------------------------ +fn account_select_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_state(|s| { + s.current_view = View::Login; + let accounts = vec![ + CachedAccount { + email: "alice@example.com".to_string(), + tokens: stub_tokens(), + }, + CachedAccount { + email: "bob@example.com".to_string(), + tokens: stub_tokens(), + }, + ]; + s.views.login = Login::with_cached_accounts(accounts); + })) + }) + .0 +} + +fn render_account_select() -> Element<'static, DebugMessage> { + let body = views::login_view(account_select_state()).map(|_| ()); + installer_chrome( + "Business installer — login (account select)", + ACCOUNT_SELECT_PATH, + body, + ) +} + +fn account_select_many_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_state(|s| { + s.current_view = View::Login; + // Eight accounts — overflows the viewport so the inner scroll + // is visible. + let accounts = (0..8) + .map(|i| CachedAccount { + email: format!("user{i}iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiooooooooooooooooooooooooooooooooooiiiii@example.com"), + tokens: stub_tokens(), + }) + .collect(); + s.views.login = Login::with_cached_accounts(accounts); + })) + }) + .0 +} + +fn render_account_select_many() -> Element<'static, DebugMessage> { + let body = views::login_view(account_select_many_state()).map(|_| ()); + installer_chrome( + "Business installer — login (account select, many cached)", + ACCOUNT_SELECT_PATH, + body, + ) +} + +fn account_select_processing_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_state(|s| { + s.current_view = View::Login; + // Two accounts: only the selected one shows "Connecting…". + let accounts = vec![ + CachedAccount { + email: "alice@example.com".to_string(), + tokens: stub_tokens(), + }, + CachedAccount { + email: "bob@example.com".to_string(), + tokens: stub_tokens(), + }, + ]; + s.views.login = Login::with_cached_accounts(accounts); + s.views.login.account_select.processing = true; + s.views.login.account_select.selected_email = Some("alice@example.com".to_string()); + })) + }) + .0 +} + +fn render_account_select_processing() -> Element<'static, DebugMessage> { + let body = views::login_view(account_select_processing_state()).map(|_| ()); + installer_chrome( + "Business installer — login (account select, connecting)", + ACCOUNT_SELECT_PATH, + body, + ) +} + +// ---- email -------------------------------------------------------------- + +fn email_empty_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_state(|s| { + s.current_view = View::Login; + s.views.login.current = LoginState::EmailEntry; + })) + }) + .0 +} + +fn render_email_empty() -> Element<'static, DebugMessage> { + let body = views::login_view(email_empty_state()).map(|_| ()); + installer_chrome( + "Business installer — login (email entry, empty)", + EMAIL_PATH, + body, + ) +} + +fn email_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_state(|s| { + s.current_view = View::Login; + s.views.login.current = LoginState::EmailEntry; + s.views + .login + .on_update_email("alice@example.com".to_string()); + })) + }) + .0 +} + +fn render_email() -> Element<'static, DebugMessage> { + let body = views::login_view(email_state()).map(|_| ()); + installer_chrome("Business installer — login (email entry)", EMAIL_PATH, body) +} + +fn email_invalid_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_state(|s| { + s.current_view = View::Login; + s.views.login.current = LoginState::EmailEntry; + s.views.login.on_update_email("not-an-email".to_string()); + })) + }) + .0 +} + +fn render_email_invalid() -> Element<'static, DebugMessage> { + let body = views::login_view(email_invalid_state()).map(|_| ()); + installer_chrome( + "Business installer — login (email entry, invalid)", + EMAIL_PATH, + body, + ) +} + +// ---- code --------------------------------------------------------------- + +fn code_empty_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_state(|s| { + s.current_view = View::Login; + s.views.login.current = LoginState::CodeEntry; + s.views + .login + .on_update_email("alice@example.com".to_string()); + })) + }) + .0 +} + +fn render_code_empty() -> Element<'static, DebugMessage> { + let body = views::login_view(code_empty_state()).map(|_| ()); + installer_chrome( + "Business installer — login (code entry, empty)", + CODE_PATH, + body, + ) +} + +fn code_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_state(|s| { + s.current_view = View::Login; + s.views.login.current = LoginState::CodeEntry; + s.views + .login + .on_update_email("alice@example.com".to_string()); + s.views.login.on_update_code("123456".to_string()); + })) + }) + .0 +} + +fn render_code() -> Element<'static, DebugMessage> { + let body = views::login_view(code_state()).map(|_| ()); + installer_chrome("Business installer — login (code entry)", CODE_PATH, body) +} + +fn code_invalid_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_state(|s| { + s.current_view = View::Login; + s.views.login.current = LoginState::CodeEntry; + s.views + .login + .on_update_email("alice@example.com".to_string()); + s.views.login.on_update_code("12abcd".to_string()); + })) + }) + .0 +} + +fn render_code_invalid() -> Element<'static, DebugMessage> { + let body = views::login_view(code_invalid_state()).map(|_| ()); + installer_chrome( + "Business installer — login (code entry, invalid)", + CODE_PATH, + body, + ) +} diff --git a/liana-business/business-installer/src/debug/mod.rs b/liana-business/business-installer/src/debug/mod.rs new file mode 100644 index 0000000000..c4f3e000fe --- /dev/null +++ b/liana-business/business-installer/src/debug/mod.rs @@ -0,0 +1,380 @@ +//! Business-installer debug stack. +//! +//! Surfaces every step view in the wizard plus the warning / conflict +//! modals through the debug overlay. Aggregated into [`INSTALLER_STACK`] +//! and re-exported by `liana-business` so its `EXTRA_STACKS` slice picks +//! it up (see `liana_business::debug::EXTRA_STACKS`). +//! +//! The pages are split into one submodule per wizard step so a designer +//! can browse them in their natural order: `login` → `orgs` → `wallets` +//! → `template_creation` → `keys` → `key_registration` (xpub fetch) → +//! `descriptor_registration` (wallet descriptor on devices). Cross-cutting +//! modals (warning / conflict) live at the end of [`INSTALLER_STACK`] and +//! are defined in this file. +//! +//! Each state-based page builds a stripped-down [`State`] via +//! `State::for_debug` (no tokio runtime, no HW bridge thread) and mutates +//! `views.*` to reach the targeted scenario. View functions never read +//! `state.backend`/`state.hw`, so the stub state renders faithfully. + +use std::collections::BTreeSet; +use std::path::PathBuf; +use std::str::FromStr; +use std::sync::OnceLock; + +use liana::miniscript::descriptor::DescriptorPublicKey; +use liana_connect::ws_business::{ + Key, KeyIdentity, KeyType, Org, SecondaryPath, SpendingPath, Timelock, User, UserRole, Wallet, + WalletStatus, +}; +use liana_gui::{ + debug::{installer_with_modal, DebugMessage, DebugPageEntry, DebugStack}, + dir::LianaDirectory, + services::connect::client::auth::AccessTokenResponse, +}; +use liana_ui::widget::Element; +use miniscript::bitcoin::Network; +use uuid::Uuid; + +use crate::state::{ + app::AppState, + views::modals::{ConflictModalState, ConflictType, WarningModalState}, + State, +}; +use crate::views::{modals::conflict::conflict_modal_view, modals::warning::warning_modal_view}; + +pub mod descriptor_registration; +pub mod key_registration; +pub mod keys; +pub mod login; +pub mod orgs; +pub mod template_creation; +pub mod wallets; + +/// SAFETY: iced renders on the main thread; debug-overlay state is only +/// read during rendering. +pub(super) struct StateCell(pub(super) T); +unsafe impl Sync for StateCell {} + +pub(super) fn datadir() -> LianaDirectory { + LianaDirectory::new(PathBuf::new()) +} + +pub(super) fn build_state(setup: impl FnOnce(&mut State)) -> State { + let mut s = State::for_debug(Network::Bitcoin, datadir()); + seed_test_data(&mut s.app); + setup(&mut s); + s +} + +/// Pre-populate `app` with three keys (Wallet Manager, Bob, Alice), a +/// 2-of-2 primary path, and two timelocked secondary paths. Used by every +/// state-based debug page so views render against realistic data without +/// every caller having to assemble the same fixture. +fn seed_test_data(app: &mut AppState) { + app.keys.insert( + 0, + Key { + id: 0, + alias: "Wallet Manageeeeeeeeeeeeeeeeeeeeeeeeeeeeeer".to_string(), + description: String::new(), + identity: KeyIdentity::Email( + "owner@exaaaaaaaaaaaaaaaaaaaaaaaaaaaaaample.com".to_string(), + ), + key_type: KeyType::Internal, + xpub: None, + xpub_source: None, + xpub_device_kind: None, + xpub_device_version: None, + xpub_file_name: None, + last_edited: Some(1779000198), + last_editor: Some(Uuid::from_u128(0x1234)), + }, + ); + app.keys.insert( + 1, + Key { + id: 1, + alias: "Bob".to_string(), + description: String::new(), + identity: KeyIdentity::Email( + "boooooooooooooooooooooooooooooooooooooooooooooooob@example.com".to_string(), + ), + key_type: KeyType::External, + xpub: None, + xpub_source: None, + xpub_device_kind: None, + xpub_device_version: None, + xpub_file_name: None, + last_edited: None, + last_editor: None, + }, + ); + app.keys.insert( + 2, + Key { + id: 2, + alias: "Alice".to_string(), + description: String::new(), + identity: KeyIdentity::Email("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaalice@eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeexample.com".to_string()), + key_type: KeyType::External, + xpub: None, + xpub_source: None, + xpub_device_kind: None, + xpub_device_version: None, + xpub_file_name: None, + last_edited: None, + last_editor: None, + }, + ); + app.primary_path = SpendingPath::new(true, 2, vec![0, 1]); + app.secondary_paths = vec![ + SecondaryPath { + path: SpendingPath::new(false, 1, vec![2, 1]), + timelock: Timelock::new(8760), + }, + SecondaryPath { + path: SpendingPath::new(false, 1, vec![0]), + timelock: Timelock::new(21900), + }, + ]; + app.next_key_id = 3; +} + +pub(super) fn stub_tokens() -> AccessTokenResponse { + AccessTokenResponse { + access_token: String::new(), + expires_at: 0, + refresh_token: String::new(), + } +} + +const SAMPLE_XPUB: &str = "[19608592/48'/1'/0'/2']tpubDEjf1AbrUjxnw8jg6Gi12CunPqnCobLP6Ktoy4Hd52pa65d6QRPg5CSkdFrqPDjJ8BAUuMEDVDRQVjtuWWksMqBeZCqyABFucN9ErQq8oVX/<0;1>/*"; + +pub(super) fn sample_xpub() -> DescriptorPublicKey { + DescriptorPublicKey::from_str(SAMPLE_XPUB).expect("sample xpub parses") +} + +/// Common helper: install an org + wallet in the backend so the breadcrumb +/// renders org / wallet names instead of placeholders. +pub(super) fn add_sample_org_and_wallet(s: &mut State) { + s.backend.users.lock().expect("poisoned").insert( + Uuid::from_u128(0x1234), + User { + name: "John Doe".to_string(), + uuid: Uuid::from_u128(0x1234), + email: "john.doe@123.4".to_string(), + role: UserRole::WizardSardineAdmin, + last_edited: None, + last_editor: None, + }, + ); + let org_id = Uuid::from_u128(0x4000); + let wallet_id = Uuid::from_u128(0x5000); + { + let mut wallets = s.backend.wallets.lock().expect("poisoned"); + wallets.insert( + wallet_id, + Wallet { + alias: "Acme treasury".to_string(), + org: org_id, + owner: Uuid::nil(), + id: wallet_id, + status: WalletStatus::Drafted, + template: None, + last_edited: None, + last_editor: None, + descriptor: None, + devices: None, + }, + ); + } + let mut org_wallets = BTreeSet::new(); + org_wallets.insert(wallet_id); + { + let mut orgs = s.backend.orgs.lock().expect("poisoned"); + orgs.insert( + org_id, + Org { + name: "Acme Vault".to_string(), + id: org_id, + wallets: org_wallets, + users: BTreeSet::new(), + owners: Vec::new(), + last_edited: None, + last_editor: None, + }, + ); + } + s.app.selected_org = Some(org_id); + s.app.selected_wallet = Some(wallet_id); +} + +// ---- cross-cutting modals ----------------------------------------------- + +const WARNING_MODAL_PATH: &str = "business_installer::views::modals::warning::warning_modal_view"; +const CONFLICT_MODAL_PATH: &str = + "business_installer::views::modals::conflict::conflict_modal_view"; + +pub static ENTRY_WARNING_MODAL: DebugPageEntry = DebugPageEntry { + view: render_warning_modal, +}; +pub static ENTRY_CONFLICT_MODAL_INFO: DebugPageEntry = DebugPageEntry { + view: render_conflict_modal_info, +}; +pub static ENTRY_CONFLICT_MODAL_CHOICE: DebugPageEntry = DebugPageEntry { + view: render_conflict_modal_choice, +}; + +fn warning_state() -> &'static WarningModalState { + static S: OnceLock = OnceLock::new(); + S.get_or_init(|| { + WarningModalState::new( + "Wallet not registered".to_string(), + "The wallet descriptor is not registered on the device.\nYou can register it in the settings.".to_string(), + ) + }) +} + +fn render_warning_modal() -> Element<'static, DebugMessage> { + let body = warning_modal_view(warning_state()).map(|_| ()); + installer_with_modal( + "Business installer — warning modal", + WARNING_MODAL_PATH, + body, + ) +} + +fn conflict_info_state() -> &'static ConflictModalState { + static S: OnceLock = OnceLock::new(); + S.get_or_init(|| ConflictModalState { + conflict_type: ConflictType::KeyDeleted, + title: "Key deleted".to_string(), + message: "The key you were editing was deleted by another user.".to_string(), + }) +} + +fn render_conflict_modal_info() -> Element<'static, DebugMessage> { + let body = conflict_modal_view(conflict_info_state()).map(|_| ()); + installer_with_modal( + "Business installer — conflict modal (info-only)", + CONFLICT_MODAL_PATH, + body, + ) +} + +fn conflict_choice_state() -> &'static ConflictModalState { + static S: OnceLock = OnceLock::new(); + S.get_or_init(|| ConflictModalState { + conflict_type: ConflictType::KeyModified { + key_id: 1, + wallet_id: Uuid::nil(), + }, + title: "Key modified".to_string(), + message: "The key you were editing has been modified by another user.\nReload to see the latest changes, or keep your edits.".to_string(), + }) +} + +fn render_conflict_modal_choice() -> Element<'static, DebugMessage> { + let body = conflict_modal_view(conflict_choice_state()).map(|_| ()); + installer_with_modal( + "Business installer — conflict modal (choice)", + CONFLICT_MODAL_PATH, + body, + ) +} + +// ---- aggregated stack --------------------------------------------------- + +pub const INSTALLER_STACK: DebugStack = DebugStack { + name: "Business installer", + menu: None, + pages: &[ + // Login + &login::ENTRY_EMAIL_EMPTY, + &login::ENTRY_EMAIL, + &login::ENTRY_EMAIL_INVALID, + &login::ENTRY_CODE_INVALID, + &login::ENTRY_CODE_EMPTY, + &login::ENTRY_CODE, + // Select account + &login::ENTRY_ACCOUNT_SELECT, + &login::ENTRY_ACCOUNT_SELECT_MANY, + &login::ENTRY_ACCOUNT_SELECT_PROCESSING, + // org select + &orgs::ENTRY_ORG_SELECT_WITH_ORGS, + // wallet select + &wallets::ENTRY_WALLET_SELECT_WITH_WALLETS, + // template creation + &template_creation::ENTRY_TEMPLATE_BUILDER, + &template_creation::ENTRY_TEMPLATE_BUILDER_OWNER, + &template_creation::ENTRY_TEMPLATE_BUILDER_WS_ADMIN, + &template_creation::ENTRY_TEMPLATE_BUILDER_WS_ADMIN_SINGLE_KEY, + &template_creation::ENTRY_TEMPLATE_BUILDER_LOCKED, + &template_creation::ENTRY_PATH_MODAL_PRIMARY, + &template_creation::ENTRY_PATH_MODAL_PRIMARY_NO_KEYS, + &template_creation::ENTRY_PATH_MODAL_PRIMARY_THRESHOLD_EMPTY, + &template_creation::ENTRY_PATH_MODAL_PRIMARY_THRESHOLD_INVALID, + &template_creation::ENTRY_PATH_MODAL_SECONDARY, + &template_creation::ENTRY_PATH_MODAL_RECOVERY_UNIT_BLOCKS, + &template_creation::ENTRY_PATH_MODAL_RECOVERY_UNIT_HOURS, + &template_creation::ENTRY_PATH_MODAL_RECOVERY_UNIT_DAYS, + &template_creation::ENTRY_PATH_MODAL_RECOVERY_UNIT_MONTHS, + &template_creation::ENTRY_PATH_MODAL_RECOVERY_NO_KEYS, + &template_creation::ENTRY_PATH_MODAL_RECOVERY_NO_KEYS_OTHERS_VALID, + &template_creation::ENTRY_PATH_MODAL_RECOVERY_THRESHOLD_EMPTY, + &template_creation::ENTRY_PATH_MODAL_RECOVERY_THRESHOLD_TOO_HIGH, + &template_creation::ENTRY_PATH_MODAL_RECOVERY_THRESHOLD_NON_NUMERIC, + &template_creation::ENTRY_PATH_MODAL_RECOVERY_TIMELOCK_EMPTY, + &template_creation::ENTRY_PATH_MODAL_RECOVERY_TIMELOCK_ZERO, + &template_creation::ENTRY_PATH_MODAL_RECOVERY_TIMELOCK_TOO_LARGE, + &template_creation::ENTRY_PATH_MODAL_RECOVERY_TIMELOCK_TOO_LARGE_BLOCKS, + &template_creation::ENTRY_PATH_MODAL_RECOVERY_TIMELOCK_TOO_LARGE_DAYS, + &template_creation::ENTRY_PATH_MODAL_RECOVERY_TIMELOCK_TOO_LARGE_MONTHS, + &template_creation::ENTRY_PATH_MODAL_RECOVERY_TIMELOCK_DUPLICATE, + &template_creation::ENTRY_PATH_MODAL_RECOVERY_THRESHOLD_AND_TIMELOCK, + // keys metadata + &keys::ENTRY_KEYS_EMPTY, + &keys::ENTRY_KEYS_WITH_BREADCRUMB, + &keys::ENTRY_KEYS_MANY, + &keys::ENTRY_KEY_MODAL_NEW_EMPTY, + &keys::ENTRY_KEY_MODAL_NEW_ALIASED, + &keys::ENTRY_KEY_MODAL_EXTERNAL, + &keys::ENTRY_KEY_MODAL_INTERNAL, + &keys::ENTRY_KEY_MODAL_COSIGNER, + &keys::ENTRY_KEY_MODAL_SAFETY_NET, + &keys::ENTRY_KEY_MODAL_INVALID_EMAIL, + &keys::ENTRY_KEY_MODAL_EMPTY_ALIAS, + // key registration (xpub fetching) + &key_registration::ENTRY_XPUB, + &key_registration::ENTRY_XPUB_PARTIAL, + &key_registration::ENTRY_XPUB_ALL_SET, + &key_registration::ENTRY_XPUB_PARTICIPANT_NO_KEYS, + &key_registration::ENTRY_XPUB_WS_ADMIN, + &key_registration::ENTRY_XPUB_MODAL_SELECT, + &key_registration::ENTRY_XPUB_MODAL_SELECT_OPTIONS_EXPANDED, + &key_registration::ENTRY_XPUB_MODAL_SELECT_PASTE_EXPANDED, + &key_registration::ENTRY_XPUB_MODAL_SELECT_PASTE_COLLAPSED, + &key_registration::ENTRY_XPUB_MODAL_SELECT_WITH_CURRENT_XPUB, + &key_registration::ENTRY_XPUB_MODAL_SELECT_ONE_DEVICE_OPTIONS_EXPANDED, + &key_registration::ENTRY_XPUB_MODAL_SELECT_MULTIPLE_DEVICES, + &key_registration::ENTRY_XPUB_MODAL_DETAILS, + &key_registration::ENTRY_XPUB_MODAL_DETAILS_FETCHING, + &key_registration::ENTRY_XPUB_MODAL_DETAILS_FETCH_ERROR, + &key_registration::ENTRY_XPUB_MODAL_DETAILS_FETCH_SUCCESS, + &key_registration::ENTRY_XPUB_MODAL_DETAILS_WRONG_NETWORK, + &key_registration::ENTRY_XPUB_MODAL_DETAILS_ACCOUNT_5, + // descriptor registration + &descriptor_registration::ENTRY_REGISTRATION, + &descriptor_registration::ENTRY_REGISTRATION_WITH_DEVICES, + &descriptor_registration::ENTRY_REGISTRATION_MODAL_REGISTERING, + &descriptor_registration::ENTRY_REGISTRATION_MODAL_CONFIRM_COLDCARD, + &descriptor_registration::ENTRY_REGISTRATION_MODAL_ERROR, + // cross-cutting modals + &ENTRY_WARNING_MODAL, + &ENTRY_CONFLICT_MODAL_INFO, + &ENTRY_CONFLICT_MODAL_CHOICE, + &login::ENTRY_LOADING_OK, + &login::ENTRY_LOADING_ERROR, + ], +}; diff --git a/liana-business/business-installer/src/debug/orgs.rs b/liana-business/business-installer/src/debug/orgs.rs new file mode 100644 index 0000000000..39ef92a2b6 --- /dev/null +++ b/liana-business/business-installer/src/debug/orgs.rs @@ -0,0 +1,86 @@ +//! Organization selection step. + +use std::collections::BTreeSet; +use std::sync::OnceLock; + +use liana_connect::ws_business::{Org, UserRole, Wallet, WalletStatus}; +use liana_gui::debug::{installer_chrome, DebugMessage, DebugPageEntry}; +use liana_ui::widget::Element; +use uuid::Uuid; + +use crate::state::State; +use crate::views::org_select_view; + +use super::{build_state, StateCell}; + +const ORG_SELECT_PATH: &str = "business_installer::views::org_select::org_select_view"; + +pub static ENTRY_ORG_SELECT_WITH_ORGS: DebugPageEntry = DebugPageEntry { + view: render_org_select_with_orgs, +}; + +fn org_select_with_orgs_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_state(|s| { + s.app.global_user_role = Some(UserRole::WizardSardineAdmin); + // org_select_view hides any org whose accessible-wallet count + // is zero, so each mock org needs at least one wallet + // registered in the backend. + let names = [ + "Acme Vault", + "Treasury Coooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo", + "Cold Storage Inc.", + ]; + let mut wallets_guard = s.backend.wallets.lock().expect("poisoned"); + let mut orgs_guard = s.backend.orgs.lock().expect("poisoned"); + for (i, name) in names.iter().enumerate() { + let org_id = Uuid::from_u128(0x1000 + i as u128); + let wallet_id = Uuid::from_u128(0x6000 + i as u128); + wallets_guard.insert( + wallet_id, + Wallet { + alias: format!("{name} treasury"), + org: org_id, + owner: Uuid::nil(), + id: wallet_id, + // Not `Finalized`: WS Admins default to + // `hide_finalized = true`, which would zero out + // the org's accessible-wallet count and cause + // `org_select_view` to skip the row entirely. + status: WalletStatus::Drafted, + template: None, + last_edited: None, + last_editor: None, + descriptor: None, + devices: None, + }, + ); + let mut org_wallets = BTreeSet::new(); + org_wallets.insert(wallet_id); + orgs_guard.insert( + org_id, + Org { + name: (*name).to_string(), + id: org_id, + wallets: org_wallets, + users: BTreeSet::new(), + owners: Vec::new(), + last_edited: None, + last_editor: None, + }, + ); + } + })) + }) + .0 +} + +fn render_org_select_with_orgs() -> Element<'static, DebugMessage> { + let body = org_select_view(org_select_with_orgs_state()).map(|_| ()); + installer_chrome( + "Business installer — org select (with orgs)", + ORG_SELECT_PATH, + body, + ) +} diff --git a/liana-business/business-installer/src/debug/template_creation.rs b/liana-business/business-installer/src/debug/template_creation.rs new file mode 100644 index 0000000000..e6ee04e0a7 --- /dev/null +++ b/liana-business/business-installer/src/debug/template_creation.rs @@ -0,0 +1,825 @@ +//! Wallet template creation step + per-path edit modal. + +use std::collections::BTreeSet; +use std::sync::OnceLock; + +use liana_connect::ws_business::{ + Key, KeyIdentity, KeyType, Org, SpendingPath, UserRole, Wallet, WalletStatus, +}; +use liana_gui::debug::{installer_chrome, installer_with_modal, DebugMessage, DebugPageEntry}; +use liana_ui::widget::Element; +use uuid::Uuid; + +use crate::state::{ + views::path::{EditPathModalState, TimelockUnit}, + State, View, +}; +use crate::views::{paths::modal::path_modal_view, template_builder_view}; + +use super::{build_state, StateCell}; + +const TEMPLATE_PATH: &str = "business_installer::views::template_builder::template_builder_view"; +const PATH_MODAL_PATH: &str = "business_installer::views::paths::modal::path_modal_view"; + +pub static ENTRY_TEMPLATE_BUILDER: DebugPageEntry = DebugPageEntry { + view: render_template_builder, +}; +pub static ENTRY_TEMPLATE_BUILDER_OWNER: DebugPageEntry = DebugPageEntry { + view: render_template_builder_owner, +}; +pub static ENTRY_TEMPLATE_BUILDER_WS_ADMIN: DebugPageEntry = DebugPageEntry { + view: render_template_builder_ws_admin, +}; +pub static ENTRY_TEMPLATE_BUILDER_LOCKED: DebugPageEntry = DebugPageEntry { + view: render_template_builder_locked, +}; +pub static ENTRY_TEMPLATE_BUILDER_WS_ADMIN_SINGLE_KEY: DebugPageEntry = DebugPageEntry { + view: render_template_builder_ws_admin_single_key, +}; +pub static ENTRY_PATH_MODAL_PRIMARY: DebugPageEntry = DebugPageEntry { + view: render_path_modal_primary, +}; +pub static ENTRY_PATH_MODAL_SECONDARY: DebugPageEntry = DebugPageEntry { + view: render_path_modal_secondary, +}; +pub static ENTRY_PATH_MODAL_RECOVERY_NO_KEYS: DebugPageEntry = DebugPageEntry { + view: render_path_modal_recovery_no_keys, +}; +pub static ENTRY_PATH_MODAL_RECOVERY_THRESHOLD_EMPTY: DebugPageEntry = DebugPageEntry { + view: render_path_modal_recovery_threshold_empty, +}; +pub static ENTRY_PATH_MODAL_RECOVERY_THRESHOLD_TOO_HIGH: DebugPageEntry = DebugPageEntry { + view: render_path_modal_recovery_threshold_too_high, +}; +pub static ENTRY_PATH_MODAL_RECOVERY_THRESHOLD_NON_NUMERIC: DebugPageEntry = DebugPageEntry { + view: render_path_modal_recovery_threshold_non_numeric, +}; +pub static ENTRY_PATH_MODAL_RECOVERY_TIMELOCK_EMPTY: DebugPageEntry = DebugPageEntry { + view: render_path_modal_recovery_timelock_empty, +}; +pub static ENTRY_PATH_MODAL_RECOVERY_TIMELOCK_ZERO: DebugPageEntry = DebugPageEntry { + view: render_path_modal_recovery_timelock_zero, +}; +pub static ENTRY_PATH_MODAL_RECOVERY_TIMELOCK_TOO_LARGE: DebugPageEntry = DebugPageEntry { + view: render_path_modal_recovery_timelock_too_large, +}; +pub static ENTRY_PATH_MODAL_RECOVERY_TIMELOCK_TOO_LARGE_BLOCKS: DebugPageEntry = DebugPageEntry { + view: render_path_modal_recovery_timelock_too_large_blocks, +}; +pub static ENTRY_PATH_MODAL_RECOVERY_TIMELOCK_TOO_LARGE_DAYS: DebugPageEntry = DebugPageEntry { + view: render_path_modal_recovery_timelock_too_large_days, +}; +pub static ENTRY_PATH_MODAL_RECOVERY_TIMELOCK_TOO_LARGE_MONTHS: DebugPageEntry = DebugPageEntry { + view: render_path_modal_recovery_timelock_too_large_months, +}; +pub static ENTRY_PATH_MODAL_RECOVERY_UNIT_BLOCKS: DebugPageEntry = DebugPageEntry { + view: render_path_modal_recovery_unit_blocks, +}; +pub static ENTRY_PATH_MODAL_RECOVERY_UNIT_HOURS: DebugPageEntry = DebugPageEntry { + view: render_path_modal_recovery_unit_hours, +}; +pub static ENTRY_PATH_MODAL_RECOVERY_UNIT_DAYS: DebugPageEntry = DebugPageEntry { + view: render_path_modal_recovery_unit_days, +}; +pub static ENTRY_PATH_MODAL_RECOVERY_UNIT_MONTHS: DebugPageEntry = DebugPageEntry { + view: render_path_modal_recovery_unit_months, +}; +pub static ENTRY_PATH_MODAL_RECOVERY_NO_KEYS_OTHERS_VALID: DebugPageEntry = DebugPageEntry { + view: render_path_modal_recovery_no_keys_others_valid, +}; +pub static ENTRY_PATH_MODAL_PRIMARY_NO_KEYS: DebugPageEntry = DebugPageEntry { + view: render_path_modal_primary_no_keys, +}; +pub static ENTRY_PATH_MODAL_PRIMARY_THRESHOLD_EMPTY: DebugPageEntry = DebugPageEntry { + view: render_path_modal_primary_threshold_empty, +}; +pub static ENTRY_PATH_MODAL_PRIMARY_THRESHOLD_INVALID: DebugPageEntry = DebugPageEntry { + view: render_path_modal_primary_threshold_invalid, +}; +pub static ENTRY_PATH_MODAL_RECOVERY_TIMELOCK_DUPLICATE: DebugPageEntry = DebugPageEntry { + view: render_path_modal_recovery_timelock_duplicate, +}; +pub static ENTRY_PATH_MODAL_RECOVERY_THRESHOLD_AND_TIMELOCK: DebugPageEntry = DebugPageEntry { + view: render_path_modal_recovery_threshold_and_timelock, +}; + +fn shared_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| StateCell(build_state(|_| {}))).0 +} + +fn render_template_builder() -> Element<'static, DebugMessage> { + let body = template_builder_view(shared_state()).map(|_| ()); + installer_chrome("Business installer — template builder", TEMPLATE_PATH, body) +} + +fn template_builder_with_status(role: UserRole, status: WalletStatus) -> State { + build_state(|s| { + s.current_view = View::WalletEdit; + s.app.current_user_role = Some(role); + let org_id = Uuid::from_u128(0x4000); + let wallet_id = Uuid::from_u128(0x5000); + { + let mut wallets = s.backend.wallets.lock().expect("poisoned"); + wallets.insert( + wallet_id, + Wallet { + alias: "Acme treasury".to_string(), + org: org_id, + owner: Uuid::nil(), + id: wallet_id, + status, + template: None, + last_edited: None, + last_editor: None, + descriptor: None, + devices: None, + }, + ); + } + { + let mut org_wallets = BTreeSet::new(); + org_wallets.insert(wallet_id); + let mut orgs = s.backend.orgs.lock().expect("poisoned"); + orgs.insert( + org_id, + Org { + name: "Acme Vault".to_string(), + id: org_id, + wallets: org_wallets, + users: BTreeSet::new(), + owners: Vec::new(), + last_edited: None, + last_editor: None, + }, + ); + } + s.app.selected_org = Some(org_id); + s.app.selected_wallet = Some(wallet_id); + }) +} + +fn template_builder_owner_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(template_builder_with_status( + UserRole::WalletManager, + WalletStatus::Drafted, + )) + }) + .0 +} + +fn render_template_builder_owner() -> Element<'static, DebugMessage> { + let body = template_builder_view(template_builder_owner_state()).map(|_| ()); + installer_chrome( + "Business installer — template (wallet manager, draft)", + TEMPLATE_PATH, + body, + ) +} + +fn template_builder_ws_admin_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(template_builder_with_status( + UserRole::WizardSardineAdmin, + WalletStatus::Drafted, + )) + }) + .0 +} + +fn render_template_builder_ws_admin() -> Element<'static, DebugMessage> { + let body = template_builder_view(template_builder_ws_admin_state()).map(|_| ()); + installer_chrome( + "Business installer — template (WS admin, draft)", + TEMPLATE_PATH, + body, + ) +} + +fn template_builder_locked_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(template_builder_with_status( + UserRole::WalletManager, + WalletStatus::Locked, + )) + }) + .0 +} + +fn render_template_builder_locked() -> Element<'static, DebugMessage> { + let body = template_builder_view(template_builder_locked_state()).map(|_| ()); + installer_chrome( + "Business installer — template (locked)", + TEMPLATE_PATH, + body, + ) +} + +fn template_builder_ws_admin_single_key_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell({ + let mut s = + template_builder_with_status(UserRole::WizardSardineAdmin, WalletStatus::Drafted); + // Single key, primary path present but empty (no key_ids), no + // recovery paths → `is_template_valid()` returns false, so the + // "Send for approval" button is rendered disabled. + s.app.keys.clear(); + s.app.keys.insert( + 0, + Key { + id: 0, + alias: "Wallet manaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaager".to_string(), + description: String::new(), + identity: KeyIdentity::Email("owner@example.com".to_string()), + key_type: KeyType::Internal, + xpub: None, + xpub_source: None, + xpub_device_kind: None, + xpub_device_version: None, + xpub_file_name: None, + last_edited: None, + last_editor: None, + }, + ); + s.app.next_key_id = 1; + s.app.primary_path = SpendingPath::new(true, 1, Vec::new()); + s.app.secondary_paths.clear(); + s + }) + }) + .0 +} + +fn render_template_builder_ws_admin_single_key() -> Element<'static, DebugMessage> { + let body = template_builder_view(template_builder_ws_admin_single_key_state()).map(|_| ()); + installer_chrome( + "Business installer — template (WS admin, single key, empty primary, send disabled)", + TEMPLATE_PATH, + body, + ) +} + +// ---- path modal --------------------------------------------------------- + +fn path_modal_state(is_primary: bool) -> State { + build_state(|s| { + s.views.paths.edit_path_modal = Some(EditPathModalState { + is_primary, + path_index: if is_primary { None } else { Some(0) }, + selected_key_ids: if is_primary { vec![0, 1] } else { vec![1, 2] }, + threshold: if is_primary { + "2".to_string() + } else { + "1".to_string() + }, + timelock_value: if is_primary { + None + } else { + Some("8760".to_string()) + }, + timelock_unit: TimelockUnit::default(), + }); + }) +} + +fn path_modal_primary_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| StateCell(path_modal_state(true))).0 +} + +fn path_modal_secondary_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| StateCell(path_modal_state(false))).0 +} + +fn render_path_modal_primary() -> Element<'static, DebugMessage> { + let body = path_modal_view(path_modal_primary_state()) + .expect("modal state set") + .map(|_| ()); + installer_with_modal( + "Business installer — path modal (primary path)", + PATH_MODAL_PATH, + body, + ) +} + +fn render_path_modal_secondary() -> Element<'static, DebugMessage> { + let body = path_modal_view(path_modal_secondary_state()) + .expect("modal state set") + .map(|_| ()); + installer_with_modal( + "Business installer — path modal (recovery / secondary)", + PATH_MODAL_PATH, + body, + ) +} + +// ---- recovery path: error variants -------------------------------------- +// +// `path_modal_view` re-renders all of its validation messages purely from +// `EditPathModalState`, so each variant just builds the modal in the +// targeted error state and lets the production view light it up. The +// surrounding `State` keeps the default `seed_test_data` keys (3 keys) and +// secondary paths (8760 / 21900 blocks) so that: +// * the keys checklist always has rows to choose from; +// * the duplicate-timelock variant has another path to clash with. + +fn recovery_modal_state(modal: EditPathModalState) -> State { + build_state(|s| { + s.views.paths.edit_path_modal = Some(modal); + }) +} + +fn render_recovery_modal( + state: &'static State, + title: &'static str, +) -> Element<'static, DebugMessage> { + let body = path_modal_view(state).expect("modal state set").map(|_| ()); + installer_with_modal(title, PATH_MODAL_PATH, body) +} + +fn path_modal_recovery_no_keys_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(recovery_modal_state(EditPathModalState { + is_primary: false, + // `None` → "Create New Path" header (rather than "Edit"). + path_index: None, + selected_key_ids: Vec::new(), + threshold: String::new(), + timelock_value: Some(String::new()), + timelock_unit: TimelockUnit::default(), + })) + }) + .0 +} + +fn render_path_modal_recovery_no_keys() -> Element<'static, DebugMessage> { + render_recovery_modal( + path_modal_recovery_no_keys_state(), + "Business installer — path modal (recovery, no keys selected)", + ) +} + +fn path_modal_recovery_threshold_empty_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(recovery_modal_state(EditPathModalState { + is_primary: false, + path_index: None, + // Two keys → threshold row visible; empty threshold → save + // disabled but no warning text. + selected_key_ids: vec![0, 1], + threshold: String::new(), + timelock_value: Some("48".to_string()), + timelock_unit: TimelockUnit::default(), + })) + }) + .0 +} + +fn render_path_modal_recovery_threshold_empty() -> Element<'static, DebugMessage> { + render_recovery_modal( + path_modal_recovery_threshold_empty_state(), + "Business installer — path modal (recovery, empty threshold)", + ) +} + +fn path_modal_recovery_threshold_too_high_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(recovery_modal_state(EditPathModalState { + is_primary: false, + path_index: None, + selected_key_ids: vec![0, 1], + // 5 > selected_count (2) → "Invalid threshold value". + threshold: "5".to_string(), + timelock_value: Some("48".to_string()), + timelock_unit: TimelockUnit::default(), + })) + }) + .0 +} + +fn render_path_modal_recovery_threshold_too_high() -> Element<'static, DebugMessage> { + render_recovery_modal( + path_modal_recovery_threshold_too_high_state(), + "Business installer — path modal (recovery, threshold > selected)", + ) +} + +fn path_modal_recovery_threshold_non_numeric_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(recovery_modal_state(EditPathModalState { + is_primary: false, + path_index: None, + selected_key_ids: vec![0, 1], + // Non-numeric → parse error → same warning. + threshold: "abc".to_string(), + timelock_value: Some("48".to_string()), + timelock_unit: TimelockUnit::default(), + })) + }) + .0 +} + +fn render_path_modal_recovery_threshold_non_numeric() -> Element<'static, DebugMessage> { + render_recovery_modal( + path_modal_recovery_threshold_non_numeric_state(), + "Business installer — path modal (recovery, threshold non-numeric)", + ) +} + +fn path_modal_recovery_timelock_empty_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(recovery_modal_state(EditPathModalState { + is_primary: false, + path_index: None, + // Single key → no threshold row; empty timelock → save disabled + // (no warning row, but the field is marked invalid). + selected_key_ids: vec![1], + threshold: "1".to_string(), + timelock_value: Some(String::new()), + timelock_unit: TimelockUnit::default(), + })) + }) + .0 +} + +fn render_path_modal_recovery_timelock_empty() -> Element<'static, DebugMessage> { + render_recovery_modal( + path_modal_recovery_timelock_empty_state(), + "Business installer — path modal (recovery, empty timelock)", + ) +} + +fn path_modal_recovery_timelock_zero_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(recovery_modal_state(EditPathModalState { + is_primary: false, + path_index: None, + selected_key_ids: vec![1], + threshold: "1".to_string(), + // Parses to 0 blocks → "Timelock cannot be zero". + timelock_value: Some("0".to_string()), + timelock_unit: TimelockUnit::default(), + })) + }) + .0 +} + +fn render_path_modal_recovery_timelock_zero() -> Element<'static, DebugMessage> { + render_recovery_modal( + path_modal_recovery_timelock_zero_state(), + "Business installer — path modal (recovery, timelock = 0)", + ) +} + +fn path_modal_recovery_timelock_too_large_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(recovery_modal_state(EditPathModalState { + is_primary: false, + path_index: None, + selected_key_ids: vec![1], + threshold: "1".to_string(), + // Default unit is Hours; max_value(Hours) = 10922. 20000 > max + // → "Max 10922 hours". + timelock_value: Some("20000".to_string()), + timelock_unit: TimelockUnit::default(), + })) + }) + .0 +} + +fn render_path_modal_recovery_timelock_too_large() -> Element<'static, DebugMessage> { + render_recovery_modal( + path_modal_recovery_timelock_too_large_state(), + "Business installer — path modal (recovery, timelock > max for unit)", + ) +} + +fn path_modal_recovery_timelock_duplicate_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(recovery_modal_state(EditPathModalState { + is_primary: false, + // Editing existing path index 0 (blocks 8760 in `seed_test_data`). + // 3650 hours × BLOCKS_PER_HOUR(6) = 21900 blocks, which collides + // with the second seeded path (`seed_test_data` uses 21900) → the + // duplicate-timelock check fires. + path_index: Some(0), + selected_key_ids: vec![1], + threshold: "1".to_string(), + timelock_value: Some("3650".to_string()), + timelock_unit: TimelockUnit::default(), + })) + }) + .0 +} + +fn render_path_modal_recovery_timelock_duplicate() -> Element<'static, DebugMessage> { + render_recovery_modal( + path_modal_recovery_timelock_duplicate_state(), + "Business installer — path modal (recovery, duplicate timelock)", + ) +} + +fn path_modal_recovery_threshold_and_timelock_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(recovery_modal_state(EditPathModalState { + is_primary: false, + path_index: None, + // Two keys + invalid threshold + zero timelock — exercises both + // error rows simultaneously. + selected_key_ids: vec![0, 1], + threshold: "5".to_string(), + timelock_value: Some("0".to_string()), + timelock_unit: TimelockUnit::default(), + })) + }) + .0 +} + +fn render_path_modal_recovery_threshold_and_timelock() -> Element<'static, DebugMessage> { + render_recovery_modal( + path_modal_recovery_threshold_and_timelock_state(), + "Business installer — path modal (recovery, invalid threshold + zero timelock)", + ) +} + +// ---- per-unit too-large warnings ---------------------------------------- +// +// `MAX_TIMELOCK_BLOCKS = 65535`. Per unit: Blocks max 65535, +// Hours 10922 (65535/6), Days 455 (65535/144), Months 15 (special-cased). +// Each variant overshoots its unit so the "Max XXX " warning row +// appears below the timelock input. + +fn path_modal_recovery_timelock_too_large_blocks_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(recovery_modal_state(EditPathModalState { + is_primary: false, + path_index: None, + selected_key_ids: vec![1], + threshold: "1".to_string(), + timelock_value: Some("70000".to_string()), + timelock_unit: TimelockUnit::Blocks, + })) + }) + .0 +} + +fn render_path_modal_recovery_timelock_too_large_blocks() -> Element<'static, DebugMessage> { + render_recovery_modal( + path_modal_recovery_timelock_too_large_blocks_state(), + "Business installer — path modal (recovery, timelock > max blocks)", + ) +} + +fn path_modal_recovery_timelock_too_large_days_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(recovery_modal_state(EditPathModalState { + is_primary: false, + path_index: None, + selected_key_ids: vec![1], + threshold: "1".to_string(), + timelock_value: Some("500".to_string()), + timelock_unit: TimelockUnit::Days, + })) + }) + .0 +} + +fn render_path_modal_recovery_timelock_too_large_days() -> Element<'static, DebugMessage> { + render_recovery_modal( + path_modal_recovery_timelock_too_large_days_state(), + "Business installer — path modal (recovery, timelock > max days)", + ) +} + +fn path_modal_recovery_timelock_too_large_months_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(recovery_modal_state(EditPathModalState { + is_primary: false, + path_index: None, + selected_key_ids: vec![1], + threshold: "1".to_string(), + timelock_value: Some("20".to_string()), + timelock_unit: TimelockUnit::Months, + })) + }) + .0 +} + +fn render_path_modal_recovery_timelock_too_large_months() -> Element<'static, DebugMessage> { + render_recovery_modal( + path_modal_recovery_timelock_too_large_months_state(), + "Business installer — path modal (recovery, timelock > max months)", + ) +} + +// ---- per-unit valid variants -------------------------------------------- +// +// Same shape (1 key, threshold 1) — only the timelock unit differs so the +// designer can see the unit selector + "Max: X " hint per unit. + +fn path_modal_recovery_unit_blocks_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(recovery_modal_state(EditPathModalState { + is_primary: false, + path_index: None, + selected_key_ids: vec![1], + threshold: "1".to_string(), + timelock_value: Some("100".to_string()), + timelock_unit: TimelockUnit::Blocks, + })) + }) + .0 +} + +fn render_path_modal_recovery_unit_blocks() -> Element<'static, DebugMessage> { + render_recovery_modal( + path_modal_recovery_unit_blocks_state(), + "Business installer — path modal (recovery, unit = blocks)", + ) +} + +fn path_modal_recovery_unit_hours_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(recovery_modal_state(EditPathModalState { + is_primary: false, + path_index: None, + selected_key_ids: vec![1], + threshold: "1".to_string(), + timelock_value: Some("48".to_string()), + timelock_unit: TimelockUnit::Hours, + })) + }) + .0 +} + +fn render_path_modal_recovery_unit_hours() -> Element<'static, DebugMessage> { + render_recovery_modal( + path_modal_recovery_unit_hours_state(), + "Business installer — path modal (recovery, unit = hours)", + ) +} + +fn path_modal_recovery_unit_days_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(recovery_modal_state(EditPathModalState { + is_primary: false, + path_index: None, + selected_key_ids: vec![1], + threshold: "1".to_string(), + timelock_value: Some("30".to_string()), + timelock_unit: TimelockUnit::Days, + })) + }) + .0 +} + +fn render_path_modal_recovery_unit_days() -> Element<'static, DebugMessage> { + render_recovery_modal( + path_modal_recovery_unit_days_state(), + "Business installer — path modal (recovery, unit = days)", + ) +} + +fn path_modal_recovery_unit_months_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(recovery_modal_state(EditPathModalState { + is_primary: false, + path_index: None, + selected_key_ids: vec![1], + threshold: "1".to_string(), + timelock_value: Some("3".to_string()), + timelock_unit: TimelockUnit::Months, + })) + }) + .0 +} + +fn render_path_modal_recovery_unit_months() -> Element<'static, DebugMessage> { + render_recovery_modal( + path_modal_recovery_unit_months_state(), + "Business installer — path modal (recovery, unit = months)", + ) +} + +// ---- save-disabled solely because of missing keys ----------------------- +// +// Keys checklist empty, but timelock has a valid value. The threshold row +// is gated by `selected_count > 1`, so it stays hidden — the visible +// demonstration is "everything filled except the checkboxes". + +fn path_modal_recovery_no_keys_others_valid_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(recovery_modal_state(EditPathModalState { + is_primary: false, + path_index: None, + selected_key_ids: Vec::new(), + // Internally valid but threshold row not rendered with 0 keys. + threshold: "1".to_string(), + timelock_value: Some("48".to_string()), + timelock_unit: TimelockUnit::default(), + })) + }) + .0 +} + +fn render_path_modal_recovery_no_keys_others_valid() -> Element<'static, DebugMessage> { + render_recovery_modal( + path_modal_recovery_no_keys_others_valid_state(), + "Business installer — path modal (recovery, save disabled: no keys but timelock valid)", + ) +} + +// ---- primary path: error variants --------------------------------------- +// +// Primary paths have no timelock row, so the only blocking inputs are +// the keys checklist and (when 2+ keys selected) the threshold field. + +fn path_modal_primary_no_keys_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(recovery_modal_state(EditPathModalState { + is_primary: true, + path_index: None, + selected_key_ids: Vec::new(), + threshold: String::new(), + timelock_value: None, + timelock_unit: TimelockUnit::default(), + })) + }) + .0 +} + +fn render_path_modal_primary_no_keys() -> Element<'static, DebugMessage> { + render_recovery_modal( + path_modal_primary_no_keys_state(), + "Business installer — path modal (primary, no keys selected)", + ) +} + +fn path_modal_primary_threshold_empty_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(recovery_modal_state(EditPathModalState { + is_primary: true, + path_index: None, + // Two keys → threshold row visible; empty threshold disables save. + selected_key_ids: vec![0, 1], + threshold: String::new(), + timelock_value: None, + timelock_unit: TimelockUnit::default(), + })) + }) + .0 +} + +fn render_path_modal_primary_threshold_empty() -> Element<'static, DebugMessage> { + render_recovery_modal( + path_modal_primary_threshold_empty_state(), + "Business installer — path modal (primary, empty threshold)", + ) +} + +fn path_modal_primary_threshold_invalid_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(recovery_modal_state(EditPathModalState { + is_primary: true, + path_index: None, + selected_key_ids: vec![0, 1], + threshold: "5".to_string(), + timelock_value: None, + timelock_unit: TimelockUnit::default(), + })) + }) + .0 +} + +fn render_path_modal_primary_threshold_invalid() -> Element<'static, DebugMessage> { + render_recovery_modal( + path_modal_primary_threshold_invalid_state(), + "Business installer — path modal (primary, threshold > selected)", + ) +} diff --git a/liana-business/business-installer/src/debug/wallets.rs b/liana-business/business-installer/src/debug/wallets.rs new file mode 100644 index 0000000000..8b3a119834 --- /dev/null +++ b/liana-business/business-installer/src/debug/wallets.rs @@ -0,0 +1,112 @@ +//! Wallet selection step. + +use std::collections::BTreeSet; +use std::sync::OnceLock; + +use liana_connect::ws_business::{Org, UserRole, Wallet, WalletStatus}; +use liana_gui::debug::{installer_chrome, DebugMessage, DebugPageEntry}; +use liana_ui::widget::Element; +use uuid::Uuid; + +use crate::state::State; +use crate::views::wallet_select_view; + +use super::{build_state, StateCell}; + +const WALLET_SELECT_PATH: &str = "business_installer::views::wallet_select::wallet_select_view"; + +pub static ENTRY_WALLET_SELECT_WITH_WALLETS: DebugPageEntry = DebugPageEntry { + view: render_wallet_select_with_wallets, +}; + +fn wallet_select_with_wallets_state() -> &'static State { + static S: OnceLock> = OnceLock::new(); + &S.get_or_init(|| { + StateCell(build_state(|s| { + s.app.global_user_role = Some(UserRole::WizardSardineAdmin); + // Production hides Finalized for WS Admins via the + // `hide_finalized` checkbox; flip it off here so the Active + // pill is visible alongside the others. + s.views.wallet_select.hide_finalized = false; + let org_id = Uuid::from_u128(0x2000); + // One wallet per realistic `WalletStatus` (Created is a + // transient backend-only state never surfaced to this view in + // production, so we skip it). + let entries: &[(&str, WalletStatus)] = &[ + ("Drafted wallet", WalletStatus::Drafted), + ( + "Looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooocked wallet", + WalletStatus::Locked, + ), + ("Validated wallet", WalletStatus::Validated), + ("Registration wallet", WalletStatus::Registration), + ("Active wallet", WalletStatus::Finalized), + ("Drafted wallet", WalletStatus::Drafted), + ( + "Looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooocked wallet", + WalletStatus::Locked, + ), + ("Validated wallet", WalletStatus::Validated), + ("Registration wallet", WalletStatus::Registration), + ("Active wallet", WalletStatus::Finalized), + ("Drafted wallet", WalletStatus::Drafted), + ( + "Looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooocked wallet", + WalletStatus::Locked, + ), + ("Validated wallet", WalletStatus::Validated), + ("Registration wallet", WalletStatus::Registration), + ("Active wallet", WalletStatus::Finalized), + ]; + let mut wallet_ids = BTreeSet::new(); + { + let mut wallets = s.backend.wallets.lock().expect("poisoned"); + for (i, (alias, status)) in entries.iter().enumerate() { + let id = Uuid::from_u128(0x3000 + i as u128); + wallet_ids.insert(id); + wallets.insert( + id, + Wallet { + alias: (*alias).to_string(), + org: org_id, + owner: Uuid::nil(), + id, + status: *status, + template: None, + last_edited: None, + last_editor: None, + descriptor: None, + devices: None, + }, + ); + } + } + { + let mut orgs = s.backend.orgs.lock().expect("poisoned"); + orgs.insert( + org_id, + Org { + name: "Acme Vault".to_string(), + id: org_id, + wallets: wallet_ids, + users: BTreeSet::new(), + owners: Vec::new(), + last_edited: None, + last_editor: None, + }, + ); + } + s.app.selected_org = Some(org_id); + })) + }) + .0 +} + +fn render_wallet_select_with_wallets() -> Element<'static, DebugMessage> { + let body = wallet_select_view(wallet_select_with_wallets_state()).map(|_| ()); + installer_chrome( + "Business installer — wallet select (with wallets)", + WALLET_SELECT_PATH, + body, + ) +} diff --git a/liana-business/business-installer/src/installer.rs b/liana-business/business-installer/src/installer.rs index de2ccfd6da..5d4844b03f 100644 --- a/liana-business/business-installer/src/installer.rs +++ b/liana-business/business-installer/src/installer.rs @@ -3,7 +3,7 @@ use crate::state::{ Msg as Message, SharedWaker, State, }; use crossbeam::channel::{self}; -use iced::Task; +use iced::{event, keyboard, Subscription, Task}; use liana::miniscript::bitcoin::{self}; use liana_gui::{ dir::LianaDirectory, @@ -101,6 +101,25 @@ impl Installer<'_, Message> for BusinessInstaller { self.state.update(message) } + fn subscription(&self) -> Subscription { + if self.state.views.login.current != LoginState::AccountSelect { + return Subscription::none(); + } + + iced::event::listen_with(|event, status, _| match (event, status) { + ( + iced::Event::Keyboard(keyboard::Event::KeyPressed { ref key, .. }), + event::Status::Ignored, + ) => match key { + keyboard::Key::Character(c) if c.as_str().eq_ignore_ascii_case("s") => { + Some(Message::AccountSelectSwitchSignet) + } + _ => None, + }, + _ => None, + }) + } + fn view(&self) -> Element<'_, Message> { self.state.view() } diff --git a/liana-business/business-installer/src/lib.rs b/liana-business/business-installer/src/lib.rs index c51febde1e..92b9a4d72f 100644 --- a/liana-business/business-installer/src/lib.rs +++ b/liana-business/business-installer/src/lib.rs @@ -4,5 +4,8 @@ mod installer; mod state; mod views; +#[cfg(feature = "debugger")] +pub mod debug; + pub use installer::BusinessInstaller; pub use state::Msg as Message; diff --git a/liana-business/business-installer/src/state/message.rs b/liana-business/business-installer/src/state/message.rs index 00efa80e0b..331894b3cc 100644 --- a/liana-business/business-installer/src/state/message.rs +++ b/liana-business/business-installer/src/state/message.rs @@ -17,6 +17,7 @@ pub enum Msg { AccountSelectConnect(String), // Connect with cached account by email AccountSelectDelete(String), // Delete cached account by email AccountSelectNewEmail, // Start fresh login with new email + AccountSelectSwitchSignet, // Switch account selection to Signet network // Org management OrgSelected(Uuid), // Select an organization diff --git a/liana-business/business-installer/src/state/mod.rs b/liana-business/business-installer/src/state/mod.rs index 340b0de74b..3ff577a95c 100644 --- a/liana-business/business-installer/src/state/mod.rs +++ b/liana-business/business-installer/src/state/mod.rs @@ -57,6 +57,8 @@ pub struct State { hw_running: bool, /// Bitcoin network (mainnet, testnet, signet, regtest) pub network: Network, + /// Datadir used to reload network-specific cache state. + pub datadir: LianaDirectory, /// Dedicated sender for HwiService - messages are bridged to notif_sender with waking hw_sender: channel::Sender, /// Handle to the bridge thread (kept alive until State is dropped) @@ -114,6 +116,7 @@ impl State { hw, hw_running: false, network, + datadir, hw_sender, _hw_bridge_handle: Some(hw_bridge_handle), bitbox_config, @@ -121,6 +124,37 @@ impl State { } } + /// Build a stripped-down `State` suitable for the debug overlay: + /// no tokio runtime handle, no HW bridge thread, stub channels. + /// View functions never read `backend` / `hw`, so they render + /// faithfully against this; do not use for `update()`. + #[cfg(feature = "debugger")] + pub(crate) fn for_debug(network: Network, datadir: LianaDirectory) -> Self { + let (notif_sender, notif_receiver) = channel::unbounded(); + let notif_waker: SharedWaker = Arc::new(Mutex::new(None)); + let (hw_sender, _hw_receiver) = channel::unbounded::(); + let bitbox_config: Arc = + Arc::new(PersistedBitboxNoiseConfig::new(&datadir)); + let hw = HwiService::new(network, None); + Self { + app: AppState::new(), + views: views::ViewsState::new(), + backend: Client::new(notif_sender.clone(), notif_waker.clone()), + current_view: View::Login, + notif_sender, + notif_receiver, + notif_waker, + hw, + hw_running: false, + network, + datadir, + hw_sender, + _hw_bridge_handle: None, + bitbox_config, + connection_error: None, + } + } + /// Start hardware wallet listening (call when modal opens) pub fn start_hw(&mut self) { if !self.hw_running { diff --git a/liana-business/business-installer/src/state/update.rs b/liana-business/business-installer/src/state/update.rs index 39e7733c28..88d0003808 100644 --- a/liana-business/business-installer/src/state/update.rs +++ b/liana-business/business-installer/src/state/update.rs @@ -36,6 +36,7 @@ impl State { Msg::AccountSelectConnect(email) => return self.on_account_select_connect(email), Msg::AccountSelectDelete(email) => self.on_account_select_delete(email), Msg::AccountSelectNewEmail => return self.on_account_select_new_email(), + Msg::AccountSelectSwitchSignet => return self.on_account_select_switch_signet(), // Org management Msg::OrgSelected(id) => self.on_org_selected(id), @@ -264,6 +265,36 @@ impl State { self.views.login.current = views::LoginState::EmailEntry; } } + + /// Switch the login cache to signet and refresh the account list. + fn on_account_select_switch_signet(&mut self) -> Task { + if self.network == miniscript::bitcoin::Network::Signet { + return Task::none(); + } + + self.network = miniscript::bitcoin::Network::Signet; + self.backend.set_network(self.network, self.datadir.clone()); + + let was_hw_running = self.hw_running; + self.stop_hw(); + let rt = tokio::runtime::Handle::current().clone(); + self.hw = async_hwi::service::HwiService::new(self.network, Some(rt)); + self.hw.set_bitbox_noise_config(self.bitbox_config.clone()); + if was_hw_running { + self.hw.start(self.hw_sender.clone()); + self.hw_running = true; + } + + let (valid_accounts, to_remove) = self.backend.validate_all_cached_tokens(); + self.backend.clear_invalid_tokens(&to_remove); + self.views.login = views::login::Login::with_cached_accounts(valid_accounts); + + if self.views.login.current == views::LoginState::EmailEntry { + text_input::focus("login_email") + } else { + Task::none() + } + } } // Org management diff --git a/liana-business/business-installer/src/views/keys/modal.rs b/liana-business/business-installer/src/views/keys/modal.rs index 9a66ea1674..57c071dd43 100644 --- a/liana-business/business-installer/src/views/keys/modal.rs +++ b/liana-business/business-installer/src/views/keys/modal.rs @@ -8,7 +8,7 @@ use liana_ui::{ component::{ button::{btn_cancel, btn_save}, form, - modal::{modal_view, none_fn, ModalWidth}, + modal::{modal_view, ModalWidth}, pick_list, text, tooltip, }, theme, @@ -179,8 +179,8 @@ pub fn edit_key_modal_view<'a>( modal_view( Some(title.to_string()), - none_fn(), - Some(|| Message::KeyCancelModal), + None::, + Some(Message::KeyCancelModal), ModalWidth::M, body, ) diff --git a/liana-business/business-installer/src/views/login/account_select.rs b/liana-business/business-installer/src/views/login/account_select.rs index 65ed6a1ead..4809e9ea3f 100644 --- a/liana-business/business-installer/src/views/login/account_select.rs +++ b/liana-business/business-installer/src/views/login/account_select.rs @@ -13,6 +13,7 @@ use liana_ui::{ }, widget::*, }; +use miniscript::bitcoin::Network; pub fn account_select_view(state: &State) -> Element<'_, Msg> { let accounts = &state.views.login.account_select.accounts; @@ -32,10 +33,24 @@ pub fn account_select_view(state: &State) -> Element<'_, Msg> { Space::with_width(Length::Fill), ]; + let network_hint = if state.network == Network::Signet { + "Signet network active" + } else { + "Press S to switch to Signet" + }; + + let network_text = row![ + Space::with_width(Length::Fill), + text::p2_medium(network_hint), + Space::with_width(Length::Fill), + ]; + let header_content = Column::new() .push(liana_business) .push(Space::with_height(30)) .push(select_account_text) + .push(Space::with_height(10)) + .push(network_text) .push(Space::with_height(30)); // Scrollable list of accounts diff --git a/liana-business/business-installer/src/views/modals/conflict.rs b/liana-business/business-installer/src/views/modals/conflict.rs index 9df37cd98b..3b119abf51 100644 --- a/liana-business/business-installer/src/views/modals/conflict.rs +++ b/liana-business/business-installer/src/views/modals/conflict.rs @@ -3,7 +3,7 @@ use iced::{widget::Space, Length}; use liana_ui::{ component::{ button::{btn_keep_changes, btn_ok, btn_reload}, - modal::{modal_view, none_fn, ModalWidth}, + modal::{modal_view, ModalWidth}, text, }, theme, @@ -33,8 +33,8 @@ pub fn conflict_modal_view(modal_state: &ConflictModalState) -> Element<'_, Msg> modal_view( Some(modal_state.title.clone()), - none_fn(), - Some(|| Msg::ConflictDismiss), + None, + Some(Msg::ConflictDismiss), ModalWidth::M, body, ) diff --git a/liana-business/business-installer/src/views/modals/warning.rs b/liana-business/business-installer/src/views/modals/warning.rs index 0303bf61a7..40782b9e11 100644 --- a/liana-business/business-installer/src/views/modals/warning.rs +++ b/liana-business/business-installer/src/views/modals/warning.rs @@ -3,7 +3,7 @@ use iced::{widget::Space, Length}; use liana_ui::{ component::{ button::btn_ok, - modal::{modal_view, none_fn, ModalWidth}, + modal::{modal_view, ModalWidth}, text, }, theme, @@ -22,8 +22,8 @@ pub fn warning_modal_view(modal_state: &WarningModalState) -> Element<'_, Msg> { modal_view( Some(modal_state.title.clone()), - none_fn(), - Some(|| Msg::WarningCloseModal), + None, + Some(Msg::WarningCloseModal), ModalWidth::M, body, ) diff --git a/liana-business/business-installer/src/views/paths/modal.rs b/liana-business/business-installer/src/views/paths/modal.rs index 1e77a2e6c1..673907cc05 100644 --- a/liana-business/business-installer/src/views/paths/modal.rs +++ b/liana-business/business-installer/src/views/paths/modal.rs @@ -13,7 +13,7 @@ use liana_ui::{ component::{ button::{btn_cancel, btn_save}, form, - modal::{modal_view, none_fn, ModalWidth}, + modal::{modal_view, ModalWidth}, pick_list, text::{self, short_email, truncate}, }, @@ -291,8 +291,8 @@ pub fn edit_path_modal_view<'a>( modal_view( Some(title.to_string()), - none_fn(), - Some(|| Msg::TemplateCancelPathModal), + None, + Some(Msg::TemplateCancelPathModal), ModalWidth::M, body, ) diff --git a/liana-business/business-installer/src/views/registration/mod.rs b/liana-business/business-installer/src/views/registration/mod.rs index 3749532dcc..47f9c503b0 100644 --- a/liana-business/business-installer/src/views/registration/mod.rs +++ b/liana-business/business-installer/src/views/registration/mod.rs @@ -26,8 +26,22 @@ use super::{INSTALLER_STEPS, MENU_ENTRY_WIDTH}; /// Main registration view pub fn registration_view(state: &State) -> Element<'_, Msg> { let reg_state = &state.views.registration; + let list_content = if reg_state.user_devices.is_empty() { + no_devices_view() + } else { + device_list_view(state) + }; + registration_view_with_cards(state, list_content, !reg_state.user_devices.is_empty()) +} - // Get org name and wallet name from backend +/// Variant of [`registration_view`] that takes a pre-built device-list element. +/// The production view computes its list from `state.views.registration.user_devices`; +/// the debug gallery passes a custom list of `key_card` variants. +pub(crate) fn registration_view_with_cards<'a>( + state: &'a State, + list_content: Element<'a, Msg>, + has_devices: bool, +) -> Element<'a, Msg> { let org_name = state .app .selected_org @@ -42,10 +56,8 @@ pub fn registration_view(state: &State) -> Element<'_, Msg> { .unwrap_or_else(|| "Wallet".to_string()); let breadcrumb = vec![org_name, wallet_name, "Register Devices".to_string()]; - // Get current user email let current_user_email = &state.views.login.email.form.value; - // Header content let header_content = Column::new() .spacing(10) .align_x(Alignment::Center) @@ -63,17 +75,7 @@ pub fn registration_view(state: &State) -> Element<'_, Msg> { Space::with_width(Length::Fill) ]; - // List content: device cards or info message - let list_content = if reg_state.user_devices.is_empty() { - no_devices_view() - } else { - device_list_view(state) - }; - - // Footer with Skip button (only if there are devices to skip) - let footer_content = if reg_state.user_devices.is_empty() { - None - } else { + let footer_content = if has_devices { let spacer = MENU_ENTRY_WIDTH - BtnWidth::XL as u32; let skip_btn = btn_skip(Some(Msg::RegistrationSkipAll)); let footer = row![ @@ -83,7 +85,6 @@ pub fn registration_view(state: &State) -> Element<'_, Msg> { Space::with_width(Length::Fill), ] .align_y(Alignment::Center); - Some( Container::new(footer) .padding(20) @@ -91,6 +92,8 @@ pub fn registration_view(state: &State) -> Element<'_, Msg> { .center_x(Length::Fill) .into(), ) + } else { + None }; layout_with_scrollable_list( diff --git a/liana-business/business-installer/src/views/registration/modal.rs b/liana-business/business-installer/src/views/registration/modal.rs index cb9f0d498f..3b40ff7da5 100644 --- a/liana-business/business-installer/src/views/registration/modal.rs +++ b/liana-business/business-installer/src/views/registration/modal.rs @@ -7,7 +7,7 @@ use iced::Alignment; use liana_ui::{ component::{ button::{btn_cancel, btn_no, btn_retry, btn_yes}, - modal::{modal_view, none_fn, ModalWidth}, + modal::{modal_view, ModalWidth}, text, }, theme, @@ -45,8 +45,8 @@ fn registering_view(_modal_state: &RegistrationModalState) -> Element<'_, Msg> { modal_view( Some("Registering Wallet".to_string()), - none_fn(), - none_fn(), + None, + None, ModalWidth::S, body, ) @@ -75,8 +75,8 @@ fn error_view(modal_state: &RegistrationModalState) -> Element<'_, Msg> { modal_view( Some("Registration Failed".to_string()), - none_fn(), - none_fn(), + None, + None, ModalWidth::S, body, ) @@ -106,8 +106,8 @@ fn confirm_coldcard_view(_modal_state: &RegistrationModalState) -> Element<'_, M modal_view( Some("Confirm Registration".to_string()), - none_fn(), - none_fn(), + None, + None, ModalWidth::S, body, ) diff --git a/liana-business/business-installer/src/views/xpub/modal.rs b/liana-business/business-installer/src/views/xpub/modal.rs index 10c372a7b8..0f863b010c 100644 --- a/liana-business/business-installer/src/views/xpub/modal.rs +++ b/liana-business/business-installer/src/views/xpub/modal.rs @@ -13,7 +13,7 @@ use liana_ui::{ component::{ button::{btn_cancel, btn_clear, btn_retry, btn_save}, form, - modal::{self, modal_view, none_fn, ModalWidth}, + modal::{self, modal_view, ModalWidth}, pick_list, scrollable, text::{self, capitalize_first, p1_bold, truncate}, tooltip, @@ -123,15 +123,15 @@ fn select_view<'a>(state: &'a State, modal_state: &'a XpubEntryModalState) -> El let alias = truncate(&modal_state.key_alias, 25); modal_view( Some(format!("Select key source - {alias}")), - none_fn(), - Some(|| Msg::XpubCancelModal), + None, + Some(Msg::XpubCancelModal), ModalWidth::L, body, ) } /// Render the Details view - shows account picker and fetch status -fn details_view(modal_state: &XpubEntryModalState) -> Element<'_, Msg> { +pub(crate) fn details_view(modal_state: &XpubEntryModalState) -> Element<'_, Msg> { // Account selection picker let accounts: Vec<_> = (0..10) .map(|i| ChildNumber::from_hardened_idx(i).expect("hardcoded")) @@ -230,8 +230,8 @@ fn details_view(modal_state: &XpubEntryModalState) -> Element<'_, Msg> { let alias = truncate(&modal_state.key_alias, 25); modal_view( Some(alias), - Some(|| Msg::XpubDeviceBack), - Some(|| Msg::XpubCancelModal), + Some(Msg::XpubDeviceBack), + Some(Msg::XpubCancelModal), ModalWidth::M, body, ) @@ -315,7 +315,7 @@ fn device_card(data: DeviceRenderData) -> Element<'static, Msg> { None, None, None, - Some(move || Msg::XpubSelectDevice(fp)), + Some(Msg::XpubSelectDevice(fp)), ) } DeviceState::Locked { pairing_code } => { @@ -335,7 +335,7 @@ fn device_card(data: DeviceRenderData) -> Element<'static, Msg> { None, None, Some(message), - none_fn(), + None, ) } DeviceState::Unsupported { reason } => { @@ -364,7 +364,7 @@ fn device_card(data: DeviceRenderData) -> Element<'static, Msg> { None, None, Some(message), - none_fn(), + None, ) } } diff --git a/liana-business/src/debug/mod.rs b/liana-business/src/debug/mod.rs new file mode 100644 index 0000000000..886adb2cc7 --- /dev/null +++ b/liana-business/src/debug/mod.rs @@ -0,0 +1,108 @@ +//! Business-specific debug stack. +//! +//! Surfaces the views in [`crate::settings::views`] in the debug overlay +//! provided by `liana-gui`. Aggregated into [`EXTRA_STACKS`], which the +//! binary's `main.rs` hands to `liana_gui::gui::GUI::new` so that the +//! overlay's stack list contains every business panel after liana-gui's +//! built-in stacks. +//! +//! The whole module is gated by the `debugger` cargo feature. + +use std::str::FromStr; +use std::sync::{Arc, OnceLock}; + +use liana::descriptors::LianaDescriptor; +use liana_gui::{ + app::wallet::Wallet, + debug::{dashboard_chrome, DebugMessage, DebugPageEntry, DebugStack, SETTINGS_MENU}, +}; +use liana_ui::widget::Element; + +use crate::settings::{ui::BusinessSettingsUI, views, BackendCurrency}; + +/// Sample descriptor used for `wallet_view`. Simple two-key Liana +/// descriptor — inert; nothing in the rendering path validates it. +const SAMPLE_DESCRIPTOR: &str = "wsh(or_d(pk([19608592/48'/1'/0'/2']tpubDEjf1AbrUjxnw8jg6Gi12CunPqnCobLP6Ktoy4Hd52pa65d6QRPg5CSkdFrqPDjJ8BAUuMEDVDRQVjtuWWksMqBeZCqyABFucN9ErQq8oVX/<0;1>/*),and_v(v:pkh([19608592/48'/1'/0'/2']tpubDEjf1AbrUjxnw8jg6Gi12CunPqnCobLP6Ktoy4Hd52pa65d6QRPg5CSkdFrqPDjJ8BAUuMEDVDRQVjtuWWksMqBeZCqyABFucN9ErQq8oVX/<2;3>/*),older(52596))))#x6u6lmej"; + +pub static ENTRY_LIST: DebugPageEntry = DebugPageEntry { view: render_list }; +pub static ENTRY_WALLET: DebugPageEntry = DebugPageEntry { + view: render_wallet, +}; +pub static ENTRY_GENERAL_OFF: DebugPageEntry = DebugPageEntry { + view: render_general_off, +}; +pub static ENTRY_GENERAL_ON: DebugPageEntry = DebugPageEntry { + view: render_general_on, +}; +pub static ENTRY_ABOUT: DebugPageEntry = DebugPageEntry { view: render_about }; + +pub const BUSINESS_SETTINGS: DebugStack = DebugStack { + name: "Business settings", + menu: Some(&SETTINGS_MENU), + pages: &[ + &ENTRY_LIST, + &ENTRY_WALLET, + &ENTRY_GENERAL_OFF, + &ENTRY_GENERAL_ON, + &ENTRY_ABOUT, + ], +}; + +/// Slice handed to `liana_gui::gui::GUI::new` from `main.rs`. Append new +/// business / business-installer stacks here as they appear. +pub const EXTRA_STACKS: &[&DebugStack] = &[ + &BUSINESS_SETTINGS, + &business_installer::debug::INSTALLER_STACK, +]; + +/// SAFETY: iced renders on the main thread; debug-overlay state is only +/// read during rendering, so satisfying `OnceLock`'s `Sync` bound with an +/// unconditional `unsafe impl Sync` is sound here. Mirrors the wrapper +/// used by `liana_gui::debug::installer_modals::StateCell`. +struct StateCell(T); +unsafe impl Sync for StateCell {} + +fn debug_settings_state() -> &'static BusinessSettingsUI { + static STATE: OnceLock> = OnceLock::new(); + &STATE + .get_or_init(|| { + let descriptor = + LianaDescriptor::from_str(SAMPLE_DESCRIPTOR).expect("sample descriptor parses"); + let wallet = Arc::new(Wallet::new(descriptor)); + StateCell(BusinessSettingsUI::for_debug(wallet)) + }) + .0 +} + +fn render_list() -> Element<'static, DebugMessage> { + let body = views::list_view().map(|_| ()); + dashboard_chrome(&SETTINGS_MENU, "Business settings — sections", body) +} + +fn render_wallet() -> Element<'static, DebugMessage> { + let body = views::wallet_view(debug_settings_state()).map(|_| ()); + dashboard_chrome(&SETTINGS_MENU, "Business settings — wallet", body) +} + +fn render_general_off() -> Element<'static, DebugMessage> { + let body = views::general_view(false, BackendCurrency::USD).map(|_| ()); + dashboard_chrome( + &SETTINGS_MENU, + "Business settings — general (fiat off)", + body, + ) +} + +fn render_general_on() -> Element<'static, DebugMessage> { + let body = views::general_view(true, BackendCurrency::EUR).map(|_| ()); + dashboard_chrome( + &SETTINGS_MENU, + "Business settings — general (fiat on, EUR)", + body, + ) +} + +fn render_about() -> Element<'static, DebugMessage> { + let body = views::about_view().map(|_| ()); + dashboard_chrome(&SETTINGS_MENU, "Business settings — about", body) +} diff --git a/liana-business/src/lib.rs b/liana-business/src/lib.rs index 34bd309bae..bf845f114c 100644 --- a/liana-business/src/lib.rs +++ b/liana-business/src/lib.rs @@ -10,6 +10,9 @@ pub use business_installer::{BusinessInstaller, Message}; pub mod settings; pub use settings::BusinessSettings; +#[cfg(feature = "debugger")] +pub mod debug; + pub const VERSION: &str = concat!( env!("CARGO_PKG_VERSION_MAJOR"), ".", diff --git a/liana-business/src/main.rs b/liana-business/src/main.rs index a789609da5..9ffd70b772 100644 --- a/liana-business/src/main.rs +++ b/liana-business/src/main.rs @@ -68,7 +68,7 @@ fn main() -> Result<(), Box> { LianaBusiness::new( (config.clone(), log_level, VERSION), #[cfg(feature = "debugger")] - &[], // Pass liana-business debug stack later here + liana_business::debug::EXTRA_STACKS, ) }, LianaBusiness::update, diff --git a/liana-business/src/settings/ui.rs b/liana-business/src/settings/ui.rs index 7f8b883bca..c6d25ecb6b 100644 --- a/liana-business/src/settings/ui.rs +++ b/liana-business/src/settings/ui.rs @@ -108,6 +108,26 @@ impl SettingsUI for BusinessSettingsUI { } } +/// Test/debug helpers. Visible only when the `debugger` feature is on so +/// the production code path doesn't grow new public surface. +#[cfg(feature = "debugger")] +impl BusinessSettingsUI { + /// Build a snapshot of `BusinessSettingsUI` from a wallet, with all + /// other fields set to their inert defaults. Used by the debug + /// overlay (`liana_business::debug`) to render `wallet_view` and + /// friends without standing up a real settings flow. + pub(crate) fn for_debug(wallet: Arc) -> Self { + Self { + data_dir: LianaDirectory::new(std::path::PathBuf::new()), + wallet, + current_section: None, + fiat_setting: PriceSetting::default(), + processing: false, + register_modal: None, + } + } +} + // Update handlers impl BusinessSettingsUI { fn on_select_section(&mut self, section: Section) -> Task { diff --git a/liana-business/src/settings/views/mod.rs b/liana-business/src/settings/views/mod.rs index 01d2e00a28..b34d758038 100644 --- a/liana-business/src/settings/views/mod.rs +++ b/liana-business/src/settings/views/mod.rs @@ -6,8 +6,9 @@ use iced::{ }; use liana_ui::{ component::{ - self, badge, button, card, pick_list, scrollable, separation, - setting::{header, settings_section, SectionKind}, + self, badge, button, card, + panels::setting::{header, settings_section, SectionKind}, + pick_list, scrollable, separation, text::*, }, icon, theme, @@ -30,7 +31,7 @@ pub fn list_view() -> Element<'static, Msg> { let general = settings_section(SectionKind::General, Msg::SelectSection(Section::General)); let about = settings_section(SectionKind::About, Msg::SelectSection(Section::About)); - component::setting::section_list(vec![general, wallet, about]) + component::panels::setting::section_list(vec![general, wallet, about]) } /// Wallet settings section view. diff --git a/liana-gui/Cargo.toml b/liana-gui/Cargo.toml index c04922f0c3..64fca5122a 100644 --- a/liana-gui/Cargo.toml +++ b/liana-gui/Cargo.toml @@ -18,7 +18,8 @@ path = "src/main.rs" [dependencies] async-trait = { workspace = true } -async-hwi = { workspace = true } +async-hwi = { workspace = true, features = ["service", "ledger", "jade", "coldcard", "bitbox", "specter"] } +crossbeam = { version = "0.8.4", features = ["crossbeam-channel"] } liana = { path = "../liana" } liana-connect = { workspace = true } lianad = { path = "../lianad", default-features = false, features = ["nonblocking_shutdown"] } diff --git a/liana-gui/src/app/state/label.rs b/liana-gui/src/app/state/label.rs index ad48990c82..5a7c2332a4 100644 --- a/liana-gui/src/app/state/label.rs +++ b/liana-gui/src/app/state/label.rs @@ -21,6 +21,17 @@ impl LabelsEdited { pub fn cache(&self) -> &HashMap> { &self.0 } + /// Seed the edit cache for `key` with its current `value` so an edit form pre-fills. + pub fn edit(&mut self, key: String, value: String) { + self.0.insert( + key, + form::Value { + valid: true, + warning: None, + value, + }, + ); + } pub fn update<'a, T: IntoIterator>( &mut self, daemon: Arc, @@ -75,6 +86,9 @@ impl LabelsEdited { Message::LabelsUpdated, )); } + view::LabelMessage::Edit => { + // TODO: open label edit modal + } }, Message::LabelsUpdated(res) => match res { Ok(new_labels) => { diff --git a/liana-gui/src/app/state/receive.rs b/liana-gui/src/app/state/receive.rs index 03c746d5af..d0ba4ae6a1 100644 --- a/liana-gui/src/app/state/receive.rs +++ b/liana-gui/src/app/state/receive.rs @@ -1,12 +1,12 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; -use iced::{widget::qr_code, Subscription, Task}; +use iced::{widget::qr_code, Length, Subscription, Task}; use liana::miniscript::bitcoin::{ bip32::{ChildNumber, Fingerprint}, Address, Network, }; -use liana_ui::{widget::modal, widget::*}; +use liana_ui::{component::form, widget::modal, widget::*}; use crate::daemon::model::LabelsLoader; use crate::dir::LianaDirectory; @@ -34,6 +34,8 @@ const PREV_ADDRESSES_PAGE_SIZE: usize = 20; pub enum Modal { VerifyAddress(VerifyAddressModal), ShowQrCode(ShowQrCodeModal), + EditLabel(String), + NewAddress(NewAddressModal), None, } @@ -65,11 +67,9 @@ impl Labelled for Addresses { pub struct ReceivePanel { data_dir: LianaDirectory, wallet: Arc, - addresses: Addresses, prev_addresses: Addresses, prev_continue_from: Option, show_prev_addresses: bool, - selected: HashSet
, labels_edited: LabelsEdited, modal: Modal, warning: Option, @@ -81,11 +81,9 @@ impl ReceivePanel { Self { data_dir, wallet, - addresses: Addresses::default(), prev_addresses: Addresses::default(), prev_continue_from: None, show_prev_addresses: false, - selected: HashSet::new(), labels_edited: LabelsEdited::default(), modal: Modal::None, warning: None, @@ -94,23 +92,11 @@ impl ReceivePanel { } pub fn address(&self, i: usize) -> Option<&Address> { - if i < self.addresses.list.len() { - self.addresses.list.get(i) - } else { - // i >= self.addresses.list.len() - self.prev_addresses.list.get(i - self.addresses.list.len()) - } + self.prev_addresses.list.get(i) } pub fn derivation_index(&self, i: usize) -> Option<&ChildNumber> { - if i < self.addresses.list.len() { - self.addresses.derivation_indexes.get(i) - } else { - // i >= self.addresses.list.len() - self.prev_addresses - .derivation_indexes - .get(i - self.addresses.list.len()) - } + self.prev_addresses.derivation_indexes.get(i) } } @@ -121,12 +107,9 @@ impl State for ReceivePanel { cache, self.warning.as_ref(), view::receive::receive( - &self.addresses.list, - &self.addresses.labels, &self.prev_addresses.list, &self.prev_addresses.labels, self.show_prev_addresses, - &self.selected, self.labels_edited.cache(), self.prev_continue_from.is_none(), self.processing, @@ -140,15 +123,36 @@ impl State for ReceivePanel { Modal::ShowQrCode(m) => modal::Modal::new(content, m.view()) .on_blur(Some(view::Message::Close)) .into(), + Modal::EditLabel(addr) => { + let value = self + .labels_edited + .cache() + .get(addr) + .expect("seeded when EditLabel modal opened"); + modal::Modal::new(content, view::receive::edit_label_modal(addr, value)) + .on_blur(Some(view::Message::Label( + vec![addr.clone()], + view::LabelMessage::Cancel, + ))) + .into() + } + Modal::NewAddress(m) => modal::Modal::new(content, m.view()) + .on_blur(Some(view::Message::NewAddress( + view::NewAddressMessage::Close, + ))) + .into(), Modal::None => content, } } fn subscription(&self) -> Subscription { - if let Modal::VerifyAddress(modal) = &self.modal { - modal.subscription() - } else { - Subscription::none() + match &self.modal { + Modal::VerifyAddress(modal) => modal.subscription(), + Modal::NewAddress(NewAddressModal { + sub: Some(NewAddressSubModal::Verify(modal)), + .. + }) => modal.subscription(), + _ => Subscription::none(), } } @@ -159,13 +163,38 @@ impl State for ReceivePanel { message: Message, ) -> Task { match message { + Message::View(view::Message::Label(items, view::LabelMessage::Edit)) => { + let addr = items.into_iter().next().unwrap_or_default(); + let current = self + .prev_addresses + .labels + .get(&addr) + .cloned() + .unwrap_or_default(); + self.labels_edited.edit(addr.clone(), current); + self.modal = Modal::EditLabel(addr); + Task::none() + } + Message::View(view::Message::Label(_, view::LabelMessage::Confirm)) + | Message::View(view::Message::Label(_, view::LabelMessage::Cancel)) => { + self.modal = Modal::None; + match self.labels_edited.update( + daemon, + message, + std::iter::once(&mut self.prev_addresses as &mut dyn LabelsLoader), + ) { + Ok(cmd) => cmd, + Err(e) => { + self.warning = Some(e); + Task::none() + } + } + } Message::View(view::Message::Label(_, _)) | Message::LabelsUpdated(_) => { match self.labels_edited.update( daemon, message, - std::iter::once(&mut self.addresses) - .chain(std::iter::once(&mut self.prev_addresses)) - .map(|a| a as &mut dyn LabelsLoader), + std::iter::once(&mut self.prev_addresses as &mut dyn LabelsLoader), ) { Ok(cmd) => cmd, Err(e) => { @@ -178,14 +207,93 @@ impl State for ReceivePanel { match res { Ok((address, derivation_index)) => { self.warning = None; - self.addresses.list.push(address); - self.addresses.derivation_indexes.push(derivation_index); + self.modal = + Modal::NewAddress(NewAddressModal::new(address, derivation_index)); } Err(e) => self.warning = Some(e), } Task::none() } + Message::View(view::Message::NewAddress(msg)) => match msg { + view::NewAddressMessage::LabelEdited(s) => { + if let Modal::NewAddress(m) = &mut self.modal { + // Empty is valid (no warning); the Generate button is gated + // on non-empty by the modal itself. + m.label.valid = s.len() <= 100; + m.label.value = s; + } + Task::none() + } + view::NewAddressMessage::Confirm => { + if let Modal::NewAddress(m) = &mut self.modal { + m.show_address = true; + } + Task::none() + } + view::NewAddressMessage::Verify => { + let verify = if let Modal::NewAddress(m) = &self.modal { + Some(VerifyAddressModal::new( + self.data_dir.clone(), + self.wallet.clone(), + cache.network, + m.address.clone(), + m.derivation_index, + )) + } else { + None + }; + if let (Modal::NewAddress(m), Some(verify)) = (&mut self.modal, verify) { + m.sub = Some(NewAddressSubModal::Verify(verify)); + } + Task::none() + } + view::NewAddressMessage::ShowQr => { + let qr = if let Modal::NewAddress(m) = &self.modal { + ShowQrCodeModal::new(&m.address, m.derivation_index) + } else { + None + }; + if let (Modal::NewAddress(m), Some(qr)) = (&mut self.modal, qr) { + m.sub = Some(NewAddressSubModal::Qr(qr)); + } + Task::none() + } + view::NewAddressMessage::Close => { + let finish = if let Modal::NewAddress(m) = &self.modal { + m.show_address + .then(|| (m.address.clone(), m.derivation_index, m.label.value.clone())) + } else { + None + }; + self.modal = Modal::None; + if let Some((address, index, label)) = finish { + let key = LabelItem::Address(address.clone()).to_string(); + self.prev_addresses.list.insert(0, address.clone()); + self.prev_addresses.derivation_indexes.insert(0, index); + self.prev_addresses.labels.insert(key, label.clone()); + let updated = HashMap::from([(LabelItem::Address(address), Some(label))]); + return Task::perform( + async move { + daemon + .update_labels(&updated) + .await + .map(|_| HashMap::new()) + .map_err(|e| e.into()) + }, + Message::LabelsUpdated, + ); + } + Task::none() + } + }, Message::View(view::Message::Close) => { + // Closing a stacked sub-modal returns to the show-address modal. + if let Modal::NewAddress(m) = &mut self.modal { + if m.sub.is_some() { + m.sub = None; + return Task::none(); + } + } self.modal = Modal::None; Task::none() } @@ -220,14 +328,6 @@ impl State for ReceivePanel { self.show_prev_addresses = !self.show_prev_addresses; Task::none() } - Message::View(view::Message::SelectAddress(addr)) => { - if self.selected.contains(&addr) { - self.selected.remove(&addr); - } else { - self.selected.insert(addr); - } - Task::none() - } Message::RevealedAddresses(res, start_index) => { self.processing = false; match res { @@ -295,13 +395,14 @@ impl State for ReceivePanel { } Task::none() } - _ => { - if let Modal::VerifyAddress(ref mut m) = self.modal { - m.update(daemon, cache, message) - } else { - Task::none() - } - } + _ => match &mut self.modal { + Modal::VerifyAddress(m) => m.update(daemon, cache, message), + Modal::NewAddress(NewAddressModal { + sub: Some(NewAddressSubModal::Verify(m)), + .. + }) => m.update(daemon, cache, message), + _ => Task::none(), + }, } } @@ -435,6 +536,67 @@ impl ShowQrCodeModal { } } +/// A modal stacked on top of the show-address step (verify on hardware, or QR code). +#[allow(clippy::large_enum_variant)] +enum NewAddressSubModal { + Verify(VerifyAddressModal), + Qr(ShowQrCodeModal), +} + +impl NewAddressSubModal { + fn view(&self) -> Element<'_, view::Message> { + match self { + NewAddressSubModal::Verify(m) => m.view(), + NewAddressSubModal::Qr(m) => m.view(), + } + } +} + +/// Two-step modal for a freshly generated address: enter a mandatory label, then +/// display the address. The address is added to the list when the modal is closed. +pub struct NewAddressModal { + address: Address, + derivation_index: ChildNumber, + label: form::Value, + show_address: bool, + /// Verify/QR modal stacked on top of the show-address step, if open. + sub: Option, +} + +impl NewAddressModal { + fn new(address: Address, derivation_index: ChildNumber) -> Self { + Self { + address, + derivation_index, + label: form::Value { + value: String::new(), + warning: None, + valid: true, + }, + show_address: false, + sub: None, + } + } + + fn view(&self) -> Element<'_, view::Message> { + let base = if self.show_address { + view::receive::new_address_show_modal(&self.address) + } else { + view::receive::new_address_label_modal(&self.label) + }; + // A nested sub-modal stacks on top of the show-address modal: it becomes + // an overlay over the (full-screen) base, so the base stays rendered behind + // and reappears once the sub-modal is closed. + if let Some(sub) = &self.sub { + modal::Modal::new(Container::new(base).center(Length::Fill), sub.view()) + .on_blur(Some(view::Message::Close)) + .into() + } else { + base + } + } +} + async fn verify_address( hw: std::sync::Arc, index: ChildNumber, @@ -488,6 +650,8 @@ mod tests { ChildNumber::from_normal_idx(0).unwrap() ))), ), + // updatelabels, triggered when the new-address modal is closed. + (None, Ok(json!(null))), ]); let wallet = Arc::new(Wallet::new(LianaDescriptor::from_str(DESC).unwrap())); let sandbox: Sandbox = Sandbox::new(ReceivePanel::new( @@ -499,13 +663,45 @@ mod tests { let sandbox = sandbox.load(client.clone(), &cache, wallet).await; let sandbox = sandbox .update( - client, + client.clone(), &cache, Message::View(viewMessage::NextReceiveAddress), ) .await; + // Generating opens the new-address modal at the mandatory-label step. + assert!(matches!( + &sandbox.state().modal, + Modal::NewAddress(m) if m.address == addr && !m.show_address + )); + + // Enter a label, confirm to reach the show-address step, then close. + let sandbox = sandbox + .update( + client.clone(), + &cache, + Message::View(viewMessage::NewAddress( + view::NewAddressMessage::LabelEdited("test".to_string()), + )), + ) + .await; + let sandbox = sandbox + .update( + client.clone(), + &cache, + Message::View(viewMessage::NewAddress(view::NewAddressMessage::Confirm)), + ) + .await; + let sandbox = sandbox + .update( + client, + &cache, + Message::View(viewMessage::NewAddress(view::NewAddressMessage::Close)), + ) + .await; + let panel = sandbox.state(); - assert_eq!(panel.addresses.list, vec![addr]); + assert_eq!(panel.prev_addresses.list, vec![addr]); + assert!(matches!(panel.modal, Modal::None)); } } diff --git a/liana-gui/src/app/view/home/mod.rs b/liana-gui/src/app/view/home/mod.rs index 9a5b64f6f8..6179e5733e 100644 --- a/liana-gui/src/app/view/home/mod.rs +++ b/liana-gui/src/app/view/home/mod.rs @@ -5,8 +5,12 @@ pub use payment_details::payment_details_view; use liana::miniscript::bitcoin; use liana_ui::{ component::{ - home::{self, rescan_warning, SyncProgress}, - payment::{self, payment_card, PaymentKind, UIPayment}, + self, + panels::home::{ + self, + payment::{payment_card, PaymentKind, UIPayment}, + rescan_warning, SyncProgress, + }, text::new, }, widget::{Column, ColumnExt, Element}, @@ -95,8 +99,8 @@ pub fn home_view<'a>( } }); - let see_more = - (!is_last_page && !events.is_empty()).then(|| payment::see_more(processing, Message::Next)); + let see_more = (!is_last_page && !events.is_empty()) + .then(|| component::list::see_more(processing, Message::Next)); #[rustfmt::skip] let payment_list = column![ diff --git a/liana-gui/src/app/view/message.rs b/liana-gui/src/app/view/message.rs index 3521284dab..b527566ba1 100644 --- a/liana-gui/src/app/view/message.rs +++ b/liana-gui/src/app/view/message.rs @@ -5,7 +5,7 @@ use crate::{ node::bitcoind::RpcAuthType, services::fiat::{Currency, PriceSource}, }; -use liana::miniscript::bitcoin::{bip32::Fingerprint, Address, OutPoint}; +use liana::miniscript::bitcoin::{bip32::Fingerprint, OutPoint}; pub trait Close { fn close() -> Self; @@ -22,8 +22,8 @@ pub enum Message { SelectPayment(OutPoint), Label(Vec, LabelMessage), NextReceiveAddress, + NewAddress(NewAddressMessage), ToggleShowPreviousAddresses, - SelectAddress(Address), Settings(SettingsMessage), CreateSpend(CreateSpendMessage), ImportSpend(ImportSpendMessage), @@ -48,11 +48,21 @@ impl Close for Message { #[derive(Debug, Clone)] pub enum LabelMessage { + Edit, Edited(String), Cancel, Confirm, } +#[derive(Debug, Clone)] +pub enum NewAddressMessage { + LabelEdited(String), + Confirm, + Verify, + ShowQr, + Close, +} + #[derive(Debug, Clone)] pub enum CreateSpendMessage { AddRecipient, diff --git a/liana-gui/src/app/view/psbt.rs b/liana-gui/src/app/view/psbt.rs index 2b28cefa7d..92f55a7583 100644 --- a/liana-gui/src/app/view/psbt.rs +++ b/liana-gui/src/app/view/psbt.rs @@ -20,7 +20,7 @@ use liana_ui::{ button, card, collapse::Collapse, form, - modal::{legacy, modal_view, none_fn, ModalWidth}, + modal::{legacy, modal_view, ModalWidth}, pill, scrollable, separation, text::{self, *}, }, @@ -1000,7 +1000,7 @@ pub fn sign_action<'a>( .width(Length::Fill); let width = ModalWidth::M; - let content = modal_view(Some(title), none_fn(), none_fn(), width, modal_content); + let content = modal_view(Some(title), None, None, width, modal_content); let width = width as u32 + 50; Column::new() diff --git a/liana-gui/src/app/view/receive.rs b/liana-gui/src/app/view/receive.rs index 7fca3ef0c0..d945b84a14 100644 --- a/liana-gui/src/app/view/receive.rs +++ b/liana-gui/src/app/view/receive.rs @@ -17,7 +17,8 @@ use liana::miniscript::bitcoin::{ use liana_ui::{ component::{ - button, card, form, scrollable, + button, card, form, label as ui_label, panels::receive, + scrollable, text::{self, *}, }, icon, theme, @@ -33,7 +34,7 @@ use crate::{ hw::HardwareWallet, }; -use super::message::Message; +use super::message::{LabelMessage, Message, NewAddressMessage}; fn address_card<'a>( row_index: usize, @@ -84,19 +85,13 @@ fn address_card<'a>( #[allow(clippy::too_many_arguments)] pub fn receive<'a>( - addresses: &'a [bitcoin::Address], - labels: &'a HashMap, prev_addresses: &'a [bitcoin::Address], prev_labels: &'a HashMap, show_prev_addresses: bool, - selected: &'a HashSet, labels_editing: &'a HashMap>, is_last_page: bool, processing: bool, ) -> Element<'a, Message> { - // Number of start and end address characters to show in collapsed view. - const NUM_ADDR_CHARS: usize = 16; - let mut addresses_count = 0; // for counting number of new addresses generated Column::new() .push( Row::new() @@ -104,7 +99,7 @@ pub fn receive<'a>( .push(Container::new(panel_title(Menu::Receive.title())).width(Length::Fill)) .push({ let (icon, label) = (Some(icon::plus_icon()), "Generate address"); - if addresses.is_empty() { + if prev_addresses.is_empty() { button::primary(icon, label) } else { button::secondary(icon, label) @@ -113,118 +108,17 @@ pub fn receive<'a>( }), ) .push(text("Always generate a new address for each deposit.")) - .push( - Row::new() - .spacing(10) - .push(addresses.iter().enumerate().rev().fold( - // iterate starting from most recently generated - Column::new().spacing(10).width(Length::Fill), - |col, (i, address)| { - addresses_count += 1; - col.push(address_card(i, address, labels, labels_editing)) - }, - )), - ) .push_maybe( - (!prev_addresses.is_empty()).then_some( - Container::new( - Button::new( - Row::new() - .align_y(Alignment::Center) - .push( - p1_bold("Previously generated addresses still awaiting deposit") - .width(Length::Fill), - ) - .push(if show_prev_addresses { - icon::collapsed_icon() - } else { - icon::collapse_icon() - }), - ) - .on_press(Message::ToggleShowPreviousAddresses) - .padding(20) - .width(Length::Fill) - .style(theme::button::transparent_border), - ) - .style(theme::card::button_simple), - ), + (!prev_addresses.is_empty()).then_some(receive::previous_addresses_header( + show_prev_addresses, + Message::ToggleShowPreviousAddresses, + )), ) .push_maybe(show_prev_addresses.then_some(Row::new().spacing(10).push( prev_addresses.iter().enumerate().fold( - // prev addresses are already ordered in descending order Column::new().spacing(10).width(Length::Fill), |col, (i, address)| { - col.push(if !selected.contains(address) { - Button::new( - Row::new() - .spacing(10) - .push( - { - let addr = address.to_string(); - let addr_len = addr.chars().count(); - Container::new( - p2_regular(if addr_len > 2 * NUM_ADDR_CHARS { - format!( - "{}...{}", - addr.chars() - .take(NUM_ADDR_CHARS) - .collect::(), - addr.chars() - .skip(addr_len - NUM_ADDR_CHARS) - .collect::(), - ) - } else { - addr - }) - .small() - .style(theme::text::secondary), - ) - } - .padding(10) - .width(Length::Fixed(350.0)), - ) - .push( - Container::new(scrollable::horizontal_thin( - Column::new() - .push(Space::with_height(Length::Fixed(10.0))) - .push( - text( - prev_labels - .get(&address.to_string()) - .cloned() - .unwrap_or_default(), - ) - .small() - .style(theme::text::secondary), - ), - )) - .padding(10) - .width(Length::Fill), - ) - .align_y(Alignment::Center), - ) - .on_press(Message::SelectAddress(address.clone())) - .style(theme::button::clickable_card) - } else { - // Continue the row index from those of generated addresses above. - let addr_str = address.to_string(); - let is_editing = labels_editing.contains_key(&addr_str); - let btn = Button::new(address_card( - addresses_count + i, - address, - prev_labels, - labels_editing, - )) - .padding(0) // so that button & card borders match - .style(theme::button::clickable_card); - // Do not set on_press while editing label so that - // clicking the form does not collapse the card. - if is_editing { - btn - } else { - btn.on_press(Message::SelectAddress(address.clone())) - } - }) + col.push(address_card(i, address, prev_labels, labels_editing)) }, ), ))) @@ -353,3 +247,44 @@ pub fn qr_modal<'a>(qr: &'a qr_code::Data, address: &'a String) -> Element<'a, M .max_width(400) .into() } + +pub fn edit_label_modal<'a>(address: &str, value: &'a form::Value) -> Element<'a, Message> { + let addr = address.to_string(); + let on_change = { + let addr = addr.clone(); + move |s| Message::Label(vec![addr.clone()], LabelMessage::Edited(s)) + }; + let confirm = Message::Label(vec![addr.clone()], LabelMessage::Confirm); + let cancel = Message::Label(vec![addr], LabelMessage::Cancel); + ui_label::edit_label_modal( + "Edit label", + "Enter an address label", + value, + on_change, + confirm, + cancel, + false, + ) +} + +pub fn new_address_label_modal<'a>(value: &'a form::Value) -> Element<'a, Message> { + ui_label::edit_label_modal( + "Label", + "Enter an address label", + value, + |s| Message::NewAddress(NewAddressMessage::LabelEdited(s)), + Message::NewAddress(NewAddressMessage::Confirm), + Message::NewAddress(NewAddressMessage::Close), + true, + ) +} + +pub fn new_address_show_modal<'a>(address: &Address) -> Element<'a, Message> { + receive::modal::show_address_modal( + address, + Message::NewAddress(NewAddressMessage::Close), + Message::NewAddress(NewAddressMessage::Verify), + Message::NewAddress(NewAddressMessage::ShowQr), + Message::Clipboard(address.to_string()), + ) +} diff --git a/liana-gui/src/app/view/settings/general.rs b/liana-gui/src/app/view/settings/general.rs index 70716ab2f6..5003269154 100644 --- a/liana-gui/src/app/view/settings/general.rs +++ b/liana-gui/src/app/view/settings/general.rs @@ -1,11 +1,11 @@ use iced::widget::{tooltip, Column, Row, Space, Toggler}; use iced::{Alignment, Length}; -use liana_ui::component::setting::SectionKind; use super::{header, SETTING_MSG}; use liana_ui::color; use liana_ui::component::card; +use liana_ui::component::panels::setting::SectionKind; use liana_ui::component::pick_list; use liana_ui::component::text::*; use liana_ui::component::tooltip_custom; diff --git a/liana-gui/src/app/view/settings/mod.rs b/liana-gui/src/app/view/settings/mod.rs index 0ba3bda2d6..d8556a9543 100644 --- a/liana-gui/src/app/view/settings/mod.rs +++ b/liana-gui/src/app/view/settings/mod.rs @@ -19,8 +19,11 @@ use liana_ui::{ component::{ self, badge, button::{self, btn_secondary_with_tooltip, BtnWidth}, - card, form, scrollable, separation, - setting::{export_section, header, settings_section, ImportExportKind, SectionKind}, + card, form, + panels::setting::{ + export_section, header, settings_section, ImportExportKind, SectionKind, + }, + scrollable, separation, text::*, }, icon, @@ -86,7 +89,7 @@ pub fn list(cache: &Cache, is_remote_backend: bool) -> Element<'_, Message> { about ]; - let content = component::setting::section_list(entries); + let content = component::panels::setting::section_list(entries); dashboard(&Menu::Settings, cache, None, content) } diff --git a/liana-gui/src/daemon/model.rs b/liana-gui/src/daemon/model.rs index 05d679abfb..2b40e2f1b8 100644 --- a/liana-gui/src/daemon/model.rs +++ b/liana-gui/src/daemon/model.rs @@ -10,7 +10,7 @@ pub use liana::{ secp256k1, Address, Amount, Network, OutPoint, Transaction, Txid, }, }; -use liana_ui::component::payment::PaymentKind; +use liana_ui::component::panels::home::payment::PaymentKind; pub use lianad::commands::{ CreateSpendResult, GetAddressResult, GetInfoResult, GetLabelsResult, LabelItem, ListCoinsEntry, ListCoinsResult, ListRevealedAddressesEntry, ListRevealedAddressesResult, ListSpendEntry, diff --git a/liana-gui/src/debug/buttons.rs b/liana-gui/src/debug/buttons.rs new file mode 100644 index 0000000000..3a722f72a3 --- /dev/null +++ b/liana-gui/src/debug/buttons.rs @@ -0,0 +1,258 @@ +//! Two galleries: +//! +//! - **Themes** — every `liana_ui::theme::button::*` style across the four +//! interactive states (Active, Hovered, Pressed, Disabled). Samples are +//! styled `Container`s, not real `Button` widgets, so all four states can +//! be displayed unconditionally. +//! - **Constructors** — every hardcoded `liana_ui::component::button::*` +//! helper rendered as real `Button` widgets, in both the interactive form +//! (`on_press` set) and the disabled form (`on_press` omitted). Click +//! events are swallowed at the GUI boundary by mapping `DebugMessage` to +//! `Message::DebugNoOp`. + +use iced::{widget::button, Alignment, Length}; +use liana_ui::{ + component::{ + button::{self as btn}, + text, + }, + icon, theme, + widget::*, +}; + +use crate::debug::{debug_chrome, DebugMessage, DebugPageEntry}; + +type StyleFn = fn(&theme::Theme, button::Status) -> button::Style; + +pub static ENTRY_THEMES: DebugPageEntry = DebugPageEntry { view: themes_view }; +pub static ENTRY_CONSTRUCTORS_THEMED: DebugPageEntry = DebugPageEntry { + view: constructors_themed_view, +}; +pub static ENTRY_CONSTRUCTORS_WIDTHS: DebugPageEntry = DebugPageEntry { + view: constructors_widths_view, +}; +pub static ENTRY_CONSTRUCTORS_HELPERS: DebugPageEntry = DebugPageEntry { + view: constructors_helpers_view, +}; + +const NAME_WIDTH: Length = Length::Fixed(220.0); +const STATE_WIDTH: Length = Length::Fixed(140.0); +const PATH_WIDTH: Length = Length::Fixed(600.0); +const SAMPLE_WIDTH: Length = Length::Fixed(600.0); +const ROW_SPACING: f32 = 12.0; +const STATES: [(button::Status, &str); 4] = [ + (button::Status::Active, "Active"), + (button::Status::Hovered, "Hovered"), + (button::Status::Pressed, "Pressed"), + (button::Status::Disabled, "Disabled"), +]; + +#[rustfmt::skip] +const THEMES: &[(&str, StyleFn)] = &[ + ("primary", theme::button::primary), + ("secondary", theme::button::secondary), + ("tertiary", theme::button::tertiary), + ("destructive", theme::button::destructive), + ("container", theme::button::container), + ("container_border", theme::button::container_border), + ("clickable_card", theme::button::clickable_card), + ("menu", theme::button::menu), + ("tab_menu", theme::button::tab_menu), + ("transparent", theme::button::transparent), + ("transparent_primary_text", theme::button::transparent_primary_text), + ("transparent_border", theme::button::transparent_border), + ("link", theme::button::link), +]; + +// ----- Themes table --------------------------------------------------------- + +/// Non-interactive label styled exactly like a button in `status`. The style +/// fn is re-evaluated at draw time so palette changes are picked up. +fn fake_button(style_fn: StyleFn, status: button::Status) -> Container<'static, DebugMessage> { + Container::new(text::p1_regular("Sample")) + .padding(10) + .style(move |theme: &theme::Theme| { + let bs = style_fn(theme, status); + iced::widget::container::Style { + text_color: Some(bs.text_color), + background: bs.background, + border: bs.border, + shadow: bs.shadow, + ..Default::default() + } + }) +} + +fn header_cell(label: &'static str, width: Length) -> Container<'static, DebugMessage> { + Container::new(text::p1_bold(label)).center_x(width) +} + +fn state_cell(style_fn: StyleFn, status: button::Status) -> Container<'static, DebugMessage> { + Container::new(fake_button(style_fn, status)).center_x(STATE_WIDTH) +} + +fn themes_table() -> Column<'static, DebugMessage> { + let header = STATES.iter().fold( + Row::new() + .spacing(ROW_SPACING) + .align_y(Alignment::Center) + .push(header_cell("Theme", NAME_WIDTH)), + |row, (_, label)| row.push(header_cell(label, STATE_WIDTH)), + ); + + THEMES.iter().fold( + Column::new().spacing(ROW_SPACING).push(header), + |col, (name, style_fn)| { + let row = STATES.iter().fold( + Row::new() + .spacing(ROW_SPACING) + .align_y(Alignment::Center) + .push(Container::new(text::p1_regular(*name)).width(NAME_WIDTH)), + |row, (status, _)| row.push(state_cell(*style_fn, *status)), + ); + col.push(row) + }, + ) +} + +// ----- Constructors table --------------------------------------------------- + +type ConstructorRow = ( + &'static str, + Element<'static, DebugMessage>, + Element<'static, DebugMessage>, +); + +fn constructor_row( + path: &'static str, + interactive: impl Into>, +) -> Row<'static, DebugMessage> { + Row::new() + .spacing(ROW_SPACING) + .align_y(Alignment::Center) + .push(Container::new(text::p1_regular(path)).width(PATH_WIDTH)) + .push(Container::new(interactive).center_x(SAMPLE_WIDTH)) + // .push(Container::new(disabled).center_x(SAMPLE_WIDTH)) +} + +/// Build a `(path, interactive, disabled)` row from a single button +/// constructor closure. The closure must produce a button with no +/// `on_press` set (i.e. its disabled form); the helper calls it twice and +/// derives the interactive form by attaching `on_press(())`. +fn row(path: &'static str, builder: impl Fn() -> Button<'static, DebugMessage>) -> ConstructorRow { + (path, builder().on_press(()).into(), builder().into()) +} + +/// Compose a constructors table from pre-built rows. Caller is responsible +/// for keeping the row count under the per-page cap (`MAX_ROWS_PER_PAGE`). +fn constructors_table(rows: Vec) -> Column<'static, DebugMessage> { + let header = Row::new() + .spacing(ROW_SPACING) + .align_y(Alignment::Center) + .push(header_cell("Constructor", PATH_WIDTH)) + .push(header_cell("Interactive", SAMPLE_WIDTH)); + + rows.into_iter().fold( + Column::new().spacing(ROW_SPACING).push(header), + |col, (path, interactive, _disabled)| col.push(constructor_row(path, interactive)), + ) +} + +/// Per-page cap on row count for constructor tables. Splitting at 15 keeps +/// each page short enough to render and compare without scrolling far. +const MAX_ROWS_PER_PAGE: usize = 15; + +fn themes_view() -> Element<'static, DebugMessage> { + debug_chrome("Button themes", themes_table()) +} + +fn constructors_themed_view() -> Element<'static, DebugMessage> { + #[rustfmt::skip] + let rows = vec![ + row("liana_ui::component::button::primary(None, \"Sample\")", || btn::primary(None, "Sample")), + row("liana_ui::component::button::secondary(None, \"Sample\")", || btn::secondary(None, "Sample")), + row("liana_ui::component::button::tertiary(None, \"Sample\")", || btn::tertiary(None, "Sample")), + row("liana_ui::component::button::destructive(None, \"Sample\")", || btn::destructive(None, "Sample")), + row("liana_ui::component::button::alert(None, \"Sample\")", || btn::alert(None, "Sample")), + row("liana_ui::component::button::transparent(None, \"Sample\")", || btn::transparent(None, "Sample")), + row("liana_ui::component::button::flat(None, \"Sample\")", || btn::flat(None, "Sample")), + row("liana_ui::component::button::border(None, \"Sample\")", || btn::border(None, "Sample")), + row("liana_ui::component::button::transparent_border(None, \"Sample\")", || btn::transparent_border(None, "Sample")), + row("liana_ui::component::button::link(None, \"Sample\")", || btn::link(None, "Sample")), + row("liana_ui::component::button::clickable_card(None, \"Sample\")", || btn::clickable_card( text::text("Sample"), Some(()))), + row("liana_ui::component::button::clickable_section(None, \"Sample\")", || btn::clickable_section( text::text("Sample"), Some(()))), + ]; + debug_assert!(rows.len() <= MAX_ROWS_PER_PAGE); + debug_chrome("Button constructors — themed", constructors_table(rows)) +} + +fn constructors_widths_view() -> Element<'static, DebugMessage> { + use btn::BtnWidth; + #[rustfmt::skip] + let rows = vec![ + // Each preset width applied to btn_primary, then to btn_secondary so + // the difference between Primary and Secondary at every size is + // visible side by side. + row("btn_primary(None, \"S\", BtnWidth::S, _)", || btn::btn_primary(None, "S", BtnWidth::S, None)), + row("btn_primary(None, \"M\", BtnWidth::M, _)", || btn::btn_primary(None, "M", BtnWidth::M, None)), + row("btn_primary(None, \"L\", BtnWidth::L, _)", || btn::btn_primary(None, "L", BtnWidth::L, None)), + row("btn_primary(None, \"XL\", BtnWidth::XL, _)", || btn::btn_primary(None, "XL", BtnWidth::XL, None)), + row("btn_primary(None, \"XXL\", BtnWidth::XXL, _)", || btn::btn_primary(None, "XXL", BtnWidth::XXL, None)), + row("btn_secondary(None, \"S\", BtnWidth::S, _)", || btn::btn_secondary(None, "S", BtnWidth::S, None)), + row("btn_secondary(None, \"M\", BtnWidth::M, _)", || btn::btn_secondary(None, "M", BtnWidth::M, None)), + row("btn_secondary(None, \"L\", BtnWidth::L, _)", || btn::btn_secondary(None, "L", BtnWidth::L, None)), + row("btn_secondary(None, \"XL\", BtnWidth::XL, _)", || btn::btn_secondary(None, "XL", BtnWidth::XL, None)), + row("btn_secondary(None, \"XXL\", BtnWidth::XXL, _)", || btn::btn_secondary(None, "XXL", BtnWidth::XXL, None)), + row("btn_secondary_with_tooltip(None, \"XXL\", BtnWidth::XXL, _, tooltip)", || btn::btn_secondary_with_tooltip(None, "XXL", Some("this is a very loooooooooooooooooooooong tooltip"), BtnWidth::XXL, None)), + ]; + debug_assert!(rows.len() <= MAX_ROWS_PER_PAGE); + debug_chrome( + "Button constructors — preset widths", + constructors_table(rows), + ) +} + +fn constructors_helpers_view() -> Element<'static, DebugMessage> { + #[rustfmt::skip] + let rows = vec![ + // Preset-width helpers (built with `None` so the row helper can attach on_press). + row("",|| btn::btn_save(None)), + row("",|| btn::btn_cancel(None)), + row("",|| btn::btn_ok(None)), + row("",|| btn::btn_clear(None)), + row("",|| btn::btn_retry(None)), + row("",|| btn::btn_yes(None)), + row("",|| btn::btn_no(None)), + row("",|| btn::btn_reset_timelock(None)), + row("",|| btn::btn_go_to_rescan(None)), + row("",|| btn::btn_dismiss(None)), + row("",|| btn::btn_customize(None)), + row("",|| btn::btn_clear_all(None)), + row("",|| btn::btn_unlock(None)), + row("",|| btn::btn_reload(None)), + row("",|| btn::btn_approve_template(None)), + row("",|| btn::btn_send_for_approval(None)), + row("",|| btn::btn_keep_changes(None)), + row("",|| btn::btn_send_token(None)), + row("",|| btn::btn_breadcrumb_previous(None)), + row("",|| btn::btn_manage_keys(None, false)), + row("",|| btn::btn_skip(None)), + row("",|| btn::btn_resend_token(None)), + row("",|| btn::btn_change_email(None)), + row("",|| btn::btn_connect_another_email(None)), + // Round icon button. + row("",|| btn::icon_btn(icon::tooltip_icon(), None)), + // Menu constructors. + row("",|| btn::menu(None, "Item", false)), + row("",|| btn::menu(None, "Item", true)), + row("",|| btn::menu_active(None, "Item", false)), + row("",|| btn::menu_active(None, "Item", true)), + row("",|| btn::menu_small(icon::wallet_icon())), + row("",|| btn::menu_active_small(icon::wallet_icon())), + ]; + + debug_chrome( + "Button constructors — helpers & menu", + constructors_table(rows), + ) +} diff --git a/liana-gui/src/debug/cards.rs b/liana-gui/src/debug/cards.rs new file mode 100644 index 0000000000..d4617c9367 --- /dev/null +++ b/liana-gui/src/debug/cards.rs @@ -0,0 +1,287 @@ +//! Gallery of every `liana_ui::component::card::*` constructor, every +//! `liana_ui::theme::card::*` style, and the common stacked/wrapped patterns +//! found in real Liana flows. +//! +//! Three pages: +//! - **Constructors** — `card::simple/modal/invalid/warning/error/...` plus +//! `clickable_card` in both interactive and disabled forms. +//! - **Theme styles** — every `theme::card::*` function applied to the same +//! labeled container, so palettes can be compared side by side. +//! - **Wrapped** — real production arrangements where one card (constructor or +//! `theme::card::*`-styled container) contains another. Each row is sourced +//! from a real call site in `liana-gui` or `liana-business` (paths in +//! comments above each row); update this gallery when new combinations +//! appear in production. + +use iced::{Alignment, Length}; +use liana_ui::{ + component::{card, modal, text}, + theme, + widget::*, +}; + +use crate::debug::{debug_chrome, DebugMessage, DebugPageEntry}; + +pub static ENTRY_CONSTRUCTORS: DebugPageEntry = DebugPageEntry { + view: constructors_view, +}; +pub static ENTRY_THEMES: DebugPageEntry = DebugPageEntry { view: themes_view }; +pub static ENTRY_WRAPPED: DebugPageEntry = DebugPageEntry { view: wrapped_view }; + +const ROW_SPACING: f32 = 30.0; + +// ----- Layout helpers ------------------------------------------------------ + +fn entry( + path: &'static str, + widget: Element<'static, DebugMessage>, +) -> Column<'static, DebugMessage> { + Column::new() + .spacing(8) + .push(text::p1_regular(path)) + .push(Container::new(widget).width(Length::Fixed(modal::BTN_W as f32))) +} + +fn build_page( + title: &'static str, + rows: Vec<(&'static str, W)>, +) -> Element<'static, DebugMessage> +where + W: Into>, +{ + let mid = rows.len().div_ceil(2); + let mut iter = rows.into_iter().map(|(p, w)| entry(p, w.into())); + let left = (&mut iter) + .take(mid) + .fold(Column::new().spacing(ROW_SPACING), Column::push); + let right = iter.fold(Column::new().spacing(ROW_SPACING), Column::push); + let body = Row::new() + .spacing(40) + .align_y(Alignment::Start) + .push(left) + .push(right); + debug_chrome(title, body) +} + +// ----- Constructors view --------------------------------------------------- + +fn sample() -> Container<'static, DebugMessage> { + Container::new(text::p1_regular("Card content")).padding(0) +} + +fn clickable_row() -> Row<'static, DebugMessage> { + Row::new() + .spacing(10) + .align_y(Alignment::Center) + .push(text::p1_regular("Click me")) +} + +fn constructors_view() -> Element<'static, DebugMessage> { + #[rustfmt::skip] + let rows = vec![ + ("card::simple()", card::simple(sample()).into()), + ("card::modal()", card::modal(sample()).into()), + ("card::invalid()", card::invalid(sample()).into()), + ("card::legacy_warning(\"Warning message\".into())", card::legacy_warning("Warning message".to_string()).into()), + ("card::error(\"Error\", \"
\".into())", card::error("Error", "Detailed error tooltip".to_string()).into()), + ("card::warning()", card::warning(sample())), + ("card::warning()", card::soft_warning(sample())), + ("card::info()", card::info(sample())), + ("card::clickable_card(, Some(()))", card::clickable_card(clickable_row(), Some(()))), + ("card::clickable_card(, None)", card::clickable_card(clickable_row(), None)), + ]; + + build_page("Cards — constructors", rows) +} + +// ----- Theme-styles view --------------------------------------------------- + +type StyleFn = fn(&theme::Theme) -> iced::widget::container::Style; + +fn styled(label: &'static str, style: StyleFn) -> Container<'static, DebugMessage> { + Container::new(text::p1_regular(label)) + .padding(15) + .style(style) +} + +fn themes_view() -> Element<'static, DebugMessage> { + #[rustfmt::skip] + let rows = vec![ + ("theme::card::simple", styled("Sample content", theme::card::simple)), + ("theme::card::button_simple", styled("Sample content", theme::card::button_simple)), + ("theme::card::transparent", styled("Sample content", theme::card::transparent)), + ("theme::card::modal", styled("Sample content", theme::card::modal)), + ("theme::card::border", styled("Sample content", theme::card::border)), + ("theme::card::invalid", styled("Sample content", theme::card::invalid)), + ("theme::card::legacy_warning", styled("Sample content", theme::card::legacy_warning)), + ("theme::card::warning", styled("Sample content", theme::card::warning)), + ("theme::card::soft_warning", styled("Sample content", theme::card::soft_warning)), + ("theme::card::info", styled("Sample content", theme::card::info)), + ("theme::card::error", styled("Sample content", theme::card::error)), + ]; + + build_page("Cards — theme styles", rows) +} + +// ----- Wrapped — real production patterns ---------------------------------- +// +// Every row below mirrors a real arrangement found in liana-gui or +// liana-business. Adjust this gallery whenever a new combination appears in +// production so the visual reference stays current. + +/// `Container.style(theme::card::simple)` info inset — the recurring panel +/// used inside xpub modals, PSBT tooltips, and disabled-state hints. +fn simple_inset(label: &'static str) -> Container<'static, DebugMessage> { + Container::new(text::p1_regular(label)) + .padding(10) + .width(Length::Fill) + .style(theme::card::simple) +} + +/// `Container.style(theme::card::border)` — the bordered panel used by the +/// home rescan-warning and disabled hardware-wallet entries. +fn bordered_inset(label: &'static str) -> Container<'static, DebugMessage> { + Container::new(text::p1_regular(label)) + .padding(10) + .width(Length::Fill) + .style(theme::card::border) +} + +fn wrapped_view() -> Element<'static, DebugMessage> { + let click_row = || -> Row<'static, DebugMessage> { + Row::new() + .spacing(10) + .align_y(Alignment::Center) + .push(text::p1_regular("Choose option")) + }; + + #[rustfmt::skip] + let rows: Vec<(&'static str, Element<'static, DebugMessage>)> = vec![ + // xpub modal: modal_view body holds a stack of `theme::card::simple` info insets. + // (business-installer/src/views/xpub/modal.rs) + ("modal_view (theme::card::modal) { card::simple × 3 }", + Container::new( + Column::new() + .spacing(10) + .push(simple_inset("Status: this key already has an xpub.")) + .push(simple_inset("Current xpub: tpub6Cv…")) + .push(simple_inset("Validation: ok")) + ) + .padding(15) + .style(theme::card::modal) + .into()), + // Settings descriptor card: card::simple wrapping a Column of label + content + button row. + // (liana-business/src/settings/views/mod.rs) + ("card::simple { label + content + button row }", + card::simple( + Column::new() + .spacing(10) + .push(text::p1_bold("Wallet descriptor:")) + .push(text::p2_regular("wsh(or_d(pk(),and_v(v:pkh(),older()))) …")) + .push( + Row::new() + .push(Container::new(text::p1_regular("")).width(Length::Fill)) + .push(text::p1_regular("[Register on device]")) + ), + ).into()), + // Card::simple with a card::simple inset inside it (current value display). + // (business-installer/src/views/xpub/modal.rs lines 78-94) + ("card::simple { text + Container(theme::card::simple) inset }", + card::simple( + Column::new() + .spacing(10) + .push(text::p1_bold("Current xpub:")) + .push(simple_inset("tpub6Cv5p1nXk3K…")) + ).into()), + // Settings menu entry: theme::card::button_simple wrapping a Row with badge + label. + // (liana-business/src/settings/views/mod.rs:175-191) + ("Container(theme::card::button_simple) { badge + label row }", + Container::new( + Row::new() + .padding(10) + .spacing(20) + .align_y(Alignment::Center) + .push(text::p1_bold("Settings entry")) + ) + .width(Length::Fill) + .style(theme::card::button_simple) + .into()), + // Home rescan-warning panel: theme::card::border wrapping content + button row. + // (liana-gui/src/app/view/home.rs:50-67) + ("Container(theme::card::border) { warning text + buttons }", + Container::new( + Column::new() + .spacing(10) + .push(text::p1_bold("Rescan recommended")) + .push(text::p1_regular("New addresses were imported, please run a rescan.")) + .push( + Row::new() + .spacing(10) + .push(text::p1_regular("[Go to rescan]")) + .push(text::p1_regular("[Dismiss]")) + ), + ) + .padding(25) + .style(theme::card::border) + .into()), + // Recovery key tooltip: pill row containing a Tooltip whose body is theme::card::simple. + // (liana-gui/src/app/view/recovery.rs:120-135) + ("card::simple { Row + tooltip(theme::card::simple) body }", + card::simple( + Column::new() + .spacing(8) + .push(text::p1_regular("Recovery path 144 blocks")) + .push(simple_inset("Tooltip body: keys held by Alice, Bob")) + ).into()), + // PSBT pending: card::simple wrapping a Row with details and a clickable_card action. + // (liana-gui/src/app/view/psbt.rs around 350-413, simplified) + ("card::simple { content + clickable_card }", + card::simple( + Column::new() + .spacing(10) + .push(text::p1_bold("PSBT pending")) + .push(text::p1_regular("Tx ID: …")) + .push(card::clickable_card(click_row(), Some(()))) + ).into()), + // Installer backup view: two card::simple cards stacked as siblings inside a Column. + // (liana-gui/src/installer/view/mod.rs:728-761) + ("Column { card::simple, card::simple } (siblings)", + Column::new() + .spacing(10) + .push(card::simple(text::p1_regular("Descriptor: wsh(…)"))) + .push(card::simple(text::p1_regular("Policy: 2-of-3 with 144-block recovery"))) + .into()), + // Form with validation error: card::simple wrapping inputs + a card::invalid notice. + // (common pattern, e.g. installer descriptor edit + validation) + ("card::simple { inputs + card::invalid notice }", + card::simple( + Column::new() + .spacing(10) + .push(text::p1_regular("Field 1")) + .push(text::p1_regular("Field 2")) + .push(card::invalid(text::p1_regular("Validation failed"))) + ).into()), + // Bordered hint with a simple inset (home_hint pattern with sub-content). + ("Container(theme::card::border) { card::simple inset }", + Container::new(simple_inset("Inner: theme::card::simple")) + .padding(15) + .style(theme::card::border) + .into()), + // Modal containing both simple and bordered insets — shows theme contrast + // between the most common inner styles inside the most common outer. + ("Container(theme::card::modal) { simple + border × 2 }", + Container::new( + Column::new() + .spacing(10) + .push(simple_inset("theme::card::simple")) + .push(bordered_inset("theme::card::border")) + .push(simple_inset("theme::card::simple")) + .push(bordered_inset("theme::card::border")) + ) + .padding(15) + .style(theme::card::modal) + .into()), + ]; + + build_page("Cards — wrapped (production patterns)", rows) +} diff --git a/liana-gui/src/debug/decrypt_modal.rs b/liana-gui/src/debug/decrypt_modal.rs new file mode 100644 index 0000000000..fd9dbf1234 --- /dev/null +++ b/liana-gui/src/debug/decrypt_modal.rs @@ -0,0 +1,119 @@ +//! Renders the installer's "Decrypt backup file" modal +//! (`installer::decrypt::DecryptModal`) across the visual states +//! reachable via its public `update()` API. +//! +//! Bytes are loaded from the test fixture +//! `liana-gui/test_assets/v0.bed`, so most pages land on the +//! `valid_content` branch of `decrypt_view`. The error page passes +//! empty bytes to surface the `InvalidEncoding` arm. Branches that +//! depend on private fields (in-flight HW fetches, the nested +//! `ExportModal`, the `XpubError` / `MnemonicStatus` paths) are out of +//! scope; expose `pub(crate)` test hooks on `DecryptModal` to cover +//! more. + +use std::str::FromStr; +use std::sync::OnceLock; + +use liana::miniscript::bitcoin::{bip32::Fingerprint, Network}; + +use liana_ui::widget::*; + +use crate::{ + debug::{installer_with_modal, DebugMessage, DebugPageEntry}, + installer::decrypt::{decrypt_view, Decrypt, DecryptModal}, +}; + +const BACKUP_BYTES: &[u8] = include_bytes!("../../test_assets/v0.bed"); +const SOURCE_PATH: &str = "liana_gui::installer::decrypt::decrypt_view"; + +/// SAFETY: iced renders on the main thread; debug-overlay state is only +/// read during rendering, so satisfying `OnceLock`'s `Sync` bound with +/// an unconditional `unsafe impl Sync` is sound here. +struct StateCell(T); +unsafe impl Sync for StateCell {} + +pub static ENTRY_INITIAL: DebugPageEntry = DebugPageEntry { view: initial_view }; +pub static ENTRY_OPTIONS_OPEN: DebugPageEntry = DebugPageEntry { + view: options_open_view, +}; +pub static ENTRY_MNEMONIC_NO_ACK: DebugPageEntry = DebugPageEntry { + view: mnemonic_no_ack_view, +}; +pub static ENTRY_MNEMONIC_ACKED: DebugPageEntry = DebugPageEntry { + view: mnemonic_acked_view, +}; +pub static ENTRY_FETCHED: DebugPageEntry = DebugPageEntry { view: fetched_view }; +pub static ENTRY_INVALID_ENCODING: DebugPageEntry = DebugPageEntry { + view: invalid_encoding_view, +}; + +fn fresh() -> DecryptModal { + DecryptModal::new(BACKUP_BYTES.to_vec(), Network::Bitcoin) +} + +fn render(state: &DecryptModal, title: &'static str) -> Element<'static, DebugMessage> { + let body: Element<'static, _> = decrypt_view(state).into(); + installer_with_modal(title, SOURCE_PATH, body.map(|_| ())) +} + +fn initial_view() -> Element<'static, DebugMessage> { + static STATE: OnceLock> = OnceLock::new(); + let s = STATE.get_or_init(|| StateCell(fresh())); + render(&s.0, "Decrypt backup — initial") +} + +fn options_open_view() -> Element<'static, DebugMessage> { + static STATE: OnceLock> = OnceLock::new(); + let s = STATE.get_or_init(|| { + let mut m = fresh(); + let _ = m.update(Decrypt::ShowOptions(true)); + StateCell(m) + }); + render(&s.0, "Decrypt backup — other options open") +} + +fn mnemonic_no_ack_view() -> Element<'static, DebugMessage> { + static STATE: OnceLock> = OnceLock::new(); + let s = STATE.get_or_init(|| { + let mut m = fresh(); + let _ = m.update(Decrypt::ShowOptions(true)); + let _ = m.update(Decrypt::SelectMnemonic); + StateCell(m) + }); + render( + &s.0, + "Decrypt backup — mnemonic input expanded, not acknowledged", + ) +} + +fn mnemonic_acked_view() -> Element<'static, DebugMessage> { + static STATE: OnceLock> = OnceLock::new(); + let s = STATE.get_or_init(|| { + let mut m = fresh(); + let _ = m.update(Decrypt::ShowOptions(true)); + let _ = m.update(Decrypt::SelectMnemonic); + let _ = m.update(Decrypt::MnemonicAck(true)); + StateCell(m) + }); + render( + &s.0, + "Decrypt backup — mnemonic input expanded, acknowledged", + ) +} + +fn fetched_view() -> Element<'static, DebugMessage> { + static STATE: OnceLock> = OnceLock::new(); + let s = STATE.get_or_init(|| { + let mut m = fresh(); + let fg = Fingerprint::from_str("8a550171").expect("valid hex"); + let _ = m.update(Decrypt::Fetched(fg, "Coldcard".to_string())); + StateCell(m) + }); + render(&s.0, "Decrypt backup — fetched key") +} + +fn invalid_encoding_view() -> Element<'static, DebugMessage> { + static STATE: OnceLock> = OnceLock::new(); + let s = STATE.get_or_init(|| StateCell(DecryptModal::new(vec![], Network::Bitcoin))); + render(&s.0, "Decrypt backup — invalid encoding error") +} diff --git a/liana-gui/src/debug/forms.rs b/liana-gui/src/debug/forms.rs new file mode 100644 index 0000000000..f2870eb197 --- /dev/null +++ b/liana-gui/src/debug/forms.rs @@ -0,0 +1,112 @@ +//! Gallery of every `liana_ui::component::form::Form` constructor and +//! relevant builder modifier. +//! +//! Forms borrow their `Value` so we keep one [`std::sync::LazyLock`] +//! per sample value at module scope — that gives us `&'static Value` +//! references the `Form<'static, …>` constructors require. +//! +//! Inputs receive `|_| ()` as the on-change callback. Since the values are +//! immutable (stored in static `LazyLock`s, not stateful), typing in the +//! field shows focus/cursor behavior but doesn't update the displayed text; +//! events route through `Message::DebugNoOp` like every other debug-overlay +//! interaction. + +use std::sync::LazyLock; + +use iced::{Alignment, Length}; +use liana_ui::{ + component::{ + form::{Form, Value}, + modal, text, + }, + theme, + widget::*, +}; + +use crate::debug::{debug_chrome, DebugMessage, DebugPageEntry}; + +pub static ENTRY: DebugPageEntry = DebugPageEntry { view }; + +const ROW_SPACING: f32 = 30.0; + +// ----- Sample values -------------------------------------------------------- + +static V_EMPTY: LazyLock> = LazyLock::new(Value::default); + +static V_HELLO: LazyLock> = LazyLock::new(|| Value { + value: "Hello world".to_string(), + warning: None, + valid: true, +}); + +static V_INVALID: LazyLock> = LazyLock::new(|| Value { + value: "bad input".to_string(), + warning: Some("This value is invalid"), + valid: false, +}); + +static V_BTC: LazyLock> = LazyLock::new(|| Value { + value: "0.00100000".to_string(), + warning: None, + valid: true, +}); + +// ----- Layout helpers ------------------------------------------------------ + +fn entry( + path: &'static str, + widget: Element<'static, DebugMessage>, +) -> Column<'static, DebugMessage> { + Column::new().spacing(8).push(text::p1_regular(path)).push( + Container::new(widget) + .width(Length::Fixed(modal::BTN_W as f32)) + .style(theme::card::border), + ) +} + +/// Build a debug page from a list of `(code-path, widget)` pairs. Each widget +/// is converted to [`Element`] internally, so callers only pass the raw +/// constructed widget — no `.into()` per row. +fn build_page( + title: &'static str, + rows: Vec<(&'static str, W)>, +) -> Element<'static, DebugMessage> +where + W: Into>, +{ + let mid = rows.len().div_ceil(2); + let mut iter = rows.into_iter().map(|(p, w)| entry(p, w.into())); + let left = (&mut iter) + .take(mid) + .fold(Column::new().spacing(ROW_SPACING), Column::push); + let right = iter.fold(Column::new().spacing(ROW_SPACING), Column::push); + let body = Row::new() + .spacing(40) + .align_y(Alignment::Start) + .push(left) + .push(right); + debug_chrome(title, body) +} + +// ----- View ---------------------------------------------------------------- + +fn view() -> Element<'static, DebugMessage> { + let on_change = |_: String| (); + + #[rustfmt::skip] + let rows = vec![ + ("Form::new(\"placeholder\", &Value::default(), |_| ())", Form::new("placeholder", &V_EMPTY, on_change)), + ("Form::new(\"placeholder\", , |_| ())", Form::new("placeholder", &V_HELLO, on_change)), + ("Form::new(\"placeholder\", , |_| ())", Form::new("placeholder", &V_INVALID, on_change)), + ("Form::new_disabled(\"placeholder\", )", Form::new_disabled("placeholder", &V_HELLO)), + ("Form::new_disabled(\"placeholder\", )", Form::new_disabled("placeholder", &V_EMPTY)), + ("Form::new_trimmed(\"placeholder\", , |_| ())", Form::new_trimmed("placeholder", &V_HELLO, on_change)), + ("Form::new_amount_btc(\"0.0\", , |_| ())", Form::new_amount_btc("0.0", &V_BTC, on_change)), + ("Form::new(...).padding(20)", Form::new("placeholder", &V_EMPTY, on_change).padding(20)), + ("Form::new(...).size(28)", Form::new("placeholder", &V_HELLO, on_change).size(28)), + ("Form::new(...).warning(\"...\")", Form::new("placeholder", &V_INVALID, on_change).warning("Explicit warning")), + ("Form::new(...).on_submit(())", Form::new("placeholder", &V_HELLO, on_change).on_submit(())), + ]; + + build_page("Forms", rows) +} diff --git a/liana-gui/src/debug/home.rs b/liana-gui/src/debug/home.rs new file mode 100644 index 0000000000..51d4932e80 --- /dev/null +++ b/liana-gui/src/debug/home.rs @@ -0,0 +1,316 @@ +//! Gallery of [`crate::app::view::home::home_view`] states, rendered inside the +//! production dashboard chrome with mock data. +//! +//! The payments list reuses the four variants from the payment-card gallery +//! (`debug/payment_cards.rs`); fiat price is always `None` in home for now, so +//! the two outgoing entries that differ only by fiat source render the same +//! until fiat support lands. `home_view` filters `PaymentKind::SendToSelf` out, +//! so that variant is carried for parity but does not appear in the list. +//! +//! `home_view` borrows balance / unconfirmed / events with the returned +//! widget's lifetime, so those live in a `OnceLock`; the remaining knobs are +//! call-scoped. Click messages are swallowed with `.map(|_| ())`. + +use std::str::FromStr; +use std::sync::OnceLock; + +use liana::miniscript::bitcoin::{Amount, OutPoint, Txid}; +use liana_ui::widget::*; + +use crate::{ + app::{ + cache::FiatPriceRequest, + menu::Menu, + view::{home, FiatAmountConverter}, + wallet::SyncStatus, + }, + daemon::model::Payment, + debug::{ + dashboard_chrome, dashboard_chrome_with_cache, payment_cards, rescanning_cache, + DebugMessage, DebugPageEntry, + }, + services::fiat::{Currency, PriceSource}, +}; + +static MENU: Menu = Menu::Home; + +pub static ENTRY_PAYMENTS: DebugPageEntry = DebugPageEntry { + view: payments_view, +}; +pub static ENTRY_EMPTY: DebugPageEntry = DebugPageEntry { view: empty_view }; +pub static ENTRY_SYNCING: DebugPageEntry = DebugPageEntry { view: syncing_view }; +pub static ENTRY_UNCONFIRMED: DebugPageEntry = DebugPageEntry { + view: unconfirmed_view, +}; +pub static ENTRY_RESCAN_WARNING: DebugPageEntry = DebugPageEntry { + view: rescan_warning_view, +}; +pub static ENTRY_EXPIRING: DebugPageEntry = DebugPageEntry { + view: expiring_view, +}; +pub static ENTRY_SEQUENCE_HINT: DebugPageEntry = DebugPageEntry { + view: sequence_hint_view, +}; +pub static ENTRY_PAGINATION: DebugPageEntry = DebugPageEntry { + view: pagination_view, +}; +pub static ENTRY_SIDEBAR_RESCAN: DebugPageEntry = DebugPageEntry { + view: sidebar_rescan_view, +}; +pub static ENTRY_FIAT: DebugPageEntry = DebugPageEntry { view: fiat_view }; + +struct Fixtures { + balance: Amount, + zero: Amount, + unconfirmed: Amount, + events: Vec, +} + +fn fixtures() -> &'static Fixtures { + static F: OnceLock = OnceLock::new(); + F.get_or_init(|| Fixtures { + balance: Amount::from_sat(200_000), + zero: Amount::from_sat(0), + unconfirmed: Amount::from_sat(50_000), + events: events(), + }) +} + +/// The exact four payment-card gallery variants ([`payment_cards::variants`]), +/// as `Payment` rows. `Payment` has no fiat or status field: status is derived +/// from `time` by `home_view`, and fiat is dropped until home gains fiat support. +fn events() -> Vec { + payment_cards::variants() + .into_iter() + .enumerate() + .map(|(i, v)| Payment { + label: v.label.map(String::from), + address: None, + address_label: None, + amount: Amount::from_sat(v.sats), + outpoint: OutPoint { + txid: Txid::from_str(&format!("{:02x}", i + 1).repeat(32)) + .expect("64-char hex literal"), + vout: 0, + }, + time: v.time, + kind: v.kind, + }) + .collect() +} + +fn expiring_coins() -> Vec { + vec![ + OutPoint { + txid: Txid::from_str(&"ab".repeat(32)).expect("64-char hex literal"), + vout: 0, + }, + OutPoint { + txid: Txid::from_str(&"cd".repeat(32)).expect("64-char hex literal"), + vout: 1, + }, + ] +} + +fn fiat_converter() -> FiatAmountConverter { + FiatAmountConverter::new( + 98_765.0, + Some(1_700_000_000), + FiatPriceRequest::new(PriceSource::MempoolSpace, Currency::USD), + ) + .expect("positive price") +} + +#[allow(clippy::too_many_arguments)] +fn home_body( + unconfirmed_balance: &'static Amount, + fiat: Option, + events: &'static [Payment], + remaining_sequence: Option, + expiring_coins: &[OutPoint], + is_last_page: bool, + processing: bool, + sync_status: SyncStatus, + show_rescan_warning: bool, +) -> Element<'static, DebugMessage> { + let fx = fixtures(); + home::home_view( + &fx.balance, + unconfirmed_balance, + &remaining_sequence, + fiat, + expiring_coins, + events, + is_last_page, + processing, + &sync_status, + show_rescan_warning, + ) + .map(|_| ()) +} + +fn payments_view() -> Element<'static, DebugMessage> { + let fx = fixtures(); + let body = home_body( + &fx.zero, + None, + &fx.events, + None, + &[], + true, + false, + SyncStatus::Synced, + false, + ); + dashboard_chrome(&MENU, "Home: payments", body) +} + +fn empty_view() -> Element<'static, DebugMessage> { + let fx = fixtures(); + let body = home_body( + &fx.zero, + None, + &[], + None, + &[], + true, + false, + SyncStatus::Synced, + false, + ); + dashboard_chrome(&MENU, "Home: empty history", body) +} + +fn syncing_view() -> Element<'static, DebugMessage> { + let fx = fixtures(); + let body = home_body( + &fx.zero, + None, + &fx.events, + None, + &[], + true, + false, + SyncStatus::BlockchainSync(0.42), + false, + ); + dashboard_chrome(&MENU, "Home: syncing (blinking balance)", body) +} + +fn unconfirmed_view() -> Element<'static, DebugMessage> { + let fx = fixtures(); + let body = home_body( + &fx.unconfirmed, + None, + &fx.events, + None, + &[], + true, + false, + SyncStatus::Synced, + false, + ); + dashboard_chrome(&MENU, "Home: unconfirmed balance", body) +} + +fn rescan_warning_view() -> Element<'static, DebugMessage> { + let fx = fixtures(); + let body = home_body( + &fx.zero, + None, + &fx.events, + None, + &[], + true, + false, + SyncStatus::Synced, + true, + ); + dashboard_chrome(&MENU, "Home: rescan warning", body) +} + +fn expiring_view() -> Element<'static, DebugMessage> { + let fx = fixtures(); + let expiring = expiring_coins(); + let body = home_body( + &fx.zero, + None, + &fx.events, + None, + &expiring, + true, + false, + SyncStatus::Synced, + false, + ); + dashboard_chrome(&MENU, "Home: recovery available", body) +} + +fn sequence_hint_view() -> Element<'static, DebugMessage> { + let fx = fixtures(); + let body = home_body( + &fx.zero, + None, + &fx.events, + Some(65_000), + &[], + true, + false, + SyncStatus::Synced, + false, + ); + dashboard_chrome(&MENU, "Home: recovery sequence hint", body) +} + +fn pagination_view() -> Element<'static, DebugMessage> { + let fx = fixtures(); + let body = home_body( + &fx.zero, + None, + &fx.events, + None, + &[], + false, + false, + SyncStatus::Synced, + false, + ); + dashboard_chrome(&MENU, "Home: see more", body) +} + +fn sidebar_rescan_view() -> Element<'static, DebugMessage> { + let fx = fixtures(); + let body = home_body( + &fx.zero, + None, + &fx.events, + None, + &[], + true, + false, + SyncStatus::Synced, + false, + ); + dashboard_chrome_with_cache( + &MENU, + "Home: sidebar rescan progress", + rescanning_cache(), + body, + ) +} + +fn fiat_view() -> Element<'static, DebugMessage> { + let fx = fixtures(); + let body = home_body( + &fx.unconfirmed, + Some(fiat_converter()), + &fx.events, + None, + &[], + true, + false, + SyncStatus::Synced, + false, + ); + dashboard_chrome(&MENU, "Home: fiat balance", body) +} diff --git a/liana-gui/src/debug/hw.rs b/liana-gui/src/debug/hw.rs new file mode 100644 index 0000000000..4c167ee8a0 --- /dev/null +++ b/liana-gui/src/debug/hw.rs @@ -0,0 +1,176 @@ +//! Gallery of every `liana_ui::component::modal::legacy::*` constructor. +//! +//! All of these widgets render a *signing device* (hardware signer, hot +//! signer, or provider-key entry): they're variations on the same row, not +//! distinct widget kinds. The list is split across two pages purely for +//! length, not by category. +//! +//! The `supported_hardware_wallet_with_account` constructor requires a +//! message type implementing `From<(Fingerprint, ChildNumber)>`; we use a +//! private [`AccountPick`] newtype and `Element::map` it back to +//! [`DebugMessage`] so the account-picker click is swallowed at the same +//! boundary as every other debug-overlay event. + +use iced::{Alignment, Length}; +use liana::miniscript::bitcoin::bip32::{ChildNumber, Fingerprint}; +use liana_ui::{ + component::{modal, text}, + theme, + widget::*, +}; + +use crate::debug::{debug_chrome, DebugMessage, DebugPageEntry}; + +pub static ENTRY_PAGE_1: DebugPageEntry = DebugPageEntry { view: page_1 }; +pub static ENTRY_PAGE_2: DebugPageEntry = DebugPageEntry { view: page_2 }; + +const ROW_SPACING: f32 = 30.0; + +/// Sample fingerprint used for every hw widget in the gallery. +fn fingerprint() -> Fingerprint { + Fingerprint::from([0xDE, 0xAD, 0xBE, 0xEF]) +} + +fn account() -> ChildNumber { + ChildNumber::from_hardened_idx(0).expect("hardcoded") +} + +/// Newtype carrying the account-pick callback message from +/// `supported_hardware_wallet_with_account`. The actual message is discarded +/// at the boundary via `Element::map`. +#[derive(Clone, Debug)] +struct AccountPick; +impl From<(Fingerprint, ChildNumber)> for AccountPick { + fn from(_: (Fingerprint, ChildNumber)) -> Self { + AccountPick + } +} + +/// One row: a code path paired with its rendered widget. Used as the input +/// shape for [`build_page`]: the chrome (label, bordered card sized to +/// [`modal::BTN_W`], two-column layout) is applied by the helper, not at the +/// call site. +type RowDef = (&'static str, Element<'static, DebugMessage>); + +/// Pair a code path with a normal hw widget that emits [`DebugMessage`]. +fn row(path: &'static str, widget: impl Into>) -> RowDef { + (path, widget.into()) +} + +/// Pair a code path with `supported_device_with_account`, mapping its +/// [`AccountPick`] message back to [`DebugMessage`] at the boundary. +fn row_with_account(path: &'static str, widget: Element<'static, AccountPick>) -> RowDef { + (path, widget.map(|_| ())) +} + +/// Wrap a single row in the standard chrome: label on top of a bordered card +/// of width [`modal::BTN_W`]. +fn entry( + path: &'static str, + widget: Element<'static, DebugMessage>, +) -> Column<'static, DebugMessage> { + Column::new().spacing(8).push(text::p1_regular(path)).push( + Container::new(widget) + .width(Length::Fixed(modal::BTN_W as f32)) + .style(theme::card::border), + ) +} + +/// Per-page cap on entry count. Splitting at 13 keeps each two-column page +/// comfortable to scan. +const MAX_ENTRIES_PER_PAGE: usize = 13; + +/// Build a debug page from a list of rows: applies [`entry`] to each, splits +/// into two side-by-side columns (first half left, rest right), wraps in +/// debug chrome. +fn build_page(title: &'static str, rows: Vec) -> Element<'static, DebugMessage> { + debug_assert!(rows.len() <= MAX_ENTRIES_PER_PAGE); + let mid = rows.len().div_ceil(2); + let mut iter = rows.into_iter().map(|(p, w)| entry(p, w)); + let left = (&mut iter) + .take(mid) + .fold(Column::new().spacing(ROW_SPACING), Column::push); + let right = iter.fold(Column::new().spacing(ROW_SPACING), Column::push); + let body = Row::new() + .spacing(40) + .align_y(Alignment::Start) + .push(left) + .push(right); + debug_chrome(title, body) +} + +fn page_1() -> Element<'static, DebugMessage> { + let alias: Option<&'static str> = Some("My signer"); + let kind = "Ledger"; + let version: Option<&'static str> = Some("v2.1.0"); + + use modal::legacy as hw; + let rows = vec![ + row("liana_ui::component::modal::legacy::locked_device(, Some(\"123-456\"), None)", + hw::locked_device::(kind, Some("123-456"), None)), + row("liana_ui::component::modal::legacy::supported_device(, , , Some(), None)", + hw::supported_device::(kind, version, fingerprint(), alias, None)), + row_with_account("liana_ui::component::modal::legacy::supported_device_with_account(...)", + hw::supported_device_with_account::( + kind, version, fingerprint(), alias, Some(account()), false, None)), + row_with_account("liana_ui::component::modal::legacy::supported_device_with_account(..., edit_account=true)", + hw::supported_device_with_account::( + kind, version, fingerprint(), alias, Some(account()), true, None)), + row("liana_ui::component::modal::legacy::warning_device(, , , Some(), \"...\", None)", + hw::warning_device::(kind, version, fingerprint(), alias, "Firmware mismatch", None)), + row("liana_ui::component::modal::legacy::unimplemented_method_device(, , , \"...\", None)", + hw::unimplemented_method_device::( + kind, version, fingerprint(), "This action isn't implemented for this device", None)), + row("liana_ui::component::modal::legacy::disabled_device(, , , \"...\", None)", + hw::disabled_device::( + kind, version, fingerprint(), "Disabled, already used", None)), + row("liana_ui::component::modal::legacy::unrelated_device(, , , None)", + hw::unrelated_device::(kind, version, fingerprint(), None)), + row("liana_ui::component::modal::legacy::processing_device(, , , Some(), None)", + hw::processing_device::(kind, version, fingerprint(), alias, None)), + row("liana_ui::component::modal::legacy::selected_device(, , , Some(), None, Some(0'), true, None)", + hw::selected_device::(kind, version, fingerprint(), alias, None, Some(account()), true, None)), + row("liana_ui::component::modal::legacy::selected_device(..., warning=Some(\"...\"))", + hw::selected_device::(kind, version, fingerprint(), alias, Some("Outdated firmware"), Some(account()), true, None)), + row("liana_ui::component::modal::legacy::signed_device(, , , Some(), None)", + hw::signed_device::(kind, version, fingerprint(), alias, None)), + row("liana_ui::component::modal::legacy::registered_device(, , , Some(), None)", + hw::registered_device::(kind, version, fingerprint(), alias, None)), + ]; + build_page("Signing devices (1/2)", rows) +} + +fn page_2() -> Element<'static, DebugMessage> { + let alias: Option<&'static str> = Some("My signer"); + let kind = "Ledger"; + let version: Option<&'static str> = Some("v2.1.0"); + + use modal::legacy as hw; + let rows = vec![ + row("liana_ui::component::modal::legacy::wrong_network_device(, , None)", + hw::wrong_network_device::(kind, version, None)), + row("liana_ui::component::modal::legacy::unsupported_device(, , None)", + hw::unsupported_device::(kind, version, None)), + row("liana_ui::component::modal::legacy::unsupported_version_device(, , \"v3.0\", None)", + hw::unsupported_version_device::(kind, version, "v3.0", None)), + row("liana_ui::component::modal::legacy::taproot_unsupported_device(, None)", + hw::taproot_unsupported_device::(kind, None)), + row("liana_ui::component::modal::legacy::signed_hot_key(, Some(), None)", + hw::signed_hot_key::(fingerprint(), alias, None)), + row("liana_ui::component::modal::legacy::selected_hot_key(, Some(), None)", + hw::selected_hot_key::(fingerprint(), alias, None)), + row("liana_ui::component::modal::legacy::unselected_hot_key(, Some(), None)", + hw::unselected_hot_key::(fingerprint(), alias, None)), + row("liana_ui::component::modal::legacy::hot_key(, Some(), can_sign=true, None)", + hw::hot_key::(fingerprint(), alias, true, None)), + row("liana_ui::component::modal::legacy::hot_key(, Some(), can_sign=false, None)", + hw::hot_key::(fingerprint(), alias, false, None)), + row("liana_ui::component::modal::legacy::selected_provider(, \"alias\", \"key_kind\", \"token\", None)", + hw::selected_provider::(fingerprint(), "Provider", "Cosigner", "TKN42", None)), + row("liana_ui::component::modal::legacy::unselected_provider(, \"alias\", \"key_kind\", \"token\", None)", + hw::unselected_provider::(fingerprint(), "Provider", "Cosigner", "TKN42", None)), + row("liana_ui::component::modal::legacy::unsaved_provider(, \"key_kind\", \"token\", None)", + hw::unsaved_provider::(fingerprint(), "Cosigner", "TKN42", None)), + ]; + build_page("Signing devices (2/2)", rows) +} diff --git a/liana-gui/src/debug/hw_modals.rs b/liana-gui/src/debug/hw_modals.rs new file mode 100644 index 0000000000..c168cb752a --- /dev/null +++ b/liana-gui/src/debug/hw_modals.rs @@ -0,0 +1,410 @@ +//! Renders the three production HW-device modals (signing, +//! registration, verify-address) by calling the same view functions +//! the real app calls (`view::psbt::sign_action`, +//! `view::settings::register_wallet_modal`, +//! `view::receive::verify_address_modal`). One page per modal; each +//! constructs mock state (a list of `HardwareWallet`s plus the +//! `signed`/`signing`/`registered`/`chosen` `HashSet`s the production +//! builder expects) and hands it to the production fn. +//! +//! Each rendered modal body is overlaid on top of the production +//! dashboard chrome via [`liana_ui::widget::modal::Modal`]; the +//! dashboard's sidebar shows the menu under which the modal appears in +//! production. All clicks are swallowed at the debug-overlay boundary. +//! +//! `HardwareWallet::Supported` requires an `Arc`; +//! we satisfy it with a [`MockHwi`] whose async methods all return +//! `UnimplementedMethod`: the rendering path never invokes them, only +//! the struct's data fields (`kind`, `version`, `fingerprint`, `alias`, +//! `registered`). + +use std::collections::HashSet; +use std::str::FromStr; +use std::sync::{Arc, Mutex, OnceLock}; + +use async_hwi::{AddressScript, DeviceKind, Error as HwiError, Version, HWI}; +use async_trait::async_trait; +use liana::descriptors::LianaDescriptor; +use liana::miniscript::bitcoin::{ + address::Address, + bip32::{ChildNumber, DerivationPath, Fingerprint, Xpub}, + psbt::Psbt, + Network, +}; + +use liana_ui::component::text::{h2, p1_regular}; +use liana_ui::widget::{modal::Modal, *}; + +use crate::{ + app::{ + menu::Menu, + view::{self, Message as ViewMessage}, + }, + debug::{static_cache, DebugMessage, DebugPageEntry, NAV_HINT}, + hw::{HardwareWallet, UnsupportedReason}, +}; + +pub static ENTRY_SIGNING: DebugPageEntry = DebugPageEntry { view: signing_view }; +pub static ENTRY_REGISTRATION: DebugPageEntry = DebugPageEntry { + view: registration_view, +}; +pub static ENTRY_VERIFY_ADDRESS: DebugPageEntry = DebugPageEntry { + view: verify_address_view, +}; + +/// Stand-in `HWI` implementation for mocked `HardwareWallet::Supported` +/// values. Every async method returns `UnimplementedMethod`; nothing in +/// the rendering path actually invokes them. +#[derive(Debug)] +struct MockHwi(DeviceKind); + +#[async_trait] +impl HWI for MockHwi { + fn device_kind(&self) -> DeviceKind { + self.0 + } + async fn get_version(&self) -> Result { + Err(HwiError::UnimplementedMethod) + } + async fn get_master_fingerprint(&self) -> Result { + Err(HwiError::UnimplementedMethod) + } + async fn get_extended_pubkey(&self, _path: &DerivationPath) -> Result { + Err(HwiError::UnimplementedMethod) + } + async fn register_wallet( + &self, + _name: &str, + _policy: &str, + ) -> Result, HwiError> { + Err(HwiError::UnimplementedMethod) + } + async fn is_wallet_registered(&self, _name: &str, _policy: &str) -> Result { + Err(HwiError::UnimplementedMethod) + } + async fn display_address(&self, _script: &AddressScript) -> Result<(), HwiError> { + Err(HwiError::UnimplementedMethod) + } + async fn sign_tx(&self, _tx: &mut Psbt) -> Result<(), HwiError> { + Err(HwiError::UnimplementedMethod) + } +} + +pub(super) fn fp(b: u8) -> Fingerprint { + Fingerprint::from([b; 4]) +} + +pub(super) fn ver(major: u32, minor: u32, patch: u32) -> Version { + Version { + major, + minor, + patch, + prerelease: None, + } +} + +pub(super) fn supported( + kind: DeviceKind, + version: Option, + fingerprint: Fingerprint, + alias: Option<&'static str>, + registered: Option, +) -> HardwareWallet { + HardwareWallet::Supported { + id: format!("dbg-{kind:?}-{fingerprint}"), + device: Arc::new(MockHwi(kind)), + kind, + fingerprint, + version, + registered, + alias: alias.map(String::from), + } +} + +pub(super) fn unsupported( + kind: DeviceKind, + version: Option, + reason: UnsupportedReason, +) -> HardwareWallet { + HardwareWallet::Unsupported { + id: format!("dbg-unsup-{kind:?}"), + kind, + version, + reason, + } +} + +pub(super) fn locked(kind: DeviceKind, pairing_code: Option<&'static str>) -> HardwareWallet { + HardwareWallet::Locked { + id: format!("dbg-lock-{kind:?}"), + device: Arc::new(Mutex::new(None)), + pairing_code: pairing_code.map(String::from), + kind, + } +} + +/// Sample Liana descriptor used to drive `sign_action`'s +/// `descriptor.contains_fingerprint_in_path` check. Fingerprint +/// `19608592` is reused as the first signing-mock's fp so that one row +/// exercises the `supported_device` (clickable) branch through +/// production logic. +const SAMPLE_DESCRIPTOR: &str = "wsh(or_d(pk([19608592/48'/1'/0'/2']tpubDEjf1AbrUjxnw8jg6Gi12CunPqnCobLP6Ktoy4Hd52pa65d6QRPg5CSkdFrqPDjJ8BAUuMEDVDRQVjtuWWksMqBeZCqyABFucN9ErQq8oVX/<0;1>/*),and_v(v:pkh([19608592/48'/1'/0'/2']tpubDEjf1AbrUjxnw8jg6Gi12CunPqnCobLP6Ktoy4Hd52pa65d6QRPg5CSkdFrqPDjJ8BAUuMEDVDRQVjtuWWksMqBeZCqyABFucN9ErQq8oVX/<2;3>/*),older(52596))))#x6u6lmej"; + +fn sample_descriptor() -> &'static LianaDescriptor { + static D: OnceLock = OnceLock::new(); + D.get_or_init(|| { + LianaDescriptor::from_str(SAMPLE_DESCRIPTOR).expect("sample descriptor parses") + }) +} + +/// Wrap a debug page body in the production dashboard, then overlay a +/// modal body on top using [`liana_ui::widget::modal::Modal`]. Both base +/// and overlay are mapped through the production `Message` type so iced +/// can join them into one widget tree. +fn dashboard_with_modal( + menu: &'static Menu, + title: &'static str, + base_body: B, + modal_body: M, +) -> Element<'static, DebugMessage> +where + B: Into>, + M: Into>, +{ + let dash_content: Column<'static, ViewMessage> = Column::new() + .spacing(30) + .push(h2(title)) + .push(p1_regular(NAV_HINT)) + .push(base_body); + let dashboard_elem = view::dashboard(menu, static_cache(), None, dash_content); + let elem: Element<'static, ViewMessage> = Modal::new(dashboard_elem, modal_body).into(); + elem.map(|_| ()) +} + +// ---- signing flow ---------------------------------------------------------- + +fn signing_hws() -> &'static [HardwareWallet] { + static HWS: OnceLock> = OnceLock::new(); + HWS.get_or_init(|| { + vec![ + supported( + DeviceKind::Ledger, + Some(ver(2, 1, 0)), + Fingerprint::from([0x19, 0x60, 0x85, 0x92]), + Some("Vault key"), + Some(true), + ), + supported( + DeviceKind::BitBox02, + Some(ver(9, 13, 0)), + fp(0xBB), + Some("Backup key"), + Some(false), + ), + supported( + DeviceKind::Coldcard, + Some(ver(5, 1, 0)), + fp(0xCC), + Some("Cosigner"), + Some(true), + ), + supported( + DeviceKind::Jade, + Some(ver(1, 0, 24)), + fp(0xDD), + Some("Jade"), + Some(true), + ), + supported( + DeviceKind::Specter, + Some(ver(2, 0, 0)), + fp(0xEE), + Some("Specter"), + Some(true), + ), + unsupported( + DeviceKind::Ledger, + Some(ver(1, 0, 0)), + UnsupportedReason::Version { + minimal_supported_version: "2.0.0".to_string(), + }, + ), + unsupported( + DeviceKind::BitBox02, + Some(ver(9, 13, 0)), + UnsupportedReason::WrongNetwork, + ), + unsupported( + DeviceKind::Coldcard, + None, + UnsupportedReason::NotPartOfWallet(fp(0xFF)), + ), + unsupported( + DeviceKind::Jade, + Some(ver(1, 0, 0)), + UnsupportedReason::AppIsNotOpen, + ), + locked(DeviceKind::BitBox02, Some("123-456")), + ] + }) +} + +fn signing_signed() -> &'static HashSet { + static S: OnceLock> = OnceLock::new(); + S.get_or_init(|| HashSet::from([fp(0xEE)])) +} + +fn signing_signing() -> &'static HashSet { + static S: OnceLock> = OnceLock::new(); + S.get_or_init(|| HashSet::from([fp(0xDD)])) +} + +fn signing_view() -> Element<'static, DebugMessage> { + let body = view::psbt::sign_action( + None, + signing_hws(), + sample_descriptor(), + None, + None, + signing_signed(), + signing_signing(), + None, + ); + dashboard_with_modal( + &super::PSBTS_MENU, + "HW modal:signing flow", + p1_regular("(production: PSBT details visible behind the modal)"), + body, + ) +} + +// ---- registration flow ----------------------------------------------------- + +fn registration_hws() -> &'static [HardwareWallet] { + static HWS: OnceLock> = OnceLock::new(); + HWS.get_or_init(|| { + vec![ + supported( + DeviceKind::Ledger, + Some(ver(2, 1, 0)), + fp(0xAA), + Some("Vault key"), + None, + ), + supported( + DeviceKind::BitBox02, + Some(ver(9, 13, 0)), + fp(0xBB), + Some("Backup key"), + None, + ), + supported( + DeviceKind::Coldcard, + Some(ver(5, 1, 0)), + fp(0xCC), + Some("Cosigner"), + Some(true), + ), + unsupported( + DeviceKind::Jade, + Some(ver(1, 0, 0)), + UnsupportedReason::WrongNetwork, + ), + locked(DeviceKind::BitBox02, Some("789-012")), + ] + }) +} + +fn registration_registered() -> &'static HashSet { + static S: OnceLock> = OnceLock::new(); + S.get_or_init(|| HashSet::from([fp(0xCC)])) +} + +fn registration_view() -> Element<'static, DebugMessage> { + let body = view::settings::register_wallet_modal( + None, + registration_hws(), + false, + None, + registration_registered(), + ); + dashboard_with_modal( + &super::SETTINGS_MENU, + "HW modal:registration flow", + p1_regular("(production: settings page visible behind the modal)"), + body, + ) +} + +// ---- verify-address flow --------------------------------------------------- + +fn verify_address_hws() -> &'static [HardwareWallet] { + static HWS: OnceLock> = OnceLock::new(); + HWS.get_or_init(|| { + vec![ + supported( + DeviceKind::Ledger, + Some(ver(2, 1, 0)), + fp(0xAA), + Some("Vault key"), + Some(true), + ), + supported( + DeviceKind::BitBox02, + Some(ver(9, 13, 0)), + fp(0xBB), + Some("Backup key"), + Some(true), + ), + supported( + DeviceKind::Specter, + Some(ver(2, 0, 0)), + fp(0xEE), + Some("Specter"), + Some(true), + ), + unsupported( + DeviceKind::Coldcard, + Some(ver(5, 1, 0)), + UnsupportedReason::Method("display_address"), + ), + locked(DeviceKind::Jade, None), + ] + }) +} + +fn verify_address_chosen() -> &'static HashSet { + static S: OnceLock> = OnceLock::new(); + S.get_or_init(|| HashSet::from([fp(0xBB)])) +} + +fn sample_address() -> &'static Address { + static A: OnceLock
= OnceLock::new(); + A.get_or_init(|| { + Address::from_str("bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq") + .expect("hardcoded sample") + .require_network(Network::Bitcoin) + .expect("mainnet sample") + }) +} + +fn sample_child_number() -> &'static ChildNumber { + static C: OnceLock = OnceLock::new(); + C.get_or_init(|| ChildNumber::Normal { index: 0 }) +} + +fn verify_address_view() -> Element<'static, DebugMessage> { + let body = view::receive::verify_address_modal( + None, + verify_address_hws(), + verify_address_chosen(), + sample_address(), + sample_child_number(), + ); + dashboard_with_modal( + &super::RECEIVE_MENU, + "HW modal:verify address flow", + p1_regular("(production: receive panel visible behind the modal)"), + body, + ) +} diff --git a/liana-gui/src/debug/hw_polling.rs b/liana-gui/src/debug/hw_polling.rs new file mode 100644 index 0000000000..2cc377d0c5 --- /dev/null +++ b/liana-gui/src/debug/hw_polling.rs @@ -0,0 +1,438 @@ +//! Stateful debug screen that runs two hardware-wallet polling backends +//! side by side: the GUI's own [`crate::hw::HardwareWallets`] wrapper on +//! the left, and [`async_hwi::service::HwiService`] (used directly by the +//! business installer) on the right. +//! +//! Unlike every other debug page, this one is interactive: the host +//! ([`crate::gui`]) detects the page by pointer equality with +//! [`ENTRY`] and renders [`HwPollingState::view`] / wires +//! [`HwPollingState::update`] and [`HwPollingState::subscription`] into +//! its own message loop instead of going through +//! [`super::render_location`]. + +use std::{collections::BTreeMap, sync::Arc}; + +use async_hwi::{ + service::{HwiService, SigningDevice, SigningDeviceMsg}, + DeviceKind, Version, +}; +use crossbeam::channel; +use iced::{ + futures::{SinkExt, Stream}, + Alignment, Length, Subscription, Task, +}; +use liana::miniscript::bitcoin::{bip32::Fingerprint, Network}; +use liana_ui::{ + component::{button, text}, + theme, + widget::*, +}; + +use crate::dir::LianaDirectory; +use crate::hw::{HardwareWallet, HardwareWalletMessage, HardwareWallets, UnsupportedReason}; +use crate::utils::subscription::run_with_id; + +use super::{installer_chrome, DebugMessage, DebugPageEntry}; + +/// Placeholder entry — the host renders the stateful view directly. The +/// placeholder is only reached if the host forgets to special-case this +/// page (which would be a bug); rendering something readable beats a +/// panic. +pub static ENTRY: DebugPageEntry = DebugPageEntry { + view: placeholder_view, +}; + +fn placeholder_view() -> Element<'static, DebugMessage> { + installer_chrome( + "HW polling", + "liana_gui::debug::hw_polling", + text::p1_regular( + "(stateful page — host should render via HwPollingState; \ + this placeholder means the dispatch path is wrong)", + ), + ) +} + +/// Message produced by the HW polling debug page. +/// +/// `From` is required by [`HwiService`]: the service +/// writes its own enum into the consumer's message type via this +/// conversion. +#[derive(Debug, Clone)] +pub enum HwPollingMessage { + ToggleLegacy, + ToggleService, + LegacyHw(HardwareWalletMessage), + ServiceHw(SigningDeviceMsg), + /// Periodic re-poll of `HwiService::list`. Needed because upstream + /// emits `SigningDeviceMsg::Update` synchronously when it spawns a + /// device-init task, but the resulting device is only inserted into + /// the shared map once that async task finishes — and no further + /// Update is sent (the listener's `should_poll` then sees the device + /// as known). A short tick covers that gap. + ServiceTick, +} + +impl From for HwPollingMessage { + fn from(v: SigningDeviceMsg) -> Self { + HwPollingMessage::ServiceHw(v) + } +} + +/// State shared between the two halves of the screen. Each half owns its +/// poller and its own on/off flag; the only shared piece is the network + +/// datadir used to construct both at creation time. +pub struct HwPollingState { + legacy: HardwareWallets, + legacy_polling: bool, + + service: Arc>, + service_polling: bool, + service_devices: BTreeMap>, + service_sender: channel::Sender, + service_receiver: channel::Receiver, +} + +impl std::fmt::Debug for HwPollingState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HwPollingState") + .field("legacy_polling", &self.legacy_polling) + .field("service_polling", &self.service_polling) + .field("service_devices", &self.service_devices.len()) + .finish() + } +} + +impl HwPollingState { + pub fn new(datadir: LianaDirectory, network: Network) -> Self { + let (service_sender, service_receiver) = channel::unbounded::(); + let rt = tokio::runtime::Handle::try_current().ok(); + let service = Arc::new(HwiService::new(network, rt)); + Self { + legacy: HardwareWallets::new(datadir, network), + legacy_polling: false, + service, + service_polling: false, + service_devices: BTreeMap::new(), + service_sender, + service_receiver, + } + } + + pub fn update(&mut self, msg: HwPollingMessage) -> Task { + match msg { + HwPollingMessage::ToggleLegacy => { + if self.legacy_polling { + self.legacy_polling = false; + self.legacy.reset_watch_list(); + } else { + self.legacy_polling = true; + } + Task::none() + } + HwPollingMessage::ToggleService => { + if self.service_polling { + self.service.stop(); + self.service_polling = false; + self.service_devices.clear(); + } else { + self.service.start(self.service_sender.clone()); + self.service_polling = true; + } + Task::none() + } + HwPollingMessage::LegacyHw(m) => match self.legacy.update(m) { + Ok(t) => t.map(HwPollingMessage::LegacyHw), + Err(e) => { + tracing::warn!("hw_polling legacy update error: {}", e); + Task::none() + } + }, + HwPollingMessage::ServiceHw(SigningDeviceMsg::Update) => { + self.service_devices = self.service.list(); + Task::none() + } + HwPollingMessage::ServiceHw(other) => { + tracing::debug!("hw_polling ignoring service msg: {:?}", other); + Task::none() + } + HwPollingMessage::ServiceTick => { + if self.service_polling { + self.service_devices = self.service.list(); + } + Task::none() + } + } + } + + pub fn subscription(&self) -> Subscription { + let mut subs: Vec> = Vec::new(); + if self.legacy_polling { + subs.push(self.legacy.refresh().map(HwPollingMessage::LegacyHw)); + } + // Receiver stream is always active when the page is mounted; it + // simply yields nothing while `service_polling` is false (no one + // is feeding the channel). + subs.push(run_with_id( + "hw_polling::service", + service_recv_stream(self.service_receiver.clone()), + )); + if self.service_polling { + subs.push( + iced::time::every(std::time::Duration::from_millis(500)) + .map(|_| HwPollingMessage::ServiceTick), + ); + } + Subscription::batch(subs) + } + + pub fn view(&self) -> Element<'_, HwPollingMessage> { + let body = Row::new() + .spacing(30) + .push(Container::new(self.legacy_column()).width(Length::FillPortion(1))) + .push(Container::new(self.service_column()).width(Length::FillPortion(1))) + .into(); + installer_chrome_owned("HW polling", "liana_gui::debug::hw_polling", body) + } + + fn legacy_column(&self) -> Column<'_, HwPollingMessage> { + let toggle = button_for(self.legacy_polling).on_press(HwPollingMessage::ToggleLegacy); + let status = if self.legacy_polling { + "running" + } else { + "stopped" + }; + let mut col = Column::new() + .spacing(15) + .push(text::h3("liana-gui · HardwareWallets")) + .push(text::caption(format!( + "crate::hw::HardwareWallets · {} device(s) · {}", + self.legacy.list.len(), + status, + ))) + .push(toggle); + for hw in &self.legacy.list { + col = col.push(legacy_device_row(hw)); + } + if self.legacy.list.is_empty() { + col = col.push(text::p2_regular("(no devices)").style(theme::text::secondary)); + } + col + } + + fn service_column(&self) -> Column<'_, HwPollingMessage> { + let toggle = button_for(self.service_polling).on_press(HwPollingMessage::ToggleService); + let status = if self.service_polling { + "running" + } else { + "stopped" + }; + let mut col = Column::new() + .spacing(15) + .push(text::h3("async-hwi · HwiService")) + .push(text::caption(format!( + "async_hwi::service::HwiService · {} device(s) · {}", + self.service_devices.len(), + status, + ))) + .push(toggle); + for dev in self.service_devices.values() { + col = col.push(service_device_row(dev)); + } + if self.service_devices.is_empty() { + col = col.push(text::p2_regular("(no devices)").style(theme::text::secondary)); + } + col + } +} + +fn button_for(running: bool) -> iced::widget::Button<'static, HwPollingMessage, theme::Theme> { + if running { + button::destructive(None, "Stop") + } else { + button::primary(None, "Start") + } +} + +fn legacy_device_row(hw: &HardwareWallet) -> Container<'static, HwPollingMessage> { + let (variant, kind, fingerprint, version, extra): ( + &'static str, + DeviceKind, + Option, + Option, + Option, + ) = match hw { + HardwareWallet::Unsupported { + kind, + version, + reason, + .. + } => ( + "Unsupported", + *kind, + None, + version.clone(), + Some(format_unsupported_reason(reason)), + ), + HardwareWallet::Locked { + kind, pairing_code, .. + } => ( + "Locked", + *kind, + None, + None, + pairing_code.as_ref().map(|c| format!("pairing: {c}")), + ), + HardwareWallet::Supported { + kind, + fingerprint, + version, + .. + } => ( + "Supported", + *kind, + Some(*fingerprint), + version.clone(), + None, + ), + }; + device_row(variant, kind, fingerprint, version, extra) +} + +fn service_device_row( + dev: &SigningDevice, +) -> Container<'static, HwPollingMessage> { + let (variant, extra): (&'static str, Option) = match dev { + SigningDevice::Unsupported { reason, .. } => ("Unsupported", Some(format!("{reason:?}"))), + SigningDevice::Locked { pairing_code, .. } => ( + "Locked", + pairing_code.as_ref().map(|c| format!("pairing: {c}")), + ), + SigningDevice::Supported(_) => ("Supported", None), + }; + let kind = *dev.kind(); + let fingerprint = dev.fingerprint(); + let version = service_device_version(dev); + device_row(variant, kind, fingerprint, version, extra) +} + +fn service_device_version(dev: &SigningDevice) -> Option { + match dev { + SigningDevice::Unsupported { version, .. } => version.clone(), + SigningDevice::Locked { .. } => None, + SigningDevice::Supported(supported) => supported.version().cloned(), + } +} + +fn device_row( + variant: &'static str, + kind: DeviceKind, + fingerprint: Option, + version: Option, + extra: Option, +) -> Container<'static, HwPollingMessage> { + let fp = fingerprint + .map(|f| f.to_string()) + .unwrap_or_else(|| "·".to_string()); + let ver = version + .as_ref() + .map(|v| v.to_string()) + .unwrap_or_else(|| "·".to_string()); + let mut col = Column::new() + .spacing(4) + .push( + Row::new() + .spacing(10) + .align_y(Alignment::Center) + .push(text::p1_bold(format!("{kind:?}"))) + .push(text::caption(variant).style(theme::text::secondary)), + ) + .push( + Row::new() + .spacing(15) + .push(text::p2_regular(format!("fp: {fp}"))) + .push(text::p2_regular(format!("v: {ver}"))), + ); + if let Some(e) = extra { + col = col.push(text::p2_regular(e).style(theme::text::secondary)); + } + Container::new(col) + .padding(10) + .style(theme::card::simple) + .width(Length::Fill) +} + +fn format_unsupported_reason(reason: &UnsupportedReason) -> String { + match reason { + UnsupportedReason::Version { + minimal_supported_version, + } => format!("min version: {minimal_supported_version}"), + UnsupportedReason::Method(m) => format!("unsupported method: {m}"), + UnsupportedReason::NotPartOfWallet(fp) => format!("not in wallet: {fp}"), + UnsupportedReason::WrongNetwork => "wrong network".to_string(), + UnsupportedReason::AppIsNotOpen => "app not open".to_string(), + } +} + +/// Owned-message variant of [`super::installer_chrome`]. The shared helper +/// hard-codes `DebugMessage` in its return type, which would force us to +/// `.map(|_| ())` and lose interactivity — so we duplicate the chrome here +/// over the generic message type. +fn installer_chrome_owned<'a, T: 'a>( + title: &'static str, + path: &'static str, + body: Element<'a, T>, +) -> Element<'a, T> { + Container::new( + Column::new() + .spacing(15) + .padding(30) + .push( + Row::new() + .spacing(15) + .align_y(Alignment::End) + .push(text::h2(title)) + .push(text::caption(path).style(theme::text::secondary)), + ) + .push( + Row::new() + .spacing(15) + .align_y(Alignment::End) + .push(text::p1_regular(super::NAV_HINT).style(theme::text::secondary)), + ) + .push(body), + ) + .style(theme::container::background) + .width(Length::Fill) + .height(Length::Fill) + .into() +} + +/// Bridge a crossbeam receiver into an iced-compatible async Stream. +/// Drives the recv loop on a blocking thread with a short timeout so the +/// stream cooperatively wakes — pure `recv()` would park the blocking +/// thread until shutdown and never release it. +fn service_recv_stream( + rx: channel::Receiver, +) -> impl Stream + Send + 'static { + type Sender = iced::futures::channel::mpsc::Sender; + iced::stream::channel(100, move |mut output: Sender| async move { + loop { + let rx2 = rx.clone(); + let res = tokio::task::spawn_blocking(move || { + rx2.recv_timeout(std::time::Duration::from_millis(500)) + }) + .await; + match res { + Ok(Ok(msg)) => { + if output.send(msg).await.is_err() { + break; + } + } + Ok(Err(channel::RecvTimeoutError::Timeout)) => continue, + Ok(Err(channel::RecvTimeoutError::Disconnected)) => break, + Err(_) => break, + } + } + }) +} diff --git a/liana-gui/src/debug/icons.rs b/liana-gui/src/debug/icons.rs new file mode 100644 index 0000000000..7944104acf --- /dev/null +++ b/liana-gui/src/debug/icons.rs @@ -0,0 +1,268 @@ +//! Icon-related debug pages, paginated to keep each one fast to render. +//! +//! Layout per font-viewer page: two side-by-side **columns**, each with a +//! header showing the codepoint range it covers and 20 rows of 16 glyph +//! cells (320 codepoints per column → 640 per page). Pages cover the +//! bootstrap-icons PUA range in 4 chunks. +//! +//! The Iconex (`Untitled1`) font is laid out separately: its 19 glyphs sit at +//! scattered codepoints across the BMP (extracted from the font's charset), +//! so the page lists each known codepoint individually rather than walking a +//! contiguous range. + +use iced::{Alignment, Font, Length}; +use liana_ui::{component::text, icon, theme, widget::*}; + +use crate::debug::{debug_chrome, DebugMessage, DebugPageEntry}; + +const BOOTSTRAP_FONT: Font = Font::with_name("bootstrap-icons"); +const ICONEX_FONT: Font = Font::with_name("Untitled1"); + +const HELPER_CELL: Length = Length::Fixed(220.0); +const GLYPH_CELL: Length = Length::Fixed(28.0); +const ROW_LABEL: Length = Length::Fixed(60.0); +const ROW_SPACING: f32 = 6.0; + +const CELLS_PER_ROW: u32 = 16; +const ROWS_PER_COLUMN: u32 = 20; +const CODES_PER_COLUMN: u32 = ROWS_PER_COLUMN * CELLS_PER_ROW; // 320 +const CODES_PER_PAGE: u32 = 2 * CODES_PER_COLUMN; // 640 + +// ----- Page entries --------------------------------------------------------- + +pub static ENTRY_HELPERS: DebugPageEntry = DebugPageEntry { view: helpers_view }; +pub static ENTRY_BOOTSTRAP_1: DebugPageEntry = DebugPageEntry { + view: bootstrap_page_1, +}; +pub static ENTRY_BOOTSTRAP_2: DebugPageEntry = DebugPageEntry { + view: bootstrap_page_2, +}; +pub static ENTRY_BOOTSTRAP_3: DebugPageEntry = DebugPageEntry { + view: bootstrap_page_3, +}; +pub static ENTRY_BOOTSTRAP_4: DebugPageEntry = DebugPageEntry { + view: bootstrap_page_4, +}; +pub static ENTRY_ICONEX: DebugPageEntry = DebugPageEntry { view: iconex_view }; + +// ----- Helpers section ------------------------------------------------------ + +type IconFn = fn() -> Text<'static>; + +#[rustfmt::skip] +const BOOTSTRAP_HELPERS: &[(&str, IconFn)] = &[ + ("cross_icon", icon::cross_icon), + ("arrow_down", icon::arrow_down), + ("arrow_back", icon::arrow_back), + ("arrow_right", icon::arrow_right), + ("arrow_return_right", icon::arrow_return_right), + ("chevron_right", icon::chevron_right), + ("recovery_icon", icon::recovery_icon), + ("plug_icon", icon::plug_icon), + ("reload_icon", icon::reload_icon), + ("import_icon", icon::import_icon), + ("wallet_icon", icon::wallet_icon), + ("bitcoin_icon", icon::bitcoin_icon), + ("block_icon", icon::block_icon), + ("dot_icon", icon::dot_icon), + ("person_icon", icon::person_icon), + ("tooltip_icon", icon::tooltip_icon), + ("plus_icon", icon::plus_icon), + ("minus_icon", icon::minus_icon), + ("warning_icon", icon::warning_icon), + ("warning_fill_icon", icon::warning_fill_icon), + ("chip_icon", icon::chip_icon), + ("trash_icon", icon::trash_icon), + ("pencil_icon", icon::pencil_icon), + ("collapse_icon", icon::collapse_icon), + ("collapsed_icon", icon::collapsed_icon), + ("down_icon", icon::down_icon), + ("up_icon", icon::up_icon), + ("network_icon", icon::network_icon), + ("previous_icon", icon::previous_icon), + ("check_icon", icon::check_icon), + ("round_key_icon", icon::round_key_icon), + ("backup_icon", icon::backup_icon), + ("restore_icon", icon::restore_icon), + ("wrench_icon", icon::wrench_icon), + ("link_icon", icon::link_icon), + ("paste_icon", icon::paste_icon), + ("usb_icon", icon::usb_icon), + ("usb_drive_icon", icon::usb_drive_icon), + ("hdd_icon", icon::hdd_icon), + ("enter_box_icon", icon::enter_box_icon), + ("collection_icon", icon::collection_icon), + ("coins_icon", icon::coins_icon), + ("receive_icon", icon::receive_icon), + ("send_icon", icon::send_icon), + ("settings_icon", icon::settings_icon), +]; + +#[rustfmt::skip] +const ICONEX_HELPERS: &[(&str, IconFn)] = &[ + ("arrow_repeat", icon::arrow_repeat), + ("home_icon", icon::home_icon), + ("key_icon", icon::key_icon), + ("history_icon", icon::history_icon), + ("clock_icon", icon::clock_icon), + ("clipboard_icon", icon::clipboard_icon), + ("circle_check_icon", icon::circle_check_icon), + ("circle_cross_icon", icon::circle_cross_icon), +]; + +fn helper_cell(name: &'static str, ctor: IconFn) -> Container<'static, DebugMessage> { + Container::new( + Row::new() + .spacing(10) + .align_y(Alignment::Center) + .push(Container::new(ctor()).center_x(GLYPH_CELL)) + .push(text::p2_regular(name)), + ) + .width(HELPER_CELL) +} + +fn helpers_grid(items: &'static [(&'static str, IconFn)]) -> Column<'static, DebugMessage> { + items + .chunks(4) + .fold(Column::new().spacing(ROW_SPACING), |col, chunk| { + let row = chunk.iter().fold( + Row::new().spacing(20).align_y(Alignment::Center), + |r, (n, f)| r.push(helper_cell(n, *f)), + ); + col.push(row) + }) +} + +fn helpers_view() -> Element<'static, DebugMessage> { + let body = Column::new() + .spacing(40) + .push(text::h3("bootstrap-icons helpers")) + .push(helpers_grid(BOOTSTRAP_HELPERS)) + .push(text::h3("Untitled1 (Iconex) helpers")) + .push(helpers_grid(ICONEX_HELPERS)); + + debug_chrome("Icons — helpers", body) +} + +// ----- Bootstrap viewer (paged) -------------------------------------------- + +fn glyph(c: char, font: Font) -> Container<'static, DebugMessage> { + Container::new( + Text::new(c.to_string()) + .font(font) + .size(18) + .align_x(iced::alignment::Horizontal::Center), + ) + .center_x(GLYPH_CELL) + .center_y(GLYPH_CELL) + .style(theme::card::border) +} + +/// Build one of the two side-by-side columns: header + 20 rows × 16 cells. +fn viewer_column(font: Font, start: u32) -> Column<'static, DebugMessage> { + let end = start + CODES_PER_COLUMN - 1; + let header = Container::new(text::p1_bold(format!("U+{start:04X} → U+{end:04X}"))) + .padding([4, 8]) + .style(theme::card::simple); + + let mut col = Column::new().spacing(ROW_SPACING).push(header); + for r in 0..ROWS_PER_COLUMN { + let row_start = start + r * CELLS_PER_ROW; + let mut row = Row::new() + .spacing(4) + .align_y(Alignment::Center) + .push(Container::new(text::caption(format!("U+{row_start:04X}"))).width(ROW_LABEL)); + for c in 0..CELLS_PER_ROW { + if let Some(ch) = char::from_u32(row_start + c) { + row = row.push(glyph(ch, font)); + } + } + col = col.push(row); + } + col +} + +fn bootstrap_page(page_start: u32) -> Element<'static, DebugMessage> { + let left = viewer_column(BOOTSTRAP_FONT, page_start); + let right = viewer_column(BOOTSTRAP_FONT, page_start + CODES_PER_COLUMN); + let body = Row::new() + .spacing(40) + .align_y(Alignment::Start) + .push(left) + .push(right); + + let title: &'static str = match page_start { + 0xF000 => "Icons — bootstrap U+F000..U+F27F", + 0xF280 => "Icons — bootstrap U+F280..U+F4FF", + 0xF500 => "Icons — bootstrap U+F500..U+F77F", + 0xF780 => "Icons — bootstrap U+F780..U+F9FF", + _ => "Icons — bootstrap", + }; + debug_chrome(title, body) +} + +fn bootstrap_page_1() -> Element<'static, DebugMessage> { + bootstrap_page(0xF000) +} +fn bootstrap_page_2() -> Element<'static, DebugMessage> { + bootstrap_page(0xF000 + CODES_PER_PAGE) +} +fn bootstrap_page_3() -> Element<'static, DebugMessage> { + bootstrap_page(0xF000 + 2 * CODES_PER_PAGE) +} +fn bootstrap_page_4() -> Element<'static, DebugMessage> { + bootstrap_page(0xF000 + 3 * CODES_PER_PAGE) +} + +// ----- Iconex viewer (explicit codepoints) --------------------------------- + +/// Iconex glyph codepoints, extracted from the font's charset. The font +/// generator (see `liana-ui/static/icons/iconex/svg_to_ttf.py`) places each +/// glyph at `hash(name) % 0xFFFF`, which is non-deterministic across Python +/// runs — so this list is the authoritative record. If a new SVG is added, +/// re-extract via `fc-query path/to/iconex-icons.ttf`. +#[rustfmt::skip] +const ICONEX_CODEPOINTS: &[u32] = &[ + 0x19DA, 0x2CEE, 0x3038, 0x3D0F, 0x46BB, 0x532D, 0x605B, 0x9F25, + 0xB0CA, 0xBD58, 0xBD6B, 0xBEBA, 0xC722, 0xC882, 0xD163, 0xE2F9, + 0xEDE9, 0xF8D3, 0xFFEC, +]; + +fn iconex_row(code: u32) -> Row<'static, DebugMessage> { + let cell: Element<'static, DebugMessage> = match char::from_u32(code) { + Some(c) => glyph(c, ICONEX_FONT).into(), + None => Container::new(text::caption("?")) + .center_x(GLYPH_CELL) + .into(), + }; + Row::new() + .spacing(10) + .align_y(Alignment::Center) + .push(Container::new(text::p2_regular(format!("U+{code:04X}"))).width(ROW_LABEL)) + .push(cell) +} + +fn iconex_view() -> Element<'static, DebugMessage> { + let mid = ICONEX_CODEPOINTS.len().div_ceil(2); + let (left_codes, right_codes) = ICONEX_CODEPOINTS.split_at(mid); + + let column = |codes: &[u32]| -> Column<'static, DebugMessage> { + let first = *codes.first().unwrap(); + let last = *codes.last().unwrap(); + let header = Container::new(text::p1_bold(format!("U+{first:04X} → U+{last:04X}"))) + .padding([4, 8]) + .style(theme::card::simple); + codes.iter().fold( + Column::new().spacing(ROW_SPACING).push(header), + |col, &c| col.push(iconex_row(c)), + ) + }; + + let body = Row::new() + .spacing(40) + .align_y(Alignment::Start) + .push(column(left_codes)) + .push(column(right_codes)); + + debug_chrome("Icons — Untitled1 (Iconex) glyphs", body) +} diff --git a/liana-gui/src/debug/installer_modals.rs b/liana-gui/src/debug/installer_modals.rs new file mode 100644 index 0000000000..6e82b4da22 --- /dev/null +++ b/liana-gui/src/debug/installer_modals.rs @@ -0,0 +1,448 @@ +//! Renders the installer's "select key source" and "edit key alias" +//! modals (`installer::SelectKeySource` and `installer::EditKeyAlias`) +//! in their `Step::Select` initial state, with assorted public-API setups. +//! +//! Each page constructs the modal via its public `new()` constructor, +//! together with a mocked [`HardwareWallets`], then calls the production +//! `DescriptorEditModal::view` to render. Visual states reachable only by +//! mutating private fields (`Step::Details`, in-flight processing, +//! errors, internal export modal) are out of scope — see the discussion +//! on the parent task. To exercise more of those, expose `pub(crate)` +//! test hooks on `SelectKeySource`. +//! +//! `HardwareWallet::Supported` requires an `Arc`; +//! we satisfy it with a [`MockHwi`] whose async methods return +//! `UnimplementedMethod` — never invoked in the rendering path. + +use std::collections::HashMap; +use std::str::FromStr; +use std::sync::{Arc, Mutex, OnceLock}; + +use async_hwi::{AddressScript, DeviceKind, Error as HwiError, Version, HWI}; +use async_trait::async_trait; +use liana::miniscript::{ + bitcoin::{ + bip32::{DerivationPath, Fingerprint, Xpub}, + psbt::Psbt, + Network, + }, + descriptor::DescriptorPublicKey, +}; +use liana_connect::keys::api::KeyKind; + +use liana_ui::widget::*; + +use crate::{ + debug::{installer_with_modal, DebugMessage, DebugPageEntry}, + dir::LianaDirectory, + hw::{HardwareWallet, HardwareWallets, UnsupportedReason}, + installer::{ + DescriptorEditModal, EditKeyAlias, Key, KeySource, Message as InstallerMessage, PathData, + PathKind, SelectKeySource, SelectKeySourceMessage, + }, + signer::Signer, +}; + +pub static ENTRY_EMPTY: DebugPageEntry = DebugPageEntry { view: empty_view }; +pub static ENTRY_OPTIONS_OPEN: DebugPageEntry = DebugPageEntry { + view: options_open_view, +}; +pub static ENTRY_WITH_HWS: DebugPageEntry = DebugPageEntry { + view: with_hws_view, +}; +pub static ENTRY_TAPROOT_PATH: DebugPageEntry = DebugPageEntry { + view: taproot_path_view, +}; +pub static ENTRY_SAFETY_NET: DebugPageEntry = DebugPageEntry { + view: safety_net_view, +}; +pub static ENTRY_EDIT_ALIAS: DebugPageEntry = DebugPageEntry { + view: edit_alias_view, +}; + +/// Sample xpub used to construct mock `Key` values. The xpub itself is +/// inert — nothing in the rendering path validates it on-chain. +const SAMPLE_XPUB: &str = "[f714c228/48'/1'/0'/2']tpubDEwJnTwfKoMvu8AXXBPydBVWDpzNP5tatjjZ56q4TQioGL7iL9xzTbMoCCQ3tfGihtff7vtR4xsjcRuhZ7HWARVAkGZ1HZcpBhVdou76k7j/<0;1>/*"; + +/// SAFETY: iced renders on the main thread; debug-overlay state is only +/// read during rendering, so satisfying `OnceLock`'s `Sync` bound with an +/// unconditional `unsafe impl Sync` is sound here. +struct StateCell(T); +unsafe impl Sync for StateCell {} + +/// Stand-in `HWI` implementation. None of the async methods are reached +/// by `hw_list_view*` rendering — they exist only so we can construct +/// `Arc`. +#[derive(Debug)] +struct MockHwi(DeviceKind); + +#[async_trait] +impl HWI for MockHwi { + fn device_kind(&self) -> DeviceKind { + self.0 + } + async fn get_version(&self) -> Result { + Err(HwiError::UnimplementedMethod) + } + async fn get_master_fingerprint(&self) -> Result { + Err(HwiError::UnimplementedMethod) + } + async fn get_extended_pubkey(&self, _path: &DerivationPath) -> Result { + Err(HwiError::UnimplementedMethod) + } + async fn register_wallet( + &self, + _name: &str, + _policy: &str, + ) -> Result, HwiError> { + Err(HwiError::UnimplementedMethod) + } + async fn is_wallet_registered(&self, _name: &str, _policy: &str) -> Result { + Err(HwiError::UnimplementedMethod) + } + async fn display_address(&self, _script: &AddressScript) -> Result<(), HwiError> { + Err(HwiError::UnimplementedMethod) + } + async fn sign_tx(&self, _tx: &mut Psbt) -> Result<(), HwiError> { + Err(HwiError::UnimplementedMethod) + } +} + +fn fp(b: u8) -> Fingerprint { + Fingerprint::from([b; 4]) +} + +fn ver(major: u32, minor: u32, patch: u32) -> Version { + Version { + major, + minor, + patch, + prerelease: None, + } +} + +fn supported_hw( + kind: DeviceKind, + version: Option, + fingerprint: Fingerprint, + alias: Option<&'static str>, +) -> HardwareWallet { + HardwareWallet::Supported { + id: format!("dbg-{kind:?}-{fingerprint}"), + device: Arc::new(MockHwi(kind)), + kind, + fingerprint, + version, + registered: None, + alias: alias.map(String::from), + } +} + +fn unsupported_hw( + kind: DeviceKind, + version: Option, + reason: UnsupportedReason, +) -> HardwareWallet { + HardwareWallet::Unsupported { + id: format!("dbg-unsup-{kind:?}"), + kind, + version, + reason, + } +} + +fn locked_hw(kind: DeviceKind, pairing_code: Option<&'static str>) -> HardwareWallet { + HardwareWallet::Locked { + id: format!("dbg-lock-{kind:?}"), + device: Arc::new(Mutex::new(None)), + pairing_code: pairing_code.map(String::from), + kind, + } +} + +fn empty_hws() -> HardwareWallets { + HardwareWallets::new( + LianaDirectory::new(std::path::PathBuf::new()), + Network::Bitcoin, + ) +} + +fn hws_with(list: Vec) -> HardwareWallets { + let mut hws = empty_hws(); + hws.list = list; + hws +} + +fn fresh_signer() -> Arc> { + Arc::new(Mutex::new( + Signer::generate(Network::Bitcoin).expect("hot signer generation"), + )) +} + +/// A primary path with one slot to fill at coordinates `(0, 0)`. +fn primary_path() -> PathData { + PathData { + coordinates: vec![(0, 0)], + keys: vec![], + token_kind: vec![], + } +} + +/// A safety-net path with one slot, restricted to `KeyKind::SafetyNet` +/// tokens — this is what unlocks the safety-net token entry in the +/// "other options" section of `SelectKeySource`. +fn safety_net_path() -> PathData { + PathData { + coordinates: vec![(0, 0)], + keys: vec![], + token_kind: vec![KeyKind::SafetyNet], + } +} + +fn empty_state() -> StateCell<(SelectKeySource, HardwareWallets)> { + let modal = SelectKeySource::new( + Network::Bitcoin, + false, + primary_path(), + HashMap::new(), + HashMap::new(), + fresh_signer(), + ); + StateCell((modal, empty_hws())) +} + +fn empty_view() -> Element<'static, DebugMessage> { + static STATE: OnceLock> = OnceLock::new(); + let s = STATE.get_or_init(empty_state); + let body = s.0 .0.view(&s.0 .1).map(|_| ()); + installer_with_modal( + "Select key source — empty primary path", + "liana_gui::installer::step::descriptor::editor::key::SelectKeySource::view", + body, + ) +} + +/// Same setup as [`empty_state`], but driven through the production +/// `update()` path with a `Collapse(true)` message so the "Other options" +/// section is expanded — surfacing the load-key / paste-xpub / generate +/// hot-key entries that are otherwise hidden behind the collapsible +/// header. +fn options_open_state() -> StateCell<(SelectKeySource, HardwareWallets)> { + let mut modal = SelectKeySource::new( + Network::Bitcoin, + false, + primary_path(), + HashMap::new(), + HashMap::new(), + fresh_signer(), + ); + let mut hws = empty_hws(); + let _ = modal.update( + &mut hws, + InstallerMessage::SelectKeySource(SelectKeySourceMessage::Collapse(true)), + ); + StateCell((modal, hws)) +} + +fn options_open_view() -> Element<'static, DebugMessage> { + static STATE: OnceLock> = OnceLock::new(); + let s = STATE.get_or_init(options_open_state); + let body = s.0 .0.view(&s.0 .1).map(|_| ()); + installer_with_modal( + "Select key source — no devices, options open", + "liana_gui::installer::step::descriptor::editor::key::SelectKeySource::view", + body, + ) +} + +fn with_hws_state() -> StateCell<(SelectKeySource, HardwareWallets)> { + // Cover every visual branch reachable from `widget_signing_device` with + // `taproot = false`: a clickable Supported row, both Locked sub-cases + // (with / without pairing code), and every `UnsupportedReason`. + let hws = hws_with(vec![ + supported_hw( + DeviceKind::Ledger, + Some(ver(2, 1, 0)), + fp(0xAA), + Some("Vault key"), + ), + locked_hw(DeviceKind::Jade, Some("123-456")), + locked_hw(DeviceKind::BitBox02, None), + unsupported_hw( + DeviceKind::Coldcard, + Some(ver(5, 1, 0)), + UnsupportedReason::Version { + minimal_supported_version: "6.0.0".to_string(), + }, + ), + unsupported_hw( + DeviceKind::Specter, + Some(ver(2, 0, 0)), + UnsupportedReason::Method("display_address"), + ), + unsupported_hw( + DeviceKind::Ledger, + Some(ver(2, 1, 0)), + UnsupportedReason::WrongNetwork, + ), + unsupported_hw( + DeviceKind::BitBox02, + Some(ver(9, 13, 0)), + UnsupportedReason::AppIsNotOpen, + ), + unsupported_hw( + DeviceKind::Coldcard, + None, + UnsupportedReason::NotPartOfWallet(fp(0xFF)), + ), + ]); + let modal = SelectKeySource::new( + Network::Bitcoin, + false, + primary_path(), + HashMap::new(), + HashMap::new(), + fresh_signer(), + ); + StateCell((modal, hws)) +} + +fn with_hws_view() -> Element<'static, DebugMessage> { + static STATE: OnceLock> = OnceLock::new(); + let s = STATE.get_or_init(with_hws_state); + let body = s.0 .0.view(&s.0 .1).map(|_| ()); + installer_with_modal( + "Select key source — with detected devices", + "liana_gui::installer::step::descriptor::editor::key::SelectKeySource::view", + body, + ) +} + +/// Detected devices for a taproot-required path (`taproot = true`). Each +/// supported device here is below its tap-miniscript minimum (or absent +/// from `DEVICES_COMPATIBLE_WITH_TAPMINISCRIPT` entirely, like Jade), so +/// `widget_signing_device`'s `(_, false, true)` arm fires and shows +/// "This device doesn't support taproot miniscript". One Specter row is +/// included as a control — Specter has no minimum, so it stays +/// selectable. +fn taproot_path_state() -> StateCell<(SelectKeySource, HardwareWallets)> { + let hws = hws_with(vec![ + supported_hw( + DeviceKind::Ledger, + Some(ver(2, 1, 0)), // < 2.2.0 + fp(0xAA), + Some("Old Ledger"), + ), + supported_hw( + DeviceKind::Coldcard, + Some(ver(5, 0, 0)), // < 6.3.3 + fp(0xBB), + Some("Old Coldcard"), + ), + supported_hw( + DeviceKind::BitBox02, + Some(ver(9, 13, 0)), // < 9.21.0 + fp(0xCC), + Some("Old BitBox02"), + ), + supported_hw( + DeviceKind::Jade, + Some(ver(1, 0, 24)), // Jade not in the table at all + fp(0xDD), + Some("Jade"), + ), + supported_hw( + DeviceKind::Specter, + Some(ver(2, 0, 0)), // Specter has no minimum -> compatible + fp(0xEE), + Some("Specter"), + ), + ]); + let modal = SelectKeySource::new( + Network::Bitcoin, + true, // taproot path + primary_path(), + HashMap::new(), + HashMap::new(), + fresh_signer(), + ); + StateCell((modal, hws)) +} + +fn taproot_path_view() -> Element<'static, DebugMessage> { + static STATE: OnceLock> = OnceLock::new(); + let s = STATE.get_or_init(taproot_path_state); + let body = s.0 .0.view(&s.0 .1).map(|_| ()); + installer_with_modal( + "Select key source — taproot path", + "liana_gui::installer::step::descriptor::editor::key::SelectKeySource::view", + body, + ) +} + +fn safety_net_state() -> StateCell<(SelectKeySource, HardwareWallets)> { + let modal = SelectKeySource::new( + Network::Bitcoin, + false, + safety_net_path(), + HashMap::new(), + HashMap::new(), + fresh_signer(), + ); + StateCell((modal, empty_hws())) +} + +fn safety_net_view() -> Element<'static, DebugMessage> { + static STATE: OnceLock> = OnceLock::new(); + let s = STATE.get_or_init(safety_net_state); + let body = s.0 .0.view(&s.0 .1).map(|_| ()); + installer_with_modal( + "Select key source — safety-net path", + "liana_gui::installer::step::descriptor::editor::key::SelectKeySource::view", + body, + ) +} + +// ---- Edit key alias -------------------------------------------------------- + +fn sample_key(name: &str, fingerprint: Fingerprint) -> Key { + Key { + source: KeySource::Manual, + name: name.to_string(), + fingerprint, + key: DescriptorPublicKey::from_str(SAMPLE_XPUB).expect("sample xpub parses"), + account: None, + } +} + +fn edit_alias_state() -> StateCell<(EditKeyAlias, HardwareWallets)> { + // Two existing keys so the "alias already used" check has something + // to bump against. + let mut keys: HashMap, Key)> = HashMap::new(); + let other_fp = fp(0xCC); + keys.insert(other_fp, (vec![(0, 1)], sample_key("Backup key", other_fp))); + let target_fp = fp(0xAA); + keys.insert( + target_fp, + (vec![(0, 0)], sample_key("Vault key", target_fp)), + ); + let modal = EditKeyAlias::new( + keys, + target_fp, + "Vault key".to_string(), + PathKind::Primary, + vec![(0, 0)], + ); + StateCell((modal, empty_hws())) +} + +fn edit_alias_view() -> Element<'static, DebugMessage> { + static STATE: OnceLock> = OnceLock::new(); + let s = STATE.get_or_init(edit_alias_state); + let body = s.0 .0.view(&s.0 .1).map(|_| ()); + installer_with_modal( + "Edit key alias — default", + "liana_gui::installer::step::descriptor::editor::key::EditKeyAlias::view", + body, + ) +} diff --git a/liana-gui/src/debug/mod.rs b/liana-gui/src/debug/mod.rs index 42f657f339..188778f990 100644 --- a/liana-gui/src/debug/mod.rs +++ b/liana-gui/src/debug/mod.rs @@ -22,7 +22,7 @@ use std::{cell::Cell, sync::OnceLock}; -use iced::{Alignment, Length}; +use iced::{widget::column, Alignment, Length}; use liana_ui::{ component::{scrollable, text}, theme, @@ -32,9 +32,24 @@ use liana_ui::{ use crate::app::{cache::Cache, menu::Menu, view::Message as ViewMessage}; pub mod badges; +pub mod buttons; +pub mod cards; +pub mod decrypt_modal; +pub mod forms; +pub mod home; +pub mod hw; +pub mod hw_modals; +pub mod hw_polling; +pub mod icons; +pub mod installer_modals; +pub mod panels; +pub mod payment_cards; pub mod pill_styles; pub mod pills; +pub mod psbts; +pub mod settings; pub mod texts; +pub mod transactions; pub const DESIGN_SYSTEM: DebugStack = DebugStack { name: "Design system", @@ -44,34 +59,87 @@ pub const DESIGN_SYSTEM: DebugStack = DebugStack { &badges::ENTRY, &pills::ENTRY, &pill_styles::ENTRY, + &buttons::ENTRY_CONSTRUCTORS_THEMED, + &buttons::ENTRY_CONSTRUCTORS_HELPERS, + &buttons::ENTRY_CONSTRUCTORS_WIDTHS, + &buttons::ENTRY_THEMES, &texts::ENTRY_LEGACY, &texts::ENTRY_NEW, + &texts::ENTRY_REPLACEMENT, &texts::ENTRY_THEMES, + &hw::ENTRY_PAGE_1, + &hw::ENTRY_PAGE_2, + &forms::ENTRY, + &cards::ENTRY_CONSTRUCTORS, + &cards::ENTRY_THEMES, + &cards::ENTRY_WRAPPED, + &payment_cards::ENTRY, + &icons::ENTRY_HELPERS, + &icons::ENTRY_BOOTSTRAP_1, + &icons::ENTRY_BOOTSTRAP_2, + &icons::ENTRY_BOOTSTRAP_3, + &icons::ENTRY_BOOTSTRAP_4, + &icons::ENTRY_ICONEX, ], }; pub const HOME_PANEL: DebugStack = DebugStack { name: "Home panel", menu: Some(&HOME_MENU), - pages: &[], + pages: &[ + &home::ENTRY_PAYMENTS, + &home::ENTRY_EMPTY, + &home::ENTRY_SYNCING, + &home::ENTRY_UNCONFIRMED, + &home::ENTRY_FIAT, + &home::ENTRY_RESCAN_WARNING, + &home::ENTRY_EXPIRING, + &home::ENTRY_SEQUENCE_HINT, + &home::ENTRY_PAGINATION, + &home::ENTRY_SIDEBAR_RESCAN, + ], }; pub const SEND_PANEL: DebugStack = DebugStack { name: "Send panel", menu: Some(&SEND_MENU), - pages: &[], + pages: &[ + &panels::ENTRY_SPEND_DRAFTING, + &panels::ENTRY_SPEND_REVIEWING, + &panels::ENTRY_RECIPIENT_VALID, + &panels::ENTRY_CREATE_SPEND_SELF, + &panels::ENTRY_CREATE_SPEND_FILLED, + ], }; pub const RECEIVE_PANEL: DebugStack = DebugStack { name: "Receive panel", menu: Some(&RECEIVE_MENU), - pages: &[], + pages: &[ + &panels::ENTRY_RECEIVE, + &panels::ENTRY_RECEIVE_WITH_PREV, + &panels::ENTRY_VERIFY_ADDRESS, + &panels::ENTRY_QR_MODAL, + ], }; pub const PSBT_PANEL: DebugStack = DebugStack { name: "PSBT", menu: Some(&PSBTS_MENU), - pages: &[], + pages: &[ + &psbts::ENTRY, + &psbts::ENTRY_IMPORT_EMPTY, + &psbts::ENTRY_IMPORT_TYPED, + &psbts::ENTRY_IMPORT_PROCESSING, + &psbts::ENTRY_IMPORT_SUCCESS, + &psbts::ENTRY_RBF_BUMP, + &psbts::ENTRY_RBF_REPLACED, + &psbts::ENTRY_RBF_CANCEL, + &psbts::ENTRY_PSBT_PENDING, + &psbts::ENTRY_PSBT_BROADCAST, + &psbts::ENTRY_PSBT_SPENT, + &psbts::ENTRY_PSBT_RECOVERY, + ], }; pub const RECOVERY_PANEL: DebugStack = DebugStack { @@ -83,7 +151,12 @@ pub const RECOVERY_PANEL: DebugStack = DebugStack { pub const TRANSACTIONS_PANEL: DebugStack = DebugStack { name: "Transactions", menu: Some(&TRANSACTIONS_MENU), - pages: &[], + pages: &[ + &transactions::ENTRY, + &panels::ENTRY_TX_OUTGOING, + &panels::ENTRY_TX_INCOMING, + &panels::ENTRY_TX_SELF, + ], }; pub const COINS_PANEL: DebugStack = DebugStack { @@ -95,19 +168,72 @@ pub const COINS_PANEL: DebugStack = DebugStack { pub const SETTINGS_PANEL: DebugStack = DebugStack { name: "Settings", menu: Some(&SETTINGS_MENU), - pages: &[], + pages: &[ + &settings::ENTRY_LIST_LOCAL, + &settings::ENTRY_LIST_REMOTE, + &settings::ENTRY_ABOUT, + &settings::ENTRY_IMPORT_EXPORT, + &settings::ENTRY_GENERAL_OFF, + &settings::ENTRY_GENERAL_ON, + &settings::ENTRY_REMOTE_BACKEND_IDLE, + &settings::ENTRY_REMOTE_BACKEND_PROCESSING, + &settings::ENTRY_REMOTE_BACKEND_SUCCESS, + &settings::ENTRY_BITCOIND_RUNNING, + &settings::ENTRY_BITCOIND_STOPPED, + &settings::ENTRY_BITCOIND_EDIT, + &settings::ENTRY_ELECTRUM_RUNNING, + &settings::ENTRY_ELECTRUM_EDIT, + &settings::ENTRY_RESCAN_IDLE, + &settings::ENTRY_RESCAN_SCANNING, + &settings::ENTRY_RESCAN_SUCCESS, + &settings::ENTRY_RESCAN_INVALID_DATE, + &settings::ENTRY_WALLET_SETTINGS, + &settings::ENTRY_WALLET_SETTINGS_PROCESSING, + &settings::ENTRY_WALLET_SETTINGS_UPDATED, + &settings::ENTRY_REGISTER_WALLET_MODAL, + ], }; pub const HW_MODALS: DebugStack = DebugStack { name: "HW modals", menu: None, - pages: &[], + pages: &[ + &hw_modals::ENTRY_SIGNING, + &hw_modals::ENTRY_REGISTRATION, + &hw_modals::ENTRY_VERIFY_ADDRESS, + ], }; pub const INSTALLER_MODALS: DebugStack = DebugStack { name: "Installer modals", menu: None, - pages: &[], + pages: &[ + &installer_modals::ENTRY_EMPTY, + &installer_modals::ENTRY_OPTIONS_OPEN, + &installer_modals::ENTRY_WITH_HWS, + &installer_modals::ENTRY_TAPROOT_PATH, + &installer_modals::ENTRY_SAFETY_NET, + &installer_modals::ENTRY_EDIT_ALIAS, + ], +}; + +pub const HW_POLLING: DebugStack = DebugStack { + name: "HW polling", + menu: None, + pages: &[&hw_polling::ENTRY], +}; + +pub const DECRYPT_MODAL: DebugStack = DebugStack { + name: "Decrypt backup modal", + menu: None, + pages: &[ + &decrypt_modal::ENTRY_INITIAL, + &decrypt_modal::ENTRY_OPTIONS_OPEN, + &decrypt_modal::ENTRY_MNEMONIC_NO_ACK, + &decrypt_modal::ENTRY_MNEMONIC_ACKED, + &decrypt_modal::ENTRY_FETCHED, + &decrypt_modal::ENTRY_INVALID_ENCODING, + ], }; /// All registered debug stacks, in navigation order. `Ctrl + D + ↑/↓` @@ -124,6 +250,8 @@ pub const STACKS: &[&DebugStack] = &[ &SETTINGS_PANEL, &HW_MODALS, &INSTALLER_MODALS, + &DECRYPT_MODAL, + &HW_POLLING, ]; /// Navigation hint shown in every debug page's chrome. @@ -215,6 +343,22 @@ fn static_cache() -> &'static Cache { .0 } +/// Like [`static_cache`] but with an in-progress rescan, so `view::dashboard` +/// shows the sidebar's rescan pill. +pub fn rescanning_cache() -> &'static Cache { + static CACHE: OnceLock = OnceLock::new(); + &CACHE + .get_or_init(|| { + let mut cache = Cache::default(); + if let Some(v) = DEBUG_VARIANT.get() { + cache.variant = *v; + } + cache.daemon_cache.rescan_progress = Some(0.42); + CacheCell(cache) + }) + .0 +} + /// Wrap a debug-page body in the production sidebar/dashboard chrome, /// highlighting the given menu entry. Sidebar click messages are swallowed /// at the boundary via `.map(|_| ())`. @@ -223,15 +367,31 @@ pub fn dashboard_chrome( title: &'static str, body: B, ) -> Element<'static, DebugMessage> +where + B: Into>, +{ + dashboard_chrome_with_cache(menu, title, static_cache(), body) +} + +/// [`dashboard_chrome`] with an explicit cache, for pages that need to drive +/// sidebar state (e.g. the rescan pill via [`rescanning_cache`]). +pub fn dashboard_chrome_with_cache( + menu: &'static Menu, + title: &'static str, + cache: &'static Cache, + body: B, +) -> Element<'static, DebugMessage> where B: Into>, { let body_msg: Element<'static, ViewMessage> = body.into().map(|_| ViewMessage::Reload); - let content: Column<'static, ViewMessage> = Column::new() + let dash: Element<'static, DebugMessage> = + crate::app::view::dashboard(menu, cache, None, body_msg).map(|_| ()); + Column::new() .spacing(30) - .push(header_row::(title, None)) - .push(body_msg); - crate::app::view::dashboard(menu, static_cache(), None, content).map(|_| ()) + .push(header_row::(title, None)) + .push(dash) + .into() } /// Variant of [`debug_chrome`] for installer / wizard / modal pages. @@ -447,8 +607,7 @@ pub fn render_location( /// next to it, optional production function path, then a stretch and the /// chord-navigation reminder right-aligned. Aligned on the baseline so /// the big h2 reads naturally next to the smaller captions. -fn header_row(title: &'static str, path: Option<&'static str>) -> Row<'static, T> { - use liana_ui::widget::SpaceExt; +fn header_row(title: &'static str, path: Option<&'static str>) -> Column<'static, T> { let mut row = Row::new() .spacing(15) .align_y(Alignment::End) @@ -459,10 +618,8 @@ fn header_row(title: &'static str, path: Option<&'static str>) -> Ro if let Some(path) = path { row = row.push(text::caption(path).style(theme::text::secondary)); } - row = row - .push(iced::widget::Space::fill_width()) - .push(text::p1_regular(NAV_HINT).style(theme::text::secondary)); - row + let row2 = Row::new().push(text::p1_regular(NAV_HINT).style(theme::text::secondary)); + column![row, row2] } /// Message type produced by debug widgets. diff --git a/liana-gui/src/debug/panels.rs b/liana-gui/src/debug/panels.rs new file mode 100644 index 0000000000..5755993ba3 --- /dev/null +++ b/liana-gui/src/debug/panels.rs @@ -0,0 +1,694 @@ +//! Debug pages for the main wallet panel views (Coins, Receive, +//! Recovery). Most of these views return inner content (no internal +//! `dashboard()` wrap), so we wrap them with [`crate::debug::dashboard_chrome`] +//! to get the sidebar-correct rendering. `recovery::recovery` is the +//! exception — it wraps in `dashboard()` itself, so we render it +//! straight through `.map(|_| ())`. + +use std::collections::HashMap; +use std::str::FromStr; +use std::sync::OnceLock; + +use liana::miniscript::bitcoin::{bip32::ChildNumber, Address, Amount, Network, OutPoint, Txid}; +use liana_ui::widget::Element; + +use crate::{ + app::{ + menu::Menu, + view::{coins as coins_view_mod, receive as receive_view, recovery as recovery_view}, + }, + daemon::model::Coin, + debug::{ + dashboard_chrome, dashboard_with_modal, DebugMessage, DebugPageEntry, COINS_MENU, + RECEIVE_MENU, RECOVERY_MENU, + }, +}; + +/// Sample Bitcoin addresses used by the receive / coins debug pages. +/// They are valid mainnet bech32 — nothing in the rendering path +/// validates ownership. +const SAMPLE_ADDRESSES: &[&str] = &[ + "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + "bc1q9d4ywgfnd8h43da5tpcxcn6ajv590cg6d3tg6axemvljvt2k76zs50tv4q", + "bc1pmfr3p9j00pfxjh0zmgp99y8zftmd3s5pmedqhyptwy6lm87hf5sspknck9", + "bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3", +]; + +fn parse_addr(s: &str) -> Address { + Address::from_str(s) + .expect("hardcoded sample") + .require_network(Network::Bitcoin) + .expect("mainnet sample") +} + +// ---- Coins ---------------------------------------------------------------- + +fn sample_coins() -> &'static Vec { + static COINS: OnceLock> = OnceLock::new(); + COINS.get_or_init(|| { + let txid = Txid::from_str("0".repeat(64).as_str()).expect("valid hex"); + vec![ + Coin { + amount: Amount::from_sat(2_500_000), + outpoint: OutPoint { txid, vout: 0 }, + address: parse_addr(SAMPLE_ADDRESSES[0]), + block_height: Some(800_000), + derivation_index: ChildNumber::from_normal_idx(0).unwrap(), + spend_info: None, + is_immature: false, + is_change: false, + is_from_self: false, + }, + Coin { + amount: Amount::from_sat(50_000), + outpoint: OutPoint { txid, vout: 1 }, + address: parse_addr(SAMPLE_ADDRESSES[1]), + block_height: None, // unconfirmed + derivation_index: ChildNumber::from_normal_idx(1).unwrap(), + spend_info: None, + is_immature: false, + is_change: false, + is_from_self: false, + }, + Coin { + amount: Amount::from_sat(10_000_000), + outpoint: OutPoint { txid, vout: 2 }, + address: parse_addr(SAMPLE_ADDRESSES[2]), + block_height: Some(799_500), // older — closer to expiry + derivation_index: ChildNumber::from_normal_idx(2).unwrap(), + spend_info: None, + is_immature: false, + is_change: false, + is_from_self: true, + }, + ] + }) +} + +fn empty_labels() -> &'static HashMap { + static M: OnceLock> = OnceLock::new(); + M.get_or_init(HashMap::new) +} + +fn empty_label_forms() -> &'static HashMap> { + static M: OnceLock>> = OnceLock::new(); + M.get_or_init(HashMap::new) +} + +pub static ENTRY_COINS_EMPTY: DebugPageEntry = DebugPageEntry { + view: render_coins_empty, +}; +pub static ENTRY_COINS_WITH_DATA: DebugPageEntry = DebugPageEntry { + view: render_coins_with_data, +}; + +fn render_coins_empty() -> Element<'static, DebugMessage> { + let body = coins_view_mod::coins_view( + crate::debug::static_cache(), + &[], + 4_032, + &[], + empty_labels(), + empty_label_forms(), + ) + .map(|_| ()); + dashboard_chrome(&COINS_MENU, "Coins panel — empty", body) +} + +fn render_coins_with_data() -> Element<'static, DebugMessage> { + let body = coins_view_mod::coins_view( + crate::debug::static_cache(), + sample_coins(), + 4_032, // ~4 weeks of blocks + &[], + empty_labels(), + empty_label_forms(), + ) + .map(|_| ()); + dashboard_chrome(&COINS_MENU, "Coins panel — with coins", body) +} + +// ---- Receive -------------------------------------------------------------- + +fn sample_addresses() -> &'static Vec
{ + static A: OnceLock> = OnceLock::new(); + A.get_or_init(|| SAMPLE_ADDRESSES.iter().copied().map(parse_addr).collect()) +} + +pub static ENTRY_RECEIVE: DebugPageEntry = DebugPageEntry { + view: render_receive, +}; +pub static ENTRY_RECEIVE_WITH_PREV: DebugPageEntry = DebugPageEntry { + view: render_receive_with_prev, +}; + +fn render_receive() -> Element<'static, DebugMessage> { + let addrs = sample_addresses(); + let body = receive_view::receive( + &addrs[..1], + empty_labels(), + true, + empty_label_forms(), + true, + false, + ) + .map(|_| ()); + dashboard_chrome(&RECEIVE_MENU, "Receive panel — single address", body) +} + +fn render_receive_with_prev() -> Element<'static, DebugMessage> { + let addrs = sample_addresses(); + let body = receive_view::receive( + &addrs[..], + empty_labels(), + true, + empty_label_forms(), + true, + false, + ) + .map(|_| ()); + dashboard_chrome( + &RECEIVE_MENU, + "Receive panel — with previous addresses shown", + body, + ) +} + +// ---- Recovery ------------------------------------------------------------- + +pub static ENTRY_RECOVERY_NONE: DebugPageEntry = DebugPageEntry { + view: render_recovery_none, +}; + +fn render_recovery_none() -> Element<'static, DebugMessage> { + // recovery::recovery wraps in `dashboard(...)` internally, so we + // render straight through `.map(|_| ())` without re-wrapping. + let _ = (RECOVERY_MENU.clone(), Menu::Recovery); // suppress unused warning if applicable + recovery_view::recovery(crate::debug::static_cache(), Vec::new(), None, None).map(|_| ()) +} + +// ---- Transactions: tx_view (single tx detail) ---------------------------- + +use iced::widget::qr_code; +use liana::miniscript::bitcoin::absolute::LockTime; +use liana::miniscript::bitcoin::transaction::Version; +use liana::miniscript::bitcoin::{ScriptBuf, Sequence, Transaction, TxIn, TxOut, Witness}; + +use crate::app::view::{receive::qr_modal, receive::verify_address_modal, transactions::tx_view}; +use crate::daemon::model::{HistoryTransaction, TransactionKind}; +use crate::debug::{hw_modals, TRANSACTIONS_MENU}; +use async_hwi::DeviceKind; +use std::collections::HashSet as StdHashSet; + +/// SAFETY: iced renders on the main thread. +struct PanelsCell(T); +unsafe impl Sync for PanelsCell {} + +fn dummy_tx(out_value: Amount) -> Transaction { + Transaction { + version: Version(2), + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint { + txid: Txid::from_str(&"0".repeat(64)).unwrap(), + vout: 0, + }, + script_sig: ScriptBuf::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::new(), + }], + output: vec![TxOut { + value: out_value, + script_pubkey: parse_addr(SAMPLE_ADDRESSES[0]).script_pubkey(), + }], + } +} + +fn build_history_tx( + kind: TransactionKind, + out_value: Amount, + time: Option, + fee: Option, +) -> HistoryTransaction { + let tx = dummy_tx(out_value); + let txid = tx.compute_txid(); + HistoryTransaction { + network: Network::Bitcoin, + labels: HashMap::new(), + coins: HashMap::new(), + change_indexes: Vec::new(), + tx, + txid, + outgoing_amount: match &kind { + TransactionKind::OutgoingSinglePayment(_) + | TransactionKind::OutgoingPaymentBatch(_) => out_value, + _ => Amount::ZERO, + }, + incoming_amount: match &kind { + TransactionKind::IncomingSinglePayment(_) + | TransactionKind::IncomingPaymentBatch(_) => out_value, + _ => Amount::ZERO, + }, + fee_amount: fee, + height: time.map(|_| 800_000), + time, + kind, + } +} + +fn outgoing_confirmed_tx() -> &'static HistoryTransaction { + static T: OnceLock = OnceLock::new(); + T.get_or_init(|| { + let outpoint = OutPoint { + txid: Txid::from_str(&"a".repeat(64)).unwrap(), + vout: 0, + }; + build_history_tx( + TransactionKind::OutgoingSinglePayment(outpoint), + Amount::from_sat(1_500_000), + Some(1_700_000_000), + Some(Amount::from_sat(2_400)), + ) + }) +} + +fn incoming_unconfirmed_tx() -> &'static HistoryTransaction { + static T: OnceLock = OnceLock::new(); + T.get_or_init(|| { + let outpoint = OutPoint { + txid: Txid::from_str(&"b".repeat(64)).unwrap(), + vout: 0, + }; + build_history_tx( + TransactionKind::IncomingSinglePayment(outpoint), + Amount::from_sat(250_000), + None, + None, + ) + }) +} + +fn self_transfer_tx() -> &'static HistoryTransaction { + static T: OnceLock = OnceLock::new(); + T.get_or_init(|| { + build_history_tx( + TransactionKind::SendToSelf, + Amount::from_sat(0), + Some(1_700_000_000), + Some(Amount::from_sat(800)), + ) + }) +} + +pub static ENTRY_TX_OUTGOING: DebugPageEntry = DebugPageEntry { + view: render_tx_outgoing, +}; +pub static ENTRY_TX_INCOMING: DebugPageEntry = DebugPageEntry { + view: render_tx_incoming, +}; +pub static ENTRY_TX_SELF: DebugPageEntry = DebugPageEntry { + view: render_tx_self, +}; + +fn render_tx_outgoing() -> Element<'static, DebugMessage> { + tx_view( + crate::debug::static_cache(), + outgoing_confirmed_tx(), + empty_label_forms(), + None, + ) + .map(|_| ()) +} + +fn render_tx_incoming() -> Element<'static, DebugMessage> { + tx_view( + crate::debug::static_cache(), + incoming_unconfirmed_tx(), + empty_label_forms(), + None, + ) + .map(|_| ()) +} + +fn render_tx_self() -> Element<'static, DebugMessage> { + tx_view( + crate::debug::static_cache(), + self_transfer_tx(), + empty_label_forms(), + None, + ) + .map(|_| ()) +} + +// ---- Receive: verify_address_modal + qr_modal ---------------------------- + +fn verify_address_hws() -> &'static Vec { + static V: OnceLock> = OnceLock::new(); + V.get_or_init(|| { + vec![ + hw_modals::supported( + DeviceKind::Ledger, + Some(hw_modals::ver(2, 1, 0)), + hw_modals::fp(0xAA), + Some("Vault key"), + Some(true), + ), + hw_modals::supported( + DeviceKind::BitBox02, + Some(hw_modals::ver(9, 13, 0)), + hw_modals::fp(0xBB), + Some("Backup key"), + Some(true), + ), + ] + }) +} + +fn empty_chosen_set() -> &'static StdHashSet { + static S: OnceLock> = + OnceLock::new(); + S.get_or_init(StdHashSet::new) +} + +fn first_address() -> &'static Address { + static A: OnceLock
= OnceLock::new(); + A.get_or_init(|| parse_addr(SAMPLE_ADDRESSES[0])) +} + +fn first_index() -> &'static ChildNumber { + static I: OnceLock = OnceLock::new(); + I.get_or_init(|| ChildNumber::from_normal_idx(0).unwrap()) +} + +pub static ENTRY_VERIFY_ADDRESS: DebugPageEntry = DebugPageEntry { + view: render_verify_address, +}; + +fn render_verify_address() -> Element<'static, DebugMessage> { + let body = verify_address_modal( + None, + verify_address_hws(), + empty_chosen_set(), + first_address(), + first_index(), + ) + .map(|_| ()); + dashboard_with_modal(&RECEIVE_MENU, "Receive — verify address modal", body) +} + +fn qr_data() -> &'static qr_code::Data { + static Q: OnceLock> = OnceLock::new(); + &Q.get_or_init(|| { + PanelsCell( + qr_code::Data::new(format!("bitcoin:{}", SAMPLE_ADDRESSES[0])) + .expect("qr-encoded sample uri"), + ) + }) + .0 +} + +fn qr_address_string() -> &'static String { + static A: OnceLock = OnceLock::new(); + A.get_or_init(|| SAMPLE_ADDRESSES[0].to_string()) +} + +pub static ENTRY_QR_MODAL: DebugPageEntry = DebugPageEntry { + view: render_qr_modal, +}; + +fn render_qr_modal() -> Element<'static, DebugMessage> { + let body = qr_modal(qr_data(), qr_address_string()).map(|_| ()); + dashboard_with_modal(&RECEIVE_MENU, "Receive — QR modal", body) +} + +// Reference to TRANSACTIONS_MENU to avoid unused-import warning when that +// stack registration moves elsewhere. +#[allow(dead_code)] +fn _keep_transactions_menu_import() -> &'static Menu { + &TRANSACTIONS_MENU +} + +// ---- Send panel: spend_view --------------------------------------------- + +use crate::app::view::spend::spend_view; +use crate::debug::SEND_MENU; + +pub static ENTRY_SPEND_DRAFTING: DebugPageEntry = DebugPageEntry { + view: render_spend_drafting, +}; +pub static ENTRY_SPEND_REVIEWING: DebugPageEntry = DebugPageEntry { + view: render_spend_reviewing, +}; + +// `spend_view` and `create_spend_tx` internally call `dashboard(...)`, +// so we render them straight through `.map(|_| ())` — wrapping with +// `dashboard_chrome` would double-nest the responsive() callback and +// cause visible flicker as both writers fight over `Cache.pane_size`. + +fn render_spend_drafting() -> Element<'static, DebugMessage> { + spend_view( + crate::debug::static_cache(), + crate::debug::psbts::pending_spend_tx_pub(), + &[], + false, + crate::debug::psbts::sample_policy_pub(), + empty_aliases_pub(), + empty_label_forms(), + Network::Bitcoin, + false, + None, + ) + .map(|_| ()) +} + +fn render_spend_reviewing() -> Element<'static, DebugMessage> { + spend_view( + crate::debug::static_cache(), + crate::debug::psbts::broadcast_spend_tx_pub(), + &[], + true, + crate::debug::psbts::sample_policy_pub(), + empty_aliases_pub(), + empty_label_forms(), + Network::Bitcoin, + false, + None, + ) + .map(|_| ()) +} + +fn empty_aliases_pub() -> &'static HashMap { + static M: OnceLock> = + OnceLock::new(); + M.get_or_init(HashMap::new) +} + +// ---- create_spend_tx + recipient_view ------------------------------------ + +use crate::app::view::spend::{create_spend_tx, recipient_view}; + +fn empty_str_form() -> &'static liana_ui::component::form::Value { + static F: OnceLock> = OnceLock::new(); + F.get_or_init(liana_ui::component::form::Value::default) +} + +fn typed_str_form(s: &str) -> liana_ui::component::form::Value { + liana_ui::component::form::Value { + value: s.to_string(), + warning: None, + valid: true, + } +} + +pub static ENTRY_RECIPIENT_VALID: DebugPageEntry = DebugPageEntry { + view: render_recipient_valid, +}; +pub static ENTRY_CREATE_SPEND_SELF: DebugPageEntry = DebugPageEntry { + view: render_create_spend_self, +}; +pub static ENTRY_CREATE_SPEND_FILLED: DebugPageEntry = DebugPageEntry { + view: render_create_spend_filled, +}; + +fn recipient_addr_form() -> &'static liana_ui::component::form::Value { + static F: OnceLock> = OnceLock::new(); + F.get_or_init(|| typed_str_form(SAMPLE_ADDRESSES[0])) +} + +fn recipient_amount_form() -> &'static liana_ui::component::form::Value { + static F: OnceLock> = OnceLock::new(); + F.get_or_init(|| typed_str_form("0.05")) +} + +fn render_recipient_valid() -> Element<'static, DebugMessage> { + // recipient_view returns Element. Map twice to + // get to DebugMessage (CreateSpendMessage → () → ()). + let body: Element<'static, ()> = recipient_view( + 0, + recipient_addr_form(), + recipient_amount_form(), + None, + None, + empty_str_form(), + false, + false, + &None, + None, + ) + .map(|_| ()); + dashboard_chrome(&SEND_MENU, "Send — single recipient (valid form)", body) +} + +fn self_spend_cache() -> &'static crate::app::cache::Cache { + static CACHE: OnceLock> = OnceLock::new(); + &CACHE + .get_or_init(|| { + let mut c = crate::app::cache::Cache::default(); + c.daemon_cache.blockheight = 800_000; + PanelsCell(c) + }) + .0 +} + +fn self_spend_label_txid() -> Txid { + Txid::from_str("1".repeat(64).as_str()).expect("valid hex") +} + +fn self_spend_coins() -> &'static Vec<(Coin, bool)> { + static COINS: OnceLock> = OnceLock::new(); + COINS.get_or_init(|| { + let txid0 = Txid::from_str("0".repeat(64).as_str()).expect("valid hex"); + let txid1 = self_spend_label_txid(); + let spent_txid = Txid::from_str("2".repeat(64).as_str()).expect("valid hex"); + let mk = |amount: u64, + txid: Txid, + vout: u32, + addr_idx: usize, + block_height: Option, + spend_info: Option, + selected: bool| { + ( + Coin { + amount: Amount::from_sat(amount), + outpoint: OutPoint { txid, vout }, + address: parse_addr(SAMPLE_ADDRESSES[addr_idx]), + block_height, + derivation_index: ChildNumber::from_normal_idx(vout).unwrap(), + spend_info, + is_immature: false, + is_change: false, + is_from_self: false, + }, + selected, + ) + }; + vec![ + // long-form sequence pill, selected + mk(931_877_204, txid0, 0, 0, Some(798_000), None, true), + // "~2 days" pill, outpoint-keyed label + mk(52_050_244, txid0, 1, 1, Some(796_150), None, false), + // "Today" pill, txid-keyed label (rendered as "From