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) => {
|
AppPlugin.addListener('appUrlOpen', (event) => {
|
||||||
try {
|
try {
|
||||||
const url = new URL(event.url);
|
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
|
// Extract path + query + fragment from the deep link URL
|
||||||
const path = url.pathname + url.search + url.hash;
|
const path = url.pathname + url.search + url.hash;
|
||||||
if (path && path !== '/') {
|
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.
|
// 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';
|
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 { Shield, UserPlus, Sparkles, Cloud, Smartphone, Check, Loader2, KeyRound } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
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 { useLoginActions } from '@/hooks/useLoginActions';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { openAmberForLogin, subscribeToAmberReturn } from '@/lib/amber';
|
||||||
|
|
||||||
|
|
||||||
interface LoginDialogProps {
|
interface LoginDialogProps {
|
||||||
|
|
@ -27,7 +33,7 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
||||||
const [nsecError, setNsecError] = useState('');
|
const [nsecError, setNsecError] = useState('');
|
||||||
|
|
||||||
// Amber NIP-55 state
|
// 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 [amberError, setAmberError] = useState('');
|
||||||
|
|
||||||
const login = useLoginActions();
|
const login = useLoginActions();
|
||||||
|
|
@ -35,43 +41,91 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
||||||
|
|
||||||
const isAndroid = /Android/i.test(navigator.userAgent);
|
const isAndroid = /Android/i.test(navigator.userAgent);
|
||||||
|
|
||||||
// Step 1: open Amber with no callbackUrl → Amber copies pubkey to clipboard
|
// Detect if running inside a Capacitor native app
|
||||||
// This is confirmed correct from Amber's IntentUtils.kt source:
|
const isCapacitor = !!(window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor?.isNativePlatform?.();
|
||||||
// - Strips "nostrsigner:" prefix
|
|
||||||
// - URLDecodes the rest
|
// Step 1: open Amber via proper intent:// URL with a callbackUrl so Amber
|
||||||
// - Parses ?type=get_public_key&... query params
|
// redirects back to the app after approval (works in both web PWA and Capacitor APK).
|
||||||
// - No callbackUrl → copies result to clipboard
|
// openAmberForLogin() handles building the correct intent: URL with the callback.
|
||||||
const handleAmberLogin = () => {
|
const handleAmberLogin = () => {
|
||||||
setAmberStep('loading');
|
setAmberStep('loading');
|
||||||
// Exact format from Amber source: nostrsigner:?type=X&compressionType=none&returnType=signature
|
// Store return path before navigating away
|
||||||
// No callbackUrl = Amber copies pubkey to clipboard
|
const currentPath = window.location.hash.replace(/^#/, '') || '/';
|
||||||
window.location.href = 'nostrsigner:?type=get_public_key&compressionType=none&returnType=signature';
|
localStorage.setItem('amber_return_path', currentPath);
|
||||||
// After navigation, app will lose focus. When user comes back, visibilitychange fires.
|
// On Capacitor the deep-link fires appUrlOpen, not a page redirect, so we
|
||||||
setTimeout(() => setAmberStep('paste'), 500);
|
// 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
|
// Check if AmberCallbackPage already wrote a pubkey to localStorage
|
||||||
useEffect(() => {
|
// (happens when Amber opens the callback in a new browser tab on Android)
|
||||||
if (amberStep !== 'paste') return;
|
const checkAmberPendingPubkey = () => {
|
||||||
const tryClipboard = async () => {
|
const storedPubkey = localStorage.getItem('amber_pending_pubkey');
|
||||||
try {
|
if (storedPubkey && /^[0-9a-f]{64}$/i.test(storedPubkey)) {
|
||||||
const text = (await navigator.clipboard.readText()).trim();
|
localStorage.removeItem('amber_pending_pubkey');
|
||||||
// Amber returns hex pubkey OR npub — handle both
|
completeAmberLogin(storedPubkey.toLowerCase());
|
||||||
if (/^[0-9a-f]{64}$/i.test(text)) {
|
return;
|
||||||
completeAmberLogin(text.toLowerCase());
|
}
|
||||||
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
if (text.startsWith('npub1')) {
|
}
|
||||||
const { nip19 } = await import('nostr-tools');
|
} catch { /* clipboard denied — fall through to manual paste */ }
|
||||||
const decoded = nip19.decode(text);
|
// Couldn't auto-detect — show the manual paste UI
|
||||||
if (decoded.type === 'npub') {
|
setAmberStep('paste');
|
||||||
completeAmberLogin(decoded.data as string);
|
};
|
||||||
return;
|
|
||||||
}
|
// Poll for amber_pending_pubkey (written by AmberCallbackPage in a separate tab)
|
||||||
}
|
useEffect(() => {
|
||||||
} catch { /* clipboard denied — user will paste manually */ }
|
if (amberStep !== 'loading') return;
|
||||||
};
|
const interval = setInterval(() => {
|
||||||
tryClipboard();
|
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]);
|
}, [amberStep]);
|
||||||
|
|
||||||
const completeAmberLogin = (pubkey: string) => {
|
const completeAmberLogin = (pubkey: string) => {
|
||||||
|
|
@ -114,6 +168,9 @@ export const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLog
|
||||||
// Reset when dialog closes
|
// Reset when dialog closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
|
// Clean up any pending Amber visibilitychange listener
|
||||||
|
window.__amberUnsubRef?.();
|
||||||
|
window.__amberUnsubRef = undefined;
|
||||||
setAmberStep('idle');
|
setAmberStep('idle');
|
||||||
setAmberError('');
|
setAmberError('');
|
||||||
setPubkeyInput('');
|
setPubkeyInput('');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue