Hermes: Ermis shortcut (Ctrl+Shift+E), sidebar add-on, vCard import, brand kit

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.
This commit is contained in:
Local Dev 2026-10-21 00:45:25 +03:00
parent 2821453278
commit 8eae448dde
6 changed files with 291 additions and 6 deletions

View file

@ -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"]
}

View file

@ -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");
},
};

View file

@ -0,0 +1,69 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hermes</title>
<style>
:root { color-scheme: light dark;
--bg:#0e131c; --panel:#141a24; --line:rgba(255,255,255,.09);
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d;
--amber:#f4c430; }
@media (prefers-color-scheme: light) {
:root { --bg:#f8faff; --panel:#ffffff; --line:rgba(0,0,0,.10);
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5;
--acid:#088A66; }
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body { background: var(--bg); color: var(--ink);
font: 14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
display: flex; flex-direction: column; align-items: center; justify-content: center;
padding: 24px 20px; text-align: center; gap: 14px; }
.mark { color: var(--acid); line-height: 0; }
.mark svg { width: 72px; height: 72px; display: block; }
h1 { margin: 0; font-size: 18px; font-weight: 600; letter-spacing: .3px; }
.lede { color: var(--mut); font-size: 13px; margin: 0; max-width: 260px; }
button { font: inherit; font-weight: 600; cursor: pointer;
background: var(--acid); color: #0a0d13; border: none;
padding: 10px 20px; border-radius: 999px; }
button:hover { filter: brightness(1.08); }
.shortcut { color: var(--dim); font-size: 11.5px; margin-top: 4px; }
.shortcut kbd { background: var(--panel); border: 1px solid var(--line);
border-radius: 4px; padding: 1px 6px; font-family: ui-monospace, monospace;
font-size: 10.5px; color: var(--ink); }
.err { color: #f6768a; font-size: 12px; min-height: 1em; }
</style>
</head>
<body>
<div class="mark" aria-hidden="true">
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
<g fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round">
<rect x="20" y="23" width="24" height="18" rx="2"/>
<path d="M20 25 L32 35 L44 25"/>
<path d="M20 30 L7 27"/>
<path d="M20 34 L8 36"/>
<path d="M44 30 L57 27"/>
<path d="M44 34 L56 36"/>
</g>
</svg>
</div>
<h1>Hermes</h1>
<p class="lede">Message anyone by their <code>.bch</code> name. Sealed-sender DMs over Nostr.</p>
<button id="openBtn">Open Messages</button>
<div class="shortcut">or press <kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>E</kbd></div>
<div class="err" id="err"></div>
<script>
const bridge = window.silentmode && typeof window.silentmode.invoke === "function" ? window.silentmode : null;
const err = (m) => { document.getElementById("err").textContent = m || ""; };
document.getElementById("openBtn").addEventListener("click", async () => {
err("");
if (!bridge) { err("sidebar bridge unavailable"); return; }
try {
const r = await bridge.invoke("open-messages");
if (!r || r.ok !== true) err("could not open messages window");
} catch (e) { err("error: " + (e && e.message || e)); }
});
</script>
</body>
</html>

95
main.js
View file

@ -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");

View file

@ -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.

View file

@ -235,9 +235,30 @@
<input id="addNick" placeholder="Alice" autocomplete="off">
</label>
<div id="addErr"></div>
<div class="buttons" style="justify-content:space-between">
<button class="ghost" id="importBtn" title="Import contacts from a .vcf file">Import vCard…</button>
<div style="display:flex; gap:8px">
<button class="ghost" id="addCancel">Cancel</button>
<button id="addSave">Add</button>
</div>
</div>
</div>
</div>
<!-- Import preview modal: a reviewable list of parsed contacts. Each row is
user-togglable; nothing is added without an explicit "Import N contacts" click.
This is the "opt-in and reviewable" part of the address-book integration. -->
<div class="modal-bg" id="importModal">
<div class="modal" style="width:520px">
<h2>Import contacts <span id="importFrom" style="font-size:11px; color:var(--mut); font-weight:400"></span></h2>
<p class="lede" style="font-size:12px">Nothing is sent anywhere. Pick who you want to add; the rest are ignored.</p>
<div id="importList" style="max-height:300px; overflow:auto; margin:10px 0; background:var(--panel2); border:1px solid var(--line); border-radius:8px; padding:4px">
<div class="empty" style="padding:20px">No importable contacts found.</div>
</div>
<div id="importErr" style="color:var(--err); font-size:12px; min-height:1em"></div>
<div class="buttons">
<button class="ghost" id="addCancel">Cancel</button>
<button id="addSave">Add</button>
<button class="ghost" id="importCancel">Close</button>
<button id="importConfirm">Import selected</button>
</div>
</div>
</div>
@ -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 = '<div class="empty" style="padding:20px">No contacts in that file carry a .bch name or Nostr pubkey.<br><small>You can still add them manually one at a time.</small></div>';
} 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;