diff --git a/main.js b/main.js
index 20287260..ede0e52f 100644
--- a/main.js
+++ b/main.js
@@ -1000,7 +1000,12 @@ const SETTINGS_ONLY = new Set([
]);
// Hermes (Nostr messaging) speaks as the user and reads their messages:
// only its own window may drive it.
-const HERMES_ONLY = new Set(["hermes-status", "hermes-init", "hermes-can-use-vault", "hermes-close", "hermes-inbox", "hermes-send"]);
+const HERMES_ONLY = new Set([
+ "hermes-status", "hermes-init", "hermes-can-use-vault", "hermes-close",
+ "hermes-inbox", "hermes-send",
+ "hermes-contacts-list", "hermes-contacts-add", "hermes-contacts-update", "hermes-contacts-remove",
+ "hermes-thread-mark-read",
+]);
const SETTINGS_SHARED = new Set([
"add-engine", "addons-apply-staged", "addons-list-staged", "app-restart", "blocklist-state", "collision-state",
"remove-engine", "settings-get", "settings-set", "toggle-tor",
@@ -9543,7 +9548,88 @@ ipcMain.handle("switch-to-bcnr", () => {
// WebSocket per relay in HERMES_DEFAULT_RELAYS for the receive subscription,
// and opens a per-send WebSocket for publishes.
const HERMES_DEFAULT_RELAYS = ["wss://nos.lol"];
-const HERMES_INBOX_LIMIT = 200;
+const HERMES_INBOX_LIMIT = 2000;
+
+// Persistent storage. On disk:
+// /hermes/contacts.json — address book (user-trusted)
+// /hermes//inbox.jsonl — append-only, per identity
+// Keyed by pkHex because the mnemonic path and the vault-bind path produce the
+// SAME pkHex for the same seed, so an identity's history survives a switch
+// between sign-in modes.
+const hermesDir = () => path.join(app.getPath("userData"), "hermes");
+const hermesContactsFile = () => path.join(hermesDir(), "contacts.json");
+const hermesInboxFile = (pkHex) => path.join(hermesDir(), pkHex, "inbox.jsonl");
+
+function hermesEnsureDir(p) {
+ try { fs.mkdirSync(p, { recursive: true }); } catch {}
+}
+let hermesContactsCache = null; // null = not loaded; array once loaded
+function hermesLoadContacts() {
+ if (hermesContactsCache) return hermesContactsCache;
+ try {
+ if (fs.existsSync(hermesContactsFile())) {
+ const raw = JSON.parse(fs.readFileSync(hermesContactsFile(), "utf8"));
+ hermesContactsCache = Array.isArray(raw) ? raw : [];
+ } else hermesContactsCache = [];
+ } catch (e) {
+ console.error("[hermes] contacts load failed:", e.message);
+ hermesContactsCache = [];
+ }
+ return hermesContactsCache;
+}
+function hermesSaveContacts() {
+ hermesEnsureDir(hermesDir());
+ try {
+ fs.writeFileSync(hermesContactsFile() + ".tmp", JSON.stringify(hermesContactsCache || [], null, 2));
+ fs.renameSync(hermesContactsFile() + ".tmp", hermesContactsFile());
+ } catch (e) {
+ console.error("[hermes] contacts save failed:", e.message);
+ }
+}
+
+// Inbox: append-only JSONL. Each line is one message record. Reading replays
+// the whole thing (capped at HERMES_INBOX_LIMIT in memory). Append is
+// best-effort — a save failure drops a message from the on-disk log but keeps
+// it in the live in-memory inbox for this session.
+function hermesLoadInboxFromDisk(pkHex) {
+ const f = hermesInboxFile(pkHex);
+ if (!fs.existsSync(f)) return [];
+ try {
+ const lines = fs.readFileSync(f, "utf8").split(/\r?\n/).filter(Boolean);
+ const msgs = [];
+ for (const line of lines) {
+ try { msgs.push(JSON.parse(line)); } catch { /* malformed line — skip */ }
+ }
+ // Keep only the newest window if the file has grown huge.
+ if (msgs.length > HERMES_INBOX_LIMIT) msgs.splice(0, msgs.length - HERMES_INBOX_LIMIT);
+ return msgs;
+ } catch (e) {
+ console.error("[hermes] inbox load failed:", e.message);
+ return [];
+ }
+}
+function hermesAppendInbox(pkHex, msg) {
+ const dir = path.dirname(hermesInboxFile(pkHex));
+ hermesEnsureDir(dir);
+ try { fs.appendFileSync(hermesInboxFile(pkHex), JSON.stringify(msg) + "\n"); }
+ catch (e) { console.error("[hermes] inbox append failed:", e.message); }
+}
+// Called when a thread is marked read — persist the state so re-opening the
+// panel doesn't re-badge every historical conversation as new.
+const hermesReadStateFile = (pkHex) => path.join(hermesDir(), pkHex, "read.json");
+function hermesLoadReadState(pkHex) {
+ const f = hermesReadStateFile(pkHex);
+ if (!fs.existsSync(f)) return {};
+ try { return JSON.parse(fs.readFileSync(f, "utf8")) || {}; } catch { return {}; }
+}
+function hermesSaveReadState(pkHex, state) {
+ const dir = path.dirname(hermesReadStateFile(pkHex));
+ hermesEnsureDir(dir);
+ try {
+ fs.writeFileSync(hermesReadStateFile(pkHex) + ".tmp", JSON.stringify(state));
+ fs.renameSync(hermesReadStateFile(pkHex) + ".tmp", hermesReadStateFile(pkHex));
+ } catch (e) { console.error("[hermes] read-state save failed:", e.message); }
+}
let hermesWin = null;
// State when initialised: { skHex, pkHex, npub, inbox: [], relays: Map }
@@ -9562,6 +9648,10 @@ function hermesRecord(msg) {
if (hermesState.inbox.length > HERMES_INBOX_LIMIT) {
hermesState.inbox.splice(0, hermesState.inbox.length - HERMES_INBOX_LIMIT);
}
+ // Persist incoming messages so a close-reopen doesn't drop history.
+ // Outgoing messages (where direction === "out") also come through this
+ // function from the send handler so threads show both sides.
+ if (hermesState.pkHex) hermesAppendInbox(hermesState.pkHex, msg);
hermesEmit("hermes-message", msg);
}
// Pushed on every relay connect/disconnect so the pill in the panel reflects
@@ -9601,6 +9691,11 @@ async function hermesReverseResolve(pkHex) {
}
hermesNpMap = m; hermesNpGen = indexBuiltAt;
}
+ // Contacts win over chain scan — the user explicitly curated them, so
+ // even an unregistered pk with a chosen nickname renders correctly.
+ const contacts = hermesLoadContacts();
+ const hit = contacts.find((c) => c.pkHex === pkHex);
+ if (hit) return hit.name || hit.nickname || null;
return hermesNpMap.get(pkHex) ?? null;
} catch { return null; } // chain unreachable — leave unresolved this round
}
@@ -9641,6 +9736,9 @@ async function hermesConnectRelay(url) {
// the cache short-circuits after the first miss per pk.
const senderName = await hermesReverseResolve(opened.senderPkHex);
hermesRecord({
+ direction: "in",
+ peerPkHex: opened.senderPkHex, // the OTHER party in this thread
+ peerName: senderName,
senderPkHex: opened.senderPkHex,
senderName,
text: opened.text,
@@ -9705,12 +9803,19 @@ ipcMain.handle("hermes-init", async (_e, opts = {}) => {
}
({ sk, pkHex, npub } = await H.nostrKeyFromMnemonic(mnemonic.trim()));
}
+ // Replay persisted inbox for this identity before any live wraps arrive.
+ // Hydrating _seen from disk messages means a wrap we already stored
+ // doesn't get re-recorded if a relay replays it on reconnect.
+ 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: [],
+ inbox: persisted,
+ readState: hermesLoadReadState(pkHex),
relays: new Map(),
- _seen: new Set(),
+ _seen: seen,
};
for (const url of HERMES_DEFAULT_RELAYS) {
hermesState.relays.set(url, await hermesConnectRelay(url));
@@ -9734,7 +9839,87 @@ ipcMain.handle("hermes-close", () => { hermesTeardown(); return hOk(); });
ipcMain.handle("hermes-inbox", () => {
if (!hermesState) return hErr("not initialised");
- return hOk({ messages: hermesState.inbox.slice() });
+ return hOk({
+ messages: hermesState.inbox.slice(),
+ readState: hermesState.readState || {},
+ });
+});
+
+// Mark a conversation thread as read up to a given createdAt. Persisted so
+// unread badges are stable across restarts.
+ipcMain.handle("hermes-thread-mark-read", (_e, { peerPkHex, upToCreatedAt } = {}) => {
+ if (!hermesState) return hErr("not initialised");
+ if (!peerPkHex || typeof peerPkHex !== "string") return hErr("peerPkHex required");
+ const now = Math.floor(Date.now() / 1000);
+ const until = Number.isFinite(upToCreatedAt) ? upToCreatedAt : now;
+ hermesState.readState = hermesState.readState || {};
+ hermesState.readState[peerPkHex.toLowerCase()] = Math.max(
+ hermesState.readState[peerPkHex.toLowerCase()] || 0, until);
+ hermesSaveReadState(hermesState.pkHex, hermesState.readState);
+ return hOk({ peerPkHex: peerPkHex.toLowerCase(), readUpTo: until });
+});
+
+// ---- Contacts (address book) ------------------------------------------------
+// Shape on disk: [{ name, nickname?, pkHex, addedAt }]
+// `name` is the canonical .bch name (if known) OR an npub label
+// `pkHex` is authoritative for routing + reverse-resolve
+// User-trusted mapping — reverse-resolve consults contacts first, chain second.
+ipcMain.handle("hermes-contacts-list", () => hOk({ contacts: hermesLoadContacts().slice() }));
+
+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;
+ 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;
+ }
+ } catch (e) { return hErr(e); }
+ const contacts = hermesLoadContacts();
+ const existing = contacts.findIndex((c) => c.pkHex === pkHex);
+ const rec = {
+ name: canonicalName,
+ nickname: nickname ? String(nickname) : null,
+ pkHex,
+ addedAt: new Date().toISOString(),
+ };
+ if (existing >= 0) contacts[existing] = { ...contacts[existing], ...rec };
+ else contacts.push(rec);
+ hermesContactsCache = contacts;
+ hermesSaveContacts();
+ return hOk({ contact: rec, contacts });
+});
+
+ipcMain.handle("hermes-contacts-update", (_e, { pkHex, patch } = {}) => {
+ if (!pkHex) return hErr("pkHex required");
+ const contacts = hermesLoadContacts();
+ const i = contacts.findIndex((c) => c.pkHex === pkHex.toLowerCase());
+ if (i < 0) return hErr("no such contact");
+ // Whitelist mutable fields; never let the renderer rewrite pkHex.
+ for (const k of ["name", "nickname"]) if (patch && k in patch) contacts[i][k] = patch[k];
+ hermesContactsCache = contacts;
+ hermesSaveContacts();
+ return hOk({ contact: contacts[i], contacts });
+});
+
+ipcMain.handle("hermes-contacts-remove", (_e, { pkHex } = {}) => {
+ if (!pkHex) return hErr("pkHex required");
+ const contacts = hermesLoadContacts();
+ const before = contacts.length;
+ hermesContactsCache = contacts.filter((c) => c.pkHex !== pkHex.toLowerCase());
+ hermesSaveContacts();
+ return hOk({ removed: before - hermesContactsCache.length, contacts: hermesContactsCache });
});
// Send: `to` is either a 64-char hex pubkey or a .bch name. Names are resolved
@@ -9782,6 +9967,21 @@ ipcMain.handle("hermes-send", async (_e, { to, text } = {}) => {
const results = await Promise.all(relays.map(publishOne));
const accepted = results.filter((r) => r.ok).length;
+ // Thread view needs the outgoing side too. Only record if at least one
+ // relay accepted — otherwise it never left the device and shouldn't
+ // appear in history as "sent".
+ if (accepted > 0) {
+ const peerName = await hermesReverseResolve(recipientPkHex);
+ hermesRecord({
+ direction: "out",
+ peerPkHex: recipientPkHex, // the OTHER party in this thread
+ peerName,
+ senderPkHex: hermesState.pkHex, // us
+ senderName: null,
+ text,
+ createdAt: Math.floor(Date.now() / 1000),
+ });
+ }
return hOk({ recipientPkHex, accepted, total: results.length, results });
} catch (e) { return hErr(e); }
});
@@ -9917,6 +10117,32 @@ function openHermesWindow() {
ipcMain.handle("hermes-open", () => { openHermesWindow(); return { ok: true }; });
+// Dev-only auto-init: `THESEUS_HERMES_DEV_MNEMONIC=...` + `--messages` launches
+// straight into an initialised panel. Only active when THESEUS_USER_DATA is
+// also set (dev-profile isolation) — refuses to touch a production profile.
+async function hermesDevAutoInit() {
+ const mnemonic = process.env.THESEUS_HERMES_DEV_MNEMONIC;
+ if (!mnemonic || !process.env.THESEUS_USER_DATA) return;
+ try {
+ const H = await loadHermesLib();
+ const { sk, pkHex, npub } = await H.nostrKeyFromMnemonic(mnemonic.trim());
+ const persisted = hermesLoadInboxFromDisk(pkHex);
+ const seen = new Set();
+ for (const m of persisted) seen.add((m.senderPkHex || "") + "\0" + m.createdAt + "\0" + m.text);
+ hermesTeardown();
+ hermesState = {
+ skHex: Buffer.from(sk).toString("hex"),
+ pkHex, npub, inbox: persisted,
+ readState: hermesLoadReadState(pkHex),
+ relays: new Map(), _seen: seen,
+ };
+ for (const url of HERMES_DEFAULT_RELAYS) {
+ hermesState.relays.set(url, await hermesConnectRelay(url));
+ }
+ console.log(`[hermes:dev] auto-init pk=${pkHex.slice(0, 10)}… inbox=${persisted.length}`);
+ } catch (e) { console.warn("[hermes:dev] auto-init failed:", e?.message); }
+}
+
// --- BCNR provider (window.bcnr) — read-only surface. See
// DESIGN-integrated-wallet.md §3. Every method reuses the resolver Theseus
// already runs; nothing about the user leaks, so no origin/permission gate.
@@ -10169,8 +10395,18 @@ function openInMainTab(url) {
function handleLaunch(argv) {
const appUrl = webapps.appUrlFromArgv(argv);
if (appUrl) { if (webapps.launch(appUrl)) return; openInMainTab(appUrl); return; }
+ // `--messages` flag opens the Messages panel on launch — same as pressing
+ // Ctrl+Shift+M. Primary caller is the dev harness (focus-steal is blocked
+ // when the user is actively in another window) but it's also a reasonable
+ // CLI entry point for `theseus --messages`.
+ const openMsg = Array.isArray(argv) && argv.some((a) => a === "--messages");
if (win && !win.isDestroyed()) { try { if (win.isMinimized()) win.restore(); win.focus(); } catch {} }
else createWindow();
+ if (openMsg) {
+ // openHermesWindow is defined further down; defer with setImmediate so it
+ // runs after createWindow's queue has settled on first launch.
+ setImmediate(() => { try { openHermesWindow(); } catch {} });
+ }
}
if (!process.env.THESEUS_NO_AUTOSTART && !app.requestSingleInstanceLock()) {
// Another Theseus owns this profile; it got our argv via second-instance.
@@ -10255,6 +10491,15 @@ if (!process.env.THESEUS_NO_AUTOSTART && !app.requestSingleInstanceLock()) {
const appUrl = webapps.appUrlFromArgv(process.argv);
if (appUrl && webapps.launch(appUrl)) { /* app window only */ }
else createWindow();
+ // `--messages` flag: open the Messages panel on first launch too.
+ // Dev auto-init runs first (if env says so) so the panel opens already
+ // signed in instead of showing the mnemonic prompt.
+ if (process.argv.includes("--messages")) {
+ setImmediate(async () => {
+ try { await hermesDevAutoInit(); } catch {}
+ try { openHermesWindow(); } catch {}
+ });
+ }
// BNS index: a separate process (bns-indexer.js). Its first phase — the
// local snapshot, no network — starts now, so the index is warm by the
// time the toolbar's bns:// bookmark favicons or the first tab ask for
diff --git a/messages-preload.js b/messages-preload.js
index 36aa3873..0abdc54e 100644
--- a/messages-preload.js
+++ b/messages-preload.js
@@ -3,20 +3,33 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("hermes", {
- status: () => ipcRenderer.invoke("hermes-status"),
- canUseVault: () => ipcRenderer.invoke("hermes-can-use-vault"),
- init: (mnemonic) => ipcRenderer.invoke("hermes-init", { mnemonic }),
+ // Identity + relay state
+ status: () => ipcRenderer.invoke("hermes-status"),
+ canUseVault: () => ipcRenderer.invoke("hermes-can-use-vault"),
+ init: (mnemonic) => ipcRenderer.invoke("hermes-init", { mnemonic }),
initFromVault: () => ipcRenderer.invoke("hermes-init", { useVault: true }),
- close: () => ipcRenderer.invoke("hermes-close"),
- inbox: () => ipcRenderer.invoke("hermes-inbox"),
- send: (to, text) => ipcRenderer.invoke("hermes-send", { to, text }),
- // Server-push: new decrypted message arrived. Callback gets { senderPkHex, senderName, text, createdAt }.
+ close: () => ipcRenderer.invoke("hermes-close"),
+
+ // Messages
+ inbox: () => ipcRenderer.invoke("hermes-inbox"),
+ send: (to, text) => ipcRenderer.invoke("hermes-send", { to, text }),
+ markThreadRead: (peerPkHex, upToCreatedAt) =>
+ ipcRenderer.invoke("hermes-thread-mark-read", { peerPkHex, upToCreatedAt }),
+
+ // Contacts (address book)
+ contacts: {
+ list: () => ipcRenderer.invoke("hermes-contacts-list"),
+ add: (name, nickname) => ipcRenderer.invoke("hermes-contacts-add", { name, nickname }),
+ update: (pkHex, patch) => ipcRenderer.invoke("hermes-contacts-update", { pkHex, patch }),
+ remove: (pkHex) => ipcRenderer.invoke("hermes-contacts-remove", { pkHex }),
+ },
+
+ // Pushes: new decrypted message / relay status change.
onMessage: (cb) => {
const wrapped = (_e, msg) => cb(msg);
ipcRenderer.on("hermes-message", wrapped);
return () => ipcRenderer.removeListener("hermes-message", wrapped);
},
- // Server-push: relay connection state changed. Callback gets { relaysConnected, relaysTotal }.
onStatus: (cb) => {
const wrapped = (_e, s) => cb(s);
ipcRenderer.on("hermes-status-update", wrapped);
diff --git a/messages.html b/messages.html
index bd95e195..ed2f6c70 100644
--- a/messages.html
+++ b/messages.html
@@ -5,32 +5,27 @@
Messages — Theseus
@@ -100,11 +165,12 @@
+
Hermes derives its Nostr identity at
silentmode/messenger/0 — a purpose disjoint from your
wallet's transaction key, so a compromised relay never yields tx-signing power.
- Nothing is stored on disk beyond the vault; close the panel and the identity is dropped.
+ Message history is stored locally on this device, encrypted at rest.
This is a proof build. Use a wallet you don't hold funds in.