diff --git a/bundled-addons/hermes/addon.json b/bundled-addons/hermes/addon.json
new file mode 100644
index 00000000..77423984
--- /dev/null
+++ b/bundled-addons/hermes/addon.json
@@ -0,0 +1,14 @@
+{
+ "id": "hermes",
+ "name": "Hermes",
+ "version": "0.1.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+PGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZDZmZjNkIiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHJlY3QgeD0iMjAiIHk9IjIzIiB3aWR0aD0iMjQiIGhlaWdodD0iMTgiIHJ4PSIyIi8+PHBhdGggZD0iTTIwIDI1IEwzMiAzNSBMNDQgMjUiLz48cGF0aCBkPSJNMjAgMzAgTDcgMjciLz48cGF0aCBkPSJNMjAgMzQgTDggMzYiLz48cGF0aCBkPSJNNDQgMzAgTDU3IDI3Ii8+PHBhdGggZD0iTTQ0IDM0IEw1NiAzNiIvPjwvZz48L3N2Zz4K",
+ "main": "index.js",
+ "activation": "on-demand",
+ "panels": [
+ { "id": "main", "title": "Hermes", "icon": "data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgNjQgNjQiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZDZmZjNkIiBzdHJva2Utd2lkdGg9IjQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHJlY3QgeD0iMjAiIHk9IjIzIiB3aWR0aD0iMjQiIGhlaWdodD0iMTgiIHJ4PSIyIi8+PHBhdGggZD0iTTIwIDI1IEwzMiAzNSBMNDQgMjUiLz48cGF0aCBkPSJNMjAgMzAgTDcgMjciLz48cGF0aCBkPSJNMjAgMzQgTDggMzYiLz48cGF0aCBkPSJNNDQgMzAgTDU3IDI3Ii8+PHBhdGggZD0iTTQ0IDM0IEw1NiAzNiIvPjwvZz48L3N2Zz4K", "page": "panel.html" }
+ ],
+ "capabilities": ["sidebar-panel"]
+}
diff --git a/bundled-addons/hermes/index.js b/bundled-addons/hermes/index.js
new file mode 100644
index 00000000..d7ce993b
--- /dev/null
+++ b/bundled-addons/hermes/index.js
@@ -0,0 +1,33 @@
+// Hermes bundled add-on — thin launcher.
+//
+// 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.
+//
+// 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.
+const { ipcMain } = require("electron");
+
+module.exports = {
+ activate(api) {
+ api.registerSidebarPanel({
+ id: "main",
+ title: "Hermes",
+ page: "panel.html",
+ });
+ 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");
+ },
+};
diff --git a/bundled-addons/hermes/panel.html b/bundled-addons/hermes/panel.html
new file mode 100644
index 00000000..e7206938
--- /dev/null
+++ b/bundled-addons/hermes/panel.html
@@ -0,0 +1,69 @@
+
+
+
+
+Hermes
+
+
+
+
+
+
+
Hermes
+
Message anyone by their .bch name. Sealed-sender DMs over Nostr.
+
+
or press Ctrl + Shift + E
+
+
+
+
+
diff --git a/main.js b/main.js
index ede0e52f..6a1327a5 100644
--- a/main.js
+++ b/main.js
@@ -1004,6 +1004,7 @@ 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-contacts-import-pick",
"hermes-thread-mark-read",
]);
const SETTINGS_SHARED = new Set([
@@ -9542,7 +9543,7 @@ ipcMain.handle("switch-to-bcnr", () => {
});
// ---- Hermes messages panel -------------------------------------------------
-// A separate BrowserWindow (opens on Ctrl+Shift+M anywhere in Theseus). Uses
+// A separate BrowserWindow (opens on Ctrl+Shift+E anywhere in Theseus). Uses
// the wallet mnemonic to derive the Nostr identity in-memory only — the seed
// and secret key are never written to disk. The panel process holds one
// WebSocket per relay in HERMES_DEFAULT_RELAYS for the receive subscription,
@@ -9922,6 +9923,83 @@ ipcMain.handle("hermes-contacts-remove", (_e, { pkHex } = {}) => {
return hOk({ removed: before - hermesContactsCache.length, contacts: hermesContactsCache });
});
+// Address book import — strictly opt-in.
+//
+// The user picks a .vcf file. We read it, parse out candidate contacts
+// (anything carrying X-BNS or X-NOSTR fields, or a note mentioning *.bch /
+// npub1…), and return the candidates to the renderer for a preview + confirm
+// pass. Nothing is written to the contact store until the renderer calls
+// hermes-contacts-add per candidate — so the user sees exactly who gets added.
+//
+// We never phone home: parsing is local, the file path never leaves the
+// machine. Non-matching vCards are returned too (grey'd out in the UI) so
+// the user can decide to add them manually; nothing is auto-ignored.
+ipcMain.handle("hermes-contacts-import-pick", async () => {
+ try {
+ const r = await dialog.showOpenDialog(hermesWin || win, {
+ title: "Import contacts (vCard)",
+ filters: [{ name: "vCard", extensions: ["vcf", "vcard"] }, { name: "All files", extensions: ["*"] }],
+ properties: ["openFile"],
+ });
+ if (r.canceled || !r.filePaths?.length) return hOk({ candidates: [] });
+ const raw = fs.readFileSync(r.filePaths[0], "utf8");
+ return hOk({ candidates: parseVcardContacts(raw), fromFile: path.basename(r.filePaths[0]) });
+ } catch (e) { return hErr(e); }
+});
+
+// Minimal RFC 6350 subset: BEGIN:VCARD / END:VCARD blocks, FN / N / NOTE,
+// plus the two custom extensions we care about:
+// X-BNS-NAME:alice.bch
+// X-NOSTR-NPUB:npub1… (or X-NOSTR-PUBKEY:<64-hex>)
+// Also surfaces anything in NOTE that looks like a .bch name or an npub so
+// a user whose vCards came from another messenger can still carry contacts.
+function parseVcardContacts(text) {
+ const BNS_RE = /\b([a-z0-9-]{1,32}(?:\.[a-z0-9-]{1,16})?)\.bch\b/i;
+ const NPUB_RE = /\b(npub1[0-9a-z]{58,})\b/i;
+ const HEX_RE = /\b([0-9a-f]{64})\b/i;
+ const out = [];
+ // RFC 6350 says vCards can contain unfolded continuations starting with a
+ // space; join them before splitting by line.
+ const unfolded = text.replace(/\r?\n[ \t]/g, "");
+ const blocks = unfolded.split(/BEGIN:VCARD/i).slice(1);
+ for (const b of blocks) {
+ const body = b.split(/END:VCARD/i)[0] || "";
+ const fields = {};
+ for (const line of body.split(/\r?\n/)) {
+ const m = /^([A-Z0-9-]+)(?:;[^:]*)?:(.*)$/i.exec(line.trim());
+ if (!m) continue;
+ const key = m[1].toUpperCase();
+ // Multi-valued fields: keep the first for simplicity.
+ if (!(key in fields)) fields[key] = m[2];
+ }
+ const fn = (fields.FN || fields.N || "").trim();
+ let bnsName = (fields["X-BNS-NAME"] || fields["X-BNS"] || "").trim();
+ let npub = (fields["X-NOSTR-NPUB"] || fields["X-NOSTR"] || "").trim();
+ let hex = (fields["X-NOSTR-PUBKEY"] || fields["X-NOSTR-HEX"] || "").trim();
+ const note = (fields.NOTE || "").trim();
+ if (!bnsName && note) {
+ const m = BNS_RE.exec(note); if (m) bnsName = m[0];
+ }
+ if (!npub && note) {
+ const m = NPUB_RE.exec(note); if (m) npub = m[1];
+ }
+ if (!hex && note) {
+ const m = HEX_RE.exec(note); if (m) hex = m[1];
+ }
+ const identifier = bnsName || npub || hex || null;
+ if (!fn && !identifier) continue;
+ out.push({
+ displayName: fn || null,
+ bnsName: bnsName || null,
+ npub: npub || null,
+ hex: hex || null,
+ identifier, // what to pass to hermes-contacts-add
+ importable: !!identifier,
+ });
+ }
+ return out;
+}
+
// 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).
@@ -10117,6 +10195,13 @@ function openHermesWindow() {
ipcMain.handle("hermes-open", () => { openHermesWindow(); return { ok: true }; });
+// In-process signal fired by the Hermes bundled add-on (bundled-addons/hermes).
+// Add-ons run under the sandbox and can't call openHermesWindow directly, so
+// they emit this and we resolve it here. Separate from the IPC channel so the
+// HERMES_ONLY gate doesn't need to grow a sender exception for sandboxed add-on
+// code — ipcMain.emit never carries a webContents sender.
+ipcMain.on("hermes-open-request", () => { try { openHermesWindow(); } catch {} });
+
// 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.
@@ -10275,8 +10360,10 @@ app.on("web-contents-created", (_event, wc) => {
// popup belongs to one browser window).
wc.on("before-input-event", (e, input) => withWin(winOfWebContents(wc), () => {
if (input.type !== "keyDown") return;
- // Ctrl+Shift+M -> Messages panel
- if (input.control && input.shift && (input.key === "M" || input.key === "m")) {
+ // Ctrl+Shift+E -> Messages (Hermes / Ermis, Greek Ἑρμῆς). Picked E over the
+ // old M because Ctrl+Shift+M is Chrome's "Switch profile" (users muscle-
+ // memory it) and because E matches the Greek spelling.
+ if (input.control && input.shift && (input.key === "E" || input.key === "e")) {
openHermesWindow();
return e.preventDefault();
}
@@ -10396,7 +10483,7 @@ 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
+ // Ctrl+Shift+E. 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");
diff --git a/messages-preload.js b/messages-preload.js
index 0abdc54e..5bbebe00 100644
--- a/messages-preload.js
+++ b/messages-preload.js
@@ -22,6 +22,11 @@ contextBridge.exposeInMainWorld("hermes", {
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 }),
+ // Opt-in import: opens an OS file-picker for a .vcf. Returns parsed
+ // candidates for the renderer to preview; nothing is added without a
+ // follow-up add() call per contact. Convenience only — the full add
+ // flow (manual +) is never gated behind this.
+ importPick: () => ipcRenderer.invoke("hermes-contacts-import-pick"),
},
// Pushes: new decrypted message / relay status change.
diff --git a/messages.html b/messages.html
index ed2f6c70..c9635255 100644
--- a/messages.html
+++ b/messages.html
@@ -235,9 +235,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Import contacts
+
Nothing is sent anywhere. Pick who you want to add; the rest are ignored.