diff --git a/src/components/DeepLinkHandler.tsx b/src/components/DeepLinkHandler.tsx index 68a9927..cfdd48c 100644 --- a/src/components/DeepLinkHandler.tsx +++ b/src/components/DeepLinkHandler.tsx @@ -1,5 +1,6 @@ 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. @@ -44,6 +45,16 @@ 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/hooks/useCrewInvites.ts b/src/hooks/useCrewInvites.ts index b888cdc..8b91934 100644 --- a/src/hooks/useCrewInvites.ts +++ b/src/hooks/useCrewInvites.ts @@ -191,6 +191,8 @@ export function useAcceptCrewInvite() { const coord = parseAddrCoord(invite.crewCoord); if (!coord) throw new Error('Invalid crew reference.'); + // The ONE required action: publish the membership attestation. This is the + // single signature the UI waits on. await confirm({ kind: coord.kind, founderPubkey: coord.pubkey, @@ -199,7 +201,12 @@ export function useAcceptCrewInvite() { visibility, }); - await dismiss(invite.id); + // Dismiss is optimistic/local-first and best-effort for cross-device sync; + // fire it WITHOUT blocking accept so a second signer round-trip can never + // leave the Accept button stuck on "loading". + void dismiss(invite.id).catch(() => { + /* local dismissal already applied; sync is best-effort */ + }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['crew-invites'] }); diff --git a/src/lib/amberIntentBridge.ts b/src/lib/amberIntentBridge.ts new file mode 100644 index 0000000..2c221ba --- /dev/null +++ b/src/lib/amberIntentBridge.ts @@ -0,0 +1,317 @@ +/** + * 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)}`; +} + +/** Open a URL via the native runtime, falling back to a plain navigation. */ +async function openExternalUrl(url: string): Promise { + const cap = (window as unknown as { + Capacitor?: { + isNativePlatform?: () => boolean; + Plugins?: { App?: { openUrl?: (o: { url: string }) => Promise } }; + }; + }).Capacitor; + + const openUrl = cap?.Plugins?.App?.openUrl; + if (openUrl) { + try { + await openUrl({ url }); + return; + } catch { + // fall through to location navigation + } + } + // Fallback: assign to location. On Android WebView this still fires the intent. + 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 1f4e458..d926a85 100644 --- a/src/lib/capacitorAmberSigner.ts +++ b/src/lib/capacitorAmberSigner.ts @@ -29,7 +29,14 @@ import type { NostrEvent, NostrSigner } from '@nostrify/nostrify'; import { Capacitor, registerPlugin } from '@capacitor/core'; -import { nip19, getEventHash } from 'nostr-tools'; +import { nip19 } from 'nostr-tools'; +import { + amberSignEventViaIntent, + amberNip04EncryptViaIntent, + amberNip04DecryptViaIntent, + amberNip44EncryptViaIntent, + amberNip44DecryptViaIntent, +} from '@/lib/amberIntentBridge'; const AMBER_PACKAGE = 'com.greenart7c3.nostrsigner'; @@ -45,10 +52,6 @@ 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 @@ -186,162 +189,33 @@ export class CapacitorAmberSigner implements NostrSigner { } async signEvent(event: Omit): Promise { - // CRITICAL (GrapheneOS + others): do NOT pass packageName here. When a - // packageName is provided, the native plugin first attempts Amber's - // ContentProvider (a background, no-UI query) which stalls on GrapheneOS and - // NEVER launches Amber's approval screen — so the user sees no prompt and the - // request just times out ("stuck on Deleting…", Amber never popped up). This - // is the exact same trap the login flow (getAmberPublicKeyViaPlugin) avoids - // by omitting packageName so the plugin goes straight to - // startActivityForResult(nostrsigner:) which shows Amber's UI. We mirror that - // here: use the raw native signer with NO packageName. - const plugin = getNativeSigner(); - if (!plugin) { - throw new Error('Amber signing is only available in the SkateSpots Android app.'); - } - const res = await withTimeout( - plugin.signEvent({ - eventJson: JSON.stringify(event), - eventId: makeId(), - npub: this.npub, - }), - 'signature', - ); - - // Amber's response shape varies across builds: some return the full signed - // event JSON in `event`, others return only `signature` (expecting the caller - // to attach it to the original event). If we blindly `JSON.parse` an empty - // `event`, or return an event with no `sig`, the resulting object silently - // publishes as a malformed event and stalls the relay forever — the exact - // "publish hangs" symptom. Handle every shape defensively and always fail - // loud rather than emitting a bad event. - let signed: Partial | null = null; - - if (res.event && res.event.trim()) { - try { - signed = JSON.parse(res.event) as Partial; - } catch { - signed = null; - } - } - - // Fall back to reconstructing the event from a bare signature. - if ((!signed || !signed.sig) && res.signature) { - signed = { ...(signed ?? {}), sig: res.signature }; - } - - if (!signed || !signed.sig) { - throw new Error( - 'Amber returned an incomplete signature. Open Amber, approve the request, then try again.', - ); - } - - // Merge Amber's response over the original event so required fields are - // never lost, then verify the essentials are present before returning. - const pubkey = signed.pubkey ?? this.pubkey; - const base = { - kind: signed.kind ?? event.kind, - content: signed.content ?? event.content, - tags: signed.tags ?? event.tags, - created_at: signed.created_at ?? event.created_at, - pubkey, - }; - - // CRITICAL: Amber (NIP-55) commonly returns ONLY the `signature` for events - // like kind:5 deletions and kind:1985 labels — no `id` and no full event - // JSON. Previously we left `id: ''`, which failed the malformed-event check - // below (or emitted a bad event that stalled the relay forever). The event - // id is a deterministic NIP-01 hash of these fields, so we compute it - // ourselves instead of depending on Amber to echo it back. This makes - // signature-only responses work for EVERY kind — including admin takedowns. - const id = - signed.id && signed.id.length === 64 ? signed.id : getEventHash(base); - - const merged: NostrEvent = { - ...base, - id, - sig: signed.sig, - }; - - if (!merged.id || !merged.sig || !merged.pubkey) { - throw new Error( - 'Amber returned a malformed event. Please retry, or restart Amber if it persists.', - ); - } - - return merged; + // 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); } readonly nip04 = { - encrypt: async (pubkey: string, plaintext: string): Promise => { - const plugin = this.rawPlugin(); - const { result } = await withTimeout( - plugin.nip04Encrypt({ - plainText: plaintext, - pubKey: pubkey, - npub: this.npub, - id: makeId(), - }), - 'encryption', - ); - return result; - }, - decrypt: async (pubkey: string, ciphertext: string): Promise => { - const plugin = this.rawPlugin(); - const { result } = await withTimeout( - plugin.nip04Decrypt({ - encryptedText: ciphertext, - pubKey: pubkey, - npub: this.npub, - id: makeId(), - }), - 'decryption', - ); - return result; - }, + encrypt: (pubkey: string, plaintext: string): Promise => + amberNip04EncryptViaIntent(pubkey, plaintext, this.pubkey), + decrypt: (pubkey: string, ciphertext: string): Promise => + amberNip04DecryptViaIntent(pubkey, ciphertext, this.pubkey), }; readonly nip44 = { - encrypt: async (pubkey: string, plaintext: string): Promise => { - const plugin = this.rawPlugin(); - const { result } = await withTimeout( - plugin.nip44Encrypt({ - plainText: plaintext, - pubKey: pubkey, - npub: this.npub, - id: makeId(), - }), - 'encryption', - ); - return result; - }, - decrypt: async (pubkey: string, ciphertext: string): Promise => { - const plugin = this.rawPlugin(); - const { result } = await withTimeout( - plugin.nip44Decrypt({ - encryptedText: ciphertext, - pubKey: pubkey, - npub: this.npub, - id: makeId(), - }), - 'decryption', - ); - return result; - }, + encrypt: (pubkey: string, plaintext: string): Promise => + amberNip44EncryptViaIntent(pubkey, plaintext, this.pubkey), + decrypt: (pubkey: string, ciphertext: string): Promise => + amberNip44DecryptViaIntent(pubkey, ciphertext, this.pubkey), }; - - /** - * Raw native signer WITHOUT packageName — same rationale as signEvent: passing - * a packageName triggers Amber's stalling ContentProvider path on GrapheneOS - * so the approval UI never appears. Throws clearly if not on native. - */ - private rawPlugin(): NostrSignerNative { - const plugin = getNativeSigner(); - if (!plugin) { - throw new Error('Amber signing is only available in the SkateSpots Android app.'); - } - return plugin; - } } /**