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:
parent
e3a95de807
commit
8256f05ca8
3 changed files with 165 additions and 24 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
120
src/lib/capacitorAmberSigner.ts
Normal file
120
src/lib/capacitorAmberSigner.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue