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:
parent
f73cb0ec8c
commit
7d071787d0
2 changed files with 99 additions and 32 deletions
|
|
@ -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 !== '/') {
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
|
|
|
|||
Loading…
Reference in a new issue