From ae0f36d287b8cd8e02fc1649f92de3325c0e20f5 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Sun, 5 Jul 2026 01:53:18 -0400 Subject: [PATCH] Goblin: goblin: payment deep link opens straight to send review Web "Open in Goblin" pay buttons fired generic nostr: URIs, which the OS routed to whatever social client claims nostr:. Register a dedicated goblin: scheme and route it (plus nostr: pay URIs) to the prefilled send-review screen, the same destination as scanning a checkout QR. Parser (payuri): accept goblin: alongside nostr: (same payload, either scheme); add is_pay_uri() to tell a payment link from a slatepack. Runtime: an argv/intent/socket payload that is a pay URI is stashed and opened by the Goblin surface via SendFlow::from_deeplink, which shares the exact apply_scan path the camera uses. Desktop cold + warm start (argv + single-instance socket) and Android cold + warm (intent-filter) covered. Registration: Linux .desktop x-scheme-handler/goblin, Android goblin intent-filter, macOS CFBundleURLTypes, Windows WiX HKCR URL protocol. --- android/app/src/main/AndroidManifest.xml | 10 ++ .../java/mw/gri/android/MainActivity.java | 10 ++ linux/Goblin.AppDir/goblin.desktop | 4 +- macos/Goblin.app/Contents/Info.plist | 13 ++ src/gui/views/goblin/mod.rs | 8 + src/gui/views/goblin/send.rs | 166 ++++++++++++------ src/gui/views/wallets/content.rs | 11 ++ src/lib.rs | 19 ++ src/nostr/payuri.rs | 87 +++++++-- wix/main.wxs | 17 ++ 10 files changed, 277 insertions(+), 68 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1321f03a..1c0060ca 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -59,6 +59,16 @@ + + + + + + + + diff --git a/android/app/src/main/java/mw/gri/android/MainActivity.java b/android/app/src/main/java/mw/gri/android/MainActivity.java index 9aef4b5c..838e7300 100644 --- a/android/app/src/main/java/mw/gri/android/MainActivity.java +++ b/android/app/src/main/java/mw/gri/android/MainActivity.java @@ -235,6 +235,16 @@ public class MainActivity extends GameActivity { String action = intent.getAction(); // Check if file was open with the application. if (action != null && action.equals(Intent.ACTION_VIEW)) { + Uri data = intent.getData(); + String scheme = data != null ? data.getScheme() : null; + // Goblin payment deep link (goblin: / nostr:): pass the URI text + // straight to native code, which routes it to the send-review flow. + // These are NOT files, so they must skip the file-descriptor path. + if (scheme != null && (scheme.equalsIgnoreCase("goblin") + || scheme.equalsIgnoreCase("nostr"))) { + onData(data.toString()); + return; + } Intent i = getIntent(); i.setData(intent.getData()); setIntent(i); diff --git a/linux/Goblin.AppDir/goblin.desktop b/linux/Goblin.AppDir/goblin.desktop index 66649a25..8ab1f520 100644 --- a/linux/Goblin.AppDir/goblin.desktop +++ b/linux/Goblin.AppDir/goblin.desktop @@ -1,7 +1,7 @@ [Desktop Entry] Name=Goblin -Exec=goblin +Exec=goblin %u Icon=goblin Type=Application Categories=Finance -MimeType=application/x-slatepack;text/plain; \ No newline at end of file +MimeType=application/x-slatepack;text/plain;x-scheme-handler/goblin; \ No newline at end of file diff --git a/macos/Goblin.app/Contents/Info.plist b/macos/Goblin.app/Contents/Info.plist index 8a6776b0..e758c4d8 100644 --- a/macos/Goblin.app/Contents/Info.plist +++ b/macos/Goblin.app/Contents/Info.plist @@ -57,6 +57,19 @@ Document + CFBundleURLTypes + + + CFBundleURLName + st.goblin.pay + CFBundleTypeRole + Viewer + CFBundleURLSchemes + + goblin + + + LSApplicationCategoryType public.app-category.finance NSHumanReadableCopyright diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index ab0d28f0..314024c5 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -432,6 +432,14 @@ impl GoblinWalletView { self.advanced = AdvancedState::default(); } + // A pending payment deep link (`goblin:` / `nostr:` pay URI, routed here + // from an OS launch/open) opens a prefilled send-review flow — the exact + // destination a scanned checkout QR lands on. + if let Some(uri) = crate::take_pending_pay_uri() { + let now = ui.input(|i| i.time); + self.send = Some(SendFlow::from_deeplink(&uri, wallet, now)); + } + // Send flow takes the full surface when active. if let Some(send) = &mut self.send { let done = send.ui(ui, wallet, cb, &mut self.avatars); diff --git a/src/gui/views/goblin/send.rs b/src/gui/views/goblin/send.rs index 4ff83553..d30a6776 100644 --- a/src/gui/views/goblin/send.rs +++ b/src/gui/views/goblin/send.rs @@ -210,6 +210,78 @@ impl SendFlow { self.stage = Stage::Recipient; } + /// Build a send flow from a scanned/deep-linked payment payload and land it + /// on the SAME screen a scanned QR would: a full checkout (recipient key + + /// amount) opens the prefilled review directly; anything else drops into the + /// recipient search with any amount/memo prefilled. Used by the `goblin:` / + /// `nostr:` deep-link handler so a web "Open in Goblin" button reaches the + /// exact QR-to-review destination. `now` is the egui input time (debounce seed + /// for the search path). + pub fn from_deeplink(uri: &str, wallet: &Wallet, now: f64) -> Self { + let mut flow = Self::default(); + flow.apply_scan(uri, wallet, now); + flow + } + + /// Apply a scanned/deep-linked payment payload (npub / `nostr:` / `goblin:` + /// URI / @handle, with optional amount+memo), mutating this flow exactly as + /// the camera scan path does. Shared by the QR scanner and the deep-link + /// handler so both spell the same destination. `now` seeds the search-box + /// debounce. + fn apply_scan(&mut self, text: &str, wallet: &Wallet, now: f64) { + match recognize_scan(text) { + // GoblinPay checkout / full pay link: recipient key AND amount are + // both known, so skip discovery and open review directly, prefilled. + // (Owner-ordered: a merchant checkout shouldn't re-walk the recipient + // search.) A cheap LOCAL contact lookup supplies a friendly name when + // we already know them; otherwise the short npub — no network + // resolution on this path. + ScanPrefill::Review { + hex, + relay_hints, + amount, + memo, + } => { + let name = wallet + .nostr_service() + .and_then(|s| s.store.contact(&hex).map(|c| display_name(&c))) + .unwrap_or_else(|| short_npub(&hex)); + self.recipient = Some(Recipient { + name, + npub: hex, + relay_hints, + }); + self.amount = amount; + if let Some(memo) = memo { + self.note = memo; + } + self.error = None; + self.stage = Stage::Review; + } + // Only a recipient (or a name/@handle needing resolution): drop it + // into the search box so the picker's debounced lookup resolves + + // verifies it like typed input, exactly as before. Any amount/memo + // still prefill. + ScanPrefill::Search { + recipient, + amount, + memo, + } => { + self.search = recipient; + self.input_changed_at = now; + self.lookup_query.clear(); + self.net_candidate = None; + if let Some(amount) = amount { + self.amount = amount; + } + if let Some(memo) = memo { + self.note = memo; + } + self.stage = Stage::Recipient; + } + } + } + /// Render the flow. Returns true when the flow is finished (close it). pub fn ui( &mut self, @@ -680,59 +752,10 @@ impl SendFlow { // seed words or slatepack contents into the search box. match &result { QrScanResult::Text(text) => { - // Classify the scan. UNTRUSTED input: the parser is pure and - // fail-closed, and every path still gates the send on the review - // screen (the user confirms) — nothing auto-SENDS. - match recognize_scan(text) { - // GoblinPay checkout QR: recipient key AND amount are both - // known, so skip discovery and open review directly, prefilled. - // (Owner-ordered: a merchant checkout shouldn't re-walk the - // recipient search.) A cheap LOCAL contact lookup supplies a - // friendly name when we already know them; otherwise the short - // npub — no network resolution on this path. - ScanPrefill::Review { - hex, - relay_hints, - amount, - memo, - } => { - let name = wallet - .nostr_service() - .and_then(|s| s.store.contact(&hex).map(|c| display_name(&c))) - .unwrap_or_else(|| short_npub(&hex)); - self.recipient = Some(Recipient { - name, - npub: hex, - relay_hints, - }); - self.amount = amount; - if let Some(memo) = memo { - self.note = memo; - } - self.error = None; - self.stage = Stage::Review; - } - // Only a recipient (or a name/@handle needing resolution): - // drop it into the search box so the picker's debounced lookup - // resolves + verifies it like typed input, exactly as before. - // Any amount/memo still prefill. - ScanPrefill::Search { - recipient, - amount, - memo, - } => { - self.search = recipient; - self.input_changed_at = ui.input(|i| i.time); - self.lookup_query.clear(); - self.net_candidate = None; - if let Some(amount) = amount { - self.amount = amount; - } - if let Some(memo) = memo { - self.note = memo; - } - } - } + // Classify + apply the scan. UNTRUSTED input: the parser is pure + // and fail-closed, and every path still gates the send on the + // review screen (the user confirms) — nothing auto-SENDS. + self.apply_scan(text, wallet, ui.input(|i| i.time)); } _ => self.error = Some(t!("goblin.send.scan_not_recipient").to_string()), } @@ -1633,6 +1656,43 @@ mod tests { } } + #[test] + fn goblin_scheme_pay_link_goes_straight_to_review() { + // The `goblin:` deep-link scheme (web "Open in Goblin" buttons) must + // classify identically to the `nostr:` QR payload: a full pay link + // (key + amount) lands on the prefilled review. + let uri = format!("goblin:{NPUB}?amount=1.5&memo=MM-ABC123"); + match recognize_scan(&uri) { + ScanPrefill::Review { + hex, + amount, + memo, + relay_hints, + } => { + assert_eq!(hex.len(), 64); + assert_eq!(amount, "1.5"); + assert_eq!(memo.as_deref(), Some("MM-ABC123")); + assert!(relay_hints.is_empty()); + } + other => panic!("expected Review, got {other:?}"), + } + } + + #[test] + fn goblin_and_nostr_schemes_classify_equivalently() { + // Same recipient + amount + memo under either scheme yields the same + // classification and the same extracted values. + for scheme in ["goblin", "nostr"] { + match recognize_scan(&format!("{scheme}:{NPUB}?amount=2&memo=Hi")) { + ScanPrefill::Review { amount, memo, .. } => { + assert_eq!(amount, "2"); + assert_eq!(memo.as_deref(), Some("Hi")); + } + other => panic!("{scheme}: expected Review, got {other:?}"), + } + } + } + #[test] fn key_without_amount_uses_search_box() { // A bare recipient key with no amount is NOT a full payment; the picker diff --git a/src/gui/views/wallets/content.rs b/src/gui/views/wallets/content.rs index f57c2d74..b87199ea 100644 --- a/src/gui/views/wallets/content.rs +++ b/src/gui/views/wallets/content.rs @@ -466,6 +466,17 @@ impl WalletsContent { if !Content::is_dual_panel_mode(ui.ctx()) && Content::is_network_panel_open() { Content::toggle_network_panel(); } + // Route a Goblin payment deep link (`goblin:` / `nostr:` pay URI) to the + // send-review flow instead of the slatepack message handler: stash it for + // the Goblin surface to open, and select/open the wallet with NO message + // so the surface shows. Same destination as scanning a checkout QR. + let data = match data { + Some(d) if crate::nostr::payuri::is_pay_uri(&d) => { + crate::set_pending_pay_uri(d); + None + } + other => other, + }; // Pass data to single wallet or show wallets selection. if wallets_size == 1 { let w = self.wallets.list()[0].clone(); diff --git a/src/lib.rs b/src/lib.rs index ace21303..fe9c8493 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -455,6 +455,25 @@ pub fn notify_payment_requested(name: &str, amount: &str) { lazy_static! { /// Data provided from deeplink or opened file. pub static ref INCOMING_DATA: Arc>> = Arc::new(RwLock::new(None)); + /// A pending `goblin:` / `nostr:` payment deep link, waiting for the Goblin + /// wallet surface to open its send-review flow. Separate from + /// [`INCOMING_DATA`] (slatepack messages / opened files): a payment link is + /// routed here so it lands on the prefilled review screen rather than the + /// slatepack message handler. Consumed by the Goblin view once a wallet is + /// open and showing. + static ref PENDING_PAY_URI: Arc>> = Arc::new(RwLock::new(None)); +} + +/// Stash a payment deep link for the Goblin surface to open (see +/// [`take_pending_pay_uri`]). The most recent link wins. +pub fn set_pending_pay_uri(uri: String) { + *PENDING_PAY_URI.write() = Some(uri); +} + +/// Take (and clear) a pending payment deep link, if any. The Goblin wallet view +/// polls this each frame and opens a prefilled send-review flow for it. +pub fn take_pending_pay_uri() -> Option { + PENDING_PAY_URI.write().take() } /// Callback from Java code with passed data. diff --git a/src/nostr/payuri.rs b/src/nostr/payuri.rs index 74835b37..b4bfd8fe 100644 --- a/src/nostr/payuri.rs +++ b/src/nostr/payuri.rs @@ -12,12 +12,16 @@ // See the License for the specific language governing permissions and // limitations under the License. -//! Pay-URI parser for scanned payment QRs. +//! Pay-URI parser for scanned payment QRs and payment deep links. //! -//! A GoblinPay checkout QR extends the plain `nostr:` URI with an optional -//! amount (and memo): +//! A GoblinPay checkout QR (or an "Open in Goblin" web button) carries an +//! optional amount (and memo) on the recipient, under either the `goblin:` +//! scheme (Goblin's own registered deep-link scheme, so the OS routes it to the +//! wallet) or the plain `nostr:` scheme (what a QR/social payload spells). Both +//! are the SAME payload — only the scheme differs: //! //! ```text +//! goblin:?amount=&memo= //! nostr:?amount=&memo= //! ``` //! @@ -39,6 +43,11 @@ const MAX_URI_LEN: usize = 4096; /// Memo byte cap (post control-strip), display / tx-message only. const MAX_MEMO_BYTES: usize = 256; +/// Schemes that unlock amount/memo parsing. `goblin:` is Goblin's registered +/// deep-link scheme (web "Open in Goblin" buttons, so the OS opens the wallet); +/// `nostr:` is the equivalent QR/social payload. Both spell the same payment. +const PAY_SCHEMES: [&str; 2] = ["goblin:", "nostr:"]; + /// A parsed pay-URI. `recipient` is fed to the existing resolver as-is (the /// bech32/name that used to go straight into the search box). `amount` is the /// raw decimal-GRIN string, present only when `amount_from_hr_string` accepted @@ -74,11 +83,11 @@ pub fn parse(scanned: &str) -> PayUri { return PayUri::bare(String::new()); } - // Strict scheme: only the `nostr:` prefix (case-insensitive) unlocks + // Strict scheme: only a `goblin:`/`nostr:` prefix (case-insensitive) unlocks // amount/memo parsing, matching the scanner's existing strip logic. Any // other payload (a bare npub, or some other scheme) is returned verbatim, // exactly as the scanner treated it before pay-URIs existed. - let rest = match strip_nostr_prefix(text) { + let rest = match strip_pay_scheme(text) { Some(rest) => rest, None => return PayUri::bare(text.to_string()), }; @@ -115,15 +124,27 @@ pub fn parse(scanned: &str) -> PayUri { } } -/// Strip a case-insensitive `nostr:` scheme prefix, returning the remainder. -/// Byte-safe against a leading multibyte char (no `[..6]` slice panic). -fn strip_nostr_prefix(text: &str) -> Option<&str> { - let head = text.get(..6)?; - if head.eq_ignore_ascii_case("nostr:") { - Some(&text[6..]) - } else { - None +/// Strip a case-insensitive payment scheme prefix (`goblin:` or `nostr:`), +/// returning the remainder. Byte-safe against a leading multibyte char (the +/// `text.get(..n)` guards against a `[..n]` slice panic). +fn strip_pay_scheme(text: &str) -> Option<&str> { + for scheme in PAY_SCHEMES { + let n = scheme.len(); + if let Some(head) = text.get(..n) { + if head.eq_ignore_ascii_case(scheme) { + return Some(&text[n..]); + } + } } + None +} + +/// True when `scanned` (once trimmed) carries a Goblin payment scheme +/// (`goblin:` or `nostr:`) — i.e. a payment deep link rather than a slatepack +/// message or opened file. Used to route an incoming argv/intent payload to the +/// send-review flow instead of the slatepack message handler. +pub fn is_pay_uri(scanned: &str) -> bool { + strip_pay_scheme(scanned.trim()).is_some() } /// Validate an `amount` value: percent-decode, then accept it ONLY if the @@ -231,6 +252,46 @@ mod tests { assert_eq!(out.amount.as_deref(), Some("2")); } + #[test] + fn goblin_scheme_equivalent_to_nostr() { + // The `goblin:` deep-link scheme is the same payload as `nostr:` — it + // MUST parse to the identical recipient / amount / memo. This is the + // contract behind the web "Open in Goblin" buttons. + let nostr = parse(&format!("nostr:{NPROFILE}?amount=1.5&memo=Coffee")); + let goblin = parse(&format!("goblin:{NPROFILE}?amount=1.5&memo=Coffee")); + assert_eq!(goblin, nostr); + assert_eq!(goblin.recipient, NPROFILE); + assert_eq!(goblin.amount.as_deref(), Some("1.5")); + assert_eq!(goblin.memo.as_deref(), Some("Coffee")); + } + + #[test] + fn goblin_scheme_case_insensitive() { + let out = parse(&format!("GOBLIN:{NPROFILE}?amount=2")); + assert_eq!(out.recipient, NPROFILE); + assert_eq!(out.amount.as_deref(), Some("2")); + } + + #[test] + fn bare_goblin_nprofile_unchanged() { + let out = parse(&format!("goblin:{NPROFILE}")); + assert_eq!(out.recipient, NPROFILE); + assert_eq!(out.amount, None); + assert_eq!(out.memo, None); + } + + #[test] + fn is_pay_uri_recognizes_both_schemes() { + assert!(is_pay_uri(&format!("goblin:{NPROFILE}?amount=1"))); + assert!(is_pay_uri(&format!("nostr:{NPROFILE}"))); + assert!(is_pay_uri(&format!(" GOBLIN:{NPROFILE} "))); + // A slatepack message / bare key / other scheme is NOT a pay URI. + assert!(!is_pay_uri("BEGINSLATEPACK. abc DEFG. ENDSLATEPACK.")); + assert!(!is_pay_uri("npub1abcdef")); + assert!(!is_pay_uri("bitcoin:bc1qxyz?amount=1")); + assert!(!is_pay_uri("")); + } + #[test] fn with_amount() { let out = parse(&format!("nostr:{NPROFILE}?amount=1.5")); diff --git a/wix/main.wxs b/wix/main.wxs index c25f7159..b895faed 100644 --- a/wix/main.wxs +++ b/wix/main.wxs @@ -59,6 +59,22 @@ + + + + + + + + + + + + + + @@ -77,6 +93,7 @@ +