diff --git a/build/nostr-lite.js b/build/nostr-lite.js index 95b0a0b..28eaeb1 100644 --- a/build/nostr-lite.js +++ b/build/nostr-lite.js @@ -8,7 +8,7 @@ * Two-file architecture: * 1. Load nostr.bundle.js (official nostr-tools bundle) * 2. Load nostr-lite.js (this file - NOSTR_LOGIN_LITE library with CSS-only themes) - * Generated on: 2026-05-11T11:51:09.384Z + * Generated on: 2026-05-27T11:25:28.804Z */ // Verify dependencies are loaded @@ -436,7 +436,7 @@ class Modal { modalContent.appendChild(modalHeader); // Add version element in bottom-right corner aligned with modal body const versionElement = document.createElement('div'); - versionElement.textContent = 'v0.1.17'; + versionElement.textContent = 'v0.1.18'; versionElement.style.cssText = ` position: absolute; bottom: 8px; @@ -578,25 +578,27 @@ class Modal { }); } - // n_signer USB hardware option - if (isMethodEnabled('nsigner', true)) { + // Unified hardware signer option (Web Serial primary, WebUSB fallback) + const nsignerEnabled = isMethodEnabled('nsigner', true) || isMethodEnabled('nsigner_cyd', true); + if (nsignerEnabled) { const secure = typeof window !== 'undefined' ? window.isSecureContext : false; const hasUsb = typeof navigator !== 'undefined' && ('usb' in navigator); + const hasSerial = typeof navigator !== 'undefined' && ('serial' in navigator); - let description = 'Sign with USB-connected n_signer hardware'; + let description = 'Use your hardware signer (auto-detect serial/USB)'; let disabled = false; if (!secure) { description = 'Requires HTTPS or localhost'; disabled = true; - } else if (!hasUsb) { - description = 'Requires Chrome/Edge WebUSB support'; + } else if (!hasSerial && !hasUsb) { + description = 'Requires Web Serial or WebUSB support (Chrome/Edge/Brave)'; disabled = true; } options.push({ type: 'nsigner', - title: 'USB Hardware signer', + title: 'USB Hardware Signer', description, icon: '๐Ÿ”', disabled @@ -715,7 +717,8 @@ class Modal { this._showConnectScreen(); break; case 'nsigner': - this._showNSignerScreen({ autoPrompt: true }); + case 'nsigner_cyd': + this._showHardwareSignerScreen({ autoPrompt: true }); break; case 'readonly': this._handleReadonly(); @@ -1696,6 +1699,7 @@ class Modal { method: 'nsigner', pubkey, connectionData: { + transport: 'webusb', nostrIndex: 0, callerSecretKey: caller.secret, callerPubkey: caller.pubkey, @@ -1712,6 +1716,7 @@ class Modal { pubkey, signer: { driver, + transport: 'webusb', nostrIndex: 0, callerSecretKey: caller.secret, callerPubkey: caller.pubkey, @@ -1727,6 +1732,419 @@ class Modal { } } + _showHardwareSignerScreen({ autoPrompt = false } = {}) { + this.modalBody.innerHTML = ''; + + const title = document.createElement('h3'); + title.textContent = 'USB Hardware Signer'; + title.style.cssText = 'margin: 0 0 12px 0; font-size: 18px; font-weight: 600; color: var(--nl-primary-color);'; + + const description = document.createElement('p'); + description.textContent = 'Connect your hardware signer. We auto-detect Web Serial first, then WebUSB if needed.'; + description.style.cssText = 'margin-bottom: 14px; color: #6b7280; font-size: 14px;'; + + const status = document.createElement('div'); + status.style.cssText = 'margin-bottom: 12px; font-size: 12px; color: #6b7280;'; + + const secure = typeof window !== 'undefined' ? window.isSecureContext : false; + const hasUsb = typeof navigator !== 'undefined' && ('usb' in navigator); + const hasSerial = typeof navigator !== 'undefined' && ('serial' in navigator); + const canSerial = secure && hasSerial && typeof window.NSignerWebSerial === 'function'; + const canUsb = secure && hasUsb && typeof window.NSignerWebUSB === 'function'; + + if (!secure || (!canSerial && !canUsb)) { + status.textContent = !secure + ? 'Requires HTTPS or localhost.' + : 'No supported hardware transport detected (need Web Serial or WebUSB).'; + + const backButton = document.createElement('button'); + backButton.textContent = 'Back'; + backButton.onclick = () => this._renderLoginOptions(); + backButton.style.cssText = this._getButtonStyle('secondary'); + + this.modalBody.appendChild(title); + this.modalBody.appendChild(description); + this.modalBody.appendChild(status); + this.modalBody.appendChild(backButton); + return; + } + + const connectButton = document.createElement('button'); + connectButton.textContent = 'Connect Hardware Signer'; + connectButton.style.cssText = this._getButtonStyle(); + + const accountList = document.createElement('div'); + accountList.style.cssText = 'margin-top: 12px;'; + + const backButton = document.createElement('button'); + backButton.textContent = 'Back'; + backButton.onclick = () => this._renderLoginOptions(); + backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 10px;'; + + const shortHex = (v) => `${v.slice(0, 12)}...${v.slice(-8)}`; + const prePromptDisplay = this.container.style.display; + let hiddenForChooser = false; + + const isChooserCancel = (err) => { + const msg = String(err?.message || err || ''); + return msg.includes('NotFoundError') || msg.includes('No port selected') || msg.includes('No device selected'); + }; + + const restoreModalVisibility = () => { + if (hiddenForChooser && this.isVisible) { + this.container.style.display = prePromptDisplay || 'block'; + hiddenForChooser = false; + } + }; + + const renderTransportChoiceScreen = (note) => { + restoreModalVisibility(); + this.modalBody.innerHTML = ''; + + const choiceTitle = document.createElement('h3'); + choiceTitle.textContent = 'USB Hardware Signer'; + choiceTitle.style.cssText = 'margin: 0 0 12px 0; font-size: 18px; font-weight: 600; color: var(--nl-primary-color);'; + this.modalBody.appendChild(choiceTitle); + + const choiceDescription = document.createElement('p'); + choiceDescription.textContent = note || 'Choose which transport your hardware signer uses:'; + choiceDescription.style.cssText = 'margin-bottom: 14px; color: #6b7280; font-size: 14px;'; + this.modalBody.appendChild(choiceDescription); + + if (canSerial) { + const serialButton = document.createElement('button'); + serialButton.textContent = 'Try Web Serial Again'; + serialButton.style.cssText = this._getButtonStyle() + 'margin-bottom: 10px;'; + serialButton.onclick = () => connectNow({ forceTransport: 'webserial', viaUserGesture: true }); + this.modalBody.appendChild(serialButton); + } + + if (canUsb) { + const usbButton = document.createElement('button'); + usbButton.textContent = 'Try WebUSB Instead'; + usbButton.style.cssText = this._getButtonStyle() + 'margin-bottom: 10px;'; + usbButton.onclick = () => connectNow({ forceTransport: 'webusb', viaUserGesture: true }); + this.modalBody.appendChild(usbButton); + } + + const choiceStatus = document.createElement('div'); + choiceStatus.style.cssText = 'margin: 10px 0; font-size: 12px; color: #6b7280;'; + choiceStatus.id = 'nl-hwsigner-choice-status'; + this.modalBody.appendChild(choiceStatus); + + const choiceBack = document.createElement('button'); + choiceBack.textContent = 'Back'; + choiceBack.onclick = () => this._renderLoginOptions(); + choiceBack.style.cssText = this._getButtonStyle('secondary'); + this.modalBody.appendChild(choiceBack); + }; + + const renderConnectingScreen = (note) => { + restoreModalVisibility(); + this.modalBody.innerHTML = ''; + + const connTitle = document.createElement('h3'); + connTitle.textContent = 'USB Hardware Signer'; + connTitle.style.cssText = 'margin: 0 0 12px 0; font-size: 18px; font-weight: 600; color: var(--nl-primary-color);'; + this.modalBody.appendChild(connTitle); + + const connStatus = document.createElement('p'); + connStatus.textContent = note || 'Connecting...'; + connStatus.style.cssText = 'margin-bottom: 14px; color: #6b7280; font-size: 14px;'; + this.modalBody.appendChild(connStatus); + }; + + const probeAccounts = async (driver, label) => { + const accounts = []; + for (let i = 0; i < 6; i++) { + try { + driver.nostrIndex = i; + console.info(`[${label}] probe index`, i); + const pubkey = await driver.getPublicKey(); + if (/^[0-9a-f]{64}$/.test(pubkey) && !accounts.find(a => a.pubkey === pubkey)) { + accounts.push({ index: i, pubkey }); + } + } catch (e) { + console.warn(`[${label}] account probe failed for index`, i, ':', e?.message || e); + } + } + + if (!accounts.length) { + throw new Error('No account pubkeys returned by signer'); + } + + return accounts; + }; + + const renderAccountButtons = (connected) => { + this.modalBody.innerHTML = ''; + + const transportLabel = connected.transport === 'webserial' ? 'Web Serial' : 'WebUSB'; + + const selectionDescription = document.createElement('p'); + selectionDescription.textContent = `Connected via ${transportLabel}. Select which account to use (${connected.accounts.length} discovered):`; + selectionDescription.style.cssText = 'margin-bottom: 20px; color: #6b7280; font-size: 14px;'; + this.modalBody.appendChild(selectionDescription); + + const table = document.createElement('table'); + table.style.cssText = ` + width: 100%; + border-collapse: collapse; + margin-bottom: 20px; + font-family: var(--nl-font-family, 'Courier New', monospace); + font-size: 12px; + `; + + const thead = document.createElement('thead'); + thead.innerHTML = ` + + # + Use + + `; + table.appendChild(thead); + + const tbody = document.createElement('tbody'); + connected.accounts.forEach((acct) => { + const row = document.createElement('tr'); + row.style.cssText = 'border: 1px solid #d1d5db;'; + + const indexCell = document.createElement('td'); + indexCell.textContent = acct.index; + indexCell.style.cssText = 'padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;'; + + const actionCell = document.createElement('td'); + actionCell.style.cssText = 'padding: 8px; border: 1px solid #d1d5db;'; + + const selectButton = document.createElement('button'); + selectButton.textContent = shortHex(acct.pubkey); + selectButton.style.cssText = ` + width: 100%; + padding: 8px 12px; + font-size: 11px; + background: var(--nl-secondary-color); + color: var(--nl-primary-color); + border: 1px solid var(--nl-primary-color); + border-radius: 4px; + cursor: pointer; + font-family: 'Courier New', monospace; + text-align: center; + `; + selectButton.onmouseover = () => { selectButton.style.borderColor = 'var(--nl-accent-color)'; }; + selectButton.onmouseout = () => { selectButton.style.borderColor = 'var(--nl-primary-color)'; }; + selectButton.onclick = () => { + connected.driver.nostrIndex = acct.index; + this._setAuthMethod('nsigner', { + pubkey: acct.pubkey, + signer: { + driver: connected.driver, + transport: connected.transport, + nostrIndex: acct.index, + callerSecretKey: connected.callerSecretKey, + callerPubkey: connected.callerPubkey, + deviceVid: connected.deviceVid, + deviceProductId: connected.deviceProductId, + deviceSerial: connected.deviceSerial + } + }); + }; + + actionCell.appendChild(selectButton); + row.appendChild(indexCell); + row.appendChild(actionCell); + tbody.appendChild(row); + }); + + table.appendChild(tbody); + this.modalBody.appendChild(table); + + const selectionBackButton = document.createElement('button'); + selectionBackButton.textContent = 'Back'; + selectionBackButton.onclick = () => this._renderLoginOptions(); + selectionBackButton.style.cssText = this._getButtonStyle('secondary'); + this.modalBody.appendChild(selectionBackButton); + }; + + const connectNow = async ({ forceTransport = null, viaUserGesture = false } = {}) => { + try { + const caller = this._getOrCreateNSignerCallerIdentity(); + const startIndex = 0; + + const connectSerialFromPort = async (port) => { + const driver = new window.NSignerWebSerial(port, { + callerSecretKey: caller.secret, + nostrIndex: startIndex + }); + await driver.open(); + return driver; + }; + + const connectUsbFromDevice = async (device) => { + const driver = new window.NSignerWebUSB(device, { + callerSecretKey: caller.secret, + nostrIndex: startIndex + }); + await driver.open(); + return driver; + }; + + let transport = null; + let driver = null; + + // Try silent paired-device reconnect first when this isn't a transport-forced retry. + if (!forceTransport) { + if (canSerial) { + try { + const pairedSerial = await window.NSignerWebSerial.getPairedDevice({}); + if (pairedSerial) { + renderConnectingScreen('Reconnecting to paired Web Serial device...'); + transport = 'webserial'; + driver = await connectSerialFromPort(pairedSerial); + } + } catch (e) { + console.warn('Paired Web Serial reconnect failed:', e); + } + } + + if (!driver && canUsb) { + try { + const pairedUsb = await window.NSignerWebUSB.getPairedDevice({ + vendorId: 0x303a, + productId: 0x4001 + }); + if (pairedUsb) { + renderConnectingScreen('Reconnecting to paired WebUSB device...'); + transport = 'webusb'; + driver = await connectUsbFromDevice(pairedUsb); + } + } catch (e) { + console.warn('Paired WebUSB reconnect failed:', e); + } + } + } + + // Need a new chooser path. + if (!driver) { + // Determine which transport to prompt. Default to Web Serial when available. + const target = forceTransport + || (canSerial ? 'webserial' : (canUsb ? 'webusb' : null)); + + if (!target) { + throw new Error('No supported hardware transport available'); + } + + if (target === 'webserial') { + renderConnectingScreen('Waiting for Web Serial port selection...'); + try { + driver = await window.NSignerWebSerial.requestAndConnect({ + callerSecretKey: caller.secret, + nostrIndex: startIndex + }); + transport = 'webserial'; + } catch (serialErr) { + if (isChooserCancel(serialErr)) { + renderTransportChoiceScreen( + canUsb + ? 'No serial port selected. Choose how to connect:' + : 'No serial port selected.' + ); + return; + } + throw serialErr; + } + } else if (target === 'webusb') { + renderConnectingScreen('Waiting for WebUSB device selection...'); + try { + const selectedDevice = await navigator.usb.requestDevice({ filters: [{ vendorId: 0x303a }] }); + transport = 'webusb'; + driver = await connectUsbFromDevice(selectedDevice); + } catch (usbErr) { + if (isChooserCancel(usbErr)) { + renderTransportChoiceScreen( + canSerial + ? 'No USB device selected. Choose how to connect:' + : 'No USB device selected.' + ); + return; + } + throw usbErr; + } + } + } + + renderConnectingScreen('Connected. Reading account pubkeys...'); + const accounts = await probeAccounts(driver, transport); + + const serialInfo = transport === 'webserial' ? (driver._port?.getInfo?.() || {}) : null; + const connected = { + transport, + accounts, + callerSecretKey: caller.secret, + callerPubkey: caller.pubkey, + driver, + deviceVid: transport === 'webserial' ? (serialInfo.usbVendorId ?? null) : driver.vendorId, + deviceProductId: transport === 'webserial' ? (serialInfo.usbProductId ?? null) : driver.productId, + deviceSerial: transport === 'webserial' ? null : (driver.serial || null) + }; + + driver.onDisconnect(() => { + window.dispatchEvent(new CustomEvent('nlReconnectionRequired', { + detail: { + method: 'nsigner', + pubkey: accounts[0].pubkey, + connectionData: { + transport, + nostrIndex: accounts[0].index, + callerSecretKey: caller.secret, + callerPubkey: caller.pubkey, + deviceVid: connected.deviceVid, + deviceProductId: connected.deviceProductId, + deviceSerial: connected.deviceSerial + }, + message: transport === 'webserial' + ? 'Your hardware signer (Web Serial) was disconnected. Reconnect to continue.' + : 'Your hardware signer (WebUSB) was disconnected. Reconnect to continue.' + } + })); + }); + + restoreModalVisibility(); + renderAccountButtons(connected); + } catch (error) { + const msg = String(error?.message || error); + let displayMsg; + if (msg.includes('NetworkError')) { + displayMsg = 'Device/port is already in use โ€” close any serial monitor and try again.'; + } else if (msg.includes('SecurityError')) { + displayMsg = 'Access denied. Check browser permissions and Linux udev/dialout setup.'; + } else if (msg.includes('NotFoundError')) { + displayMsg = 'No device selected.'; + } else { + displayMsg = `Connect failed: ${msg}`; + } + + // Always re-surface a usable UI on failure. + renderTransportChoiceScreen(displayMsg); + } + }; + + connectButton.onclick = () => connectNow(); + + this.modalBody.appendChild(title); + this.modalBody.appendChild(description); + this.modalBody.appendChild(connectButton); + this.modalBody.appendChild(status); + this.modalBody.appendChild(accountList); + this.modalBody.appendChild(backButton); + + if (autoPrompt) { + hiddenForChooser = true; + this.container.style.display = 'none'; + connectNow(); + return; + } + } + _showNSignerScreen({ autoPrompt = false } = {}) { this.modalBody.innerHTML = ''; @@ -1843,6 +2261,7 @@ class Modal { pubkey: acct.pubkey, signer: { driver: connected.driver, + transport: 'webusb', nostrIndex: acct.index, callerSecretKey: connected.callerSecretKey, callerPubkey: connected.callerPubkey, @@ -1928,6 +2347,7 @@ class Modal { method: 'nsigner', pubkey: accounts[0].pubkey, connectionData: { + transport: 'webusb', nostrIndex: accounts[0].index, callerSecretKey: caller.secret, callerPubkey: caller.pubkey, @@ -1984,6 +2404,252 @@ class Modal { } } + _showCydSerialScreen({ autoPrompt = false } = {}) { + this.modalBody.innerHTML = ''; + + const title = document.createElement('h3'); + title.textContent = 'CYD Serial Hardware Signer'; + title.style.cssText = 'margin: 0 0 12px 0; font-size: 18px; font-weight: 600; color: var(--nl-primary-color);'; + + const description = document.createElement('p'); + description.textContent = 'Select your CYD (ESP32-2432S028) serial port when prompted, then choose one of the discovered accounts.'; + description.style.cssText = 'margin-bottom: 14px; color: #6b7280; font-size: 14px;'; + + const status = document.createElement('div'); + status.style.cssText = 'margin-bottom: 12px; font-size: 12px; color: #6b7280;'; + + const secure = typeof window !== 'undefined' ? window.isSecureContext : false; + const hasSerial = typeof navigator !== 'undefined' && ('serial' in navigator); + + if (!secure || !hasSerial) { + status.textContent = !secure + ? 'Requires HTTPS or localhost for Web Serial.' + : 'Web Serial unavailable. Use Chrome 89+, Edge 89+, or Brave.'; + + const backButton = document.createElement('button'); + backButton.textContent = 'Back'; + backButton.onclick = () => this._renderLoginOptions(); + backButton.style.cssText = this._getButtonStyle('secondary'); + + this.modalBody.appendChild(title); + this.modalBody.appendChild(description); + this.modalBody.appendChild(status); + this.modalBody.appendChild(backButton); + return; + } + + const connectButton = document.createElement('button'); + connectButton.textContent = 'Choose Serial Port'; + connectButton.style.cssText = this._getButtonStyle(); + + const accountList = document.createElement('div'); + accountList.style.cssText = 'margin-top: 12px;'; + + const backButton = document.createElement('button'); + backButton.textContent = 'Back'; + backButton.onclick = () => this._renderLoginOptions(); + backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 10px;'; + + const shortHex = (v) => `${v.slice(0, 12)}...${v.slice(-8)}`; + const prePromptDisplay = this.container.style.display; + let hiddenForChooser = false; + + const renderAccountButtons = (connected) => { + this.modalBody.innerHTML = ''; + + const selectionDescription = document.createElement('p'); + selectionDescription.textContent = `Select which account to use (${connected.accounts.length} accounts discovered from CYD signer):`; + selectionDescription.style.cssText = 'margin-bottom: 20px; color: #6b7280; font-size: 14px;'; + this.modalBody.appendChild(selectionDescription); + + const table = document.createElement('table'); + table.style.cssText = ` + width: 100%; + border-collapse: collapse; + margin-bottom: 20px; + font-family: var(--nl-font-family, 'Courier New', monospace); + font-size: 12px; + `; + + const thead = document.createElement('thead'); + thead.innerHTML = ` + + # + Use + + `; + table.appendChild(thead); + + const tbody = document.createElement('tbody'); + connected.accounts.forEach((acct) => { + const row = document.createElement('tr'); + row.style.cssText = 'border: 1px solid #d1d5db;'; + + const indexCell = document.createElement('td'); + indexCell.textContent = acct.index; + indexCell.style.cssText = 'padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;'; + + const actionCell = document.createElement('td'); + actionCell.style.cssText = 'padding: 8px; border: 1px solid #d1d5db;'; + + const selectButton = document.createElement('button'); + selectButton.textContent = shortHex(acct.pubkey); + selectButton.style.cssText = ` + width: 100%; + padding: 8px 12px; + font-size: 11px; + background: var(--nl-secondary-color); + color: var(--nl-primary-color); + border: 1px solid var(--nl-primary-color); + border-radius: 4px; + cursor: pointer; + font-family: 'Courier New', monospace; + text-align: center; + `; + selectButton.onmouseover = () => { selectButton.style.borderColor = 'var(--nl-accent-color)'; }; + selectButton.onmouseout = () => { selectButton.style.borderColor = 'var(--nl-primary-color)'; }; + selectButton.onclick = () => { + connected.driver.nostrIndex = acct.index; + this._setAuthMethod('nsigner', { + pubkey: acct.pubkey, + signer: { + driver: connected.driver, + transport: 'webserial', + nostrIndex: acct.index, + callerSecretKey: connected.callerSecretKey, + callerPubkey: connected.callerPubkey, + deviceVid: connected.deviceVid, + deviceProductId: connected.deviceProductId, + deviceSerial: null + } + }); + }; + + actionCell.appendChild(selectButton); + row.appendChild(indexCell); + row.appendChild(actionCell); + tbody.appendChild(row); + }); + + table.appendChild(tbody); + this.modalBody.appendChild(table); + + const selectionBackButton = document.createElement('button'); + selectionBackButton.textContent = 'Back'; + selectionBackButton.onclick = () => this._renderLoginOptions(); + selectionBackButton.style.cssText = this._getButtonStyle('secondary'); + this.modalBody.appendChild(selectionBackButton); + }; + + const connectNow = async () => { + try { + const startIndex = 0; + status.textContent = 'Connecting...'; + accountList.innerHTML = ''; + + const caller = this._getOrCreateNSignerCallerIdentity(); + + if (!window.NSignerWebSerial || typeof window.NSignerWebSerial !== 'function') { + throw new Error('NSignerWebSerial driver missing'); + } + + const driver = await window.NSignerWebSerial.requestAndConnect({ + callerSecretKey: caller.secret, + nostrIndex: startIndex + }); + + status.textContent = 'Connected. Reading account pubkeys...'; + const accounts = []; + for (let i = startIndex; i < startIndex + 6; i++) { + try { + driver.nostrIndex = i; + console.info('NSignerWebSerial probe index', i); + const pubkey = await driver.getPublicKey(); + console.info('NSignerWebSerial probe index', i, 'pubkey=', pubkey); + if (/^[0-9a-f]{64}$/.test(pubkey) && !accounts.find(a => a.pubkey === pubkey)) { + accounts.push({ index: i, pubkey }); + } + } catch (e) { + console.warn('NSignerWebSerial account probe failed for index', i, ':', e?.message || e); + } + } + + console.info('NSignerWebSerial probe complete, accounts found:', accounts.length, accounts); + + if (!accounts.length) { + throw new Error('No account pubkeys returned by CYD signer'); + } + + const info = driver._port?.getInfo?.() || {}; + const connected = { + accounts, + callerSecretKey: caller.secret, + callerPubkey: caller.pubkey, + driver, + deviceVid: info.usbVendorId ?? null, + deviceProductId: info.usbProductId ?? null + }; + + driver.onDisconnect(() => { + window.dispatchEvent(new CustomEvent('nlReconnectionRequired', { + detail: { + method: 'nsigner', + pubkey: accounts[0].pubkey, + connectionData: { + transport: 'webserial', + nostrIndex: accounts[0].index, + callerSecretKey: caller.secret, + callerPubkey: caller.pubkey, + deviceVid: connected.deviceVid, + deviceProductId: connected.deviceProductId, + deviceSerial: null + }, + message: 'Your CYD n_signer device was disconnected. Reconnect to continue.' + } + })); + }); + + if (hiddenForChooser && this.isVisible) { + this.container.style.display = prePromptDisplay || 'block'; + hiddenForChooser = false; + } + renderAccountButtons(connected); + } catch (error) { + const msg = String(error?.message || error); + if (msg.includes('NotFoundError') || msg.includes('No port selected')) { + status.textContent = 'No serial port selected.'; + } else if (msg.includes('NetworkError')) { + status.textContent = 'Port already in use โ€” close any serial monitor and try again.'; + } else if (msg.includes('SecurityError')) { + status.textContent = 'Access denied. On Linux, add yourself to the dialout group and replug.'; + } else { + status.textContent = `Connect failed: ${msg}`; + } + } + + if (hiddenForChooser && this.isVisible) { + this.container.style.display = prePromptDisplay || 'block'; + hiddenForChooser = false; + } + }; + + connectButton.onclick = connectNow; + + this.modalBody.appendChild(title); + this.modalBody.appendChild(description); + this.modalBody.appendChild(connectButton); + this.modalBody.appendChild(status); + this.modalBody.appendChild(accountList); + this.modalBody.appendChild(backButton); + + if (autoPrompt) { + hiddenForChooser = true; + this.container.style.display = 'none'; + connectNow(); + return; + } + } + _showConnectScreen() { this.modalBody.innerHTML = ''; @@ -3056,6 +3722,500 @@ if (typeof window !== 'undefined') { } +// ====================================== +// NSigner WebSerial Driver (CYD) +// ====================================== + +class NSignerWebSerial { + // CH340 VID:PID 0x1a86:0x7523 (most CYD revisions) + // CP2102 VID:PID 0x10c4:0xea60 (some CYD revisions) + static FILTERS = [ + { usbVendorId: 0x1a86, usbProductId: 0x7523 }, + { usbVendorId: 0x10c4, usbProductId: 0xea60 } + ]; + + static async requestAndConnect(options = {}) { + if (!('serial' in navigator)) { + throw new Error('Web Serial API not available in this browser (requires Chrome 89+, Edge 89+, or Brave)'); + } + + const port = await navigator.serial.requestPort({ filters: NSignerWebSerial.FILTERS }); + const driver = new NSignerWebSerial(port, options); + await driver.open(); + return driver; + } + + static async getPairedDevice(options = {}) { + if (!('serial' in navigator)) return null; + + const ports = await navigator.serial.getPorts(); + if (!ports || ports.length === 0) return null; + + const vendorId = options.vendorId ?? null; + const productId = options.productId ?? null; + + const match = ports.find(p => { + const info = p.getInfo(); + if (vendorId !== null && info?.usbVendorId !== vendorId) return false; + if (productId !== null && info?.usbProductId !== productId) return false; + return true; + }); + + return match || null; + } + + // Identical helpers to NSignerWebUSB โ€” kept here so the class is self-contained. + static randomSecretHex() { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return NSignerWebSerial._hex(bytes); + } + + static toPubkeyHex(secretHex) { + const secret = NSignerWebSerial._hexToBytes(secretHex); + const nt = window.NostrTools || {}; + + if (nt.schnorr && typeof nt.schnorr.getPublicKey === 'function') { + const pub = nt.schnorr.getPublicKey(secret); + return typeof pub === 'string' ? pub.toLowerCase() : NSignerWebSerial._hex(pub); + } + + if (typeof nt.getPublicKey === 'function') { + const pub = nt.getPublicKey(secret); + return typeof pub === 'string' ? pub.toLowerCase() : NSignerWebSerial._hex(pub); + } + + throw new Error('NostrTools.getPublicKey is unavailable in this bundle'); + } + + constructor(port, { callerSecretKey, nostrIndex = 0, openStabilizeMs = 1200, signalStrategies = null } = {}) { + if (!port) throw new Error('SerialPort is required'); + if (!callerSecretKey) throw new Error('callerSecretKey is required'); + + this._port = port; + this.callerSecretKey = callerSecretKey; + this.nostrIndex = Number.isFinite(Number(nostrIndex)) ? Number(nostrIndex) : 0; + + this._reader = null; + this._readLoopPromise = null; + this._ring = new Uint8Array(0); + this._pending = new Map(); // id -> { resolve, reject, timer } + this._disconnectHandlers = new Set(); + this._rpcCounter = 0; + this._open = false; + this._opening = false; + this._sawDisconnectDuringOpen = false; + this._openStabilizeMs = Math.max(0, Number(openStabilizeMs) || 1200); + this._signalStrategies = Array.isArray(signalStrategies) && signalStrategies.length + ? signalStrategies + : [ + { dataTerminalReady: false, requestToSend: false, label: 'dtr=0 rts=0' }, + { dataTerminalReady: true, requestToSend: true, label: 'dtr=1 rts=1' } + ]; + + // Bound port-level disconnect listener. + this._boundPortDisconnect = () => { + if (this._opening) this._sawDisconnectDuringOpen = true; + this._handleDisconnect(); + }; + } + + get isOpen() { + return this._open; + } + + get vendorId() { + return this._port?.getInfo()?.usbVendorId ?? null; + } + + get productId() { + return this._port?.getInfo()?.usbProductId ?? null; + } + + // Web Serial / CH340 / CP2102 do not expose a serial number. + get serial() { + return null; + } + + onDisconnect(cb) { + if (typeof cb === 'function') this._disconnectHandlers.add(cb); + return () => this._disconnectHandlers.delete(cb); + } + + async open() { + const originalInfo = this._port?.getInfo?.() || {}; + let lastError = null; + + for (let attempt = 0; attempt < 2; attempt++) { + this._opening = true; + this._sawDisconnectDuringOpen = false; + + try { + if (attempt > 0) { + const replacement = await NSignerWebSerial._findReenumeratedPort(originalInfo); + if (replacement) this._port = replacement; + } + + await this._port.open({ + baudRate: 115200, + dataBits: 8, + parity: 'none', + stopBits: 1, + flowControl: 'none' + }); + + this._port.addEventListener('disconnect', this._boundPortDisconnect); + + await this._applySignalStrategies(); + + // Wait for potential auto-reset / re-enumeration after open+signals. + await new Promise(r => setTimeout(r, this._openStabilizeMs)); + + if (this._sawDisconnectDuringOpen || !this._port?.readable || !this._port?.writable) { + throw new Error('Serial port dropped during open stabilization'); + } + + this._open = true; + this._readLoopPromise = this._readLoop(); + return; + } catch (err) { + lastError = err; + + try { this._port.removeEventListener('disconnect', this._boundPortDisconnect); } catch (_) {} + try { await this._port.close(); } catch (_) {} + + // Short wait before retrying in case device just re-enumerated. + await new Promise(r => setTimeout(r, 400)); + } finally { + this._opening = false; + } + } + + throw new Error(`Failed to open n_signer serial port: ${lastError?.message || lastError}`); + } + + async close() { + this._open = false; + + // Cancel the reader โ€” this breaks out of the read loop. + if (this._reader) { + try { await this._reader.cancel(); } catch (_) {} + } + + // Reject all in-flight RPCs. + for (const [, { reject, timer }] of this._pending) { + clearTimeout(timer); + reject(new Error('Connection closed')); + } + this._pending.clear(); + + this._port.removeEventListener('disconnect', this._boundPortDisconnect); + + try { await this._port.close(); } catch (_) {} + } + + // โ”€โ”€ Public RPC methods (identical surface to NSignerWebUSB) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + async getPublicKey() { + const params = [{ nostr_index: this.nostrIndex }]; + const resp = await this._rpcCall('get_public_key', params); + if (!resp || typeof resp.result !== 'string') { + throw new Error('Invalid get_public_key response'); + } + return resp.result.trim().toLowerCase(); + } + + async signEvent(unsignedEvent) { + const params = [unsignedEvent, { nostr_index: this.nostrIndex }]; + const resp = await this._rpcCall('sign_event', params); + if (!resp || typeof resp.result !== 'object') { + throw new Error('Invalid sign_event response'); + } + return resp.result; + } + + async nip04Encrypt(peerHex, plaintext) { + const resp = await this._rpcCall('nip04_encrypt', [peerHex, plaintext, { nostr_index: this.nostrIndex }]); + if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nip04_encrypt response'); + return resp.result; + } + + async nip04Decrypt(peerHex, ciphertext) { + const resp = await this._rpcCall('nip04_decrypt', [peerHex, ciphertext, { nostr_index: this.nostrIndex }]); + if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nip04_decrypt response'); + return resp.result; + } + + async nip44Encrypt(peerHex, plaintext) { + const resp = await this._rpcCall('nip44_encrypt', [peerHex, plaintext, { nostr_index: this.nostrIndex }]); + if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nip44_encrypt response'); + return resp.result; + } + + async nip44Decrypt(peerHex, ciphertext) { + const resp = await this._rpcCall('nip44_decrypt', [peerHex, ciphertext, { nostr_index: this.nostrIndex }]); + if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nip44_decrypt response'); + return resp.result; + } + + // โ”€โ”€ Internal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + async _rpcCall(method, params) { + const id = `nl-${Date.now()}-${++this._rpcCounter}`; + const auth = await this._buildAuth(id, method, params); + const req = { jsonrpc: '2.0', id, method, params, auth }; + + console.info('NSignerWebSerial _rpcCall outbound:', JSON.stringify(req)); + + const resp = await this._sendRpc(req); + + if (resp?.error) { + const msg = resp.error?.message || JSON.stringify(resp.error); + throw new Error(`n_signer ${method} failed: ${msg}`); + } + + return resp; + } + + _sendRpc(reqObj) { + return new Promise((resolve, reject) => { + const id = reqObj.id; + const timer = setTimeout(() => { + this._pending.delete(id); + reject(new Error('Timed out waiting for n_signer response')); + }, 30000); + + this._pending.set(id, { resolve, reject, timer }); + + this._writeFrame(reqObj).catch(err => { + this._pending.delete(id); + clearTimeout(timer); + reject(err); + }); + }); + } + + async _writeFrame(reqObj) { + const body = NSignerWebSerial._utf8(JSON.stringify(reqObj)); + const frame = new Uint8Array(4 + body.length); + frame.set(NSignerWebSerial._be32(body.length), 0); + frame.set(body, 4); + + console.info('NSignerWebSerial _writeFrame:', { payloadBytes: body.length, frameBytes: frame.length }); + + // Acquire writer, write, release immediately. + const w = this._port.writable.getWriter(); + try { + await w.write(frame); + } finally { + w.releaseLock(); + } + } + + async _readLoop() { + let ring = new Uint8Array(0); + + try { + this._reader = this._port.readable.getReader(); + + while (true) { + const { value, done } = await this._reader.read(); + if (done) break; + if (!value || value.length === 0) continue; + + // Append chunk to ring buffer. + const next = new Uint8Array(ring.length + value.length); + next.set(ring, 0); + next.set(value, ring.length); + ring = next; + + // Parse as many complete frames as possible. + while (ring.length >= 4) { + const n = (ring[0] << 24) | (ring[1] << 16) | (ring[2] << 8) | ring[3]; + + // Invalid length header โ€” slide one byte (boot-log recovery). + if (n <= 0 || n > 1_000_000) { + ring = ring.slice(1); + continue; + } + + // Not enough bytes yet for the full payload. + if (ring.length < 4 + n) break; + + const payload = ring.slice(4, 4 + n); + ring = ring.slice(4 + n); + + let resp; + try { + resp = JSON.parse(new TextDecoder().decode(payload)); + } catch (e) { + console.warn('NSignerWebSerial: frame parse error:', e); + continue; + } + + console.info('NSignerWebSerial _readLoop inbound:', JSON.stringify(resp)); + + // Resolve the matching pending RPC. + if (resp && resp.id && this._pending.has(resp.id)) { + const { resolve, timer } = this._pending.get(resp.id); + this._pending.delete(resp.id); + clearTimeout(timer); + resolve(resp); + } + } + } + } catch (err) { + if (err && err.name !== 'AbortError') { + console.warn('NSignerWebSerial read loop error:', err); + } + } finally { + try { this._reader.releaseLock(); } catch (_) {} + this._reader = null; + this._handleDisconnect(); + } + } + + _handleDisconnect() { + if (!this._open && !this._opening) return; // already closed cleanly + this._open = false; + + // Reject all in-flight RPCs. + for (const [, { reject, timer }] of this._pending) { + clearTimeout(timer); + reject(new Error('n_signer device disconnected')); + } + this._pending.clear(); + + for (const cb of this._disconnectHandlers) { + try { cb(); } catch (_) {} + } + } + + async _buildAuth(rpcId, method, params) { + const callerPriv = NSignerWebSerial._hexToBytes(this.callerSecretKey); + const callerPubX = NSignerWebSerial.toPubkeyHex(this.callerSecretKey); + + const createdAt = Math.floor(Date.now() / 1000); + const paramsJson = JSON.stringify(params ?? null); + const bodyHash = await NSignerWebSerial._sha256Hex(NSignerWebSerial._utf8(paramsJson)); + + const tags = [ + ['nsigner_rpc', String(rpcId)], + ['nsigner_method', String(method)], + ['nsigner_body_hash', bodyHash] + ]; + + const content = 'nostr_login_lite'; + const nt = window.NostrTools || {}; + + // Prefer finalizeEvent() โ€” stable across nostr-tools bundle shapes. + if (typeof nt.finalizeEvent === 'function') { + const finalized = nt.finalizeEvent({ + kind: 27235, + created_at: createdAt, + tags, + content + }, callerPriv); + + return { + id: String(finalized.id || '').toLowerCase(), + pubkey: String(finalized.pubkey || callerPubX).toLowerCase(), + created_at: createdAt, + kind: 27235, + tags, + content, + sig: String(finalized.sig || '').toLowerCase() + }; + } + + // Fallback: schnorr.sign directly. + const ser = JSON.stringify([0, callerPubX, createdAt, 27235, tags, content]); + const id = await NSignerWebSerial._sha256Hex(NSignerWebSerial._utf8(ser)); + + if (!nt.schnorr || typeof nt.schnorr.sign !== 'function') { + throw new Error('NostrTools signer unavailable (need finalizeEvent or schnorr.sign)'); + } + + const sigBytes = await nt.schnorr.sign(id, callerPriv, new Uint8Array(32)); + const sigHex = typeof sigBytes === 'string' ? sigBytes : NSignerWebSerial._hex(sigBytes); + + return { id, pubkey: callerPubX, created_at: createdAt, kind: 27235, tags, content, sig: sigHex }; + } + + async _applySignalStrategies() { + if (!this._port?.setSignals) return; + + for (const strategy of this._signalStrategies) { + try { + await this._port.setSignals({ + dataTerminalReady: !!strategy.dataTerminalReady, + requestToSend: !!strategy.requestToSend + }); + // Small settle gap between strategies. + await new Promise(r => setTimeout(r, 120)); + } catch (_) { + // Ignore unsupported setSignals implementations. + } + + if (this._sawDisconnectDuringOpen) return; + } + } + + static async _findReenumeratedPort(matchInfo = {}) { + try { + if (!navigator.serial?.getPorts) return null; + const ports = await navigator.serial.getPorts(); + if (!ports?.length) return null; + + const vid = matchInfo?.usbVendorId; + const pid = matchInfo?.usbProductId; + + return ports.find((p) => { + const i = p.getInfo?.() || {}; + if (vid != null && i.usbVendorId !== vid) return false; + if (pid != null && i.usbProductId !== pid) return false; + return true; + }) || ports[0] || null; + } catch (_) { + return null; + } + } + + // โ”€โ”€ Static utilities (mirrors NSignerWebUSB) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + static _utf8(s) { + return new TextEncoder().encode(s); + } + + static _be32(n) { + return new Uint8Array([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]); + } + + static _hex(bytes) { + return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); + } + + static _hexToBytes(hex) { + const v = String(hex || '').trim().toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(v)) { + throw new Error('Secret key must be 64 hex chars'); + } + const out = new Uint8Array(32); + for (let i = 0; i < 32; i++) out[i] = parseInt(v.slice(i * 2, i * 2 + 2), 16); + return out; + } + + static async _sha256Hex(dataBytes) { + const h = await crypto.subtle.digest('SHA-256', dataBytes); + return NSignerWebSerial._hex(new Uint8Array(h)); + } +} + +if (typeof window !== 'undefined') { + window.NSignerWebSerial = NSignerWebSerial; +} + + // ====================================== // FloatingTab Component (Recovered from git history) @@ -4300,6 +5460,7 @@ class AuthManager { case 'nsigner': if (authData.signer) { authState.nsigner = { + transport: String(authData.signer.transport || 'webusb').toLowerCase(), nostrIndex: Number(authData.signer.nostrIndex ?? 0), callerSecretKey: authData.signer.callerSecretKey, callerPubkey: authData.signer.callerPubkey, @@ -4686,13 +5847,91 @@ class AuthManager { const nsigner = authState.nsigner; if (!nsigner) return null; - if (!window.isSecureContext || !('usb' in navigator) || !window.NSignerWebUSB) { + const transport = String(nsigner.transport || 'webusb').toLowerCase(); + const isWebSerial = transport === 'webserial'; + + if (!window.isSecureContext) { return { method: 'nsigner', pubkey: authState.pubkey, requiresReconnection: true, connectionData: nsigner, - message: 'n_signer requires HTTPS/localhost and a WebUSB-capable browser (Chrome/Edge).' + message: 'n_signer requires HTTPS or localhost.' + }; + } + + if (isWebSerial) { + if (!('serial' in navigator) || !window.NSignerWebSerial) { + return { + method: 'nsigner', + pubkey: authState.pubkey, + requiresReconnection: true, + connectionData: nsigner, + message: 'CYD n_signer requires Web Serial (Chrome 89+, Edge 89+, or Brave).' + }; + } + + const paired = await window.NSignerWebSerial.getPairedDevice({ + vendorId: nsigner.deviceVid ?? null, + productId: nsigner.deviceProductId ?? null + }); + + if (!paired) { + return { + method: 'nsigner', + pubkey: authState.pubkey, + requiresReconnection: true, + connectionData: nsigner, + message: 'Your CYD n_signer device is not connected. Plug it in and reconnect to continue.' + }; + } + + try { + const driver = new window.NSignerWebSerial(paired, { + callerSecretKey: nsigner.callerSecretKey, + nostrIndex: Number(nsigner.nostrIndex ?? 0) + }); + + await driver.open(); + const restoredPubkey = await driver.getPublicKey(); + if (restoredPubkey !== String(authState.pubkey || '').toLowerCase()) { + await driver.close(); + this.clearAuthState(); + return null; + } + + return { + method: 'nsigner', + pubkey: authState.pubkey, + signer: { + driver, + transport: 'webserial', + nostrIndex: Number(nsigner.nostrIndex ?? 0), + callerSecretKey: nsigner.callerSecretKey, + callerPubkey: nsigner.callerPubkey, + deviceVid: nsigner.deviceVid, + deviceProductId: nsigner.deviceProductId, + deviceSerial: null + } + }; + } catch (_) { + return { + method: 'nsigner', + pubkey: authState.pubkey, + requiresReconnection: true, + connectionData: nsigner, + message: 'Could not reopen your CYD n_signer serial port. Reconnect from the modal.' + }; + } + } + + if (!('usb' in navigator) || !window.NSignerWebUSB) { + return { + method: 'nsigner', + pubkey: authState.pubkey, + requiresReconnection: true, + connectionData: nsigner, + message: 'n_signer requires a WebUSB-capable browser (Chrome/Edge).' }; } @@ -4731,6 +5970,7 @@ class AuthManager { pubkey: authState.pubkey, signer: { driver, + transport: 'webusb', nostrIndex: Number(nsigner.nostrIndex ?? 0), callerSecretKey: nsigner.callerSecretKey, callerPubkey: nsigner.callerPubkey, diff --git a/deploy.sh b/deploy.sh index c3f3eca..38ab8ac 100755 --- a/deploy.sh +++ b/deploy.sh @@ -2,5 +2,5 @@ rsync -avz --chmod=644 --progress \ build/{nostr-lite.js,nostr.bundle.js} \ - examples/{nsigner.html,feather_webusb_demo.html} \ + examples/{nsigner.html,feather_webusb_demo.html,cyd_webserial_demo.html} \ ubuntu@laantungir.net:html/nostr-login-lite/ diff --git a/examples/cyd_webserial_demo.html b/examples/cyd_webserial_demo.html new file mode 100644 index 0000000..7db9c41 --- /dev/null +++ b/examples/cyd_webserial_demo.html @@ -0,0 +1,737 @@ + + + + + + n_signer CYD Web Serial Demo + + + +
+

n_signer CYD Web Serial Demo

+

Connect to the CYD (ESP32-2432S028) board over Web Serial (CH340/CP2102 USB-UART bridge), choose a key index, fetch a pubkey, sign a kind 1 event, and test NIP-04 / NIP-44 encrypt + decrypt RPCs.

+ +
+ Requirements: Chrome 89+, Edge 89+, or Brave. Web Serial is not available in Firefox or Safari.
+ Linux users: Add yourself to the dialout group (sudo usermod -aG dialout $USER) and replug the device.
+ Windows users: Install the WCH-IC CH340 driver if the port does not appear.
+ Note: Close any serial monitor (Arduino IDE, idf.py monitor) before connecting โ€” only one app can hold the port at a time. +
+ +
+
+ + + Disconnected +
+

+    
+ +
+
+

Public Key

+ + +
+ +
+

+      
+ +
+

Sign Kind 1 Event

+ + + + +
+ +
+

+      
+ +
+

NIP-04 Encrypt

+ + + + +
+ +
+

+      
+ +
+

NIP-04 Decrypt

+ + + + +
+ +
+

+      
+ +
+

NIP-44 Encrypt

+ + + + +
+ +
+

+      
+ +
+

NIP-44 Decrypt

+ + + + +
+ +
+

+      
+
+
+ + + + diff --git a/plans/cyd_webserial_signer.md b/plans/cyd_webserial_signer.md new file mode 100644 index 0000000..3325045 --- /dev/null +++ b/plans/cyd_webserial_signer.md @@ -0,0 +1,154 @@ +# CYD Web Serial signer integration + +Add support for the new n_signer hardware board (CYD: ESP32-2432S028, the resistive-touch ILI9341 board) to `nostr_login_lite`, alongside the existing Feather WebUSB signer. + +## Background + +The existing [`NSignerWebUSB`](../src/signers/nsigner-webusb.js:1) talks to the Feather S3 board over **WebUSB** because the Feather has a native USB peripheral, enumerates with VID `0x303a`, and exposes a vendor-class interface (class 0xFF) plus a control transfer (`0x22, value=1`) that switches the device into WebUSB mode. + +The new CYD board does **not** have native USB. The ESP32 talks to the host through a CH340 (or, on some revisions, a CP2102) USB-to-UART bridge chip, so the host enumerates a plain CDC serial device. WebUSB cannot reach it; the Web Serial API (`navigator.serial`) can. + +On the firmware side the protocol is unchanged: both Feather and CYD speak the same 4-byte big-endian length-prefixed JSON-RPC framing โ€” only the underlying byte stream is different (USB bulk endpoint vs. UART byte stream). All RPC semantics (auth envelope kind 27235, nsigner_rpc / nsigner_method / nsigner_body_hash tags, `get_public_key`, `sign_event`, `nip04_encrypt`, `nip04_decrypt`, `nip44_encrypt`, `nip44_decrypt`) are identical. + +## Goals + +1. Add `NSignerWebSerial` to `src/signers/` with the same public API as [`NSignerWebUSB`](../src/signers/nsigner-webusb.js:1) so the rest of `nostr_login_lite` (modal, login flow, build pipeline) is agnostic to which board is connected. +2. Add a `cyd_webserial_demo.html` example mirroring [`feather_webusb_demo.html`](../examples/feather_webusb_demo.html), so a user can manually verify `get_public_key`, `sign_event`, NIP-04 and NIP-44 roundtrips against the CYD without booting the full SDK. +3. Update the login modal so the user can pick "Feather (WebUSB)" or "CYD (Serial)" at connect time. +4. Bundle the new signer into `nostr_login_lite.js` via `build.js`. +5. Document browser support, CH340 driver setup, and DTR/RTS behavior. + +## Non-goals + +- Touching the firmware: the CYD firmware in `n_signer/firmware/cyd_esp32_2432s028/` already exposes the correct protocol on UART0 at 115200 8N1 with no flow control. +- Replacing the existing `NSignerWebUSB`. Both transports coexist; users with Feather hardware continue to use WebUSB. +- Supporting Firefox/Safari. Web Serial (and WebUSB) are Chromium-only. This is a known limitation, not a regression. + +## Transport mapping + +| Concern | WebUSB (Feather) | Web Serial (CYD) | +|---|---|---| +| Device picker | `navigator.usb.requestDevice({filters:[{vendorId:0x303a}]})` | `navigator.serial.requestPort({filters:[{usbVendorId:0x1a86, usbProductId:0x7523}, {usbVendorId:0x10c4, usbProductId:0xea60}]})` | +| Open | `dev.open(); selectConfiguration(1); claimInterface; selectAlternateInterface; controlTransferOut(req=0x22, value=1)` | `port.open({baudRate:115200, dataBits:8, parity:"none", stopBits:1, flowControl:"none"})` | +| DTR/RTS | n/a | `port.setSignals({dataTerminalReady:false, requestToSend:false})` immediately after open to avoid pulsing EN/IO0 (which would reset the ESP32 or put it in download mode) | +| Already-paired discovery | `navigator.usb.getDevices()` | `navigator.serial.getPorts()` | +| Write | `dev.transferOut(EP_OUT, frame)` | `writer = port.writable.getWriter(); await writer.write(frame); writer.releaseLock()` | +| Read | `dev.transferIn(EP_IN, 512)` returning `DataView` | `reader = port.readable.getReader(); { value, done } = await reader.read(); reader.releaseLock()` (or hold the reader for the lifetime of the connection โ€” see below) | +| Disconnect event | `navigator.usb.addEventListener('disconnect', ...)` | `port.addEventListener('disconnect', ...)` plus the reader stream throwing on unplug | +| Close | `releaseInterface(); device.close()` | `await reader.cancel(); await port.close()` | + +Framing protocol is identical: `[4-byte big-endian length][JSON body]`. The CYD firmware in [`uart_transport.c`](../../../n_signer/firmware/cyd_esp32_2432s028/main/uart_transport.c) already tolerates leading non-frame bytes (early boot logs) by sliding the parser one byte at a time when the length header is invalid, so the client doesn't need to coordinate boot timing. + +## Design + +### `NSignerWebSerial` class + +File: `src/signers/nsigner-webserial.js`. + +Public API mirrors [`NSignerWebUSB`](../src/signers/nsigner-webusb.js:1) exactly: + +```text +static FILTERS // [{usbVendorId:0x1a86, usbProductId:0x7523}, {usbVendorId:0x10c4, usbProductId:0xea60}] +static async requestAndConnect(options) // picker -> open -> return driver +static async getPairedDevice(options) // navigator.serial.getPorts() -> first matching +static randomSecretHex() // unchanged from WebUSB version +static toPubkeyHex(secretHex) // unchanged +constructor(port, { callerSecretKey, nostrIndex }) +get isOpen, vendorId, productId, serial +onDisconnect(cb) +async open() +async close() +async getPublicKey() +async signEvent(unsignedEvent) +async nip04Encrypt(peerHex, plaintext) +async nip04Decrypt(peerHex, ciphertext) +async nip44Encrypt(peerHex, plaintext) +async nip44Decrypt(peerHex, ciphertext) +``` + +Internal differences from the WebUSB class: + +1. **Constructor takes a `SerialPort`** (from `navigator.serial`) rather than a `USBDevice`. +2. **`vendorId` / `productId` / `serial`** come from `port.getInfo()` (`usbVendorId`, `usbProductId`). `serial` is generally unavailable on Web Serial โ€” keep the field for API parity but expect `null`. +3. **Single long-lived reader.** Web Serial's reader, unlike WebUSB's `transferIn` calls, owns the readable stream for as long as it's locked. The cleanest pattern is: + - On `open()`, start a background `_readLoop()` task that acquires the reader once, accumulates bytes into a ring buffer, parses frames, and resolves the pending RPC promise keyed by `req.id`. + - `_sendRpc()` registers `{id, resolve, reject, timer}` in a `Map`, writes the frame via the writer, and awaits the registered promise. + - On `close()`, call `reader.cancel()` to break out of the loop, then `port.close()`. +4. **DTR/RTS handling.** Right after `port.open()`, call `port.setSignals({dataTerminalReady: false, requestToSend: false})`. The CH340's RTS/DTR are wired to the ESP32 EN/IO0 reset/boot pins through a small transistor network on the CYD; the auto-reset-into-bootloader sequence triggers when esptool toggles them in a specific pattern. We never want either asserted during normal operation. Test empirically โ€” if it turns out the CYD revision in use ignores them, we leave the call as a defensive no-op. +5. **Reconnect detection.** The `disconnect` event on the port fires when the user unplugs the cable; our loop's `reader.read()` will resolve `{done:true}` shortly after. Both paths should call the registered `_disconnectHandlers`. +6. **Frame parser.** Identical to the WebUSB ring-buffer parser, including the "slide one byte and retry" recovery when the length header is invalid (e.g., during the CYD bootloader's early-boot stdout noise). +7. **Auth envelope construction.** Reuse the kind-27235 builder verbatim. Extract `_buildAuth`, `_be32`, `_hex`, `_hexToBytes`, `_sha256Hex`, `_utf8` into a shared helper module (e.g. `src/signers/_auth.js`) so both classes import the same code rather than duplicating it. Optional polish โ€” not required for the first cut. + +### Standalone demo: `examples/cyd_webserial_demo.html` + +Copy [`feather_webusb_demo.html`](../examples/feather_webusb_demo.html). Replace only: +- Title and intro text ("n_signer CYD Web Serial Demo"). +- `connect()` function: swap WebUSB calls for Web Serial as per the transport mapping above. +- The `sendRpc()` loop: use the long-lived reader pattern. +- A small "Disconnect" button that calls `port.close()` cleanly. + +Keep all the auth-envelope code, kind 1 signer, NIP-04/44 sections unchanged. + +### Modal / login flow + +In `src/ui/modal.js`, where the WebUSB option is rendered, add a sibling "CYD (Serial)" option. On click, instantiate `NSignerWebSerial` instead of `NSignerWebUSB`. The downstream code already uses the common interface so no further plumbing should be needed. + +### Build pipeline + +In `build.js`, add `src/signers/nsigner-webserial.js` (and the shared `_auth.js` if extracted) to the concatenation list, exposing `window.NSignerWebSerial` so non-bundled consumers can use it directly the same way [`window.NSignerWebUSB`](../src/signers/nsigner-webusb.js:355) is exposed today. + +## DTR/RTS / CH340 caveats + +- The CH340 datasheet specifies DTR and RTS are open-collector outputs. On the CYD, these tie through transistors to the ESP32 EN (reset) and IO0 (boot mode) pins respectively. esptool relies on this circuit for one-click flashing. +- Linux's `cdc-acm` driver (well, the `ch341` kernel driver) toggles DTR/RTS on `open(2)` by default, which is why naively running `cat /dev/ttyUSB0` resets the board. Web Serial does NOT do this automatically as far as Chromium's implementation goes โ€” but to be safe we explicitly set both to `false` after open. +- If the board resets on connect anyway, mitigations: + - Add a 10 ยตF capacitor between EN and GND (a common hardware fix; out of scope for this plan). + - In software, after `port.open()`, immediately call `setSignals({dataTerminalReady:false, requestToSend:false})`, then wait ~200 ms before doing anything else, then drain and discard any pending bytes (since the firmware will spew boot logs). + - If the user does see a reset, the client should be resilient: a reset means `port.readable` will close, the disconnect event fires, but the OS-level serial port may still exist. The client should NOT try to reopen automatically; surface "device reset, please reconnect" to the user. + +## Browser support and udev + +- **Web Serial**: Chrome 89+, Edge 89+, Brave, Opera. Not in Firefox or Safari. +- **Linux**: the user's account must be in `dialout` (Debian/Ubuntu) or `uucp` (Arch) for unprivileged access. ChromeOS exposes serial ports via permission prompts. macOS and Windows: no extra setup beyond the CH340/CP2102 driver. +- **macOS CH340 driver**: WCH-IC ships a driver for older macOS; macOS 11+ has an in-tree driver but it sometimes conflicts with old kexts. Document this in the README. +- **Windows CH340 driver**: WCH-IC's official driver is required on Windows 10/11 (the in-box `usbser` driver does not auto-bind to all CH340 PID variants). + +## Validation + +1. Open `cyd_webserial_demo.html` in Chrome, click Connect, pick the CYD port. +2. Verify "Connected" status appears; auto-fetch returns the device's `pubkey` (64-hex). +3. Sign a kind 1 event โ€” on the CYD's touchscreen the approval prompt should appear; tap "Approve" โ€” the demo logs the signed event. +4. Tap "Always" once for nip04_encrypt, run an encrypt/decrypt roundtrip, then for nip44. Confirm second invocation of the same method skips the approval prompt due to `Always` caching on-device. +5. Tap "Deny" on one โ€” confirm the client receives a deny error. +6. Unplug the cable โ€” confirm the modal/SDK fires `onDisconnect`. + +## Risks / open questions + +- **CH340 vs CP2102 hardware revisions.** The two known VID/PID pairs cover most CYDs; if a third variant appears (e.g., FTDI FT232R, `0x0403:0x6001`), add a third filter entry. Web Serial allows multiple filters in one `requestPort` call so the picker shows any matching device. +- **`SerialPort.getInfo()`** on some Chrome versions returns `null` vendor/product IDs on Linux when the kernel driver doesn't expose them through `/sys/`. The fallback is to show all ports in the picker (no filter) โ€” slightly worse UX but functional. +- **No serial number** is exposed by CH340/CP2102 chips on CYDs, so the "remember this device" UX from `NSignerWebUSB.getPairedDevice` will only be able to match on VID/PID, not serial. If multiple CYDs are connected, the user picks each time. +- **Concurrent access.** Only one tab can open a given serial port at a time. If the user has the Arduino IDE serial monitor or `idf.py monitor` running on the same port, Web Serial's `port.open()` will throw `NetworkError`. Document this. + +## Phasing + +| Phase | Deliverable | +|---|---| +| 1 | `cyd_webserial_demo.html` โ€” standalone, no SDK. Lets us validate the transport works end-to-end against the CYD firmware. | +| 2 | `src/signers/nsigner-webserial.js` โ€” class with full API parity. | +| 3 | Modal integration in `src/ui/modal.js`; build pipeline update in `build.js`. | +| 4 | Documentation: README updates (browser support, driver setup, udev rule note), plus a short user-facing note in the n_signer-side `firmware/README.md` linking to the new demo. | + +Phase 1 is the recommended first step because the firmware is already done โ€” landing a working demo verifies the transport and unblocks Phase 2 with a known-good wire format to imitate. + +## Acceptance criteria + +- `cyd_webserial_demo.html` performs a full get_public_key / sign_event / nip04 roundtrip / nip44 roundtrip against a CYD device. +- `NSignerWebSerial` passes the same smoke tests as `NSignerWebUSB`, with the same public surface (so any existing call site that takes either driver works without changes). +- Login modal renders both transport options and produces an equivalent session for either path. +- The Feather WebUSB path continues to work unchanged. + +## Question raised by the user + +> Is our new board ready to work with `nostr_login_lite` once Phase 1โ€“3 land? + +**Yes.** The CYD firmware in `n_signer/firmware/cyd_esp32_2432s028/` already implements the full JSON-RPC surface (`get_public_key`, `sign_event`, `nip04_encrypt`, `nip04_decrypt`, `nip44_encrypt`, `nip44_decrypt`) over UART0, with the same auth envelope and approval UI as the Feather. Once `NSignerWebSerial` exists and the modal offers it, `nostr_login_lite` will treat the CYD as a fully-supported signer. The only remaining gating items are the optional polish in this plan (touch calibration persistence and the CYD-side flash helper script โ€” both are out of scope here and tracked on the n_signer firmware side). diff --git a/src/VERSION b/src/VERSION index 04c5555..f8bc4c6 100644 --- a/src/VERSION +++ b/src/VERSION @@ -1 +1 @@ -0.1.17 +0.1.18 diff --git a/src/build.js b/src/build.js index 3519823..d0f7b9e 100644 --- a/src/build.js +++ b/src/build.js @@ -230,6 +230,37 @@ if (typeof window !== 'undefined') { console.warn('โš ๏ธ NSigner driver not found: signers/nsigner-webusb.js'); } + // Add NSigner WebSerial driver (CYD ESP32-2432S028) + const nsignerSerialDriverPath = path.join(__dirname, 'signers/nsigner-webserial.js'); + if (fs.existsSync(nsignerSerialDriverPath)) { + let nsignerSerialContent = fs.readFileSync(nsignerSerialDriverPath, 'utf8'); + + let lines = nsignerSerialContent.split('\n'); + let contentStartIndex = 0; + + for (let i = 0; i < Math.min(15, lines.length); i++) { + const line = lines[i].trim(); + if (line.startsWith('/**') || line.startsWith('*') || + line.startsWith('/*') || line.startsWith('//')) { + contentStartIndex = i + 1; + } else if (line && !line.startsWith('*') && !line.startsWith('//')) { + break; + } + } + + if (contentStartIndex > 0) { + lines = lines.slice(contentStartIndex); + } + + bundle += `// ======================================\n`; + bundle += `// NSigner WebSerial Driver (CYD)\n`; + bundle += `// ======================================\n\n`; + bundle += lines.join('\n'); + bundle += '\n\n'; + } else { + console.warn('โš ๏ธ NSigner WebSerial driver not found: signers/nsigner-webserial.js'); + } + // Add main library code // console.log('๐Ÿ“„ Adding Main Library...'); bundle += ` @@ -1476,6 +1507,7 @@ class AuthManager { case 'nsigner': if (authData.signer) { authState.nsigner = { + transport: String(authData.signer.transport || 'webusb').toLowerCase(), nostrIndex: Number(authData.signer.nostrIndex ?? 0), callerSecretKey: authData.signer.callerSecretKey, callerPubkey: authData.signer.callerPubkey, @@ -1862,13 +1894,91 @@ class AuthManager { const nsigner = authState.nsigner; if (!nsigner) return null; - if (!window.isSecureContext || !('usb' in navigator) || !window.NSignerWebUSB) { + const transport = String(nsigner.transport || 'webusb').toLowerCase(); + const isWebSerial = transport === 'webserial'; + + if (!window.isSecureContext) { return { method: 'nsigner', pubkey: authState.pubkey, requiresReconnection: true, connectionData: nsigner, - message: 'n_signer requires HTTPS/localhost and a WebUSB-capable browser (Chrome/Edge).' + message: 'n_signer requires HTTPS or localhost.' + }; + } + + if (isWebSerial) { + if (!('serial' in navigator) || !window.NSignerWebSerial) { + return { + method: 'nsigner', + pubkey: authState.pubkey, + requiresReconnection: true, + connectionData: nsigner, + message: 'CYD n_signer requires Web Serial (Chrome 89+, Edge 89+, or Brave).' + }; + } + + const paired = await window.NSignerWebSerial.getPairedDevice({ + vendorId: nsigner.deviceVid ?? null, + productId: nsigner.deviceProductId ?? null + }); + + if (!paired) { + return { + method: 'nsigner', + pubkey: authState.pubkey, + requiresReconnection: true, + connectionData: nsigner, + message: 'Your CYD n_signer device is not connected. Plug it in and reconnect to continue.' + }; + } + + try { + const driver = new window.NSignerWebSerial(paired, { + callerSecretKey: nsigner.callerSecretKey, + nostrIndex: Number(nsigner.nostrIndex ?? 0) + }); + + await driver.open(); + const restoredPubkey = await driver.getPublicKey(); + if (restoredPubkey !== String(authState.pubkey || '').toLowerCase()) { + await driver.close(); + this.clearAuthState(); + return null; + } + + return { + method: 'nsigner', + pubkey: authState.pubkey, + signer: { + driver, + transport: 'webserial', + nostrIndex: Number(nsigner.nostrIndex ?? 0), + callerSecretKey: nsigner.callerSecretKey, + callerPubkey: nsigner.callerPubkey, + deviceVid: nsigner.deviceVid, + deviceProductId: nsigner.deviceProductId, + deviceSerial: null + } + }; + } catch (_) { + return { + method: 'nsigner', + pubkey: authState.pubkey, + requiresReconnection: true, + connectionData: nsigner, + message: 'Could not reopen your CYD n_signer serial port. Reconnect from the modal.' + }; + } + } + + if (!('usb' in navigator) || !window.NSignerWebUSB) { + return { + method: 'nsigner', + pubkey: authState.pubkey, + requiresReconnection: true, + connectionData: nsigner, + message: 'n_signer requires a WebUSB-capable browser (Chrome/Edge).' }; } @@ -1907,6 +2017,7 @@ class AuthManager { pubkey: authState.pubkey, signer: { driver, + transport: 'webusb', nostrIndex: Number(nsigner.nostrIndex ?? 0), callerSecretKey: nsigner.callerSecretKey, callerPubkey: nsigner.callerPubkey, diff --git a/src/signers/nsigner-webserial.js b/src/signers/nsigner-webserial.js new file mode 100644 index 0000000..1c4d831 --- /dev/null +++ b/src/signers/nsigner-webserial.js @@ -0,0 +1,488 @@ +class NSignerWebSerial { + // CH340 VID:PID 0x1a86:0x7523 (most CYD revisions) + // CP2102 VID:PID 0x10c4:0xea60 (some CYD revisions) + static FILTERS = [ + { usbVendorId: 0x1a86, usbProductId: 0x7523 }, + { usbVendorId: 0x10c4, usbProductId: 0xea60 } + ]; + + static async requestAndConnect(options = {}) { + if (!('serial' in navigator)) { + throw new Error('Web Serial API not available in this browser (requires Chrome 89+, Edge 89+, or Brave)'); + } + + const port = await navigator.serial.requestPort({ filters: NSignerWebSerial.FILTERS }); + const driver = new NSignerWebSerial(port, options); + await driver.open(); + return driver; + } + + static async getPairedDevice(options = {}) { + if (!('serial' in navigator)) return null; + + const ports = await navigator.serial.getPorts(); + if (!ports || ports.length === 0) return null; + + const vendorId = options.vendorId ?? null; + const productId = options.productId ?? null; + + const match = ports.find(p => { + const info = p.getInfo(); + if (vendorId !== null && info?.usbVendorId !== vendorId) return false; + if (productId !== null && info?.usbProductId !== productId) return false; + return true; + }); + + return match || null; + } + + // Identical helpers to NSignerWebUSB โ€” kept here so the class is self-contained. + static randomSecretHex() { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return NSignerWebSerial._hex(bytes); + } + + static toPubkeyHex(secretHex) { + const secret = NSignerWebSerial._hexToBytes(secretHex); + const nt = window.NostrTools || {}; + + if (nt.schnorr && typeof nt.schnorr.getPublicKey === 'function') { + const pub = nt.schnorr.getPublicKey(secret); + return typeof pub === 'string' ? pub.toLowerCase() : NSignerWebSerial._hex(pub); + } + + if (typeof nt.getPublicKey === 'function') { + const pub = nt.getPublicKey(secret); + return typeof pub === 'string' ? pub.toLowerCase() : NSignerWebSerial._hex(pub); + } + + throw new Error('NostrTools.getPublicKey is unavailable in this bundle'); + } + + constructor(port, { callerSecretKey, nostrIndex = 0, openStabilizeMs = 1200, signalStrategies = null } = {}) { + if (!port) throw new Error('SerialPort is required'); + if (!callerSecretKey) throw new Error('callerSecretKey is required'); + + this._port = port; + this.callerSecretKey = callerSecretKey; + this.nostrIndex = Number.isFinite(Number(nostrIndex)) ? Number(nostrIndex) : 0; + + this._reader = null; + this._readLoopPromise = null; + this._ring = new Uint8Array(0); + this._pending = new Map(); // id -> { resolve, reject, timer } + this._disconnectHandlers = new Set(); + this._rpcCounter = 0; + this._open = false; + this._opening = false; + this._sawDisconnectDuringOpen = false; + this._openStabilizeMs = Math.max(0, Number(openStabilizeMs) || 1200); + this._signalStrategies = Array.isArray(signalStrategies) && signalStrategies.length + ? signalStrategies + : [ + { dataTerminalReady: false, requestToSend: false, label: 'dtr=0 rts=0' }, + { dataTerminalReady: true, requestToSend: true, label: 'dtr=1 rts=1' } + ]; + + // Bound port-level disconnect listener. + this._boundPortDisconnect = () => { + if (this._opening) this._sawDisconnectDuringOpen = true; + this._handleDisconnect(); + }; + } + + get isOpen() { + return this._open; + } + + get vendorId() { + return this._port?.getInfo()?.usbVendorId ?? null; + } + + get productId() { + return this._port?.getInfo()?.usbProductId ?? null; + } + + // Web Serial / CH340 / CP2102 do not expose a serial number. + get serial() { + return null; + } + + onDisconnect(cb) { + if (typeof cb === 'function') this._disconnectHandlers.add(cb); + return () => this._disconnectHandlers.delete(cb); + } + + async open() { + const originalInfo = this._port?.getInfo?.() || {}; + let lastError = null; + + for (let attempt = 0; attempt < 2; attempt++) { + this._opening = true; + this._sawDisconnectDuringOpen = false; + + try { + if (attempt > 0) { + const replacement = await NSignerWebSerial._findReenumeratedPort(originalInfo); + if (replacement) this._port = replacement; + } + + await this._port.open({ + baudRate: 115200, + dataBits: 8, + parity: 'none', + stopBits: 1, + flowControl: 'none' + }); + + this._port.addEventListener('disconnect', this._boundPortDisconnect); + + await this._applySignalStrategies(); + + // Wait for potential auto-reset / re-enumeration after open+signals. + await new Promise(r => setTimeout(r, this._openStabilizeMs)); + + if (this._sawDisconnectDuringOpen || !this._port?.readable || !this._port?.writable) { + throw new Error('Serial port dropped during open stabilization'); + } + + this._open = true; + this._readLoopPromise = this._readLoop(); + return; + } catch (err) { + lastError = err; + + try { this._port.removeEventListener('disconnect', this._boundPortDisconnect); } catch (_) {} + try { await this._port.close(); } catch (_) {} + + // Short wait before retrying in case device just re-enumerated. + await new Promise(r => setTimeout(r, 400)); + } finally { + this._opening = false; + } + } + + throw new Error(`Failed to open n_signer serial port: ${lastError?.message || lastError}`); + } + + async close() { + this._open = false; + + // Cancel the reader โ€” this breaks out of the read loop. + if (this._reader) { + try { await this._reader.cancel(); } catch (_) {} + } + + // Reject all in-flight RPCs. + for (const [, { reject, timer }] of this._pending) { + clearTimeout(timer); + reject(new Error('Connection closed')); + } + this._pending.clear(); + + this._port.removeEventListener('disconnect', this._boundPortDisconnect); + + try { await this._port.close(); } catch (_) {} + } + + // โ”€โ”€ Public RPC methods (identical surface to NSignerWebUSB) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + async getPublicKey() { + const params = [{ nostr_index: this.nostrIndex }]; + const resp = await this._rpcCall('get_public_key', params); + if (!resp || typeof resp.result !== 'string') { + throw new Error('Invalid get_public_key response'); + } + return resp.result.trim().toLowerCase(); + } + + async signEvent(unsignedEvent) { + const params = [unsignedEvent, { nostr_index: this.nostrIndex }]; + const resp = await this._rpcCall('sign_event', params); + if (!resp || typeof resp.result !== 'object') { + throw new Error('Invalid sign_event response'); + } + return resp.result; + } + + async nip04Encrypt(peerHex, plaintext) { + const resp = await this._rpcCall('nip04_encrypt', [peerHex, plaintext, { nostr_index: this.nostrIndex }]); + if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nip04_encrypt response'); + return resp.result; + } + + async nip04Decrypt(peerHex, ciphertext) { + const resp = await this._rpcCall('nip04_decrypt', [peerHex, ciphertext, { nostr_index: this.nostrIndex }]); + if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nip04_decrypt response'); + return resp.result; + } + + async nip44Encrypt(peerHex, plaintext) { + const resp = await this._rpcCall('nip44_encrypt', [peerHex, plaintext, { nostr_index: this.nostrIndex }]); + if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nip44_encrypt response'); + return resp.result; + } + + async nip44Decrypt(peerHex, ciphertext) { + const resp = await this._rpcCall('nip44_decrypt', [peerHex, ciphertext, { nostr_index: this.nostrIndex }]); + if (!resp || typeof resp.result !== 'string') throw new Error('Invalid nip44_decrypt response'); + return resp.result; + } + + // โ”€โ”€ Internal โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + async _rpcCall(method, params) { + const id = `nl-${Date.now()}-${++this._rpcCounter}`; + const auth = await this._buildAuth(id, method, params); + const req = { jsonrpc: '2.0', id, method, params, auth }; + + console.info('NSignerWebSerial _rpcCall outbound:', JSON.stringify(req)); + + const resp = await this._sendRpc(req); + + if (resp?.error) { + const msg = resp.error?.message || JSON.stringify(resp.error); + throw new Error(`n_signer ${method} failed: ${msg}`); + } + + return resp; + } + + _sendRpc(reqObj) { + return new Promise((resolve, reject) => { + const id = reqObj.id; + const timer = setTimeout(() => { + this._pending.delete(id); + reject(new Error('Timed out waiting for n_signer response')); + }, 30000); + + this._pending.set(id, { resolve, reject, timer }); + + this._writeFrame(reqObj).catch(err => { + this._pending.delete(id); + clearTimeout(timer); + reject(err); + }); + }); + } + + async _writeFrame(reqObj) { + const body = NSignerWebSerial._utf8(JSON.stringify(reqObj)); + const frame = new Uint8Array(4 + body.length); + frame.set(NSignerWebSerial._be32(body.length), 0); + frame.set(body, 4); + + console.info('NSignerWebSerial _writeFrame:', { payloadBytes: body.length, frameBytes: frame.length }); + + // Acquire writer, write, release immediately. + const w = this._port.writable.getWriter(); + try { + await w.write(frame); + } finally { + w.releaseLock(); + } + } + + async _readLoop() { + let ring = new Uint8Array(0); + + try { + this._reader = this._port.readable.getReader(); + + while (true) { + const { value, done } = await this._reader.read(); + if (done) break; + if (!value || value.length === 0) continue; + + // Append chunk to ring buffer. + const next = new Uint8Array(ring.length + value.length); + next.set(ring, 0); + next.set(value, ring.length); + ring = next; + + // Parse as many complete frames as possible. + while (ring.length >= 4) { + const n = (ring[0] << 24) | (ring[1] << 16) | (ring[2] << 8) | ring[3]; + + // Invalid length header โ€” slide one byte (boot-log recovery). + if (n <= 0 || n > 1_000_000) { + ring = ring.slice(1); + continue; + } + + // Not enough bytes yet for the full payload. + if (ring.length < 4 + n) break; + + const payload = ring.slice(4, 4 + n); + ring = ring.slice(4 + n); + + let resp; + try { + resp = JSON.parse(new TextDecoder().decode(payload)); + } catch (e) { + console.warn('NSignerWebSerial: frame parse error:', e); + continue; + } + + console.info('NSignerWebSerial _readLoop inbound:', JSON.stringify(resp)); + + // Resolve the matching pending RPC. + if (resp && resp.id && this._pending.has(resp.id)) { + const { resolve, timer } = this._pending.get(resp.id); + this._pending.delete(resp.id); + clearTimeout(timer); + resolve(resp); + } + } + } + } catch (err) { + if (err && err.name !== 'AbortError') { + console.warn('NSignerWebSerial read loop error:', err); + } + } finally { + try { this._reader.releaseLock(); } catch (_) {} + this._reader = null; + this._handleDisconnect(); + } + } + + _handleDisconnect() { + if (!this._open && !this._opening) return; // already closed cleanly + this._open = false; + + // Reject all in-flight RPCs. + for (const [, { reject, timer }] of this._pending) { + clearTimeout(timer); + reject(new Error('n_signer device disconnected')); + } + this._pending.clear(); + + for (const cb of this._disconnectHandlers) { + try { cb(); } catch (_) {} + } + } + + async _buildAuth(rpcId, method, params) { + const callerPriv = NSignerWebSerial._hexToBytes(this.callerSecretKey); + const callerPubX = NSignerWebSerial.toPubkeyHex(this.callerSecretKey); + + const createdAt = Math.floor(Date.now() / 1000); + const paramsJson = JSON.stringify(params ?? null); + const bodyHash = await NSignerWebSerial._sha256Hex(NSignerWebSerial._utf8(paramsJson)); + + const tags = [ + ['nsigner_rpc', String(rpcId)], + ['nsigner_method', String(method)], + ['nsigner_body_hash', bodyHash] + ]; + + const content = 'nostr_login_lite'; + const nt = window.NostrTools || {}; + + // Prefer finalizeEvent() โ€” stable across nostr-tools bundle shapes. + if (typeof nt.finalizeEvent === 'function') { + const finalized = nt.finalizeEvent({ + kind: 27235, + created_at: createdAt, + tags, + content + }, callerPriv); + + return { + id: String(finalized.id || '').toLowerCase(), + pubkey: String(finalized.pubkey || callerPubX).toLowerCase(), + created_at: createdAt, + kind: 27235, + tags, + content, + sig: String(finalized.sig || '').toLowerCase() + }; + } + + // Fallback: schnorr.sign directly. + const ser = JSON.stringify([0, callerPubX, createdAt, 27235, tags, content]); + const id = await NSignerWebSerial._sha256Hex(NSignerWebSerial._utf8(ser)); + + if (!nt.schnorr || typeof nt.schnorr.sign !== 'function') { + throw new Error('NostrTools signer unavailable (need finalizeEvent or schnorr.sign)'); + } + + const sigBytes = await nt.schnorr.sign(id, callerPriv, new Uint8Array(32)); + const sigHex = typeof sigBytes === 'string' ? sigBytes : NSignerWebSerial._hex(sigBytes); + + return { id, pubkey: callerPubX, created_at: createdAt, kind: 27235, tags, content, sig: sigHex }; + } + + async _applySignalStrategies() { + if (!this._port?.setSignals) return; + + for (const strategy of this._signalStrategies) { + try { + await this._port.setSignals({ + dataTerminalReady: !!strategy.dataTerminalReady, + requestToSend: !!strategy.requestToSend + }); + // Small settle gap between strategies. + await new Promise(r => setTimeout(r, 120)); + } catch (_) { + // Ignore unsupported setSignals implementations. + } + + if (this._sawDisconnectDuringOpen) return; + } + } + + static async _findReenumeratedPort(matchInfo = {}) { + try { + if (!navigator.serial?.getPorts) return null; + const ports = await navigator.serial.getPorts(); + if (!ports?.length) return null; + + const vid = matchInfo?.usbVendorId; + const pid = matchInfo?.usbProductId; + + return ports.find((p) => { + const i = p.getInfo?.() || {}; + if (vid != null && i.usbVendorId !== vid) return false; + if (pid != null && i.usbProductId !== pid) return false; + return true; + }) || ports[0] || null; + } catch (_) { + return null; + } + } + + // โ”€โ”€ Static utilities (mirrors NSignerWebUSB) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + static _utf8(s) { + return new TextEncoder().encode(s); + } + + static _be32(n) { + return new Uint8Array([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]); + } + + static _hex(bytes) { + return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); + } + + static _hexToBytes(hex) { + const v = String(hex || '').trim().toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(v)) { + throw new Error('Secret key must be 64 hex chars'); + } + const out = new Uint8Array(32); + for (let i = 0; i < 32; i++) out[i] = parseInt(v.slice(i * 2, i * 2 + 2), 16); + return out; + } + + static async _sha256Hex(dataBytes) { + const h = await crypto.subtle.digest('SHA-256', dataBytes); + return NSignerWebSerial._hex(new Uint8Array(h)); + } +} + +if (typeof window !== 'undefined') { + window.NSignerWebSerial = NSignerWebSerial; +} diff --git a/src/ui/modal.js b/src/ui/modal.js index 4f9dfc9..c2f97dc 100644 --- a/src/ui/modal.js +++ b/src/ui/modal.js @@ -266,25 +266,27 @@ class Modal { }); } - // n_signer USB hardware option - if (isMethodEnabled('nsigner', true)) { + // Unified hardware signer option (Web Serial primary, WebUSB fallback) + const nsignerEnabled = isMethodEnabled('nsigner', true) || isMethodEnabled('nsigner_cyd', true); + if (nsignerEnabled) { const secure = typeof window !== 'undefined' ? window.isSecureContext : false; const hasUsb = typeof navigator !== 'undefined' && ('usb' in navigator); + const hasSerial = typeof navigator !== 'undefined' && ('serial' in navigator); - let description = 'Sign with USB-connected n_signer hardware'; + let description = 'Use your hardware signer (auto-detect serial/USB)'; let disabled = false; if (!secure) { description = 'Requires HTTPS or localhost'; disabled = true; - } else if (!hasUsb) { - description = 'Requires Chrome/Edge WebUSB support'; + } else if (!hasSerial && !hasUsb) { + description = 'Requires Web Serial or WebUSB support (Chrome/Edge/Brave)'; disabled = true; } options.push({ type: 'nsigner', - title: 'USB Hardware signer', + title: 'USB Hardware Signer', description, icon: '๐Ÿ”', disabled @@ -403,7 +405,8 @@ class Modal { this._showConnectScreen(); break; case 'nsigner': - this._showNSignerScreen({ autoPrompt: true }); + case 'nsigner_cyd': + this._showHardwareSignerScreen({ autoPrompt: true }); break; case 'readonly': this._handleReadonly(); @@ -1384,6 +1387,7 @@ class Modal { method: 'nsigner', pubkey, connectionData: { + transport: 'webusb', nostrIndex: 0, callerSecretKey: caller.secret, callerPubkey: caller.pubkey, @@ -1400,6 +1404,7 @@ class Modal { pubkey, signer: { driver, + transport: 'webusb', nostrIndex: 0, callerSecretKey: caller.secret, callerPubkey: caller.pubkey, @@ -1415,6 +1420,419 @@ class Modal { } } + _showHardwareSignerScreen({ autoPrompt = false } = {}) { + this.modalBody.innerHTML = ''; + + const title = document.createElement('h3'); + title.textContent = 'USB Hardware Signer'; + title.style.cssText = 'margin: 0 0 12px 0; font-size: 18px; font-weight: 600; color: var(--nl-primary-color);'; + + const description = document.createElement('p'); + description.textContent = 'Connect your hardware signer. We auto-detect Web Serial first, then WebUSB if needed.'; + description.style.cssText = 'margin-bottom: 14px; color: #6b7280; font-size: 14px;'; + + const status = document.createElement('div'); + status.style.cssText = 'margin-bottom: 12px; font-size: 12px; color: #6b7280;'; + + const secure = typeof window !== 'undefined' ? window.isSecureContext : false; + const hasUsb = typeof navigator !== 'undefined' && ('usb' in navigator); + const hasSerial = typeof navigator !== 'undefined' && ('serial' in navigator); + const canSerial = secure && hasSerial && typeof window.NSignerWebSerial === 'function'; + const canUsb = secure && hasUsb && typeof window.NSignerWebUSB === 'function'; + + if (!secure || (!canSerial && !canUsb)) { + status.textContent = !secure + ? 'Requires HTTPS or localhost.' + : 'No supported hardware transport detected (need Web Serial or WebUSB).'; + + const backButton = document.createElement('button'); + backButton.textContent = 'Back'; + backButton.onclick = () => this._renderLoginOptions(); + backButton.style.cssText = this._getButtonStyle('secondary'); + + this.modalBody.appendChild(title); + this.modalBody.appendChild(description); + this.modalBody.appendChild(status); + this.modalBody.appendChild(backButton); + return; + } + + const connectButton = document.createElement('button'); + connectButton.textContent = 'Connect Hardware Signer'; + connectButton.style.cssText = this._getButtonStyle(); + + const accountList = document.createElement('div'); + accountList.style.cssText = 'margin-top: 12px;'; + + const backButton = document.createElement('button'); + backButton.textContent = 'Back'; + backButton.onclick = () => this._renderLoginOptions(); + backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 10px;'; + + const shortHex = (v) => `${v.slice(0, 12)}...${v.slice(-8)}`; + const prePromptDisplay = this.container.style.display; + let hiddenForChooser = false; + + const isChooserCancel = (err) => { + const msg = String(err?.message || err || ''); + return msg.includes('NotFoundError') || msg.includes('No port selected') || msg.includes('No device selected'); + }; + + const restoreModalVisibility = () => { + if (hiddenForChooser && this.isVisible) { + this.container.style.display = prePromptDisplay || 'block'; + hiddenForChooser = false; + } + }; + + const renderTransportChoiceScreen = (note) => { + restoreModalVisibility(); + this.modalBody.innerHTML = ''; + + const choiceTitle = document.createElement('h3'); + choiceTitle.textContent = 'USB Hardware Signer'; + choiceTitle.style.cssText = 'margin: 0 0 12px 0; font-size: 18px; font-weight: 600; color: var(--nl-primary-color);'; + this.modalBody.appendChild(choiceTitle); + + const choiceDescription = document.createElement('p'); + choiceDescription.textContent = note || 'Choose which transport your hardware signer uses:'; + choiceDescription.style.cssText = 'margin-bottom: 14px; color: #6b7280; font-size: 14px;'; + this.modalBody.appendChild(choiceDescription); + + if (canSerial) { + const serialButton = document.createElement('button'); + serialButton.textContent = 'Try Web Serial Again'; + serialButton.style.cssText = this._getButtonStyle() + 'margin-bottom: 10px;'; + serialButton.onclick = () => connectNow({ forceTransport: 'webserial', viaUserGesture: true }); + this.modalBody.appendChild(serialButton); + } + + if (canUsb) { + const usbButton = document.createElement('button'); + usbButton.textContent = 'Try WebUSB Instead'; + usbButton.style.cssText = this._getButtonStyle() + 'margin-bottom: 10px;'; + usbButton.onclick = () => connectNow({ forceTransport: 'webusb', viaUserGesture: true }); + this.modalBody.appendChild(usbButton); + } + + const choiceStatus = document.createElement('div'); + choiceStatus.style.cssText = 'margin: 10px 0; font-size: 12px; color: #6b7280;'; + choiceStatus.id = 'nl-hwsigner-choice-status'; + this.modalBody.appendChild(choiceStatus); + + const choiceBack = document.createElement('button'); + choiceBack.textContent = 'Back'; + choiceBack.onclick = () => this._renderLoginOptions(); + choiceBack.style.cssText = this._getButtonStyle('secondary'); + this.modalBody.appendChild(choiceBack); + }; + + const renderConnectingScreen = (note) => { + restoreModalVisibility(); + this.modalBody.innerHTML = ''; + + const connTitle = document.createElement('h3'); + connTitle.textContent = 'USB Hardware Signer'; + connTitle.style.cssText = 'margin: 0 0 12px 0; font-size: 18px; font-weight: 600; color: var(--nl-primary-color);'; + this.modalBody.appendChild(connTitle); + + const connStatus = document.createElement('p'); + connStatus.textContent = note || 'Connecting...'; + connStatus.style.cssText = 'margin-bottom: 14px; color: #6b7280; font-size: 14px;'; + this.modalBody.appendChild(connStatus); + }; + + const probeAccounts = async (driver, label) => { + const accounts = []; + for (let i = 0; i < 6; i++) { + try { + driver.nostrIndex = i; + console.info(`[${label}] probe index`, i); + const pubkey = await driver.getPublicKey(); + if (/^[0-9a-f]{64}$/.test(pubkey) && !accounts.find(a => a.pubkey === pubkey)) { + accounts.push({ index: i, pubkey }); + } + } catch (e) { + console.warn(`[${label}] account probe failed for index`, i, ':', e?.message || e); + } + } + + if (!accounts.length) { + throw new Error('No account pubkeys returned by signer'); + } + + return accounts; + }; + + const renderAccountButtons = (connected) => { + this.modalBody.innerHTML = ''; + + const transportLabel = connected.transport === 'webserial' ? 'Web Serial' : 'WebUSB'; + + const selectionDescription = document.createElement('p'); + selectionDescription.textContent = `Connected via ${transportLabel}. Select which account to use (${connected.accounts.length} discovered):`; + selectionDescription.style.cssText = 'margin-bottom: 20px; color: #6b7280; font-size: 14px;'; + this.modalBody.appendChild(selectionDescription); + + const table = document.createElement('table'); + table.style.cssText = ` + width: 100%; + border-collapse: collapse; + margin-bottom: 20px; + font-family: var(--nl-font-family, 'Courier New', monospace); + font-size: 12px; + `; + + const thead = document.createElement('thead'); + thead.innerHTML = ` + + # + Use + + `; + table.appendChild(thead); + + const tbody = document.createElement('tbody'); + connected.accounts.forEach((acct) => { + const row = document.createElement('tr'); + row.style.cssText = 'border: 1px solid #d1d5db;'; + + const indexCell = document.createElement('td'); + indexCell.textContent = acct.index; + indexCell.style.cssText = 'padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;'; + + const actionCell = document.createElement('td'); + actionCell.style.cssText = 'padding: 8px; border: 1px solid #d1d5db;'; + + const selectButton = document.createElement('button'); + selectButton.textContent = shortHex(acct.pubkey); + selectButton.style.cssText = ` + width: 100%; + padding: 8px 12px; + font-size: 11px; + background: var(--nl-secondary-color); + color: var(--nl-primary-color); + border: 1px solid var(--nl-primary-color); + border-radius: 4px; + cursor: pointer; + font-family: 'Courier New', monospace; + text-align: center; + `; + selectButton.onmouseover = () => { selectButton.style.borderColor = 'var(--nl-accent-color)'; }; + selectButton.onmouseout = () => { selectButton.style.borderColor = 'var(--nl-primary-color)'; }; + selectButton.onclick = () => { + connected.driver.nostrIndex = acct.index; + this._setAuthMethod('nsigner', { + pubkey: acct.pubkey, + signer: { + driver: connected.driver, + transport: connected.transport, + nostrIndex: acct.index, + callerSecretKey: connected.callerSecretKey, + callerPubkey: connected.callerPubkey, + deviceVid: connected.deviceVid, + deviceProductId: connected.deviceProductId, + deviceSerial: connected.deviceSerial + } + }); + }; + + actionCell.appendChild(selectButton); + row.appendChild(indexCell); + row.appendChild(actionCell); + tbody.appendChild(row); + }); + + table.appendChild(tbody); + this.modalBody.appendChild(table); + + const selectionBackButton = document.createElement('button'); + selectionBackButton.textContent = 'Back'; + selectionBackButton.onclick = () => this._renderLoginOptions(); + selectionBackButton.style.cssText = this._getButtonStyle('secondary'); + this.modalBody.appendChild(selectionBackButton); + }; + + const connectNow = async ({ forceTransport = null, viaUserGesture = false } = {}) => { + try { + const caller = this._getOrCreateNSignerCallerIdentity(); + const startIndex = 0; + + const connectSerialFromPort = async (port) => { + const driver = new window.NSignerWebSerial(port, { + callerSecretKey: caller.secret, + nostrIndex: startIndex + }); + await driver.open(); + return driver; + }; + + const connectUsbFromDevice = async (device) => { + const driver = new window.NSignerWebUSB(device, { + callerSecretKey: caller.secret, + nostrIndex: startIndex + }); + await driver.open(); + return driver; + }; + + let transport = null; + let driver = null; + + // Try silent paired-device reconnect first when this isn't a transport-forced retry. + if (!forceTransport) { + if (canSerial) { + try { + const pairedSerial = await window.NSignerWebSerial.getPairedDevice({}); + if (pairedSerial) { + renderConnectingScreen('Reconnecting to paired Web Serial device...'); + transport = 'webserial'; + driver = await connectSerialFromPort(pairedSerial); + } + } catch (e) { + console.warn('Paired Web Serial reconnect failed:', e); + } + } + + if (!driver && canUsb) { + try { + const pairedUsb = await window.NSignerWebUSB.getPairedDevice({ + vendorId: 0x303a, + productId: 0x4001 + }); + if (pairedUsb) { + renderConnectingScreen('Reconnecting to paired WebUSB device...'); + transport = 'webusb'; + driver = await connectUsbFromDevice(pairedUsb); + } + } catch (e) { + console.warn('Paired WebUSB reconnect failed:', e); + } + } + } + + // Need a new chooser path. + if (!driver) { + // Determine which transport to prompt. Default to Web Serial when available. + const target = forceTransport + || (canSerial ? 'webserial' : (canUsb ? 'webusb' : null)); + + if (!target) { + throw new Error('No supported hardware transport available'); + } + + if (target === 'webserial') { + renderConnectingScreen('Waiting for Web Serial port selection...'); + try { + driver = await window.NSignerWebSerial.requestAndConnect({ + callerSecretKey: caller.secret, + nostrIndex: startIndex + }); + transport = 'webserial'; + } catch (serialErr) { + if (isChooserCancel(serialErr)) { + renderTransportChoiceScreen( + canUsb + ? 'No serial port selected. Choose how to connect:' + : 'No serial port selected.' + ); + return; + } + throw serialErr; + } + } else if (target === 'webusb') { + renderConnectingScreen('Waiting for WebUSB device selection...'); + try { + const selectedDevice = await navigator.usb.requestDevice({ filters: [{ vendorId: 0x303a }] }); + transport = 'webusb'; + driver = await connectUsbFromDevice(selectedDevice); + } catch (usbErr) { + if (isChooserCancel(usbErr)) { + renderTransportChoiceScreen( + canSerial + ? 'No USB device selected. Choose how to connect:' + : 'No USB device selected.' + ); + return; + } + throw usbErr; + } + } + } + + renderConnectingScreen('Connected. Reading account pubkeys...'); + const accounts = await probeAccounts(driver, transport); + + const serialInfo = transport === 'webserial' ? (driver._port?.getInfo?.() || {}) : null; + const connected = { + transport, + accounts, + callerSecretKey: caller.secret, + callerPubkey: caller.pubkey, + driver, + deviceVid: transport === 'webserial' ? (serialInfo.usbVendorId ?? null) : driver.vendorId, + deviceProductId: transport === 'webserial' ? (serialInfo.usbProductId ?? null) : driver.productId, + deviceSerial: transport === 'webserial' ? null : (driver.serial || null) + }; + + driver.onDisconnect(() => { + window.dispatchEvent(new CustomEvent('nlReconnectionRequired', { + detail: { + method: 'nsigner', + pubkey: accounts[0].pubkey, + connectionData: { + transport, + nostrIndex: accounts[0].index, + callerSecretKey: caller.secret, + callerPubkey: caller.pubkey, + deviceVid: connected.deviceVid, + deviceProductId: connected.deviceProductId, + deviceSerial: connected.deviceSerial + }, + message: transport === 'webserial' + ? 'Your hardware signer (Web Serial) was disconnected. Reconnect to continue.' + : 'Your hardware signer (WebUSB) was disconnected. Reconnect to continue.' + } + })); + }); + + restoreModalVisibility(); + renderAccountButtons(connected); + } catch (error) { + const msg = String(error?.message || error); + let displayMsg; + if (msg.includes('NetworkError')) { + displayMsg = 'Device/port is already in use โ€” close any serial monitor and try again.'; + } else if (msg.includes('SecurityError')) { + displayMsg = 'Access denied. Check browser permissions and Linux udev/dialout setup.'; + } else if (msg.includes('NotFoundError')) { + displayMsg = 'No device selected.'; + } else { + displayMsg = `Connect failed: ${msg}`; + } + + // Always re-surface a usable UI on failure. + renderTransportChoiceScreen(displayMsg); + } + }; + + connectButton.onclick = () => connectNow(); + + this.modalBody.appendChild(title); + this.modalBody.appendChild(description); + this.modalBody.appendChild(connectButton); + this.modalBody.appendChild(status); + this.modalBody.appendChild(accountList); + this.modalBody.appendChild(backButton); + + if (autoPrompt) { + hiddenForChooser = true; + this.container.style.display = 'none'; + connectNow(); + return; + } + } + _showNSignerScreen({ autoPrompt = false } = {}) { this.modalBody.innerHTML = ''; @@ -1531,6 +1949,7 @@ class Modal { pubkey: acct.pubkey, signer: { driver: connected.driver, + transport: 'webusb', nostrIndex: acct.index, callerSecretKey: connected.callerSecretKey, callerPubkey: connected.callerPubkey, @@ -1616,6 +2035,7 @@ class Modal { method: 'nsigner', pubkey: accounts[0].pubkey, connectionData: { + transport: 'webusb', nostrIndex: accounts[0].index, callerSecretKey: caller.secret, callerPubkey: caller.pubkey, @@ -1672,6 +2092,252 @@ class Modal { } } + _showCydSerialScreen({ autoPrompt = false } = {}) { + this.modalBody.innerHTML = ''; + + const title = document.createElement('h3'); + title.textContent = 'CYD Serial Hardware Signer'; + title.style.cssText = 'margin: 0 0 12px 0; font-size: 18px; font-weight: 600; color: var(--nl-primary-color);'; + + const description = document.createElement('p'); + description.textContent = 'Select your CYD (ESP32-2432S028) serial port when prompted, then choose one of the discovered accounts.'; + description.style.cssText = 'margin-bottom: 14px; color: #6b7280; font-size: 14px;'; + + const status = document.createElement('div'); + status.style.cssText = 'margin-bottom: 12px; font-size: 12px; color: #6b7280;'; + + const secure = typeof window !== 'undefined' ? window.isSecureContext : false; + const hasSerial = typeof navigator !== 'undefined' && ('serial' in navigator); + + if (!secure || !hasSerial) { + status.textContent = !secure + ? 'Requires HTTPS or localhost for Web Serial.' + : 'Web Serial unavailable. Use Chrome 89+, Edge 89+, or Brave.'; + + const backButton = document.createElement('button'); + backButton.textContent = 'Back'; + backButton.onclick = () => this._renderLoginOptions(); + backButton.style.cssText = this._getButtonStyle('secondary'); + + this.modalBody.appendChild(title); + this.modalBody.appendChild(description); + this.modalBody.appendChild(status); + this.modalBody.appendChild(backButton); + return; + } + + const connectButton = document.createElement('button'); + connectButton.textContent = 'Choose Serial Port'; + connectButton.style.cssText = this._getButtonStyle(); + + const accountList = document.createElement('div'); + accountList.style.cssText = 'margin-top: 12px;'; + + const backButton = document.createElement('button'); + backButton.textContent = 'Back'; + backButton.onclick = () => this._renderLoginOptions(); + backButton.style.cssText = this._getButtonStyle('secondary') + 'margin-top: 10px;'; + + const shortHex = (v) => `${v.slice(0, 12)}...${v.slice(-8)}`; + const prePromptDisplay = this.container.style.display; + let hiddenForChooser = false; + + const renderAccountButtons = (connected) => { + this.modalBody.innerHTML = ''; + + const selectionDescription = document.createElement('p'); + selectionDescription.textContent = `Select which account to use (${connected.accounts.length} accounts discovered from CYD signer):`; + selectionDescription.style.cssText = 'margin-bottom: 20px; color: #6b7280; font-size: 14px;'; + this.modalBody.appendChild(selectionDescription); + + const table = document.createElement('table'); + table.style.cssText = ` + width: 100%; + border-collapse: collapse; + margin-bottom: 20px; + font-family: var(--nl-font-family, 'Courier New', monospace); + font-size: 12px; + `; + + const thead = document.createElement('thead'); + thead.innerHTML = ` + + # + Use + + `; + table.appendChild(thead); + + const tbody = document.createElement('tbody'); + connected.accounts.forEach((acct) => { + const row = document.createElement('tr'); + row.style.cssText = 'border: 1px solid #d1d5db;'; + + const indexCell = document.createElement('td'); + indexCell.textContent = acct.index; + indexCell.style.cssText = 'padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;'; + + const actionCell = document.createElement('td'); + actionCell.style.cssText = 'padding: 8px; border: 1px solid #d1d5db;'; + + const selectButton = document.createElement('button'); + selectButton.textContent = shortHex(acct.pubkey); + selectButton.style.cssText = ` + width: 100%; + padding: 8px 12px; + font-size: 11px; + background: var(--nl-secondary-color); + color: var(--nl-primary-color); + border: 1px solid var(--nl-primary-color); + border-radius: 4px; + cursor: pointer; + font-family: 'Courier New', monospace; + text-align: center; + `; + selectButton.onmouseover = () => { selectButton.style.borderColor = 'var(--nl-accent-color)'; }; + selectButton.onmouseout = () => { selectButton.style.borderColor = 'var(--nl-primary-color)'; }; + selectButton.onclick = () => { + connected.driver.nostrIndex = acct.index; + this._setAuthMethod('nsigner', { + pubkey: acct.pubkey, + signer: { + driver: connected.driver, + transport: 'webserial', + nostrIndex: acct.index, + callerSecretKey: connected.callerSecretKey, + callerPubkey: connected.callerPubkey, + deviceVid: connected.deviceVid, + deviceProductId: connected.deviceProductId, + deviceSerial: null + } + }); + }; + + actionCell.appendChild(selectButton); + row.appendChild(indexCell); + row.appendChild(actionCell); + tbody.appendChild(row); + }); + + table.appendChild(tbody); + this.modalBody.appendChild(table); + + const selectionBackButton = document.createElement('button'); + selectionBackButton.textContent = 'Back'; + selectionBackButton.onclick = () => this._renderLoginOptions(); + selectionBackButton.style.cssText = this._getButtonStyle('secondary'); + this.modalBody.appendChild(selectionBackButton); + }; + + const connectNow = async () => { + try { + const startIndex = 0; + status.textContent = 'Connecting...'; + accountList.innerHTML = ''; + + const caller = this._getOrCreateNSignerCallerIdentity(); + + if (!window.NSignerWebSerial || typeof window.NSignerWebSerial !== 'function') { + throw new Error('NSignerWebSerial driver missing'); + } + + const driver = await window.NSignerWebSerial.requestAndConnect({ + callerSecretKey: caller.secret, + nostrIndex: startIndex + }); + + status.textContent = 'Connected. Reading account pubkeys...'; + const accounts = []; + for (let i = startIndex; i < startIndex + 6; i++) { + try { + driver.nostrIndex = i; + console.info('NSignerWebSerial probe index', i); + const pubkey = await driver.getPublicKey(); + console.info('NSignerWebSerial probe index', i, 'pubkey=', pubkey); + if (/^[0-9a-f]{64}$/.test(pubkey) && !accounts.find(a => a.pubkey === pubkey)) { + accounts.push({ index: i, pubkey }); + } + } catch (e) { + console.warn('NSignerWebSerial account probe failed for index', i, ':', e?.message || e); + } + } + + console.info('NSignerWebSerial probe complete, accounts found:', accounts.length, accounts); + + if (!accounts.length) { + throw new Error('No account pubkeys returned by CYD signer'); + } + + const info = driver._port?.getInfo?.() || {}; + const connected = { + accounts, + callerSecretKey: caller.secret, + callerPubkey: caller.pubkey, + driver, + deviceVid: info.usbVendorId ?? null, + deviceProductId: info.usbProductId ?? null + }; + + driver.onDisconnect(() => { + window.dispatchEvent(new CustomEvent('nlReconnectionRequired', { + detail: { + method: 'nsigner', + pubkey: accounts[0].pubkey, + connectionData: { + transport: 'webserial', + nostrIndex: accounts[0].index, + callerSecretKey: caller.secret, + callerPubkey: caller.pubkey, + deviceVid: connected.deviceVid, + deviceProductId: connected.deviceProductId, + deviceSerial: null + }, + message: 'Your CYD n_signer device was disconnected. Reconnect to continue.' + } + })); + }); + + if (hiddenForChooser && this.isVisible) { + this.container.style.display = prePromptDisplay || 'block'; + hiddenForChooser = false; + } + renderAccountButtons(connected); + } catch (error) { + const msg = String(error?.message || error); + if (msg.includes('NotFoundError') || msg.includes('No port selected')) { + status.textContent = 'No serial port selected.'; + } else if (msg.includes('NetworkError')) { + status.textContent = 'Port already in use โ€” close any serial monitor and try again.'; + } else if (msg.includes('SecurityError')) { + status.textContent = 'Access denied. On Linux, add yourself to the dialout group and replug.'; + } else { + status.textContent = `Connect failed: ${msg}`; + } + } + + if (hiddenForChooser && this.isVisible) { + this.container.style.display = prePromptDisplay || 'block'; + hiddenForChooser = false; + } + }; + + connectButton.onclick = connectNow; + + this.modalBody.appendChild(title); + this.modalBody.appendChild(description); + this.modalBody.appendChild(connectButton); + this.modalBody.appendChild(status); + this.modalBody.appendChild(accountList); + this.modalBody.appendChild(backButton); + + if (autoPrompt) { + hiddenForChooser = true; + this.container.style.display = 'none'; + connectNow(); + return; + } + } + _showConnectScreen() { this.modalBody.innerHTML = '';