hermes: three-form handle parser, hermes: scheme display, save-forever signin

Panel + standalone window now accept all three handle forms as synonyms, and
the UI defaults to the hermes:alice scheme form. Signin is persistent across
restarts via safeStorage-sealed identity; the ⋯ menu adds Sign out.

- lib/hermes.js: parseHandle(input) → {kind, value} canonicalises any of
  hermes:alice, alice.erm, alice.bch, alice@parent.tld, npub1…, or raw hex.
  renderHandle(name, mode) formats a .erm name as hermes:alice (default),
  alice.erm (dotted), or alice@hermes.tel (email). Non-.erm names always
  render dotted so a .bch handle can't masquerade as a sub-identity.
- main.js: hermesResolveTo() unifies contacts-add + send + their panel-API
  twins behind one path. Sub-identity (alice@parent.tld) resolution returns
  a crisp "not available yet" until the mainnet mint of the parent name's
  signed-records manifest lands — the parser accepts the form today so UI
  copy and placeholders can show it.
- main.js: safeStorage-sealed identity at <userData>/hermes-identity/,
  auto-restored on app ready. New _hermesAPI.signOut wipes it. init() takes
  { remember: false } for a one-shot session (default: persist). A
  hermes-restored event fires on auto-restore so the panel skips the
  signin screen.
- panel.html: renderHandle() mirror client-side; ⋯ menu with "Signed in as
  hermes:alice" header + Sign out; placeholders show all three forms; the
  signin hint tells users they stay signed in.

On-chain `.erm` and `hermes.tel` mints are deliberately deferred to mainnet
per user direction — the parser and display paths are tld-agnostic, so a
mainnet switch is one constant change in lib/hermes.js (HERMES_TLD /
HERMES_EMAIL_PARENT) plus the panel's mirror of them.
This commit is contained in:
Local Dev 2026-10-21 12:32:59 +03:00
parent 8777b1fc56
commit 4133153f32
4 changed files with 340 additions and 71 deletions

View file

@ -18,7 +18,7 @@
const { ipcMain } = require("electron");
const PANEL_METHODS = [
"status", "canUseVault", "close", "init", "inbox", "send",
"status", "canUseVault", "close", "init", "signOut", "inbox", "send",
"markThreadRead", "contactsList", "contactsAdd", "contactsUpdate",
"contactsRemove",
];

View file

