1
0
forked from GRIN/grim

Build 9: QR scan, camera fixes, first-run onboarding, sweep fixes

Camera/QR (validated live with a v4l2loopback virtual camera):
- decode non-MJPEG frames on Linux (raw YUYV was undecodable: eternal
  spinner), enumerate devices off the UI thread without unwrap, open
  cameras by their real device index (list position broke whenever
  /dev/video0 was absent), show "No camera found" after 5s
- scan-to-pay: QR button in the send recipient search row and the
  mobile home header; in-surface camera panel feeding recipient
  resolution; only text payloads accepted (seeds/slatepacks refused)
- receive QR was unscannable: full cells (0.5px gaps fragmented finder
  patterns at 4.5px cells), always ink-on-white plate (inverted codes
  fail many scanners), center mark 26% -> 19% (zbar chokes above);
  rqrr+zbar both decode the live card now; unit tests cover the exact
  widget geometry

First-run onboarding (replaces the stock empty state only; the wallet
list, add-wallet modal and GRIM creation flow stay for later wallets):
- intro -> node choice (Private integrated / Instant external with
  URL) -> create or restore (wraps MnemonicSetup; word grid, paste,
  SeedQR scan) -> optional @username claim with prominent skip
- fonts bind at creation context so frame one can use Geist families

Sweep fixes (22 confirmed findings, the simple ones):
- yellow theme: active sidebar nav was dark-on-dark (P1)
- window frame ring painted with theme bg: transparent clear color
  rendered as a black band without a compositor (P2)
- import/rotate identity inputs get visible field wells (P2)
- receive buttons: verb labels + transient "Copied" feedback
- review hero card fills the column; fee row copy; pay-tab stale and
  duplicated hints; unlock modal copy; node card subtitle truncation
- build number stays current (.git/logs/HEAD rerun trigger)

Backup restore: import accepts the export-time password and re-encrypts
under the current wallet password; cross-device restores work.

31 lib tests green. Mainnet-validated: both flows restored real
wallets; 0.1 grin sent B->A through NIP-17 over Tor with async
open/close ping-pong; tx 71fbfce4f591 posted on-chain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-06-11 04:07:53 -04:00
parent 908df117e6
commit d8cf06b577
16 changed files with 1412 additions and 125 deletions
+3
View File
@@ -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!(
+1 -1
View File
@@ -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:'
+6
View File
@@ -167,6 +167,12 @@ impl<Platform: PlatformCallbacks> App<Platform> {
/// 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 {
+38 -13
View File
@@ -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::<RgbFormat>(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::<RgbFormat>() {
let mut bytes: Vec<u8> = 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();
};
}
});
+79 -1
View File
@@ -36,6 +36,8 @@ pub struct CameraContent {
qr_scan_state: Arc<RwLock<QrScanState>>,
/// Uniform Resources URIs collected from QR code scanning.
ur_data: Arc<RwLock<Option<(Vec<String>, 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()),
}
}
}
+2 -1
View File
@@ -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")
+145 -49
View File
@@ -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<String>,
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<std::sync::Mutex<Option<Result<String, String>>>>,
@@ -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)
+880
View File
@@ -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<String>,
/// QR scanner for recovery phrase import.
scan_modal: Option<CameraScanContent>,
/// Created and opened wallet, present from the Identity step on.
wallet: Option<Wallet>,
/// 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<Wallet> {
// 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<Wallet> {
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();
}
_ => {}
});
}
}
}
+113 -14
View File
@@ -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<std::sync::Mutex<Option<ResolveOutcome>>>,
/// Resolution in flight (bech32/nip05).
resolving: bool,
/// Camera QR scanner when scanning for a recipient.
scan: Option<CameraContent>,
/// 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);
+43 -16
View File
@@ -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();
+36 -20
View File
@@ -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<WalletCreationContent>,
/// First-run onboarding content, shown while no wallets exist.
onboarding: Option<OnboardingContent>,
/// Settings content.
settings_content: Option<SettingsContent>,
@@ -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()
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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;
+4
View File
@@ -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))
})
}
+31
View File
@@ -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<NostrIdentity, IdentityError> {
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();
+29 -8
View File
@@ -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<String, String> {
pub fn import_nostr_identity(
&self,
input: String,
password: String,
backup_password: Option<String>,
) -> Result<String, String> {
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)