diff --git a/build.rs b/build.rs index 3b43f3b..fbf4fce 100644 --- a/build.rs +++ b/build.rs @@ -23,7 +23,10 @@ fn main() { .filter(|s| !s.is_empty()) .unwrap_or_else(|| "dev".to_string()); println!("cargo:rustc-env=GOBLIN_BUILD={}", build); + // .git/HEAD only changes on branch switches; the reflog is appended on + // every commit, so the build number stays current. println!("cargo:rerun-if-changed=.git/HEAD"); + println!("cargo:rerun-if-changed=.git/logs/HEAD"); // Setting up git hooks in the project: rustfmt and so on. let git_hooks = format!( diff --git a/locales/en.yml b/locales/en.yml index 27e4bed..c6c28c4 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -49,7 +49,7 @@ wallets: add: Add wallet name: 'Name:' pass: 'Password:' - pass_empty: Enter password from the wallet + pass_empty: Enter the wallet password current_pass: 'Current password:' new_pass: 'New password:' min_tx_conf_count: 'Minimum amount of confirmations for transactions:' diff --git a/src/gui/app.rs b/src/gui/app.rs index af5a9b4..addb690 100755 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -167,6 +167,12 @@ impl App { /// Draw custom desktop window frame content. fn custom_frame_ui(&mut self, ui: &mut egui::Ui, is_fullscreen: bool) { + // Paint the whole window with the theme background first: the frame + // margin ring is otherwise transparent, which X11 without a + // compositor renders as a black border (and a black strip under the + // sidebar in light/yellow themes). + ui.painter() + .rect_filled(ui.max_rect(), CornerRadius::ZERO, Colors::fill()); let content_bg_rect = { let mut r = ui.max_rect(); if !is_fullscreen { diff --git a/src/gui/platform/desktop/mod.rs b/src/gui/platform/desktop/mod.rs index 9bb1b7a..3f8632d 100644 --- a/src/gui/platform/desktop/mod.rs +++ b/src/gui/platform/desktop/mod.rs @@ -62,17 +62,22 @@ impl Desktop { use nokhwa::Camera; use nokhwa::pixel_format::RgbFormat; use nokhwa::utils::ApiBackend; - use nokhwa::utils::{CameraIndex, RequestedFormat, RequestedFormatType}; - - let devices = nokhwa::query(ApiBackend::Auto).unwrap(); - cameras_amount.store(devices.len(), Ordering::Relaxed); - let index = camera_index.load(Ordering::Relaxed); - if devices.is_empty() || index >= devices.len() { - return; - } + use nokhwa::utils::{FrameFormat, RequestedFormat, RequestedFormatType}; thread::spawn(move || { - let index = CameraIndex::Index(camera_index.load(Ordering::Relaxed) as u32); + // Device enumeration does IO — keep it off the UI thread, and + // treat a backend error the same as "no cameras". + let devices = nokhwa::query(ApiBackend::Auto).unwrap_or_default(); + cameras_amount.store(devices.len(), Ordering::Relaxed); + let index = camera_index.load(Ordering::Relaxed); + if devices.is_empty() || index >= devices.len() { + return; + } + + // Open by the enumerated device's own index, not the list + // position: on v4l they differ whenever /dev/video0 is absent + // (first camera at video1, loopback-only setups, …). + let index = devices[index].index().clone(); let requested = RequestedFormat::new::(RequestedFormatType::AbsoluteHighestFrameRate); // Create and open camera. @@ -89,9 +94,29 @@ impl Desktop { } // Get a frame. if let Ok(frame) = camera.frame() { - // Save image. - let mut w_image = LAST_CAMERA_IMAGE.write(); - *w_image = Some((frame.buffer().to_vec(), 0)); + // Consumers expect an encoded image. MJPEG frames + // already are; anything else (YUYV, NV12, …) must + // be decoded to RGB and re-encoded, or the readers + // fail on the raw buffer and show a spinner forever. + let bytes = if frame.source_frame_format() == FrameFormat::MJPEG { + Some(frame.buffer().to_vec()) + } else if let Ok(image) = frame.decode_image::() { + let mut bytes: Vec = Vec::new(); + image + .write_to( + &mut std::io::Cursor::new(&mut bytes), + image::ImageFormat::Jpeg, + ) + .ok() + .map(|_| bytes) + } else { + None + }; + if let Some(bytes) = bytes { + // Save image. + let mut w_image = LAST_CAMERA_IMAGE.write(); + *w_image = Some((bytes, 0)); + } } else { // Clear image. let mut w_image = LAST_CAMERA_IMAGE.write(); @@ -99,7 +124,7 @@ impl Desktop { break; } } - camera.stop_stream().unwrap(); + let _ = camera.stop_stream(); }; } }); diff --git a/src/gui/views/camera.rs b/src/gui/views/camera.rs index 49ef280..70879f8 100644 --- a/src/gui/views/camera.rs +++ b/src/gui/views/camera.rs @@ -36,6 +36,8 @@ pub struct CameraContent { qr_scan_state: Arc>, /// Uniform Resources URIs collected from QR code scanning. ur_data: Arc, usize)>>>, + /// When waiting for the first frame started, to surface missing cameras. + wait_start: std::time::Instant, } impl Default for CameraContent { @@ -43,6 +45,7 @@ impl Default for CameraContent { Self { qr_scan_state: Arc::new(RwLock::new(QrScanState::default())), ur_data: Arc::new(RwLock::new(None)), + wait_start: std::time::Instant::now(), } } } @@ -153,8 +156,24 @@ impl CameraContent { } } - /// Draw camera loading progress content. + /// Draw camera loading progress content, or a missing-camera notice when + /// no frame ever arrives (no device, device busy, or capture failed). fn loading_ui(&self, ui: &mut egui::Ui) -> Rect { + if self.wait_start.elapsed().as_secs() >= 5 { + let space = ui.available_width() / 3.0; + return ui + .vertical_centered(|ui| { + ui.add_space(space); + ui.label( + RichText::new("No camera found") + .size(17.0) + .color(Colors::inactive_text()), + ); + ui.add_space(space); + }) + .response + .rect; + } let space = (ui.available_width() - View::BIG_SPINNER_SIZE) / 2.0; ui.vertical_centered(|ui| { ui.add_space(space); @@ -448,3 +467,62 @@ impl CameraContent { None } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Render a QR the way the goblin receive card paints it (dark modules + /// on a white plate with ~5% padding, goblin mark covering the center) + /// and prove the camera scanner pipeline (rqrr) decodes it. Guards both + /// the scan path and the card's scannability by third-party apps. + #[test] + fn goblin_receive_qr_decodes_with_center_mark() { + let uri = "nostr:npub15l60z00nm4ptmnsj9lcp4husnaltytw85eu05dt7ksdmsje0p98su2f0ch"; + let qr = qrcodegen::QrCode::encode_text(uri, qrcodegen::QrCodeEcc::High).unwrap(); + let n = qr.size(); + + // Mirror widgets::qr_code geometry at its receive-card size. + let size = 220.0f32; + let pad = (size * 0.05).max(8.0); + let dim = (size + pad * 2.0).ceil() as u32; + let cell = size / n as f32; + let mut img = image::GrayImage::from_pixel(dim, dim, image::Luma([255u8])); + let mut fill = |x0: f32, y0: f32, w: f32, h: f32, v: u8| { + for y in y0.max(0.0) as u32..((y0 + h).min(dim as f32) as u32) { + for x in x0.max(0.0) as u32..((x0 + w).min(dim as f32) as u32) { + img.put_pixel(x, y, image::Luma([v])); + } + } + }; + for y in 0..n { + for x in 0..n { + if qr.get_module(x, y) { + fill(pad + x as f32 * cell, pad + y as f32 * cell, cell, cell, 14); + } + } + } + // The goblin mark backing square over the center modules. + let backing = size * 0.19; + let c = dim as f32 / 2.0; + fill(c - backing / 2.0, c - backing / 2.0, backing, backing, 255); + + let mut prepared = rqrr::PreparedImage::prepare(img); + let grids = prepared.detect_grids(); + assert_eq!(grids.len(), 1, "scanner should find exactly one QR"); + let mut data = vec![]; + grids[0].decode_to(&mut data).expect("QR should decode"); + assert_eq!(String::from_utf8(data).unwrap(), uri); + } + + /// A scanned nostr URI must come back as plain text (the send flow + /// strips the scheme and resolves the npub), never as another variant. + #[test] + fn nostr_uri_parses_as_text() { + let uri = "nostr:npub15l60z00nm4ptmnsj9lcp4husnaltytw85eu05dt7ksdmsje0p98su2f0ch"; + match CameraContent::parse_qr_code(uri.as_bytes().to_vec()) { + QrScanResult::Text(text) => assert_eq!(text.to_string(), uri), + other => panic!("expected Text, got {:?}", other.text()), + } + } +} diff --git a/src/gui/views/content.rs b/src/gui/views/content.rs index bd1f83e..624a038 100644 --- a/src/gui/views/content.rs +++ b/src/gui/views/content.rs @@ -96,7 +96,8 @@ impl ContentContainer for Content { } // The open-wallet (Goblin) surface is full-bleed: node info lives in // its sidebar, so the network column stays hidden while it shows. - let wallet_open = self.wallets.showing_wallet(); + // Same for first-run onboarding, which owns the whole window. + let wallet_open = self.wallets.showing_wallet() || self.wallets.onboarding_active(); // Show network content. egui::SidePanel::left("network_panel") diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index e9ae413..d3d0bf3 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -15,13 +15,14 @@ //! The Goblin Cash App-style wallet surface for an open wallet. pub mod data; +pub mod onboarding; pub mod send; pub mod widgets; use eframe::epaint::{CornerRadius, FontId, Stroke}; use egui::{Align, Color32, Layout, Margin, RichText, ScrollArea, Sense, Vec2}; -use crate::gui::icons::{ARROW_DOWN, CLOCK, COPY, USER_CIRCLE, WALLET}; +use crate::gui::icons::{ARROW_DOWN, CHECK, CLOCK, COPY, QR_CODE, USER_CIRCLE, WALLET}; use crate::gui::platform::PlatformCallbacks; use crate::gui::theme::{self, fonts}; use crate::gui::views::{Content, View}; @@ -70,6 +71,9 @@ pub struct GoblinWalletView { /// Relay list being edited and the add-relay input. relay_edit: Vec, relay_input: String, + /// Transient "Copied" feedback on the Receive buttons: which one + /// (0 = npub, 1 = grin address) and when it was clicked. + receive_copied: Option<(u8, std::time::Instant)>, } /// Sub-pages of the Settings tab. @@ -98,6 +102,7 @@ impl Default for GoblinWalletView { node_secret_input: String::new(), relay_edit: Vec::new(), relay_input: String::new(), + receive_copied: None, } } } @@ -133,6 +138,7 @@ struct ImportState { stage: u8, nsec: String, password: String, + backup_password: String, new_npub: String, error: String, result: std::sync::Arc>>>, @@ -144,6 +150,7 @@ impl Default for ImportState { stage: 1, nsec: String::new(), password: String::new(), + backup_password: String::new(), new_npub: String::new(), error: String::new(), result: std::sync::Arc::new(std::sync::Mutex::new(None)), @@ -423,7 +430,9 @@ impl GoblinWalletView { if active { t.surface2 } else { t.hover }, ); } - let color = if active { t.text } else { t.text_dim }; + // The active pill is a surface; on-surface ink keeps it readable + // in the yellow theme (dark pill on bright bg). + let color = if active { t.surface_text } else { t.text_dim }; ui.painter().text( rect.left_center() + Vec2::new(14.0, 0.0), egui::Align2::LEFT_CENTER, @@ -533,10 +542,15 @@ impl GoblinWalletView { .font(FontId::new(14.0, fonts::semibold())) .color(t.surface_text), ); - ui.label( - RichText::new(node_summary(wallet)) - .font(FontId::new(12.0, fonts::regular())) - .color(t.surface_text_dim), + // Single line: wrapping strands the "·" separator at the + // end of the first line in the narrow sidebar card. + ui.add( + egui::Label::new( + RichText::new(node_summary(wallet)) + .font(FontId::new(12.0, fonts::regular())) + .color(t.surface_text_dim), + ) + .truncate(), ); }); }); @@ -579,6 +593,28 @@ impl GoblinWalletView { if w::avatar(ui, &handle, 36.0, 6).clicked() { self.tab = Tab::Me; } + // Scan-to-pay, left of the avatar per the refs. + ui.add_space(10.0); + let (rect, resp) = + ui.allocate_exact_size(Vec2::splat(36.0), Sense::click()); + ui.painter().circle_filled( + rect.center(), + 18.0, + theme::tokens().surface2, + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + QR_CODE, + FontId::new(17.0, fonts::regular()), + theme::tokens().surface_text, + ); + let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand); + if resp.clicked() { + let mut flow = SendFlow::default(); + flow.request_scan(); + self.send = Some(flow); + } }); }); ui.add_space(28.0); @@ -688,17 +724,20 @@ impl GoblinWalletView { ui.add_space(if tall { 32.0 } else { 16.0 }); // Numpad on mobile, typed input on desktop. + let typed_hint = View::is_desktop() && self.pay_amount.is_empty(); if !View::is_desktop() { w::numpad(ui, &mut self.pay_amount); } else { w::amount_typed_input(ui, &mut self.pay_amount); - ui.vertical_centered(|ui| { - ui.label( - RichText::new("Type an amount") - .font(FontId::new(13.0, fonts::regular())) - .color(t.text_mute), - ); - }); + if typed_hint { + ui.vertical_centered(|ui| { + ui.label( + RichText::new("Type an amount") + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_mute), + ); + }); + } } ui.add_space(20.0); @@ -737,7 +776,8 @@ impl GoblinWalletView { }, ); }); - if !valid { + // Skip when the "Type an amount" hint is already showing above. + if !valid && !typed_hint { ui.add_space(8.0); ui.vertical_centered(|ui| { ui.label( @@ -964,6 +1004,15 @@ impl GoblinWalletView { }); ui.add_space(12.0); + // Transient per-button "Copied" feedback; silent copies read as dead + // buttons. + let fresh = |at: std::time::Instant| at.elapsed().as_millis() < 1500; + let copied0 = matches!(self.receive_copied, Some((0, at)) if fresh(at)); + let copied1 = matches!(self.receive_copied, Some((1, at)) if fresh(at)); + if self.receive_copied.is_some() { + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(200)); + } ui.horizontal(|ui| { let half = (ui.available_width() - 10.0) / 2.0; ui.scope_builder( @@ -972,13 +1021,19 @@ impl GoblinWalletView { Vec2::new(half, 56.0), )), |ui| { - if w::big_action(ui, &format!("{} Copy", COPY), true).clicked() { + let label = if copied0 { + format!("{} Copied", CHECK) + } else { + format!("{} Copy npub", COPY) + }; + if w::big_action(ui, &label, true).clicked() { let copy = if npub.is_empty() { handle.clone() } else { npub.clone() }; cb.copy_string_to_buffer(copy); + self.receive_copied = Some((0, std::time::Instant::now())); } }, ); @@ -990,9 +1045,15 @@ impl GoblinWalletView { )), |ui| { // Copy the grin1 slatepack address for manual/Tor exchange. - if w::big_action(ui, "Address", false).clicked() { + let label = if copied1 { + format!("{} Copied", CHECK) + } else { + "Copy address".to_string() + }; + if w::big_action(ui, &label, false).clicked() { if let Some(addr) = wallet.slatepack_address() { cb.copy_string_to_buffer(addr); + self.receive_copied = Some((1, std::time::Instant::now())); } } }, @@ -1150,6 +1211,15 @@ impl GoblinWalletView { } } }); + ui.add_space(6.0); + ui.label( + RichText::new( + "Moving devices? Back up BOTH: your seed phrase (funds) \ + and an identity backup (name + key).", + ) + .font(FontId::new(12.0, fonts::regular())) + .color(t.text_mute), + ); if self.claim.is_some() { ui.add_space(8.0); self.claim_ui(ui, wallet); @@ -1686,22 +1756,26 @@ impl GoblinWalletView { .color(t.surface_text_dim), ); ui.add_space(10.0); - ui.add( - egui::TextEdit::singleline(&mut rotate.reset_input) - .hint_text("Type RESET") - .desired_width(f32::INFINITY) - .text_color(t.surface_text) - .frame(false), - ); + w::field_well(ui, |ui| { + ui.add( + egui::TextEdit::singleline(&mut rotate.reset_input) + .hint_text("Type RESET") + .desired_width(f32::INFINITY) + .text_color(t.surface_text) + .frame(false), + ); + }); ui.add_space(8.0); - ui.add( - egui::TextEdit::singleline(&mut rotate.password) - .hint_text("Wallet password") - .password(true) - .desired_width(f32::INFINITY) - .text_color(t.surface_text) - .frame(false), - ); + w::field_well(ui, |ui| { + ui.add( + egui::TextEdit::singleline(&mut rotate.password) + .hint_text("Wallet password") + .password(true) + .desired_width(f32::INFINITY) + .text_color(t.surface_text) + .frame(false), + ); + }); ui.add_space(10.0); let armed = rotate.reset_input.trim() == "RESET" && !rotate.password.is_empty(); ui.horizontal(|ui| { @@ -1855,23 +1929,38 @@ impl GoblinWalletView { .color(t.surface_text_dim), ); ui.add_space(10.0); - ui.add( - egui::TextEdit::singleline(&mut import.nsec) - .hint_text("nsec1… or identity backup JSON") - .password(true) - .desired_width(f32::INFINITY) - .text_color(t.surface_text) - .frame(false), - ); + w::field_well(ui, |ui| { + ui.add( + egui::TextEdit::singleline(&mut import.nsec) + .hint_text("nsec1… or identity backup JSON") + .password(true) + .desired_width(f32::INFINITY) + .text_color(t.surface_text) + .frame(false), + ); + }); ui.add_space(8.0); - ui.add( - egui::TextEdit::singleline(&mut import.password) - .hint_text("Wallet password") - .password(true) - .desired_width(f32::INFINITY) - .text_color(t.surface_text) - .frame(false), - ); + w::field_well(ui, |ui| { + ui.add( + egui::TextEdit::singleline(&mut import.password) + .hint_text("Wallet password") + .password(true) + .desired_width(f32::INFINITY) + .text_color(t.surface_text) + .frame(false), + ); + }); + ui.add_space(8.0); + w::field_well(ui, |ui| { + ui.add( + egui::TextEdit::singleline(&mut import.backup_password) + .hint_text("Backup password (only if exported elsewhere)") + .password(true) + .desired_width(f32::INFINITY) + .text_color(t.surface_text) + .frame(false), + ); + }); ui.add_space(10.0); let pasted = import.nsec.trim(); let armed = (pasted.starts_with("nsec1") || pasted.starts_with('{')) @@ -1902,9 +1991,12 @@ impl GoblinWalletView { let slot = import.result.clone(); let nsec = std::mem::take(&mut import.nsec); let password = std::mem::take(&mut import.password); + let bpw = std::mem::take(&mut import.backup_password); + let bpw = if bpw.is_empty() { None } else { Some(bpw) }; let wallet = wallet.clone(); std::thread::spawn(move || { - let res = wallet.import_nostr_identity(nsec, password); + let res = + wallet.import_nostr_identity(nsec, password, bpw); *slot.lock().unwrap() = Some(res); }); } @@ -2154,7 +2246,11 @@ fn start_claim_register(claim: &mut ClaimState, name: &str, wallet: &Wallet) { /// Draw the small Goblin mascot mark. pub fn widgets_logo(ui: &mut egui::Ui) { - let size = 22.0; + widgets_logo_sized(ui, 22.0); +} + +/// Tinted goblin mark at a given size. +pub fn widgets_logo_sized(ui: &mut egui::Ui, size: f32) { let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); let img = egui::Image::new(egui::include_image!("../../../../img/goblin-logo2-256.png")) .tint(theme::tokens().text) diff --git a/src/gui/views/goblin/onboarding.rs b/src/gui/views/goblin/onboarding.rs new file mode 100644 index 0000000..094d309 --- /dev/null +++ b/src/gui/views/goblin/onboarding.rs @@ -0,0 +1,880 @@ +// Copyright 2026 The Goblin Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! First-run onboarding: what Goblin is → node choice → wallet create or +//! restore → optional payment-identity username. Wraps GRIM's mnemonic and +//! wallet-creation machinery without replacing it — the stock creation flow +//! stays available from the wallet list for later wallets. + +use eframe::epaint::FontId; +use egui::{Align, Layout, RichText, ScrollArea, Sense, Vec2}; +use grin_util::ZeroingString; + +use crate::gui::icons::ARROW_LEFT; +use crate::gui::platform::PlatformCallbacks; +use crate::gui::theme::{self, fonts}; +use crate::gui::views::types::{ContentContainer, ModalPosition, QrScanResult}; +use crate::gui::views::wallets::creation::MnemonicSetup; +use crate::gui::views::{CameraScanContent, Content, Modal, View}; +use crate::node::Node; +use crate::wallet::types::{ConnectionMethod, PhraseMode, PhraseSize}; +use crate::wallet::{ConnectionsConfig, ExternalConnection, Wallet, WalletList}; + +use super::widgets::{self as w}; +use super::{ClaimMsg, ClaimState, start_claim_register}; + +/// Identifier for the recovery-phrase QR scan [`Modal`]. +const OB_PHRASE_SCAN_MODAL: &'static str = "ob_phrase_scan_modal"; + +/// Onboarding step. +#[derive(PartialEq, Eq, Clone, Copy)] +enum Step { + Intro, + Node, + WalletSetup, + Words, + ConfirmWords, + Identity, +} + +/// First-run onboarding content. +pub struct OnboardingContent { + step: Step, + /// Node choice: integrated (own node) or external URL. + integrated: bool, + ext_url: String, + /// Wallet setup inputs. + restore: bool, + name: String, + pass: String, + pass2: String, + /// GRIM's mnemonic machinery (word grid, validation, import). + mnemonic_setup: MnemonicSetup, + /// Wallet creation error, if any. + error: Option, + /// QR scanner for recovery phrase import. + scan_modal: Option, + /// Created and opened wallet, present from the Identity step on. + wallet: Option, + /// Optional username claim state (same machinery as Settings). + claim: ClaimState, +} + +impl Default for OnboardingContent { + fn default() -> Self { + Self { + step: Step::Intro, + integrated: true, + ext_url: "https://main.gri.mw".to_string(), + restore: false, + name: "Main wallet".to_string(), + pass: String::new(), + pass2: String::new(), + mnemonic_setup: MnemonicSetup::default(), + error: None, + scan_modal: None, + wallet: None, + claim: ClaimState::default(), + } + } +} + +impl OnboardingContent { + /// Render onboarding. Returns the wallet once the user finishes the + /// final step, so the host can select it and drop this content. + pub fn ui( + &mut self, + ui: &mut egui::Ui, + wallets: &mut WalletList, + cb: &dyn PlatformCallbacks, + ) -> Option { + // Draw owned modals (word input, phrase scan) when opened. + if let Some(id) = Modal::opened() { + if id == OB_PHRASE_SCAN_MODAL { + Modal::ui(ui.ctx(), cb, |ui, modal, cb| { + self.scan_modal_ui(ui, modal, cb); + }); + } else if self.mnemonic_setup.modal_ids().contains(&id) { + Modal::ui(ui.ctx(), cb, |ui, modal, cb| { + self.mnemonic_setup.modal_ui(ui, modal, cb); + }); + } + } + + let mut done = None; + ScrollArea::vertical() + .id_salt("goblin_onboarding") + .auto_shrink([false; 2]) + .show(ui, |ui| { + w::centered_column(ui, Content::SIDE_PANEL_WIDTH * 1.2, |ui| { + ui.add_space(View::get_top_inset() + 24.0); + match self.step { + Step::Intro => self.intro_ui(ui), + Step::Node => self.node_ui(ui), + Step::WalletSetup => self.wallet_setup_ui(ui), + Step::Words => self.words_ui(ui, wallets, cb), + Step::ConfirmWords => self.confirm_ui(ui, wallets, cb), + Step::Identity => done = self.identity_ui(ui), + } + ui.add_space(View::get_bottom_inset() + 24.0); + }); + }); + done + } + + /// Back chip + step kicker shared by all steps after the intro. + fn step_header(&mut self, ui: &mut egui::Ui, kicker: &str, title: &str, back: Step) { + let t = theme::tokens(); + ui.horizontal(|ui| { + let (rect, resp) = ui.allocate_exact_size(Vec2::splat(36.0), Sense::click()); + ui.painter().circle_filled(rect.center(), 18.0, t.surface2); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + ARROW_LEFT, + FontId::new(16.0, fonts::regular()), + t.surface_text, + ); + if resp + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + { + self.error = None; + self.step = back; + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.label( + RichText::new(kicker) + .font(fonts::kicker()) + .color(t.text_mute), + ); + }); + }); + ui.add_space(18.0); + ui.label( + RichText::new(title) + .font(FontId::new(26.0, fonts::bold())) + .color(t.text), + ); + ui.add_space(14.0); + } + + // ── Intro ──────────────────────────────────────────────────────────── + + fn intro_ui(&mut self, ui: &mut egui::Ui) { + let t = theme::tokens(); + ui.add_space(26.0); + ui.vertical_centered(|ui| { + super::widgets_logo_sized(ui, 72.0); + ui.add_space(14.0); + ui.label( + RichText::new("goblin") + .font(FontId::new(34.0, fonts::bold())) + .color(t.text), + ); + }); + ui.add_space(26.0); + let lines: [(&str, &str); 3] = [ + ( + "Private money", + "Goblin is a wallet for grin — digital cash with no amounts \ + or addresses on its chain.", + ), + ( + "Pay people, not addresses", + "Payments travel as end-to-end encrypted messages over nostr \ + and Tor. Goblin handles the plumbing; relays only ever see \ + ciphertext.", + ), + ( + "Yours alone", + "Keys, names and history live on this device. Built on the \ + GRIM wallet.", + ), + ]; + for (head, body) in lines { + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.label( + RichText::new(head) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.add_space(4.0); + ui.label( + RichText::new(body) + .font(FontId::new(13.5, fonts::regular())) + .color(t.surface_text_dim), + ); + }); + ui.add_space(10.0); + } + ui.add_space(16.0); + if w::big_action(ui, "Get started", false).clicked() { + self.step = Step::Node; + } + ui.add_space(8.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new("Takes about a minute. You can change everything later.") + .font(FontId::new(12.5, fonts::regular())) + .color(t.text_mute), + ); + }); + } + + // ── Node choice ────────────────────────────────────────────────────── + + fn node_card(ui: &mut egui::Ui, selected: bool, title: &str, word: &str, body: &str) -> bool { + let t = theme::tokens(); + let resp = ui + .scope(|ui| { + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.horizontal(|ui| { + let (dot, _) = ui.allocate_exact_size(Vec2::splat(18.0), Sense::hover()); + ui.painter().circle_stroke( + dot.center(), + 8.0, + eframe::epaint::Stroke::new(1.5, t.surface_text_mute), + ); + if selected { + ui.painter().circle_filled(dot.center(), 5.0, t.accent); + } + ui.add_space(8.0); + ui.label( + RichText::new(title) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let galley = ui.painter().layout_no_wrap( + word.to_string(), + FontId::new(12.0, fonts::semibold()), + t.bg, + ); + let pad = Vec2::new(10.0, 5.0); + let (rect, _) = + ui.allocate_exact_size(galley.size() + pad * 2.0, Sense::hover()); + ui.painter().rect_filled( + rect, + eframe::epaint::CornerRadius::same(10), + t.accent, + ); + ui.painter().galley(rect.min + pad, galley, t.bg); + }); + }); + ui.add_space(6.0); + ui.label( + RichText::new(body) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + }); + }) + .response; + resp.interact(Sense::click()) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .clicked() + } + + fn node_ui(&mut self, ui: &mut egui::Ui) { + let t = theme::tokens(); + self.step_header( + ui, + "STEP 1 OF 3 · NETWORK", + "How should Goblin\nwatch the chain?", + Step::Intro, + ); + if Self::node_card( + ui, + self.integrated, + "Run my own node", + "Private", + "Trusts no one — your wallet checks the chain itself. Syncs in \ + the background while you finish setup.", + ) { + self.integrated = true; + } + ui.add_space(10.0); + if Self::node_card( + ui, + !self.integrated, + "Connect to a node", + "Instant", + "No sync wait. The node you pick can see your wallet's queries.", + ) { + self.integrated = false; + } + if !self.integrated { + ui.add_space(10.0); + w::field_well(ui, |ui| { + ui.add( + egui::TextEdit::singleline(&mut self.ext_url) + .hint_text("https://node.example.com") + .desired_width(f32::INFINITY) + .text_color(t.surface_text) + .frame(false), + ); + }); + } + ui.add_space(8.0); + ui.label( + RichText::new("Changeable any time in Settings → Node.") + .font(FontId::new(12.5, fonts::regular())) + .color(t.text_mute), + ); + ui.add_space(16.0); + let url_ok = self.integrated + || self.ext_url.trim().starts_with("http://") + || self.ext_url.trim().starts_with("https://"); + if w::big_action(ui, "Continue", false).clicked() && url_ok { + self.step = Step::WalletSetup; + } + if !url_ok { + ui.add_space(8.0); + ui.label( + RichText::new("Node URL must start with http:// or https://") + .font(FontId::new(13.0, fonts::regular())) + .color(t.neg), + ); + } + } + + // ── Wallet name + password, create vs restore ─────────────────────── + + fn wallet_setup_ui(&mut self, ui: &mut egui::Ui) { + let t = theme::tokens(); + self.step_header(ui, "STEP 2 OF 3 · WALLET", "Set up your wallet", Step::Node); + + // Create / Restore segmented choice. + ui.horizontal(|ui| { + let half = (ui.available_width() - 10.0) / 2.0; + for (restore, label) in [(false, "Create new"), (true, "Restore from seed")] { + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + let active = self.restore == restore; + let resp = w::chip(ui, label, active); + if resp.clicked() { + self.restore = restore; + } + }, + ); + ui.add_space(10.0); + } + }); + ui.add_space(14.0); + + w::field_well(ui, |ui| { + ui.add( + egui::TextEdit::singleline(&mut self.name) + .hint_text("Wallet name") + .desired_width(f32::INFINITY) + .text_color(t.surface_text) + .frame(false), + ); + }); + ui.add_space(8.0); + w::field_well(ui, |ui| { + ui.add( + egui::TextEdit::singleline(&mut self.pass) + .hint_text("Password") + .password(true) + .desired_width(f32::INFINITY) + .text_color(t.surface_text) + .frame(false), + ); + }); + ui.add_space(8.0); + w::field_well(ui, |ui| { + ui.add( + egui::TextEdit::singleline(&mut self.pass2) + .hint_text("Repeat password") + .password(true) + .desired_width(f32::INFINITY) + .text_color(t.surface_text) + .frame(false), + ); + }); + ui.add_space(10.0); + ui.label( + RichText::new(if self.restore { + "Have your seed words ready — you'll enter them next." + } else { + "Next you'll get 24 seed words to write down. They are the \ + money — anyone holding them holds your funds." + }) + .font(FontId::new(12.5, fonts::regular())) + .color(t.text_mute), + ); + ui.add_space(16.0); + + let pass_ok = !self.pass.is_empty() && self.pass == self.pass2; + let name_ok = !self.name.trim().is_empty(); + if w::big_action(ui, "Continue", false).clicked() && pass_ok && name_ok { + self.mnemonic_setup.reset(); + self.mnemonic_setup.mnemonic.set_mode(if self.restore { + PhraseMode::Import + } else { + PhraseMode::Generate + }); + self.mnemonic_setup.mnemonic.set_size(PhraseSize::Words24); + self.error = None; + self.step = Step::Words; + } + if !self.pass.is_empty() && self.pass != self.pass2 { + ui.add_space(8.0); + ui.label( + RichText::new("Passwords don't match") + .font(FontId::new(13.0, fonts::regular())) + .color(t.neg), + ); + } + } + + // ── Seed words (display for create, entry for restore) ────────────── + + fn words_ui( + &mut self, + ui: &mut egui::Ui, + wallets: &mut WalletList, + cb: &dyn PlatformCallbacks, + ) { + let t = theme::tokens(); + let restore = self.mnemonic_setup.mnemonic.mode() == PhraseMode::Import; + self.step_header( + ui, + "STEP 2 OF 3 · WALLET", + if restore { + "Enter your seed words" + } else { + "Write these words down" + }, + Step::WalletSetup, + ); + if restore { + // Word count picker for restores. + ui.horizontal(|ui| { + for size in PhraseSize::VALUES { + let label = format!("{}", size.value()); + let active = self.mnemonic_setup.mnemonic.size() == size; + if w::chip(ui, &label, active).clicked() { + self.mnemonic_setup.mnemonic.set_size(size); + } + ui.add_space(6.0); + } + }); + ui.add_space(10.0); + } else { + ui.label( + RichText::new( + "On paper, in order. Anyone with these words can take \ + your funds; without them a lost device means lost funds.", + ) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + ui.add_space(10.0); + } + + // GRIM's word grid (edit mode when restoring). + self.mnemonic_setup.word_list_ui(ui, restore); + ui.add_space(14.0); + + if restore { + ui.horizontal(|ui| { + let half = (ui.available_width() - 10.0) / 2.0; + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + if w::chip(ui, "Paste", false).clicked() { + let data = ZeroingString::from(cb.get_string_from_buffer()); + self.mnemonic_setup.mnemonic.import(&data); + } + }, + ); + ui.add_space(10.0); + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 44.0), + )), + |ui| { + if w::chip(ui, "Scan QR", false).clicked() { + self.scan_modal = Some(CameraScanContent::default()); + Modal::new(OB_PHRASE_SCAN_MODAL) + .position(ModalPosition::CenterTop) + .title(t!("scan_qr")) + .closeable(false) + .show(); + cb.start_camera(); + } + }, + ); + }); + ui.add_space(14.0); + } else if w::chip(ui, "Copy to clipboard (avoid this)", false).clicked() { + cb.copy_string_to_buffer(self.mnemonic_setup.mnemonic.get_phrase()); + } + if !restore { + ui.add_space(14.0); + } + + let ready = if restore { + !self.mnemonic_setup.mnemonic.has_empty_or_invalid() + } else { + true + }; + let label = if restore { + "Restore wallet" + } else { + "I wrote them down" + }; + if ready { + if w::big_action(ui, label, false).clicked() { + if restore { + self.create_wallet(wallets); + } else { + self.step = Step::ConfirmWords; + } + } + } else { + ui.label( + RichText::new("Fill every word — tap a word to edit it, or paste the phrase.") + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_mute), + ); + } + self.error_ui(ui); + } + + fn confirm_ui( + &mut self, + ui: &mut egui::Ui, + wallets: &mut WalletList, + cb: &dyn PlatformCallbacks, + ) { + let t = theme::tokens(); + self.step_header(ui, "STEP 2 OF 3 · WALLET", "Now prove it", Step::Words); + ui.label( + RichText::new("Enter the words you just wrote down. Tap a word to type it.") + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + ui.add_space(10.0); + self.mnemonic_setup.word_list_ui(ui, true); + ui.add_space(14.0); + if w::chip(ui, "Paste", false).clicked() { + let data = ZeroingString::from(cb.get_string_from_buffer()); + self.mnemonic_setup.mnemonic.import(&data); + } + ui.add_space(14.0); + if !self.mnemonic_setup.mnemonic.has_empty_or_invalid() { + if w::big_action(ui, "Create wallet", false).clicked() { + self.create_wallet(wallets); + } + } else { + ui.label( + RichText::new("Keep going — every word, in order.") + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_mute), + ); + } + self.error_ui(ui); + } + + fn error_ui(&self, ui: &mut egui::Ui) { + if let Some(err) = &self.error { + ui.add_space(10.0); + ui.label( + RichText::new(err) + .font(FontId::new(13.0, fonts::regular())) + .color(theme::tokens().neg), + ); + } + } + + /// Resolve the connection method, create the wallet, open it and move + /// to the identity step. + fn create_wallet(&mut self, wallets: &mut WalletList) { + // Connection: integrated starts the local node; external reuses an + // existing saved connection with the same URL or saves a new one. + let method = if self.integrated { + if !Node::is_running() { + Node::start(); + } + ConnectionMethod::Integrated + } else { + let url = self.ext_url.trim().trim_end_matches('/').to_string(); + let existing = ConnectionsConfig::ext_conn_list() + .into_iter() + .find(|c| c.url.trim_end_matches('/') == url); + let conn = match existing { + Some(c) => c, + None => { + let c = ExternalConnection::new(url, None, None); + ConnectionsConfig::add_ext_conn(c.clone()); + c + } + }; + ConnectionMethod::External(conn.id, conn.url.clone()) + }; + + let pass = ZeroingString::from(self.pass.clone()); + match Wallet::create( + &self.name.trim().to_string(), + &pass, + &self.mnemonic_setup.mnemonic, + &method, + ) { + Ok(w) => { + self.mnemonic_setup.reset(); + wallets.add(w.clone()); + match w.open(pass) { + Ok(_) => { + self.wallet = Some(w); + self.error = None; + self.step = Step::Identity; + } + Err(e) => self.error = Some(format!("Couldn't open the wallet: {:?}", e)), + } + } + Err(e) => self.error = Some(format!("Couldn't create the wallet: {:?}", e)), + } + } + + // ── Identity (optional username) ───────────────────────────────────── + + fn identity_ui(&mut self, ui: &mut egui::Ui) -> Option { + let t = theme::tokens(); + // No back from here: the wallet exists now. + ui.label( + RichText::new("STEP 3 OF 3 · IDENTITY") + .font(fonts::kicker()) + .color(t.text_mute), + ); + ui.add_space(18.0); + ui.label( + RichText::new("Your payment identity") + .font(FontId::new(26.0, fonts::bold())) + .color(t.text), + ); + ui.add_space(14.0); + + let wallet = self.wallet.clone()?; + let service = wallet.nostr_service(); + let (npub, connected) = service + .as_ref() + .map(|s| (s.npub(), s.is_connected())) + .unwrap_or((String::new(), false)); + + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.horizontal(|ui| { + w::avatar(ui, "N", 44.0, 6); + ui.add_space(10.0); + ui.vertical(|ui| { + let short = if npub.len() > 20 { + format!("{}…{}", &npub[..12], &npub[npub.len() - 6..]) + } else if npub.is_empty() { + "key being made…".to_string() + } else { + npub.clone() + }; + ui.label( + RichText::new(short) + .font(FontId::new(15.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.label( + RichText::new(if connected { + "connected over Tor" + } else { + "connecting over Tor…" + }) + .font(FontId::new(12.0, fonts::regular())) + .color(t.surface_text_mute), + ); + }); + }); + ui.add_space(8.0); + ui.label( + RichText::new( + "A fresh key, made for payments — deliberately not part \ + of your seed, so you can replace it without touching \ + your funds. Back it up later in Settings → Identity.", + ) + .font(FontId::new(12.5, fonts::regular())) + .color(t.surface_text_dim), + ); + }); + ui.add_space(14.0); + + // Optional username claim — the same machinery as Settings. + if let Some(msg) = self.claim.result.lock().unwrap().take() { + self.claim.checking = false; + match msg { + ClaimMsg::Availability(ok) => { + self.claim.available = Some(ok); + self.claim.message = Some(if ok { + "Available!".into() + } else { + "Taken".into() + }); + } + ClaimMsg::Registered(nip05) => { + self.claim.message = + Some(format!("You're @{}", nip05.split('@').next().unwrap_or(""))); + self.claim.available = Some(true); + if let Some(s) = wallet.nostr_service() { + { + let mut id = s.identity.write(); + id.nip05 = Some(nip05.clone()); + id.anonymous = false; + } + s.save_identity(); + } + } + ClaimMsg::Error(e) => { + self.claim.available = Some(false); + self.claim.message = Some(e); + } + } + } + let registered = wallet + .nostr_service() + .map(|s| s.identity.read().nip05.is_some()) + .unwrap_or(false); + if !registered { + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.label( + RichText::new("Pick a username — optional") + .font(FontId::new(15.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.add_space(4.0); + ui.label( + RichText::new( + "Friends pay @you instead of a long key. Public on \ + goblin.st; payments stay encrypted. Skip it and \ + you're simply anonymous — claim one any time later.", + ) + .font(FontId::new(12.5, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(8.0); + w::field_well(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new("@") + .font(FontId::new(16.0, fonts::semibold())) + .color(t.surface_text), + ); + let edit = egui::TextEdit::singleline(&mut self.claim.input) + .hint_text("yourname") + .desired_width(ui.available_width()) + .text_color(t.surface_text) + .frame(false); + if ui.add(edit).changed() { + self.claim.available = None; + self.claim.message = None; + } + }); + }); + if let Some(msg) = &self.claim.message { + ui.add_space(6.0); + ui.label( + RichText::new(msg) + .font(FontId::new(13.0, fonts::regular())) + .color(if self.claim.available == Some(false) { + t.neg + } else { + t.pos + }), + ); + } + ui.add_space(10.0); + let name = self.claim.input.trim().to_lowercase(); + let valid = name.len() >= 3 && name.len() <= 30; + if self.claim.checking { + ui.horizontal(|ui| { + View::small_loading_spinner(ui); + ui.add_space(8.0); + ui.label(RichText::new("Working…").color(t.surface_text_dim)); + }); + ui.ctx().request_repaint(); + } else { + ui.add_enabled_ui(valid && connected, |ui| { + if w::big_action_on_card(ui, "Claim username").clicked() { + start_claim_register(&mut self.claim, &name, &wallet); + } + }); + if !connected { + ui.add_space(6.0); + ui.label( + RichText::new( + "Available once Tor is connected — or skip and claim later.", + ) + .font(FontId::new(12.0, fonts::regular())) + .color(t.surface_text_mute), + ); + } + } + }); + ui.add_space(16.0); + } else { + ui.add_space(2.0); + } + + if !connected { + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(500)); + } + + let main_label = if registered { + "Open my wallet" + } else { + "Skip for now" + }; + if w::big_action(ui, main_label, false).clicked() { + return Some(wallet); + } + None + } + + /// Recovery-phrase QR scan modal content. + fn scan_modal_ui(&mut self, ui: &mut egui::Ui, _: &Modal, cb: &dyn PlatformCallbacks) { + if let Some(content) = self.scan_modal.as_mut() { + content.modal_ui(ui, cb, |result| match result { + QrScanResult::Text(text) => { + self.mnemonic_setup.mnemonic.import(&text); + Modal::close(); + } + QrScanResult::SeedQR(text) => { + self.mnemonic_setup.mnemonic.import(&text); + Modal::close(); + } + _ => {} + }); + } + } +} diff --git a/src/gui/views/goblin/send.rs b/src/gui/views/goblin/send.rs index a6e0c4b..1e9a183 100644 --- a/src/gui/views/goblin/send.rs +++ b/src/gui/views/goblin/send.rs @@ -18,10 +18,11 @@ use eframe::epaint::FontId; use egui::{Align, Layout, RichText, ScrollArea, Sense, Vec2}; use grin_core::core::amount_from_hr_string; -use crate::gui::icons::{ARROW_LEFT, MAGNIFYING_GLASS, USERS}; +use crate::gui::icons::{ARROW_LEFT, MAGNIFYING_GLASS, QR_CODE, USERS}; use crate::gui::platform::PlatformCallbacks; use crate::gui::theme::{self, fonts}; -use crate::gui::views::View; +use crate::gui::views::types::QrScanResult; +use crate::gui::views::{CameraContent, View}; use crate::nostr::nip05; use crate::wallet::Wallet; use crate::wallet::types::WalletTask; @@ -64,6 +65,10 @@ pub struct SendFlow { resolve_slot: std::sync::Arc>>, /// Resolution in flight (bech32/nip05). resolving: bool, + /// Camera QR scanner when scanning for a recipient. + scan: Option, + /// Start scanning on the next recipient frame (entry from header icon). + start_scan: bool, } impl Default for SendFlow { @@ -78,6 +83,8 @@ impl Default for SendFlow { error: None, resolve_slot: std::sync::Arc::new(std::sync::Mutex::new(None)), resolving: false, + scan: None, + start_scan: false, } } } @@ -97,6 +104,12 @@ impl SendFlow { self.stage = Stage::Recipient; } + /// Open the recipient picker with the QR scanner active (header entry). + pub fn request_scan(&mut self) { + self.start_scan = true; + self.stage = Stage::Recipient; + } + /// Render the flow. Returns true when the flow is finished (close it). pub fn ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) -> bool { let t = theme::tokens(); @@ -165,12 +178,32 @@ impl SendFlow { cb: &dyn PlatformCallbacks, ) -> bool { let t = theme::tokens(); + + // Header-icon entry arms the scanner before the first frame. + if self.start_scan { + self.start_scan = false; + cb.start_camera(); + self.scan = Some(CameraContent::default()); + } + + // Scanner mode: the camera feed replaces the picker until cancelled. + if self.scan.is_some() { + if self.back_header(ui, "Scan to pay") { + cb.stop_camera(); + self.scan = None; + return false; + } + self.scan_ui(ui, wallet, cb); + return false; + } + if self.back_header(ui, "Send to") { return true; } - // Search field: filled rounded box per the design. + // Search field: filled rounded box per the design, QR scan at right. let mut search = self.search.clone(); + let mut open_scan = false; egui::Frame { fill: t.surface2, corner_radius: eframe::epaint::CornerRadius::same(14), @@ -187,12 +220,28 @@ impl SendFlow { ui.add_space(8.0); let edit = egui::TextEdit::singleline(&mut search) .hint_text("@handle, npub, or name") - .desired_width(ui.available_width()) + .desired_width(ui.available_width() - 34.0) .text_color(t.surface_text) .frame(false); ui.add(edit); + let scan_btn = ui + .add( + egui::Label::new( + RichText::new(QR_CODE) + .font(FontId::new(20.0, fonts::regular())) + .color(t.surface_text), + ) + .sense(Sense::click()), + ) + .on_hover_cursor(egui::CursorIcon::PointingHand); + open_scan = scan_btn.clicked(); }); }); + if open_scan { + cb.start_camera(); + self.scan = Some(CameraContent::default()); + self.error = None; + } if search != self.search { self.search = search; self.error = None; @@ -218,14 +267,16 @@ impl SendFlow { } else if w::big_action(ui, "Find recipient", false).clicked() { self.resolve_search(wallet); } - if let Some(err) = &self.error { - ui.add_space(8.0); - ui.label( - RichText::new(err) - .font(FontId::new(13.0, fonts::regular())) - .color(t.neg), - ); - } + ui.add_space(16.0); + } + // Shown outside the non-empty-search gate: scan failures land here + // with the search box still empty. + if let Some(err) = &self.error { + ui.label( + RichText::new(err) + .font(FontId::new(13.0, fonts::regular())) + .color(t.neg), + ); ui.add_space(16.0); } @@ -237,7 +288,6 @@ impl SendFlow { ); ui.add_space(8.0); let peers = recent_peers(wallet, 20); - let _ = cb; ScrollArea::vertical() .auto_shrink([false; 2]) .show(ui, |ui| { @@ -261,6 +311,54 @@ impl SendFlow { false } + /// Camera feed scanning for a recipient QR (npub / nostr: URI / @handle). + fn scan_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + const NO_RECIPIENT: &str = "That QR isn't a goblin recipient — expected an npub or @handle"; + let t = theme::tokens(); + let result = self.scan.as_mut().and_then(|cam| { + let res = cam.qr_scan_result(); + if res.is_none() { + cam.ui(ui, cb); + } + res + }); + if let Some(result) = result { + cb.stop_camera(); + self.scan = None; + // Only plain text payloads can name a recipient — never echo + // seed words or slatepack contents into the search box. + match &result { + QrScanResult::Text(text) => { + let text = text.trim(); + let text = text + .strip_prefix("nostr:") + .or_else(|| text.strip_prefix("NOSTR:")) + .unwrap_or(text); + self.search = text.to_string(); + self.resolve_search(wallet); + if self.error.is_some() { + self.error = Some(NO_RECIPIENT.to_string()); + } + } + _ => self.error = Some(NO_RECIPIENT.to_string()), + } + return; + } + ui.add_space(14.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new("Point at a goblin receive QR") + .font(FontId::new(14.0, fonts::regular())) + .color(t.text_dim), + ); + }); + ui.add_space(14.0); + if w::big_action(ui, "Cancel", false).clicked() { + cb.stop_camera(); + self.scan = None; + } + } + fn resolve_search(&mut self, wallet: &Wallet) { let input = self.search.trim().to_string(); self.error = None; @@ -458,6 +556,7 @@ impl SendFlow { let amount = self.amount.clone(); w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); ui.add_space(8.0); let label = format!("You're sending {}", recipient.name); let galley = ui.painter().layout_no_wrap( @@ -486,7 +585,7 @@ impl SendFlow { if !self.note.trim().is_empty() { w::info_row(ui, "Note", &format!("\u{201C}{}\u{201D}", self.note.trim())); } - w::info_row(ui, "Network fee", "deducted from your balance"); + w::info_row(ui, "Network fee", "Deducted from your balance"); w::info_row(ui, "Privacy", "Mimblewimble"); w::info_row(ui, "Delivery", "Encrypted nostr DM over Tor"); ui.add_space(16.0); diff --git a/src/gui/views/goblin/widgets.rs b/src/gui/views/goblin/widgets.rs index 2e35b48..7d6865d 100644 --- a/src/gui/views/goblin/widgets.rs +++ b/src/gui/views/goblin/widgets.rs @@ -224,46 +224,73 @@ pub fn chip_outline(ui: &mut Ui, label: &str) -> Response { resp } -/// Paint a QR code for `text` in theme ink with the goblin mark centered, -/// per the design's receive card. Encoding a short URI is microseconds, so -/// this is done synchronously each frame; modules are plain painter rects. +/// Paint a QR code for `text` with the goblin mark centered, per the +/// design's receive card. Always dark modules on a white plate, whatever the +/// theme: inverted (light-on-dark) codes fail to decode in a number of +/// scanner apps. Encoding a short URI is microseconds, so this is done +/// synchronously each frame; modules are plain painter rects. pub fn qr_code(ui: &mut Ui, text: &str, size: f32) { - let t = theme::tokens(); - let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); + let plate = Color32::WHITE; + let ink = Color32::from_rgb(0x0E, 0x0E, 0x0C); // High error correction tolerates the center mark covering modules. let Ok(qr) = qrcodegen::QrCode::encode_text(text, qrcodegen::QrCodeEcc::High) else { return; }; + let pad = (size * 0.05).max(8.0); + let (outer, _) = ui.allocate_exact_size(Vec2::splat(size + pad * 2.0), Sense::hover()); + ui.painter() + .rect_filled(outer, CornerRadius::same(16), plate); + let rect = outer.shrink(pad); let n = qr.size(); let cell = size / n as f32; - let radius = (cell * 0.3).max(1.0) as u8; + // Full cells with no inter-module gap: at receive-card density (~4.5px + // cells) even a 0.5px gap fragments the finder patterns and scanners + // fail to detect the code at all (probed with rqrr). Corner rounding + // only when cells are big enough that the notching can't matter. + let radius = if cell >= 6.0 { (cell * 0.3) as u8 } else { 0 }; for y in 0..n { for x in 0..n { if qr.get_module(x, y) { let min = rect.min + Vec2::new(x as f32 * cell, y as f32 * cell); ui.painter().rect_filled( - egui::Rect::from_min_size(min, Vec2::splat(cell - 0.5)), + egui::Rect::from_min_size(min, Vec2::splat(cell)), CornerRadius::same(radius), - t.surface_text, + ink, ); } } } - // Goblin mark on a surface backing square in the center. - let backing = size * 0.26; + // Goblin mark on a plate-colored backing square in the center. 19% of + // the code: at 26%, zbar-class scanners fail on the glyph (rqrr and + // ZXing-class tolerate it); 19% passes everything probed. + let backing = size * 0.19; let b_rect = egui::Rect::from_center_size(rect.center(), Vec2::splat(backing)); - ui.painter().rect_filled( - b_rect, - CornerRadius::same((backing * 0.18) as u8), - t.surface, - ); + ui.painter() + .rect_filled(b_rect, CornerRadius::same((backing * 0.18) as u8), plate); let m_rect = egui::Rect::from_center_size(rect.center(), Vec2::splat(backing * 0.72)); egui::Image::new(egui::include_image!("../../../../img/goblin-logo2-256.png")) - .tint(t.surface_text) + .tint(ink) .fit_to_exact_size(m_rect.size()) .paint_at(ui, m_rect); } +/// A filled input well for a text field sitting on a card, so the field +/// reads as a field: frameless edits on the card fill are invisible. +pub fn field_well(ui: &mut Ui, content: impl FnOnce(&mut Ui)) { + let t = theme::tokens(); + egui::Frame { + fill: t.surface2, + stroke: Stroke::new(1.0, t.line), + corner_radius: CornerRadius::same(10), + inner_margin: egui::Margin::symmetric(12, 10), + ..Default::default() + } + .show(ui, |ui| { + ui.set_min_width(ui.available_width()); + content(ui); + }); +} + /// A balance hero block: kicker, big number + ツ, optional fiat line. pub fn balance_hero(ui: &mut Ui, atomic: u64, fiat: Option<&str>, size: f32) { let t = theme::tokens(); diff --git a/src/gui/views/wallets/content.rs b/src/gui/views/wallets/content.rs index 90aab25..54fe198 100644 --- a/src/gui/views/wallets/content.rs +++ b/src/gui/views/wallets/content.rs @@ -16,8 +16,8 @@ use eframe::epaint::RectShape; use egui::os::OperatingSystem; use egui::scroll_area::ScrollBarVisibility; use egui::{ - Align, CornerRadius, CursorIcon, Id, Layout, Margin, OpenUrl, RichText, ScrollArea, Sense, - StrokeKind, UiBuilder, + Align, CornerRadius, CursorIcon, Id, Layout, Margin, OpenUrl, ScrollArea, Sense, StrokeKind, + UiBuilder, }; use egui_async::Bind; use std::time::Duration; @@ -25,10 +25,11 @@ use std::time::Duration; use crate::AppConfig; use crate::gui::Colors; use crate::gui::icons::{ - ARROW_LEFT, BOOKMARKS, CALENDAR_CHECK, CLOUD_ARROW_DOWN, COMPUTER_TOWER, FOLDER_PLUS, GEAR, - GEAR_FINE, GLOBE, GLOBE_SIMPLE, LOCK_KEY, NOTEPAD, PLUS, SIDEBAR_SIMPLE, SUITCASE, + ARROW_LEFT, BOOKMARKS, CALENDAR_CHECK, CLOUD_ARROW_DOWN, COMPUTER_TOWER, GEAR, GEAR_FINE, + GLOBE, GLOBE_SIMPLE, LOCK_KEY, NOTEPAD, PLUS, SIDEBAR_SIMPLE, SUITCASE, }; use crate::gui::platform::PlatformCallbacks; +use crate::gui::views::goblin::onboarding::OnboardingContent; use crate::gui::views::settings::SettingsContent; use crate::gui::views::types::{ ContentContainer, LinePosition, ModalPosition, TitleContentType, TitleType, @@ -64,6 +65,8 @@ pub struct WalletsContent { wallet_content: WalletContent, /// Wallet creation content. creation_content: Option, + /// First-run onboarding content, shown while no wallets exist. + onboarding: Option, /// Settings content. settings_content: Option, @@ -95,6 +98,7 @@ impl Default for WalletsContent { wallet_settings_content: WalletSettingsModal::new(ConnectionMethod::Integrated), wallet_content: WalletContent::default(), creation_content: None, + onboarding: None, settings_content: None, check_update: Bind::new(false), update_info: (false, None), @@ -194,16 +198,21 @@ impl ContentContainer for WalletsContent { let showing_settings = self.showing_settings(); let creating_wallet = self.creating_wallet(); let showing_wallet = self.showing_wallet() && !creating_wallet && !showing_settings; + // First-run onboarding owns the surface while no wallets exist (and + // keeps it through its identity step, when the new wallet is already + // open but not yet selected). + let onboarding_active = !showing_wallet && self.onboarding_active(); let dual_panel = is_dual_panel_mode(ui); let content_width = ui.available_width(); let list_hidden = showing_settings || creating_wallet + || onboarding_active || self.wallets.list().is_empty() || (showing_wallet && (!dual_panel || !AppConfig::show_wallets_at_dual_panel())); // Show title panel, except over the full-bleed Goblin wallet surface - // (it has its own header/navigation; lock + node info live there). - if !showing_wallet { + // and onboarding (they have their own header/navigation). + if !showing_wallet && !onboarding_active { self.title_ui(ui, dual_panel, cb); } @@ -300,6 +309,8 @@ impl ContentContainer for WalletsContent { }, fill: if self.showing_settings() { Colors::fill_lite() + } else if onboarding_active { + Colors::fill() } else { Colors::fill_deep() }, @@ -337,20 +348,17 @@ impl ContentContainer for WalletsContent { self.select_wallet(w, None, cb); } } - } else if self.wallets.list().is_empty() { - View::center_content(ui, 350.0 + View::get_bottom_inset(), |ui| { - View::app_logo_name_version(ui); - ui.add_space(4.0); - - let text = t!("wallets.create_desc"); - ui.label(RichText::new(text).size(16.0).color(Colors::gray())); - ui.add_space(8.0); - // Show wallet creation button. - let add_text = format!("{} {}", FOLDER_PLUS, t!("wallets.add")); - View::button(ui, add_text, Colors::white_or_black(false), || { - self.show_add_wallet_modal(); - }); - }); + } else if onboarding_active { + // First-run onboarding replaces the stock empty state; + // later wallets still use the list's add-wallet flow. + if self.onboarding.is_none() { + self.onboarding = Some(OnboardingContent::default()); + } + let ob = self.onboarding.as_mut().unwrap(); + if let Some(w) = ob.ui(ui, &mut self.wallets, cb) { + self.onboarding = None; + self.select_wallet(&w, None, cb); + } } else { return; } @@ -402,6 +410,14 @@ impl WalletsContent { self.creation_content.is_some() } + /// Check if first-run onboarding owns the surface (no wallets yet, or + /// the onboarding identity step is still finishing). + pub fn onboarding_active(&self) -> bool { + !self.showing_settings() + && !self.creating_wallet() + && (self.onboarding.is_some() || self.wallets.list().is_empty()) + } + /// Check if application settings are showing. pub fn showing_settings(&self) -> bool { self.settings_content.is_some() diff --git a/src/gui/views/wallets/creation/mnemonic.rs b/src/gui/views/wallets/creation/mnemonic.rs index e7dfa8e..49b57c9 100644 --- a/src/gui/views/wallets/creation/mnemonic.rs +++ b/src/gui/views/wallets/creation/mnemonic.rs @@ -133,7 +133,7 @@ impl MnemonicSetup { } /// Draw grid of words for mnemonic phrase. - fn word_list_ui(&mut self, ui: &mut egui::Ui, edit: bool) { + pub(crate) fn word_list_ui(&mut self, ui: &mut egui::Ui, edit: bool) { ui.add_space(6.0); ui.scope(|ui| { // Setup spacing between columns. diff --git a/src/gui/views/wallets/mod.rs b/src/gui/views/wallets/mod.rs index 00a45c9..5b0829d 100644 --- a/src/gui/views/wallets/mod.rs +++ b/src/gui/views/wallets/mod.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -mod creation; +pub(crate) mod creation; pub mod modals; mod content; diff --git a/src/lib.rs b/src/lib.rs index 7f4835e..10d843b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -101,6 +101,10 @@ where Box::new(|cc| { // Setup images support. egui_extras::install_image_loaders(&cc.egui_ctx); + // Bind fonts before the first frame: set_fonts inside a frame only + // applies on the next pass, and the first-run onboarding references + // named weight families (Geist) on frame one. + setup_fonts(&cc.egui_ctx); Ok(Box::new(app)) }) } diff --git a/src/nostr/identity.rs b/src/nostr/identity.rs index 9d6f554..bed17ce 100644 --- a/src/nostr/identity.rs +++ b/src/nostr/identity.rs @@ -159,6 +159,18 @@ impl NostrIdentity { Ok((identity, keys)) } + /// Build an identity from already-unlocked keys under a (possibly + /// different) password — used when importing a backup that was exported + /// under another wallet's password. + pub fn from_unlocked_keys( + keys: &Keys, + password: &str, + source: IdentitySource, + account: u32, + ) -> Result { + Self::from_keys(keys, password, source, account) + } + /// Create a brand-new random identity, independent of the wallet seed. pub fn create_random(password: &str) -> Result<(NostrIdentity, Keys), IdentityError> { let keys = Keys::generate(); @@ -237,6 +249,25 @@ impl NostrIdentity { mod tests { use super::*; + #[test] + fn backup_restores_under_new_password() { + // Export under one wallet password, restore on a device with another. + let (a, _) = NostrIdentity::create_random("old-pw").unwrap(); + let json = serde_json::to_string(&a).unwrap(); + let parsed: NostrIdentity = serde_json::from_str(&json).unwrap(); + let keys = parsed.unlock("old-pw").unwrap(); + let b = NostrIdentity::from_unlocked_keys( + &keys, + "new-pw", + parsed.source, + parsed.derivation_account, + ) + .unwrap(); + assert_eq!(b.npub, a.npub); + assert!(b.unlock("new-pw").is_ok()); + assert!(b.unlock("old-pw").is_err()); + } + #[test] fn random_identities_are_unlinked_and_unlock() { let (a, ka) = NostrIdentity::create_random("pw-1").unwrap(); diff --git a/src/wallet/wallet.rs b/src/wallet/wallet.rs index 8c00b93..ff564ba 100644 --- a/src/wallet/wallet.rs +++ b/src/wallet/wallet.rs @@ -540,25 +540,46 @@ impl Wallet { /// or an exported identity-backup JSON (which restores the username and /// rotation history too). The current identity is overwritten; its npub /// is kept in `prev_npubs` for reference. Blocking-safe (no network). - pub fn import_nostr_identity(&self, input: String, password: String) -> Result { + pub fn import_nostr_identity( + &self, + input: String, + password: String, + backup_password: Option, + ) -> Result { let svc = self .nostr_service() .ok_or_else(|| "nostr is not running".to_string())?; - // Prove the password against the current identity before replacing. + // Prove THIS wallet's password against the current identity first. let old = svc.identity.read().clone(); old.unlock(&password) .map_err(|_| "Wrong password".to_string())?; let input = input.trim(); let (mut new_identity, new_keys) = if input.starts_with('{') { - // Identity backup JSON: the ncryptsec inside stays as exported; - // it must decrypt with the provided (= export-time) password. - let ident: NostrIdentity = + // Identity backup JSON: decrypt with the password it was exported + // under (may differ on a new device), then RE-ENCRYPT under this + // wallet's password so future unlocks use the current one. + let backup: NostrIdentity = serde_json::from_str(input).map_err(|_| "Invalid identity backup".to_string())?; - let keys = ident.unlock(&password).map_err(|_| { - "Backup password mismatch (use the wallet password from when \ - it was exported)" + let bpw = backup_password + .as_deref() + .filter(|s| !s.is_empty()) + .unwrap_or(&password); + let keys = backup.unlock(bpw).map_err(|_| { + "Couldn't decrypt the backup — if it was exported on another \ + device, enter that wallet's password in the backup-password \ + field" .to_string() })?; + let mut ident = NostrIdentity::from_unlocked_keys( + &keys, + &password, + backup.source, + backup.derivation_account, + ) + .map_err(|e| format!("re-encryption failed: {e}"))?; + ident.nip05 = backup.nip05.clone(); + ident.anonymous = backup.anonymous; + ident.prev_npubs = backup.prev_npubs.clone(); (ident, keys) } else { NostrIdentity::create_imported(input, &password)