Fix Amber login for PWA installed to home screen
PWA standalone mode is single-tab. Opening nostrsigner: with a callbackUrl
sends the callback to the browser (a different app), not back to the PWA.
The PWA tab is suspended and never sees the result.
Fix:
- Detect PWA standalone mode via matchMedia('(display-mode: standalone)')
- In PWA mode: open Amber WITHOUT a callbackUrl — Amber copies pubkey to
clipboard automatically when no callback is given
- Use visibilitychange to detect when user returns to PWA after approving
- Auto-read clipboard on return and complete login silently
- Manual "I approved" button now tries localStorage first (browser), then
falls back to clipboard read (PWA) — covers both contexts with one button
- Browser mode unchanged: localStorage cross-tab polling still works
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
0c87fa5827
commit
65914303f1
1 changed files with 80 additions and 29 deletions
|
|
@ -90,20 +90,56 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
|
||||||
setAmberStep('waiting');
|
setAmberStep('waiting');
|
||||||
setErrors(prev => ({ ...prev, amber: undefined }));
|
setErrors(prev => ({ ...prev, amber: undefined }));
|
||||||
|
|
||||||
// Use localStorage (not sessionStorage) — survives navigation on Android browsers
|
// Detect if running as installed PWA (standalone) — window.matchMedia
|
||||||
localStorage.setItem('amber_return_path', window.location.pathname);
|
// returns true when launched from home screen on Android/iOS
|
||||||
// Stamp the time so we know this is a fresh attempt
|
const isPWA = window.matchMedia('(display-mode: standalone)').matches
|
||||||
localStorage.setItem('amber_login_attempt_ts', String(Date.now()));
|
|| ('standalone' in window.navigator && (window.navigator as { standalone?: boolean }).standalone === true);
|
||||||
|
|
||||||
|
if (isPWA) {
|
||||||
|
// ── PWA mode ──────────────────────────────────────────────────────────
|
||||||
|
// When installed to home screen, the app is single-tab. Opening
|
||||||
|
// nostrsigner: with a callbackUrl sends the callback to the browser,
|
||||||
|
// not back to the PWA — so the PWA never sees the result.
|
||||||
|
//
|
||||||
|
// Instead: open Amber WITHOUT a callbackUrl. Amber copies the pubkey
|
||||||
|
// to clipboard when there's no callback. When the user comes back,
|
||||||
|
// the visibilitychange fires and we auto-read the clipboard.
|
||||||
|
const amberUrl = 'nostrsigner:?compressionType=none&returnType=signature&type=get_public_key';
|
||||||
|
window.location.href = amberUrl;
|
||||||
|
|
||||||
|
// When user returns to the PWA after approving in Amber, auto-read clipboard
|
||||||
|
unsubAmberRef.current = subscribeToAmberReturn(async () => {
|
||||||
|
unsubAmberRef.current = null;
|
||||||
|
try {
|
||||||
|
const text = await navigator.clipboard.readText();
|
||||||
|
const pubkey = text.trim().toLowerCase();
|
||||||
|
const pubkeyRegex = /^[0-9a-f]{64}$/;
|
||||||
|
if (pubkeyRegex.test(pubkey)) {
|
||||||
|
login.amber(pubkey);
|
||||||
|
setAmberStep('success');
|
||||||
|
toast({ title: '🛹 Logged in', description: 'Welcome back via Amber!' });
|
||||||
|
setTimeout(() => { onLogin(); onClose(); }, 600);
|
||||||
|
} else {
|
||||||
|
// Clipboard had something else — show manual button
|
||||||
|
setErrors(prev => ({ ...prev, amber: 'Could not auto-read pubkey. Tap "I approved" to complete login.' }));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Clipboard permission denied — show manual button
|
||||||
|
setErrors(prev => ({ ...prev, amber: 'Tap "I approved — complete login" to finish.' }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Browser mode (tab-based) ─────────────────────────────────────────────
|
||||||
|
// Use localStorage cross-tab signal: AmberCallbackPage writes
|
||||||
|
// 'amber_pending_pubkey', this tab's poll picks it up and calls login.amber()
|
||||||
|
localStorage.setItem('amber_return_path', window.location.pathname);
|
||||||
|
|
||||||
// Poll localStorage every 400ms for 'amber_pending_pubkey'.
|
|
||||||
// AmberCallbackPage writes this key when Amber approves — even if it runs
|
|
||||||
// in a separate tab (GrapheneOS opens callback in new tab). This tab picks
|
|
||||||
// it up, calls login.amber() here where React state is alive, and completes.
|
|
||||||
if (pollRef.current) clearInterval(pollRef.current);
|
if (pollRef.current) clearInterval(pollRef.current);
|
||||||
pollRef.current = setInterval(() => {
|
pollRef.current = setInterval(() => {
|
||||||
const pendingPubkey = localStorage.getItem('amber_pending_pubkey');
|
const pendingPubkey = localStorage.getItem('amber_pending_pubkey');
|
||||||
if (pendingPubkey) {
|
if (pendingPubkey) {
|
||||||
// Consume it immediately so we don't process twice
|
|
||||||
localStorage.removeItem('amber_pending_pubkey');
|
localStorage.removeItem('amber_pending_pubkey');
|
||||||
clearInterval(pollRef.current!);
|
clearInterval(pollRef.current!);
|
||||||
pollRef.current = null;
|
pollRef.current = null;
|
||||||
|
|
@ -120,13 +156,16 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
|
||||||
}
|
}
|
||||||
}, 400);
|
}, 400);
|
||||||
|
|
||||||
// Also try visibilitychange for browsers that keep the tab alive
|
// visibilitychange fallback — if browser keeps tab alive and user cancels
|
||||||
unsubAmberRef.current = subscribeToAmberReturn(() => {
|
unsubAmberRef.current = subscribeToAmberReturn(() => {
|
||||||
setAmberStep(prev => prev === 'waiting' ? 'initial' : prev);
|
// Only reset if callback page didn't already complete login
|
||||||
|
const stillPending = !localStorage.getItem('amber_pending_pubkey') === false;
|
||||||
|
if (stillPending) {
|
||||||
|
setAmberStep(prev => prev === 'waiting' ? 'initial' : prev);
|
||||||
|
}
|
||||||
unsubAmberRef.current = null;
|
unsubAmberRef.current = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Open Amber — it will call /amber-callback?event=<pubkey> on approve
|
|
||||||
const callbackUrl = `${window.location.origin}/amber-callback?event=`;
|
const callbackUrl = `${window.location.origin}/amber-callback?event=`;
|
||||||
const amberUrl = `nostrsigner:?compressionType=none&returnType=signature&type=get_public_key&callbackUrl=${encodeURIComponent(callbackUrl)}`;
|
const amberUrl = `nostrsigner:?compressionType=none&returnType=signature&type=get_public_key&callbackUrl=${encodeURIComponent(callbackUrl)}`;
|
||||||
window.location.href = amberUrl;
|
window.location.href = amberUrl;
|
||||||
|
|
@ -357,29 +396,24 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
|
||||||
<div>
|
<div>
|
||||||
<p className='text-sm text-white font-bold mb-1'>Waiting for Amber...</p>
|
<p className='text-sm text-white font-bold mb-1'>Waiting for Amber...</p>
|
||||||
<p className='text-xs text-gray-400'>
|
<p className='text-xs text-gray-400'>
|
||||||
Approve the request in Amber, then come back here.
|
Approve in Amber, then come back here. Login completes automatically.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/* Animated dots */}
|
|
||||||
<div className="flex justify-center gap-1.5">
|
<div className="flex justify-center gap-1.5">
|
||||||
{[0, 1, 2].map(i => (
|
{[0, 1, 2].map(i => (
|
||||||
<span
|
<span key={i} className="w-2 h-2 rounded-full bg-[#ccff00] animate-bounce"
|
||||||
key={i}
|
style={{ animationDelay: `${i * 0.15}s` }} />
|
||||||
className="w-2 h-2 rounded-full bg-[#ccff00] animate-bounce"
|
|
||||||
style={{ animationDelay: `${i * 0.15}s` }}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{/* Fallback for Android browsers that navigate away —
|
|
||||||
polling handles auto-complete, but this button is a
|
{/* Manual fallback — handles both PWA (clipboard) and browser (localStorage) */}
|
||||||
manual escape hatch if the user lands back here stuck */}
|
<p className="text-xs text-gray-500">Stuck? Tap below after approving in Amber:</p>
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
Already approved in Amber?
|
|
||||||
</p>
|
|
||||||
<Button
|
<Button
|
||||||
className="w-full rounded-full bg-[#ccff00]/20 border border-[#ccff00]/50 text-[#ccff00] hover:bg-[#ccff00]/30 font-bold text-sm h-auto py-3"
|
className="w-full rounded-full bg-[#ccff00]/20 border border-[#ccff00]/50 text-[#ccff00] hover:bg-[#ccff00]/30 font-bold text-sm h-auto py-3"
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
// Check for pending pubkey written by AmberCallbackPage
|
setErrors(prev => ({ ...prev, amber: undefined }));
|
||||||
|
|
||||||
|
// 1. Check localStorage first (browser tab / new-tab callback)
|
||||||
const pendingPubkey = localStorage.getItem('amber_pending_pubkey');
|
const pendingPubkey = localStorage.getItem('amber_pending_pubkey');
|
||||||
if (pendingPubkey) {
|
if (pendingPubkey) {
|
||||||
localStorage.removeItem('amber_pending_pubkey');
|
localStorage.removeItem('amber_pending_pubkey');
|
||||||
|
|
@ -393,13 +427,30 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin, onS
|
||||||
setErrors(prev => ({ ...prev, amber: 'Login failed. Please try again.' }));
|
setErrors(prev => ({ ...prev, amber: 'Login failed. Please try again.' }));
|
||||||
setAmberStep('initial');
|
setAmberStep('initial');
|
||||||
}
|
}
|
||||||
} else {
|
return;
|
||||||
setErrors(prev => ({ ...prev, amber: 'Not approved yet — go back to Amber and approve first.' }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Try clipboard (PWA mode — Amber copies pubkey when no callback URL)
|
||||||
|
try {
|
||||||
|
const text = await navigator.clipboard.readText();
|
||||||
|
const pubkey = text.trim().toLowerCase();
|
||||||
|
const pubkeyRegex = /^[0-9a-f]{64}$/;
|
||||||
|
if (pubkeyRegex.test(pubkey)) {
|
||||||
|
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
|
||||||
|
login.amber(pubkey);
|
||||||
|
setAmberStep('success');
|
||||||
|
toast({ title: '🛹 Logged in', description: 'Welcome back via Amber!' });
|
||||||
|
setTimeout(() => { onLogin(); onClose(); }, 600);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch { /* clipboard permission denied */ }
|
||||||
|
|
||||||
|
setErrors(prev => ({ ...prev, amber: 'Not detected yet. Make sure you approved in Amber, then try again.' }));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
I approved — complete login
|
I approved — complete login
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{errors.amber && (
|
{errors.amber && (
|
||||||
<p className="text-xs text-red-400">{errors.amber}</p>
|
<p className="text-xs text-red-400">{errors.amber}</p>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue