/** * CapacitorAmberSigner * * Talks to the native Amber signer (nostr-signer-capacitor-plugin) over the * Capacitor bridge. * * WHY WE DON'T `import('nostr-signer-capacitor-plugin')` ANYMORE * ------------------------------------------------------------- * The package is marked `external` in vite.config.ts and the native build uses * `inlineDynamicImports`, so a runtime `import('nostr-signer-capacitor-plugin')` * stays in the bundle as a BARE specifier the Android WebView cannot resolve. * That import threw, `getPlugin()` returned null, and every signing operation * (including the Blossom upload auth event) failed with * "Capacitor Amber plugin not available". * * THE FIX: the plugin's JS layer is just a thin wrapper around * `registerPlugin('NostrSignerPlugin')` from @capacitor/core. @capacitor/core * IS bundled, so we call `registerPlugin` ourselves to get the native bridge * proxy directly and reimplement the (tiny, well-known) wrapper logic here. * This is the same runtime-plugin pattern that fixed the camera + white-screen. * * API shape reference: coracle-social/nostr-signer-capacitor-plugin#main * native.signEvent({ packageName, eventJson, eventId, npub }) * → { signature, id, event } * native.getPublicKey({ packageName, permissions? }) → { npub, package } * native.nipXXEncrypt/Decrypt({ packageName, plainText|encryptedText, pubKey, npub, id }) * → { result, id } */ import type { NostrEvent, NostrSigner } from '@nostrify/nostrify'; import { Capacitor, registerPlugin } from '@capacitor/core'; import { nip19 } from 'nostr-tools'; const AMBER_PACKAGE = 'com.greenart7c3.nostrsigner'; /** Raw native bridge interface (Android). All params are options objects. */ interface NostrSignerNative { setPackageName(options: { packageName: string }): Promise; isExternalSignerInstalled(options?: { packageName?: string }): Promise<{ installed: boolean }>; getPublicKey(options?: { packageName?: string; permissions?: string }): Promise<{ npub: string; package?: string }>; signEvent(options: { packageName?: string; eventJson: string; eventId: string; npub: string }): Promise<{ signature?: string; id?: string; event: string }>; nip04Encrypt(options: { packageName?: string; plainText: string; pubKey: string; npub: string; id?: string }): Promise<{ result: string; id?: string }>; nip04Decrypt(options: { packageName?: string; encryptedText: string; pubKey: string; npub: string; id?: string }): Promise<{ result: string; id?: string }>; nip44Encrypt(options: { packageName?: string; plainText: string; pubKey: string; npub: string; id?: string }): Promise<{ result: string; id?: string }>; 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 // bridge promise would otherwise hang FOREVER — which is exactly the "stuck // loading" symptom on the crew logo upload and crew publish. This turns that // silent hang into a clear, catchable error. const AMBER_TIMEOUT_MS = 90_000; /** * Race a bridge promise against a timeout so a hung Amber round-trip surfaces * as an error instead of spinning forever. */ function withTimeout(promise: Promise, label: string): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject( new Error( `Amber didn't respond to the ${label} request. Open Amber, approve the request, then try again. ` + `If nothing appeared, make sure Amber is installed and SkateSpots is allowed to use it.`, ), ); }, AMBER_TIMEOUT_MS); promise.then( (value) => { clearTimeout(timer); resolve(value); }, (err) => { clearTimeout(timer); reject(err); }, ); }); } // Lazily build the native bridge proxy once. `registerPlugin` is a pure factory // (builds a proxy, touches no native APIs at call time), so it's safe on web too // — the proxy simply has no implementation there and calls reject. let nativePlugin: NostrSignerNative | null = null; function getNativeSigner(): NostrSignerNative | null { try { if (!Capacitor?.isNativePlatform?.()) return null; } catch { return null; } if (!nativePlugin) { nativePlugin = registerPlugin('NostrSignerPlugin'); } return nativePlugin; } // Set the Amber package name exactly ONCE per app session. Calling it before // every sign/encrypt op is unnecessary bridge chatter and, on some builds, a // repeated setPackageName can itself wedge. We cache the promise so concurrent // callers await the same setup. let packageNameSet = false; async function ensurePackageName(plugin: NostrSignerNative): Promise { if (packageNameSet) return; // CRITICAL: setPackageName MUST NOT be able to wedge the login flow. On some // devices (GrapheneOS in particular) this bridge call can hang and never // resolve — and because getPlugin() awaited it, getPublicKey() was never even // reached, so its 90s timeout never started → eternal "Waiting for Amber". // We race it against a short timeout and simply continue regardless: Amber's // package is also passed explicitly on every getPublicKey/sign/encrypt call, // so setPackageName is a nice-to-have, not a hard requirement. try { await Promise.race([ plugin.setPackageName({ packageName: AMBER_PACKAGE }), new Promise((resolve) => setTimeout(resolve, 3000)), ]); } catch (e) { console.warn('[CapacitorAmberSigner] setPackageName warning (continuing):', e); } packageNameSet = true; } /** * Get the native signer, ensuring the Amber package name is set first. * Throws a clear error if not on a native platform (caller can surface it). */ async function getPlugin(): Promise { const plugin = getNativeSigner(); if (!plugin) { throw new Error('Amber signing is only available in the SkateSpots Android app.'); } await ensurePackageName(plugin); return plugin; } /** * Login: get public key from Amber. * * The Java bridge calls npubToHex() before returning, so the "npub" field is * actually a hex pubkey string despite the misleading name (older builds). We * handle both hex and real npub just in case. */ export async function getAmberPublicKeyViaPlugin(): Promise { // Get the raw native bridge directly. CRITICALLY we do NOT call // setPackageName here and we do NOT pass packageName to getPublicKey. // // WHY: the native getPublicKey first tries Amber's ContentProvider (a // background, no-UI query) whenever a packageName is known. On GrapheneOS that // cross-app ContentResolver query is blocked/stalls and never returns cleanly, // so the code never falls through to the intent that actually opens Amber's // approval screen → eternal "Waiting for Amber". By omitting packageName, the // plugin skips the content-resolver branch and goes STRAIGHT to // startActivityForResult(nostrsigner:) which launches Amber's UI. Amber returns // both the pubkey AND its package, so subsequent signing still works. const plugin = getNativeSigner(); if (!plugin) { throw new Error('Amber signing is only available in the SkateSpots Android app.'); } const result = await withTimeout( plugin.getPublicKey({ permissions: JSON.stringify([{ type: 'get_public_key' }]) }), 'login', ); // JSObject from Capacitor — normalize to a plain object for reliable access. const plain = JSON.parse(JSON.stringify(result)) as Record; const value = plain.npub ?? plain.pubkey ?? plain.result ?? ''; if (!value) throw new Error('Amber returned empty key'); if (/^[0-9a-f]{64}$/i.test(value)) { return value.toLowerCase(); } if (value.startsWith('npub1')) { const decoded = nip19.decode(value); if (decoded.type !== 'npub') throw new Error('Unexpected key format from Amber'); return decoded.data as string; } throw new Error(`Unexpected Amber response: "${value.slice(0, 30)}"`); } export class CapacitorAmberSigner implements NostrSigner { readonly pubkey: string; private readonly npub: string; constructor(pubkeyHex: string) { this.pubkey = pubkeyHex; this.npub = nip19.npubEncode(pubkeyHex); } async getPublicKey(): Promise { return this.pubkey; } async signEvent(event: Omit): Promise { const plugin = await getPlugin(); const { event: signedJson } = await withTimeout( plugin.signEvent({ packageName: AMBER_PACKAGE, eventJson: JSON.stringify(event), eventId: makeId(), npub: this.npub, }), 'signature', ); return JSON.parse(signedJson) as NostrEvent; } readonly nip04 = { encrypt: async (pubkey: string, plaintext: string): Promise => { const plugin = await getPlugin(); 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 getPlugin(); const { result } = await withTimeout( plugin.nip04Decrypt({ packageName: AMBER_PACKAGE, encryptedText: ciphertext, pubKey: pubkey, npub: this.npub, id: makeId(), }), 'decryption', ); return result; }, }; readonly nip44 = { encrypt: async (pubkey: string, plaintext: string): Promise => { const plugin = await getPlugin(); 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 getPlugin(); const { result } = await withTimeout( plugin.nip44Decrypt({ packageName: AMBER_PACKAGE, encryptedText: ciphertext, pubKey: pubkey, npub: this.npub, id: makeId(), }), 'decryption', ); return result; }, }; } /** * True when we're running inside the native Capacitor app AND the native * NostrSigner bridge is usable. This is the ONLY reliable native check — * reading `window.Capacitor` from a component can be false at mount time or in * builds where Capacitor doesn't attach itself to `window`. Always call this at * the moment of the action (e.g. inside the login click handler), not at render. */ export function isNativeAmberAvailable(): boolean { try { return !!Capacitor?.isNativePlatform?.() && !!getNativeSigner(); } catch { return false; } } /** * Is an external signer (Amber) installed on this device? */ export async function isAmberInstalled(): Promise { const plugin = getNativeSigner(); if (!plugin) return false; try { const { installed } = await plugin.isExternalSignerInstalled({ packageName: AMBER_PACKAGE }); return installed; } catch { return false; } }