diff --git a/src/hooks/useNotificationSW.ts b/src/hooks/useNotificationSW.ts index fdbc2c4..eaf0693 100644 --- a/src/hooks/useNotificationSW.ts +++ b/src/hooks/useNotificationSW.ts @@ -1,5 +1,11 @@ -import { useEffect } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { useNotificationsStore } from '@/stores/notificationsStore'; +import { + subscribeToRelay, + unsubscribeFromRelay, + testRelayConnectivity, + DEFAULT_RELAY_URL, +} from '@/lib/pushRelay'; interface SWMessage { type: string; @@ -16,28 +22,117 @@ interface SWMessage { }; } +export interface WebPushState { + supported: boolean; + permission: NotificationPermission; + subscribed: boolean; + loading: boolean; + error: string | null; + relayUrl: string; + relayReachable: boolean; +} + +export interface UseNotificationSWReturn { + state: WebPushState; + requestPermission: () => Promise; + subscribe: (metaAddress: string, relayUrl?: string) => Promise; + unsubscribe: (metaAddress: string) => Promise; + testRelay: (relayUrl?: string) => Promise; + updateRelayUrl: (url: string) => void; +} + +const STORAGE_KEY_RELAY_URL = 'wraith:push-relay-url'; +const STORAGE_KEY_SUBSCRIBED = 'wraith:push-subscribed'; + /** - * Registers the Stellar notification service worker and listens for - * WRAITH_NOTIFICATION messages from it, persisting them into the - * notifications store. + * Registers the Stellar notification service worker and manages Web Push + * subscription lifecycle for stealth payment alerts. + * + * Features: + * - Service worker registration and message handling + * - Web Push subscription management + * - Privacy-first relay integration (only meta-address hash) + * - User-configurable relay URL + * - Permission request and state management * * Should be mounted once at the app root level. */ -export function useNotificationSW() { +export function useNotificationSW(): UseNotificationSWReturn { const addNotification = useNotificationsStore((state) => state.addNotification); + const swRef = useRef(null); + + const [state, setState] = useState({ + supported: false, + permission: 'default', + subscribed: false, + loading: true, + error: null, + relayUrl: localStorage.getItem(STORAGE_KEY_RELAY_URL) || DEFAULT_RELAY_URL, + relayReachable: false, + }); + + // Check browser support + useEffect(() => { + const supported = + 'serviceWorker' in navigator && 'Notification' in window && 'PushManager' in window; + setState((prev) => ({ ...prev, supported, loading: false })); + }, []); + + // Check subscription state from localStorage + useEffect(() => { + const subscribed = localStorage.getItem(STORAGE_KEY_SUBSCRIBED) === 'true'; + setState((prev) => ({ ...prev, subscribed })); + }, []); + + // Check notification permission + useEffect(() => { + if (state.supported) { + setState((prev) => ({ ...prev, permission: Notification.permission })); + } + }, [state.supported]); + // Register service worker useEffect(() => { - if (!('serviceWorker' in navigator)) return; + if (!state.supported) return; + + let cancelled = false; + + async function registerSW() { + try { + const registration = await navigator.serviceWorker.register( + new URL('../sw/stellar-notification-sw.ts', import.meta.url), + { type: 'module' }, + ); - // Register the SW (Vite bundles SW files referenced via URL constructor) - navigator.serviceWorker - .register(new URL('../sw/stellar-notification-sw.ts', import.meta.url), { type: 'module' }) - .catch((err) => { - // Non-fatal — notifications simply won't fire in this environment + if (cancelled) return; + + swRef.current = registration; + + // Listen for permission changes + if ('permissions' in navigator) { + const permissionStatus = await (navigator as any).permissions.query({ + name: 'notifications', + }); + permissionStatus.onchange = () => { + setState((prev) => ({ ...prev, permission: Notification.permission })); + }; + } + } catch (err) { + if (cancelled) return; console.warn('[wraith] SW registration failed:', err); - }); + setState((prev) => ({ ...prev, error: 'Service worker registration failed' })); + } + } - // Listen for WRAITH_NOTIFICATION messages posted by the SW + registerSW(); + + return () => { + cancelled = true; + }; + }, [state.supported]); + + // Listen for WRAITH_NOTIFICATION messages posted by the SW + useEffect(() => { const handler = (event: MessageEvent) => { if ( event.data?.type !== 'WRAITH_NOTIFICATION' || @@ -54,4 +149,158 @@ export function useNotificationSW() { navigator.serviceWorker.removeEventListener('message', handler); }; }, [addNotification]); + + // Request notification permission + const requestPermission = useCallback(async (): Promise => { + if (!state.supported) return false; + + try { + const permission = await Notification.requestPermission(); + setState((prev) => ({ ...prev, permission })); + return permission === 'granted'; + } catch (error) { + console.error('Permission request failed:', error); + setState((prev) => ({ + ...prev, + error: error instanceof Error ? error.message : 'Permission request failed', + })); + return false; + } + }, [state.supported]); + + // Subscribe to Web Push + const subscribe = useCallback( + async (metaAddress: string, relayUrl?: string) => { + if (!state.supported || !swRef.current) { + throw new Error('Web Push not supported or service worker not registered'); + } + + if (state.permission !== 'granted') { + const granted = await requestPermission(); + if (!granted) { + throw new Error('Notification permission denied'); + } + } + + setState((prev) => ({ ...prev, loading: true, error: null })); + + try { + // Check if already subscribed + const existingSubscription = await swRef.current.pushManager.getSubscription(); + if (existingSubscription) { + console.log('[useNotificationSW] Already subscribed to push service'); + } else { + // Subscribe to push service with VAPID key + // Note: In production, this should be a proper VAPID public key + // For demo purposes, we'll skip VAPID and use no-ops + const subscription = await swRef.current.pushManager.subscribe({ + userVisibleOnly: true, + // applicationServerKey: new Uint8Array([...]), // Add proper VAPID key in production + }); + + console.log('[useNotificationSW] Subscribed to push service'); + } + + // Get the subscription for relay registration + const subscription = await swRef.current.pushManager.getSubscription(); + if (!subscription) { + throw new Error('Failed to get push subscription'); + } + + // Subscribe to relay + const relayUrlToUse = relayUrl || state.relayUrl; + const response = await subscribeToRelay(subscription, metaAddress, { + relayUrl: relayUrlToUse, + chain: 'stellar', + }); + + if (!response.success) { + throw new Error(response.error || 'Failed to subscribe to relay'); + } + + localStorage.setItem(STORAGE_KEY_SUBSCRIBED, 'true'); + setState((prev) => ({ + ...prev, + subscribed: true, + loading: false, + error: null, + })); + } catch (error) { + console.error('Subscription failed:', error); + setState((prev) => ({ + ...prev, + loading: false, + error: error instanceof Error ? error.message : 'Subscription failed', + })); + throw error; + } + }, + [state.supported, state.permission, state.relayUrl, requestPermission], + ); + + // Unsubscribe from Web Push + const unsubscribe = useCallback( + async (metaAddress: string) => { + if (!swRef.current) return; + + setState((prev) => ({ ...prev, loading: true, error: null })); + + try { + const subscription = await swRef.current.pushManager.getSubscription(); + if (subscription) { + // Unsubscribe from relay + await unsubscribeFromRelay(subscription, metaAddress, { + relayUrl: state.relayUrl, + chain: 'stellar', + }); + + // Unsubscribe from push service + await subscription.unsubscribe(); + } + + localStorage.removeItem(STORAGE_KEY_SUBSCRIBED); + setState((prev) => ({ + ...prev, + subscribed: false, + loading: false, + error: null, + })); + } catch (error) { + console.error('Unsubscribe failed:', error); + setState((prev) => ({ + ...prev, + loading: false, + error: error instanceof Error ? error.message : 'Unsubscribe failed', + })); + throw error; + } + }, + [state.relayUrl], + ); + + // Test relay connectivity + const testRelay = useCallback( + async (relayUrl?: string): Promise => { + const relayUrlToUse = relayUrl || state.relayUrl; + const result = await testRelayConnectivity({ relayUrl: relayUrlToUse }); + setState((prev) => ({ ...prev, relayReachable: result.reachable })); + return result.reachable; + }, + [state.relayUrl], + ); + + // Update relay URL + const updateRelayUrl = useCallback((url: string) => { + localStorage.setItem(STORAGE_KEY_RELAY_URL, url); + setState((prev) => ({ ...prev, relayUrl: url })); + }, []); + + return { + state, + requestPermission, + subscribe, + unsubscribe, + testRelay, + updateRelayUrl, + }; } diff --git a/src/i18n/en.json b/src/i18n/en.json index f55a594..87cbadd 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -71,7 +71,9 @@ "scanFailed": "Scan failed", "expectedConfirmation": "Expected confirmation", "seconds_approx": "~5 seconds", - "balance": "Balance" + "balance": "Balance", + "update": "Update", + "test": "Test" }, "horizen": { "network": "Horizen Testnet / ETH", @@ -95,7 +97,46 @@ "announcerContractName": "Soroban", "networkFeeAmount": "100 stroops", "recipientPlaceholder": "st:xlm:...", - "validMetaAddressError": "Enter a valid Stellar meta-address (st:xlm:...)" + "validMetaAddressError": "Enter a valid Stellar meta-address (st:xlm:...)", + "scanningStrategy": "Scanning Strategy", + "scanningStrategyDescription": "Choose how the Stellar receive scanner filters announcements. Changes take effect immediately on the next scan.", + "strategyFast": "Fast", + "strategyFastDescription": "View-tag only prefilter. Skips expensive shared secret computation.", + "strategyFastTooltip": "The view-tag reveals one byte of correlation to a passive observer of the RPC.", + "strategyBalanced": "Balanced", + "strategyBalancedDescription": "View-tag prefilter + full scan. Default behavior.", + "strategyBalancedTooltip": "The view-tag reveals one byte of correlation to a passive observer of the RPC.", + "strategyFull": "Full", + "strategyFullDescription": "Ignore view-tag. Full shared secret computation on all announcements.", + "strategyFullTooltip": "Most thorough scan but slowest. Ignores view-tag optimization.", + "activeStrategy": "Active Strategy", + "changeInSettings": "change in Settings", + "webPush": "Web Push Notifications", + "webPushDescription": "Receive stealth payment alerts even when the app is closed. Uses a privacy-first relay that only receives your meta-address hash (no personal data).", + "webPushNotSupported": "Web Push is not supported in this browser. Please use Chrome, Edge, or Firefox on desktop or Android.", + "webPushPermissionDenied": "Notification permission denied. Enable notifications in your browser settings to use Web Push.", + "relayUrl": "Relay URL", + "selfHostableRelay": "Self-hostable relay endpoint. Default: {{defaultUrl}}", + "status": "Status", + "subscribed": "Subscribed", + "notSubscribed": "Not Subscribed", + "subscribedNotice": "You will receive stealth payment alerts via Web Push even when the app is closed.", + "subscribeToAlerts": "Subscribe to Alerts", + "unsubscribe": "Unsubscribe", + "subscribing": "Subscribing...", + "unsubscribing": "Unsubscribing...", + "testing": "Testing...", + "privacyNotice": "Privacy Notice", + "privacyNoticeText": "Only the SHA-256 hash of your meta-address is sent to the relay. No personal data, wallet addresses, or keys are transmitted. You can self-host the relay for complete control.", + "noActiveMetaAddress": "No active meta-address. Derive keys on the Receive page first.", + "subscribeSuccess": "Successfully subscribed to stealth payment alerts via Web Push.", + "subscribeFailed": "Failed to subscribe to Web Push.", + "unsubscribeSuccess": "Successfully unsubscribed from Web Push notifications.", + "unsubscribeFailed": "Failed to unsubscribe from Web Push.", + "relayReachable": "Relay is reachable and healthy.", + "relayNotReachable": "Relay is not reachable. Check the URL and try again.", + "relayUrlUpdated": "Relay URL updated. Test connectivity to verify.", + "relayTestFailed": "Failed to test relay connectivity." }, "solana": { "network": "Solana Devnet / SOL", diff --git a/src/lib/pushRelay.ts b/src/lib/pushRelay.ts new file mode 100644 index 0000000..ba70c61 --- /dev/null +++ b/src/lib/pushRelay.ts @@ -0,0 +1,182 @@ +/** + * pushRelay.ts + * + * Library for interacting with Web Push relay service for stealth payment alerts. + * + * Privacy-first design: + * - Only meta-address hash is sent as identifier + * - No personal or wallet data is transmitted + * - User can configure their own self-hosted relay + * + * Relay API contract: + * POST /subscribe + * { + * subscription: PushSubscriptionJSON, + * metaAddressHash: string, // SHA-256 hash of the stealth meta-address + * chain: 'stellar' + * } + * + * POST /unsubscribe + * { + * subscription: PushSubscriptionJSON, + * metaAddressHash: string, + * chain: 'stellar' + * } + */ + +const DEFAULT_RELAY_URL = 'https://relay.wraith-protocol.dev/api'; + +interface RelayConfig { + relayUrl: string; + chain: 'stellar'; +} + +interface SubscribeRequest { + subscription: PushSubscriptionJSON; + metaAddressHash: string; + chain: 'stellar'; +} + +interface UnsubscribeRequest { + subscription: PushSubscriptionJSON; + metaAddressHash: string; + chain: 'stellar'; +} + +interface RelayResponse { + success: boolean; + error?: string; +} + +/** + * Compute SHA-256 hash of a string (meta-address) + */ +async function computeMetaAddressHash(metaAddress: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(metaAddress); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +/** + * Subscribe to push notifications via relay + */ +export async function subscribeToRelay( + subscription: PushSubscription, + metaAddress: string, + config: Partial = {}, +): Promise { + const relayUrl = config.relayUrl || DEFAULT_RELAY_URL; + const chain = config.chain || 'stellar'; + + try { + const metaAddressHash = await computeMetaAddressHash(metaAddress); + + const response = await fetch(`${relayUrl}/subscribe`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + subscription: subscription.toJSON(), + metaAddressHash, + chain, + } as SubscribeRequest), + }); + + if (!response.ok) { + const errorText = await response.text(); + return { + success: false, + error: `Relay returned ${response.status}: ${errorText}`, + }; + } + + const data = await response.json(); + return data as RelayResponse; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error subscribing to relay', + }; + } +} + +/** + * Unsubscribe from push notifications via relay + */ +export async function unsubscribeFromRelay( + subscription: PushSubscription, + metaAddress: string, + config: Partial = {}, +): Promise { + const relayUrl = config.relayUrl || DEFAULT_RELAY_URL; + const chain = config.chain || 'stellar'; + + try { + const metaAddressHash = await computeMetaAddressHash(metaAddress); + + const response = await fetch(`${relayUrl}/unsubscribe`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + subscription: subscription.toJSON(), + metaAddressHash, + chain, + } as UnsubscribeRequest), + }); + + if (!response.ok) { + const errorText = await response.text(); + return { + success: false, + error: `Relay returned ${response.status}: ${errorText}`, + }; + } + + const data = await response.json(); + return data as RelayResponse; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error unsubscribing from relay', + }; + } +} + +/** + * Test relay connectivity + */ +export async function testRelayConnectivity( + config: Partial = {}, +): Promise<{ reachable: boolean; error?: string }> { + const relayUrl = config.relayUrl || DEFAULT_RELAY_URL; + + try { + const response = await fetch(`${relayUrl}/health`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (response.ok) { + return { reachable: true }; + } else { + return { + reachable: false, + error: `Relay returned ${response.status}`, + }; + } + } catch (error) { + return { + reachable: false, + error: error instanceof Error ? error.message : 'Unknown error testing relay', + }; + } +} + +export { DEFAULT_RELAY_URL }; diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index ade33c9..1a69633 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useTheme, type ThemePreference } from '@/context/ThemeContext'; import { useStealthKeys } from '@/context/StealthKeysContext'; @@ -12,6 +12,10 @@ import { generateRecoveryFilename, bytesToHex, } from '@/lib/stellar/recoveryKit'; +import { useNotificationSW } from '@/hooks/useNotificationSW'; +import { DEFAULT_RELAY_URL } from '@/lib/pushRelay'; + +type ScanningStrategy = 'fast' | 'balanced' | 'full'; const preferences: Array<{ value: ThemePreference; label: string; description: string }> = [ { @@ -23,6 +27,32 @@ const preferences: Array<{ value: ThemePreference; label: string; description: s { value: 'dark', label: 'Dark', description: 'Always use the dark theme.' }, ]; +const scanningStrategies: Array<{ + value: ScanningStrategy; + labelKey: string; + descriptionKey: string; + tooltipKey: string; +}> = [ + { + value: 'fast', + labelKey: 'stellar.strategyFast', + descriptionKey: 'stellar.strategyFastDescription', + tooltipKey: 'stellar.strategyFastTooltip', + }, + { + value: 'balanced', + labelKey: 'stellar.strategyBalanced', + descriptionKey: 'stellar.strategyBalancedDescription', + tooltipKey: 'stellar.strategyBalancedTooltip', + }, + { + value: 'full', + labelKey: 'stellar.strategyFull', + descriptionKey: 'stellar.strategyFullDescription', + tooltipKey: 'stellar.strategyFullTooltip', + }, +]; + export default function Settings() { const { t } = useTranslation(); const { preference, setThemePreference } = useTheme(); @@ -43,6 +73,26 @@ export default function Settings() { exitRecoveryMode, } = useStealthKeys(); + // Web Push Notification State + const { + state: pushState, + requestPermission, + subscribe, + unsubscribe, + testRelay, + updateRelayUrl, + } = useNotificationSW(); + + // Scanning Strategy State + const [scanningStrategy, setScanningStrategy] = useState(() => { + const saved = localStorage.getItem('wraith-scanning-strategy'); + return (saved as ScanningStrategy) || 'balanced'; + }); + + useEffect(() => { + localStorage.setItem('wraith-scanning-strategy', scanningStrategy); + }, [scanningStrategy]); + // Export State const [exportPassphrase, setExportPassphrase] = useState(''); const [includeSpendingScalar, setIncludeSpendingScalar] = useState(true); @@ -63,6 +113,16 @@ export default function Settings() { const [isRestoring, setIsRestoring] = useState(false); const fileInputRef = useRef(null); + // Web Push UI State + const [relayUrlInput, setRelayUrlInput] = useState(pushState.relayUrl); + const [pushMessage, setPushMessage] = useState<{ + type: 'success' | 'error'; + text: string; + } | null>(null); + const [isSubscribing, setIsSubscribing] = useState(false); + const [isUnsubscribing, setIsUnsubscribing] = useState(false); + const [isTestingRelay, setIsTestingRelay] = useState(false); + // Active meta-address and keys for current chain const activeMetaAddress = chain === 'stellar' @@ -238,6 +298,100 @@ export default function Settings() { } }; + // Web Push Handlers + const handleSubscribe = async () => { + setPushMessage(null); + + if (!activeMetaAddress) { + setPushMessage({ + type: 'error', + text: 'No active meta-address. Derive keys on the Receive page first.', + }); + return; + } + + setIsSubscribing(true); + + try { + await subscribe(activeMetaAddress, relayUrlInput); + setPushMessage({ + type: 'success', + text: 'Successfully subscribed to stealth payment alerts via Web Push.', + }); + } catch (err) { + setPushMessage({ + type: 'error', + text: err instanceof Error ? err.message : 'Failed to subscribe to Web Push.', + }); + } finally { + setIsSubscribing(false); + } + }; + + const handleUnsubscribe = async () => { + setPushMessage(null); + + if (!activeMetaAddress) { + setPushMessage({ + type: 'error', + text: 'No active meta-address to unsubscribe.', + }); + return; + } + + setIsUnsubscribing(true); + + try { + await unsubscribe(activeMetaAddress); + setPushMessage({ + type: 'success', + text: 'Successfully unsubscribed from Web Push notifications.', + }); + } catch (err) { + setPushMessage({ + type: 'error', + text: err instanceof Error ? err.message : 'Failed to unsubscribe from Web Push.', + }); + } finally { + setIsUnsubscribing(false); + } + }; + + const handleTestRelay = async () => { + setPushMessage(null); + setIsTestingRelay(true); + + try { + const reachable = await testRelay(relayUrlInput); + if (reachable) { + setPushMessage({ + type: 'success', + text: 'Relay is reachable and healthy.', + }); + } else { + setPushMessage({ + type: 'error', + text: 'Relay is not reachable. Check the URL and try again.', + }); + } + } catch (err) { + setPushMessage({ + type: 'error', + text: err instanceof Error ? err.message : 'Failed to test relay connectivity.', + }); + } finally { + setIsTestingRelay(false); + } + }; + + const handleUpdateRelayUrl = () => { + updateRelayUrl(relayUrlInput); + setPushMessage({ + type: 'success', + text: 'Relay URL updated. Test connectivity to verify.', + }); + }; + return (
@@ -535,6 +689,164 @@ export default function Settings() { ))} + + {/* Scanning Strategy Preferences */} +
+ + {t('stellar.scanningStrategy')} + +

