From 0438d70cae4fd4ebf20b71729c3dff385e197aa2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 20:21:13 -0400 Subject: [PATCH] Build 18: move the integrated node off the wallet list into the cog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove the Node status chip from the wallet-list header; the integrated node now lives in the cog's settings as a status + Enable/Autorun section, with a "Node settings" button into the full stats/mining/tuning panel. - Stop the desktop two-column rail from auto-docking the node beside the settings screen, so the node has a single home in the cog at every width (the full panel opens only on demand). Fixes the wide-window double-exposure. - Prove NIP-17 payments transit the top public relays: parameterize the nostr e2e roundtrip and add damus.io + nos.lol proofs (3/3 green). - Add DEVELOPING.md documenting how we iterate and test (Xwayland :2 recipe, Build N cadence, gui-sweep, e2e tests, live infra) — no secrets. Verified live on :2: chip gone from the list; cog shows the node once (single column, no left rail); "Node settings" opens the full panel on demand. 34 lib tests green; nip17 roundtrip green over nrelay + damus + nos.lol. Co-Authored-By: Claude Fable 5 --- DEVELOPING.md | 206 ++++++++++++++++++++++++++++++ src/gui/views/content.rs | 7 +- src/gui/views/settings/content.rs | 51 +++++++- src/gui/views/wallets/content.rs | 43 +------ tests/nostr_e2e.rs | 34 ++++- 5 files changed, 293 insertions(+), 48 deletions(-) create mode 100644 DEVELOPING.md diff --git a/DEVELOPING.md b/DEVELOPING.md new file mode 100644 index 0000000..d515f5a --- /dev/null +++ b/DEVELOPING.md @@ -0,0 +1,206 @@ +# Developing & testing Goblin + +How this fork is actually iterated on, built, driven, and verified — so you can +pick development back up after a `/clear` without re-deriving the loop. This is +the *dev/test workflow*, not an architecture tour (for that see `HANDOFF.md`, +the `~/.claude/.../memory/` notes, and the code map in +`goblin-implementation-state`). + +> **Secrets stay out of this file.** Test-wallet seed phrases and passwords are +> never committed. They live in the local handoff notes only. This doc refers +> to wallets by role. + +--- + +## 1. Workspace layout + +Everything lives under `…/Goblin Project/`: + +| Dir | What it is | +| --- | --- | +| `goblin/` | **The fork you edit.** Crate name `grim`, binary `goblin`. | +| `goblin-nip05d/` | The NIP-05 identity server (axum + sqlite), deployed at goblin.st. | +| `grim/`, `grin/`, `egui/`, `android-activity/`, `nokhwa/` | Reference clones — **not build inputs**, just there to read upstream source. | + +Inside `goblin/src/gui/views/`: +- `goblin/` — the Cash App-style surfaces (home/activity/send/receive/me, onboarding, the Goblin settings pages, widgets). +- `wallets/` — the wallet list + open-wallet host (returning-user list lives in `wallets/content.rs`). +- `network/` — the integrated GRIN node panel (stats/mining/tuning/recovery). +- `settings/` — the app-level cog settings (`settings/content.rs`). + +First build needs submodules: `git submodule update --init --recursive` inside +`goblin/` (else `grin_api` fails). As **butler**, the rustup toolchain is in +`$HOME`, so `cargo`/`rustc`/`rustfmt` just work — no env overrides. + +--- + +## 2. Build & versioning + +```sh +cd goblin +cargo build # ~15s incremental debug; clean release ~2m +``` + +**Builds are numbered, not semver'd.** `build.rs` computes +`Build N = git rev-list --count b51a46b..HEAD` (the GRIM fork base) and exposes +it as the `GOBLIN_BUILD` env var baked into the binary. Each meaningful change +is committed with the message `Build N`. To see the next number: + +```sh +git rev-list --count b51a46b..HEAD +``` + +A pre-commit hook runs `rustfmt --check` and aborts if anything is unformatted +(it reformats first). Always run `rustfmt --edition 2024 ` +before committing. Pushing `origin master` is the owner's job. + +--- + +## 3. Driving the real app for screenshots (Xwayland :2) + +The desktop session is **KDE Wayland**, where `xdotool` input injection does +**not** work (and Parsec may hold the live display). The validated recipe is a +throwaway rootful Xwayland server on display `:2`: + +```sh +# 1. Stand up an isolated X server (once per session; no window manager inside). +Xwayland :2 -geometry 1600x1000 -decorate -noreset & + +# 2. Launch Goblin onto it. MUST unset WAYLAND_DISPLAY or winit picks Wayland. +env -u WAYLAND_DISPLAY DISPLAY=:2 ./target/debug/goblin & + +# 3. Find the window, focus it (no WM => keys go nowhere until you focus once). +WID=$(DISPLAY=:2 xdotool search --name Goblin | head -1) +DISPLAY=:2 xdotool windowfocus "$WID" + +# 4. Drive it. +DISPLAY=:2 xdotool mousemove click 1 +DISPLAY=:2 xdotool type 'hello' +DISPLAY=:2 xdotool key Return + +# 5. Capture (ImageMagick). No WM => window sits at 0,0, so image coords == click coords. +DISPLAY=:2 import -window "$WID" /tmp/shot.png +``` + +Form factors — resize the window to test responsive layouts: + +```sh +DISPLAY=:2 xdotool windowsize "$WID" 390 844 # mobile (bottom tabs) +DISPLAY=:2 xdotool windowsize "$WID" 1280 800 # desktop sidebar (>=720px wide) +``` + +The layout flips between mobile bottom-tabs and desktop sidebar around 700-720px. + +Kill stale instances by exact name only — **`pgrep -x goblin`, never `-f`** +(`-f` would match this checkout's paths and your editor). Data dir is `~/.goblin`. + +--- + +## 4. The `goblin-gui-sweep` workflow (run after every UI change) + +The saved multi-agent workflow at `.claude/workflows/goblin-gui-sweep.js` +(project root) is the standard post-UI-change check. Invoke it via the Workflow +tool by name `goblin-gui-sweep`. It: + +1. **Capture** — stands up `:2` if dead, builds, kills stale instances, launches, + unlocks the test wallet, and drives **every** surface (desktop + mobile + widths, all three themes, settings sub-pages, a send-to-review via a local + npub, the ~700px responsive flip), screenshotting to `/tmp/goblin-gui-sweep/`. +2. **Review** — fans out reviewer agents (rendering / accessibility / design / + copy) over the screenshot groups. +3. **Verify** — adversarially re-checks each finding to drop false positives. +4. **Report** — writes `/tmp/goblin-gui-sweep/report.md` + `collage.png`. + +The driver always finishes its rotation (it restores the Dark theme and locks +back), and known-normal states are allowlisted in the reviewer prompts so they +aren't re-flagged. + +> Immediate-mode caveat: egui lags one frame on click, so a nav highlight may be +> missing in the exact frame you clicked. Verify highlights on the *next* +> capture, not the click frame — several "bugs" have been this artifact. + +--- + +## 5. Test wallets + +Live testing uses real GRIN on mainnet, so there are dedicated wallets: + +- A **local dev wallet** in `~/.goblin` for GUI drives (used by the sweep). +- **Two funded mainnet wallets** imported for end-to-end send/receive — referred + to by role: **Wallet A** and **Wallet B (`@fartmuncher22`)**. Wallet B holds a + small amount of grin for full live payment tests. + +**Their seed phrases and passwords are deliberately kept out of the repo** — see +the local handoff notes. Never paste them into code, logs, commits, or galleries. + +--- + +## 6. Identity / relay / node infra + +The live backend runs on **`us-ea.st`** (`ssh us-east`): + +- **Relay** — `wss://nrelay.us-ea.st` (the Goblin relay, docker). The relay we + control; always keep it in the relay set for deliverability. +- **NIP-05 server** — `goblin-nip05d` (axum + sqlite) at `/opt/goblin/nip05d`, + serving `https://goblin.st` (registration, avatars, `.well-known/nostr.json`). +- **DNS** — PowerDNS; **nginx** fronts goblin.st and hosts the `/review` gallery. +- **GRIN node** — mainnet node at `127.0.0.1:3413`. + +Payments are NIP-17 gift wraps (kind 1059). `DEFAULT_RELAYS` (`src/nostr/relays.rs`) +is `nrelay.us-ea.st` + `relay.damus.io` + `nos.lol`; the recipient's kind-10050 +DM-relay list plus the sender's own relays decide where a payment is delivered, +so **both parties must share at least one reachable relay**. The relay set is +user-editable in **Goblin → Settings → Relays** (`gui/views/goblin/mod.rs`, +`relays_ui`); Save & reconnect rewrites kind-10050 and restarts the service. + +--- + +## 7. Tests + +```sh +cd goblin +cargo test --lib # fast unit tests, offline + +# Live network e2e (opt-in, hit real relays/server) — tests/nostr_e2e.rs: +cargo test --test nostr_e2e nip17_slatepack_roundtrip -- --ignored --nocapture # our relay +cargo test --test nostr_e2e nip17_roundtrip -- --ignored --nocapture # + damus.io + nos.lol +cargo test --test nostr_e2e nip05_registration -- --ignored --nocapture # goblin.st register/resolve +cargo test --test nostr_e2e avatar -- --ignored --nocapture # avatar upload/serve/limit +cargo test --test replay_check -- --ignored --nocapture # server replay/squat guards +``` + +The `nip17_roundtrip_*` tests prove a gift-wrapped Goblin payment actually +transits each relay: Bob advertises a kind-10050 DM relay and subscribes; Alice +sends; Bob unwraps, checks the seal author, and extracts the slatepack. The +`--ignored` roundtrips can transiently time out on public-relay latency — re-run +before treating a timeout as a regression. + +--- + +## 8. Review galleries (goblin.st/review) + +Screenshot galleries from a sweep are published for the owner by copying the +collage/shots to `us-ea.st` under the nginx `/review/` location (note the +trailing slash — bare `/review` is 301'd to `/review/`). Galleries are put up +time-boxed (e.g. a 1- or 24-hour expiry) and taken down after. + +--- + +## 9. Gotchas worth keeping + +- **`View::title_button_big` paints dark-on-yellow ink** — invisible on a dark + surface. When you need an icon button on a dark background (e.g. the wallet-list + gear), draw it manually with `ui.painter().text(... Colors::text(false))`. +- **`xdotool` onboarding drives desync** when the layout shifts mid-flow (the + password-match step re-flows). Fresh-launch clean fields are reliable; the + word-confirm steps drift — don't trust a blind coordinate script across a + re-flow, re-screenshot and re-find. +- **Camera/QR:** open the device by `devices[pos].index()`, not the nostr + list-position — they differ and the wrong one opens the wrong `/dev/video*`. +- **Tor needs Go at build time.** `build.rs` builds the webtunnel bridge via + `scripts/webtunnel.sh`, which silently no-ops if Go is missing, embedding a + 0-byte bridge → Tor never bootstraps. If relay/NIP-05 stay "connecting…": + `pacman -S go`, force a `build.rs` rerun (touch `build.rs`), rebuild. +- **`git` "dubious ownership"** after a prior root session: + `git config --global --add safe.directory "$PWD"`; root-owned `target/` + artifacts → `sudo chown -R butler:butler goblin/target` or `cargo clean`. diff --git a/src/gui/views/content.rs b/src/gui/views/content.rs index 07a4b81..75fe2c3 100644 --- a/src/gui/views/content.rs +++ b/src/gui/views/content.rs @@ -102,8 +102,13 @@ impl ContentContainer for Content { // the panel opens only when explicitly toggled, never forced open by // dual-panel mode (otherwise GRIM's node column dominates the list). let list_screen = self.wallets.wallet_list_screen(); + // The app-settings (cog) screen owns the node now: it lives in the + // cog's own section, and the full panel opens only when explicitly + // requested from there — never auto-docked beside settings, which + // would expose the node twice on a wide screen. + let app_settings = self.wallets.showing_settings(); let show_network = !wallet_open - && if list_screen { + && if list_screen || app_settings { Self::is_network_panel_open() } else { is_panel_open diff --git a/src/gui/views/settings/content.rs b/src/gui/views/settings/content.rs index f3822f0..1dfa905 100644 --- a/src/gui/views/settings/content.rs +++ b/src/gui/views/settings/content.rs @@ -14,11 +14,13 @@ use crate::AppConfig; use crate::gui::Colors; -use crate::gui::icons::GLOBE_SIMPLE; +use crate::gui::icons::{DATABASE, FADERS, GLOBE_SIMPLE, POWER}; use crate::gui::platform::PlatformCallbacks; -use crate::gui::views::View; +use crate::gui::views::network::NetworkContent; use crate::gui::views::settings::{InterfaceSettingsContent, NetworkSettingsContent}; use crate::gui::views::types::ContentContainer; +use crate::gui::views::{Content, View}; +use crate::node::Node; /// Application settings content. pub struct SettingsContent { @@ -64,6 +66,51 @@ impl SettingsContent { self.network_settings.ui(ui, cb); ui.add_space(8.0); + // Integrated node — relocated here from the wallet-list chip so the + // list stays uncluttered. Quick status + enable/autorun, plus a button + // into the full node panel (stats, mining, tuning, recovery). + View::horizontal_line(ui, Colors::stroke()); + ui.add_space(6.0); + View::sub_title(ui, format!("{} {}", DATABASE, t!("network.node"))); + View::horizontal_line(ui, Colors::stroke()); + ui.add_space(8.0); + + let running = Node::is_running(); + let (status_color, status_text) = if !running { + (Colors::gray(), "Disabled") + } else if Node::not_syncing() { + (Colors::pos(), "Running · synced") + } else { + (Colors::gold(), "Running · syncing…") + }; + ui.vertical_centered(|ui| { + ui.label( + egui::RichText::new(status_text) + .size(15.0) + .color(status_color), + ); + }); + ui.add_space(8.0); + + if !running { + View::action_button( + ui, + format!("{} {}", POWER, t!("network.enable_node")), + || { + Node::start(); + }, + ); + ui.add_space(4.0); + } + NetworkContent::autorun_node_ui(ui); + ui.add_space(8.0); + View::action_button(ui, format!("{} {}", FADERS, t!("network.settings")), || { + if !Content::is_network_panel_open() { + Content::toggle_network_panel(); + } + }); + ui.add_space(8.0); + // Do not show Tor settings on Android. // let os = OperatingSystem::from_target_os(); // let show_tor = os != OperatingSystem::Android; diff --git a/src/gui/views/wallets/content.rs b/src/gui/views/wallets/content.rs index 02164cb..e7618e7 100644 --- a/src/gui/views/wallets/content.rs +++ b/src/gui/views/wallets/content.rs @@ -574,49 +574,12 @@ impl WalletsContent { } } - /// Slim header for the wallet-list surface: a tappable integrated-node - /// status chip (opens the full node panel — every feature intact, just - /// opt-in) on the left, and the app-settings gear on the right. + /// Slim header for the wallet-list surface: just the app-settings gear, + /// right-aligned. The integrated node moved into the gear's settings — out + /// of the list, every node feature still intact. fn wallet_list_header_ui(&mut self, ui: &mut egui::Ui) { - use crate::node::Node; ui.add_space(6.0); ui.horizontal(|ui| { - // Node status chip. - let running = Node::is_running(); - let synced = running && Node::not_syncing(); - let (dot, label) = if !running { - (Colors::gray(), "Node") - } else if synced { - (Colors::pos(), "Node synced") - } else { - (Colors::gold(), "Syncing…") - }; - let galley = ui.painter().layout_no_wrap( - label.to_string(), - eframe::epaint::FontId::proportional(14.0), - Colors::text(false), - ); - let pad = egui::Vec2::new(12.0, 7.0); - let chip_w = 10.0 + 8.0 + galley.size().x + pad.x * 2.0; - let (rect, resp) = - ui.allocate_exact_size(egui::Vec2::new(chip_w, 34.0), Sense::click()); - ui.painter() - .rect_filled(rect, CornerRadius::same(17), Colors::fill_lite()); - let dot_c = egui::pos2(rect.min.x + pad.x + 5.0, rect.center().y); - ui.painter().circle_filled(dot_c, 4.0, dot); - ui.painter().galley( - egui::pos2(dot_c.x + 13.0, rect.center().y - galley.size().y / 2.0), - galley, - Colors::text(false), - ); - if resp - .on_hover_cursor(CursorIcon::PointingHand) - .on_hover_text("Node settings") - .clicked() - { - Content::toggle_network_panel(); - } - // App-settings gear, right-aligned. Drawn manually (the title-bar // button uses dark-on-yellow ink, invisible on this dark surface). ui.with_layout(Layout::right_to_left(Align::Center), |ui| { diff --git a/tests/nostr_e2e.rs b/tests/nostr_e2e.rs index e798a88..cdb2cdb 100644 --- a/tests/nostr_e2e.rs +++ b/tests/nostr_e2e.rs @@ -21,6 +21,30 @@ const SLATEPACK: &str = "BEGINSLATEPACK. 4H1qx1wHe668tFW yC2gfL8PPd8kSgv \ #[tokio::test] #[ignore] async fn nip17_slatepack_roundtrip() { + nip17_roundtrip_over(RELAY).await; +} + +/// Same NIP-17 payment roundtrip over relay.damus.io — proves Goblin gift +/// wraps transit a top public relay, not only the relay we run. +/// Run: cargo test --test nostr_e2e nip17_roundtrip_damus -- --ignored --nocapture +#[tokio::test] +#[ignore] +async fn nip17_roundtrip_damus() { + nip17_roundtrip_over("wss://relay.damus.io").await; +} + +/// And over nos.lol, the other large public relay in DEFAULT_RELAYS. +/// Run: cargo test --test nostr_e2e nip17_roundtrip_nos_lol -- --ignored --nocapture +#[tokio::test] +#[ignore] +async fn nip17_roundtrip_nos_lol() { + nip17_roundtrip_over("wss://nos.lol").await; +} + +/// The shared roundtrip, parameterized by relay: Bob advertises a kind-10050 +/// DM relay and subscribes to gift wraps; Alice sends a NIP-17 payment DM; Bob +/// unwraps it, verifies the seal author, and extracts the slatepack + subject. +async fn nip17_roundtrip_over(relay: &str) { let alice = Keys::generate(); let bob = Keys::generate(); println!("alice: {}", alice.public_key().to_bech32().unwrap()); @@ -28,13 +52,13 @@ async fn nip17_slatepack_roundtrip() { // Bob's client: connect, advertise DM relays, subscribe to gift wraps. let bob_client = Client::new(bob.clone()); - bob_client.add_relay(RELAY).await.unwrap(); + bob_client.add_relay(relay).await.unwrap(); bob_client.connect().await; tokio::time::sleep(Duration::from_secs(2)).await; // Publish Bob's kind-10050 DM relay list so senders find this relay. let dm_relays = EventBuilder::new(Kind::InboxRelays, "") - .tag(Tag::custom(TagKind::custom("relay"), [RELAY.to_string()])); + .tag(Tag::custom(TagKind::custom("relay"), [relay.to_string()])); bob_client.send_event_builder(dm_relays).await.unwrap(); let filter = Filter::new() @@ -45,14 +69,14 @@ async fn nip17_slatepack_roundtrip() { // Alice's client: connect and send a NIP-17 payment DM to Bob. let alice_client = Client::new(alice.clone()); - alice_client.add_relay(RELAY).await.unwrap(); + alice_client.add_relay(relay).await.unwrap(); alice_client.connect().await; tokio::time::sleep(Duration::from_secs(2)).await; let content = protocol::build_payment_content(SLATEPACK); let tags = protocol::build_rumor_tags(Some("lunch :)")); alice_client - .send_private_msg_to([RELAY], bob.public_key(), content, tags) + .send_private_msg_to([relay], bob.public_key(), content, tags) .await .unwrap(); println!("alice sent gift-wrapped payment DM"); @@ -91,7 +115,7 @@ async fn nip17_slatepack_roundtrip() { let subject = protocol::extract_subject(&result.rumor.tags); assert_eq!(subject.as_deref(), Some("lunch :)")); - println!("✓ NIP-17 slatepack roundtrip verified over {RELAY}"); + println!("✓ NIP-17 slatepack roundtrip verified over {relay}"); bob_client.disconnect().await; alice_client.disconnect().await; }