SkateSpots/src/lib/amber.ts
shakespeare.diy c30546f700 fix: use Android intent: URL format for Amber (not nostrsigner: directly)
The nostrsigner: scheme used directly caused "malformed request" in Amber
when opened from a browser context. The correct format per Amber's README
for web clients is the Android intent: URL format:

  intent:<payload>#Intent;scheme=nostrsigner;S.type=...;S.callbackUrl=...;end

Updated all Amber operations (login, sign, nip04/nip44 encrypt/decrypt)
to use buildIntentUrl() helper instead of raw nostrsigner: URLs.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-07-07 19:35:00 +07:00

637 lines
19 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);
}
/**
* 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=<result>
// 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:<payload>#Intent;scheme=nostrsigner;S.<key>=<value>;...;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:<payload>#Intent;scheme=nostrsigner;S.<key>=<val>;...;end
*/
function buildIntentUrl(payload: string, params: Record<string, string>): 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<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 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<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(() => {});
},
};
}