diff --git a/bundled-addons/hermes/addon.json b/bundled-addons/hermes/addon.json
index 92b8a711..2dc30d83 100644
--- a/bundled-addons/hermes/addon.json
+++ b/bundled-addons/hermes/addon.json
@@ -1,14 +1,14 @@
{
"id": "hermes",
"name": "Hermes",
- "version": "0.1.0",
+ "version": "0.2.0",
"description": "Message anyone by their .bch name. End-to-end encrypted DMs over Nostr (NIP-17), identity derived from your wallet seed.",
"author": "Silent Mode",
- "icon": "data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNjQgNjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzhCREY4IiBzdHJva2Utd2lkdGg9IjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgdHJhbnNmb3JtPSJyb3RhdGUoLTIyIDMyIDMyKSI+PHJlY3QgeD0iMjAiIHk9IjI2IiB3aWR0aD0iMzAiIGhlaWdodD0iMTgiIHJ4PSIxLjUiLz48cGF0aCBkPSJNMjAgMjcgTDM1IDM3IEw1MCAyNyIvPjxwYXRoIGQ9Ik0yMCAzNiBMNCAzNiIvPjwvZz48L3N2Zz4K",
+ "icon": "data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNjQgNjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzhCREY4IiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgdHJhbnNmb3JtPSJyb3RhdGUoLTIwIDMyIDMyKSI+PHJlY3QgeD0iMTgiIHk9IjI0IiB3aWR0aD0iMzQiIGhlaWdodD0iMjIiIHJ4PSIxLjUiLz48cGF0aCBkPSJNMTggMjUgTDM1IDM5IEw1MiAyNSIvPjxwYXRoIGQ9Ik0xOCAyOCBMLTQgMjQiLz48cGF0aCBkPSJNMTggMzYgTC02IDM2Ii8+PHBhdGggZD0iTTE4IDQ0IEwwIDQ2Ii8+PC9nPjwvc3ZnPg==",
"main": "index.js",
"activation": "on-demand",
"panels": [
- { "id": "main", "title": "Hermes", "icon": "data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNjQgNjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzhCREY4IiBzdHJva2Utd2lkdGg9IjUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgdHJhbnNmb3JtPSJyb3RhdGUoLTIyIDMyIDMyKSI+PHJlY3QgeD0iMjAiIHk9IjI2IiB3aWR0aD0iMzAiIGhlaWdodD0iMTgiIHJ4PSIxLjUiLz48cGF0aCBkPSJNMjAgMjcgTDM1IDM3IEw1MCAyNyIvPjxwYXRoIGQ9Ik0yMCAzNiBMNCAzNiIvPjwvZz48L3N2Zz4K", "page": "panel.html", "side": "left" }
+ { "id": "main", "title": "Hermes", "icon": "data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNjQgNjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzhCREY4IiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgdHJhbnNmb3JtPSJyb3RhdGUoLTIwIDMyIDMyKSI+PHJlY3QgeD0iMTgiIHk9IjI0IiB3aWR0aD0iMzQiIGhlaWdodD0iMjIiIHJ4PSIxLjUiLz48cGF0aCBkPSJNMTggMjUgTDM1IDM5IEw1MiAyNSIvPjxwYXRoIGQ9Ik0xOCAyOCBMLTQgMjQiLz48cGF0aCBkPSJNMTggMzYgTC02IDM2Ii8+PHBhdGggZD0iTTE4IDQ0IEwwIDQ2Ii8+PC9nPjwvc3ZnPg==", "page": "panel.html", "side": "left" }
],
"capabilities": ["sidebar-panel"]
}
diff --git a/bundled-addons/hermes/index.js b/bundled-addons/hermes/index.js
index 2d9598cb..16b736ed 100644
--- a/bundled-addons/hermes/index.js
+++ b/bundled-addons/hermes/index.js
@@ -1,19 +1,28 @@
-// Hermes bundled add-on — thin launcher.
+// Hermes bundled add-on — thin proxy to the host's panel API.
//
-// Real messaging code (NIP-17 wrap/unwrap, relay subscriptions, persistent
-// inbox, contacts) lives in Theseus main.js: too much raw network + crypto to
-// run under the add-on sandbox. This add-on exists for ONE reason: register a
-// left-strip sidebar entry so the Messages panel is discoverable without
-// pressing Ctrl+Shift+E.
+// All the real messaging code (NIP-17 wrap/unwrap, relay subscriptions,
+// persistent inbox, contacts) lives in Theseus main.js. Too much raw network
+// + crypto to run under the add-on sandbox, and the vault seed must never
+// cross the host/add-on boundary.
//
-// The panel (panel.html) is a small chrome: mark, "Open Messages" button,
-// shortcut hint. Click → forwarded through api.onMessage → an in-process
-// ipcMain.emit that main.js listens for and resolves to openHermesWindow().
-// Keeping the forwarding tiny: nothing crypto-sensitive flows through this
-// add-on, so a compromise of this one-handler surface can at worst open an
-// otherwise-openable window.
+// This file does two things:
+// 1. Register the left-strip sidebar entry (so Hermes is discoverable
+// without pressing Ctrl+Shift+E).
+// 2. Proxy a small, allow-listed set of panel operations between the
+// panel's silentmode.invoke() calls and main.js's `global._hermesAPI`.
+// The panel never sees ipcRenderer — only this named handler surface.
+//
+// Events coming the other way (inbox updates, relay status changes) are
+// fanned out via a shared ipcMain event bus `__hermes_bus`: main.js's
+// hermesBusEmit publishes, this file forwards each payload to the panel.
const { ipcMain } = require("electron");
+const PANEL_METHODS = [
+ "status", "canUseVault", "close", "init", "inbox", "send",
+ "markThreadRead", "contactsList", "contactsAdd", "contactsUpdate",
+ "contactsRemove",
+];
+
module.exports = {
activate(api) {
api.registerSidebarPanel({
@@ -26,13 +35,47 @@ module.exports = {
// declaring it in both keeps dormant vs active renders consistent.
side: "left",
});
+
+ // Legacy launcher behavior: panel.html can still ask to open the
+ // standalone Messages window. Kept because Ctrl+Shift+E users have
+ // muscle memory, and because a few flows (debug, multi-window)
+ // benefit from a free-floating window.
api.onMessage("open-messages", () => {
- // In-process emit: main.js listens on "hermes-open-request" and calls
- // openHermesWindow(). Using emit (not .send) because this is a
- // same-process signal, not an IPC to a renderer.
ipcMain.emit("hermes-open-request");
return { ok: true };
});
- api.log("registered hermes sidebar panel");
+
+ // Panel API proxies. Every method listed in PANEL_METHODS invokes the
+ // same-named function on global._hermesAPI. The main.js side defines
+ // that object once hermes is initialised; before that, status/canUseVault
+ // are the only safe methods and will return a stub-ok.
+ for (const name of PANEL_METHODS) {
+ api.onMessage(name, async (payload) => {
+ const hermes = global._hermesAPI;
+ if (!hermes || typeof hermes[name] !== "function") {
+ // Host not ready. Treat status/canUseVault as "no identity yet"
+ // so the panel can render its signin screen without a toast.
+ if (name === "status") return { ok: true, ready: false };
+ if (name === "canUseVault") return { ok: true, available: false };
+ return { ok: false, error: "hermes host not ready" };
+ }
+ return await hermes[name](payload);
+ });
+ }
+
+ // Main-side → panel event bus. hermesBusEmit in main.js fires events on
+ // ipcMain under __hermes_bus; forward each to the panel via api.emit so
+ // panel-side `silentmode.on("hermes-message", cb)` works identically to
+ // the ipcRenderer path used by the standalone window.
+ const busForward = (channel, payload) => {
+ try { api.emit(channel, payload); } catch {}
+ };
+ ipcMain.on("__hermes_bus", busForward);
+
+ api.onDeactivate && api.onDeactivate(() => {
+ try { ipcMain.off("__hermes_bus", busForward); } catch {}
+ });
+
+ api.log("hermes sidebar panel registered (proxy mode)");
},
};
diff --git a/bundled-addons/hermes/panel.html b/bundled-addons/hermes/panel.html
index acbc4fbc..30dd0e4d 100644
--- a/bundled-addons/hermes/panel.html
+++ b/bundled-addons/hermes/panel.html
@@ -5,63 +5,593 @@
Hermes
-
+
+
+
+
+
+
Hermes
+
+
—
+
+
+
+
+
+
+
Hermes
-
Message anyone by their .bch name. Sealed-sender DMs over Nostr.
-
-
or press Ctrl + Shift + E
-
+
End-to-end encrypted DMs. Your identity is your .bch name — same wallet seed as Theseus.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
—
+
+
+
+
+
Pick a conversation, or add a new one.
+
+
+
+
+
+
+
+
+
+
+
+
Start a new conversation
+
+
+
+
+
+
+
+
diff --git a/main.js b/main.js
index caa10cfd..b0ea2ac8 100644
--- a/main.js
+++ b/main.js
@@ -9781,6 +9781,11 @@ const hErr = (e) => ({ ok: false, err: String((e && e.message) || e) });
function hermesEmit(channel, payload) {
if (hermesWin && !hermesWin.isDestroyed()) hermesWin.webContents.send(channel, payload);
+ // Also fan out on an in-process bus so the bundled Hermes addon can forward
+ // the event to its sidebar panel. The addon listens on __hermes_bus and
+ // calls api.emit to deliver to the panel. Decoupled from hermesWin so the
+ // panel receives live updates even when the standalone window is closed.
+ try { ipcMain.emit("__hermes_bus", null, channel, payload); } catch {}
}
function hermesRecord(msg) {
hermesState.inbox.push(msg);
@@ -10138,6 +10143,168 @@ function parseVcardContacts(text) {
return out;
}
+// ---- Panel proxy ----------------------------------------------------------
+// Exposed to the bundled Hermes addon via global so the sidebar panel can
+// invoke hermes operations through silentmode.invoke(name) → api.onMessage
+// in the addon → these methods. Pre-existing ipcMain.handle wrappers above
+// remain for the standalone Messages window, which still uses them under
+// HERMES_ONLY gating. Bodies kept inline / duplicated with the handlers so
+// the refactor to a shared module stays optional.
+global._hermesAPI = {
+ status: () => {
+ if (!hermesState) return hOk({ ready: false });
+ let connected = 0;
+ for (const s of hermesState.relays.values()) if (s.ready) connected++;
+ return hOk({ ready: true, npub: hermesState.npub, pkHex: hermesState.pkHex,
+ relaysConnected: connected, relaysTotal: hermesState.relays.size });
+ },
+ canUseVault: () => hOk({ available: !!(vaultState && vaultState.messengerRoot) }),
+ close: () => { hermesTeardown(); return hOk(); },
+ inbox: () => hermesState
+ ? hOk({ messages: hermesState.inbox.slice(), readState: hermesState.readState || {} })
+ : hErr("not initialised"),
+ init: async (opts = {}) => {
+ try {
+ hermesTeardown();
+ const H = await loadHermesLib();
+ let sk, pkHex, npub;
+ 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));
+ } 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()));
+ }
+ 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,
+ readState: hermesLoadReadState(pkHex),
+ relays: new Map(), _seen: seen,
+ };
+ for (const url of HERMES_DEFAULT_RELAYS) {
+ hermesState.relays.set(url, await hermesConnectRelay(url));
+ }
+ 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 });
+ } catch (e) { hermesTeardown(); return hErr(e); }
+ },
+ markThreadRead: ({ 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 });
+ },
+ 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;
+ 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;
+ }
+ } 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 });
+ },
+ contactsUpdate: ({ 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");
+ 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 });
+ },
+ contactsRemove: ({ 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: async ({ to, text } = {}) => {
+ if (!hermesState) return hErr("not initialised");
+ 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;
+ }
+ const sk = Buffer.from(hermesState.skHex, "hex");
+ const wrap = H.wrapChat({ senderSk: sk, recipientPkHex, text });
+ const publishOne = (url) => new Promise((resolve) => {
+ const ws = new WebSocket(url);
+ let settled = false;
+ const done = (r) => { if (settled) return; settled = true; try { ws.close(1000); } catch {} resolve(r); };
+ const t = setTimeout(() => done({ url, ok: false, detail: "timeout" }), 8000);
+ ws.on("open", () => ws.send(JSON.stringify(["EVENT", wrap])));
+ ws.on("message", (buf) => {
+ let msg; try { msg = JSON.parse(buf.toString()); } catch { return; }
+ if (msg[0] === "OK" && msg[1] === wrap.id) {
+ clearTimeout(t);
+ done({ url, ok: !!msg[2], detail: msg[3] || "" });
+ }
+ });
+ ws.on("error", (e) => done({ url, ok: false, detail: "ws error: " + e.message }));
+ });
+ const results = await Promise.all(relays.map(publishOne));
+ const accepted = results.filter((r) => r.ok).length;
+ if (accepted > 0) {
+ const peerName = await hermesReverseResolve(recipientPkHex);
+ hermesRecord({
+ direction: "out", peerPkHex: recipientPkHex, peerName,
+ senderPkHex: hermesState.pkHex, senderName: null,
+ text, createdAt: Math.floor(Date.now() / 1000),
+ });
+ }
+ return hOk({ recipientPkHex, accepted, total: results.length, results });
+ } catch (e) { return hErr(e); }
+ },
+};
+
// Send: `to` is either a 64-char hex pubkey or a .bch name. Names are resolved
// via the portable resolver (getResolver above), the `np` record parsed, then
// wrapped + published to each relay listed in `nr` (falling back to defaults).