fix: use nostr-signer-capacitor-plugin for Amber NIP-55 login

All previous URL-scheme approaches (nostrsigner:, intent:) were fundamentally
wrong for a Capacitor app. JavaScript in a WebView cannot reliably dispatch
Android intents via URL navigation.

The correct solution — used by Coracle and other Capacitor-based Nostr apps —
is the nostr-signer-capacitor-plugin which communicates with Amber via native
Android intents through the Capacitor bridge:

  NostrSignerPlugin.setPackageName({ packageName: 'com.greenart7c3.nostrsigner' })
  NostrSignerPlugin.isExternalSignerInstalled()
  NostrSignerPlugin.getPublicKey() → { npub }

This is synchronous from the JS side, handles the entire Android intent
roundtrip natively, and returns the pubkey directly. No URL encoding,
no callback URLs, no redirects, no malformed request errors.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-08 11:59:04 +07:00
parent 92f317f5e5
commit f010a61608
4 changed files with 92 additions and 94 deletions

7
package-lock.json generated
View file

@ -77,6 +77,7 @@
"leaflet.markercluster": "^1.5.3",
"lucide-react": "^0.462.0",
"ngeohash": "^0.6.3",
"nostr-signer-capacitor-plugin": "^0.0.5",
"nostr-tools": "^2.13.0",
"npubcash-sdk": "^0.2.0",
"qrcode": "^1.5.4",
@ -6554,6 +6555,12 @@
"node": ">=0.10.0"
}
},
"node_modules/nostr-signer-capacitor-plugin": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/nostr-signer-capacitor-plugin/-/nostr-signer-capacitor-plugin-0.0.5.tgz",
"integrity": "sha512-/EvqWz71HZ5cWmzvfXWTm48AWZtbeZDbOg3vLwXyXPjnIp1DR7Wurww/Mo41ORNu1DNPlqH20l7kIXKO6vR5og==",
"license": "MIT"
},
"node_modules/nostr-tools": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.15.0.tgz",

View file

