736 lines
26 KiB
HTML
736 lines
26 KiB
HTML
<!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 ─────────────────────────────────────────────────────────────────
|
|
// Keep chooser unfiltered so any serial-capable device can be selected.
|
|
const SERIAL_FILTERS = [];
|
|
|
|
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 = SERIAL_FILTERS.length
|
|
? await navigator.serial.requestPort({ filters: SERIAL_FILTERS })
|
|
: await navigator.serial.requestPort();
|
|
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("nostr_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("nostr_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("nostr_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("nostr_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("nostr_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("nostr_nip44_decrypt", params);
|
|
nip44DecOutEl.textContent = requireStringResult(resp, "NIP-44 decrypt");
|
|
} catch (e) {
|
|
nip44DecOutEl.textContent = String(e);
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|