SkateSpots/src/lib/amber.ts
shakespeare.diy 03d0fe93b0 Improve Amber login flow - zero friction, auto-complete on return
- Add dedicated /amber-callback route so Amber always has a stable return URL
  - Handles get_public_key (login), sign_event, nip44/nip04 encrypt/decrypt
  - Shows animated processing screen while completing, then redirects back
- Add subscribeToAmberReturn() helper in amber.ts using visibilitychange
  - Auto-detects when user returns to the tab after cancelling in Amber
  - Resets login UI to initial state gracefully
- Update buildCallbackUrl() to use /amber-callback instead of current page URL
- Rewrite Amber tab in LoginDialog:
  - Remove 2-step clipboard paste flow entirely
  - Single tap opens Amber, login completes automatically on return
  - Waiting state shows animated dots + cancel button
  - No manual pubkey entry needed
- Save return path in sessionStorage so AmberCallbackPage redirects user back

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-06-17 22:29:06 +07:00

508 lines
15 KiB
TypeScript

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<AmberPendingOperation | null> {
// 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);
}
/**
* 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 = new URLSearchParams(window.location.search);
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 = new URLSearchParams(window.location.search);
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.
*/
export function cleanAmberCallbackUrl(): void {
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.
* Using a fixed /amber-callback path so Amber always returns to the same place.
*/
function buildCallbackUrl(): string {
return `${window.location.origin}/amber-callback?event=`;
}
/**
* 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);
// NIP-55 nostrsigner: URI — Amber opens, user approves, Amber calls callbackUrl
const amberUrl = `nostrsigner:?compressionType=none&returnType=signature&type=get_public_key&callbackUrl=${encodeURIComponent(callbackUrl)}`;
console.log('[Amber] Opening Amber for login, callback:', callbackUrl);
window.location.href = 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<NostrEvent, 'id' | 'sig'>): void {
const operationId = generateOperationId();
const callbackUrl = buildCallbackUrl();
const eventJson = JSON.stringify(event);
setAmberPendingOperation({
type: 'sign_event',
id: operationId,
eventJson,
timestamp: Date.now(),
});
const encodedEvent = encodeURIComponent(eventJson);
const amberUrl = `nostrsigner:${encodedEvent}?compressionType=none&returnType=event&type=sign_event&callbackUrl=${encodeURIComponent(callbackUrl)}`;
window.location.href = 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 encodedText = encodeURIComponent(plaintext);
const amberUrl = `nostrsigner:${encodedText}?pubkey=${pubkey}&compressionType=none&returnType=signature&type=nip04_encrypt&callbackUrl=${encodeURIComponent(callbackUrl)}`;
window.location.href = 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 encodedText = encodeURIComponent(ciphertext);
const amberUrl = `nostrsigner:${encodedText}?pubkey=${pubkey}&compressionType=none&returnType=signature&type=nip04_decrypt&callbackUrl=${encodeURIComponent(callbackUrl)}`;
window.location.href = 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 encodedText = encodeURIComponent(plaintext);
const amberUrl = `nostrsigner:${encodedText}?pubkey=${pubkey}&compressionType=none&returnType=signature&type=nip44_encrypt&callbackUrl=${encodeURIComponent(callbackUrl)}`;
window.location.href = 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 encodedText = encodeURIComponent(ciphertext);
const amberUrl = `nostrsigner:${encodedText}?pubkey=${pubkey}&compressionType=none&returnType=signature&type=nip44_decrypt&callbackUrl=${encodeURIComponent(callbackUrl)}`;
window.location.href = 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<string> => {
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<string> {
return this.pubkey;
}
async sign(event: Omit<NostrEvent, 'id' | 'sig'>): Promise<NostrEvent> {
// 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<string> => {
openAmberForNip04Encrypt(pubkey, plaintext);
return new Promise(() => {});
},
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
openAmberForNip04Decrypt(pubkey, ciphertext);
return new Promise(() => {});
},
};
readonly nip44 = {
encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
openAmberForNip44Encrypt(pubkey, plaintext);
return new Promise(() => {});
},
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
openAmberForNip44Decrypt(pubkey, ciphertext);
return new Promise(() => {});
},
};
}