From 8256f05ca808fba69a5ac4d2c6ecd4d066ddd684 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 9 Jul 2026 11:38:23 +0700 Subject: [PATCH] Wire up nostr-signer-capacitor-plugin for Amber login and signing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CapacitorAmberSigner: a full NostrSigner implementation backed by the native nostr-signer-capacitor-plugin (login, signEvent, nip04, nip44 all go through Amber via native Android intents) - LoginDialog: on Capacitor, call getAmberPublicKeyViaPlugin() directly instead of the intent:// URL redirect — returns a Promise, no callback URL or polling needed. Shows clear error if Amber not installed. - useCurrentUser: on Capacitor amber logins use CapacitorAmberSigner so every publish/sign goes through the plugin, not window.location.href - Web/PWA path unchanged — still uses URL redirect + AmberSigner Co-authored-by: shakespeare.diy --- src/components/auth/LoginDialog.tsx | 60 ++++++++------ src/hooks/useCurrentUser.ts | 9 ++- src/lib/capacitorAmberSigner.ts | 120 ++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 24 deletions(-) create mode 100644 src/lib/capacitorAmberSigner.ts diff --git a/src/components/auth/LoginDialog.tsx b/src/components/auth/LoginDialog.tsx index 1fd14b2..ca86a08 100644 --- a/src/components/auth/LoginDialog.tsx +++ b/src/components/auth/LoginDialog.tsx @@ -16,6 +16,7 @@ import { useLoginActions } from '@/hooks/useLoginActions'; import { useToast } from '@/hooks/useToast'; import { cn } from '@/lib/utils'; import { openAmberForLogin, subscribeToAmberReturn } from '@/lib/amber'; +import { getAmberPublicKeyViaPlugin, isAmberInstalled } from '@/lib/capacitorAmberSigner'; interface LoginDialogProps { @@ -44,34 +45,47 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog // Detect if running inside a Capacitor native app const isCapacitor = !!(window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor?.isNativePlatform?.(); - // Step 1: open Amber via proper intent:// URL with a callbackUrl so Amber - // redirects back to the app after approval (works in both web PWA and Capacitor APK). - // openAmberForLogin() handles building the correct intent: URL with the callback. - const handleAmberLogin = () => { + // Step 1: open Amber + // - On Capacitor (ZapStore APK): use the native plugin — direct Promise, no redirects needed + // - On web/PWA: use intent:// URL with callbackUrl so Amber redirects back + const handleAmberLogin = async () => { setAmberStep('loading'); + setAmberError(''); + + if (isCapacitor) { + // ── Native path (ZapStore/Capacitor APK) ──────────────────────────────── + // Check Amber is installed first for a clear error message + const installed = await isAmberInstalled(); + if (!installed) { + setAmberError('Amber is not installed. Install it from the ZapStore or GitHub.'); + setAmberStep('error'); + return; + } + try { + // This opens Amber natively, user approves, pubkey comes back as a Promise + const pubkey = await getAmberPublicKeyViaPlugin(); + completeAmberLogin(pubkey); + } catch (e: unknown) { + // User cancelled or Amber threw + const msg = (e as Error)?.message ?? 'Amber login failed'; + setAmberError(msg.includes('cancelled') || msg.includes('cancel') + ? 'Login cancelled. Tap "Open Amber" to try again.' + : msg); + setAmberStep('error'); + } + return; + } + + // ── Web/PWA path ───────────────────────────────────────────────────────── // Store return path before navigating away const currentPath = window.location.hash.replace(/^#/, '') || '/'; localStorage.setItem('amber_return_path', currentPath); - // On Capacitor the deep-link fires appUrlOpen, not a page redirect, so we - // also need to poll localStorage for the pubkey written by AmberCallbackPage. - if (isCapacitor) { - // Listen for when app regains focus — Amber will have fired the deep link - const unsub = subscribeToAmberReturn(() => { - // Give DeepLinkHandler 300 ms to process the URL and write to localStorage - setTimeout(checkAmberPendingPubkey, 300); - }); - // Store unsub so we can clean up (store in ref-like closure) - window.__amberUnsubRef = unsub; - } + // Listen for app regaining focus (after Amber redirect) + const unsub = subscribeToAmberReturn(() => { + checkAmberPendingPubkey(); + }); + window.__amberUnsubRef = unsub; openAmberForLogin(); - // Fallback: if somehow we stay on the same page (no redirect happened), - // switch to the manual paste step after the dialog loses/regains focus. - if (!isCapacitor) { - const unsub = subscribeToAmberReturn(() => { - checkAmberPendingPubkey(); - }); - window.__amberUnsubRef = unsub; - } }; // Check if AmberCallbackPage already wrote a pubkey to localStorage diff --git a/src/hooks/useCurrentUser.ts b/src/hooks/useCurrentUser.ts index 0f69454..2e02170 100644 --- a/src/hooks/useCurrentUser.ts +++ b/src/hooks/useCurrentUser.ts @@ -4,6 +4,7 @@ import { useCallback, useMemo } from 'react'; import { useAuthor } from './useAuthor.ts'; import { AmberSigner } from '@/lib/amber'; +import { CapacitorAmberSigner } from '@/lib/capacitorAmberSigner'; import type { AmberLoginType } from './useLoginActions'; /** Extended login type that includes Amber */ @@ -23,7 +24,13 @@ export function useCurrentUser() { return NUser.fromExtensionLogin(login); case 'amber': { // Nostr login with Amber (NIP-55) const amberLogin = login as AmberLoginType; - const signer = new AmberSigner(amberLogin.pubkey); + // On Capacitor (ZapStore APK) use the native plugin signer so all + // signing operations go through Amber via native intents. + // On web/PWA fall back to the URL-redirect based AmberSigner. + const isCapacitor = !!(window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor?.isNativePlatform?.(); + const signer = isCapacitor + ? new CapacitorAmberSigner(amberLogin.pubkey) + : new AmberSigner(amberLogin.pubkey); return new NUser({ pubkey: amberLogin.pubkey, signer }); } // Other login types can be defined here diff --git a/src/lib/capacitorAmberSigner.ts b/src/lib/capacitorAmberSigner.ts new file mode 100644 index 0000000..9d7bcf0 --- /dev/null +++ b/src/lib/capacitorAmberSigner.ts @@ -0,0 +1,120 @@ +/** + * CapacitorAmberSigner + * + * A NostrSigner implementation that delegates all cryptographic operations + * to the nostr-signer-capacitor-plugin, which in turn communicates with + * Amber (or any NIP-55 compatible signer app) via native Android intents. + * + * This ONLY works inside a Capacitor native app. On web/PWA use AmberSigner + * (the intent:// URL redirect approach) instead. + * + * Usage: + * const signer = new CapacitorAmberSigner(pubkeyHex); + * const event = await signer.signEvent({ kind: 1, content: 'hello', tags: [], created_at: ... }); + */ + +import type { NostrEvent, NostrSigner } from '@nostrify/nostrify'; +import { nip19 } from 'nostr-tools'; + +// Amber's package name on Android — used to target the correct signer app. +const AMBER_PACKAGE_NAME = 'com.greenart7c3.nostrsigner'; + +/** + * Lazy-load the plugin and set the package name once. + * Returns null if not running inside Capacitor (web/PWA fallback). + */ +async function getPlugin() { + try { + const mod = await import('nostr-signer-capacitor-plugin'); + const plugin = mod.default ?? mod.NostrSignerPlugin; + if (!plugin) return null; + await plugin.setPackageName({ packageName: AMBER_PACKAGE_NAME }); + return plugin; + } catch { + return null; + } +} + +export class CapacitorAmberSigner implements NostrSigner { + readonly pubkey: string; + + constructor(pubkeyHex: string) { + this.pubkey = pubkeyHex; + } + + async getPublicKey(): Promise { + return this.pubkey; + } + + async signEvent(event: Omit): Promise { + const plugin = await getPlugin(); + if (!plugin) throw new Error('Capacitor Amber plugin not available'); + + const { event: signedJson } = await plugin.signEvent({ + eventJson: JSON.stringify(event), + }); + + return JSON.parse(signedJson) as NostrEvent; + } + + readonly nip04 = { + 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({ pubKey: pubkey, plainText: plaintext }); + return result; + }, + 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({ pubKey: pubkey, encryptedText: ciphertext }); + return result; + }, + }; + + readonly nip44 = { + 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({ pubKey: pubkey, plainText: plaintext }); + return result; + }, + 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({ pubKey: pubkey, encryptedText: ciphertext }); + return result; + }, + }; +} + +/** + * Check if the Amber signer app is installed on the device. + * Returns false on web/PWA (plugin not available). + */ +export async function isAmberInstalled(): Promise { + const plugin = await getPlugin(); + if (!plugin) return false; + try { + const { installed } = await plugin.isExternalSignerInstalled(); + return installed; + } catch { + return false; + } +} + +/** + * Request the public key from Amber via the Capacitor plugin. + * Returns the hex pubkey string, or throws on failure/cancel. + */ +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?'); + + const { npub } = await plugin.getPublicKey(); + + // Plugin returns npub — decode to hex + const decoded = nip19.decode(npub); + if (decoded.type !== 'npub') throw new Error('Unexpected key format from Amber plugin'); + return decoded.data as string; +}