Fix Amber login for ZapStore/Capacitor app

- 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 <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-08 22:29:26 +07:00
parent f73cb0ec8c
commit 7d071787d0
2 changed files with 99 additions and 32 deletions

View file

@ -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 !== '/') {

View file

@ -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<LoginDialogProps> = ({ 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<LoginDialogProps> = ({ 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<LoginDialogProps> = ({ 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('');