hermes.x/messenger: wrap the Theseus panel as a stand-alone webapp
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.
This commit is contained in:
parent
a9831f8f12
commit
86e67d05e7
2 changed files with 1509 additions and 242 deletions
1215
messenger/index.html
1215
messenger/index.html
File diff suppressed because it is too large
Load diff
536
messenger/shim.js
Normal file
536
messenger/shim.js
Normal file
|
|
@ -0,0 +1,536 @@
|
|||
// 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,
|
||||
};
|
||||
})();
|
||||
Loading…
Add table
Reference in a new issue