@ -174,9 +174,17 @@
<div class="title">Hermes</div>
<div class="spc"></div>
<div class="status" id="status"><span class="dot" id="statusDot"></span><span id="statusText">—</span></div>
<button class="ic" id="menuBtn" title="More" style="display:none">⋯</button>
<button class="ic" id="popOutBtn" title="Open in a window">⧉</button>
</div>
<!-- overflow menu -->
<div id="menu" style="position:absolute;top:46px;right:10px;z-index:50;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:4px;min-width:150px;display:none;box-shadow:0 6px 24px rgba(0,0,0,.3)">
<button class="menu-item" id="whoBtn" style="display:block;width:100%;text-align:left;background:transparent;border:0;color:var(--mut);font:inherit;font-size:11px;padding:6px 10px;cursor:default">Signed in as —</button>
<div style="height:1px;background:var(--line);margin:2px 0"></div>
<button class="menu-item" id="signOutBtn" style="display:block;width:100%;text-align:left;background:transparent;border:0;color:var(--ink);font:inherit;font-size:13px;padding:7px 10px;cursor:pointer;border-radius:6px">Sign out</button>
</div>
<!-- ========== SIGNIN SCREEN ========== -->
<div class="screen active" id="signin">
<div class="mk" aria-hidden="true">
@ -202,6 +210,7 @@
</div>
<div class="err" id="signinErr"></div>
<div class="hint" id="vaultHint"></div>
<div class="hint" style="margin-top:4px;max-width:300px">You'll stay signed in. Sign out from the ⋯ menu.</div>
</div>
<!-- ========== APP SCREEN ========== -->
@ -209,7 +218,7 @@
<div class="cols">
<div class="contacts-pane">
<div class="contacts-head">
<input id="search" placeholder="Search or type a .bch name" autocomplete="off" spellcheck="false">
<input id="search" placeholder="Search or type hermes:alice" autocomplete="off" spellcheck="false">
<button id="addBtn" title="New conversation">+</button>
</div>
<div class="contacts-list" id="contactsList"></div>
@ -236,7 +245,7 @@
<div class="modal-bg" id="addModal">
<div class="modal">
<h3>Start a new conversation</h3>
<input id="addName" placeholder="alice.bch, or an npub1…" autocomplete="off" spellcheck="false">
<input id="addName" placeholder="hermes:alice · alice.bch · alice@hermes.tel · npub1…" autocomplete="off" spellcheck="false">
<div class="err" id="addErr"></div>
<div class="row">
<button class="secondary" id="addCancel">Cancel</button>
@ -254,15 +263,32 @@ const err = (el, m) => { el.textContent = m || ""; };
const state = {
ready: false,
me: null, // { pkHex, npub }
me: null, // { pkHex, npub, handle? }
canUseVault: false,
contacts: [],
messages: [], // flat inbox from host
readState: {}, // peerPkHex → epoch sec read-up-to
selected: null, // peerPkHex (lowercase) of focused thread
threads: new Map(), // peerPkHex → { peerName, messages: [...] }
renderMode: "scheme", // "scheme" (hermes:alice) | "dotted" | "email"
};
// Must stay in sync with HERMES_TLD / HERMES_EMAIL_PARENT in lib/hermes.js —
// used only for display; actual resolution happens host-side via parseHandle.
const HERMES_TLD = "erm";
const HERMES_EMAIL_PARENT = "hermes.tel";
function renderHandle(name, mode) {
const m = mode || state.renderMode;
const n = String(name || "").toLowerCase();
if (!n || !n.includes(".")) return n;
const suffix = "." + HERMES_TLD;
if (!n.endsWith(suffix)) return n;
const local = n.slice(0, -suffix.length);
if (m === "dotted") return n;
if (m === "email") return `${local}@${HERMES_EMAIL_PARENT}`;
return `hermes:${local}`;
}
function h(tag, attrs, kids) {
const n = document.createElement(tag);
if (attrs) for (const k of Object.keys(attrs)) {
@ -285,7 +311,7 @@ function initials(name) {
function groupByPeer(messages, contacts, mePk) {
const nameOf = new Map();
for (const c of contacts) nameOf.set(c.pkHex, c.nickname || c.name);
for (const c of contacts) nameOf.set(c.pkHex, c.nickname || renderHandle(c.name));
const map = new Map();
for (const m of messages) {
const peer = m.peerPkHex || (m.direction === "out" ? null : m.senderPkHex);
@ -327,10 +353,10 @@ function renderContacts() {
list.textContent = "";
const q = $("search").value.trim().toLowerCase();
const nameOf = new Map();
for (const c of state.contacts) nameOf.set(c.pkHex, c.nickname || c.name);
for (const c of state.contacts) nameOf.set(c.pkHex, c.nickname || renderHandle(c.name));
const items = [];
for (const [pk, t] of state.threads) {
const label = t.peerName || nameOf.get(pk) || pk.slice(0, 10) + "…";
const label = (t.peerName && renderHandle(t.peerName)) || nameOf.get(pk) || pk.slice(0, 10) + "…";
if (q && !label.toLowerCase().includes(q) && !pk.includes(q)) continue;
const last = t.messages[t.messages.length - 1];
items.push({ pk, label, last, unread: unreadCount(t, state.readState[pk]) });
@ -338,7 +364,7 @@ function renderContacts() {
// Contacts with no messages yet
for (const c of state.contacts) {
if (state.threads.has(c.pkHex)) continue;
const label = c.nickname || c.name || c.pkHex.slice(0, 10) + "…";
const label = c.nickname || renderHandle(c.name) || c.pkHex.slice(0, 10) + "…";
if (q && !label.toLowerCase().includes(q) && !c.pkHex.includes(q)) continue;
items.push({ pk: c.pkHex, label, last: null, unread: 0 });
}
@ -379,8 +405,8 @@ function renderThread() {
}
const t = state.threads.get(pk);
const nameOf = new Map();
for (const c of state.contacts) nameOf.set(c.pkHex, c.nickname || c.name);
const label = (t && t.peerName) || nameOf.get(pk) || pk.slice(0, 16) + "…";
for (const c of state.contacts) nameOf.set(c.pkHex, c.nickname || renderHandle(c.name));
const label = (t && t.peerName && renderHandle(t.peerName)) || nameOf.get(pk) || pk.slice(0, 16) + "…";
$("threadName").textContent = label;
$("threadPk").textContent = pk;
head.style.display = "flex";
@ -458,10 +484,33 @@ function showApp(me) {
state.me = me;
$("signin").classList.remove("active");
$("app").classList.add("active");
const label = me && me.handle ? renderHandle(me.handle) :
me && me.npub ? me.npub.slice(0, 14) + "…" : "—";
$("whoBtn").textContent = "Signed in as " + label;
$("menuBtn").style.display = "grid";
refreshContacts().then(refreshInbox);
refreshStatus();
}
function showSignin() {
state.ready = false;
state.me = null;
state.selected = null;
state.messages = [];
state.threads = new Map();
$("app").classList.remove("active", "show-thread");
$("signin").classList.add("active");
$("menuBtn").style.display = "none";
closeMenu();
}
function openMenu() { $("menu").style.display = "block"; }
function closeMenu() { $("menu").style.display = "none"; }
document.addEventListener("click", (e) => {
const m = $("menu"); if (!m) return;
if (m.style.display === "block" && !m.contains(e.target) && e.target !== $("menuBtn")) closeMenu();
});
async function initWithOpts(opts) {
err($("signinErr"), "");
const btn = opts.useVault ? $("useVaultBtn") : $("useMnemonicBtn");
@ -508,6 +557,12 @@ async function initWithOpts(opts) {
if (window.silentmode.on) {
window.silentmode.on("hermes-message", () => refreshInbox());
window.silentmode.on("hermes-status-update", (p) => setStatus(p));
// Auto-restore fires on app launch if the user had a saved identity.
// The host already set hermesState up; the panel just needs to react
// as if it finished a signin.
window.silentmode.on("hermes-restored", (p) => {
if (!state.ready && p && p.pkHex) showApp({ pkHex: p.pkHex, npub: p.npub, handle: p.handle || null });
});
}
// Mild polling in case the event bus misses a tick (dev launcher, re-attach).
@ -525,6 +580,16 @@ $("useMnemonicBtn").addEventListener("click", () => {
initWithOpts({ useVault: false, mnemonic });
});
$("popOutBtn").addEventListener("click", () => bridge.invoke("open-messages"));
$("menuBtn").addEventListener("click", (e) => {
e.stopPropagation();
if ($("menu").style.display === "block") closeMenu();
else openMenu();
});
$("signOutBtn").addEventListener("click", async () => {
closeMenu();
try { await bridge.invoke("signOut"); } catch {}
showSignin();
});
$("search").addEventListener("input", renderContacts);
$("backBtn").addEventListener("click", () => {

View file

@ -79,6 +79,92 @@ export function nostrKeyFromRoot(root) {
const HEX_PUBKEY_RE = /^[0-9a-f]{64}$/i;
// ---- handle forms ----------------------------------------------------------
// Hermes accepts three equivalent handle forms, all resolving to the same
// BNS registration under the hood:
//
// hermes:alice — URI scheme, hides the TLD (default display)
// alice.erm — dotted BNS form
// alice@hermes.tel — email form; `hermes.tel` is a BNS name whose
// signed-records manifest lists sub-identities
//
// `.erm` and `hermes.tel` are the planned on-chain names; both mint on
// mainnet, pending user action. Until then, parseHandle still accepts all
// three forms — resolution against the real chain just returns "not found"
// for unminted TLDs/parents. Existing `.bch` names keep working untouched.
export const HERMES_SCHEME = "hermes";
export const HERMES_TLD = "erm";
export const HERMES_EMAIL_PARENT = "hermes.tel";
const HANDLE_LOCAL_RE = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
const DOTTED_NAME_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/;
/**
* Normalize any accepted handle form to a canonical lookup descriptor:
* { kind: "hex", value: "<64 hex>" }
* { kind: "npub", value: "npub1…" }
* { kind: "name", value: "alice.erm" } — BNS name lookup
* { kind: "sub", value: { local: "alice", parent: "hermes.tel" } }
* — signed-records
* sub-identity lookup
*/
export function parseHandle(input) {
const s = String(input || "").trim();
if (!s) throw new Error("empty handle");
if (/^[0-9a-f]{64}$/i.test(s)) return { kind: "hex", value: s.toLowerCase() };
if (s.toLowerCase().startsWith("npub1")) return { kind: "npub", value: s };
const lower = s.toLowerCase();
// hermes:alice — expand to the default TLD. `.erm` is the planned name,
// but any TLD switch is a one-line change to HERMES_TLD above.
const schemePrefix = HERMES_SCHEME + ":";
if (lower.startsWith(schemePrefix)) {
const local = lower.slice(schemePrefix.length).trim();
if (!HANDLE_LOCAL_RE.test(local)) throw new Error(`bad hermes: handle: ${s.slice(0, 48)}`);
return { kind: "name", value: `${local}.${HERMES_TLD}` };
}
// alice@parent.tld — email form, treated as a sub-identity under parent.
if (lower.includes("@")) {
const at = lower.indexOf("@");
const local = lower.slice(0, at);
const parent = lower.slice(at + 1);
if (!HANDLE_LOCAL_RE.test(local) || !DOTTED_NAME_RE.test(parent)) {
throw new Error(`bad email handle: ${s.slice(0, 48)}`);
}
return { kind: "sub", value: { local, parent } };
}
// alice.erm, alice.bch, alice.tel — dotted BNS name. Must have at least
// one dot to disambiguate from "bare" sub-identity requests (which the
// client enriches with the default parent before calling in).
if (DOTTED_NAME_RE.test(lower)) {
return { kind: "name", value: lower };
}
throw new Error(`unrecognised handle: ${s.slice(0, 48)}`);
}
/**
* Render a canonical descriptor (or a BNS name) in the user's preferred form.
* Modes:
* "scheme" (default) → hermes:alice (only when the name is under HERMES_TLD)
* "dotted" → alice.erm
* "email" → alice@hermes.tel (only for HERMES_TLD names)
* Non-hermes names (alice.bch, bob.sc) always render dotted — the scheme
* and email forms are reserved for the Hermes TLD so a .bch name can't
* accidentally look like a sub-identity.
*/
export function renderHandle(name, mode = "scheme") {
const n = String(name || "").toLowerCase();
if (!n || !n.includes(".")) return n;
const suffix = "." + HERMES_TLD;
if (!n.endsWith(suffix)) return n;
const local = n.slice(0, -suffix.length);
if (mode === "dotted") return n;
if (mode === "email") return `${local}@${HERMES_EMAIL_PARENT}`;
return `${HERMES_SCHEME}:${local}`;
}
export function parseNpRecord(raw) {
if (typeof raw !== "string" || !raw.trim()) throw new Error("np: empty or missing");
const v = raw.trim();

240
main.js
View file

@ -9776,6 +9776,140 @@ let hermesWin = null;
// (nostr-tools expects Uint8Array; converting on demand keeps the surface easier to reason about).
let hermesState = null;
// ---- handle resolution -----------------------------------------------------
// One shared helper for every "turn an input into a Hermes recipient" callsite
// (contactsAdd, send, and their standalone-window ipcMain twins). Takes any
// form the client accepts — direct hex, npub, hermes:alice, alice.erm,
// alice.bch, alice@parent.tld — and returns:
// { pkHex, relays: string[], canonicalName: string | null }
// `canonicalName` is set when the input resolved via a BNS name (used for the
// contacts record); null for direct-key forms. `relays` falls back to defaults
// when the resolved entry has no nr record.
//
// Sub-identity (alice@parent.tld) resolution: the parent's signed-records
// manifest will carry sub-identities once the mainnet mint of hermes.erm /
// hermes.tel lands. Until then, every sub lookup returns "name not registered"
// — but the client-side parser accepts the form, so the UI already shows the
// right affordance.
async function hermesResolveTo(input, { defaultRelays } = {}) {
const H = await loadHermesLib();
const parsed = H.parseHandle(input);
if (parsed.kind === "hex") {
return { pkHex: parsed.value, relays: (defaultRelays || HERMES_DEFAULT_RELAYS).slice(), canonicalName: null };
}
if (parsed.kind === "npub") {
return { pkHex: H.parseNpRecord(parsed.value), relays: (defaultRelays || HERMES_DEFAULT_RELAYS).slice(), canonicalName: null };
}
if (parsed.kind === "name") {
const R = await getResolver();
const canonicalName = R.normalizeName(parsed.value);
const entry = await R.resolveName(canonicalName, { WebSocket });
if (!entry) throw new Error(`no on-chain registration for ${canonicalName}`);
const info = H.parseHermesRecords(entry, { defaultRelays: defaultRelays || HERMES_DEFAULT_RELAYS });
return { pkHex: info.npPk, relays: info.relays, canonicalName };
}
if (parsed.kind === "sub") {
// alice@hermes.tel style. Needs the parent's signed-records manifest to
// ship — pending mainnet. For now, give the client a crisp error so the
// panel can surface "not available yet" rather than looking generic.
throw new Error(
`sub-identity "${parsed.value.local}@${parsed.value.parent}" lookup not available yet ` +
"(waiting on mainnet mint of the parent name's signed-records manifest)");
}
throw new Error("could not resolve handle");
}
// ---- identity persistence --------------------------------------------------
// Save-forever signin. Theseus-side: safeStorage (DPAPI on Windows, Keychain
// on macOS, libsecret on Linux) wraps the raw skHex at rest. The accompanying
// metadata (handle label, source, savedAt) is plaintext — nothing in it
// unlocks the key. One active identity per profile; a second successful init
// with persist:true replaces whatever was there. Signing out erases both the
// sealed blob and the metadata.
const hermesIdentityDir = () => path.join(app.getPath("userData"), "hermes-identity");
const hermesSealedKeyPath = () => path.join(hermesIdentityDir(), "id.sealed");
const hermesIdentityMetaPath = () => path.join(hermesIdentityDir(), "id.json");
function hermesSaveIdentity({ skHex, pkHex, npub, source, handle }) {
if (!safeStorage || !safeStorage.isEncryptionAvailable()) {
console.warn("[hermes] safeStorage unavailable — identity not persisted");
return false;
}
try {
hermesEnsureDir(hermesIdentityDir());
const sealed = safeStorage.encryptString(String(skHex));
fs.writeFileSync(hermesSealedKeyPath() + ".tmp", sealed);
fs.renameSync(hermesSealedKeyPath() + ".tmp", hermesSealedKeyPath());
const meta = { pkHex, npub, source, handle: handle || null, savedAt: new Date().toISOString() };
fs.writeFileSync(hermesIdentityMetaPath() + ".tmp", JSON.stringify(meta, null, 2));
fs.renameSync(hermesIdentityMetaPath() + ".tmp", hermesIdentityMetaPath());
return true;
} catch (e) {
console.error("[hermes] identity save failed:", e.message);
return false;
}
}
function hermesLoadIdentity() {
try {
if (!fs.existsSync(hermesSealedKeyPath()) || !fs.existsSync(hermesIdentityMetaPath())) return null;
if (!safeStorage || !safeStorage.isEncryptionAvailable()) return null;
const sealed = fs.readFileSync(hermesSealedKeyPath());
const skHex = safeStorage.decryptString(sealed);
const meta = JSON.parse(fs.readFileSync(hermesIdentityMetaPath(), "utf8"));
if (!/^[0-9a-f]{64}$/i.test(skHex || "") || !meta || !meta.pkHex) return null;
return { skHex, ...meta };
} catch (e) {
console.error("[hermes] identity load failed:", e.message);
return null;
}
}
function hermesForgetIdentity() {
for (const p of [hermesSealedKeyPath(), hermesIdentityMetaPath()]) {
try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch {}
}
}
// Called once at startup. If a saved identity exists, bring hermesState up
// without the user re-signing. Keeps the panel/window ready-to-use on launch
// so inbox watchers reconnect without a UI gesture. Non-fatal — any failure
// (safeStorage locked, file corrupt, lib load crash) silently falls back to
// the signin screen as if nothing was saved.
async function hermesAutoRestore() {
try {
if (hermesState) return; // already live (dev-restart edge case)
const saved = hermesLoadIdentity();
if (!saved) return;
const H = await loadHermesLib();
const sk = Uint8Array.from(Buffer.from(saved.skHex, "hex"));
const pkHex = saved.pkHex;
const npub = saved.npub;
const persisted = hermesLoadInboxFromDisk(pkHex);
const seen = new Set();
for (const m of persisted) seen.add((m.senderPkHex || "") + "\0" + m.createdAt + "\0" + m.text);
hermesState = {
skHex: saved.skHex, pkHex, npub,
inbox: persisted,
readState: hermesLoadReadState(pkHex),
relays: new Map(), _seen: seen,
source: saved.source || "restored",
handle: saved.handle || null,
};
void H; // ensure lib is warm so later send/wrap paths don't block
for (const url of HERMES_DEFAULT_RELAYS) {
hermesState.relays.set(url, await hermesConnectRelay(url));
}
console.log("[hermes] identity restored for", pkHex.slice(0, 10) + "…", "source=" + hermesState.source);
hermesEmit("hermes-restored", { pkHex, npub, source: hermesState.source });
} catch (e) {
console.error("[hermes] auto-restore failed:", e.message);
}
}
// Fire a little after app ready so loadHermesLib's dynamic import and
// safeStorage both exist. Non-blocking.
app.whenReady().then(() => setTimeout(() => { void hermesAutoRestore(); }, 1500));
const hOk = (o = {}) => ({ ok: true, ...o });
const hErr = (e) => ({ ok: false, err: String((e && e.message) || e) });
@ -10012,23 +10146,12 @@ ipcMain.handle("hermes-contacts-list", () => hOk({ contacts: hermesLoadContacts(
ipcMain.handle("hermes-contacts-add", async (_e, { name, nickname } = {}) => {
const raw = String(name || "").trim();
if (!raw) return hErr("name is required (e.g. alice.bch or an npub)");
// Resolve to a pkHex: direct hex, npub, or .bch name via on-chain np.
let pkHex = null, canonicalName = raw;
if (!raw) return hErr("name is required (e.g. hermes:alice or an npub)");
let pkHex, canonicalName;
try {
const H = await loadHermesLib();
if (/^[0-9a-f]{64}$/i.test(raw)) {
pkHex = raw.toLowerCase();
} else if (raw.startsWith("npub1")) {
pkHex = H.parseNpRecord(raw);
} else {
const R = await getResolver();
canonicalName = R.normalizeName(raw);
const entry = await R.resolveName(canonicalName, { WebSocket });
if (!entry) return hErr(`no on-chain registration for ${canonicalName}`);
const parsed = H.parseHermesRecords(entry);
pkHex = parsed.npPk;
}
const r = await hermesResolveTo(raw);
pkHex = r.pkHex;
canonicalName = r.canonicalName || raw;
} catch (e) { return hErr(e); }
const contacts = hermesLoadContacts();
const existing = contacts.findIndex((c) => c.pkHex === pkHex);
@ -10167,27 +10290,30 @@ global._hermesAPI = {
try {
hermesTeardown();
const H = await loadHermesLib();
let sk, pkHex, npub;
let sk, pkHex, npub, source;
if (opts.useVault) {
if (!vaultState || !vaultState.messengerRoot) {
return hErr("password vault is locked or was set up without a mnemonic");
}
({ sk, pkHex, npub } = H.nostrKeyFromRoot(vaultState.messengerRoot));
source = "vault";
} else {
const mnemonic = opts.mnemonic;
if (!mnemonic || typeof mnemonic !== "string" || mnemonic.trim().split(/\s+/).length < 12) {
return hErr("enter a 12- or 24-word mnemonic");
}
({ sk, pkHex, npub } = await H.nostrKeyFromMnemonic(mnemonic.trim()));
source = "mnemonic";
}
const skHex = Buffer.from(sk).toString("hex");
const persisted = hermesLoadInboxFromDisk(pkHex);
const seen = new Set();
for (const m of persisted) seen.add((m.senderPkHex || "") + "\0" + m.createdAt + "\0" + m.text);
hermesState = {
skHex: Buffer.from(sk).toString("hex"),
pkHex, npub, inbox: persisted,
skHex, pkHex, npub, inbox: persisted,
readState: hermesLoadReadState(pkHex),
relays: new Map(), _seen: seen,
source, handle: opts.handle || null,
};
for (const url of HERMES_DEFAULT_RELAYS) {
hermesState.relays.set(url, await hermesConnectRelay(url));
@ -10195,10 +10321,25 @@ global._hermesAPI = {
await new Promise((r) => setTimeout(r, 400));
let connected = 0;
for (const s of hermesState.relays.values()) if (s.ready) connected++;
return hOk({ npub, pkHex, source: opts.useVault ? "vault" : "mnemonic",
relaysConnected: connected, relaysTotal: hermesState.relays.size });
// Persist by default. Caller passes `remember: false` for a one-shot
// signin that disappears when hermesTeardown runs. The vault path only
// persists if the user said so — vault-wrapped identities are
// re-derivable from the already-unlocked vault, so saving the sealed
// skHex a second time is write-amplification, not security.
const remember = opts.remember !== false;
if (remember) {
hermesSaveIdentity({ skHex, pkHex, npub, source, handle: opts.handle || null });
}
return hOk({ npub, pkHex, source,
relaysConnected: connected, relaysTotal: hermesState.relays.size,
persisted: remember });
} catch (e) { hermesTeardown(); return hErr(e); }
},
signOut: () => {
hermesForgetIdentity();
hermesTeardown();
return hOk({ signedOut: true });
},
markThreadRead: ({ peerPkHex, upToCreatedAt } = {}) => {
if (!hermesState) return hErr("not initialised");
if (!peerPkHex || typeof peerPkHex !== "string") return hErr("peerPkHex required");
@ -10213,19 +10354,12 @@ global._hermesAPI = {
contactsList: () => hOk({ contacts: hermesLoadContacts().slice() }),
contactsAdd: async ({ name, nickname } = {}) => {
const raw = String(name || "").trim();
if (!raw) return hErr("name is required (e.g. alice.bch or an npub)");
let pkHex = null, canonicalName = raw;
if (!raw) return hErr("name is required (e.g. hermes:alice or an npub)");
let pkHex, canonicalName;
try {
const H = await loadHermesLib();
if (/^[0-9a-f]{64}$/i.test(raw)) pkHex = raw.toLowerCase();
else if (raw.startsWith("npub1")) pkHex = H.parseNpRecord(raw);
else {
const R = await getResolver();
canonicalName = R.normalizeName(raw);
const entry = await R.resolveName(canonicalName, { WebSocket });
if (!entry) return hErr(`no on-chain registration for ${canonicalName}`);
pkHex = H.parseHermesRecords(entry).npPk;
}
const r = await hermesResolveTo(raw);
pkHex = r.pkHex;
canonicalName = r.canonicalName || raw;
} catch (e) { return hErr(e); }
const contacts = hermesLoadContacts();
const existing = contacts.findIndex((c) => c.pkHex === pkHex);
@ -10260,19 +10394,12 @@ global._hermesAPI = {
if (!to || !text) return hErr("to and text required");
try {
const H = await loadHermesLib();
let recipientPkHex; let relays = HERMES_DEFAULT_RELAYS.slice();
const trimmed = String(to).trim();
if (/^[0-9a-f]{64}$/i.test(trimmed)) recipientPkHex = trimmed.toLowerCase();
else if (trimmed.startsWith("npub1")) recipientPkHex = H.parseNpRecord(trimmed);
else {
const R = await getResolver();
const name = R.normalizeName(trimmed);
const entry = await R.resolveName(name, { WebSocket });
if (!entry) return hErr(`no on-chain registration for ${name}`);
const parsed = H.parseHermesRecords(entry, { defaultRelays: HERMES_DEFAULT_RELAYS });
recipientPkHex = parsed.npPk;
relays = parsed.relays;
}
let recipientPkHex, relays;
try {
const r = await hermesResolveTo(to, { defaultRelays: HERMES_DEFAULT_RELAYS });
recipientPkHex = r.pkHex;
relays = r.relays;
} catch (e) { return hErr(e); }
const sk = Buffer.from(hermesState.skHex, "hex");
const wrap = H.wrapChat({ senderSk: sk, recipientPkHex, text });
const publishOne = (url) => new Promise((resolve) => {
@ -10313,21 +10440,12 @@ ipcMain.handle("hermes-send", async (_e, { to, text } = {}) => {
if (!to || !text) return hErr("to and text required");
try {
const H = await loadHermesLib();
let recipientPkHex; let relays = HERMES_DEFAULT_RELAYS.slice();
const trimmed = String(to).trim();
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
recipientPkHex = trimmed.toLowerCase();
} else if (trimmed.startsWith("npub1")) {
recipientPkHex = H.parseNpRecord(trimmed);
} else {
const R = await getResolver();
const name = R.normalizeName(trimmed);
const entry = await R.resolveName(name, { WebSocket });
if (!entry) return hErr(`no on-chain registration for ${name}`);
const parsed = H.parseHermesRecords(entry, { defaultRelays: HERMES_DEFAULT_RELAYS });
recipientPkHex = parsed.npPk;
relays = parsed.relays;
}
let recipientPkHex, relays;
try {
const r = await hermesResolveTo(to, { defaultRelays: HERMES_DEFAULT_RELAYS });
recipientPkHex = r.pkHex;
relays = r.relays;
} catch (e) { return hErr(e); }
const sk = Buffer.from(hermesState.skHex, "hex");
const wrap = H.wrapChat({ senderSk: sk, recipientPkHex, text });