Unify hardware signer UX with serial-first auto-detect and WebUSB fallback; add transport-aware nsigner persistence/restore
This commit is contained in:
1262
build/nostr-lite.js
1262
build/nostr-lite.js
File diff suppressed because it is too large
Load Diff
@@ -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/
|
||||
|
||||
737
examples/cyd_webserial_demo.html
Normal file
737
examples/cyd_webserial_demo.html
Normal file
@@ -0,0 +1,737 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>n_signer CYD Web Serial Demo</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0b0f14;
|
||||
--panel: #121821;
|
||||
--panel-2: #182231;
|
||||
--text: #e6edf3;
|
||||
--muted: #9fb0c3;
|
||||
--accent: #58a6ff;
|
||||
--good: #3fb950;
|
||||
--bad: #f85149;
|
||||
--border: #263448;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 980px;
|
||||
margin: 20px auto;
|
||||
padding: 0 14px 24px;
|
||||
}
|
||||
h1 { margin: 0 0 8px; font-size: 1.45rem; }
|
||||
p.note { margin: 0 0 14px; color: var(--muted); }
|
||||
|
||||
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.card {
|
||||
background: linear-gradient(180deg, var(--panel), var(--panel-2));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
.card h2 {
|
||||
font-size: 1rem;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 0.86rem;
|
||||
color: var(--muted);
|
||||
display: block;
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
input, textarea, button {
|
||||
font: inherit;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
input, textarea {
|
||||
width: 100%;
|
||||
background: #0c131d;
|
||||
color: var(--text);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
textarea { min-height: 84px; resize: vertical; }
|
||||
input[type="number"] { max-width: 130px; }
|
||||
|
||||
button {
|
||||
background: #1f6feb;
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
}
|
||||
button[disabled] {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.secondary { background: #334155; }
|
||||
.danger { background: #7f1d1d; }
|
||||
|
||||
.status {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: #2a3648;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.status.ok { color: var(--good); border-color: #2f5a3a; }
|
||||
.status.err { color: var(--bad); border-color: #6a3131; }
|
||||
|
||||
pre {
|
||||
margin: 8px 0 0;
|
||||
background: #0a1018;
|
||||
border: 1px solid #1b2636;
|
||||
color: #d7e2ee;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
overflow: auto;
|
||||
max-height: 220px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
|
||||
.info-box {
|
||||
background: #0d1f35;
|
||||
border: 1px solid #1e3a5f;
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 0.84rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.info-box strong { color: var(--accent); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>n_signer CYD Web Serial Demo</h1>
|
||||
<p class="note">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.</p>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>Requirements:</strong> Chrome 89+, Edge 89+, or Brave. Web Serial is not available in Firefox or Safari.<br>
|
||||
<strong>Linux users:</strong> Add yourself to the <code>dialout</code> group (<code>sudo usermod -aG dialout $USER</code>) and replug the device.<br>
|
||||
<strong>Windows users:</strong> Install the WCH-IC CH340 driver if the port does not appear.<br>
|
||||
<strong>Note:</strong> Close any serial monitor (Arduino IDE, idf.py monitor) before connecting — only one app can hold the port at a time.
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<button id="connectBtn">Connect CYD (Serial)</button>
|
||||
<button id="disconnectBtn" class="danger" disabled>Disconnect</button>
|
||||
<span id="connStatus" class="status">Disconnected</span>
|
||||
</div>
|
||||
<pre id="log" class="mono"></pre>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<section class="card">
|
||||
<h2>Public Key</h2>
|
||||
<label for="keyIndex">Key index (nostr_index)</label>
|
||||
<input id="keyIndex" type="text" inputmode="numeric" pattern="[0-9]*" value="0" />
|
||||
<div class="row" style="margin-top:10px">
|
||||
<button id="pubkeyBtn" disabled>Get Public Key</button>
|
||||
</div>
|
||||
<pre id="pubkeyOut" class="mono"></pre>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Sign Kind 1 Event</h2>
|
||||
<label for="kind1Content">Content</label>
|
||||
<textarea id="kind1Content">hello from cyd webserial demo</textarea>
|
||||
<label for="kind1Tags">Tags JSON (array)</label>
|
||||
<input id="kind1Tags" value="[]" />
|
||||
<div class="row" style="margin-top:10px">
|
||||
<button id="signKind1Btn" disabled>Create + Sign kind 1</button>
|
||||
</div>
|
||||
<pre id="signOut" class="mono"></pre>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>NIP-04 Encrypt</h2>
|
||||
<label for="nip04Peer">Peer pubkey (hex, 32-byte x-only)</label>
|
||||
<input id="nip04Peer" placeholder="e.g. 64 hex chars" />
|
||||
<label for="nip04Msg">Plaintext</label>
|
||||
<textarea id="nip04Msg">hello via nip04</textarea>
|
||||
<div class="row" style="margin-top:10px">
|
||||
<button id="nip04EncBtn" disabled>Encrypt (nip04_encrypt)</button>
|
||||
</div>
|
||||
<pre id="nip04Out" class="mono"></pre>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>NIP-04 Decrypt</h2>
|
||||
<label for="nip04DecPeer">Peer pubkey (hex, 32-byte x-only)</label>
|
||||
<input id="nip04DecPeer" placeholder="e.g. 64 hex chars" />
|
||||
<label for="nip04Cipher">Ciphertext</label>
|
||||
<textarea id="nip04Cipher" placeholder="ciphertext?iv=..."></textarea>
|
||||
<div class="row" style="margin-top:10px">
|
||||
<button id="nip04DecBtn" disabled>Decrypt (nip04_decrypt)</button>
|
||||
</div>
|
||||
<pre id="nip04DecOut" class="mono"></pre>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>NIP-44 Encrypt</h2>
|
||||
<label for="nip44Peer">Peer pubkey (hex, 32-byte x-only)</label>
|
||||
<input id="nip44Peer" placeholder="e.g. 64 hex chars" />
|
||||
<label for="nip44Msg">Plaintext</label>
|
||||
<textarea id="nip44Msg">hello via nip44</textarea>
|
||||
<div class="row" style="margin-top:10px">
|
||||
<button id="nip44EncBtn" disabled>Encrypt (nip44_encrypt)</button>
|
||||
</div>
|
||||
<pre id="nip44Out" class="mono"></pre>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>NIP-44 Decrypt</h2>
|
||||
<label for="nip44DecPeer">Peer pubkey (hex, 32-byte x-only)</label>
|
||||
<input id="nip44DecPeer" placeholder="e.g. 64 hex chars" />
|
||||
<label for="nip44Cipher">Ciphertext</label>
|
||||
<textarea id="nip44Cipher" placeholder="base64 payload"></textarea>
|
||||
<div class="row" style="margin-top:10px">
|
||||
<button id="nip44DecBtn" disabled>Decrypt (nip44_decrypt)</button>
|
||||
</div>
|
||||
<pre id="nip44DecOut" class="mono"></pre>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { schnorr } from "https://esm.sh/@noble/curves@1.5.0/secp256k1?bundle";
|
||||
|
||||
// ── DOM refs ──────────────────────────────────────────────────────────────
|
||||
const logEl = document.getElementById("log");
|
||||
const connStatusEl = document.getElementById("connStatus");
|
||||
const connectBtn = document.getElementById("connectBtn");
|
||||
const disconnectBtn = document.getElementById("disconnectBtn");
|
||||
const pubkeyBtn = document.getElementById("pubkeyBtn");
|
||||
const signKind1Btn = document.getElementById("signKind1Btn");
|
||||
const nip04EncBtn = document.getElementById("nip04EncBtn");
|
||||
const nip04DecBtn = document.getElementById("nip04DecBtn");
|
||||
const nip44EncBtn = document.getElementById("nip44EncBtn");
|
||||
const nip44DecBtn = document.getElementById("nip44DecBtn");
|
||||
|
||||
const keyIndexEl = document.getElementById("keyIndex");
|
||||
const pubkeyOutEl = document.getElementById("pubkeyOut");
|
||||
const kind1ContentEl = document.getElementById("kind1Content");
|
||||
const kind1TagsEl = document.getElementById("kind1Tags");
|
||||
const signOutEl = document.getElementById("signOut");
|
||||
const nip04PeerEl = document.getElementById("nip04Peer");
|
||||
const nip04MsgEl = document.getElementById("nip04Msg");
|
||||
const nip04OutEl = document.getElementById("nip04Out");
|
||||
const nip04DecPeerEl = document.getElementById("nip04DecPeer");
|
||||
const nip04CipherEl = document.getElementById("nip04Cipher");
|
||||
const nip04DecOutEl = document.getElementById("nip04DecOut");
|
||||
const nip44PeerEl = document.getElementById("nip44Peer");
|
||||
const nip44MsgEl = document.getElementById("nip44Msg");
|
||||
const nip44OutEl = document.getElementById("nip44Out");
|
||||
const nip44DecPeerEl = document.getElementById("nip44DecPeer");
|
||||
const nip44CipherEl = document.getElementById("nip44Cipher");
|
||||
const nip44DecOutEl = document.getElementById("nip44DecOut");
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
// CH340 VID:PID 0x1a86:0x7523 (most CYD revisions)
|
||||
// CP2102 VID:PID 0x10c4:0xea60 (some CYD revisions)
|
||||
const SERIAL_FILTERS = [
|
||||
{ usbVendorId: 0x1a86, usbProductId: 0x7523 },
|
||||
{ usbVendorId: 0x10c4, usbProductId: 0xea60 }
|
||||
];
|
||||
|
||||
let port = null; // SerialPort
|
||||
let writer = null; // WritableStreamDefaultWriter
|
||||
let reader = null; // ReadableStreamDefaultReader
|
||||
let ownPubkey = "";
|
||||
let rpcCounter = 0;
|
||||
|
||||
// Pending RPC promises keyed by request id
|
||||
const pending = new Map(); // id -> { resolve, reject, timer }
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
function log(...args) {
|
||||
logEl.textContent += args.join(" ") + "\n";
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
|
||||
function setStatus(text, mode = "") {
|
||||
connStatusEl.textContent = text;
|
||||
connStatusEl.className = `status ${mode}`.trim();
|
||||
}
|
||||
|
||||
function setConnected(connected) {
|
||||
connectBtn.disabled = connected;
|
||||
disconnectBtn.disabled = !connected;
|
||||
pubkeyBtn.disabled = !connected;
|
||||
signKind1Btn.disabled = !connected;
|
||||
nip04EncBtn.disabled = !connected;
|
||||
nip04DecBtn.disabled = !connected;
|
||||
nip44EncBtn.disabled = !connected;
|
||||
nip44DecBtn.disabled = !connected;
|
||||
}
|
||||
|
||||
function hex(bytes) {
|
||||
return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function utf8(s) {
|
||||
return new TextEncoder().encode(s);
|
||||
}
|
||||
|
||||
function be32(n) {
|
||||
return new Uint8Array([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff]);
|
||||
}
|
||||
|
||||
async function sha256Hex(dataBytes) {
|
||||
const h = await crypto.subtle.digest("SHA-256", dataBytes);
|
||||
return hex(new Uint8Array(h));
|
||||
}
|
||||
|
||||
function getIndexOptions() {
|
||||
const raw = Number.parseInt(String(keyIndexEl.value ?? "0"), 10);
|
||||
const index = Number.isFinite(raw) && raw >= 0 ? raw : 0;
|
||||
return { nostr_index: index };
|
||||
}
|
||||
|
||||
function pretty(value) {
|
||||
try { return JSON.stringify(value, null, 2); }
|
||||
catch { return String(value); }
|
||||
}
|
||||
|
||||
function requirePeerHex(peer) {
|
||||
const v = String(peer || "").trim().toLowerCase();
|
||||
if (!/^[0-9a-f]{64}$/.test(v)) {
|
||||
throw new Error("Peer pubkey must be exactly 64 hex chars (x-only pubkey)");
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
function requireStringResult(resp, what) {
|
||||
if (resp && typeof resp.result === "string") return resp.result;
|
||||
throw new Error(`${what} failed: ${pretty(resp)}`);
|
||||
}
|
||||
|
||||
// ── Auth envelope ─────────────────────────────────────────────────────────
|
||||
async function buildAuth(method, params) {
|
||||
// Demo caller key — fixed, non-secret (demo only).
|
||||
const callerPriv = Uint8Array.from({ length: 32 }, (_, i) => i + 1);
|
||||
const callerPubX = hex(schnorr.getPublicKey(callerPriv));
|
||||
|
||||
const createdAt = Math.floor(Date.now() / 1000);
|
||||
const paramsJson = JSON.stringify(params);
|
||||
const bodyHash = await sha256Hex(utf8(paramsJson));
|
||||
const tags = [
|
||||
["nsigner_rpc", String(++rpcCounter)],
|
||||
["nsigner_method", method],
|
||||
["nsigner_body_hash", bodyHash],
|
||||
];
|
||||
|
||||
const content = "cyd-webserial-demo";
|
||||
const ser = JSON.stringify([0, callerPubX, createdAt, 27235, tags, content]);
|
||||
const id = await sha256Hex(utf8(ser));
|
||||
const sigBytes = await schnorr.sign(id, callerPriv, new Uint8Array(32));
|
||||
const sigHex = typeof sigBytes === "string" ? sigBytes : hex(sigBytes);
|
||||
|
||||
return { id, pubkey: callerPubX, created_at: createdAt, kind: 27235, tags, content, sig: sigHex };
|
||||
}
|
||||
|
||||
// ── Frame writer ──────────────────────────────────────────────────────────
|
||||
async function writeFrame(reqObj) {
|
||||
const body = utf8(JSON.stringify(reqObj));
|
||||
const frame = new Uint8Array(4 + body.length);
|
||||
frame.set(be32(body.length), 0);
|
||||
frame.set(body, 4);
|
||||
|
||||
// Acquire writer, write, release immediately so other callers can use it.
|
||||
const w = port.writable.getWriter();
|
||||
try {
|
||||
await w.write(frame);
|
||||
} finally {
|
||||
w.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Background read loop ──────────────────────────────────────────────────
|
||||
// Holds the reader for the lifetime of the connection.
|
||||
// Parses length-prefixed frames and resolves pending RPC promises.
|
||||
async function readLoop() {
|
||||
let ring = new Uint8Array(0);
|
||||
|
||||
try {
|
||||
reader = port.readable.getReader();
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await 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) {
|
||||
log("⚠ Frame parse error:", String(e));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve the matching pending RPC.
|
||||
if (resp && resp.id && pending.has(resp.id)) {
|
||||
const { resolve, timer } = pending.get(resp.id);
|
||||
pending.delete(resp.id);
|
||||
clearTimeout(timer);
|
||||
resolve(resp);
|
||||
} else {
|
||||
log("← unsolicited frame:", JSON.stringify(resp));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// reader.cancel() throws a non-error on clean close; ignore it.
|
||||
if (err && err.name !== "AbortError") {
|
||||
log("Read loop error:", String(err));
|
||||
}
|
||||
} finally {
|
||||
try { reader.releaseLock(); } catch (_) {}
|
||||
reader = null;
|
||||
handleDisconnect("Device disconnected");
|
||||
}
|
||||
}
|
||||
|
||||
// ── RPC call ──────────────────────────────────────────────────────────────
|
||||
function sendRpc(reqObj) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = reqObj.id;
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error("Timed out waiting for n_signer response"));
|
||||
}, 30000);
|
||||
|
||||
pending.set(id, { resolve, reject, timer });
|
||||
writeFrame(reqObj).catch(err => {
|
||||
pending.delete(id);
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function rpcCall(method, params) {
|
||||
const id = `cyd-${Date.now()}-${++rpcCounter}`;
|
||||
const auth = await buildAuth(method, params);
|
||||
const req = { jsonrpc: "2.0", id, method, params, auth };
|
||||
log("→", method, JSON.stringify(params));
|
||||
const resp = await sendRpc(req);
|
||||
log("←", method, JSON.stringify(resp));
|
||||
return resp;
|
||||
}
|
||||
|
||||
// ── Connect / disconnect ──────────────────────────────────────────────────
|
||||
async function applySignalStrategies(serialPort) {
|
||||
const strategies = [
|
||||
{ dataTerminalReady: false, requestToSend: false, label: "dtr=0 rts=0" },
|
||||
{ dataTerminalReady: true, requestToSend: true, label: "dtr=1 rts=1" }
|
||||
];
|
||||
|
||||
if (!serialPort?.setSignals) return;
|
||||
|
||||
for (const s of strategies) {
|
||||
try {
|
||||
await serialPort.setSignals({
|
||||
dataTerminalReady: !!s.dataTerminalReady,
|
||||
requestToSend: !!s.requestToSend
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 120));
|
||||
} catch (e) {
|
||||
log("⚠ setSignals strategy failed:", s.label, String(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function findReenumeratedPort(matchInfo = {}) {
|
||||
try {
|
||||
const ports = await navigator.serial.getPorts();
|
||||
if (!ports || !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;
|
||||
}
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
if (!("serial" in navigator)) {
|
||||
throw new Error("Web Serial API not available — use Chrome 89+, Edge 89+, or Brave");
|
||||
}
|
||||
|
||||
const initiallySelected = await navigator.serial.requestPort({ filters: SERIAL_FILTERS });
|
||||
const selectedInfo = initiallySelected.getInfo?.() || {};
|
||||
let lastError = null;
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
let droppedDuringOpen = false;
|
||||
|
||||
try {
|
||||
port = attempt === 0 ? initiallySelected : (await findReenumeratedPort(selectedInfo));
|
||||
if (!port) throw new Error("Serial port not available after re-enumeration");
|
||||
|
||||
const onPortDisconnect = () => {
|
||||
droppedDuringOpen = true;
|
||||
handleDisconnect("Port disconnect event");
|
||||
};
|
||||
|
||||
port.addEventListener("disconnect", onPortDisconnect);
|
||||
|
||||
await port.open({
|
||||
baudRate: 115200,
|
||||
dataBits: 8,
|
||||
parity: "none",
|
||||
stopBits: 1,
|
||||
flowControl: "none"
|
||||
});
|
||||
|
||||
await applySignalStrategies(port);
|
||||
|
||||
// Stabilize after open/signals: some CYD revisions briefly reset and re-enumerate.
|
||||
await new Promise(r => setTimeout(r, 1200));
|
||||
|
||||
if (droppedDuringOpen || !port?.readable || !port?.writable) {
|
||||
throw new Error("Serial port dropped during open stabilization");
|
||||
}
|
||||
|
||||
// Start background read loop (does not block).
|
||||
readLoop();
|
||||
|
||||
const info = port.getInfo();
|
||||
const vid = info?.usbVendorId != null ? `0x${info.usbVendorId.toString(16).padStart(4,"0")}` : "?";
|
||||
const pid = info?.usbProductId != null ? `0x${info.usbProductId.toString(16).padStart(4,"0")}` : "?";
|
||||
|
||||
setConnected(true);
|
||||
setStatus(`Connected (VID:${vid} PID:${pid})`, "ok");
|
||||
log(`Connected. VID:${vid} PID:${pid}`);
|
||||
|
||||
try {
|
||||
await fetchOwnPubkeyAndFillPeers();
|
||||
log("Default peer pubkeys set to selected signer pubkey");
|
||||
} catch (e) {
|
||||
log("Auto pubkey fetch failed:", String(e));
|
||||
}
|
||||
|
||||
return;
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
try { if (port) await port.close(); } catch (_) {}
|
||||
await new Promise(r => setTimeout(r, 400));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Failed to open CYD serial port: ${String(lastError?.message || lastError)}`);
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
// Cancel the reader to break out of readLoop.
|
||||
if (reader) {
|
||||
try { await reader.cancel(); } catch (_) {}
|
||||
}
|
||||
|
||||
// Cancel all pending RPCs.
|
||||
for (const [id, { reject, timer }] of pending) {
|
||||
clearTimeout(timer);
|
||||
reject(new Error("Connection closed"));
|
||||
}
|
||||
pending.clear();
|
||||
|
||||
try {
|
||||
if (port) await port.close();
|
||||
} catch (_) {}
|
||||
|
||||
port = null;
|
||||
setConnected(false);
|
||||
setStatus("Disconnected");
|
||||
log("Disconnected.");
|
||||
}
|
||||
|
||||
function handleDisconnect(reason) {
|
||||
if (!port) return; // already cleaned up
|
||||
log("⚠", reason);
|
||||
|
||||
// Reject all pending RPCs.
|
||||
for (const [id, { reject, timer }] of pending) {
|
||||
clearTimeout(timer);
|
||||
reject(new Error("Device disconnected"));
|
||||
}
|
||||
pending.clear();
|
||||
|
||||
port = null;
|
||||
setConnected(false);
|
||||
setStatus("Disconnected — device reset or unplugged", "err");
|
||||
}
|
||||
|
||||
// ── Pubkey helper ─────────────────────────────────────────────────────────
|
||||
async function fetchOwnPubkeyAndFillPeers() {
|
||||
const params = [getIndexOptions()];
|
||||
const resp = await rpcCall("get_public_key", params);
|
||||
if (resp && typeof resp.result === "string") {
|
||||
ownPubkey = resp.result.trim().toLowerCase();
|
||||
pubkeyOutEl.textContent = pretty(resp);
|
||||
if (/^[0-9a-f]{64}$/.test(ownPubkey)) {
|
||||
nip04PeerEl.value = ownPubkey;
|
||||
nip04DecPeerEl.value = ownPubkey;
|
||||
nip44PeerEl.value = ownPubkey;
|
||||
nip44DecPeerEl.value = ownPubkey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Button handlers ───────────────────────────────────────────────────────
|
||||
connectBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await connect();
|
||||
} catch (e) {
|
||||
setStatus("Connect failed", "err");
|
||||
log("Connect failed:", String(e));
|
||||
}
|
||||
});
|
||||
|
||||
disconnectBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await disconnect();
|
||||
} catch (e) {
|
||||
log("Disconnect error:", String(e));
|
||||
}
|
||||
});
|
||||
|
||||
pubkeyBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await fetchOwnPubkeyAndFillPeers();
|
||||
} catch (e) {
|
||||
pubkeyOutEl.textContent = String(e);
|
||||
}
|
||||
});
|
||||
|
||||
signKind1Btn.addEventListener("click", async () => {
|
||||
try {
|
||||
let tags = [];
|
||||
try {
|
||||
tags = JSON.parse(kind1TagsEl.value || "[]");
|
||||
if (!Array.isArray(tags)) throw new Error("tags must be an array");
|
||||
} catch (e) {
|
||||
throw new Error(`Invalid tags JSON: ${String(e)}`);
|
||||
}
|
||||
|
||||
const unsignedEvent = {
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
content: String(kind1ContentEl.value || ""),
|
||||
};
|
||||
|
||||
const params = [unsignedEvent, getIndexOptions()];
|
||||
const resp = await rpcCall("sign_event", params);
|
||||
signOutEl.textContent = pretty(resp?.result ?? resp);
|
||||
} catch (e) {
|
||||
signOutEl.textContent = String(e);
|
||||
}
|
||||
});
|
||||
|
||||
nip04EncBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
const peer = requirePeerHex(nip04PeerEl.value);
|
||||
const msg = String(nip04MsgEl.value || "");
|
||||
const params = [peer, msg, getIndexOptions()];
|
||||
const resp = await rpcCall("nip04_encrypt", params);
|
||||
nip04OutEl.textContent = pretty(resp?.result ?? resp);
|
||||
if (resp && typeof resp.result === "string") {
|
||||
nip04DecPeerEl.value = peer;
|
||||
nip04CipherEl.value = resp.result;
|
||||
}
|
||||
} catch (e) {
|
||||
nip04OutEl.textContent = String(e);
|
||||
}
|
||||
});
|
||||
|
||||
nip04DecBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
const peer = requirePeerHex(nip04DecPeerEl.value);
|
||||
const ciphertext = String(nip04CipherEl.value || "");
|
||||
const params = [peer, ciphertext, getIndexOptions()];
|
||||
const resp = await rpcCall("nip04_decrypt", params);
|
||||
nip04DecOutEl.textContent = requireStringResult(resp, "NIP-04 decrypt");
|
||||
} catch (e) {
|
||||
nip04DecOutEl.textContent = String(e);
|
||||
}
|
||||
});
|
||||
|
||||
nip44EncBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
const peer = requirePeerHex(nip44PeerEl.value);
|
||||
const msg = String(nip44MsgEl.value || "");
|
||||
const params = [peer, msg, getIndexOptions()];
|
||||
const resp = await rpcCall("nip44_encrypt", params);
|
||||
nip44OutEl.textContent = pretty(resp?.result ?? resp);
|
||||
if (resp && typeof resp.result === "string") {
|
||||
nip44DecPeerEl.value = peer;
|
||||
nip44CipherEl.value = resp.result;
|
||||
}
|
||||
} catch (e) {
|
||||
nip44OutEl.textContent = String(e);
|
||||
}
|
||||
});
|
||||
|
||||
nip44DecBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
const peer = requirePeerHex(nip44DecPeerEl.value);
|
||||
const ciphertext = String(nip44CipherEl.value || "");
|
||||
const params = [peer, ciphertext, getIndexOptions()];
|
||||
const resp = await rpcCall("nip44_decrypt", params);
|
||||
nip44DecOutEl.textContent = requireStringResult(resp, "NIP-44 decrypt");
|
||||
} catch (e) {
|
||||
nip44DecOutEl.textContent = String(e);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
154
plans/cyd_webserial_signer.md
Normal file
154
plans/cyd_webserial_signer.md
Normal file
@@ -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).
|
||||
@@ -1 +1 @@
|
||||
0.1.17
|
||||
0.1.18
|
||||
|
||||
115
src/build.js
115
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,
|
||||
|
||||
488
src/signers/nsigner-webserial.js
Normal file
488
src/signers/nsigner-webserial.js
Normal file
@@ -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;
|
||||
}
|
||||
680
src/ui/modal.js
680
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 = `
|
||||
<tr style="background: #f3f4f6;">
|
||||
<th style="padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;">#</th>
|
||||
<th style="padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;">Use</th>
|
||||
</tr>
|
||||
`;
|
||||
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 = `
|
||||
<tr style="background: #f3f4f6;">
|
||||
<th style="padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;">#</th>
|
||||
<th style="padding: 8px; text-align: center; border: 1px solid #d1d5db; font-weight: bold;">Use</th>
|
||||
</tr>
|
||||
`;
|
||||
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 = '';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user