Wire up nostr-signer-capacitor-plugin for Amber login and signing

- 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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-09 11:38:23 +07:00
parent e3a95de807
commit 8256f05ca8
3 changed files with 165 additions and 24 deletions

View file

@ -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<LoginDialogProps> = ({ 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

View file

@ -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

View file

@ -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<string> {
return this.pubkey;
}
async signEvent(event: Omit<NostrEvent, 'id' | 'sig'>): Promise<NostrEvent> {
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<string> => {
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<string> => {
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<string> => {
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<string> => {
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<boolean> {
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<string> {
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;
}