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:
parent
8777b1fc56
commit
4133153f32
4 changed files with 340 additions and 71 deletions
|
|
@ -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",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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
240
main.js
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue