Finalize NIP-46 remote signer integration

This commit is contained in:
thePR0M3TH3AN committed 2025-10-21 16:28:26 -04:00
1 parent 6047ffc22e
commit d3c3ac1ee7
7 files changed
+1979 -24

No files matched your search

+1
View File
@@ -428,6 +428,7 @@ class Application {
providers: authProviders,
services: {
authService: this.authService,
nostrClient,
},
callbacks: {
onProviderSelected: (providerId) => {
+1262 -12
View File
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -1,4 +1,5 @@
import nip07 from "./nip07.js";
import nip46 from "./nip46.js";
import nsec from "./nsec.js";
function normalizeProviderId(provider, fallbackId) {
@@ -37,7 +38,7 @@ function normalizeProviderMetadata(provider, fallbackId) {
return Object.freeze({ id, label, description, badgeVariant });
}
const providerList = [nip07, nsec].filter(Boolean);
const providerList = [nip07, nip46, nsec].filter(Boolean);
const providerMap = {};
const metadataMap = {};
+117
View File
@@ -0,0 +1,117 @@
const PROVIDER_ID = "nip46";
const PROVIDER_LABEL = "Remote signer";
const PROVIDER_DESCRIPTION = "Connect to a NIP-46 compatible signer via Nostr Connect.";
const PROVIDER_CAPABILITIES = Object.freeze([
Object.freeze({
id: "signing",
label: "Signs events remotely",
variant: "info",
}),
Object.freeze({
id: "multi-device",
label: "Works across browsers",
variant: "neutral",
}),
]);
const PROVIDER_BUTTON = Object.freeze({
variant: "outline",
});
const PROVIDER_MESSAGES = Object.freeze({
loading: "Connecting to remote signer…",
slow: "Waiting for the signer to approve…",
error: "Failed to connect to the remote signer. Please try again.",
});
function normalizePubkey(result) {
if (typeof result === "string") {
return result;
}
if (!result || typeof result !== "object") {
return "";
}
if (typeof result.pubkey === "string") {
return result.pubkey;
}
if (typeof result.publicKey === "string") {
return result.publicKey;
}
return "";
}
function normalizeOptions(options) {
if (!options || typeof options !== "object") {
return {
connectionString: "",
remember: true,
reuseStored: false,
};
}
return {
connectionString:
typeof options.connectionString === "string" ? options.connectionString.trim() : "",
remember: options.remember !== false,
reuseStored: options.reuseStored === true,
};
}
export default {
id: PROVIDER_ID,
label: PROVIDER_LABEL,
description: PROVIDER_DESCRIPTION,
badgeVariant: "info",
capabilities: PROVIDER_CAPABILITIES,
button: PROVIDER_BUTTON,
messages: PROVIDER_MESSAGES,
errorMessage: PROVIDER_MESSAGES.error,
async login({ nostrClient, options } = {}) {
if (!nostrClient || typeof nostrClient !== "object") {
const error = new Error("Remote signer login is not available.");
error.code = "provider-unavailable";
throw error;
}
if (typeof nostrClient.connectRemoteSigner !== "function") {
const error = new Error("Remote signer support is unavailable in this environment.");
error.code = "provider-unavailable";
throw error;
}
const normalized = normalizeOptions(options);
let result = null;
if (normalized.reuseStored) {
if (typeof nostrClient.useStoredRemoteSigner !== "function") {
const error = new Error("No stored remote signer is available on this device.");
error.code = "no-stored-session";
throw error;
}
result = await nostrClient.useStoredRemoteSigner();
} else {
const uri = normalized.connectionString;
if (!uri) {
const error = new Error("A connection string is required to continue.");
error.code = "connection-required";
throw error;
}
result = await nostrClient.connectRemoteSigner({
connectionString: uri,
remember: normalized.remember,
});
}
const pubkey = normalizePubkey(result);
return {
authType: PROVIDER_ID,
pubkey,
signer: result && typeof result === "object" ? result.signer || null : null,
};
},
};
+434 -6
View File
@@ -202,6 +202,10 @@ export default class LoginModalController {
services.authService && typeof services.authService === "object"
? services.authService
: null,
nostrClient:
services.nostrClient && typeof services.nostrClient === "object"
? services.nostrClient
: null,
};
this.callbacks = {
@@ -233,11 +237,18 @@ export default class LoginModalController {
this.modalElement?.querySelector(".modal-body") || null;
this.nsecTemplate =
this.modalElement?.querySelector("template[data-login-nsec-dialog]") || null;
this.nip46Template =
this.modalElement?.querySelector("template[data-login-nip46-dialog]") || null;
this.template = null;
this.providerEntries = new Map();
this.slowTimers = new Map();
this.boundClickHandler = (event) => this.handleContainerClick(event);
this.activeNsecForm = null;
this.activeNip46Form = null;
this.remoteSignerUnsubscribe = null;
this.lastRemoteSignerStatus = null;
this.initializeRemoteSignerStatus();
this.initialized = false;
this.initialize();
@@ -260,11 +271,20 @@ export default class LoginModalController {
this.renderProviders();
if (typeof this.lastRemoteSignerStatus !== "undefined") {
this.applyRemoteSignerStatus(this.lastRemoteSignerStatus);
}
this.providerContainer.addEventListener("click", this.boundClickHandler);
this.initialized = true;
}
getNostrClient() {
const direct = this.services?.nostrClient;
if (direct && typeof direct === "object") {
return direct;
}
const service = this.services?.authService;
if (!service || typeof service !== "object") {
return null;
@@ -288,6 +308,143 @@ export default class LoginModalController {
return null;
}
getStoredNip46Metadata() {
const nostrClient = this.getNostrClient();
if (!nostrClient || typeof nostrClient.getStoredNip46Metadata !== "function") {
return null;
}
try {
return nostrClient.getStoredNip46Metadata();
} catch (error) {
devLogger.warn(
"[LoginModalController] Failed to inspect stored NIP-46 session:",
error,
);
}
return null;
}
initializeRemoteSignerStatus() {
const nostrClient = this.getNostrClient();
if (!nostrClient) {
this.applyRemoteSignerStatus(null);
return;
}
if (typeof nostrClient.getRemoteSignerStatus === "function") {
try {
const status = nostrClient.getRemoteSignerStatus();
this.applyRemoteSignerStatus(status);
} catch (error) {
devLogger.warn(
"[LoginModalController] Failed to read initial remote signer status:",
error,
);
this.applyRemoteSignerStatus(null);
}
} else {
this.applyRemoteSignerStatus(null);
}
if (typeof nostrClient.onRemoteSignerChange === "function") {
try {
this.remoteSignerUnsubscribe = nostrClient.onRemoteSignerChange((status) => {
this.applyRemoteSignerStatus(status);
});
} catch (error) {
devLogger.warn(
"[LoginModalController] Failed to subscribe to remote signer updates:",
error,
);
}
}
}
formatRemoteSignerLabel(status) {
if (!status || typeof status !== "object") {
return "remote signer";
}
const { label, remoteNpub, remotePubkey } = status;
if (typeof label === "string" && label.trim()) {
return label.trim();
}
if (typeof remoteNpub === "string" && remoteNpub.trim()) {
return remoteNpub.trim();
}
if (typeof remotePubkey === "string" && remotePubkey.trim()) {
const trimmed = remotePubkey.trim();
if (trimmed.length <= 16) {
return trimmed;
}
return `${trimmed.slice(0, 8)}…${trimmed.slice(-6)}`;
}
return "remote signer";
}
applyRemoteSignerStatus(status) {
this.lastRemoteSignerStatus = status || null;
const storedMetadata = this.getStoredNip46Metadata();
const hasStoredSession = storedMetadata?.hasSession === true;
const normalizedState =
typeof status?.state === "string" && status.state.trim()
? status.state.trim()
: hasStoredSession
? "stored"
: "idle";
let message = "";
let showDisconnect = false;
switch (normalizedState) {
case "connected": {
if (typeof status?.userNpub === "string" && status.userNpub.trim()) {
message = `Connected as ${status.userNpub.trim()}`;
} else {
const label = this.formatRemoteSignerLabel(status);
message = `Connected to ${label}`;
}
showDisconnect = true;
break;
}
case "connecting": {
message = "Connecting to remote signer…";
break;
}
case "error": {
message =
typeof status?.message === "string" && status.message.trim()
? status.message.trim()
: "Remote signer unavailable.";
break;
}
case "stored": {
if (hasStoredSession) {
const label = this.formatRemoteSignerLabel(status || storedMetadata);
message = `Saved remote signer (${label}) ready to connect.`;
}
break;
}
default: {
message = "";
break;
}
}
this.setProviderStatus("nip46", {
message,
reset: !message,
showDisconnect,
statusState: normalizedState,
});
}
async promptForNsecOptions() {
if (!this.modalBody || !this.nsecTemplate) {
userLogger.warn("[LoginModalController] Direct key login is unavailable in this build.");
@@ -552,6 +709,160 @@ export default class LoginModalController {
});
}
async promptForNip46Options() {
if (!this.modalBody || !this.nip46Template) {
userLogger.warn(
"[LoginModalController] Remote signer login is unavailable in this build.",
);
return null;
}
if (this.activeNip46Form) {
return null;
}
const fragment = this.nip46Template.content
? this.nip46Template.content.cloneNode(true)
: null;
if (!fragment) {
return null;
}
const form = fragment.querySelector("[data-nip46-form]");
if (!(form instanceof HTMLFormElement)) {
return null;
}
const connectInput = form.querySelector("[data-nip46-connect-uri]");
const rememberCheckbox = form.querySelector("[data-nip46-remember]");
const reuseButton = form.querySelector("[data-nip46-reuse]");
const cancelButton = form.querySelector("[data-nip46-cancel]");
const errorNode = form.querySelector("[data-nip46-error]");
const storedMetadata = this.getStoredNip46Metadata();
const hasStoredSession = storedMetadata?.hasSession === true;
if (reuseButton instanceof HTMLButtonElement) {
reuseButton.classList.toggle("hidden", !hasStoredSession);
}
const elementsToHide = [];
for (const child of Array.from(this.modalBody.children)) {
if (child instanceof HTMLElement && child !== form) {
if (child.tagName === "TEMPLATE") {
continue;
}
const wasHidden = child.classList.contains("hidden");
if (!wasHidden) {
child.classList.add("hidden");
}
elementsToHide.push({ element: child, wasHidden });
}
}
this.modalBody.appendChild(form);
this.activeNip46Form = form;
const setError = (message) => {
if (!(errorNode instanceof HTMLElement)) {
return;
}
const normalized = typeof message === "string" ? message.trim() : "";
if (normalized) {
errorNode.textContent = normalized;
errorNode.classList.remove("hidden");
} else {
errorNode.textContent = "";
errorNode.classList.add("hidden");
}
};
setError("");
if (connectInput instanceof HTMLTextAreaElement) {
connectInput.focus();
}
let submitHandler = null;
let cancelHandler = null;
let reuseHandler = null;
const cleanup = () => {
if (submitHandler) {
form.removeEventListener("submit", submitHandler);
}
if (cancelHandler && cancelButton instanceof HTMLButtonElement) {
cancelButton.removeEventListener("click", cancelHandler);
}
if (reuseHandler && reuseButton instanceof HTMLButtonElement) {
reuseButton.removeEventListener("click", reuseHandler);
}
if (form.parentElement) {
form.parentElement.removeChild(form);
}
for (const entry of elementsToHide) {
if (!(entry?.element instanceof HTMLElement)) {
continue;
}
if (!entry.wasHidden) {
entry.element.classList.remove("hidden");
}
}
this.activeNip46Form = null;
};
return new Promise((resolve) => {
let settled = false;
const finish = (detail) => {
if (settled) {
return;
}
settled = true;
cleanup();
resolve(detail);
};
submitHandler = (event) => {
event.preventDefault();
setError("");
if (!(connectInput instanceof HTMLTextAreaElement)) {
setError("Enter the remote signer connection link to continue.");
return;
}
const raw = connectInput.value.trim();
if (!raw) {
setError("Paste the nostrconnect:// or bunker:// link from your signer.");
connectInput.focus();
return;
}
const remember =
rememberCheckbox instanceof HTMLInputElement ? rememberCheckbox.checked : true;
finish({ connectionString: raw, remember: remember !== false });
};
form.addEventListener("submit", submitHandler);
if (cancelButton instanceof HTMLButtonElement) {
cancelHandler = (event) => {
event.preventDefault();
finish(null);
};
cancelButton.addEventListener("click", cancelHandler);
}
if (reuseButton instanceof HTMLButtonElement) {
reuseHandler = (event) => {
event.preventDefault();
finish({ reuseStored: true });
};
reuseButton.addEventListener("click", reuseHandler);
}
});
}
resolveTemplate() {
if (!this.modalElement) {
return null;
@@ -660,6 +971,17 @@ export default class LoginModalController {
statusNode.classList.add("hidden");
}
const statusRow = button.querySelector("[data-provider-status-row]");
if (statusRow instanceof HTMLElement) {
statusRow.classList.add("hidden");
}
const disconnectButton = button.querySelector("[data-provider-disconnect]");
if (disconnectButton instanceof HTMLButtonElement) {
disconnectButton.dataset.providerId = provider.id;
disconnectButton.classList.add("hidden");
}
const capabilitiesNode = button.querySelector(
"[data-provider-capabilities]",
);
@@ -702,6 +1024,9 @@ export default class LoginModalController {
button,
descriptionNode: descriptionNode instanceof HTMLElement ? descriptionNode : null,
statusNode: statusNode instanceof HTMLElement ? statusNode : null,
statusRow: statusRow instanceof HTMLElement ? statusRow : null,
disconnectButton:
disconnectButton instanceof HTMLButtonElement ? disconnectButton : null,
defaultDescription: provider.description,
},
};
@@ -713,6 +1038,17 @@ export default class LoginModalController {
return;
}
const disconnectButton = target.closest("[data-provider-disconnect]");
if (disconnectButton instanceof HTMLButtonElement) {
const providerId = disconnectButton.dataset.providerId;
if (providerId) {
this.handleProviderDisconnect(providerId);
}
event.preventDefault();
event.stopPropagation();
return;
}
const button = target.closest("[data-provider-button]");
if (!(button instanceof HTMLButtonElement)) {
return;
@@ -726,10 +1062,60 @@ export default class LoginModalController {
this.handleProviderSelection(providerId);
}
async handleProviderDisconnect(providerId) {
if (providerId !== "nip46") {
return;
}
const nostrClient = this.getNostrClient();
if (!nostrClient || typeof nostrClient.disconnectRemoteSigner !== "function") {
return;
}
try {
await nostrClient.disconnectRemoteSigner({ keepStored: false });
} catch (error) {
devLogger.warn("[LoginModalController] Failed to disconnect remote signer:", error);
}
}
getProviderEntry(providerId) {
return this.providerEntries.get(providerId) || null;
}
setProviderStatus(providerId, options = {}) {
const entry = this.getProviderEntry(providerId);
if (!entry) {
return;
}
const {
message = "",
reset = false,
showDisconnect = false,
disableDisconnect = false,
statusState = null,
} = options || {};
if (statusState) {
entry.statusState = statusState;
} else {
delete entry.statusState;
}
this.updateStatusMessage(entry, message, reset);
const shouldShowRow = !reset && typeof message === "string" && message.trim();
if (entry.statusRow instanceof HTMLElement) {
entry.statusRow.classList.toggle("hidden", !shouldShowRow);
}
if (entry.disconnectButton instanceof HTMLButtonElement) {
entry.disconnectButton.classList.toggle("hidden", !showDisconnect);
entry.disconnectButton.disabled = !!disableDisconnect;
}
}
setLoadingState(providerId, isLoading) {
const entry = this.getProviderEntry(providerId);
if (!entry) {
@@ -741,10 +1127,14 @@ export default class LoginModalController {
entry.button.dataset.state = "loading";
entry.button.setAttribute("aria-busy", "true");
this.updateStatusMessage(
entry,
entry.provider.messages.loading || "Connecting…",
);
const loadingMessage = entry.provider.messages.loading || "Connecting…";
this.setProviderStatus(providerId, {
message: loadingMessage,
reset: false,
showDisconnect: false,
disableDisconnect: true,
statusState: "loading",
});
this.startSlowTimer(providerId, entry);
} else {
const shouldRemainDisabled = entry.provider.disabled === true;
@@ -758,7 +1148,7 @@ export default class LoginModalController {
delete entry.button.dataset.state;
entry.button.removeAttribute("aria-busy");
this.stopSlowTimer(providerId);
this.updateStatusMessage(entry, "", true);
this.setProviderStatus(providerId, { message: "", reset: true });
}
}
@@ -812,7 +1202,13 @@ export default class LoginModalController {
const slowMessage =
entry.provider.messages.slow || "Waiting for the provider…";
this.updateStatusMessage(entry, slowMessage);
this.setProviderStatus(providerId, {
message: slowMessage,
reset: false,
showDisconnect: false,
statusState: "waiting",
disableDisconnect: true,
});
}, timeout);
this.slowTimers.set(providerId, timerId);
@@ -877,6 +1273,26 @@ export default class LoginModalController {
);
return;
}
} else if (entry.provider.id === "nip46") {
try {
const nip46Options = await this.promptForNip46Options();
if (!nip46Options) {
devLogger.log(
"[LoginModalController] Remote signer login cancelled by the user.",
);
if (entry.button instanceof HTMLButtonElement) {
entry.button.focus();
}
return;
}
providerOptions = nip46Options;
} catch (promptError) {
devLogger.error(
"[LoginModalController] Failed to collect remote signer connection:",
promptError,
);
return;
}
}
this.setLoadingState(providerId, true);
@@ -942,6 +1358,18 @@ export default class LoginModalController {
this.stopSlowTimer(providerId);
}
if (typeof this.remoteSignerUnsubscribe === "function") {
try {
this.remoteSignerUnsubscribe();
} catch (error) {
devLogger.warn(
"[LoginModalController] Remote signer unsubscribe handler threw:",
error,
);
}
}
this.remoteSignerUnsubscribe = null;
this.providerEntries.clear();
this.initialized = false;
}