mirror of
https://code.gri.mw/GUI/grim.git
synced 2026-07-10 17:04:10 +00:00
Build 8: stability fixes, Cash App-style shell, settings, key rotation
Stability (found via GUI testing):
- tor: create arti runtime on a clean thread; lazy TOR_STATE init panicked
inside tokio contexts and poisoned the whole Tor/nostr stack
- store: open rkv SafeMode envs with capacity headroom; reopening at
exactly DEFAULT_MAX_DBS crashed every wallet restart (DbsFull)
- goblin ui: centered_column hands children a full-height rect; ScrollAreas
inside clipped everything below the first widget
- build: webtunnel client was silently embedded as 0 bytes without Go;
warn at build, extract with create_dir_all + exec bit at runtime
- price: CoinGecko requires a User-Agent (403 otherwise); add retry-once
backoff and a parse-failure diagnostic
- tor: retry http_request up to 3x on fresh isolated circuits
UX overhaul (per owner direction + Cash App references):
- floating icon-only 3-tab pill: Wallet / center accent ツ Pay puck /
Activity (requests badge kept); Me opens via header avatar
- Pay tab: amount-first surface (numpad on mobile, typed on desktop) with
Request and Pay; Pay carries the amount straight to Review
- Request flips Receive into "Requesting Nツ" state with a clear chip
- full-bleed goblin surface: GRIM title panel and network column hidden
while a wallet is open; node status card lives in the sidebar above the
profile chip; Lock wallet row added (Settings -> Wallet)
- goblin branding: titlebar, wallet-list logo + GOBLIN, new mark assets
(white master, theme-tinted) in wordmark, QR center, wallet list
- build-based versioning: Build N = commits since the GRIM fork base,
emitted by build.rs; About leads with it, Third party credits GRIM,
grin node, nostr-sdk, arti, egui and the implemented NIPs
Accessibility & settings:
- surface_text{,_dim,_mute} tokens: yellow theme has dark cards on a
bright bg; all on-surface text now readable in every theme (incl. QR)
- settings rows clickable across the whole row; profile card fills width;
density option removed (comfy fixed)
- editable Node connections (integrated/external, add/remove) and Relays
(add/remove + live service restart); NIPs explainer page with goblin.st
context; third-party rows link to upstream projects
- standardized npub truncation (head 12 ... tail 6) shown in profile
Identity (owner decision: drop NIP-06 seed binding):
- random standalone nsec (Keys::generate); seed proves nothing about the
identity and cannot resurrect it; legacy Derived identities still unlock
- key rotation: double warning (pending payments disrupted), typed RESET +
wallet password, fresh random key, username moved atomically via the
name server transfer endpoint; aborts cleanly if the move fails
- encrypted identity backup export (NIP-49 ncryptsec JSON, includes
username + history) and import accepting nsec or backup JSON
- nip05d: POST /api/v1/transfer (NIP-98 by current owner, atomic
owner-guarded swap, one-name-per-pubkey enforced) + SQL invariant tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
+1
-1
@@ -291,7 +291,7 @@ impl<Platform: PlatformCallbacks> App<Platform> {
|
||||
}
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+1392
-154
File diff suppressed because it is too large
Load Diff
@@ -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::<f64>() {
|
||||
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::<f64>() {
|
||||
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);
|
||||
|
||||
|
||||
+181
-20
@@ -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<R>(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 {
|
||||
|
||||
+7
-10
@@ -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)),
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
+24
-3
@@ -29,8 +29,13 @@ lazy_static! {
|
||||
static ref RATE: RwLock<Option<(f64, i64)>> = 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<f64> {
|
||||
/// 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<f64> {
|
||||
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<f64> = serde_json::from_str::<serde_json::Value>(&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::<String>()
|
||||
);
|
||||
}
|
||||
parsed
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
|
||||
+29
-1
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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";
|
||||
|
||||
@@ -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::<Value>(&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('/');
|
||||
|
||||
+6
-1
@@ -49,8 +49,13 @@ impl NostrStore {
|
||||
pub fn new(dir: PathBuf) -> Self {
|
||||
let _ = fs::create_dir_all(&dir);
|
||||
let mut manager = Manager::<SafeModeEnvironment>::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::<SafeMode>)
|
||||
.get_or_create(dir.as_path(), |p: &std::path::Path| {
|
||||
Rkv::with_capacity::<SafeMode>(p, 16)
|
||||
})
|
||||
.unwrap();
|
||||
let env = created_arc.clone();
|
||||
let k = created_arc.read().unwrap();
|
||||
|
||||
+58
-5
@@ -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<String>,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> Option<String> {
|
||||
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<String>,
|
||||
headers: Vec<(String, String)>,
|
||||
) -> Option<String> {
|
||||
// Bind once to avoid a TOCTOU panic if Tor restarts mid-request.
|
||||
let Some((client_cfg, _)) = Self::client_config() else {
|
||||
|
||||
+5
-1
@@ -30,8 +30,12 @@ impl TxHeightStore {
|
||||
/// Create new transaction height storage from provided directory.
|
||||
pub fn new(dir: PathBuf) -> Self {
|
||||
let mut manager = Manager::<SafeModeEnvironment>::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::<SafeMode>)
|
||||
.get_or_create(dir.as_path(), |p: &std::path::Path| {
|
||||
Rkv::with_capacity::<SafeMode>(p, 16)
|
||||
})
|
||||
.unwrap();
|
||||
let env = created_arc.clone();
|
||||
let k = created_arc.read().unwrap();
|
||||
|
||||
+141
-20
@@ -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<String, String> {
|
||||
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<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.
|
||||
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<SecretKey> {
|
||||
let r_key = self.keychain_mask.read();
|
||||
|
||||
Reference in New Issue
Block a user