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:
@@ -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
@@ -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:'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user