From 50914b7b9bf7cb4f19980208caa57542440e9694 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Fri, 4 Sep 2026 21:44:27 +0700 Subject: [PATCH] fix(amber): call setPackageName before signing so the plugin opens Amber (1.2.9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against the plugin's Java source: signEvent/nip*Encrypt/Decrypt IMMEDIATELY reject with "Signer package name not set" when no package is configured — which is exactly what my earlier "omit packageName" change caused, and that rejection surfaced as the endless spinner on delete/accept/zap. With the package set, the plugin tries the content resolver, and when that returns null (permission not remembered) it falls back to startActivityForResult(nostrsigner:) — the SAME native call login uses that reliably opens Amber. Restore native plugin signEvent/nip04/nip44 with setPackageName + AMBER_PACKAGE, reconstruct signature-only responses, and remove the unused JS-intent bridge. Bump to 1.2.9. Co-authored-by: shakespeare.diy --- package.json | 2 +- src/components/DeepLinkHandler.tsx | 11 - src/lib/amberIntentBridge.ts | 314 ----------------------------- src/lib/capacitorAmberSigner.ts | 115 ++++++++--- 4 files changed, 89 insertions(+), 353 deletions(-) delete mode 100644 src/lib/amberIntentBridge.ts diff --git a/package.json b/package.json index 1090b39..3480127 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "skate-spots", "private": true, - "version": "1.2.8", + "version": "1.2.9", "type": "module", "homepage": "https://skate-spots.com", "scripts": { diff --git a/src/components/DeepLinkHandler.tsx b/src/components/DeepLinkHandler.tsx index cfdd48c..68a9927 100644 --- a/src/components/DeepLinkHandler.tsx +++ b/src/components/DeepLinkHandler.tsx @@ -1,6 +1,5 @@ import { useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { resolveAmberIntent } from '@/lib/amberIntentBridge'; /** * Forwards OS-level deep-link opens into the React Router navigation stack. @@ -45,16 +44,6 @@ export function DeepLinkHandler() { AppPlugin.addListener('appUrlOpen', (event) => { try { - // FIRST: try to resolve an in-process Amber Intent operation. The native - // signer (CapacitorAmberSigner) drives sign/encrypt/decrypt through the - // OS Intent flow and awaits the result via this deep-link callback. If - // this URL carries an `id=` that matches a pending op, resolve it HERE - // and stop — we must NOT navigate to /amber-callback (that path is the - // web/PWA reload-based flow and would double-process the result). - if (resolveAmberIntent(event.url)) { - return; - } - const url = new URL(event.url); // Pre-capture Amber callback event param into sessionStorage so // AmberCallbackPage can find it after React Router renders the route. diff --git a/src/lib/amberIntentBridge.ts b/src/lib/amberIntentBridge.ts deleted file mode 100644 index 8fa53ad..0000000 --- a/src/lib/amberIntentBridge.ts +++ /dev/null @@ -1,314 +0,0 @@ -/** - * amberIntentBridge - * ================= - * - * Reliable Amber (NIP-55) signing/encryption for the NATIVE Android app - * (Capacitor / ZapStore APK), using the OS **Intent** flow with a deep-link - * callback — NOT the capacitor plugin's Content-Resolver path. - * - * WHY THIS EXISTS - * --------------- - * The `nostr-signer-capacitor-plugin` `signEvent()` tries Amber's *Content - * Resolver* FIRST (a background, no-UI query). Per NIP-55 that only works for - * permissions the user chose to "remember", and it returns `null` otherwise so - * the client can fall back to an Intent. On GrapheneOS that cross-app - * ContentResolver query does not return cleanly — it STALLS — so Amber's - * approval screen never opens and the request hangs forever ("stuck on - * Deleting…", "Accept invite spins", Amber never pops up). - * - * Login works because it uses `getPublicKey` which, without remembered - * permissions, falls through to `startActivityForResult(nostrsigner:)` and - * launches Amber's UI. We mirror that reliable behaviour for EVERY signing and - * encryption op by driving the Intent directly: - * - * 1. Build an `intent:#Intent;scheme=nostrsigner;...;end` URL with a - * `callbackUrl` pointing back into the app via its custom scheme - * (`org.skatespots.app://amber-callback?id=&event=`). - * 2. Open it (Capacitor App.openUrl → falls back to window.location.href). - * Android launches Amber's approval UI — the path that WORKS on GrapheneOS. - * 3. Return a Promise kept pending in an in-memory registry keyed by `opId`. - * 4. When the user approves, Amber appends the result and reopens the app via - * the deep link. `resolveAmberIntent(url)` (called from DeepLinkHandler) - * matches `opId` and resolves the pending Promise IN PROCESS — no page - * reload, so the original mutation (delete, accept invite, zap, …) - * completes normally. - * - * This module has NO hard dependency on @capacitor/app; it uses the global - * window.Capacitor bridge so the web/CI build is unaffected. - */ - -import type { NostrEvent } from '@nostrify/nostrify'; -import { reconstructSignedEvent } from '@/lib/amber'; - -/** Amber operation types we drive through the Intent bridge. */ -export type AmberIntentType = - | 'sign_event' - | 'nip04_encrypt' - | 'nip04_decrypt' - | 'nip44_encrypt' - | 'nip44_decrypt'; - -interface PendingOp { - type: AmberIntentType; - /** Resolve with the RAW string result from Amber (sig / event JSON / ciphertext / plaintext). */ - resolve: (rawResult: string) => void; - reject: (err: Error) => void; - timer: ReturnType; - /** Original unsigned event JSON — needed to reconstruct a full signed event. */ - eventJson?: string; - /** The signer's own pubkey (hex) — used when reconstructing. */ - currentUser: string; -} - -/** - * In-memory registry of operations awaiting an Amber callback. Keyed by a - * unique per-request id embedded in the callbackUrl so the deep-link handler - * can resolve exactly the right promise even if several are outstanding. - */ -const pending = new Map(); - -/** Native app's custom URI scheme (matches Capacitor appId). */ -const CALLBACK_SCHEME = 'org.skatespots.app'; - -/** - * How long to wait for Amber to come back before rejecting. Interactive taps - * (delete, accept, zap) should fail fast with a clear message rather than hang. - * The user has to switch to Amber, approve, and switch back — 60s is generous. - */ -const INTENT_TIMEOUT_MS = 60_000; - -function makeOpId(): string { - return `si_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; -} - -/** - * Launch an `intent:` URL so Amber opens. - * - * We navigate the WebView to the intent URL. Our custom native MainActivity - * (see android-templates/MainActivity.java) intercepts `intent:` / - * `nostrsigner:` navigations in shouldOverrideUrlLoading and hands them to the - * OS via startActivity() — a real native launch that opens Amber (the default - * Capacitor WebView silently drops these, which is why Amber never appeared). - * - * We assign to location.href in a microtask-safe way so the surrounding promise - * registration (in runIntent) has already stored the pending op before Android - * potentially pauses/replaces our WebView. - */ -async function openExternalUrl(url: string): Promise { - // Defer one tick so the pending-op registry is populated before we navigate. - await Promise.resolve(); - window.location.href = url; -} - -/** - * Build the Android `intent:` URL for Amber. - * - * Format (per Amber README / NIP-55 web section): - * intent:#Intent;scheme=nostrsigner;S.k=v;...;end - * - * - `payload` is the request body (event JSON / plaintext / ciphertext), - * URI-encoded and placed BEFORE the `#`. - * - Extra params are `S.=` pairs separated by `;`. - * - `callbackUrl` is embedded RAW (no encoding) and MUST NOT contain a `#`. - */ -function buildIntentUrl(payload: string, params: Record): string { - const extras = Object.entries(params) - .map(([k, v]) => `S.${k}=${v}`) - .join(';'); - return `intent:${payload}#Intent;scheme=nostrsigner;${extras};end`; -} - -/** - * Launch Amber for a single operation and return a promise that resolves with - * the RAW string result once the app is reopened via the deep-link callback. - */ -function runIntent(args: { - type: AmberIntentType; - /** Request body sent to Amber (already the plaintext/ciphertext/event JSON). */ - payload: string; - /** Amber return type: 'event' for sign, 'signature' for encrypt/decrypt. */ - returnType: 'event' | 'signature'; - /** Counterparty pubkey for encryption ops. */ - pubKey?: string; - /** Original event JSON (sign only) for reconstruction on the way back. */ - eventJson?: string; - currentUser: string; -}): Promise { - const opId = makeOpId(); - - // Callback carries the opId so the deep-link handler resolves the right - // promise, plus the trailing `event=` where Amber appends the result. - const callbackUrl = `${CALLBACK_SCHEME}://amber-callback?id=${opId}&event=`; - - const params: Record = { - type: args.type, - compressionType: 'none', - returnType: args.returnType, - callbackUrl, - current_user: args.currentUser, - }; - if (args.pubKey) params.pubKey = args.pubKey; - - const intentUrl = buildIntentUrl(encodeURIComponent(args.payload), params); - - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - pending.delete(opId); - reject( - new Error( - "Amber didn't respond. Open Amber, approve the request, then try again. " + - 'If nothing appeared, make sure Amber is installed and allowed to run.', - ), - ); - }, INTENT_TIMEOUT_MS); - - pending.set(opId, { - type: args.type, - resolve, - reject, - timer, - eventJson: args.eventJson, - currentUser: args.currentUser, - }); - - // Fire the intent AFTER registering so a fast callback can't race us. - openExternalUrl(intentUrl).catch((err) => { - const op = pending.get(opId); - if (op) { - clearTimeout(op.timer); - pending.delete(opId); - } - reject(err instanceof Error ? err : new Error(String(err))); - }); - }); -} - -/** - * Resolve a pending Amber operation from a deep-link callback URL. - * - * Called by DeepLinkHandler on every `appUrlOpen`. Returns `true` if the URL - * was an Amber intent-bridge callback that matched a pending op (so the caller - * can stop further routing), `false` otherwise. - */ -export function resolveAmberIntent(rawUrl: string): boolean { - let url: URL; - try { - url = new URL(rawUrl); - } catch { - return false; - } - - const opId = url.searchParams.get('id'); - if (!opId) return false; - - const op = pending.get(opId); - if (!op) { - // No matching op (already resolved / timed out). Still consume it so the - // generic navigation handler doesn't try to route to /amber-callback. - return true; - } - - clearTimeout(op.timer); - pending.delete(opId); - - const result = url.searchParams.get('event') ?? ''; - if (!result) { - op.reject( - new Error('Amber returned no result. The request may have been rejected.'), - ); - return true; - } - - op.resolve(result); - return true; -} - -/** True if there are outstanding intent operations awaiting a callback. */ -export function hasPendingAmberIntent(): boolean { - return pending.size > 0; -} - -// ─── High-level operations used by the native signer ───────────────────────── - -/** Sign an event via the Amber Intent flow; resolves to a full NostrEvent. */ -export async function amberSignEventViaIntent( - event: Omit, - currentUser: string, -): Promise { - const eventJson = JSON.stringify(event); - const raw = await runIntent({ - type: 'sign_event', - payload: eventJson, - returnType: 'event', - eventJson, - currentUser, - }); - - const signed = reconstructSignedEvent(raw, eventJson, currentUser); - if (!signed || !signed.sig) { - throw new Error( - 'Amber returned an incomplete signature. Open Amber, approve the request, then try again.', - ); - } - return signed; -} - -/** NIP-04 encrypt via the Amber Intent flow. */ -export function amberNip04EncryptViaIntent( - peerPubkey: string, - plaintext: string, - currentUser: string, -): Promise { - return runIntent({ - type: 'nip04_encrypt', - payload: plaintext, - returnType: 'signature', - pubKey: peerPubkey, - currentUser, - }); -} - -/** NIP-04 decrypt via the Amber Intent flow. */ -export function amberNip04DecryptViaIntent( - peerPubkey: string, - ciphertext: string, - currentUser: string, -): Promise { - return runIntent({ - type: 'nip04_decrypt', - payload: ciphertext, - returnType: 'signature', - pubKey: peerPubkey, - currentUser, - }); -} - -/** NIP-44 encrypt via the Amber Intent flow. */ -export function amberNip44EncryptViaIntent( - peerPubkey: string, - plaintext: string, - currentUser: string, -): Promise { - return runIntent({ - type: 'nip44_encrypt', - payload: plaintext, - returnType: 'signature', - pubKey: peerPubkey, - currentUser, - }); -} - -/** NIP-44 decrypt via the Amber Intent flow. */ -export function amberNip44DecryptViaIntent( - peerPubkey: string, - ciphertext: string, - currentUser: string, -): Promise { - return runIntent({ - type: 'nip44_decrypt', - payload: ciphertext, - returnType: 'signature', - pubKey: peerPubkey, - currentUser, - }); -} diff --git a/src/lib/capacitorAmberSigner.ts b/src/lib/capacitorAmberSigner.ts index d926a85..718dc40 100644 --- a/src/lib/capacitorAmberSigner.ts +++ b/src/lib/capacitorAmberSigner.ts @@ -30,13 +30,7 @@ import type { NostrEvent, NostrSigner } from '@nostrify/nostrify'; import { Capacitor, registerPlugin } from '@capacitor/core'; import { nip19 } from 'nostr-tools'; -import { - amberSignEventViaIntent, - amberNip04EncryptViaIntent, - amberNip04DecryptViaIntent, - amberNip44EncryptViaIntent, - amberNip44DecryptViaIntent, -} from '@/lib/amberIntentBridge'; +import { reconstructSignedEvent } from '@/lib/amber'; const AMBER_PACKAGE = 'com.greenart7c3.nostrsigner'; @@ -52,6 +46,10 @@ interface NostrSignerNative { nip44Decrypt(options: { packageName?: string; encryptedText: string; pubKey: string; npub: string; id?: string }): Promise<{ result: string; id?: string }>; } +function makeId(): string { + return `${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; +} + // How long to wait for Amber to return before giving up. Amber is a separate // app: the user has to switch to it, approve, and switch back. If the native // activity-result never fires (user backs out, Amber not granted, etc.) the @@ -188,33 +186,96 @@ export class CapacitorAmberSigner implements NostrSigner { return this.pubkey; } + /** + * Get the native plugin AND ensure the Amber package name is set on it. + * + * THE REAL FIX (verified against the plugin's Java source): + * The plugin's signEvent/nip*Encrypt/Decrypt IMMEDIATELY reject with + * "Signer package name not set. Call setPackageName first." if no package is + * configured — which is exactly what happened when we (wrongly) omitted it, and + * that rejection surfaced as the endless spinner. + * + * With the package set, the plugin tries Amber's Content Resolver first; when + * that returns null (permission not "remembered"), it FALLS BACK to + * startActivityForResult(nostrsigner:…) — the SAME native call login uses that + * reliably OPENS AMBER's approval screen. So the correct behaviour is: set the + * package, then call the native method. No JS-intent navigation needed. + */ + private async plugin(): Promise { + const plugin = getNativeSigner(); + if (!plugin) { + throw new Error('Amber signing is only available in the SkateSpots Android app.'); + } + // Idempotent; safe to call before every op. This is what unblocks signing. + await plugin.setPackageName({ packageName: AMBER_PACKAGE }); + return plugin; + } + async signEvent(event: Omit): Promise { - // CRITICAL FIX (GrapheneOS + others): drive Amber via the OS INTENT flow with - // a deep-link callback — NOT the capacitor plugin's Content-Resolver path. - // - // The plugin's signEvent() queries Amber's ContentProvider first. On - // GrapheneOS that background cross-app query STALLS and never returns, so - // Amber's approval screen never opens and signing hangs forever (the "stuck - // on Deleting…", "Accept invite spins", "Amber never pops up" bug). Login - // works only because getPublicKey falls through to an Intent. Here we use the - // Intent directly for signing too: it force-launches Amber's UI (the path - // that reliably works) and the result comes back via the app's custom-scheme - // deep link, resolved in-process by resolveAmberIntent() (see DeepLinkHandler). - return amberSignEventViaIntent(event, this.pubkey); + const plugin = await this.plugin(); + const res = await withTimeout( + plugin.signEvent({ + packageName: AMBER_PACKAGE, + eventJson: JSON.stringify(event), + eventId: makeId(), + npub: this.npub, + }), + 'signature', + ); + + // Amber may return a full event JSON in `event`, or only a bare `signature`. + // Reconstruct defensively so signature-only responses (common for kind:5 + // deletions and kind:1985 labels) still produce a valid, publishable event. + const raw = + res.event && res.event.trim() + ? res.event + : (res.signature ?? ''); + + const signed = reconstructSignedEvent(raw, JSON.stringify(event), this.pubkey); + if (!signed || !signed.sig) { + throw new Error( + 'Amber returned an incomplete signature. Open Amber, approve the request, then try again.', + ); + } + return signed; } readonly nip04 = { - encrypt: (pubkey: string, plaintext: string): Promise => - amberNip04EncryptViaIntent(pubkey, plaintext, this.pubkey), - decrypt: (pubkey: string, ciphertext: string): Promise => - amberNip04DecryptViaIntent(pubkey, ciphertext, this.pubkey), + encrypt: async (pubkey: string, plaintext: string): Promise => { + const plugin = await this.plugin(); + const { result } = await withTimeout( + plugin.nip04Encrypt({ packageName: AMBER_PACKAGE, plainText: plaintext, pubKey: pubkey, npub: this.npub, id: makeId() }), + 'encryption', + ); + return result; + }, + decrypt: async (pubkey: string, ciphertext: string): Promise => { + const plugin = await this.plugin(); + const { result } = await withTimeout( + plugin.nip04Decrypt({ packageName: AMBER_PACKAGE, encryptedText: ciphertext, pubKey: pubkey, npub: this.npub, id: makeId() }), + 'decryption', + ); + return result; + }, }; readonly nip44 = { - encrypt: (pubkey: string, plaintext: string): Promise => - amberNip44EncryptViaIntent(pubkey, plaintext, this.pubkey), - decrypt: (pubkey: string, ciphertext: string): Promise => - amberNip44DecryptViaIntent(pubkey, ciphertext, this.pubkey), + encrypt: async (pubkey: string, plaintext: string): Promise => { + const plugin = await this.plugin(); + const { result } = await withTimeout( + plugin.nip44Encrypt({ packageName: AMBER_PACKAGE, plainText: plaintext, pubKey: pubkey, npub: this.npub, id: makeId() }), + 'encryption', + ); + return result; + }, + decrypt: async (pubkey: string, ciphertext: string): Promise => { + const plugin = await this.plugin(); + const { result } = await withTimeout( + plugin.nip44Decrypt({ packageName: AMBER_PACKAGE, encryptedText: ciphertext, pubKey: pubkey, npub: this.npub, id: makeId() }), + 'decryption', + ); + return result; + }, }; }