mirror of
https://code.gri.mw/GUI/grim.git
synced 2026-07-12 17:58:55 +00:00
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.
This commit is contained in:
@@ -59,6 +59,16 @@
|
||||
<data android:pathPattern=".*\\.slatepack" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Goblin payment deep link: web "Open in Goblin" buttons and
|
||||
goblin: QR/links open the wallet straight to the send-review
|
||||
screen. BROWSABLE so a browser/click can resolve it. -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="goblin" />
|
||||
</intent-filter>
|
||||
|
||||
<meta-data android:name="android.app.lib_name" android:value="grim" />
|
||||
</activity>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
MimeType=application/x-slatepack;text/plain;x-scheme-handler/goblin;
|
||||
@@ -57,6 +57,19 @@
|
||||
<string>Document</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>st.goblin.pay</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>goblin</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.finance</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
|
||||
@@ -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);
|
||||
|
||||
+113
-53
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
+19
@@ -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<RwLock<Option<String>>> = 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<RwLock<Option<String>>> = 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<String> {
|
||||
PENDING_PAY_URI.write().take()
|
||||
}
|
||||
|
||||
/// Callback from Java code with passed data.
|
||||
|
||||
+74
-13
@@ -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:<nprofile-or-npub>?amount=<decimal GRIN>&memo=<percent-encoded>
|
||||
//! nostr:<nprofile-or-npub>?amount=<decimal GRIN>&memo=<percent-encoded>
|
||||
//! ```
|
||||
//!
|
||||
@@ -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"));
|
||||
|
||||
@@ -59,6 +59,22 @@
|
||||
</Extension>
|
||||
</ProgId>
|
||||
</Component>
|
||||
|
||||
<!-- Register the goblin: URL protocol so web "Open in Goblin" pay
|
||||
buttons launch the wallet with the payment link as argv[1],
|
||||
which routes to the prefilled send-review screen. -->
|
||||
<Component Id="GoblinUrlProtocol" Guid="a4f1c2e8-9b3d-4e6a-8c7f-2d1b5e9a0c34">
|
||||
<RegistryKey Root="HKCR" Key="goblin">
|
||||
<RegistryValue Type="string" Value="URL:Goblin Protocol" KeyPath="yes"/>
|
||||
<RegistryValue Name="URL Protocol" Type="string" Value=""/>
|
||||
<RegistryKey Key="shell\open\command">
|
||||
<RegistryValue Type="string" Value=""[#goblin.exe]" "%1""/>
|
||||
</RegistryKey>
|
||||
<RegistryKey Key="DefaultIcon">
|
||||
<RegistryValue Type="string" Value="[#goblin.exe]"/>
|
||||
</RegistryKey>
|
||||
</RegistryKey>
|
||||
</Component>
|
||||
</DirectoryRef>
|
||||
|
||||
<!-- Step 2: Add the shortcut to your installer package -->
|
||||
@@ -77,6 +93,7 @@
|
||||
|
||||
<Feature Id="MainApplication" Title="Goblin" Level="1">
|
||||
<ComponentRef Id="goblin.exe" />
|
||||
<ComponentRef Id="GoblinUrlProtocol" />
|
||||
<ComponentRef Id="License" />
|
||||
<!-- Step 3: Tell WiX to install the shortcut -->
|
||||
<ComponentRef Id="ApplicationShortcutDesktop" />
|
||||
|
||||
Reference in New Issue
Block a user