From 8eae448ddeda913377879fa5147da0e4ef9a621a Mon Sep 17 00:00:00 2001 From: Local Dev Date: Wed, 21 Oct 2026 00:45:25 +0300 Subject: [PATCH] Hermes: Ermis shortcut (Ctrl+Shift+E), sidebar add-on, vCard import, brand kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shortcut: Ctrl+Shift+M -> Ctrl+Shift+E. Chrome owns Ctrl+Shift+M as 'switch profile' and E matches the Greek spelling (Ermis). Sidebar discoverability: new bundled add-on at bundled-addons/hermes/ registers a left-strip icon + launcher panel. The panel is a thin chrome (mark + 'Open Messages' + shortcut hint) that forwards clicks through api.onMessage -> ipcMain.emit('hermes-open-request') -> openHermesWindow. All real messaging code stays in main.js — add-on is purely for discovery. Address book: opt-in vCard (.vcf) import. Dialog in the Add Contact modal lets the user pick a file; main.js parses BEGIN:VCARD blocks for X-BNS-NAME / X-NOSTR-NPUB / X-NOSTR-PUBKEY plus .bch names and npubs mentioned in NOTE fields. Candidates are previewed per-contact with checkboxes; nothing is added until the user confirms. Non-matching vCards are returned too (so the user can still add manually) rather than silently dropped. Nothing is uploaded; parsing is local. site/hermes: status badge flipped from 'designed - not yet built' to 'MVP - desktop'. New 'What ships today' grid covers threaded chat, send by name, address book, local history (all live) and mobile + Tor (next / planned). Hero carries the new Ctrl+Shift+E shortcut and a link to /hermes/branding. site/hermes/branding: new page. Mark SVG (winged envelope, two variants for 16-20px and 24-160px display sizes), palette (reuses Silent Mode tokens + an amber accent that's distinctly warm-yellow, not Hermes-orange — so it never collides with Hermès the fashion house), name explanation (English Hermes / Greek Ermis), voice notes, do/don't table, MPL-2.0 attribution. Hermes/MOBILE-RECOMMENDATION.md: analysis of three mobile paths (inside Ariadne / standalone / hybrid). Recommendation is Option 1: ship as a screen inside Ariadne. Reuses the wallet seed, aligns with the design doc's 'one seed many purposes' architecture, lowest total cost. --- bundled-addons/hermes/addon.json | 14 +++++ bundled-addons/hermes/index.js | 33 +++++++++++ bundled-addons/hermes/panel.html | 69 +++++++++++++++++++++++ main.js | 95 ++++++++++++++++++++++++++++++-- messages-preload.js | 5 ++ messages.html | 81 ++++++++++++++++++++++++++- 6 files changed, 291 insertions(+), 6 deletions(-) create mode 100644 bundled-addons/hermes/addon.json create mode 100644 bundled-addons/hermes/index.js create mode 100644 bundled-addons/hermes/panel.html 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 @@
+
+ +
+ + +
+
+ + + + + @@ -459,6 +480,62 @@ $("addSave").addEventListener("click", async () => { if (res.contact && res.contact.pkHex) selectThread(res.contact.pkHex); }); +// ---- vCard import flow (opt-in) ---- +$("importBtn").addEventListener("click", async () => { + $("addErr").textContent = ""; + const res = await hermes.contacts.importPick(); + if (!res.ok) { $("addErr").textContent = "import: " + res.err; return; } + const candidates = res.candidates || []; + if (!candidates.length) { $("addErr").textContent = "nothing to import from that file"; return; } + // Switch to the import preview modal. + $("addModal").classList.remove("show"); + $("importFrom").textContent = res.fromFile ? "from " + res.fromFile : ""; + $("importList").innerHTML = ""; + $("importErr").textContent = ""; + const importable = candidates.filter((c) => c.importable); + if (!importable.length) { + $("importList").innerHTML = '
No contacts in that file carry a .bch name or Nostr pubkey.
You can still add them manually one at a time.
'; + } else { + for (const c of importable) { + const row = document.createElement("label"); + row.style.cssText = "display:flex; gap:10px; padding:8px 10px; align-items:center; cursor:pointer; border-radius:6px;"; + const cb = document.createElement("input"); + cb.type = "checkbox"; cb.checked = true; + cb.dataset.identifier = c.identifier; + cb.dataset.nickname = c.displayName || ""; + const meta = document.createElement("div"); + meta.style.cssText = "flex:1; min-width:0"; + const title = document.createElement("div"); + title.style.cssText = "font-weight:500; font-size:13px; color:var(--ink); overflow:hidden; text-overflow:ellipsis; white-space:nowrap"; + title.textContent = c.displayName || c.identifier; + const sub = document.createElement("div"); + sub.style.cssText = "font-size:11px; color:var(--mut); font-family:ui-monospace,monospace; overflow:hidden; text-overflow:ellipsis; white-space:nowrap"; + sub.textContent = c.identifier; + meta.appendChild(title); meta.appendChild(sub); + row.appendChild(cb); row.appendChild(meta); + $("importList").appendChild(row); + } + } + $("importModal").classList.add("show"); +}); +$("importCancel").addEventListener("click", () => $("importModal").classList.remove("show")); +$("importConfirm").addEventListener("click", async () => { + const picked = Array.from($("importList").querySelectorAll('input[type=checkbox]:checked')); + if (!picked.length) { $("importErr").textContent = "nothing selected"; return; } + $("importConfirm").disabled = true; + let added = 0, failed = 0; + for (const cb of picked) { + const res = await hermes.contacts.add(cb.dataset.identifier, cb.dataset.nickname || null); + if (res && res.ok) added++; else failed++; + } + $("importConfirm").disabled = false; + $("importErr").textContent = `added ${added}, failed ${failed}`; + if (added) await reloadAll(); + if (added && !failed) { + setTimeout(() => $("importModal").classList.remove("show"), 1200); + } +}); + $("btnRemoveContact").addEventListener("click", async () => { if (!state.selectedPk) return; if (!confirm("Remove this contact? Message history for this thread stays on disk.")) return;