import type { NostrEvent, NostrSigner } from '@nostrify/nostrify'; /** * Pending Amber operation stored in multiple places for PWA resilience. * Used to track operations that are waiting for Amber to return. */ export interface AmberPendingOperation { type: 'get_public_key' | 'sign_event' | 'nip04_encrypt' | 'nip04_decrypt' | 'nip44_encrypt' | 'nip44_decrypt'; /** Unique ID for the operation */ id: string; /** The unsigned event JSON (for sign_event) */ eventJson?: string; /** Timestamp when operation was initiated */ timestamp: number; /** The pubkey to encrypt/decrypt with (for nip04/nip44 operations) */ targetPubkey?: string; /** The plaintext to encrypt (for encrypt operations) */ plaintext?: string; /** The ciphertext to decrypt (for decrypt operations) */ ciphertext?: string; } const AMBER_PENDING_KEY = 'amber_pending_operation'; const AMBER_LOGIN_KEY = 'amber_login'; /** * Store pending operation info for when Amber returns. * Uses multiple storage mechanisms for PWA resilience. */ export function setAmberPendingOperation(operation: AmberPendingOperation): void { const json = JSON.stringify(operation); // Store in multiple places for resilience (PWAs can lose localStorage on redirect) try { localStorage.setItem(AMBER_PENDING_KEY, json); } catch (e) { console.warn('[Amber] Failed to store in localStorage:', e); } try { sessionStorage.setItem(AMBER_PENDING_KEY, json); } catch (e) { console.warn('[Amber] Failed to store in sessionStorage:', e); } // Also store in IndexedDB for maximum persistence try { const request = indexedDB.open('amber_store', 1); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains('pending')) { db.createObjectStore('pending'); } }; request.onsuccess = () => { const db = request.result; const tx = db.transaction('pending', 'readwrite'); tx.objectStore('pending').put(json, AMBER_PENDING_KEY); }; } catch (e) { console.warn('[Amber] Failed to store in IndexedDB:', e); } } /** * Get pending operation from any available storage. */ export function getAmberPendingOperation(): AmberPendingOperation | null { // Try localStorage first let stored = localStorage.getItem(AMBER_PENDING_KEY); // Fall back to sessionStorage if (!stored) { stored = sessionStorage.getItem(AMBER_PENDING_KEY); if (stored) { console.log('[Amber] Found pending op in sessionStorage (localStorage was empty)'); } } if (!stored) return null; try { return JSON.parse(stored) as AmberPendingOperation; } catch { return null; } } /** * Get pending operation from IndexedDB (async). */ export async function getAmberPendingOperationAsync(): Promise { // First try sync storage const syncResult = getAmberPendingOperation(); if (syncResult) return syncResult; // Try IndexedDB return new Promise((resolve) => { try { const request = indexedDB.open('amber_store', 1); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains('pending')) { db.createObjectStore('pending'); } }; request.onsuccess = () => { const db = request.result; if (!db.objectStoreNames.contains('pending')) { resolve(null); return; } const tx = db.transaction('pending', 'readonly'); const getReq = tx.objectStore('pending').get(AMBER_PENDING_KEY); getReq.onsuccess = () => { if (getReq.result) { console.log('[Amber] Found pending op in IndexedDB'); try { resolve(JSON.parse(getReq.result)); } catch { resolve(null); } } else { resolve(null); } }; getReq.onerror = () => resolve(null); }; request.onerror = () => resolve(null); } catch { resolve(null); } }); } /** * Clear the pending operation from all storage. */ export function clearAmberPendingOperation(): void { try { localStorage.removeItem(AMBER_PENDING_KEY); } catch { /* ignore */ } try { sessionStorage.removeItem(AMBER_PENDING_KEY); } catch { /* ignore */ } try { const request = indexedDB.open('amber_store', 1); request.onsuccess = () => { const db = request.result; if (db.objectStoreNames.contains('pending')) { const tx = db.transaction('pending', 'readwrite'); tx.objectStore('pending').delete(AMBER_PENDING_KEY); } }; } catch { /* ignore */ } } /** * Store Amber login (pubkey). */ export function setAmberLogin(pubkey: string): void { localStorage.setItem(AMBER_LOGIN_KEY, pubkey); } /** * Get stored Amber login pubkey. */ export function getAmberLogin(): string | null { return localStorage.getItem(AMBER_LOGIN_KEY); } /** * Clear Amber login. */ export function clearAmberLogin(): void { localStorage.removeItem(AMBER_LOGIN_KEY); } /** * Extract search params from both the real query string AND the hash fragment. * * With HashRouter the URL looks like: * https://localhost/#/amber-callback?event=XXXX * In that case `window.location.search` is empty but the params are embedded * inside `window.location.hash` after the path segment. */ function getSearchParams(): URLSearchParams { // Normal query string (PWA / browser) if (window.location.search) { return new URLSearchParams(window.location.search); } // HashRouter: extract the query string portion from the hash const hash = window.location.hash; // e.g. "#/amber-callback?event=XXX" const qIndex = hash.indexOf('?'); if (qIndex !== -1) { return new URLSearchParams(hash.slice(qIndex)); } return new URLSearchParams(); } /** * Check if the current page was opened as an Amber callback. * Also checks pre-captured data from main.tsx for PWA resilience. */ export function isAmberCallback(): boolean { const params = getSearchParams(); if (params.has('event')) return true; // Check pre-captured data (PWAs might lose URL params during hydration) const preCaptured = sessionStorage.getItem('amber_callback_event'); if (preCaptured) { console.log('[Amber] Found pre-captured callback data'); return true; } return false; } /** * Get the result from Amber callback URL. * Falls back to pre-captured data from main.tsx. */ export function getAmberCallbackResult(): string | null { const params = getSearchParams(); const fromUrl = params.get('event'); if (fromUrl) { console.log('[Amber] Got result from URL params:', fromUrl); return fromUrl; } // Check pre-captured data const preCaptured = sessionStorage.getItem('amber_callback_event'); if (preCaptured) { console.log('[Amber] Got result from pre-captured data:', preCaptured); // Clear it so we don't process it again sessionStorage.removeItem('amber_callback_event'); sessionStorage.removeItem('amber_callback_url'); sessionStorage.removeItem('amber_callback_timestamp'); return preCaptured; } return null; } /** * Clean the URL after processing Amber callback. * With HashRouter we replace the hash to remove the query params. */ export function cleanAmberCallbackUrl(): void { if (window.location.hash.includes('?')) { // HashRouter: strip query from hash fragment const hashPath = window.location.hash.split('?')[0]; window.history.replaceState({}, '', window.location.pathname + hashPath); } else { const cleanUrl = window.location.origin + window.location.pathname; window.history.replaceState({}, '', cleanUrl); } } /** * Generate a unique operation ID. */ function generateOperationId(): string { return `amber_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; } /** * Build the dedicated Amber callback URL. * * This app uses HashRouter, so ALL in-app routes live inside the hash fragment: * https://skate-spots.org/#/amber-callback * * Amber will open the callbackUrl in the browser after the user approves. * The URL must therefore use the hash-based path so React Router can match * the /amber-callback route and render AmberCallbackPage. * * In the Capacitor APK we must use the app's custom URI scheme so Android fires * an intent back into the app (not a browser navigation). * * The app ID is `org.skatespots.app` — Capacitor registers this as a URI scheme * automatically via the `appId` in capacitor.config.ts. */ function buildCallbackUrl(): string { const isCapacitor = !!(window as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor?.isNativePlatform?.(); if (isCapacitor) { // Use the app's custom scheme so Amber fires the intent back into the APK. // DeepLinkHandler in AppRouter.tsx catches appUrlOpen and navigates to the // correct route inside the HashRouter. return 'org.skatespots.app://amber-callback?event='; } // HashRouter: routes live in the # fragment, so the callback URL must be: // https://skate-spots.org/#/amber-callback?event= // This is what Amber will open in the browser tab after user approves. return `${window.location.origin}/#/amber-callback?event=`; } /** * Navigate to an Amber nostrsigner: URL. * * For web apps the correct Android intent format is: * intent:#Intent;scheme=nostrsigner;S.=;...;end * * This is the format Amber's GitHub README documents for web clients. * The nostrsigner: URL format (without intent: wrapper) causes the * "malformed request" error because Android doesn't route it correctly * from a browser context. * * Reference: https://github.com/greenart7c3/Amber (Web Applications section) */ function openAmberUrl(url: string): void { window.location.href = url; } /** * Build an Android intent URL for Amber web usage. * Converts nostrsigner: params into the intent: format Amber expects from browsers. * * Format: intent:#Intent;scheme=nostrsigner;S.=;...;end */ function buildIntentUrl(payload: string, params: Record): string { const extras = Object.entries(params) .map(([k, v]) => `S.${k}=${encodeURIComponent(v)}`) .join(';'); return `intent:${payload}#Intent;scheme=nostrsigner;${extras};end`; } /** * Open Amber for get_public_key WITHOUT a callback URL. * Used in PWA/Capacitor mode: Amber copies pubkey to clipboard, * the app reads it on visibilitychange. */ export function openAmberForLoginNoCallback(): void { const amberUrl = buildIntentUrl('', { compressionType: 'none', returnType: 'signature', type: 'get_public_key', }); openAmberUrl(amberUrl); } /** * Open Amber for get_public_key operation. * Uses a dedicated /amber-callback route so the app state is preserved * and the callback is processed reliably on return. */ export function openAmberForLogin(): void { const operationId = generateOperationId(); const callbackUrl = buildCallbackUrl(); const pendingOp = { type: 'get_public_key' as const, id: operationId, timestamp: Date.now(), }; setAmberPendingOperation(pendingOp); // Store the current hash path so AmberCallbackPage can navigate back here // after login (e.g. user was on /map and tapped Login → returns to /map). const currentPath = window.location.hash.replace(/^#/, '') || '/'; localStorage.setItem('amber_return_path', currentPath); // Android intent format for web → Amber communication (per Amber README) const amberUrl = buildIntentUrl('', { compressionType: 'none', returnType: 'signature', type: 'get_public_key', callbackUrl: callbackUrl, }); console.log('[Amber] Opening Amber for login, intent:', amberUrl); openAmberUrl(amberUrl); } /** * Subscribe to app becoming visible again after user switches back from Amber. * Calls `onReturn` once when the page regains visibility. * Returns an unsubscribe function. * * Usage: * const unsub = subscribeToAmberReturn(() => checkClipboardForPubkey()); */ export function subscribeToAmberReturn(onReturn: () => void): () => void { const handler = () => { if (document.visibilityState === 'visible') { document.removeEventListener('visibilitychange', handler); onReturn(); } }; document.addEventListener('visibilitychange', handler); return () => document.removeEventListener('visibilitychange', handler); } /** * Open Amber for sign_event operation. * Returns a promise that resolves when user returns to the page. * The actual result needs to be processed on page load. */ export function openAmberForSignEvent(event: Omit): void { const operationId = generateOperationId(); const callbackUrl = buildCallbackUrl(); const eventJson = JSON.stringify(event); setAmberPendingOperation({ type: 'sign_event', id: operationId, eventJson, timestamp: Date.now(), }); const amberUrl = buildIntentUrl(encodeURIComponent(eventJson), { compressionType: 'none', returnType: 'event', type: 'sign_event', callbackUrl: callbackUrl, }); openAmberUrl(amberUrl); } /** * Open Amber for NIP-04 encrypt operation. */ export function openAmberForNip04Encrypt(pubkey: string, plaintext: string): void { const operationId = generateOperationId(); const callbackUrl = buildCallbackUrl(); setAmberPendingOperation({ type: 'nip04_encrypt', id: operationId, targetPubkey: pubkey, plaintext, timestamp: Date.now(), }); const amberUrl = buildIntentUrl(encodeURIComponent(plaintext), { pubKey: pubkey, compressionType: 'none', returnType: 'signature', type: 'nip04_encrypt', callbackUrl: callbackUrl, }); openAmberUrl(amberUrl); } /** * Open Amber for NIP-04 decrypt operation. */ export function openAmberForNip04Decrypt(pubkey: string, ciphertext: string): void { const operationId = generateOperationId(); const callbackUrl = buildCallbackUrl(); setAmberPendingOperation({ type: 'nip04_decrypt', id: operationId, targetPubkey: pubkey, ciphertext, timestamp: Date.now(), }); const amberUrl = buildIntentUrl(encodeURIComponent(ciphertext), { pubKey: pubkey, compressionType: 'none', returnType: 'signature', type: 'nip04_decrypt', callbackUrl: callbackUrl, }); openAmberUrl(amberUrl); } /** * Open Amber for NIP-44 encrypt operation. */ export function openAmberForNip44Encrypt(pubkey: string, plaintext: string): void { const operationId = generateOperationId(); const callbackUrl = buildCallbackUrl(); setAmberPendingOperation({ type: 'nip44_encrypt', id: operationId, targetPubkey: pubkey, plaintext, timestamp: Date.now(), }); const amberUrl = buildIntentUrl(encodeURIComponent(plaintext), { pubKey: pubkey, compressionType: 'none', returnType: 'signature', type: 'nip44_encrypt', callbackUrl: callbackUrl, }); openAmberUrl(amberUrl); } /** * Open Amber for NIP-44 decrypt operation. */ export function openAmberForNip44Decrypt(pubkey: string, ciphertext: string): void { const operationId = generateOperationId(); const callbackUrl = buildCallbackUrl(); setAmberPendingOperation({ type: 'nip44_decrypt', id: operationId, targetPubkey: pubkey, ciphertext, timestamp: Date.now(), }); const amberUrl = buildIntentUrl(encodeURIComponent(ciphertext), { pubKey: pubkey, compressionType: 'none', returnType: 'signature', type: 'nip44_decrypt', callbackUrl: callbackUrl, }); openAmberUrl(amberUrl); } /** * Decode a gzip-compressed Amber result (prefixed with "Signer1"). * Falls back to treating as plain text if not compressed. */ export function decodeAmberResult(result: string): string { if (result.startsWith('Signer1')) { // Gzip compressed result const base64 = result.substring(7); // Remove "Signer1" prefix try { const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } // Use DecompressionStream if available const ds = new DecompressionStream('gzip'); const writer = ds.writable.getWriter(); writer.write(bytes); writer.close(); const reader = ds.readable.getReader(); const chunks: Uint8Array[] = []; const readChunks = async (): Promise => { const { done, value } = await reader.read(); if (done) { const combined = new Uint8Array(chunks.reduce((acc, c) => acc + c.length, 0)); let offset = 0; for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.length; } return new TextDecoder().decode(combined); } chunks.push(value); return readChunks(); }; // For sync usage, we'll just return the raw result // The caller should handle async decompression if needed console.warn('Gzip decompression requires async handling'); return result; } catch (e) { console.error('Failed to decode gzip result:', e); return result; } } // Plain text result - might be URL encoded try { return decodeURIComponent(result); } catch { return result; } } /** * AmberSigner implements the NostrSigner interface for Amber. * * Note: Due to the nature of web-to-Amber communication via URL redirects, * signing operations will redirect the user to Amber and require them to * return to the app. This means sign() cannot return synchronously. * * For a better UX, consider using NIP-46 (Nostr Connect / Bunker) instead. */ export class AmberSigner implements NostrSigner { readonly pubkey: string; constructor(pubkey: string) { this.pubkey = pubkey; } async getPublicKey(): Promise { return this.pubkey; } async sign(event: Omit): Promise { // Store the current state and redirect to Amber // The result will be processed on page reload openAmberForSignEvent(event); // This promise will never resolve in the current page context // because we're redirecting to Amber return new Promise(() => { // The page will redirect, so this never resolves }); } readonly nip04 = { encrypt: async (pubkey: string, plaintext: string): Promise => { openAmberForNip04Encrypt(pubkey, plaintext); return new Promise(() => {}); }, decrypt: async (pubkey: string, ciphertext: string): Promise => { openAmberForNip04Decrypt(pubkey, ciphertext); return new Promise(() => {}); }, }; readonly nip44 = { encrypt: async (pubkey: string, plaintext: string): Promise => { openAmberForNip44Encrypt(pubkey, plaintext); return new Promise(() => {}); }, decrypt: async (pubkey: string, ciphertext: string): Promise => { openAmberForNip44Decrypt(pubkey, ciphertext); return new Promise(() => {}); }, }; }