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 { useToast } from '@/hooks/useToast';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { openAmberForLogin, subscribeToAmberReturn } from '@/lib/amber';
|
import { openAmberForLogin, subscribeToAmberReturn } from '@/lib/amber';
|
||||||
|
import { getAmberPublicKeyViaPlugin, isAmberInstalled } from '@/lib/capacitorAmberSigner';
|
||||||
|
|
||||||
|
|
||||||
interface LoginDialogProps {
|
interface LoginDialogProps {
|
||||||
|
|
@ -44,34 +45,47 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
||||||
// Detect if running inside a Capacitor native app
|
// Detect if running inside a Capacitor native app
|
||||||
const isCapacitor = !!(window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor?.isNativePlatform?.();
|
const isCapacitor = !!(window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor?.isNativePlatform?.();
|
||||||
|
|
||||||
// Step 1: open Amber via proper intent:// URL with a callbackUrl so Amber
|
// Step 1: open Amber
|
||||||
// redirects back to the app after approval (works in both web PWA and Capacitor APK).
|
// - On Capacitor (ZapStore APK): use the native plugin — direct Promise, no redirects needed
|
||||||
// openAmberForLogin() handles building the correct intent: URL with the callback.
|
// - On web/PWA: use intent:// URL with callbackUrl so Amber redirects back
|
||||||
const handleAmberLogin = () => {
|
const handleAmberLogin = async () => {
|
||||||
setAmberStep('loading');
|
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
|
// Store return path before navigating away
|
||||||
const currentPath = window.location.hash.replace(/^#/, '') || '/';
|
const currentPath = window.location.hash.replace(/^#/, '') || '/';
|
||||||
localStorage.setItem('amber_return_path', currentPath);
|
localStorage.setItem('amber_return_path', currentPath);
|
||||||
// On Capacitor the deep-link fires appUrlOpen, not a page redirect, so we
|
// Listen for app regaining focus (after Amber redirect)
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
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(() => {
|
const unsub = subscribeToAmberReturn(() => {
|
||||||
checkAmberPendingPubkey();
|
checkAmberPendingPubkey();
|
||||||
});
|
});
|
||||||
window.__amberUnsubRef = unsub;
|
window.__amberUnsubRef = unsub;
|
||||||
}
|
openAmberForLogin();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if AmberCallbackPage already wrote a pubkey to localStorage
|
// Check if AmberCallbackPage already wrote a pubkey to localStorage
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
import { useAuthor } from './useAuthor.ts';
|
import { useAuthor } from './useAuthor.ts';
|
||||||
import { AmberSigner } from '@/lib/amber';
|
import { AmberSigner } from '@/lib/amber';
|
||||||
|
import { CapacitorAmberSigner } from '@/lib/capacitorAmberSigner';
|
||||||
import type { AmberLoginType } from './useLoginActions';
|
import type { AmberLoginType } from './useLoginActions';
|
||||||
|
|
||||||
/** Extended login type that includes Amber */
|
/** Extended login type that includes Amber */
|
||||||
|
|
@ -23,7 +24,13 @@ export function useCurrentUser() {
|
||||||
return NUser.fromExtensionLogin(login);
|
return NUser.fromExtensionLogin(login);
|
||||||
case 'amber': { // Nostr login with Amber (NIP-55)
|
case 'amber': { // Nostr login with Amber (NIP-55)
|
||||||
const amberLogin = login as AmberLoginType;
|
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 });
|
return new NUser({ pubkey: amberLogin.pubkey, signer });
|
||||||
}
|
}
|
||||||
// Other login types can be defined here
|
// 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