Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions public/manifest.webmanifest
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
150 changes: 149 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<KeyVault | null>(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 (
<main className="flex min-h-screen items-center justify-center bg-surface px-4">
<section className="w-full max-w-sm border border-outline-variant bg-surface-container p-6">
<img src="/logo.png" alt="" className="mb-5 h-8 w-8" />
<h1 className="font-heading text-lg font-bold uppercase tracking-widest text-on-surface">
Wraith locked
</h1>
<p className="mt-2 font-body text-sm leading-relaxed text-on-surface-variant">
Your session was locked after five minutes of inactivity.
</p>

{isPasskeySupported() && (
<button
type="button"
onClick={unlockWithPasskey}
disabled={busy}
className="mt-6 h-11 w-full bg-primary font-heading text-[11px] font-semibold uppercase tracking-widest text-surface disabled:opacity-50"
>
Unlock with passkey
</button>
)}

<form onSubmit={unlockWithPassphrase} className="mt-4">
<label
htmlFor="session-passphrase"
className="font-heading text-[10px] uppercase tracking-widest text-outline"
>
Vault passphrase
</label>
<input
id="session-passphrase"
type="password"
autoComplete="current-password"
value={passphrase}
onChange={(event) => 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"
/>
<button
type="submit"
disabled={busy || !passphrase}
className="mt-3 h-11 w-full border border-primary font-heading text-[11px] font-semibold uppercase tracking-widest text-primary disabled:opacity-50"
>
{busy ? 'Unlocking...' : 'Unlock with passphrase'}
</button>
</form>

{error && (
<p role="alert" className="mt-4 font-body text-xs text-error">
{error}
</p>
)}
</section>
</main>
);
}

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 <SessionLock onUnlock={() => setSessionLocked(false)} />;

return (
<div className="flex min-h-screen flex-col">
Expand Down
47 changes: 46 additions & 1 deletion src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<void>;
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<BeforeInstallPromptEvent | null>(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') },
Expand Down Expand Up @@ -60,6 +96,15 @@ export function Header() {
</div>

<div className="flex items-center gap-2 sm:gap-3">
{installPrompt && (
<button
type="button"
onClick={installApp}
className="h-8 border border-primary px-3 font-heading text-[10px] font-semibold uppercase tracking-widest text-primary transition-colors hover:bg-primary hover:text-surface"
>
Install
</button>
)}
<LocaleSwitcher />
<button
onClick={toggleTheme}
Expand Down
120 changes: 120 additions & 0 deletions src/lib/idleLock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
export const APP_IDLE_TIMEOUT_MS = 5 * 60 * 1000;

const ACTIVITY_EVENTS: Array<keyof WindowEventMap> = [
'pointerdown',
'keydown',
'touchstart',
'scroll',
];

export interface IdleLockOptions {
timeoutMs: number;
onIdle: () => void;
lockOnBlur?: boolean;
lockOnVisibilityChange?: boolean;
}

/**
* Shared inactivity timer used by both the encrypted vault and the app session.
* It checks elapsed wall-clock time when a mobile tab becomes visible again so
* background timer throttling cannot leave a stale session unlocked.
*/
export class IdleLock {
private timer: ReturnType<typeof globalThis.setTimeout> | null = null;
private lastActivityAt = 0;
private started = false;

private readonly handleActivity = () => this.touch();
private readonly handleBlur = () => {
if (this.options.lockOnBlur) this.fire();
};
private readonly handleVisibilityChange = () => {
if (document.visibilityState === 'hidden' && this.options.lockOnVisibilityChange) {
this.fire();
return;
}

if (document.visibilityState === 'visible') this.checkElapsedTime();
};

constructor(private readonly options: IdleLockOptions) {}

start() {
if (typeof window === 'undefined' || this.options.timeoutMs <= 0) return;

this.stop();
this.started = true;
this.lastActivityAt = Date.now();
for (const eventName of ACTIVITY_EVENTS) {
window.addEventListener(eventName, this.handleActivity, { passive: true });
}
window.addEventListener('blur', this.handleBlur);
document.addEventListener('visibilitychange', this.handleVisibilityChange);
this.schedule();
}

stop() {
this.clearTimer();
if (!this.started || typeof window === 'undefined') return;

for (const eventName of ACTIVITY_EVENTS) {
window.removeEventListener(eventName, this.handleActivity);
}
window.removeEventListener('blur', this.handleBlur);
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
this.started = false;
}

touch() {
if (!this.started) return;
this.lastActivityAt = Date.now();
this.schedule();
}

private checkElapsedTime() {
if (!this.started) return;
if (Date.now() - this.lastActivityAt >= this.options.timeoutMs) {
this.fire();
} else {
this.schedule();
}
}

private schedule() {
this.clearTimer();
if (!this.started) return;

const remaining = Math.max(0, this.options.timeoutMs - (Date.now() - this.lastActivityAt));
this.timer = globalThis.setTimeout(() => this.checkElapsedTime(), remaining);
}

private fire() {
if (!this.started) return;
this.stop();
this.options.onIdle();
}

private clearTimer() {
if (this.timer === null) return;
globalThis.clearTimeout(this.timer);
this.timer = null;
}
}

export function isPasskeySupported() {
return typeof window !== 'undefined' && 'PublicKeyCredential' in window;
}

export async function authenticateWithPasskey(): Promise<void> {
if (!isPasskeySupported()) throw new Error('Passkeys are not supported on this device.');

const credential = await navigator.credentials.get({
publicKey: {
challenge: globalThis.crypto.getRandomValues(new Uint8Array(32)),
timeout: 60_000,
userVerification: 'required',
},
});

if (!credential) throw new Error('No passkey is available for Wraith.');
}
Loading
Loading