diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest index 24eb32a..206d156 100644 --- a/public/manifest.webmanifest +++ b/public/manifest.webmanifest @@ -10,6 +10,14 @@ "background_color": "#0e0e0e", "lang": "en", "categories": ["finance", "utilities"], + "share_target": { + "action": "/send", + "method": "GET", + "enctype": "application/x-www-form-urlencoded", + "params": { + "text": "text" + } + }, "icons": [ { "src": "/icons/icon-72x72.png", diff --git a/src/App.tsx b/src/App.tsx index 0a664f0..71a0066 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,5 @@ -import { Routes, Route, Navigate } from 'react-router-dom'; +import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'; +import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom'; import { Header } from '@/components/Header'; import { AutoSign } from '@/components/AutoSign'; import { TelemetryBanner } from '@/components/TelemetryBanner'; @@ -16,9 +17,156 @@ import StellarSplit from '@/pages/StellarSplit'; import Names from '@/pages/Names'; import Activity from '@/pages/Activity'; import Debug from '@/pages/Debug'; +import { useChain } from '@/context/ChainContext'; +import { useStealthKeys } from '@/context/StealthKeysContext'; +import { KeyVault } from '@/vault/KeyVault'; +import { + APP_IDLE_TIMEOUT_MS, + IdleLock, + authenticateWithPasskey, + isPasskeySupported, +} from '@/lib/idleLock'; +import { parseStellarQrPayload } from '@/utils/qr'; + +function SessionLock({ onUnlock }: { onUnlock: () => void }) { + const [passphrase, setPassphrase] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const vaultRef = useRef(null); + + const unlockWithPasskey = async () => { + setBusy(true); + setError(''); + try { + await authenticateWithPasskey(); + onUnlock(); + } catch { + setError('No Wraith passkey was found. Unlock with your vault passphrase instead.'); + } finally { + setBusy(false); + } + }; + + const unlockWithPassphrase = async (event: FormEvent) => { + event.preventDefault(); + if (!passphrase) return; + + setBusy(true); + setError(''); + try { + vaultRef.current ??= new KeyVault({ + idleTimeoutMs: 0, + lockOnBlur: false, + lockOnVisibilityChange: false, + }); + await vaultRef.current.unlock(passphrase); + await vaultRef.current.lock(); + setPassphrase(''); + onUnlock(); + } catch { + setError('That passphrase could not unlock the Wraith vault.'); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ +

+ Wraith locked +

+

+ Your session was locked after five minutes of inactivity. +

+ + {isPasskeySupported() && ( + + )} + +
+ + setPassphrase(event.target.value)} + className="mt-2 h-11 w-full border border-outline-variant bg-surface px-3 font-body text-sm text-on-surface outline-none focus:border-primary" + /> + +
+ + {error && ( +

+ {error} +

+ )} +
+
+ ); +} export function App() { useNotificationSW(); + const location = useLocation(); + const navigate = useNavigate(); + const { setChain } = useChain(); + const { clearEvm, clearStellar, clearSolana, clearCkb } = useStealthKeys(); + const [sessionLocked, setSessionLocked] = useState(false); + + const relockSession = useCallback(() => { + clearEvm(); + clearStellar(); + clearSolana(); + clearCkb(); + setSessionLocked(true); + }, [clearCkb, clearEvm, clearSolana, clearStellar]); + + useEffect(() => { + if (sessionLocked) return; + const idleLock = new IdleLock({ timeoutMs: APP_IDLE_TIMEOUT_MS, onIdle: relockSession }); + idleLock.start(); + return () => idleLock.stop(); + }, [relockSession, sessionLocked]); + + useEffect(() => { + if (location.pathname !== '/send') return; + const sharedText = new URLSearchParams(location.search).get('text'); + if (!sharedText) return; + + try { + const payload = parseStellarQrPayload(sharedText); + const params = new URLSearchParams({ to: payload.metaAddress }); + if (payload.amount) params.set('amount', payload.amount); + if (payload.memo) params.set('memo', payload.memo); + setChain('stellar'); + navigate(`/send?${params.toString()}`, { replace: true }); + } catch { + // Leave unsupported shared text untouched so the user can correct it manually. + } + }, [location.pathname, location.search, navigate, setChain]); + + if (sessionLocked) return setSessionLocked(false)} />; return (
diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 70d439a..89bf8f2 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Link, useLocation } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { ChainSwitcher } from './ChainSwitcher'; @@ -8,13 +8,49 @@ import { NetworkChip } from './NetworkChip'; import { useTheme } from '@/context/ThemeContext'; import { useNotificationsStore } from '@/stores/notificationsStore'; +const INSTALL_PROMPT_DISMISSED_KEY = 'wraith:pwa-install-dismissed'; + +interface BeforeInstallPromptEvent extends Event { + prompt: () => Promise; + userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>; +} + export function Header() { const location = useLocation(); const { t } = useTranslation(); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const [installPrompt, setInstallPrompt] = useState(null); const { theme, toggleTheme } = useTheme(); const unreadCount = useNotificationsStore((state) => state.unreadCount()); + useEffect(() => { + const captureInstallPrompt = (event: Event) => { + event.preventDefault(); + if (localStorage.getItem(INSTALL_PROMPT_DISMISSED_KEY) === 'true') return; + setInstallPrompt(event as BeforeInstallPromptEvent); + }; + const hideInstallPrompt = () => setInstallPrompt(null); + + window.addEventListener('beforeinstallprompt', captureInstallPrompt); + window.addEventListener('appinstalled', hideInstallPrompt); + return () => { + window.removeEventListener('beforeinstallprompt', captureInstallPrompt); + window.removeEventListener('appinstalled', hideInstallPrompt); + }; + }, []); + + const installApp = async () => { + if (!installPrompt) return; + + const prompt = installPrompt; + setInstallPrompt(null); + await prompt.prompt(); + const choice = await prompt.userChoice; + if (choice.outcome === 'dismissed') { + localStorage.setItem(INSTALL_PROMPT_DISMISSED_KEY, 'true'); + } + }; + const navLinks = [ { to: '/send', label: t('nav.send') }, { to: '/receive', label: t('nav.receive') }, @@ -60,6 +96,15 @@ export function Header() {
+ {installPrompt && ( + + )}