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:
parent
92f317f5e5
commit
f010a61608
4 changed files with 92 additions and 94 deletions
7
package-lock.json
generated
7
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ── */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue