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 @@
+