diff --git a/js/nostr/client.js b/js/nostr/client.js index 41dc2d0e..aa62ffde 100644 --- a/js/nostr/client.js +++ b/js/nostr/client.js @@ -164,6 +164,12 @@ import { } from "./nip46Client.js"; import { profileCache } from "../state/profileCache.js"; import { createPrivateKeyCipherClosures } from "./signerHelpers.js"; +import { + setActiveSigner as setActiveSignerInRegistry, + getActiveSigner as getActiveSignerFromRegistry, + clearActiveSigner as clearActiveSignerInRegistry, + resolveActiveSigner as resolveActiveSignerFromRegistry, +} from "../nostrClientRegistry.js"; function normalizeProfileFromEvent(event) { if (!event || !event.content) return null; @@ -174,9 +180,6 @@ function normalizeProfileFromEvent(event) { } } -let activeSigner = null; -const activeSignerRegistry = new Map(); - function attachNipMethodAliases(signer) { if (!signer || typeof signer !== "object") { return; @@ -283,45 +286,31 @@ function setActiveSigner(signer) { hydrateExtensionSignerCapabilities(signer); attachNipMethodAliases(signer); - activeSigner = signer; const pubkey = typeof signer.pubkey === "string" && signer.pubkey.trim() ? signer.pubkey.trim().toLowerCase() : ""; if (pubkey) { - activeSignerRegistry.set(pubkey, signer); profileCache.clearSignerRuntime(pubkey); } + + setActiveSignerInRegistry(signer); } function getActiveSigner() { - return activeSigner; + const signer = getActiveSignerFromRegistry(); + hydrateExtensionSignerCapabilities(signer); + return signer; } function clearActiveSigner() { - activeSigner = null; - activeSignerRegistry.clear(); + clearActiveSignerInRegistry(); } function resolveActiveSigner(pubkey) { - if (typeof pubkey === "string" && pubkey.trim()) { - const normalized = pubkey.trim().toLowerCase(); - const direct = activeSignerRegistry.get(normalized); - if (direct) { - hydrateExtensionSignerCapabilities(direct); - return direct; - } - if ( - activeSigner?.pubkey && - typeof activeSigner.pubkey === "string" && - activeSigner.pubkey.trim().toLowerCase() === normalized - ) { - hydrateExtensionSignerCapabilities(activeSigner); - return activeSigner; - } - } - hydrateExtensionSignerCapabilities(activeSigner); - return activeSigner; + const signer = resolveActiveSignerFromRegistry(pubkey); + hydrateExtensionSignerCapabilities(signer); + return signer; } function shouldRequestExtensionPermissions(signer) { diff --git a/js/nostrClientFacade.js b/js/nostrClientFacade.js index f05bd6b8..6aabbde3 100644 --- a/js/nostrClientFacade.js +++ b/js/nostrClientFacade.js @@ -9,3 +9,13 @@ export { nostrClient, requestDefaultExtensionPermissions, } from "./nostr/defaultClient.js"; + +export { + registerSigner, + setActiveSigner, + getActiveSigner, + resolveActiveSigner, + listRegisteredSigners, + onActiveSignerChanged, + offActiveSignerChanged, +} from "./nostrClientRegistry.js"; diff --git a/js/nostrClientRegistry.js b/js/nostrClientRegistry.js index ab0cb7f4..25d21901 100644 --- a/js/nostrClientRegistry.js +++ b/js/nostrClientRegistry.js @@ -2,6 +2,77 @@ let registeredNostrClient = null; let requestPermissionsDelegate = null; +const registeredSigners = new Map(); +let activeSigner = null; +let activeSignerPubkey = ""; +const activeSignerListeners = new Set(); + +const globalScope = + typeof window !== "undefined" + ? window + : typeof globalThis !== "undefined" + ? globalThis + : null; + +function normalizePubkey(pubkey) { + return typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; +} + +function emitSignerEvent(type, detail) { + if (!globalScope || typeof globalScope.dispatchEvent !== "function") { + return; + } + + let event = null; + if (typeof globalScope.CustomEvent === "function") { + event = new globalScope.CustomEvent(type, { detail }); + } else if (typeof Event === "function") { + event = new Event(type); + event.detail = detail; + } + + if (event) { + globalScope.dispatchEvent(event); + } +} + +function hasPermissionChanges(previousMeta, nextMeta) { + const previous = previousMeta?.permissions ?? null; + const next = nextMeta?.permissions ?? null; + try { + return JSON.stringify(previous) !== JSON.stringify(next); + } catch (error) { + return previous !== next; + } +} + +function updateActiveSigner(nextSigner, nextPubkey) { + const normalizedPubkey = normalizePubkey(nextPubkey); + const previousSigner = activeSigner; + const previousPubkey = activeSignerPubkey; + + activeSigner = nextSigner || null; + activeSignerPubkey = normalizedPubkey; + + if ( + previousSigner !== activeSigner || + previousPubkey !== activeSignerPubkey + ) { + const payload = { + previousSigner, + previousPubkey, + signer: activeSigner, + pubkey: activeSignerPubkey, + }; + activeSignerListeners.forEach((listener) => { + try { + listener(payload); + } catch (error) { + // Intentionally swallow listener errors to avoid breaking signer updates. + } + }); + } +} export function registerNostrClient(client, options = {}) { registeredNostrClient = client || null; @@ -45,6 +116,127 @@ export function requestDefaultExtensionPermissions(...args) { return Promise.resolve({ ok: false, error }); } +export function registerSigner(pubkey, signer, meta = {}) { + const normalized = normalizePubkey(pubkey); + if (!normalized) { + return null; + } + + const previous = registeredSigners.get(normalized) || null; + + if (!signer || typeof signer !== "object") { + if (previous) { + registeredSigners.delete(normalized); + emitSignerEvent("signer:disconnected", { + pubkey: normalized, + signer: previous.signer, + meta: previous.meta, + }); + + if (activeSignerPubkey === normalized) { + updateActiveSigner(null, ""); + } + } + return null; + } + + const sanitizedMeta = + meta && typeof meta === "object" ? { ...meta } : {}; + + registeredSigners.set(normalized, { signer, meta: sanitizedMeta }); + + if (!previous) { + emitSignerEvent("signer:connected", { + pubkey: normalized, + signer, + meta: sanitizedMeta, + }); + } else if (hasPermissionChanges(previous.meta, sanitizedMeta)) { + emitSignerEvent("signer:permissions:changed", { + pubkey: normalized, + signer, + previousMeta: previous.meta, + meta: sanitizedMeta, + }); + } + + if (activeSignerPubkey === normalized) { + updateActiveSigner(signer, normalized); + } + + return signer; +} + +export function setActiveSigner(signerOrPubkey) { + if (!signerOrPubkey) { + updateActiveSigner(null, ""); + return null; + } + + if (typeof signerOrPubkey === "string") { + const normalized = normalizePubkey(signerOrPubkey); + const entry = registeredSigners.get(normalized); + updateActiveSigner(entry?.signer || null, normalized); + return entry?.signer || null; + } + + if (typeof signerOrPubkey === "object") { + const pubkey = normalizePubkey(signerOrPubkey.pubkey); + if (pubkey) { + registerSigner(pubkey, signerOrPubkey); + } + updateActiveSigner(signerOrPubkey, pubkey); + return signerOrPubkey; + } + + return null; +} + +export function clearActiveSigner() { + updateActiveSigner(null, ""); +} + +export function getActiveSigner() { + return activeSigner; +} + +export function resolveActiveSigner(pubkey) { + const normalized = normalizePubkey(pubkey); + if (normalized) { + const entry = registeredSigners.get(normalized); + if (entry?.signer) { + return entry.signer; + } + if ( + activeSigner && + typeof activeSigner.pubkey === "string" && + normalizePubkey(activeSigner.pubkey) === normalized + ) { + return activeSigner; + } + } + return activeSigner; +} + +export function listRegisteredSigners() { + return Array.from(registeredSigners.entries()).map(([pubkey, entry]) => ({ + pubkey, + signer: entry.signer, + meta: entry.meta, + })); +} + +export function onActiveSignerChanged(callback) { + if (typeof callback === "function") { + activeSignerListeners.add(callback); + } +} + +export function offActiveSignerChanged(callback) { + activeSignerListeners.delete(callback); +} + export const __nostrClientRegistryTestHooks = { clearNostrClientRegistration, + clearActiveSigner, };