diff --git a/package.json b/package.json index 363829c..fb23ffe 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "skate-spots", "private": true, - "version": "1.0.70", + "version": "1.0.71", "type": "module", "homepage": "https://skate-spots.com", "scripts": { diff --git a/src/lib/capacitorAmberSigner.ts b/src/lib/capacitorAmberSigner.ts index 134b319..2944b57 100644 --- a/src/lib/capacitorAmberSigner.ts +++ b/src/lib/capacitorAmberSigner.ts @@ -1,78 +1,110 @@ /** * CapacitorAmberSigner * - * Wraps nostr-signer-capacitor-plugin with the CORRECT API as per the - * actual compiled source (dist/esm/index.js) — NOT the README docs. + * Talks to the native Amber signer (nostr-signer-capacitor-plugin) over the + * Capacitor bridge. * - * Every method takes packageName as the FIRST positional argument. - * signEvent/encrypt/decrypt also require id (unique op id) and npub. + * 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)}`; } -async function getPlugin() { +// 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 { - const { NostrSignerPlugin } = await import('nostr-signer-capacitor-plugin'); - if (!NostrSignerPlugin) return null; - // JS layer does: native.setPackageName({ packageName }) - // so the JS wrapper expects a plain string which it wraps into { packageName } - // BUT the JS source shows: async setPackageName(packageName) { native.setPackageName({ packageName }) } - // So we pass plain string to the JS wrapper — it handles the wrapping - await NostrSignerPlugin.setPackageName(AMBER_PACKAGE); - return NostrSignerPlugin; - } catch (e: unknown) { - const code = (e as { code?: string })?.code; - if (code !== 'ANDROID_ONLY') { - console.error('[CapacitorAmberSigner] getPlugin failed:', e); - } + if (!Capacitor?.isNativePlatform?.()) return null; + } catch { return null; } + if (!nativePlugin) { + nativePlugin = registerPlugin('NostrSignerPlugin'); + } + return nativePlugin; +} + +/** + * 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.'); + } + try { + await plugin.setPackageName({ packageName: AMBER_PACKAGE }); + } catch (e) { + // setPackageName can be a no-op that throws on some builds; log but continue. + console.warn('[CapacitorAmberSigner] setPackageName warning:', e); + } + return plugin; } /** * Login: get public key from Amber. * - * The plugin tries Content Resolver first (silent, returns cached key), - * then falls back to opening Amber UI if no cached result. - * - * The Java bridge calls npubToHex() before returning, so the "npub" field - * is actually a hex pubkey string despite the misleading name. + * 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 { const plugin = await getPlugin(); - if (!plugin) throw new Error('Capacitor Amber plugin not available — are you on a native build?'); - // Pass null as packageName to skip the Content Resolver silent path - // and always open the Amber UI so the user explicitly selects their key. - // This prevents returning a stale/wrong cached key. - // Pass AMBER_PACKAGE (not null) so the Content Resolver URI is valid. - // If Content Resolver has a default account it returns immediately (silent). - // If not, it falls through to open Amber UI via intent. - const result = await plugin.getPublicKey(AMBER_PACKAGE); + const result = await plugin.getPublicKey({ packageName: AMBER_PACKAGE }); - // JSObject from Capacitor — stringify and reparse to get a plain JS object - // so property access works reliably + // JSObject from Capacitor — normalize to a plain object for reliable access. const plain = JSON.parse(JSON.stringify(result)) as Record; - - // Java puts the value in "npub" field but calls npubToHex() first, - // so it's actually a 64-char hex string despite the misleading name const value = plain.npub ?? plain.pubkey ?? plain.result ?? ''; if (!value) throw new Error('Amber returned empty key'); - // Already hex if (/^[0-9a-f]{64}$/i.test(value)) { return value.toLowerCase(); } - // Real npub — decode to hex if (value.startsWith('npub1')) { const decoded = nip19.decode(value); if (decoded.type !== 'npub') throw new Error('Unexpected key format from Amber'); @@ -84,7 +116,6 @@ export async function getAmberPublicKeyViaPlugin(): Promise { export class CapacitorAmberSigner implements NostrSigner { readonly pubkey: string; - // Store the npub for use in signing calls private readonly npub: string; constructor(pubkeyHex: string) { @@ -96,99 +127,76 @@ export class CapacitorAmberSigner implements NostrSigner { return this.pubkey; } - /** - * signEvent(packageName, eventJson, id, npub) - * Returns { event: string } — signed event JSON - */ async signEvent(event: Omit): Promise { const plugin = await getPlugin(); - if (!plugin) throw new Error('Capacitor Amber plugin not available'); - - const id = makeId(); - const { event: signedJson } = await plugin.signEvent( - AMBER_PACKAGE, - JSON.stringify(event), - id, - this.npub, - ); + const { event: signedJson } = await plugin.signEvent({ + packageName: AMBER_PACKAGE, + eventJson: JSON.stringify(event), + eventId: makeId(), + npub: this.npub, + }); return JSON.parse(signedJson) as NostrEvent; } readonly nip04 = { - /** - * nip04Encrypt(packageName, plainText, id, pubKey, npub) - */ encrypt: async (pubkey: string, plaintext: string): Promise => { const plugin = await getPlugin(); - if (!plugin) throw new Error('Capacitor Amber plugin not available'); - const { result } = await plugin.nip04Encrypt( - AMBER_PACKAGE, - plaintext, - makeId(), - pubkey, - this.npub, - ); + const { result } = await plugin.nip04Encrypt({ + packageName: AMBER_PACKAGE, + plainText: plaintext, + pubKey: pubkey, + npub: this.npub, + id: makeId(), + }); return result; }, - /** - * nip04Decrypt(packageName, encryptedText, id, pubKey, npub) - */ decrypt: async (pubkey: string, ciphertext: string): Promise => { const plugin = await getPlugin(); - if (!plugin) throw new Error('Capacitor Amber plugin not available'); - const { result } = await plugin.nip04Decrypt( - AMBER_PACKAGE, - ciphertext, - makeId(), - pubkey, - this.npub, - ); + const { result } = await plugin.nip04Decrypt({ + packageName: AMBER_PACKAGE, + encryptedText: ciphertext, + pubKey: pubkey, + npub: this.npub, + id: makeId(), + }); return result; }, }; readonly nip44 = { - /** - * nip44Encrypt(packageName, plainText, id, pubKey, npub) - */ encrypt: async (pubkey: string, plaintext: string): Promise => { const plugin = await getPlugin(); - if (!plugin) throw new Error('Capacitor Amber plugin not available'); - const { result } = await plugin.nip44Encrypt( - AMBER_PACKAGE, - plaintext, - makeId(), - pubkey, - this.npub, - ); + const { result } = await plugin.nip44Encrypt({ + packageName: AMBER_PACKAGE, + plainText: plaintext, + pubKey: pubkey, + npub: this.npub, + id: makeId(), + }); return result; }, - /** - * nip44Decrypt(packageName, encryptedText, id, pubKey, npub) - */ decrypt: async (pubkey: string, ciphertext: string): Promise => { const plugin = await getPlugin(); - if (!plugin) throw new Error('Capacitor Amber plugin not available'); - const { result } = await plugin.nip44Decrypt( - AMBER_PACKAGE, - ciphertext, - makeId(), - pubkey, - this.npub, - ); + const { result } = await plugin.nip44Decrypt({ + packageName: AMBER_PACKAGE, + encryptedText: ciphertext, + pubKey: pubkey, + npub: this.npub, + id: makeId(), + }); return result; }, }; } /** - * isExternalSignerInstalled(packageName) + * Is an external signer (Amber) installed on this device? */ export async function isAmberInstalled(): Promise { - const plugin = await getPlugin(); + const plugin = getNativeSigner(); if (!plugin) return false; try { - const { installed } = await plugin.isExternalSignerInstalled(AMBER_PACKAGE); + const { installed } = await plugin.isExternalSignerInstalled({ packageName: AMBER_PACKAGE }); return installed; } catch { return false;