The Hermes sidebar panel from TheseusNavigator/bundled-addons/hermes/
runs as a web page here with no changes to the UI code — all the host
API it needs (bridge.invoke, bridge.on, storage) is provided by the
shim.js next to it. The two pieces together give visitors without
Theseus a working browser messenger: paste an nsec or 12/24-word
mnemonic, resolve a .x name on-chain, send a NIP-17 gift-wrapped DM
over a WebSocket to the recipient's declared relays.
What landed:
- index.html — copy of the panel's panel.html with the site topnav
wrapped above it (so Overview / Branding / home stay reachable) and
the panel's own .bar + .screen in a .panel-root flex container. The
.screen stays position:absolute inset:44px 0 0 0 — just anchored to
the panel root now instead of the viewport. Shared.css provides the
topnav styling; the panel's inline <style> keeps the rest. Three
assets/mark.svg references rewritten to ../assets/mark.svg so the
injected <base href="/bns/hermes.x/messenger/"> resolves them to the
site-root assets/ directory, not messenger/assets/.
- shim.js — ~540 lines implementing window.silentmode.{invoke,on,off,
storage,sidebar}. invoke handlers: init(nsec | mnemonic | create),
status, inbox, send, contactsList, contactsAdd, markThreadRead,
getProfile (stub), signOut, canUseVault (always false in a browser),
phone/backup/updateProfile stubs for the Theseus-only paths. Crypto
is pure nostr-tools from jsdelivr ESM — nip44 v2 for the content key,
wrapChat / unwrapChat mirroring TheseusNavigator/lib/hermes.js exactly.
Keys: nsec1 via nip19, BIP-39 via WebCrypto PBKDF2+HKDF with info
"silentmode/messenger/0" (same path as the Theseus host).
Name resolution hits /api/name/<name> for np + nr; the shim parses np
as hex(64) or npub1, nr as whitespace/comma-separated wss:// URLs,
with wss://nos.lol as the default when the owner publishes no nr.
localStorage falls back to an in-memory Map when it throws — the
navigate.st relay serves BNS sites under a CSP sandbox without
allow-same-origin, which disables localStorage. Signed in state
survives the tab's lifetime; reload re-prompts for the key. Loaded
outside the sandbox (Theseus direct origin), localStorage works and
state persists across sessions.
536 lines
22 KiB
JavaScript
536 lines
22 KiB
JavaScript
// Browser host-shim for the Hermes messenger panel.
|
|
//
|
|
// The panel UI (panel.html inside Theseus) talks to the Theseus main process
|
|
// over an IPC bridge exposed as window.silentmode. This shim re-implements
|
|
// that bridge in a plain browser tab: NIP-17 wrap/unwrap with nostr-tools,
|
|
// Nostr pubkey + relay lookup via navigate.st/api/name, inbox + contacts in
|
|
// localStorage.
|
|
//
|
|
// Why this works at all: the panel calls bridge.invoke(name, payload) and
|
|
// awaits a promise, and subscribes with bridge.on(channel, cb). Every op is
|
|
// a pure function of the panel's state, the user's secret key, and a WSS
|
|
// connection — no Node, no filesystem, no Electron. The real Theseus host
|
|
// adds vault binding, mnemonic persistence and backup; the browser can
|
|
// never promise any of that, so the shim stubs those paths and surfaces an
|
|
// honest error when the panel asks.
|
|
//
|
|
// Scope v0 (2026-10-21):
|
|
// - nsec1… paste and BIP-39 12/24-word paste both go in `mnemonicInput`
|
|
// - one default relay: wss://nos.lol (matches Theseus HERMES_DEFAULT_RELAYS)
|
|
// - send resolves `alice.x` → np+nr via /api/name
|
|
// - receive via a single long-lived REQ subscription
|
|
// - inbox + contacts in localStorage, scoped by `hermes.<pkHex>.*`
|
|
// - no profile fetch (truncated pkHex shows); phone/backup/vault stubbed
|
|
//
|
|
// Keep this file plain classic script. The panel's own <script> is inline,
|
|
// so window.silentmode has to exist by the time this HTML parses it. The
|
|
// nostr-tools import is lazy via dynamic import() inside init() — that way
|
|
// the module load happens AFTER html parse but BEFORE the user has had time
|
|
// to paste a key.
|
|
(function () {
|
|
"use strict";
|
|
|
|
const GATEWAY = location.origin; // navigate.st in prod
|
|
const DEFAULT_RELAYS = ["wss://nos.lol"];
|
|
const HERMES_PURPOSE = "silentmode/messenger/0";
|
|
const KIND_CHAT = 14, KIND_SEAL = 13, KIND_GIFT_WRAP = 1059;
|
|
const TWO_DAYS_S = 2 * 24 * 60 * 60;
|
|
|
|
// ---- module state ------------------------------------------------------
|
|
let nt = null; // nostr-tools module, lazy-loaded
|
|
let identity = null; // { sk: Uint8Array, pkHex, npub }
|
|
let relays = DEFAULT_RELAYS.slice(); // live set after init
|
|
let sockets = new Map(); // url -> WebSocket (receive subs)
|
|
let seenWrapIds = new Set(); // guard against duplicate inserts
|
|
const emitter = new EventTarget(); // panel subscriptions
|
|
const skeyFor = (pkHex) => `hermes.${pkHex}`;
|
|
|
|
// The navigate.st relay serves BNS sites under a CSP sandbox without
|
|
// allow-same-origin, which disables localStorage. That is a deliberate
|
|
// sealed-view policy, not a bug: a page that cannot persist a key cannot
|
|
// leak one on close. We fall back to an in-memory store so the panel keeps
|
|
// working for the tab's lifetime; reload clears everything. When the same
|
|
// page is loaded inside Theseus (direct origin), localStorage works and
|
|
// state persists per-pubkey across sessions.
|
|
const memStore = new Map();
|
|
const hasLocalStorage = (() => {
|
|
try { localStorage.setItem("__hs_probe", "1"); localStorage.removeItem("__hs_probe"); return true; }
|
|
catch { return false; }
|
|
})();
|
|
const storeGet = (k) => {
|
|
if (hasLocalStorage) { try { return localStorage.getItem(k); } catch { return null; } }
|
|
return memStore.has(k) ? memStore.get(k) : null;
|
|
};
|
|
const storeSet = (k, v) => {
|
|
if (hasLocalStorage) { try { localStorage.setItem(k, v); return; } catch {} }
|
|
memStore.set(k, v);
|
|
};
|
|
|
|
// ---- lazy nostr-tools --------------------------------------------------
|
|
async function loadNT() {
|
|
if (nt) return nt;
|
|
nt = await import("https://cdn.jsdelivr.net/npm/nostr-tools@2.10.4/+esm");
|
|
return nt;
|
|
}
|
|
|
|
// ---- key derivation (BIP-39 + HKDF) via WebCrypto ---------------------
|
|
const enc = new TextEncoder();
|
|
async function bip39ToSeed(mnemonic, passphrase = "") {
|
|
const km = await crypto.subtle.importKey("raw",
|
|
enc.encode(String(mnemonic).normalize("NFKD").trim()),
|
|
"PBKDF2", false, ["deriveBits"]);
|
|
const salt = enc.encode(("mnemonic" + String(passphrase || "")).normalize("NFKD"));
|
|
return new Uint8Array(await crypto.subtle.deriveBits(
|
|
{ name: "PBKDF2", salt, iterations: 2048, hash: "SHA-512" }, km, 512));
|
|
}
|
|
async function seedToPurposeRoot(seedBytes, purpose) {
|
|
const key = await crypto.subtle.importKey("raw", seedBytes, "HKDF", false, ["deriveBits"]);
|
|
const info = enc.encode(`silentmode/${purpose}`);
|
|
return new Uint8Array(await crypto.subtle.deriveBits(
|
|
{ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info }, key, 256));
|
|
}
|
|
|
|
// ---- parse the input the user pasted ----------------------------------
|
|
// Three forms accepted:
|
|
// • nsec1… → NIP-19 decode → 32-byte sk
|
|
// • 12/24 BIP-39 words → PBKDF2+HKDF path (matches Theseus's
|
|
// nostrKeyFromMnemonic with purpose "messenger/0")
|
|
// • 64-char hex → raw sk, no decoding
|
|
async function parseIdentityInput(input) {
|
|
await loadNT();
|
|
const v = String(input || "").trim();
|
|
if (!v) throw new Error("paste an nsec1… or a 12/24-word mnemonic");
|
|
if (/^nsec1[0-9a-z]+$/i.test(v)) {
|
|
const d = nt.nip19.decode(v);
|
|
if (d.type !== "nsec" || !(d.data instanceof Uint8Array) || d.data.length !== 32) {
|
|
throw new Error("nsec decoded to unexpected shape");
|
|
}
|
|
return d.data;
|
|
}
|
|
if (/^[0-9a-f]{64}$/i.test(v)) {
|
|
const hex = v.toLowerCase();
|
|
const out = new Uint8Array(32);
|
|
for (let i = 0; i < 32; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16);
|
|
return out;
|
|
}
|
|
const words = v.split(/\s+/).filter(Boolean);
|
|
if (words.length === 12 || words.length === 15 || words.length === 18 ||
|
|
words.length === 21 || words.length === 24) {
|
|
const seed = await bip39ToSeed(v, "");
|
|
return await seedToPurposeRoot(seed, "messenger/0");
|
|
}
|
|
throw new Error("expected an nsec1… key or 12/24-word mnemonic");
|
|
}
|
|
|
|
// ---- relay socket pool -------------------------------------------------
|
|
// One socket per relay. On open we send our REQ subscription for inbound
|
|
// kind:1059 gift wraps addressed to our pubkey. Reconnect on close with
|
|
// simple backoff — browser tabs that sleep still re-establish on wake.
|
|
function openRelay(url) {
|
|
if (sockets.has(url)) return sockets.get(url);
|
|
const sub = { url, ws: null, backoff: 1000, connected: false };
|
|
const connect = () => {
|
|
try {
|
|
const ws = new WebSocket(url);
|
|
sub.ws = ws;
|
|
ws.addEventListener("open", () => {
|
|
sub.connected = true;
|
|
sub.backoff = 1000;
|
|
emitStatus();
|
|
if (!identity) return;
|
|
ws.send(JSON.stringify(["REQ", "hermes-inbox", {
|
|
kinds: [KIND_GIFT_WRAP],
|
|
"#p": [identity.pkHex],
|
|
// The 2-day jitter means wraps for events sent "now" can carry
|
|
// created_at up to TWO_DAYS_S in the past. Ask for ~7 days of
|
|
// backfill on open so a reconnect doesn't lose recent messages.
|
|
since: Math.floor(Date.now() / 1000) - 7 * 24 * 60 * 60,
|
|
}]));
|
|
});
|
|
ws.addEventListener("message", (ev) => onRelayMessage(url, ev.data));
|
|
ws.addEventListener("close", () => {
|
|
sub.connected = false; sub.ws = null;
|
|
emitStatus();
|
|
setTimeout(connect, sub.backoff);
|
|
sub.backoff = Math.min(30000, sub.backoff * 2);
|
|
});
|
|
ws.addEventListener("error", () => { try { ws.close(); } catch {} });
|
|
} catch (e) {
|
|
setTimeout(connect, sub.backoff);
|
|
sub.backoff = Math.min(30000, sub.backoff * 2);
|
|
}
|
|
};
|
|
connect();
|
|
sockets.set(url, sub);
|
|
return sub;
|
|
}
|
|
function closeAllRelays() {
|
|
for (const sub of sockets.values()) {
|
|
try { sub.ws && sub.ws.close(1000); } catch {}
|
|
}
|
|
sockets.clear();
|
|
}
|
|
|
|
async function onRelayMessage(url, data) {
|
|
let msg; try { msg = JSON.parse(data); } catch { return; }
|
|
if (!Array.isArray(msg)) return;
|
|
if (msg[0] === "EVENT" && msg[2] && msg[2].kind === KIND_GIFT_WRAP) {
|
|
await handleWrap(msg[2]);
|
|
}
|
|
// NOTICE / OK / EOSE / CLOSED all ignored for v0
|
|
}
|
|
|
|
async function handleWrap(wrap) {
|
|
if (!identity) return;
|
|
if (seenWrapIds.has(wrap.id)) return;
|
|
seenWrapIds.add(wrap.id);
|
|
try {
|
|
await loadNT();
|
|
const { getPublicKey, verifyEvent, nip44 } = nt;
|
|
const convKey = (sk, pkHex) => nip44.v2.utils.getConversationKey(sk, pkHex);
|
|
const decrypt = (sk, pkHex, cipher) => nip44.v2.decrypt(cipher, convKey(sk, pkHex));
|
|
|
|
if (!verifyEvent(wrap)) throw new Error("gift wrap signature invalid");
|
|
const seal = JSON.parse(decrypt(identity.sk, wrap.pubkey, wrap.content));
|
|
if (seal.kind !== KIND_SEAL) throw new Error("inner not a seal");
|
|
if (!verifyEvent(seal)) throw new Error("seal signature invalid");
|
|
const rumor = JSON.parse(decrypt(identity.sk, seal.pubkey, seal.content));
|
|
if (rumor.kind !== KIND_CHAT) throw new Error("inner not a chat");
|
|
if (rumor.pubkey !== seal.pubkey) throw new Error("rumor pubkey mismatch");
|
|
|
|
recordMessage({
|
|
id: wrap.id,
|
|
peerPkHex: rumor.pubkey.toLowerCase(),
|
|
senderPkHex: rumor.pubkey.toLowerCase(),
|
|
meHex: identity.pkHex,
|
|
text: String(rumor.content || ""),
|
|
createdAt: Number(rumor.created_at) || 0,
|
|
direction: "in",
|
|
});
|
|
emitter.dispatchEvent(new CustomEvent("hermes-message", { detail: {} }));
|
|
} catch (e) {
|
|
// A relay serves everyone's wraps; most aren't ours. Decrypt errors on
|
|
// other-recipient wraps are the common case here, not an app bug.
|
|
}
|
|
}
|
|
|
|
// ---- storage helpers ---------------------------------------------------
|
|
function loadInbox() {
|
|
if (!identity) return [];
|
|
try { return JSON.parse(storeGet(skeyFor(identity.pkHex) + ".inbox") || "[]"); }
|
|
catch { return []; }
|
|
}
|
|
function saveInbox(list) {
|
|
if (!identity) return;
|
|
try { storeSet(skeyFor(identity.pkHex) + ".inbox", JSON.stringify(list)); } catch {}
|
|
}
|
|
function recordMessage(m) {
|
|
const inbox = loadInbox();
|
|
if (inbox.some((x) => x.id === m.id)) return;
|
|
inbox.push(m);
|
|
inbox.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
|
|
saveInbox(inbox);
|
|
}
|
|
function loadContacts() {
|
|
if (!identity) return [];
|
|
try { return JSON.parse(storeGet(skeyFor(identity.pkHex) + ".contacts") || "[]"); }
|
|
catch { return []; }
|
|
}
|
|
function saveContacts(list) {
|
|
if (!identity) return;
|
|
try { storeSet(skeyFor(identity.pkHex) + ".contacts", JSON.stringify(list)); } catch {}
|
|
}
|
|
function loadReadState() {
|
|
if (!identity) return {};
|
|
try { return JSON.parse(storeGet(skeyFor(identity.pkHex) + ".read") || "{}"); }
|
|
catch { return {}; }
|
|
}
|
|
function saveReadState(st) {
|
|
if (!identity) return;
|
|
try { storeSet(skeyFor(identity.pkHex) + ".read", JSON.stringify(st)); } catch {}
|
|
}
|
|
|
|
// ---- name resolution ---------------------------------------------------
|
|
// /api/name/<label> returns the chain entry (records.np, records.nr live
|
|
// alongside h/u/s3/ip/p). The signed-manifest apex merge is server-side,
|
|
// so a response here already reflects anything the owner published off-chain.
|
|
async function resolveTarget(input) {
|
|
await loadNT();
|
|
const v = String(input || "").trim();
|
|
if (!v) throw new Error("empty recipient");
|
|
if (/^[0-9a-f]{64}$/i.test(v)) {
|
|
return { pkHex: v.toLowerCase(), relays: relays.slice(), canonicalName: null };
|
|
}
|
|
if (/^npub1[0-9a-z]+$/i.test(v)) {
|
|
const d = nt.nip19.decode(v);
|
|
if (d.type !== "npub" || typeof d.data !== "string") throw new Error("bad npub");
|
|
return { pkHex: d.data.toLowerCase(), relays: relays.slice(), canonicalName: null };
|
|
}
|
|
const lower = v.replace(/^hermes:/i, "").toLowerCase();
|
|
const name = /\./.test(lower) ? lower : (lower + ".x");
|
|
const r = await fetch(`${GATEWAY}/api/name/${encodeURIComponent(name)}`,
|
|
{ signal: AbortSignal.timeout(5000) });
|
|
if (!r.ok) throw new Error(`${name}: no on-chain registration`);
|
|
const j = await r.json();
|
|
const rec = (j && j.records) || {};
|
|
const np = rec.np ? parseNp(rec.np) : null;
|
|
if (!np) throw new Error(`${name}: no np record on chain (owner hasn't published a Nostr key)`);
|
|
const nr = parseNr(rec.nr, relays);
|
|
return { pkHex: np, relays: nr, canonicalName: name };
|
|
}
|
|
function parseNp(raw) {
|
|
const v = String(raw || "").trim();
|
|
if (/^[0-9a-f]{64}$/i.test(v)) return v.toLowerCase();
|
|
if (/^npub1[0-9a-z]+$/i.test(v)) {
|
|
const d = nt.nip19.decode(v);
|
|
if (d.type === "npub" && typeof d.data === "string") return d.data.toLowerCase();
|
|
}
|
|
return null;
|
|
}
|
|
function parseNr(raw, defaults) {
|
|
if (!raw || typeof raw !== "string") return defaults.slice();
|
|
const out = []; const seen = new Set();
|
|
for (const tok of raw.split(/[,\s]+/)) {
|
|
const url = tok.trim();
|
|
if (!/^wss?:\/\//i.test(url)) continue;
|
|
if (seen.has(url)) continue;
|
|
seen.add(url); out.push(url);
|
|
}
|
|
return out.length ? out : defaults.slice();
|
|
}
|
|
|
|
// ---- status / emit -----------------------------------------------------
|
|
function status() {
|
|
const conns = [...sockets.values()];
|
|
const connected = conns.filter((s) => s.connected).length;
|
|
return {
|
|
ok: true,
|
|
ready: !!identity,
|
|
pkHex: identity && identity.pkHex,
|
|
npub: identity && identity.npub,
|
|
handle: null,
|
|
// Panel reads flat relaysConnected / relaysTotal; keep the nested
|
|
// relays object for any future caller that wants structured data too.
|
|
relaysConnected: connected,
|
|
relaysTotal: conns.length,
|
|
relays: { total: conns.length, connected },
|
|
};
|
|
}
|
|
function emitStatus() {
|
|
emitter.dispatchEvent(new CustomEvent("hermes-status-update", { detail: status() }));
|
|
}
|
|
|
|
// ---- invoke handlers ---------------------------------------------------
|
|
const handlers = {
|
|
async canUseVault() {
|
|
// There is no Theseus vault in a plain browser tab. Panel disables the
|
|
// vault button when `available` is false.
|
|
return { available: false };
|
|
},
|
|
|
|
async status() {
|
|
return status();
|
|
},
|
|
|
|
async init(opts) {
|
|
try {
|
|
let sk;
|
|
if (opts && opts.create) {
|
|
await loadNT();
|
|
sk = nt.generateSecretKey();
|
|
} else {
|
|
sk = await parseIdentityInput(opts && opts.mnemonic);
|
|
}
|
|
await loadNT();
|
|
const pkHex = nt.getPublicKey(sk);
|
|
identity = { sk, pkHex, npub: nt.nip19.npubEncode(pkHex) };
|
|
// One socket per relay, pulls in since-7d backlog on open
|
|
for (const url of relays) openRelay(url);
|
|
emitStatus();
|
|
return { ok: true, pkHex: identity.pkHex, npub: identity.npub, handle: null };
|
|
} catch (e) {
|
|
return { ok: false, err: (e && e.message) || String(e) };
|
|
}
|
|
},
|
|
|
|
async signOut() {
|
|
identity = null;
|
|
closeAllRelays();
|
|
seenWrapIds = new Set();
|
|
emitStatus();
|
|
return { ok: true };
|
|
},
|
|
|
|
async inbox() {
|
|
if (!identity) return { ok: true, messages: [] };
|
|
// Panel wants: { ok, messages: [{ id, pkHex, direction, text, createdAt }] }
|
|
// where pkHex is the PEER's pubkey (the one who is NOT me).
|
|
const raw = loadInbox();
|
|
return { ok: true, messages: raw };
|
|
},
|
|
|
|
async contactsList() {
|
|
if (!identity) return { ok: true, contacts: [] };
|
|
return { ok: true, contacts: loadContacts() };
|
|
},
|
|
|
|
async contactsAdd(payload) {
|
|
if (!identity) return { ok: false, err: "signed out" };
|
|
try {
|
|
const target = await resolveTarget(payload && payload.name);
|
|
const list = loadContacts();
|
|
if (!list.some((c) => c.pkHex === target.pkHex)) {
|
|
list.push({
|
|
pkHex: target.pkHex,
|
|
name: target.canonicalName || payload.name,
|
|
relays: target.relays,
|
|
addedAt: Math.floor(Date.now() / 1000),
|
|
});
|
|
saveContacts(list);
|
|
}
|
|
return { ok: true, contact: list.find((c) => c.pkHex === target.pkHex) };
|
|
} catch (e) {
|
|
return { ok: false, err: (e && e.message) || String(e) };
|
|
}
|
|
},
|
|
|
|
async markThreadRead(payload) {
|
|
if (!identity) return { ok: true };
|
|
const pk = String(payload && payload.peerPkHex || "").toLowerCase();
|
|
if (!pk) return { ok: true };
|
|
const upTo = Number(payload && payload.upToCreatedAt) || Math.floor(Date.now() / 1000);
|
|
const st = loadReadState();
|
|
st[pk] = Math.max(upTo, Number(st[pk] || 0));
|
|
saveReadState(st);
|
|
return { ok: true };
|
|
},
|
|
|
|
async send(payload) {
|
|
if (!identity) return { ok: false, err: "signed out" };
|
|
try {
|
|
await loadNT();
|
|
const to = String(payload && payload.to || "");
|
|
const text = String(payload && payload.text || "");
|
|
if (!text.trim()) return { ok: false, err: "empty message" };
|
|
const target = await resolveTarget(to);
|
|
const wrap = buildWrap({ senderSk: identity.sk, recipientPkHex: target.pkHex, text });
|
|
// Publish to the recipient's declared relays + our own set, so our
|
|
// own future reconnect pulls the wrap back from somewhere we read from.
|
|
const publishTo = Array.from(new Set([...(target.relays || []), ...relays]));
|
|
const results = await Promise.allSettled(publishTo.map((url) => publishOne(url, wrap)));
|
|
const okCount = results.filter((r) => r.status === "fulfilled" && r.value === true).length;
|
|
if (!okCount) throw new Error("no relay accepted the message");
|
|
// Mirror the sent message locally so the panel's own thread view
|
|
// shows it immediately — a relay won't echo our own wrap back to us.
|
|
recordMessage({
|
|
id: wrap.id,
|
|
peerPkHex: target.pkHex,
|
|
senderPkHex: identity.pkHex,
|
|
meHex: identity.pkHex,
|
|
text,
|
|
createdAt: Math.floor(Date.now() / 1000),
|
|
direction: "out",
|
|
});
|
|
emitter.dispatchEvent(new CustomEvent("hermes-message", { detail: {} }));
|
|
return { ok: true, acceptedBy: okCount, totalRelays: publishTo.length };
|
|
} catch (e) {
|
|
return { ok: false, err: (e && e.message) || String(e) };
|
|
}
|
|
},
|
|
|
|
// ---- stubs the panel asks about but we don't implement in v0 -------
|
|
async getProfile() { return { ok: true, profile: null }; },
|
|
async hasBackup() { return { ok: true, hasBackup: false }; },
|
|
async getBackup() { return { ok: false, err: "backup is only in the Theseus desktop messenger" }; },
|
|
async getPhone() { return { ok: true, phone: null }; },
|
|
async setPhone() { return { ok: false, err: "phone linking is only in the Theseus desktop messenger" }; },
|
|
async clearPhone() { return { ok: true }; },
|
|
async updateProfile() { return { ok: false, err: "profile editing is only in the Theseus desktop messenger" }; },
|
|
"open-messages"() { return { ok: true }; },
|
|
};
|
|
|
|
// ---- wrap: identical to lib/hermes.js wrapChat ------------------------
|
|
function buildWrap({ senderSk, recipientPkHex, text }) {
|
|
const { getPublicKey, getEventHash, finalizeEvent, generateSecretKey, nip44 } = nt;
|
|
const convKey = (sk, pkHex) => nip44.v2.utils.getConversationKey(sk, pkHex);
|
|
const encrypt = (sk, pkHex, plain) => nip44.v2.encrypt(plain, convKey(sk, pkHex));
|
|
const senderPkHex = getPublicKey(senderSk);
|
|
const jitteredNow = () => Math.floor(Date.now() / 1000) - Math.floor(Math.random() * TWO_DAYS_S);
|
|
const rumor = {
|
|
kind: KIND_CHAT,
|
|
pubkey: senderPkHex,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [["p", recipientPkHex]],
|
|
content: text,
|
|
};
|
|
rumor.id = getEventHash(rumor);
|
|
const seal = finalizeEvent({
|
|
kind: KIND_SEAL,
|
|
created_at: jitteredNow(),
|
|
tags: [],
|
|
content: encrypt(senderSk, recipientPkHex, JSON.stringify(rumor)),
|
|
}, senderSk);
|
|
const ephemeralSk = generateSecretKey();
|
|
return finalizeEvent({
|
|
kind: KIND_GIFT_WRAP,
|
|
created_at: jitteredNow(),
|
|
tags: [["p", recipientPkHex]],
|
|
content: encrypt(ephemeralSk, recipientPkHex, JSON.stringify(seal)),
|
|
}, ephemeralSk);
|
|
}
|
|
|
|
// Publish an event to one relay with an OK confirmation + 6 s timeout.
|
|
// A one-shot socket per publish keeps the inbound subscription clean and
|
|
// avoids racing OK responses between concurrent sends on the same socket.
|
|
function publishOne(url, ev) {
|
|
return new Promise((resolve) => {
|
|
let settled = false;
|
|
const done = (ok) => { if (settled) return; settled = true; try { ws.close(1000); } catch {} resolve(!!ok); };
|
|
let ws;
|
|
try { ws = new WebSocket(url); }
|
|
catch { return resolve(false); }
|
|
const t = setTimeout(() => done(false), 6000);
|
|
ws.addEventListener("open", () => ws.send(JSON.stringify(["EVENT", ev])));
|
|
ws.addEventListener("message", (m) => {
|
|
let msg; try { msg = JSON.parse(m.data); } catch { return; }
|
|
if (Array.isArray(msg) && msg[0] === "OK" && msg[1] === ev.id) {
|
|
clearTimeout(t); done(msg[2] === true);
|
|
}
|
|
});
|
|
ws.addEventListener("error", () => { clearTimeout(t); done(false); });
|
|
ws.addEventListener("close", () => { clearTimeout(t); done(false); });
|
|
});
|
|
}
|
|
|
|
// ---- public bridge -----------------------------------------------------
|
|
window.silentmode = {
|
|
async invoke(name, payload) {
|
|
const h = handlers[name];
|
|
if (!h) return { ok: false, err: `unknown invoke: ${name}` };
|
|
try { return await h(payload); }
|
|
catch (e) { return { ok: false, err: (e && e.message) || String(e) }; }
|
|
},
|
|
on(channel, cb) {
|
|
const listener = (ev) => { try { cb(ev.detail); } catch {} };
|
|
emitter.addEventListener(channel, listener);
|
|
return () => emitter.removeEventListener(channel, listener);
|
|
},
|
|
off(channel, cb) {
|
|
// panel uses on()'s returned unsubscribe; this is a convenience alias
|
|
emitter.removeEventListener(channel, cb);
|
|
},
|
|
storage: {
|
|
async get(k) {
|
|
try { return JSON.parse(storeGet("hermes.shim." + k) || "null"); }
|
|
catch { return null; }
|
|
},
|
|
async set(k, v) {
|
|
try { storeSet("hermes.shim." + k, JSON.stringify(v)); } catch {}
|
|
},
|
|
},
|
|
// The panel reads `.sidebar` as an optional capability — null means we're
|
|
// not inside Theseus's sidebar. Keeps the "pop out of sidebar" button
|
|
// from throwing when panel tries to call it.
|
|
sidebar: null,
|
|
};
|
|
})();
|