+ {t('stellar.scanningStrategyDescription')} +

+ {scanningStrategies.map((option) => ( + + ))} +
+ + {/* Web Push Notifications */} +
+ + {t('stellar.webPush')} + +

+ {t('stellar.webPushDescription')} +

+ + {/* Support Check */} + {!pushState.supported && ( +
+

{t('stellar.webPushNotSupported')}

+
+ )} + + {/* Permission Status */} + {pushState.supported && pushState.permission === 'denied' && ( +
+

{t('stellar.webPushPermissionDenied')}

+
+ )} + + {/* Relay URL Configuration */} + {pushState.supported && pushState.permission !== 'denied' && ( +
+ +
+ setRelayUrlInput(e.target.value)} + placeholder="https://relay.wraith-protocol.dev/api" + className="h-11 flex-1 border border-outline-variant bg-surface px-3.5 font-mono text-sm text-primary placeholder:text-outline focus:border-primary" + /> + + +
+

+ {t('stellar.selfHostableRelay', { defaultUrl: DEFAULT_RELAY_URL })} +

+
+ )} + + {/* Subscription Status */} + {pushState.supported && pushState.permission !== 'denied' && ( +
+
+ + {t('stellar.status')} + + + {pushState.subscribed ? t('stellar.subscribed') : t('stellar.notSubscribed')} + +
+ {pushState.subscribed && ( +
+

✓ {t('stellar.subscribedNotice')}

+
+ )} +
+ )} + + {/* Subscribe/Unsubscribe Buttons */} + {pushState.supported && pushState.permission !== 'denied' && ( +
+ {!pushState.subscribed ? ( + + ) : ( + + )} +
+ )} + + {/* Privacy Notice */} + {pushState.supported && pushState.permission !== 'denied' && ( +
+ {t('stellar.privacyNotice')}: + {t('stellar.privacyNoticeText')} +
+ )} + + {/* Push Messages */} + {pushMessage && ( +

+ {pushMessage.text} +

+ )} +
); } diff --git a/src/sw/app-sw.ts b/src/sw/app-sw.ts index 298d431..388d7ed 100644 --- a/src/sw/app-sw.ts +++ b/src/sw/app-sw.ts @@ -76,6 +76,17 @@ interface StoredViewingKey { encryptedSpendingScalar: string; lastScannedLedger?: number; timestamp: number; + relayUrl?: string; + metaAddressHash?: string; + pushSubscription?: PushSubscriptionJSON; +} + +interface PushSubscriptionJSON { + endpoint: string; + keys: { + p256dh: string; + auth: string; + }; } interface NotificationData { @@ -241,17 +252,22 @@ self.addEventListener('notificationclick', (event) => { notification.close(); event.waitUntil( - self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => { - for (const client of clients) { - if (client.url.includes('/receive') || client.url.includes('/stellar')) { - client.focus(); - client.postMessage({ type: 'NAVIGATE_TO_MATCH', stealthAddress: data.stealthAddress }); - return; + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { + // Focus existing Wraith tab and navigate to /notifications + const existing = clientList.find((c) => c.url.includes(self.location.origin) && 'focus' in c); + if (existing) { + (existing as WindowClient).focus(); + (existing as WindowClient).navigate('/notifications'); + // Also post match info so the page can pre-highlight it + if (data?.stealthAddress) { + existing.postMessage({ type: 'NAVIGATE_TO_MATCH', stealthAddress: data.stealthAddress }); } + return; } - if (clients.openWindow) { - return clients.openWindow('/receive?match=' + data.stealthAddress); - } + const dest = data?.stealthAddress + ? `/notifications?match=${data.stealthAddress}` + : '/notifications'; + return self.clients.openWindow(dest); }), ); }); @@ -341,12 +357,206 @@ self.addEventListener('message', (event) => { if (type === 'TRIGGER_SCAN') { event.waitUntil(handleSync()); } + + // Push subscription management + if (type === 'REGISTER_PUSH_SUBSCRIPTION') { + event.waitUntil( + (async () => { + try { + const { subscription, metaAddressHash, relayUrl } = event.data ?? {}; + if (!subscription || !metaAddressHash) { + throw new Error('Missing subscription or metaAddressHash'); + } + + // Store subscription info in IndexedDB + const db = await openDB(); + const transaction = db.transaction([STORE_NAME], 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + + // Update existing entry or create new one + const existing = await new Promise((resolve, reject) => { + const request = store.get(subscription.keys?.p256dh || 'default'); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + }); + + const entry: StoredViewingKey = existing || { + publicKey: subscription.keys?.p256dh || 'default', + encryptedViewingKey: '', + encryptedSpendingPubKey: '', + encryptedSpendingScalar: '', + timestamp: Date.now(), + }; + + // Store relay URL and meta-address hash + (entry as any).relayUrl = relayUrl; + (entry as any).metaAddressHash = metaAddressHash; + (entry as any).pushSubscription = subscription; + + await new Promise((resolve, reject) => { + const request = store.put(entry); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); + + db.close(); + (event.source as Client)?.postMessage({ type: 'PUSH_SUBSCRIPTION_REGISTERED' }); + } catch (error) { + console.error('[app-sw] Failed to register push subscription:', error); + (event.source as Client)?.postMessage({ + type: 'PUSH_SUBSCRIPTION_ERROR', + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + })(), + ); + } + + if (type === 'UNREGISTER_PUSH_SUBSCRIPTION') { + event.waitUntil( + (async () => { + try { + const { subscription, metaAddressHash } = event.data ?? {}; + if (!subscription) { + throw new Error('Missing subscription'); + } + + const db = await openDB(); + const transaction = db.transaction([STORE_NAME], 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + + // Remove push subscription from entry + const existing = await new Promise((resolve, reject) => { + const request = store.get(subscription.keys?.p256dh || 'default'); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + }); + + if (existing) { + delete (existing as any).relayUrl; + delete (existing as any).metaAddressHash; + delete (existing as any).pushSubscription; + + await new Promise((resolve, reject) => { + const request = store.put(existing); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); + } + + db.close(); + (event.source as Client)?.postMessage({ type: 'PUSH_SUBSCRIPTION_UNREGISTERED' }); + } catch (error) { + console.error('[app-sw] Failed to unregister push subscription:', error); + (event.source as Client)?.postMessage({ + type: 'PUSH_SUBSCRIPTION_ERROR', + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + })(), + ); + } }); // ── Push (future) ────────────────────────────────────────────────────────────── -self.addEventListener('push', (_event) => { - // Reserved for future server-sent push notifications. +interface PushPayload { + id: string; + title: string; + body: string; + amount?: string; + asset?: string; + sender?: string; + data?: Record; +} + +interface NotificationData { + stealthAddress: string; + amount?: string; + timestamp: number; +} + +// Store for deduplication of notifications +const PROCED_NOTIFICATIONS = new Set(); + +// Helper to check if notification was already processed +function isNotificationProcessed(id: string): boolean { + if (PROCED_NOTIFICATIONS.has(id)) { + return true; + } + PROCED_NOTIFICATIONS.add(id); + // Limit cache size to prevent memory issues + if (PROCED_NOTIFICATIONS.size > 1000) { + const first = PROCED_NOTIFICATIONS.values().next().value; + PROCED_NOTIFICATIONS.delete(first); + } + return false; +} + +function parsePushPayload(event: PushEvent): PushPayload { + try { + const json = event.data?.json() as Partial | undefined; + if (json && json.title) { + return { + id: json.id ?? `sw-${Date.now()}`, + title: json.title, + body: json.body ?? '', + amount: json.amount, + asset: json.asset, + sender: json.sender, + data: json.data, + }; + } + } catch { + // ignore parse errors — fall through to default + } + return { + id: `sw-${Date.now()}`, + title: 'New stealth payment detected', + body: 'Open Wraith to view payment details.', + }; +} + +self.addEventListener('push', (event: PushEvent) => { + const payload = parsePushPayload(event); + + // Deduplicate: skip if we've already processed this notification + if (isNotificationProcessed(payload.id)) { + console.log(`[app-sw] Skipping duplicate notification: ${payload.id}`); + return; + } + + const lines: string[] = [payload.body]; + if (payload.amount && payload.asset) { + lines.push(`Amount: ${payload.amount} ${payload.asset}`); + } else if (payload.amount) { + lines.push(`Amount: ${payload.amount}`); + } + if (payload.sender) { + const short = + payload.sender.length > 24 + ? `${payload.sender.slice(0, 10)}…${payload.sender.slice(-10)}` + : payload.sender; + lines.push(`From: ${short}`); + } + + const notificationOptions: NotificationOptions = { + body: lines.join('\n'), + icon: '/favicon-32x32.png', + badge: '/favicon-16x16.png', + tag: payload.id, + data: { + id: payload.id, + stealthAddress: payload.sender, + amount: payload.amount, + asset: payload.asset, + sender: payload.sender, + timestamp: Date.now(), + ...payload.data, + } as NotificationData & Record, + }; + + event.waitUntil(self.registration.showNotification(payload.title, notificationOptions)); }); export {}; diff --git a/src/sw/stellar-notification-sw.ts b/src/sw/stellar-notification-sw.ts index 9272365..5a7321b 100644 --- a/src/sw/stellar-notification-sw.ts +++ b/src/sw/stellar-notification-sw.ts @@ -60,6 +60,17 @@ interface StoredViewingKey { encryptedSpendingScalar: string; lastScannedLedger?: number; timestamp: number; + relayUrl?: string; + metaAddressHash?: string; + pushSubscription?: PushSubscriptionJSON; +} + +interface PushSubscriptionJSON { + endpoint: string; + keys: { + p256dh: string; + auth: string; + }; } interface NotificationData { @@ -221,9 +232,32 @@ async function handleSync(_event: ExtendableEvent): Promise { // ─── push event ─────────────────────────────────────────────────────────────── +// Store for deduplication of notifications +const PROCED_NOTIFICATIONS = new Set(); + +// Helper to check if notification was already processed +function isNotificationProcessed(id: string): boolean { + if (PROCED_NOTIFICATIONS.has(id)) { + return true; + } + PROCED_NOTIFICATIONS.add(id); + // Limit cache size to prevent memory issues + if (PROCED_NOTIFICATIONS.size > 1000) { + const first = PROCED_NOTIFICATIONS.values().next().value; + PROCED_NOTIFICATIONS.delete(first); + } + return false; +} + self.addEventListener('push', (event: PushEvent) => { const payload = parsePushPayload(event); + // Deduplicate: skip if we've already processed this notification + if (isNotificationProcessed(payload.id)) { + console.log(`[wraith-sw] Skipping duplicate notification: ${payload.id}`); + return; + } + const lines: string[] = [payload.body]; if (payload.amount && payload.asset) { lines.push(`Amount: ${payload.amount} ${payload.asset}`); @@ -379,6 +413,105 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { if (type === 'TRIGGER_SCAN') { event.waitUntil(handleSync(event as unknown as ExtendableEvent)); } + + // Push subscription management + if (type === 'REGISTER_PUSH_SUBSCRIPTION') { + event.waitUntil( + (async () => { + try { + const { subscription, metaAddressHash, relayUrl } = event.data ?? {}; + if (!subscription || !metaAddressHash) { + throw new Error('Missing subscription or metaAddressHash'); + } + + // Store subscription info in IndexedDB + const db = await openDB(); + const transaction = db.transaction([STORE_NAME], 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + + // Update existing entry or create new one + const existing = await new Promise((resolve, reject) => { + const request = store.get(subscription.keys?.p256dh || 'default'); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + }); + + const entry: StoredViewingKey = existing || { + publicKey: subscription.keys?.p256dh || 'default', + encryptedViewingKey: '', + encryptedSpendingPubKey: '', + encryptedSpendingScalar: '', + timestamp: Date.now(), + }; + + // Store relay URL and meta-address hash + (entry as any).relayUrl = relayUrl; + (entry as any).metaAddressHash = metaAddressHash; + (entry as any).pushSubscription = subscription; + + await new Promise((resolve, reject) => { + const request = store.put(entry); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); + + db.close(); + (event.source as Client)?.postMessage({ type: 'PUSH_SUBSCRIPTION_REGISTERED' }); + } catch (error) { + console.error('[wraith-sw] Failed to register push subscription:', error); + (event.source as Client)?.postMessage({ + type: 'PUSH_SUBSCRIPTION_ERROR', + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + })(), + ); + } + + if (type === 'UNREGISTER_PUSH_SUBSCRIPTION') { + event.waitUntil( + (async () => { + try { + const { subscription, metaAddressHash } = event.data ?? {}; + if (!subscription) { + throw new Error('Missing subscription'); + } + + const db = await openDB(); + const transaction = db.transaction([STORE_NAME], 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + + // Remove push subscription from entry + const existing = await new Promise((resolve, reject) => { + const request = store.get(subscription.keys?.p256dh || 'default'); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + }); + + if (existing) { + delete (existing as any).relayUrl; + delete (existing as any).metaAddressHash; + delete (existing as any).pushSubscription; + + await new Promise((resolve, reject) => { + const request = store.put(existing); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(); + }); + } + + db.close(); + (event.source as Client)?.postMessage({ type: 'PUSH_SUBSCRIPTION_UNREGISTERED' }); + } catch (error) { + console.error('[wraith-sw] Failed to unregister push subscription:', error); + (event.source as Client)?.postMessage({ + type: 'PUSH_SUBSCRIPTION_ERROR', + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + })(), + ); + } }); // ─── install / activate ───────────────────────────────────────────────────────