- 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>
508 lines
15 KiB
TypeScript
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(() => {});
|
|
},
|
|
};
|
|
}
|