@ -1,7 +1,7 @@
{
"name": "skate-spots",
"private": true,
"version": "1.0.31",
"version": "1.0.32",
"type": "module",
"homepage": "https://skate-spots.com",
"scripts": {
@ -83,6 +83,7 @@
"leaflet.markercluster": "^1.5.3",
"lucide-react": "^0.462.0",
"ngeohash": "^0.6.3",
"nostr-signer-capacitor-plugin": "^0.0.5",
"nostr-tools": "^2.13.0",
"npubcash-sdk": "^0.2.0",
"qrcode": "^1.5.4",

View file

@ -1,5 +1,6 @@
v1.0.31 — Amber login redesigned for GrapheneOS
v1.0.32 — Amber login via native Capacitor plugin
- GrapheneOS Vanadium blocks intent: URLs from web pages (security hardening)
- New flow: tap Open Amber → approve in Amber → app tries clipboard auto-read → if denied, shows paste input for hex pubkey
- Works on all Android browsers including GrapheneOS Vanadium
- Replaced all URL-scheme hacks with nostr-signer-capacitor-plugin
- Plugin uses native Android intents directly — no nostrsigner: URL, no intent: URL, no encoding issues
- This is how Coracle and other proper Capacitor apps do it
- Requires: npm run android:build && cap sync android (plugin needs native sync)

View file

@ -27,21 +27,51 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
const [nsecError, setNsecError] = useState('');
// Amber NIP-55 state
const [amberStep, setAmberStep] = useState<'idle' | 'waiting' | 'paste' | 'done'>('idle');
const [pubkeyInput, setPubkeyInput] = useState('');
const [pubkeyError, setPubkeyError] = useState('');
const [amberStep, setAmberStep] = useState<'idle' | 'loading' | 'done' | 'error'>('idle');
const [amberError, setAmberError] = useState('');
const login = useLoginActions();
const { toast } = useToast();
const isAndroid = /Android/i.test(navigator.userAgent);
// Step 1: open Amber asking it to copy pubkey to clipboard (no callback URL)
const handleAmberLogin = () => {
// nostrsigner: with no callbackUrl → Amber copies pubkey to clipboard
// This works on ALL Android browsers including GrapheneOS Vanadium
window.location.href = 'intent:#Intent;scheme=nostrsigner;S.compressionType=none;S.returnType=signature;S.type=get_public_key;end';
setAmberStep('waiting');
const handleAmberLogin = async () => {
setAmberStep('loading');
setAmberError('');
try {
// Use the Capacitor plugin for native intent-based NIP-55 communication.
// This is the correct approach for Capacitor APKs — it uses Android intents
// directly, not URL schemes from JavaScript (which don't work reliably).
const { default: NostrSignerPlugin } = await import('nostr-signer-capacitor-plugin');
// Set Amber's package name
await NostrSignerPlugin.setPackageName({ packageName: 'com.greenart7c3.nostrsigner' });
// Check Amber is installed
const { installed } = await NostrSignerPlugin.isExternalSignerInstalled();
if (!installed) {
throw new Error('Amber is not installed. Install it from Zapstore or F-Droid.');
}
// Request pubkey — opens Amber, user approves, returns npub
const { npub } = await NostrSignerPlugin.getPublicKey();
// Convert npub to hex pubkey
const { nip19 } = await import('nostr-tools');
const decoded = nip19.decode(npub);
if (decoded.type !== 'npub') throw new Error('Invalid pubkey from Amber');
const pubkey = decoded.data as string;
login.amber(pubkey);
setAmberStep('done');
toast({ title: '🛹 Logged in!', description: 'Connected via Amber!' });
setTimeout(() => { onLogin(); onClose(); }, 800);
} catch (e: unknown) {
const msg = (e as Error)?.message ?? 'Could not connect to Amber';
console.error('[Amber] login failed:', e);
setAmberError(msg);
setAmberStep('error');
}
};
// Step 2: user comes back — try clipboard automatically, else show paste input
@ -91,8 +121,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
useEffect(() => {
if (!isOpen) {
setAmberStep('idle');
setPubkeyInput('');
setPubkeyError('');
setAmberError('');
setBunkerUri('');
setBunkerError('');
setNsecInput('');
@ -181,86 +210,46 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
{/* ── AMBER TAB ── */}
<TabsContent value='amber' className='space-y-3'>
<div className='text-center p-5 rounded-xl bg-black/50 border-2 border-[#ccff00]/30 space-y-4'>
{/* Done */}
{amberStep === 'done' && (
<div className='text-center p-5 rounded-xl bg-black/50 border-2 border-green-500/30 space-y-3'>
<Check className='w-10 h-10 mx-auto text-green-500' />
<p className='text-sm text-green-400 font-bold'>Logged in!</p>
<Loader2 className="w-4 h-4 mx-auto animate-spin text-green-500" />
</div>
)}
{/* Step 1 idle — show login button */}
{amberStep === 'idle' && (
<div className='text-center p-5 rounded-xl bg-black/50 border-2 border-[#ccff00]/30 space-y-4'>
<div className="w-14 h-14 rounded-full bg-[#ccff00]/10 border-2 border-[#ccff00]/40 flex items-center justify-center mx-auto">
<Smartphone className='w-7 h-7 text-[#ccff00]' />
</div>
<div>
<p className='text-sm text-white font-bold'>Login with Amber</p>
<p className='text-xs text-gray-400 mt-1'>Opens Amber → approve → come back here.</p>
</div>
<Button
className='w-full rounded-full py-3 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase'
onClick={handleAmberLogin}
>
<Smartphone className="w-4 h-4 mr-2" />Open Amber
</Button>
</div>
)}
{/* Step 2 waiting — app just navigated to Amber */}
{amberStep === 'waiting' && (
<div className='text-center p-5 rounded-xl bg-black/50 border-2 border-[#ccff00]/40 space-y-4'>
<Loader2 className='w-8 h-8 text-[#ccff00] animate-spin mx-auto' />
<div>
<p className='text-sm text-white font-bold'>Approve in Amber</p>
<p className='text-xs text-gray-400 mt-1'>Tap "Get public key" in Amber, then come back here.</p>
</div>
<Button variant="ghost" className='text-xs text-gray-500 h-auto py-2'
onClick={() => setAmberStep('paste')}>
Already approved? Paste pubkey manually
</Button>
<Button variant="ghost" className='text-xs text-gray-600 h-auto py-1'
onClick={() => setAmberStep('idle')}>Cancel</Button>
</div>
)}
{/* Step 3 paste — clipboard read failed or user chose manual */}
{amberStep === 'paste' && (
<div className='p-5 rounded-xl bg-black/50 border-2 border-[#ccff00]/30 space-y-4'>
<div className='text-center'>
<Smartphone className='w-8 h-8 mx-auto mb-2 text-[#ccff00]' />
<p className='text-sm text-white font-bold'>Paste your pubkey</p>
<p className='text-xs text-gray-400 mt-1'>
In Amber: tap your profile → copy public key (hex). Paste it below.
</p>
</div>
<div className='space-y-2'>
<Input
placeholder='hex pubkey (64 characters)...'
value={pubkeyInput}
onChange={e => { setPubkeyInput(e.target.value); setPubkeyError(''); }}
className='bg-black/60 border-[#ccff00]/30 text-white placeholder:text-gray-600 text-xs font-mono'
autoComplete='off'
autoCorrect='off'
spellCheck={false}
/>
{pubkeyError && <p className="text-xs text-red-400">{pubkeyError}</p>}
</div>
<Button
className='w-full rounded-full py-3 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase'
disabled={!pubkeyInput.trim()}
onClick={handlePubkeySubmit}
>
<Check className='w-4 h-4 mr-2' />Connect
</Button>
<Button variant="ghost" className='w-full text-xs text-gray-600 h-auto py-1'
onClick={() => setAmberStep('idle')}>Back</Button>
</div>
)}
{amberStep === 'done' && (
<>
<Check className='w-10 h-10 mx-auto text-green-500' />
<p className='text-sm text-green-400 font-bold'>Logged in!</p>
<Loader2 className="w-4 h-4 mx-auto animate-spin text-green-500" />
</>
)}
{amberStep !== 'done' && (
<>
<div className="w-14 h-14 rounded-full bg-[#ccff00]/10 border-2 border-[#ccff00]/40 flex items-center justify-center mx-auto">
{amberStep === 'loading'
? <Loader2 className='w-7 h-7 text-[#ccff00] animate-spin' />
: <Smartphone className='w-7 h-7 text-[#ccff00]' />
}
</div>
<div>
<p className='text-sm text-white font-bold'>Login with Amber</p>
<p className='text-xs text-gray-400 mt-1'>
{amberStep === 'loading' ? 'Opening Amber — approve the request...' : 'NIP-55 Android signer. One tap, done.'}
</p>
</div>
{amberStep === 'error' && (
<p className='text-xs text-red-400 bg-red-500/10 rounded-lg p-2'>{amberError}</p>
)}
<Button
className='w-full rounded-full py-3 bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold uppercase'
onClick={handleAmberLogin}
disabled={amberStep === 'loading'}
>
{amberStep === 'loading'
? <><Loader2 className="w-4 h-4 mr-2 animate-spin" />Waiting...</>
: <><Smartphone className="w-4 h-4 mr-2" />Login with Amber</>
}
</Button>
</>
)}
</div>
</TabsContent>
{/* ── BUNKER TAB ── */}