diff --git a/build.rs b/build.rs index 944728d5..3b43f3bb 100644 --- a/build.rs +++ b/build.rs @@ -2,9 +2,29 @@ use std::path::PathBuf; use std::process::Command; use std::{env, fs}; +/// The GRIM commit Goblin forked from; builds count commits on top of it. +const GOBLIN_FORK_BASE: &str = "b51a46b"; + fn main() { built::write_built_file().expect("Failed to acquire build-time information"); + // Goblin versioning is build-based: Build N = commits since the fork. + let build = Command::new("git") + .args([ + "rev-list", + "--count", + &format!("{}..HEAD", GOBLIN_FORK_BASE), + ]) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "dev".to_string()); + println!("cargo:rustc-env=GOBLIN_BUILD={}", build); + println!("cargo:rerun-if-changed=.git/HEAD"); + // Setting up git hooks in the project: rustfmt and so on. let git_hooks = format!( "git config core.hooksPath {}", @@ -82,14 +102,14 @@ fn main() { Command::new("./scripts/webtunnel.bat") .arg(go_os) .arg(go_arch) - .arg(webtunnel_file) + .arg(&webtunnel_file) .output() } else { Command::new("bash") .arg("./scripts/webtunnel.sh") .arg(go_os) .arg(go_arch) - .arg(webtunnel_file) + .arg(&webtunnel_file) .output() }; if let Ok(out) = output { @@ -97,5 +117,16 @@ fn main() { panic!("webtunnel go build failed:\n{:?}", out); } } + // The build script exits 0 when Go is absent, leaving the placeholder + // empty — surface that loudly instead of shipping broken bridges. + let still_empty = fs::metadata(&webtunnel_file) + .map(|m| m.len() == 0) + .unwrap_or(true); + if still_empty { + println!( + "cargo:warning=webtunnel client was not built (is Go installed?) — \ + Tor webtunnel bridges will not work at runtime" + ); + } } } diff --git a/img/goblin-logo-256.png b/img/goblin-logo-256.png new file mode 100644 index 00000000..06cc5508 Binary files /dev/null and b/img/goblin-logo-256.png differ diff --git a/img/goblin-logo2-256.png b/img/goblin-logo2-256.png new file mode 100644 index 00000000..ad3924fd Binary files /dev/null and b/img/goblin-logo2-256.png differ diff --git a/src/gui/app.rs b/src/gui/app.rs index a8382b16..af5a9b4c 100755 --- a/src/gui/app.rs +++ b/src/gui/app.rs @@ -291,7 +291,7 @@ impl App { } // Paint the title. - let title_text = format!("Grim {} ツ", crate::VERSION); + let title_text = format!("Goblin ツ · Build {}", crate::BUILD); painter.text( title_rect.center(), egui::Align2::CENTER_CENTER, diff --git a/src/gui/theme.rs b/src/gui/theme.rs index 178019d2..f4c99c00 100644 --- a/src/gui/theme.rs +++ b/src/gui/theme.rs @@ -54,6 +54,12 @@ pub struct ThemeTokens { pub text: Color32, pub text_dim: Color32, pub text_mute: Color32, + /// Text on surface/surface2 fills. Matches `text` in light/dark, but the + /// yellow theme has dark surfaces on a bright bg, so on-surface text must + /// be light there while `text` stays dark for the bg. + pub surface_text: Color32, + pub surface_text_dim: Color32, + pub surface_text_mute: Color32, pub line: Color32, pub accent: Color32, pub accent_dark: Color32, @@ -86,6 +92,9 @@ pub const LIGHT: ThemeTokens = ThemeTokens { text: Color32::from_rgb(0x0E, 0x0E, 0x0C), text_dim: Color32::from_rgb(0x6B, 0x6A, 0x63), text_mute: Color32::from_rgb(0xA6, 0xA3, 0x9B), + surface_text: Color32::from_rgb(0x0E, 0x0E, 0x0C), + surface_text_dim: Color32::from_rgb(0x6B, 0x6A, 0x63), + surface_text_mute: Color32::from_rgb(0xA6, 0xA3, 0x9B), // rgba(14,14,12,0.08) premultiplied. line: Color32::from_rgba_premultiplied(1, 1, 1, 20), accent: Color32::from_rgb(0xFF, 0xD6, 0x0A), @@ -106,6 +115,9 @@ pub const DARK: ThemeTokens = ThemeTokens { text: Color32::from_rgb(0xFA, 0xFA, 0xF7), text_dim: Color32::from_rgb(0x9A, 0x98, 0x8F), text_mute: Color32::from_rgb(0x60, 0x5E, 0x58), + surface_text: Color32::from_rgb(0xFA, 0xFA, 0xF7), + surface_text_dim: Color32::from_rgb(0x9A, 0x98, 0x8F), + surface_text_mute: Color32::from_rgb(0x60, 0x5E, 0x58), // rgba(255,255,255,0.08) premultiplied. line: Color32::from_rgba_premultiplied(20, 20, 20, 20), accent: Color32::from_rgb(0xFF, 0xD6, 0x0A), @@ -126,6 +138,9 @@ pub const YELLOW: ThemeTokens = ThemeTokens { text: Color32::from_rgb(0x0E, 0x0E, 0x0C), text_dim: Color32::from_rgb(0x3A, 0x3A, 0x36), text_mute: Color32::from_rgb(0x6B, 0x6A, 0x63), + surface_text: Color32::from_rgb(0xFA, 0xFA, 0xF7), + surface_text_dim: Color32::from_rgb(0x9A, 0x98, 0x8F), + surface_text_mute: Color32::from_rgb(0x60, 0x5E, 0x58), // rgba(14,14,12,0.18) premultiplied. line: Color32::from_rgba_premultiplied(2, 2, 2, 46), accent: Color32::from_rgb(0x0E, 0x0E, 0x0C), diff --git a/src/gui/views/content.rs b/src/gui/views/content.rs index 77b5c138..bd1f83ec 100644 --- a/src/gui/views/content.rs +++ b/src/gui/views/content.rs @@ -94,6 +94,9 @@ impl ContentContainer for Content { if self.network.showing_settings() { panel_width = ui.available_width(); } + // 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(); // Show network content. egui::SidePanel::left("network_panel") @@ -102,7 +105,7 @@ impl ContentContainer for Content { .frame(egui::Frame { ..Default::default() }) - .show_animated_inside(ui, is_panel_open, |ui| { + .show_animated_inside(ui, is_panel_open && !wallet_open, |ui| { self.network.ui(ui, cb); }); diff --git a/src/gui/views/goblin/data.rs b/src/gui/views/goblin/data.rs index 21235454..f268b6bc 100644 --- a/src/gui/views/goblin/data.rs +++ b/src/gui/views/goblin/data.rs @@ -68,8 +68,9 @@ pub fn short_npub(hex: &str) -> String { use nostr_sdk::{PublicKey, ToBech32}; if let Ok(pk) = PublicKey::from_hex(hex) { if let Ok(npub) = pk.to_bech32() { - if npub.len() > 16 { - return format!("{}…{}", &npub[..9], &npub[npub.len() - 4..]); + // Standard truncation: "npub1" + 7 head chars … 6 tail chars. + if npub.len() > 18 { + return format!("{}…{}", &npub[..12], &npub[npub.len() - 6..]); } return npub; } diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index 12a0d02b..e9ae4138 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -18,8 +18,8 @@ pub mod data; pub mod send; pub mod widgets; -use eframe::epaint::{FontId, Stroke}; -use egui::{Align, Layout, Margin, RichText, ScrollArea, Sense, Vec2}; +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::platform::PlatformCallbacks; @@ -32,10 +32,13 @@ use self::data::{ActivityItem, activity_items, recent_peers}; use self::send::SendFlow; use self::widgets as w; -/// Goblin navigation tabs. +/// Goblin navigation tabs. The mobile bar shows Home / Pay / Activity; +/// Receive and Me stay reachable (Pay's Request action, header avatar, +/// desktop sidebar). #[derive(Clone, Copy, PartialEq, Eq)] pub enum Tab { Home, + Pay, Activity, Receive, Me, @@ -51,6 +54,31 @@ pub struct GoblinWalletView { wallet_id: Option, /// Inline username-claim state for the Me tab. claim: Option, + /// Inline key-rotation state for the Me tab. + rotate: Option, + /// Inline nsec-import state for the Me tab. + import_nsec: Option, + /// Amount being entered on the Pay tab. + pay_amount: String, + /// Amount being requested, shown on the Receive screen. + request_amount: Option, + /// Sub-page open inside the Settings tab. + settings_page: SettingsPage, + /// Inputs for adding an external node connection. + node_url_input: String, + node_secret_input: String, + /// Relay list being edited and the add-relay input. + relay_edit: Vec, + relay_input: String, +} + +/// Sub-pages of the Settings tab. +#[derive(Clone, Copy, PartialEq, Eq)] +enum SettingsPage { + Main, + Node, + Relays, + Nips, } impl Default for GoblinWalletView { @@ -61,6 +89,64 @@ impl Default for GoblinWalletView { approving: std::collections::HashSet::new(), wallet_id: None, claim: None, + rotate: None, + import_nsec: None, + pay_amount: String::new(), + request_amount: None, + settings_page: SettingsPage::Main, + node_url_input: String::new(), + node_secret_input: String::new(), + relay_edit: Vec::new(), + relay_input: String::new(), + } + } +} + +/// Inline key-rotation flow state (two warnings + typed confirmation). +struct RotateState { + /// 1 = warning, 2 = confirm (RESET + password), 3 = working, + /// 4 = done (new npub), 5 = error (message). + stage: u8, + reset_input: String, + password: String, + new_npub: String, + error: String, + result: std::sync::Arc>>>, +} + +impl Default for RotateState { + fn default() -> Self { + Self { + stage: 1, + reset_input: String::new(), + password: String::new(), + new_npub: String::new(), + error: String::new(), + result: std::sync::Arc::new(std::sync::Mutex::new(None)), + } + } +} + +/// Inline nsec-import flow state (restore path for the random-key model). +struct ImportState { + /// 1 = form, 3 = working, 4 = done, 5 = error. + stage: u8, + nsec: String, + password: String, + new_npub: String, + error: String, + result: std::sync::Arc>>>, +} + +impl Default for ImportState { + fn default() -> Self { + Self { + stage: 1, + nsec: String::new(), + password: String::new(), + new_npub: String::new(), + error: String::new(), + result: std::sync::Arc::new(std::sync::Mutex::new(None)), } } } @@ -104,6 +190,10 @@ impl GoblinWalletView { self.send = None; return false; } + if self.tab == Tab::Me && self.settings_page != SettingsPage::Main { + self.settings_page = SettingsPage::Main; + return false; + } if self.tab != Tab::Home { self.tab = Tab::Home; return false; @@ -123,7 +213,12 @@ impl GoblinWalletView { self.tab = Tab::Home; self.send = None; self.claim = None; + self.rotate = None; + self.import_nsec = None; self.approving.clear(); + self.pay_amount.clear(); + self.request_amount = None; + self.settings_page = SettingsPage::Main; } // Send flow takes the full surface when active. @@ -162,12 +257,11 @@ impl GoblinWalletView { .frame(egui::Frame { fill: t.bg, inner_margin: Margin { - left: 8, - right: 8, - top: 8, - bottom: (4.0 + bottom_inset) as i8, + left: 16, + right: 16, + top: 10, + bottom: (12.0 + bottom_inset) as i8, }, - stroke: Stroke::new(1.0, t.line), ..Default::default() }) .show_inside(ui, |ui| { @@ -189,7 +283,8 @@ impl GoblinWalletView { }) .show_inside(ui, |ui| { w::centered_column(ui, Content::SIDE_PANEL_WIDTH * 1.2, |ui| match self.tab { - Tab::Home => self.home_ui(ui, wallet, cb), + Tab::Home => self.home_ui(ui, wallet, cb, wide_desktop), + Tab::Pay => self.pay_ui(ui, wallet), Tab::Activity => self.activity_ui(ui, wallet, cb), Tab::Receive => self.receive_ui(ui, wallet, cb), Tab::Me => self.me_ui(ui, wallet, cb), @@ -197,41 +292,91 @@ impl GoblinWalletView { }); } + /// Floating 3-item pill bar: Wallet · Pay (center ツ puck) · Activity. fn tab_bar_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { let t = theme::tokens(); let has_requests = wallet .nostr_service() .map(|s| !s.store.pending_requests().is_empty()) .unwrap_or(false); - let tabs = [ - (Tab::Home, WALLET, "Wallet", false), - (Tab::Activity, CLOCK, "Activity", has_requests), - (Tab::Receive, ARROW_DOWN, "Receive", false), - (Tab::Me, USER_CIRCLE, "Me", false), - ]; + + let bar_h = 64.0; + let bar_w = ui.available_width().min(340.0); + let margin = ((ui.available_width() - bar_w) / 2.0).max(0.0); ui.horizontal(|ui| { - let w = ui.available_width() / tabs.len() as f32; - for (tab, icon, label, badge) in tabs { - let (rect, resp) = ui.allocate_exact_size(Vec2::new(w, 48.0), Sense::click()); + ui.add_space(margin); + let (bar_rect, _) = ui.allocate_exact_size(Vec2::new(bar_w, bar_h), Sense::hover()); + // Soft shadow + floating pill. + let shadow = bar_rect.translate(Vec2::new(0.0, 3.0)).expand(2.0); + ui.painter().rect_filled( + shadow, + CornerRadius::same(34), + Color32::from_black_alpha(70), + ); + ui.painter().rect( + bar_rect, + CornerRadius::same(32), + t.surface, + Stroke::new(1.0, t.line), + egui::StrokeKind::Inside, + ); + + let cell = bar_w / 3.0; + let tabs = [ + (Tab::Home, Some(WALLET), false), + (Tab::Pay, None, false), + (Tab::Activity, Some(CLOCK), has_requests), + ]; + for (i, (tab, icon, badge)) in tabs.into_iter().enumerate() { + let rect = egui::Rect::from_min_size( + bar_rect.min + Vec2::new(i as f32 * cell, 0.0), + Vec2::new(cell, bar_h), + ); + let resp = ui.interact(rect, ui.id().with(("goblin_tab", i)), Sense::click()); let active = self.tab == tab; - let color = if active { t.text } else { t.text_mute }; - ui.painter().text( - rect.center() - Vec2::new(0.0, 8.0), - egui::Align2::CENTER_CENTER, - icon, - FontId::new(22.0, fonts::regular()), - color, - ); - ui.painter().text( - rect.center() + Vec2::new(0.0, 14.0), - egui::Align2::CENTER_CENTER, - label, - FontId::new(11.0, fonts::semibold()), - color, - ); + match icon { + Some(icon) => { + // Icon-only; the active tab gets a circular highlight. + if active { + ui.painter().circle_filled(rect.center(), 22.0, t.surface2); + } + let color = if active { + t.surface_text + } else { + t.surface_text_mute + }; + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + icon, + FontId::new(23.0, fonts::regular()), + color, + ); + } + None => { + // Center Pay action: accent ツ puck. + let grow = if active || resp.hovered() { 1.0 } else { 0.0 }; + ui.painter().circle_filled( + rect.center(), + 24.0 + grow, + if resp.hovered() { + t.accent_dark + } else { + t.accent + }, + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + w::TSU, + FontId::new(24.0, fonts::bold()), + t.accent_ink, + ); + } + } if badge { ui.painter() - .circle_filled(rect.center() + Vec2::new(12.0, -10.0), 4.0, t.neg); + .circle_filled(rect.center() + Vec2::new(13.0, -13.0), 4.5, t.neg); } if resp.clicked() { self.tab = tab; @@ -259,16 +404,16 @@ impl GoblinWalletView { .nostr_service() .map(|s| !s.store.pending_requests().is_empty()) .unwrap_or(false); - // (tab, icon, label, is_send_action, badge) + // (tab, icon, label, badge) let items = [ - (Some(Tab::Home), WALLET, "Wallet", false), - (Some(Tab::Activity), CLOCK, "Activity", has_requests), - (None, crate::gui::icons::ARROW_UP, "Send", false), - (Some(Tab::Receive), ARROW_DOWN, "Receive", false), - (Some(Tab::Me), USER_CIRCLE, "Settings", false), + (Tab::Home, WALLET, "Wallet", false), + (Tab::Pay, crate::gui::icons::ARROW_UP, "Pay", false), + (Tab::Activity, CLOCK, "Activity", has_requests), + (Tab::Receive, ARROW_DOWN, "Receive", false), + (Tab::Me, USER_CIRCLE, "Settings", false), ]; for (tab, icon, label, badge) in items { - let active = tab.map(|x| x == self.tab).unwrap_or(false); + let active = tab == self.tab; let (rect, resp) = ui.allocate_exact_size(Vec2::new(ui.available_width(), 44.0), Sense::click()); if active || resp.hovered() { @@ -305,59 +450,141 @@ impl GoblinWalletView { .circle_filled(rect.right_center() - Vec2::new(14.0, 0.0), 4.0, t.neg); } if resp.clicked() { - match tab { - Some(x) => self.tab = x, - None => self.send = Some(SendFlow::default()), - } + self.tab = tab; } ui.add_space(4.0); } - // Profile card pinned to the bottom. + // Node status + profile cards pinned to the bottom (node info lives + // here so the surface needs no separate network column). ui.with_layout(Layout::bottom_up(Align::Min), |ui| { - let (handle, connected) = wallet - .nostr_service() - .map(|s| { - let id = s.identity.read(); - let h = id - .nip05 - .clone() - .map(|n| format!("@{}", n.split('@').next().unwrap_or(""))) - .unwrap_or_else(|| data::short_npub(&hex_of(&id.npub))); - (h, s.is_connected()) - }) - .unwrap_or_else(|| ("Anonymous".to_string(), false)); - w::card(ui, |ui| { - ui.horizontal(|ui| { - w::avatar(ui, &handle, 36.0, 6); - ui.add_space(10.0); - ui.vertical(|ui| { - ui.label( - RichText::new(&handle) - .font(FontId::new(14.0, fonts::semibold())) - .color(t.text), - ); - ui.label( - RichText::new(if connected { - "synced · Tor" - } else { - "connecting…" - }) - .font(FontId::new(12.0, fonts::regular())) - .color(t.text_mute), - ); + let width = ui.available_width(); + let bottom = ui.allocate_ui_with_layout( + Vec2::new(width, 148.0), + Layout::top_down(Align::Min), + |ui| { + self.node_card_ui(ui, wallet); + ui.add_space(8.0); + let (handle, connected) = wallet + .nostr_service() + .map(|s| { + let id = s.identity.read(); + let h = id + .nip05 + .clone() + .map(|n| format!("@{}", n.split('@').next().unwrap_or(""))) + .unwrap_or_else(|| data::short_npub(&hex_of(&id.npub))); + (h, s.is_connected()) + }) + .unwrap_or_else(|| ("Anonymous".to_string(), false)); + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.horizontal(|ui| { + w::avatar(ui, &handle, 36.0, 6); + ui.add_space(10.0); + ui.vertical(|ui| { + ui.label( + RichText::new(&handle) + .font(FontId::new(14.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.label( + RichText::new(if connected { + "synced · Tor" + } else { + "connecting…" + }) + .font(FontId::new(12.0, fonts::regular())) + .color(t.surface_text_mute), + ); + }); + }); }); + }, + ); + // Both bottom cards (node status + profile) open Settings. + if bottom.response.interact(Sense::click()).clicked() { + self.tab = Tab::Me; + } + }); + } + + /// Compact node status card: sync state dot, block height, connection. + fn node_card_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { + let t = theme::tokens(); + let height = wallet + .get_data() + .map(|d| d.info.last_confirmed_height) + .unwrap_or(0); + let synced = height > 0 && !wallet.syncing(); + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.horizontal(|ui| { + let (dot, _) = ui.allocate_exact_size(Vec2::splat(10.0), Sense::hover()); + ui.painter().circle_filled( + dot.center(), + 4.0, + if synced { t.pos } else { t.accent }, + ); + ui.add_space(8.0); + ui.vertical(|ui| { + ui.label( + RichText::new(if synced { "Node synced" } else { "Syncing…" }) + .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), + ); }); }); }); } - fn home_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + fn home_ui( + &mut self, + ui: &mut egui::Ui, + wallet: &Wallet, + cb: &dyn PlatformCallbacks, + wide: bool, + ) { let data = wallet.get_data(); ScrollArea::vertical() .auto_shrink([false; 2]) .show(ui, |ui| { - ui.add_space(48.0); + // Mobile header: wordmark left, avatar (opens settings) right. + if !wide { + ui.add_space(10.0); + ui.horizontal(|ui| { + widgets_logo(ui); + ui.add_space(8.0); + ui.label( + RichText::new("goblin") + .font(FontId::new(18.0, fonts::bold())) + .color(theme::tokens().text), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let handle = wallet + .nostr_service() + .map(|s| { + let id = s.identity.read(); + id.nip05 + .clone() + .map(|n| format!("@{}", n.split('@').next().unwrap_or(""))) + .unwrap_or_else(|| "N".to_string()) + }) + .unwrap_or_else(|| "N".to_string()); + if w::avatar(ui, &handle, 36.0, 6).clicked() { + self.tab = Tab::Me; + } + }); + }); + ui.add_space(28.0); + } else { + ui.add_space(48.0); + } let spendable = data .as_ref() .map(|d| d.info.amount_currently_spendable) @@ -374,36 +601,7 @@ impl GoblinWalletView { ui.add_space(24.0); // Recent peers strip. - let peers = recent_peers(wallet, 8); - if !peers.is_empty() { - w::kicker(ui, "Recent"); - ui.add_space(12.0); - ScrollArea::horizontal() - .id_salt("goblin_peers") - .auto_shrink([false, true]) - .show(ui, |ui| { - ui.horizontal(|ui| { - for (name, hue, npub) in &peers { - ui.vertical(|ui| { - let resp = w::avatar(ui, name, 48.0, *hue); - ui.add_space(6.0); - let short: String = name.chars().take(6).collect(); - ui.label( - RichText::new(short) - .font(FontId::new(12.0, fonts::medium())), - ); - if resp.clicked() { - let mut f = SendFlow::default(); - f.prefill_contact(name.clone(), npub.clone()); - self.send = Some(f); - } - }); - ui.add_space(12.0); - } - }); - }); - ui.add_space(20.0); - } + self.peers_strip_ui(ui, wallet, "goblin_peers_home"); // Recent activity. w::kicker(ui, "Activity"); @@ -424,6 +622,133 @@ impl GoblinWalletView { }); } + /// Horizontal recent-contacts strip; tapping one starts a prefilled send. + fn peers_strip_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, salt: &str) { + let peers = recent_peers(wallet, 8); + if peers.is_empty() { + return; + } + w::kicker(ui, "Recent"); + ui.add_space(12.0); + ScrollArea::horizontal() + .id_salt(salt.to_string()) + .auto_shrink([false, true]) + .show(ui, |ui| { + ui.horizontal(|ui| { + for (name, hue, npub) in &peers { + ui.vertical(|ui| { + let resp = w::avatar(ui, name, 48.0, *hue); + ui.add_space(6.0); + let short: String = name.chars().take(6).collect(); + ui.label(RichText::new(short).font(FontId::new(12.0, fonts::medium()))); + if resp.clicked() { + let mut f = SendFlow::default(); + f.prefill_contact(name.clone(), npub.clone()); + self.send = Some(f); + } + }); + ui.add_space(12.0); + } + }); + }); + ui.add_space(20.0); + } + + /// Pay tab: amount-first combined pay/request surface. + fn pay_ui(&mut self, ui: &mut egui::Ui, _wallet: &Wallet) { + let t = theme::tokens(); + ui.add_space(8.0); + ui.label( + RichText::new("Pay") + .font(FontId::new(28.0, fonts::bold())) + .color(t.text), + ); + + // Big centered amount. + let display = if self.pay_amount.is_empty() { + "0".to_string() + } else { + self.pay_amount.clone() + }; + let tall = ui.available_height() > 560.0; + ui.add_space(if tall { 56.0 } else { 24.0 }); + w::amount_text_centered(ui, &display, 76.0); + if let Some(rate) = crate::http::grin_usd_rate() { + if let Ok(grin) = display.parse::() { + ui.add_space(6.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(format!("≈ ${:.2}", grin * rate)) + .font(FontId::new(14.0, fonts::regular())) + .color(t.text_dim), + ); + }); + } + } + ui.add_space(if tall { 32.0 } else { 16.0 }); + + // Numpad on mobile, typed input on desktop. + 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), + ); + }); + } + ui.add_space(20.0); + + // Request | Pay actions, half width each. + let valid = grin_core::core::amount_from_hr_string(&self.pay_amount) + .map(|a| a > 0) + .unwrap_or(false); + 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, 56.0), + )), + |ui| { + if w::big_action(ui, "Request", true).clicked() && valid { + self.request_amount = Some(self.pay_amount.clone()); + self.pay_amount.clear(); + self.tab = Tab::Receive; + } + }, + ); + ui.add_space(10.0); + ui.scope_builder( + egui::UiBuilder::new().max_rect(egui::Rect::from_min_size( + ui.cursor().min, + Vec2::new(half, 56.0), + )), + |ui| { + if w::big_action(ui, "Pay", false).clicked() && valid { + let mut f = SendFlow::default(); + f.prefill_amount(self.pay_amount.clone()); + self.pay_amount.clear(); + self.send = Some(f); + } + }, + ); + }); + if !valid { + ui.add_space(8.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new("Enter an amount to pay or request") + .font(FontId::new(12.0, fonts::regular())) + .color(t.text_mute), + ); + }); + } + } + fn activity_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { ui.add_space(8.0); ui.label( @@ -433,6 +758,9 @@ impl GoblinWalletView { ); ui.add_space(12.0); + // Recent contacts strip (Cash App-style row above the feed). + self.peers_strip_ui(ui, wallet, "goblin_peers_activity"); + // Pending payment requests pinned on top. if let Some(service) = wallet.nostr_service() { let requests = service.store.pending_requests(); @@ -505,19 +833,19 @@ impl GoblinWalletView { ui.label( RichText::new(format!("{} requests", name)) .font(FontId::new(15.0, fonts::semibold())) - .color(t.text), + .color(t.surface_text), ); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 0.0; ui.label( RichText::new(w::amount_str(req.amount)) .font(FontId::new(15.0, fonts::mono_semibold())) - .color(t.text), + .color(t.surface_text), ); ui.label( RichText::new(w::TSU) .font(FontId::new(13.0, fonts::medium())) - .color(t.text_dim), + .color(t.surface_text_dim), ); }); }); @@ -599,19 +927,39 @@ impl GoblinWalletView { w::card(ui, |ui| { ui.vertical_centered(|ui| { // QR of the nostr handle (nostr: URI). + ui.add_space(12.0); let uri = format!("nostr:{}", npub); - crate::gui::views::QrCodeContent::new(uri, false).ui(ui, cb); - ui.add_space(8.0); + w::qr_code(ui, &uri, 220.0); + ui.add_space(14.0); ui.label( RichText::new(&handle) .font(FontId::new(18.0, fonts::bold())) - .color(t.text), - ); - ui.label( - RichText::new("Share your handle to get paid") - .font(FontId::new(13.0, fonts::regular())) - .color(t.text_dim), + .color(t.surface_text), ); + match &self.request_amount { + Some(amt) => { + ui.label( + RichText::new(format!( + "Requesting {}{} — share to get paid", + amt, + w::TSU + )) + .font(FontId::new(13.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.add_space(6.0); + if w::chip(ui, "Clear request", false).clicked() { + self.request_amount = None; + } + } + None => { + ui.label( + RichText::new("Share your handle to get paid") + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + } + } }); }); @@ -663,6 +1011,12 @@ impl GoblinWalletView { fn me_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { let t = theme::tokens(); + match self.settings_page { + SettingsPage::Node => return self.node_settings_ui(ui, wallet), + SettingsPage::Relays => return self.relays_ui(ui, wallet), + SettingsPage::Nips => return self.nips_ui(ui), + SettingsPage::Main => {} + } ui.add_space(8.0); ui.label( RichText::new("Settings") @@ -686,6 +1040,7 @@ impl GoblinWalletView { .unwrap_or_else(|| ("Anonymous".to_string(), String::new(), false)); w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); ui.horizontal(|ui| { w::avatar(ui, &handle, 56.0, 6); ui.add_space(14.0); @@ -693,8 +1048,16 @@ impl GoblinWalletView { ui.label( RichText::new(&handle) .font(FontId::new(17.0, fonts::bold())) - .color(t.text), + .color(t.surface_text), ); + if npub.len() > 18 { + // Standard truncation: head 12 chars … tail 6. + ui.label( + RichText::new(format!("{}…{}", &npub[..12], &npub[npub.len() - 6..])) + .font(FontId::new(12.0, fonts::mono())) + .color(t.surface_text_mute), + ); + } let status = if connected { "Connected over Tor" } else { @@ -703,7 +1066,7 @@ impl GoblinWalletView { ui.label( RichText::new(status) .font(FontId::new(13.0, fonts::regular())) - .color(t.text_dim), + .color(t.surface_text_dim), ); }); }); @@ -756,12 +1119,49 @@ impl GoblinWalletView { cb.copy_string_to_buffer(nsec); } } + // Encrypted backup file: the identity JSON as stored + // (NIP-49 ncryptsec inside), incl. username + history. + if settings_row_btn( + ui, + "Export identity backup (encrypted)", + crate::gui::icons::DOWNLOAD_SIMPLE, + ) { + if let Some(s) = wallet.nostr_service() { + let json = serde_json::to_string_pretty(&*s.identity.read()) + .unwrap_or_default(); + cb.copy_string_to_buffer(json); + } + } + if settings_row_danger( + ui, + "Rotate nostr key", + crate::gui::icons::ARROWS_CLOCKWISE, + ) && self.rotate.is_none() + { + self.rotate = Some(RotateState::default()); + } + if settings_row_btn( + ui, + "Import identity (nsec / backup)", + crate::gui::icons::KEY, + ) && self.import_nsec.is_none() + { + self.import_nsec = Some(ImportState::default()); + } } }); if self.claim.is_some() { ui.add_space(8.0); self.claim_ui(ui, wallet); } + if self.rotate.is_some() { + ui.add_space(8.0); + self.rotate_ui(ui, wallet, cb); + } + if self.import_nsec.is_some() { + ui.add_space(8.0); + self.import_nsec_ui(ui, wallet); + } ui.add_space(16.0); w::kicker(ui, "Appearance"); @@ -775,21 +1175,36 @@ impl GoblinWalletView { if settings_row_btn(ui, "Theme", theme_label) { cycle_theme(ui.ctx()); } - let density_label = match crate::AppConfig::density() { - crate::gui::theme::DensityKind::Compact => "Compact", - crate::gui::theme::DensityKind::Regular => "Regular", - crate::gui::theme::DensityKind::Comfy => "Comfy", - }; - if settings_row_btn(ui, "Density", density_label) { - cycle_density(); - } }); ui.add_space(16.0); + let mut open_relays = false; + let mut open_node = false; settings_group(ui, "Wallet", |ui| { settings_row(ui, "Display unit", "ツ (grin)"); - settings_row(ui, "Relays", &relay_summary(wallet)); + if settings_row_nav(ui, "Relays", &relay_summary(wallet)) { + open_relays = true; + } + if settings_row_nav(ui, "Node", &node_summary(wallet)) { + open_node = true; + } + if settings_row_btn(ui, "Lock wallet", crate::gui::icons::LOCK) { + wallet.close(); + } }); + if open_relays { + self.relay_edit = wallet + .nostr_service() + .map(|s| s.config.read().relays()) + .unwrap_or_default(); + self.relay_input.clear(); + self.settings_page = SettingsPage::Relays; + } + if open_node { + self.node_url_input.clear(); + self.node_secret_input.clear(); + self.settings_page = SettingsPage::Node; + } ui.add_space(16.0); w::kicker(ui, "Archive"); @@ -810,13 +1225,753 @@ impl GoblinWalletView { ui.add_space(16.0); settings_group(ui, "About", |ui| { + settings_row(ui, "Goblin", &format!("Build {}", crate::BUILD)); settings_row(ui, "Network", "Mimblewimble · no address on chain"); - settings_row(ui, "Version", env!("CARGO_PKG_VERSION")); }); + + ui.add_space(16.0); + let mut open_nips = false; + settings_group(ui, "Third party", |ui| { + if settings_row_nav(ui, "GRIM (upstream wallet)", crate::VERSION) { + open_url(ui, "https://github.com/ardocrat/grim"); + } + if settings_row_nav(ui, "Grin node", "5.4.0") { + open_url(ui, "https://github.com/mimblewimble/grin"); + } + if settings_row_nav(ui, "nostr-sdk", "0.44") { + open_url(ui, "https://github.com/rust-nostr/nostr"); + } + if settings_row_nav(ui, "arti (Tor)", "0.42") { + open_url(ui, "https://gitlab.torproject.org/tpo/core/arti"); + } + if settings_row_nav(ui, "egui", "0.33") { + open_url(ui, "https://github.com/emilk/egui"); + } + if settings_row_nav(ui, "NIPs", "05 · 17 · 44 · 49 · 59 · 98") { + open_nips = true; + } + }); + if open_nips { + self.settings_page = SettingsPage::Nips; + } ui.add_space(16.0); }); } + /// Back header for Settings sub-pages; returns true when back is tapped. + fn sub_header(&mut self, ui: &mut egui::Ui, title: &str) -> bool { + let t = theme::tokens(); + let mut back = false; + ui.add_space(8.0); + 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, + crate::gui::icons::ARROW_LEFT, + FontId::new(16.0, fonts::regular()), + theme::ink_for(t.surface2), + ); + back = resp.clicked(); + ui.add_space(12.0); + ui.label( + RichText::new(title) + .font(FontId::new(24.0, fonts::bold())) + .color(t.text), + ); + }); + ui.add_space(16.0); + back + } + + /// Node connection editor: pick integrated/external, add or remove nodes. + fn node_settings_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { + use crate::wallet::types::ConnectionMethod; + use crate::wallet::{ConnectionsConfig, ExternalConnection}; + let t = theme::tokens(); + if self.sub_header(ui, "Node") { + self.settings_page = SettingsPage::Main; + return; + } + ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + let live = wallet.get_current_connection(); + let saved = wallet.get_config().connection(); + settings_group(ui, "Connection", |ui| { + let integrated = matches!(saved, ConnectionMethod::Integrated); + let row = ui.horizontal(|ui| { + ui.label( + RichText::new("Integrated node") + .font(FontId::new(15.0, fonts::medium())) + .color(t.surface_text), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if integrated { + ui.label( + RichText::new(crate::gui::icons::CHECK) + .font(FontId::new(16.0, fonts::regular())) + .color(t.pos), + ); + } + }); + }); + ui.add_space(10.0); + if !integrated && row.response.interact(Sense::click()).clicked() { + wallet.update_connection(&ConnectionMethod::Integrated); + } + for conn in ConnectionsConfig::ext_conn_list() { + let active = + matches!(&saved, ConnectionMethod::External(id, _) if *id == conn.id); + let label = conn.url.replace("https://", "").replace("http://", ""); + let mut removed = false; + let row = ui.horizontal(|ui| { + ui.label( + RichText::new(&label) + .font(FontId::new(15.0, fonts::medium())) + .color(t.surface_text), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if active { + ui.label( + RichText::new(crate::gui::icons::CHECK) + .font(FontId::new(16.0, fonts::regular())) + .color(t.pos), + ); + } else { + let x = ui.label( + RichText::new(crate::gui::icons::X) + .font(FontId::new(15.0, fonts::regular())) + .color(t.surface_text_mute), + ); + if x.interact(Sense::click()).clicked() { + ConnectionsConfig::remove_ext_conn(conn.id); + removed = true; + } + } + }); + }); + ui.add_space(10.0); + if !removed && !active && row.response.interact(Sense::click()).clicked() { + wallet.update_connection(&ConnectionMethod::External( + conn.id, + conn.url.clone(), + )); + } + } + }); + if saved != live { + ui.add_space(8.0); + ui.label( + RichText::new("Applies after the wallet is locked and unlocked again.") + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + } + + ui.add_space(16.0); + settings_group(ui, "Add external node", |ui| { + ui.add( + egui::TextEdit::singleline(&mut self.node_url_input) + .hint_text("https://node.example.com:3413") + .desired_width(f32::INFINITY) + .text_color(t.surface_text) + .frame(false), + ); + ui.add_space(8.0); + ui.add( + egui::TextEdit::singleline(&mut self.node_secret_input) + .hint_text("API secret (optional)") + .desired_width(f32::INFINITY) + .text_color(t.surface_text) + .frame(false), + ); + }); + ui.add_space(10.0); + let url = self.node_url_input.trim().to_string(); + let valid = url.starts_with("http://") || url.starts_with("https://"); + if w::big_action(ui, "Add node", false).clicked() && valid { + let secret = { + let s = self.node_secret_input.trim(); + if s.is_empty() { + None + } else { + Some(s.to_string()) + } + }; + let conn = ExternalConnection::new(url, None, secret); + wallet + .update_connection(&ConnectionMethod::External(conn.id, conn.url.clone())); + ConnectionsConfig::add_ext_conn(conn); + self.node_url_input.clear(); + self.node_secret_input.clear(); + } + ui.add_space(16.0); + }); + } + + /// Relay list editor; saving restarts the nostr service live. + fn relays_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { + let t = theme::tokens(); + if self.sub_header(ui, "Relays") { + self.settings_page = SettingsPage::Main; + return; + } + ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + ui.label( + RichText::new( + "Payment messages are mirrored to every relay below; \ + one reachable relay is enough to receive.", + ) + .font(FontId::new(13.0, fonts::regular())) + .color(t.text_dim), + ); + ui.add_space(14.0); + settings_group(ui, "Your relays", |ui| { + let mut remove: Option = None; + let many = self.relay_edit.len() > 1; + for (i, relay) in self.relay_edit.iter().enumerate() { + ui.horizontal(|ui| { + ui.label( + RichText::new(relay) + .font(FontId::new(14.0, fonts::medium())) + .color(t.surface_text), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if many { + let x = ui.label( + RichText::new(crate::gui::icons::X) + .font(FontId::new(15.0, fonts::regular())) + .color(t.surface_text_mute), + ); + if x.interact(Sense::click()).clicked() { + remove = Some(i); + } + } + }); + }); + ui.add_space(10.0); + } + if let Some(i) = remove { + self.relay_edit.remove(i); + } + }); + + ui.add_space(16.0); + settings_group(ui, "Add relay", |ui| { + ui.add( + egui::TextEdit::singleline(&mut self.relay_input) + .hint_text("wss://relay.example.com") + .desired_width(f32::INFINITY) + .text_color(t.surface_text) + .frame(false), + ); + }); + ui.add_space(10.0); + let relay = self.relay_input.trim().to_string(); + let valid = relay.starts_with("wss://") || relay.starts_with("ws://"); + if w::big_action_on_card(ui, "Add relay").clicked() + && valid && !self.relay_edit.contains(&relay) + { + self.relay_edit.push(relay); + self.relay_input.clear(); + } + ui.add_space(10.0); + if w::big_action(ui, "Save & reconnect", false).clicked() { + if let Some(s) = wallet.nostr_service() { + { + let mut c = s.config.write(); + c.set_relays(self.relay_edit.clone()); + c.save(); + } + s.restart(wallet.clone()); + } + self.settings_page = SettingsPage::Main; + } + ui.add_space(16.0); + }); + } + + /// What-is-nostr explainer and tappable NIP reference list. + fn nips_ui(&mut self, ui: &mut egui::Ui) { + let t = theme::tokens(); + if self.sub_header(ui, "nostr & NIPs") { + self.settings_page = SettingsPage::Main; + return; + } + ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + ui.label( + RichText::new( + "Goblin speaks nostr — an open protocol of signed messages \ + passed through simple relay servers. Your wallet carries \ + its own nostr identity: a standalone random key, kept \ + deliberately independent of your funds and seed. Every \ + payment travels as an end-to-end encrypted direct message \ + between identities, with the slatepack riding inside.", + ) + .font(FontId::new(14.0, fonts::regular())) + .color(t.text_dim), + ); + ui.add_space(10.0); + ui.label( + RichText::new( + "goblin.st is Goblin's name service: claiming a username \ + publishes a name → key mapping there (NIP-05), so people \ + can pay @you instead of a long npub. The username is \ + public; payment contents never are. NIPs are the \ + protocol's building blocks — tap one to read the spec.", + ) + .font(FontId::new(14.0, fonts::regular())) + .color(t.text_dim), + ); + ui.add_space(16.0); + let nips = [ + ( + "05", + "Names", + "Maps @username@goblin.st to your key, so handles work like addresses.", + ), + ( + "17", + "Private messages", + "The encrypted DM envelope every payment travels in.", + ), + ( + "44", + "Encryption", + "The authenticated cipher used inside those messages.", + ), + ( + "49", + "Key encryption", + "How the secret key is stored at rest, locked by your password.", + ), + ( + "59", + "Gift wrap", + "Wraps messages so relays can't see who is talking to whom.", + ), + ( + "98", + "HTTP auth", + "Signs the username registration request to goblin.st.", + ), + ]; + for (num, title, blurb) in nips { + let resp = ui.scope(|ui| { + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.label( + RichText::new(format!("NIP-{} · {}", num, title)) + .font(FontId::new(14.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.add_space(2.0); + ui.label( + RichText::new(blurb) + .font(FontId::new(12.0, fonts::regular())) + .color(t.surface_text_dim), + ); + }); + }); + if resp.response.interact(Sense::click()).clicked() { + open_url( + ui, + &format!( + "https://github.com/nostr-protocol/nips/blob/master/{}.md", + num + ), + ); + } + ui.add_space(8.0); + } + ui.add_space(16.0); + }); + } + + /// Inline key-rotation flow: warning → typed RESET + password → result. + fn rotate_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet, cb: &dyn PlatformCallbacks) { + let t = theme::tokens(); + let rotate = self.rotate.as_mut().unwrap(); + // Poll the worker result. + if rotate.stage == 3 { + if let Some(res) = rotate.result.lock().unwrap().take() { + match res { + Ok(npub) => { + rotate.new_npub = npub; + rotate.stage = 4; + } + Err(e) => { + rotate.error = e; + rotate.stage = 5; + } + } + } + } + let mut close = false; + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + match rotate.stage { + 1 => { + ui.label( + RichText::new("Rotate nostr key") + .font(FontId::new(15.0, fonts::semibold())) + .color(t.neg), + ); + ui.add_space(6.0); + for line in [ + "• You get a brand-new RANDOM key; the old npub stops \ + receiving. There is no derivation chain between them.", + "• The new key is NOT recoverable from your seed — back \ + up the new nsec right after rotating.", + "• Your @username moves to the new key automatically.", + "• Payments still in flight to the old key WILL be \ + disrupted — wait for pending payments to finish first.", + "• Contacts who saved your npub directly must re-find \ + you via your @username.", + ] { + ui.label( + RichText::new(line) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(4.0); + } + ui.add_space(8.0); + 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::big_action_on_card(ui, "Cancel").clicked() { + close = true; + } + }, + ); + 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::big_action(ui, "Continue", false).clicked() { + rotate.stage = 2; + } + }, + ); + }); + } + 2 => { + ui.label( + RichText::new("Final confirmation") + .font(FontId::new(15.0, fonts::semibold())) + .color(t.neg), + ); + ui.add_space(6.0); + ui.label( + RichText::new( + "This cannot be undone from the app. Type RESET and \ + enter your wallet password to rotate.", + ) + .font(FontId::new(13.0, fonts::regular())) + .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), + ); + 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), + ); + ui.add_space(10.0); + let armed = rotate.reset_input.trim() == "RESET" && !rotate.password.is_empty(); + 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::big_action_on_card(ui, "Cancel").clicked() { + close = true; + } + }, + ); + 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| { + ui.add_enabled_ui(armed, |ui| { + if w::big_action(ui, "Rotate key", false).clicked() { + rotate.stage = 3; + let slot = rotate.result.clone(); + let password = std::mem::take(&mut rotate.password); + rotate.reset_input.clear(); + let wallet = wallet.clone(); + std::thread::spawn(move || { + let res = wallet.rotate_nostr_identity(password); + *slot.lock().unwrap() = Some(res); + }); + } + }); + }, + ); + }); + } + 3 => { + ui.horizontal(|ui| { + View::small_loading_spinner(ui); + ui.add_space(8.0); + ui.label( + RichText::new("Rotating key…") + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + }); + ui.ctx().request_repaint(); + } + 4 => { + ui.label( + RichText::new("Key rotated") + .font(FontId::new(15.0, fonts::semibold())) + .color(t.pos), + ); + ui.add_space(4.0); + let npub = &rotate.new_npub; + let short = if npub.len() > 18 { + format!("{}…{}", &npub[..12], &npub[npub.len() - 6..]) + } else { + npub.clone() + }; + ui.label( + RichText::new(format!("New npub: {}", short)) + .font(FontId::new(13.0, fonts::mono())) + .color(t.surface_text_dim), + ); + ui.add_space(6.0); + ui.label( + RichText::new( + "Back up the NEW secret key now — your seed cannot \ + recover it.", + ) + .font(FontId::new(13.0, fonts::semibold())) + .color(t.neg), + ); + ui.add_space(10.0); + if w::big_action_on_card(ui, "Copy new nsec backup").clicked() { + if let Some(nsec) = wallet.nostr_service().and_then(|s| s.nsec()) { + cb.copy_string_to_buffer(nsec); + } + } + ui.add_space(8.0); + if w::big_action(ui, "Done", false).clicked() { + close = true; + } + } + _ => { + ui.label( + RichText::new("Rotation failed") + .font(FontId::new(15.0, fonts::semibold())) + .color(t.neg), + ); + ui.add_space(4.0); + ui.label( + RichText::new(&rotate.error) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(10.0); + if w::big_action_on_card(ui, "Close").clicked() { + close = true; + } + } + } + }); + if close { + self.rotate = None; + } + } + + /// Inline nsec-import flow: replaces the identity with an imported key. + fn import_nsec_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { + let t = theme::tokens(); + let import = self.import_nsec.as_mut().unwrap(); + if import.stage == 3 { + if let Some(res) = import.result.lock().unwrap().take() { + match res { + Ok(npub) => { + import.new_npub = npub; + import.stage = 4; + } + Err(e) => { + import.error = e; + import.stage = 5; + } + } + } + } + let mut close = false; + w::card(ui, |ui| { + ui.set_min_width(ui.available_width()); + match import.stage { + 1 => { + ui.label( + RichText::new("Import identity") + .font(FontId::new(15.0, fonts::semibold())) + .color(t.surface_text), + ); + ui.add_space(6.0); + ui.label( + RichText::new( + "Replaces this wallet's nostr identity — paste a \ + bare nsec or an exported identity backup (the \ + backup also restores your username and history). \ + Back up the current key first if you still need it.", + ) + .font(FontId::new(13.0, fonts::regular())) + .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), + ); + 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), + ); + ui.add_space(10.0); + let pasted = import.nsec.trim(); + let armed = (pasted.starts_with("nsec1") || pasted.starts_with('{')) + && !import.password.is_empty(); + 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::big_action_on_card(ui, "Cancel").clicked() { + close = true; + } + }, + ); + 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| { + ui.add_enabled_ui(armed, |ui| { + if w::big_action(ui, "Import", false).clicked() { + import.stage = 3; + let slot = import.result.clone(); + let nsec = std::mem::take(&mut import.nsec); + let password = std::mem::take(&mut import.password); + let wallet = wallet.clone(); + std::thread::spawn(move || { + let res = wallet.import_nostr_identity(nsec, password); + *slot.lock().unwrap() = Some(res); + }); + } + }); + }, + ); + }); + } + 3 => { + ui.horizontal(|ui| { + View::small_loading_spinner(ui); + ui.add_space(8.0); + ui.label( + RichText::new("Importing…") + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + }); + ui.ctx().request_repaint(); + } + 4 => { + ui.label( + RichText::new("Identity replaced") + .font(FontId::new(15.0, fonts::semibold())) + .color(t.pos), + ); + ui.add_space(4.0); + let npub = &import.new_npub; + let short = if npub.len() > 18 { + format!("{}…{}", &npub[..12], &npub[npub.len() - 6..]) + } else { + npub.clone() + }; + ui.label( + RichText::new(format!("Now using: {}", short)) + .font(FontId::new(13.0, fonts::mono())) + .color(t.surface_text_dim), + ); + ui.add_space(10.0); + if w::big_action(ui, "Done", false).clicked() { + close = true; + } + } + _ => { + ui.label( + RichText::new("Import failed") + .font(FontId::new(15.0, fonts::semibold())) + .color(t.neg), + ); + ui.add_space(4.0); + ui.label( + RichText::new(&import.error) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + ui.add_space(10.0); + if w::big_action_on_card(ui, "Close").clicked() { + close = true; + } + } + } + }); + if close { + self.import_nsec = None; + } + } + /// Inline username-claim widget (availability check + register over Tor). fn claim_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) { let t = theme::tokens(); @@ -852,14 +2007,19 @@ impl GoblinWalletView { ui.label( RichText::new("Pick a username") .font(FontId::new(15.0, fonts::semibold())) - .color(t.text), + .color(t.surface_text), ); ui.add_space(8.0); ui.horizontal(|ui| { - ui.label(RichText::new("@").font(FontId::new(16.0, fonts::semibold()))); + ui.label( + RichText::new("@") + .font(FontId::new(16.0, fonts::semibold())) + .color(t.surface_text), + ); let edit = egui::TextEdit::singleline(&mut claim.input) .hint_text("yourname") .desired_width(ui.available_width() - 20.0) + .text_color(t.surface_text) .frame(false); if ui.add(edit).changed() { claim.available = None; @@ -870,7 +2030,7 @@ impl GoblinWalletView { ui.label( RichText::new("Shown as @you. Public on goblin.st. Payments stay encrypted.") .font(FontId::new(12.0, fonts::regular())) - .color(t.text_mute), + .color(t.surface_text_mute), ); if let Some(msg) = &claim.message { ui.add_space(6.0); @@ -891,7 +2051,7 @@ impl GoblinWalletView { ui.horizontal(|ui| { View::small_loading_spinner(ui); ui.add_space(8.0); - ui.label(RichText::new("Working…").color(t.text_dim)); + ui.label(RichText::new("Working…").color(t.surface_text_dim)); }); ui.ctx().request_repaint(); } else { @@ -904,7 +2064,7 @@ impl GoblinWalletView { )), |ui| { ui.add_enabled_ui(valid, |ui| { - if w::big_action(ui, "Check", true).clicked() { + if w::big_action_on_card(ui, "Check").clicked() { start_claim_check(claim, &name, wallet); } }); @@ -996,7 +2156,7 @@ fn start_claim_register(claim: &mut ClaimState, name: &str, wallet: &Wallet) { pub fn widgets_logo(ui: &mut egui::Ui) { let size = 22.0; let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); - let img = egui::Image::new(egui::include_image!("../../../../img/goblin-mask-64.png")) + let img = egui::Image::new(egui::include_image!("../../../../img/goblin-logo2-256.png")) .tint(theme::tokens().text) .fit_to_exact_size(Vec2::splat(size)); img.paint_at(ui, rect); @@ -1032,13 +2192,13 @@ fn settings_row(ui: &mut egui::Ui, label: &str, value: &str) { ui.label( RichText::new(label) .font(FontId::new(15.0, fonts::medium())) - .color(t.text), + .color(t.surface_text), ); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { ui.label( RichText::new(value) .font(FontId::new(13.0, fonts::regular())) - .color(t.text_dim), + .color(t.surface_text_dim), ); }); }); @@ -1048,17 +2208,17 @@ fn settings_row(ui: &mut egui::Ui, label: &str, value: &str) { fn settings_row_btn(ui: &mut egui::Ui, label: &str, icon: &str) -> bool { let t = theme::tokens(); let mut clicked = false; - ui.horizontal(|ui| { + let row = ui.horizontal(|ui| { ui.label( RichText::new(label) .font(FontId::new(15.0, fonts::medium())) - .color(t.text), + .color(t.surface_text), ); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { let resp = ui.label( RichText::new(icon) .font(FontId::new(18.0, fonts::regular())) - .color(t.text_dim), + .color(t.surface_text_dim), ); if resp.interact(Sense::click()).clicked() { clicked = true; @@ -1066,7 +2226,61 @@ fn settings_row_btn(ui: &mut egui::Ui, label: &str, icon: &str) -> bool { }); }); ui.add_space(10.0); - clicked + // The whole row is tappable, not just the trailing value/icon. + clicked || row.response.interact(Sense::click()).clicked() +} + +/// A danger-styled settings row button (whole row taps). +fn settings_row_danger(ui: &mut egui::Ui, label: &str, icon: &str) -> bool { + let t = theme::tokens(); + let row = ui.horizontal(|ui| { + ui.label( + RichText::new(label) + .font(FontId::new(15.0, fonts::medium())) + .color(t.neg), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.label( + RichText::new(icon) + .font(FontId::new(18.0, fonts::regular())) + .color(t.neg), + ); + }); + }); + ui.add_space(10.0); + row.response.interact(Sense::click()).clicked() +} + +/// A settings row that navigates somewhere: value + chevron, whole row taps. +fn settings_row_nav(ui: &mut egui::Ui, label: &str, value: &str) -> bool { + let t = theme::tokens(); + let row = ui.horizontal(|ui| { + ui.label( + RichText::new(label) + .font(FontId::new(15.0, fonts::medium())) + .color(t.surface_text), + ); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.label( + RichText::new(crate::gui::icons::CARET_RIGHT) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_mute), + ); + ui.add_space(4.0); + ui.label( + RichText::new(value) + .font(FontId::new(13.0, fonts::regular())) + .color(t.surface_text_dim), + ); + }); + }); + ui.add_space(10.0); + row.response.interact(Sense::click()).clicked() +} + +/// Open a URL in the system browser. +fn open_url(ui: &egui::Ui, url: &str) { + ui.ctx().open_url(egui::OpenUrl::new_tab(url)); } fn approve_button(ui: &mut egui::Ui) -> bool { @@ -1102,16 +2316,6 @@ fn cycle_theme(ctx: &egui::Context) { } /// Cycle the density Comfy → Regular → Compact → Comfy. -fn cycle_density() { - use crate::gui::theme::DensityKind; - let next = match crate::AppConfig::density() { - DensityKind::Comfy => DensityKind::Regular, - DensityKind::Regular => DensityKind::Compact, - DensityKind::Compact => DensityKind::Comfy, - }; - crate::AppConfig::set_density(next); -} - /// Cycle the incoming-payment accept policy Anyone → Contacts → Ask → Anyone. fn cycle_accept_policy(wallet: &Wallet) { use crate::nostr::config::AcceptPolicy; @@ -1140,6 +2344,40 @@ fn relay_summary(wallet: &Wallet) -> String { } /// Compute a fiat preview line for the balance, when a rate is available. +/// One-line node summary: "Block 1,847,221 · main.gri.mw · Tor". +fn node_summary(wallet: &Wallet) -> String { + let height = wallet + .get_data() + .map(|d| d.info.last_confirmed_height) + .unwrap_or(0); + let conn = match wallet.get_current_connection() { + crate::wallet::types::ConnectionMethod::Integrated => "integrated node".to_string(), + crate::wallet::types::ConnectionMethod::External(_, url) => url + .replace("https://", "") + .replace("http://", "") + .trim_end_matches('/') + .to_string(), + }; + if height == 0 { + format!("{} · syncing", conn) + } else { + format!("Block {} · {}", fmt_thousands(height), conn) + } +} + +/// Format a number with thousands separators. +fn fmt_thousands(n: u64) -> String { + let s = n.to_string(); + let mut out = String::with_capacity(s.len() + s.len() / 3); + for (i, c) in s.chars().enumerate() { + if i > 0 && (s.len() - i) % 3 == 0 { + out.push(','); + } + out.push(c); + } + out +} + fn fiat_line(data: &Option) -> Option { let _ = data; // Fiat rate provider is wired in P7; hide the line until a rate is available. diff --git a/src/gui/views/goblin/send.rs b/src/gui/views/goblin/send.rs index da1105b5..a6e0c4bc 100644 --- a/src/gui/views/goblin/send.rs +++ b/src/gui/views/goblin/send.rs @@ -90,6 +90,13 @@ impl SendFlow { self.stage = Stage::Amount; } + /// Pre-fill the amount (Pay tab, amount-first): the flow starts at the + /// recipient picker and jumps straight to review once one is resolved. + pub fn prefill_amount(&mut self, amount: String) { + self.amount = amount; + 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(); @@ -162,28 +169,35 @@ impl SendFlow { return true; } - // Search field. + // Search field: filled rounded box per the design. let mut search = self.search.clone(); - ui.horizontal(|ui| { - ui.label( - RichText::new(MAGNIFYING_GLASS) - .font(FontId::new(18.0, fonts::regular())) - .color(t.text_mute), - ); - ui.add_space(8.0); - let edit = egui::TextEdit::singleline(&mut search) - .hint_text("@handle, npub, or name") - .desired_width(ui.available_width()) - .frame(false); - ui.add(edit); + egui::Frame { + fill: t.surface2, + corner_radius: eframe::epaint::CornerRadius::same(14), + inner_margin: egui::Margin::symmetric(14, 13), + ..Default::default() + } + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + RichText::new(MAGNIFYING_GLASS) + .font(FontId::new(18.0, fonts::regular())) + .color(t.surface_text_mute), + ); + ui.add_space(8.0); + let edit = egui::TextEdit::singleline(&mut search) + .hint_text("@handle, npub, or name") + .desired_width(ui.available_width()) + .text_color(t.surface_text) + .frame(false); + ui.add(edit); + }); }); if search != self.search { self.search = search; self.error = None; } - ui.add_space(6.0); - View::horizontal_line(ui, t.line); - ui.add_space(12.0); + ui.add_space(14.0); // Poll the async resolution result (set by a worker thread). self.poll_resolution(wallet); @@ -316,7 +330,12 @@ impl SendFlow { npub: hex.to_string(), hue, }); - self.stage = Stage::Amount; + // Amount-first (Pay tab): the amount is already set, go straight + // to review; otherwise continue to amount entry. + let preset = amount_from_hr_string(&self.amount) + .map(|a| a > 0) + .unwrap_or(false); + self.stage = if preset { Stage::Review } else { Stage::Amount }; } fn amount_ui(&mut self, ui: &mut egui::Ui, wallet: &Wallet) -> bool { @@ -327,17 +346,23 @@ impl SendFlow { } let recipient = self.recipient.clone().unwrap(); - // Recipient chip. - ui.vertical_centered(|ui| { - ui.horizontal(|ui| { - w::avatar(ui, &recipient.name, 28.0, recipient.hue); - ui.add_space(8.0); - ui.label( - RichText::new(format!("To {}", recipient.name)) - .font(FontId::new(14.0, fonts::semibold())) - .color(t.text), - ); - }); + // Recipient chip, centered per the design. + let name_label = format!("To {}", recipient.name); + let name_galley = ui.painter().layout_no_wrap( + name_label.clone(), + FontId::new(14.0, fonts::semibold()), + t.text, + ); + let chip_w = 28.0 + 8.0 + name_galley.size().x; + ui.horizontal(|ui| { + ui.add_space(((ui.available_width() - chip_w) / 2.0).max(0.0)); + w::avatar(ui, &recipient.name, 28.0, recipient.hue); + ui.add_space(8.0); + ui.label( + RichText::new(name_label) + .font(FontId::new(14.0, fonts::semibold())) + .color(t.text), + ); }); ui.add_space(20.0); @@ -347,26 +372,26 @@ impl SendFlow { } else { self.amount.clone() }; - ui.vertical_centered(|ui| { - w::amount_text(ui, &display, 64.0); - if let Some(rate) = crate::http::grin_usd_rate() { - if let Ok(grin) = display.parse::() { - ui.add_space(6.0); + w::amount_text_centered(ui, &display, 64.0); + if let Some(rate) = crate::http::grin_usd_rate() { + if let Ok(grin) = display.parse::() { + ui.add_space(6.0); + ui.vertical_centered(|ui| { ui.label( RichText::new(format!("≈ ${:.2}", grin * rate)) .font(FontId::new(14.0, fonts::regular())) .color(t.text_dim), ); - } + }); } - }); + } ui.add_space(16.0); // Quick chips. ui.horizontal(|ui| { ui.add_space((ui.available_width() - 220.0).max(0.0) / 2.0); for v in ["1", "10", "100", "Max"] { - if w::chip(ui, v, false).clicked() { + if w::chip_outline(ui, v).clicked() { if v == "Max" { let max = wallet .get_data() @@ -389,12 +414,13 @@ impl SendFlow { ui.label( RichText::new("Note") .font(FontId::new(14.0, fonts::regular())) - .color(t.text_dim), + .color(t.surface_text_dim), ); ui.add_space(8.0); let edit = egui::TextEdit::singleline(&mut self.note) .hint_text("Add a note…") .desired_width(ui.available_width()) + .text_color(t.surface_text) .frame(false); note_focused = ui.add(edit).has_focus(); }); @@ -407,27 +433,7 @@ impl SendFlow { } else if !note_focused { // Only consume keystrokes for the amount when the note field is // not focused, so typing a note doesn't also edit the amount. - ui.input(|i| { - for ev in &i.events { - if let egui::Event::Text(txt) = ev { - for ch in txt.chars() { - if ch.is_ascii_digit() { - w::apply_key(&mut self.amount, &ch.to_string()); - } else if ch == '.' { - w::apply_key(&mut self.amount, "."); - } - } - } - if let egui::Event::Key { - key: egui::Key::Backspace, - pressed: true, - .. - } = ev - { - w::apply_key(&mut self.amount, "<"); - } - } - }); + w::amount_typed_input(ui, &mut self.amount); } ui.add_space(8.0); @@ -452,19 +458,27 @@ impl SendFlow { let amount = self.amount.clone(); w::card(ui, |ui| { - ui.vertical_centered(|ui| { - ui.horizontal(|ui| { - w::avatar(ui, &recipient.name, 36.0, recipient.hue); - ui.add_space(8.0); - ui.label( - RichText::new(format!("You're sending {}", recipient.name)) - .font(FontId::new(14.0, fonts::regular())) - .color(t.text_dim), - ); - }); + ui.add_space(8.0); + let label = format!("You're sending {}", recipient.name); + let galley = ui.painter().layout_no_wrap( + label.clone(), + FontId::new(14.0, fonts::regular()), + t.text_dim, + ); + let row_w = 36.0 + 8.0 + galley.size().x; + ui.horizontal(|ui| { + ui.add_space(((ui.available_width() - row_w) / 2.0).max(0.0)); + w::avatar(ui, &recipient.name, 36.0, recipient.hue); ui.add_space(8.0); - w::amount_text(ui, &amount, 48.0); + ui.label( + RichText::new(label) + .font(FontId::new(14.0, fonts::regular())) + .color(t.surface_text_dim), + ); }); + ui.add_space(8.0); + w::amount_text_centered_ink(ui, &amount, 48.0, t.surface_text, t.surface_text_dim); + ui.add_space(8.0); }); ui.add_space(16.0); diff --git a/src/gui/views/goblin/widgets.rs b/src/gui/views/goblin/widgets.rs index e9e1932b..2e35b485 100644 --- a/src/gui/views/goblin/widgets.rs +++ b/src/gui/views/goblin/widgets.rs @@ -47,14 +47,15 @@ pub fn avatar(ui: &mut Ui, name: &str, size: f32, hue: usize) -> Response { resp } -/// Draw a balance/amount: big mono number + smaller ツ mark, tight. +/// Draw a balance/amount: big bold number + smaller ツ mark, tight. +/// Geist (sans) per the design; mono is reserved for kernel/block ids. pub fn amount_text(ui: &mut Ui, value: &str, size: f32) { let t = theme::tokens(); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 0.0; ui.label( RichText::new(value) - .font(FontId::new(size, fonts::mono_semibold())) + .font(FontId::new(size, fonts::bold())) .color(t.text), ); ui.add_space(1.0); @@ -66,6 +67,46 @@ pub fn amount_text(ui: &mut Ui, value: &str, size: f32) { }); } +/// Like [`amount_text`] but centered in the available width. +pub fn amount_text_centered(ui: &mut Ui, value: &str, size: f32) { + let t = theme::tokens(); + amount_text_centered_ink(ui, value, size, t.text, t.text_dim); +} + +/// Centered amount with explicit inks, for drawing on card surfaces. +pub fn amount_text_centered_ink( + ui: &mut Ui, + value: &str, + size: f32, + num_ink: Color32, + mark_ink: Color32, +) { + let num = + ui.painter() + .layout_no_wrap(value.to_string(), FontId::new(size, fonts::bold()), num_ink); + let mark = ui.painter().layout_no_wrap( + TSU.to_string(), + FontId::new(size * 0.4, fonts::medium()), + mark_ink, + ); + let total = num.size().x + 1.0 + mark.size().x; + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + ui.add_space(((ui.available_width() - total) / 2.0).max(0.0)); + ui.label( + RichText::new(value) + .font(FontId::new(size, fonts::bold())) + .color(num_ink), + ); + ui.add_space(1.0); + ui.label( + RichText::new(TSU) + .font(FontId::new(size * 0.4, fonts::medium())) + .color(mark_ink), + ); + }); +} + /// An uppercase letterspaced kicker label. pub fn kicker(ui: &mut Ui, text: &str) { let t = theme::tokens(); @@ -108,13 +149,37 @@ pub fn big_action(ui: &mut Ui, label: &str, secondary: bool) -> Response { resp } +/// Secondary big action drawn on a card surface: same shape as +/// [`big_action`], but the label uses on-surface text so it stays readable +/// on the yellow theme's dark cards. +pub fn big_action_on_card(ui: &mut Ui, label: &str) -> Response { + let t = theme::tokens(); + let desired = Vec2::new(ui.available_width(), 56.0); + let (rect, resp) = ui.allocate_exact_size(desired, Sense::click()); + ui.painter().rect( + rect, + CornerRadius::same(14), + Color32::TRANSPARENT, + Stroke::new(1.5, t.line), + egui::StrokeKind::Inside, + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + label, + FontId::new(17.0, fonts::semibold()), + t.surface_text, + ); + resp +} + /// A pill/chip; returns the click response. `active` paints it inverted. pub fn chip(ui: &mut Ui, label: &str, active: bool) -> Response { let t = theme::tokens(); let galley = ui.painter().layout_no_wrap( label.to_string(), FontId::new(13.0, fonts::semibold()), - if active { t.bg } else { t.text }, + if active { t.bg } else { t.surface_text }, ); let pad = Vec2::new(14.0, 8.0); let size = galley.size() + pad * 2.0; @@ -130,11 +195,75 @@ pub fn chip(ui: &mut Ui, label: &str, active: bool) -> Response { ui.painter().galley( rect.center() - galley.size() / 2.0, galley, - if active { t.bg } else { t.text }, + if active { t.bg } else { t.surface_text }, ); resp } +/// An outline pill chip (transparent fill, line border) per the design's +/// amount quick-select row. +pub fn chip_outline(ui: &mut Ui, label: &str) -> Response { + let t = theme::tokens(); + let galley = ui.painter().layout_no_wrap( + label.to_string(), + FontId::new(13.0, fonts::semibold()), + t.text, + ); + let pad = Vec2::new(14.0, 8.0); + let size = galley.size() + pad * 2.0; + let (rect, resp) = ui.allocate_exact_size(size, Sense::click()); + ui.painter().rect( + rect, + CornerRadius::same(255), + Color32::TRANSPARENT, + Stroke::new(1.0, t.line), + egui::StrokeKind::Inside, + ); + ui.painter() + .galley(rect.center() - galley.size() / 2.0, galley, t.text); + 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. +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()); + // High error correction tolerates the center mark covering modules. + let Ok(qr) = qrcodegen::QrCode::encode_text(text, qrcodegen::QrCodeEcc::High) else { + return; + }; + let n = qr.size(); + let cell = size / n as f32; + let radius = (cell * 0.3).max(1.0) as u8; + 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)), + CornerRadius::same(radius), + t.surface_text, + ); + } + } + } + // Goblin mark on a surface backing square in the center. + let backing = size * 0.26; + 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, + ); + 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) + .fit_to_exact_size(m_rect.size()) + .paint_at(ui, m_rect); +} + /// 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(); @@ -295,14 +424,15 @@ pub fn send_receive(ui: &mut Ui) -> (bool, bool) { send = resp_s.clicked(); ui.add_space(10.0); let (rr, resp_r) = ui.allocate_exact_size(Vec2::new(w, h), Sense::click()); + let r_fill = if resp_r.hovered() { + t.hover + } else { + t.surface2 + }; ui.painter().rect( rr, CornerRadius::same(14), - if resp_r.hovered() { - t.hover - } else { - t.surface2 - }, + r_fill, Stroke::NONE, egui::StrokeKind::Inside, ); @@ -311,7 +441,7 @@ pub fn send_receive(ui: &mut Ui) -> (bool, bool) { egui::Align2::CENTER_CENTER, format!("{} Receive", crate::gui::icons::ARROW_DOWN), FontId::new(16.0, fonts::semibold()), - t.text, + theme::ink_for(r_fill), ); receive = resp_r.clicked(); }); @@ -360,6 +490,32 @@ pub fn numpad(ui: &mut Ui, amount: &mut String) -> bool { } /// Apply a numpad key to the amount string with validation. +/// Apply typed keyboard events (digits, '.', backspace) to an amount string, +/// for desktop where the on-screen numpad is hidden. +pub fn amount_typed_input(ui: &Ui, amount: &mut String) { + ui.input(|i| { + for ev in &i.events { + if let egui::Event::Text(txt) = ev { + for ch in txt.chars() { + if ch.is_ascii_digit() { + apply_key(amount, &ch.to_string()); + } else if ch == '.' { + apply_key(amount, "."); + } + } + } + if let egui::Event::Key { + key: egui::Key::Backspace, + pressed: true, + .. + } = ev + { + apply_key(amount, "<"); + } + } + }); +} + pub fn apply_key(amount: &mut String, key: &str) { match key { "<" => { @@ -396,19 +552,24 @@ pub fn fill_bg(ui: &Ui, color: Color32) { } /// Center a fixed-width column for narrow content on wide screens. +/// Hands the child the full remaining height: wrapping in `horizontal()` +/// would start the row a single line tall, so a `ScrollArea` inside would +/// clip everything below the first widget. pub fn centered_column(ui: &mut Ui, width: f32, add: impl FnOnce(&mut Ui) -> R) -> R { let avail = ui.available_width(); let w = width.min(avail); let margin = ((avail - w) / 2.0).max(0.0); - let mut result = None; - ui.horizontal(|ui| { - ui.add_space(margin); - ui.vertical(|ui| { - ui.set_width(w); - result = Some(add(ui)); - }); - }); - result.unwrap() + let mut rect = ui.available_rect_before_wrap(); + rect.min.x += margin; + rect.max.x = rect.min.x + w; + let mut child = ui.new_child( + egui::UiBuilder::new() + .max_rect(rect) + .layout(Layout::top_down(Align::Min)), + ); + let result = add(&mut child); + ui.allocate_rect(child.min_rect(), Sense::hover()); + result } /// Hold-to-send button: fills over `hold_secs`; returns true once on completion. @@ -468,7 +629,7 @@ impl HoldToSend { if self.progress > 0.5 { t.accent_ink } else { - t.text + theme::ink_for(t.surface2) }, ); if self.progress >= 1.0 { diff --git a/src/gui/views/views.rs b/src/gui/views/views.rs index a9b4e051..31b42174 100644 --- a/src/gui/views/views.rs +++ b/src/gui/views/views.rs @@ -26,7 +26,6 @@ use egui_extras::image::load_svg_bytes_with_size; use lazy_static::lazy_static; use std::sync::atomic::{AtomicI32, Ordering}; -use crate::AppConfig; use crate::gui::Colors; use crate::gui::icons::{CHECK_FAT, CHECK_SQUARE, SQUARE}; use crate::gui::views::types::LinePosition; @@ -694,27 +693,25 @@ impl View { /// Draw application logo image with name and version. pub fn app_logo_name_version(ui: &mut egui::Ui) { ui.add_space(-1.0); - let logo = if AppConfig::dark_theme().unwrap_or(false) { - egui::include_image!("../../../img/logo_light.png") - } else { - egui::include_image!("../../../img/logo.png") - }; + // Goblin mark (white master, tinted for the current theme). + let logo = egui::include_image!("../../../img/goblin-logo2-256.png"); // Show application logo and name. ui.scope(|ui| { ui.set_opacity(0.9); egui::Image::new(logo) - .fit_to_exact_size(egui::vec2(182.0, 182.0)) + .tint(Colors::white_or_black(true)) + .fit_to_exact_size(egui::vec2(150.0, 150.0)) .ui(ui); }); - ui.add_space(-11.0); + ui.add_space(8.0); ui.label( - RichText::new("GRIM") + RichText::new("GOBLIN") .size(24.0) .color(Colors::white_or_black(true)), ); ui.add_space(-2.0); ui.label( - RichText::new(crate::VERSION) + RichText::new(format!("Build {}", crate::BUILD)) .size(16.0) .color(Colors::title(false)), ); diff --git a/src/gui/views/wallets/content.rs b/src/gui/views/wallets/content.rs index 90d40d5d..90aab255 100644 --- a/src/gui/views/wallets/content.rs +++ b/src/gui/views/wallets/content.rs @@ -201,8 +201,11 @@ impl ContentContainer for WalletsContent { || self.wallets.list().is_empty() || (showing_wallet && (!dual_panel || !AppConfig::show_wallets_at_dual_panel())); - // Show title panel. - self.title_ui(ui, dual_panel, cb); + // 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 { + self.title_ui(ui, dual_panel, cb); + } egui::SidePanel::right("wallet_panel") .resizable(false) diff --git a/src/http/price.rs b/src/http/price.rs index 469b47ee..c26b9ab9 100644 --- a/src/http/price.rs +++ b/src/http/price.rs @@ -29,8 +29,13 @@ lazy_static! { static ref RATE: RwLock> = RwLock::new(None); static ref FETCHING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + static ref LAST_TRY: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0); } +/// Minimum delay between fetch attempts, so a failing fetch (e.g. Tor still +/// bootstrapping) does not respawn a thread every frame. +const RETRY_SECS: i64 = 30; + fn now() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -58,9 +63,14 @@ pub fn grin_usd_rate() -> Option { /// Spawn a background refresh over Tor (deduplicated). fn trigger_refresh() { use std::sync::atomic::Ordering; + let t = now(); + if t - LAST_TRY.load(Ordering::SeqCst) < RETRY_SECS { + return; + } if FETCHING.swap(true, Ordering::SeqCst) { return; } + LAST_TRY.store(t, Ordering::SeqCst); std::thread::spawn(|| { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() @@ -80,7 +90,18 @@ fn trigger_refresh() { async fn fetch_rate() -> Option { let url = "https://api.coingecko.com/api/v3/simple/price?ids=grin&vs_currencies=usd".to_string(); - let body = Tor::http_request("GET", url, None, vec![]).await?; - let doc: serde_json::Value = serde_json::from_str(&body).ok()?; - doc.get("grin")?.get("usd")?.as_f64() + // CoinGecko rejects requests without a User-Agent (403). A static, + // non-identifying UA is fine over Tor. + let headers = vec![("User-Agent".to_string(), "goblin-wallet".to_string())]; + let body = Tor::http_request("GET", url, None, headers).await?; + let parsed: Option = serde_json::from_str::(&body) + .ok() + .and_then(|doc| doc.get("grin")?.get("usd")?.as_f64()); + if parsed.is_none() { + log::warn!( + "price: unexpected response from rate API (Tor exit blocked?): {}", + body.chars().take(120).collect::() + ); + } + parsed } diff --git a/src/lib.rs b/src/lib.rs index 423ad220..7f4835eb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,8 +42,12 @@ mod settings; mod tor; mod wallet; +/// Upstream GRIM version the fork is based on (third-party credit). pub const VERSION: &str = env!("CARGO_PKG_VERSION"); +/// Goblin build number: commits on top of the GRIM base (see build.rs). +pub const BUILD: &str = env!("GOBLIN_BUILD"); + /// Android platform entry point. #[allow(dead_code)] #[cfg(target_os = "android")] diff --git a/src/nostr/identity.rs b/src/nostr/identity.rs index 33d861ab..9d6f5543 100644 --- a/src/nostr/identity.rs +++ b/src/nostr/identity.rs @@ -26,10 +26,14 @@ use std::path::PathBuf; /// Where the keys came from. #[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)] pub enum IdentitySource { - /// NIP-06 derivation from the wallet BIP-39 mnemonic. + /// NIP-06 derivation from the wallet BIP-39 mnemonic (legacy: binds the + /// identity to the seed forever; superseded by `Random`). Derived, /// Imported nsec. Imported, + /// Freshly generated random key, independent of the wallet seed: the + /// seed proves nothing about the identity and cannot resurrect it. + Random, } /// Identity file stored at `wallet_data/nostr/identity.json`. @@ -47,6 +51,9 @@ pub struct NostrIdentity { pub nip05: Option, /// User chose to stay anonymous (no NIP-05, no kind-0 metadata). pub anonymous: bool, + /// Previous npubs from key rotations (newest last), for reference. + #[serde(default)] + pub prev_npubs: Vec, } /// NIP-49 scrypt work factor (~64 MiB, interactive-grade). @@ -152,6 +159,13 @@ impl NostrIdentity { Ok((identity, keys)) } + /// 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(); + let identity = Self::from_keys(&keys, password, IdentitySource::Random, 0)?; + Ok((identity, keys)) + } + /// Create an imported identity from an nsec string. pub fn create_imported( nsec: &str, @@ -192,6 +206,7 @@ impl NostrIdentity { npub, nip05: None, anonymous: true, + prev_npubs: Vec::new(), }) } @@ -222,6 +237,19 @@ impl NostrIdentity { mod tests { use super::*; + #[test] + fn random_identities_are_unlinked_and_unlock() { + let (a, ka) = NostrIdentity::create_random("pw-1").unwrap(); + let (b, _) = NostrIdentity::create_random("pw-1").unwrap(); + // Fresh entropy every time: no chain between identities. + assert_ne!(a.npub, b.npub); + assert_eq!(a.source, IdentitySource::Random); + // NIP-49 roundtrip with the right password; wrong one fails. + let unlocked = a.unlock("pw-1").unwrap(); + assert_eq!(unlocked.public_key(), ka.public_key()); + assert!(a.unlock("wrong").is_err()); + } + // NIP-06 test vector: this mnemonic must derive this npub (account 0). const NIP06_MNEMONIC: &str = "leader monkey parrot ring guide accident before fence cannon height naive bean"; diff --git a/src/nostr/nip05.rs b/src/nostr/nip05.rs index 868d20fb..1f53169b 100644 --- a/src/nostr/nip05.rs +++ b/src/nostr/nip05.rs @@ -187,6 +187,48 @@ pub async fn register(server: &str, name: &str, keys: &Keys) -> RegisterResult { } } +/// Atomically move an owned name to a new pubkey (key rotation). +/// Signed by the OLD key; the server enforces ownership and the +/// one-name-per-pubkey rule for the target key. +pub async fn transfer( + server: &str, + name: &str, + old_keys: &Keys, + new_pubkey_hex: &str, +) -> Result<(), String> { + let server = server.trim_end_matches('/'); + let url = format!("{}/api/v1/transfer", server); + let body = serde_json::json!({ + "name": name.to_lowercase(), + "new_pubkey": new_pubkey_hex, + }) + .to_string(); + let Some(auth) = nip98_auth(old_keys, &url, "POST", Some(body.as_bytes())) else { + return Err("auth event build failed".to_string()); + }; + let headers = vec![ + ("Authorization".to_string(), auth), + ("Content-Type".to_string(), "application/json".to_string()), + ]; + let Some(resp) = Tor::http_request("POST", url, Some(body), headers).await else { + return Err("network unavailable".to_string()); + }; + if resp.contains("\"transferred\":true") { + return Ok(()); + } + let err = serde_json::from_str::(&resp) + .ok() + .and_then(|d| d.get("error").and_then(|e| e.as_str()).map(String::from)) + .unwrap_or_else(|| { + if resp.trim().is_empty() { + "name server does not support transfer yet".to_string() + } else { + format!("unexpected response: {}", resp) + } + }); + Err(err) +} + /// Release a registered name (NIP-98 authed by the owner). pub async fn unregister(server: &str, name: &str, keys: &Keys) -> bool { let server = server.trim_end_matches('/'); diff --git a/src/nostr/store.rs b/src/nostr/store.rs index 22e90f59..5e5100ac 100644 --- a/src/nostr/store.rs +++ b/src/nostr/store.rs @@ -49,8 +49,13 @@ impl NostrStore { pub fn new(dir: PathBuf) -> Self { let _ = fs::create_dir_all(&dir); let mut manager = Manager::::singleton().write().unwrap(); + // Open with headroom above the 5 stores below: rkv's SafeMode checks + // capacity before existence, so reopening an env that already holds + // `DEFAULT_MAX_DBS` (5) named dbs fails with DbsFull. let created_arc = manager - .get_or_create(dir.as_path(), Rkv::new::) + .get_or_create(dir.as_path(), |p: &std::path::Path| { + Rkv::with_capacity::(p, 16) + }) .unwrap(); let env = created_arc.clone(); let k = created_arc.read().unwrap(); diff --git a/src/tor/tor.rs b/src/tor/tor.rs index f96b6e10..fee340f9 100644 --- a/src/tor/tor.rs +++ b/src/tor/tor.rs @@ -90,15 +90,47 @@ pub struct Tor { impl Default for Tor { fn default() -> Self { - // Extract webtunnel bridge binary. - if !fs::exists(TorConfig::webtunnel_path()).unwrap_or(true) { + // Extract the bundled webtunnel bridge client when missing or empty + // (an empty file means the app was built without Go — see build.rs). + let wt_path = TorConfig::webtunnel_path(); + let wt_missing = fs::metadata(&wt_path).map(|m| m.len() == 0).unwrap_or(true); + if wt_missing { let webtunnel = include_bytes!(concat!(env!("OUT_DIR"), "/tor/webtunnel")); - if !webtunnel.is_empty() { - fs::write(TorConfig::webtunnel_path(), webtunnel).unwrap_or_default(); + if webtunnel.is_empty() { + error!( + "Tor: bundled webtunnel client is empty (built without Go?), \ + webtunnel bridges will not work" + ); + } else { + if let Some(parent) = std::path::Path::new(&wt_path).parent() { + let _ = fs::create_dir_all(parent); + } + match fs::write(&wt_path, webtunnel) { + Ok(_) => { + // The pluggable transport must be executable. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = + fs::set_permissions(&wt_path, fs::Permissions::from_mode(0o755)); + } + } + Err(e) => error!("Tor: webtunnel client extraction failed: {}", e), + } } } + // Create the arti runtime on a clean thread: creation calls block_on, + // which panics (and poisons this lazy static) when the first TOR_STATE + // access happens on a thread already inside a tokio runtime context. + let runtime = std::thread::Builder::new() + .name("tor-runtime-init".to_string()) + .spawn(TokioNativeTlsRuntime::create) + .expect("failed to spawn tor runtime init thread") + .join() + .expect("tor runtime init thread panicked") + .unwrap(); Self { - runtime: TokioNativeTlsRuntime::create().unwrap(), + runtime, client_config: Arc::new(RwLock::new(None)), client_launching: Arc::new(AtomicBool::new(false)), run: Arc::new(RwLock::new(BTreeMap::new())), @@ -368,11 +400,32 @@ impl Tor { /// Perform an HTTP request over the embedded Tor client with optional /// headers, returning the response body. Used for NIP-05 and price APIs. + /// Retries with a fresh isolated circuit: single-circuit connect failures + /// right after bootstrap are common and transient. pub async fn http_request( method: &str, url: String, body: Option, headers: Vec<(String, String)>, + ) -> Option { + for attempt in 0..3 { + if attempt > 0 { + tokio::time::sleep(Duration::from_millis(1500)).await; + } + let resp = + Self::http_request_once(method, url.clone(), body.clone(), headers.clone()).await; + if resp.is_some() { + return resp; + } + } + None + } + + async fn http_request_once( + method: &str, + url: String, + body: Option, + headers: Vec<(String, String)>, ) -> Option { // Bind once to avoid a TOCTOU panic if Tor restarts mid-request. let Some((client_cfg, _)) = Self::client_config() else { diff --git a/src/wallet/store.rs b/src/wallet/store.rs index 6ab90887..2aec96cb 100644 --- a/src/wallet/store.rs +++ b/src/wallet/store.rs @@ -30,8 +30,12 @@ impl TxHeightStore { /// Create new transaction height storage from provided directory. pub fn new(dir: PathBuf) -> Self { let mut manager = Manager::::singleton().write().unwrap(); + // Headroom above the stores below; see NostrStore::new on rkv SafeMode + // rejecting reopens of an env already at capacity. let created_arc = manager - .get_or_create(dir.as_path(), Rkv::new::) + .get_or_create(dir.as_path(), |p: &std::path::Path| { + Rkv::with_capacity::(p, 16) + }) .unwrap(); let env = created_arc.clone(); let k = created_arc.read().unwrap(); diff --git a/src/wallet/wallet.rs b/src/wallet/wallet.rs index 6a36338a..8c00b938 100644 --- a/src/wallet/wallet.rs +++ b/src/wallet/wallet.rs @@ -426,31 +426,25 @@ impl Wallet { return; } let nostr_dir = config.get_nostr_path(); - // Load existing identity or derive one from the wallet seed (NIP-06). + // Load the existing identity or generate a fresh RANDOM one. The key + // is deliberately independent of the wallet seed: the seed must never + // double as identity evidence, and a rotation must not be derivable + // from it. The nsec backup is therefore the identity backup. let identity = match NostrIdentity::load(&nostr_dir) { Some(identity) => identity, - None => { - let phrase = match self.get_recovery(password.to_string()) { - Ok(p) => p, - Err(e) => { - error!("nostr: recovery phrase unavailable: {:?}", e); - return; - } - }; - match NostrIdentity::create_derived(&phrase, password, 0) { - Ok((identity, _)) => { - if let Err(e) = identity.save(&nostr_dir) { - error!("nostr: identity save failed: {e}"); - return; - } - identity - } - Err(e) => { - error!("nostr: identity derivation failed: {e}"); + None => match NostrIdentity::create_random(password) { + Ok((identity, _)) => { + if let Err(e) = identity.save(&nostr_dir) { + error!("nostr: identity save failed: {e}"); return; } + identity } - } + Err(e) => { + error!("nostr: identity creation failed: {e}"); + return; + } + }, }; let keys = match identity.unlock(password) { Ok(keys) => keys, @@ -472,6 +466,133 @@ impl Wallet { r_nostr.clone() } + /// Rotate the nostr identity to a brand-new RANDOM key (no derivation + /// chain: a future seed compromise cannot reach it, and the old key + /// shares nothing with it), atomically moving the registered username + /// (if any) to the new key via the name server. Blocking (Tor HTTP): + /// call from a worker thread. Returns the new bech32 npub. + pub fn rotate_nostr_identity(&self, password: String) -> Result { + let svc = self + .nostr_service() + .ok_or_else(|| "nostr is not running".to_string())?; + // Snapshot the old identity and prove the password by unlocking it + // (NIP-49 decryption fails on a wrong password). + let old = svc.identity.read().clone(); + let old_keys = old + .unlock(&password) + .map_err(|_| "Wrong password".to_string())?; + + // Generate the replacement identity. + let (mut new_identity, new_keys) = NostrIdentity::create_random(&password) + .map_err(|e| format!("key generation failed: {e}"))?; + + // Move the username first; abort the rotation if that fails so the + // user never ends up keyless-but-named or named-but-keyless. + if let Some(nip05) = old.nip05.clone() { + let name = nip05.split('@').next().unwrap_or_default().to_string(); + let server = { svc.config.read().nip05_server() }; + let new_hex = new_keys.public_key().to_hex(); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| e.to_string())?; + rt.block_on(async { + crate::nostr::nip05::transfer(&server, &name, &old_keys, &new_hex).await + }) + .map_err(|e| format!("Couldn't move @{name}: {e}"))?; + new_identity.nip05 = Some(nip05); + new_identity.anonymous = false; + } + new_identity.prev_npubs = { + let mut v = old.prev_npubs.clone(); + v.push(old.npub.clone()); + v + }; + + // Persist, then swap the running service for one bound to the new key. + let config = self.get_config(); + let nostr_dir = config.get_nostr_path(); + new_identity + .save(&nostr_dir) + .map_err(|e| format!("identity save failed: {e}"))?; + svc.stop(); + for _ in 0..100 { + if !svc.is_running() { + break; + } + thread::sleep(Duration::from_millis(100)); + } + let wallet_dir = PathBuf::from(config.get_data_path()); + let nostr_config = NostrConfig::load(wallet_dir); + let store = NostrStore::new(config.get_nostr_db_path()); + let new_npub = new_identity.npub.clone(); + let new_svc = NostrService::new(new_keys, new_identity, nostr_config, store, nostr_dir); + { + let mut w_nostr = self.nostr.write(); + *w_nostr = Some(new_svc.clone()); + } + new_svc.start(self.clone()); + info!("nostr: identity rotated to {}", new_npub); + Ok(new_npub) + } + + /// Replace the nostr identity with an imported key — either a bare nsec + /// 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 { + let svc = self + .nostr_service() + .ok_or_else(|| "nostr is not running".to_string())?; + // Prove the password against the current identity before replacing. + 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 = + 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)" + .to_string() + })?; + (ident, keys) + } else { + NostrIdentity::create_imported(input, &password) + .map_err(|_| "Invalid nsec".to_string())? + }; + if new_identity.npub != old.npub && !new_identity.prev_npubs.contains(&old.npub) { + new_identity.prev_npubs.push(old.npub.clone()); + } + let config = self.get_config(); + let nostr_dir = config.get_nostr_path(); + new_identity + .save(&nostr_dir) + .map_err(|e| format!("identity save failed: {e}"))?; + svc.stop(); + for _ in 0..100 { + if !svc.is_running() { + break; + } + thread::sleep(Duration::from_millis(100)); + } + let wallet_dir = PathBuf::from(config.get_data_path()); + let nostr_config = NostrConfig::load(wallet_dir); + let store = NostrStore::new(config.get_nostr_db_path()); + let new_npub = new_identity.npub.clone(); + let new_svc = NostrService::new(new_keys, new_identity, nostr_config, store, nostr_dir); + { + let mut w_nostr = self.nostr.write(); + *w_nostr = Some(new_svc.clone()); + } + new_svc.start(self.clone()); + info!("nostr: identity replaced by import: {}", new_npub); + Ok(new_npub) + } + /// Get keychain mask [`SecretKey`]. pub fn keychain_mask(&self) -> Option { let r_key = self.keychain_mask.read();