Build 18: move the integrated node off the wallet list into the cog
- 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 <noreply@anthropic.com>
This commit is contained in:
+206
@@ -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 <changed files>`
|
||||
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 <x> <y> 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`.
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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| {
|
||||
|
||||
+29
-5
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user