From 7d071787d08a128693e04c70e7b6ef679efc7001 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Wed, 8 Jul 2026 22:29:26 +0700 Subject: [PATCH] Fix Amber login for ZapStore/Capacitor app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LoginDialog now uses openAmberForLogin() which sends the proper intent:// URL with a callbackUrl instead of the old nostrsigner: scheme that only copies to clipboard (broken on most setups) - Add polling loop (500ms, 3min timeout) for amber_pending_pubkey written by AmberCallbackPage — works even when Amber opens the callback in a new browser tab (GrapheneOS/Android behaviour) - visibilitychange listener now waits for app to regain focus before attempting clipboard read, fixing race condition - DeepLinkHandler pre-captures ?event= param into sessionStorage when Amber fires appUrlOpen deep link in Capacitor, so AmberCallbackPage can always find the result - Add 'paste' to amberStep union type (was missing, caused TS error) Co-authored-by: shakespeare.diy --- src/components/DeepLinkHandler.tsx | 10 +++ src/components/auth/LoginDialog.tsx | 121 ++++++++++++++++++++-------- 2 files changed, 99 insertions(+), 32 deletions(-) diff --git a/src/components/DeepLinkHandler.tsx b/src/components/DeepLinkHandler.tsx index a0f4f09..68a9927 100644 --- a/src/components/DeepLinkHandler.tsx +++ b/src/components/DeepLinkHandler.tsx @@ -45,6 +45,16 @@ export function DeepLinkHandler() { AppPlugin.addListener('appUrlOpen', (event) => { try { const url = new URL(event.url); + // Pre-capture Amber callback event param into sessionStorage so + // AmberCallbackPage can find it after React Router renders the route. + const eventParam = url.searchParams.get('event'); + if (eventParam) { + try { + sessionStorage.setItem('amber_callback_event', eventParam); + sessionStorage.setItem('amber_callback_url', event.url); + sessionStorage.setItem('amber_callback_timestamp', Date.now().toString()); + } catch { /* ignore storage errors */ } + } // Extract path + query + fragment from the deep link URL const path = url.pathname + url.search + url.hash; if (path && path !== '/') { diff --git a/src/components/auth/LoginDialog.tsx b/src/components/auth/LoginDialog.tsx index a785a9f..1fd14b2 100644 --- a/src/components/auth/LoginDialog.tsx +++ b/src/components/auth/LoginDialog.tsx @@ -2,6 +2,11 @@ // It is important that all functionality in this file is preserved, and should only be modified if explicitly requested. import React, { useState, useEffect } from 'react'; + +// Extend window with a cleanup ref for Amber visibilitychange listener +declare global { + interface Window { __amberUnsubRef?: () => void; } +} import { Shield, UserPlus, Sparkles, Cloud, Smartphone, Check, Loader2, KeyRound } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -10,6 +15,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { useLoginActions } from '@/hooks/useLoginActions'; import { useToast } from '@/hooks/useToast'; import { cn } from '@/lib/utils'; +import { openAmberForLogin, subscribeToAmberReturn } from '@/lib/amber'; interface LoginDialogProps { @@ -27,7 +33,7 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog const [nsecError, setNsecError] = useState(''); // Amber NIP-55 state - const [amberStep, setAmberStep] = useState<'idle' | 'loading' | 'done' | 'error'>('idle'); + const [amberStep, setAmberStep] = useState<'idle' | 'loading' | 'paste' | 'done' | 'error'>('idle'); const [amberError, setAmberError] = useState(''); const login = useLoginActions(); @@ -35,43 +41,91 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog const isAndroid = /Android/i.test(navigator.userAgent); - // Step 1: open Amber with no callbackUrl → Amber copies pubkey to clipboard - // This is confirmed correct from Amber's IntentUtils.kt source: - // - Strips "nostrsigner:" prefix - // - URLDecodes the rest - // - Parses ?type=get_public_key&... query params - // - No callbackUrl → copies result to clipboard + // 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 = () => { setAmberStep('loading'); - // Exact format from Amber source: nostrsigner:?type=X&compressionType=none&returnType=signature - // No callbackUrl = Amber copies pubkey to clipboard - window.location.href = 'nostrsigner:?type=get_public_key&compressionType=none&returnType=signature'; - // After navigation, app will lose focus. When user comes back, visibilitychange fires. - setTimeout(() => setAmberStep('paste'), 500); + // 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; + } + 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; + } }; - // Step 2: when app regains focus, try clipboard auto-read first - useEffect(() => { - if (amberStep !== 'paste') return; - const tryClipboard = async () => { - try { - const text = (await navigator.clipboard.readText()).trim(); - // Amber returns hex pubkey OR npub — handle both - if (/^[0-9a-f]{64}$/i.test(text)) { - completeAmberLogin(text.toLowerCase()); + // Check if AmberCallbackPage already wrote a pubkey to localStorage + // (happens when Amber opens the callback in a new browser tab on Android) + const checkAmberPendingPubkey = () => { + const storedPubkey = localStorage.getItem('amber_pending_pubkey'); + if (storedPubkey && /^[0-9a-f]{64}$/i.test(storedPubkey)) { + localStorage.removeItem('amber_pending_pubkey'); + completeAmberLogin(storedPubkey.toLowerCase()); + return; + } + // Also try clipboard as fallback + tryClipboardForPubkey(); + }; + + // Try reading the pubkey from clipboard (Amber may have copied it there) + const tryClipboardForPubkey = async () => { + try { + const text = (await navigator.clipboard.readText()).trim(); + if (/^[0-9a-f]{64}$/i.test(text)) { + completeAmberLogin(text.toLowerCase()); + return; + } + if (text.startsWith('npub1')) { + const { nip19 } = await import('nostr-tools'); + const decoded = nip19.decode(text); + if (decoded.type === 'npub') { + completeAmberLogin(decoded.data as string); return; } - if (text.startsWith('npub1')) { - const { nip19 } = await import('nostr-tools'); - const decoded = nip19.decode(text); - if (decoded.type === 'npub') { - completeAmberLogin(decoded.data as string); - return; - } - } - } catch { /* clipboard denied — user will paste manually */ } - }; - tryClipboard(); + } + } catch { /* clipboard denied — fall through to manual paste */ } + // Couldn't auto-detect — show the manual paste UI + setAmberStep('paste'); + }; + + // Poll for amber_pending_pubkey (written by AmberCallbackPage in a separate tab) + useEffect(() => { + if (amberStep !== 'loading') return; + const interval = setInterval(() => { + const storedPubkey = localStorage.getItem('amber_pending_pubkey'); + if (storedPubkey && /^[0-9a-f]{64}$/i.test(storedPubkey)) { + localStorage.removeItem('amber_pending_pubkey'); + clearInterval(interval); + completeAmberLogin(storedPubkey.toLowerCase()); + } + }, 500); + // Stop polling after 3 minutes + const timeout = setTimeout(() => { + clearInterval(interval); + if (amberStep === 'loading') setAmberStep('paste'); + }, 180_000); + return () => { clearInterval(interval); clearTimeout(timeout); }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [amberStep]); const completeAmberLogin = (pubkey: string) => { @@ -114,6 +168,9 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog // Reset when dialog closes useEffect(() => { if (!isOpen) { + // Clean up any pending Amber visibilitychange listener + window.__amberUnsubRef?.(); + window.__amberUnsubRef = undefined; setAmberStep('idle'); setAmberError(''); setPubkeyInput('');