hermes: ship the browser side of the Theseus feature set

Fixes the "unknown invoke: fetchCloudBackup" dead-end on mobile sign-in
and the empty hooks the Settings UI depended on. The shim now implements:

- Local wrap: on signup it generates a BIP-39 mnemonic (@scure/bip39),
  derives the Nostr sk via PBKDF2+HKDF with purpose "messenger/0" (same
  chain Theseus runs), scrypt+AES-GCM wraps the mnemonic (@noble/hashes
  scrypt matching HERMES_SCRYPT N=2^15 r=8 p=1), stores it in
  localStorage.hermes.wrap. hasBackup/getBackup use that wrap, so a
  password sign-in now works on the same device.

- Cloud backup: publishCloudBackup / fetchCloudBackup / removeCloudBackup
  / hasCloudBackup matching Theseus's dtag format
  (hermes.backup.v1/<sha256(bareName)[:32]>). Password unlocks the local
  wrap, re-wraps for the cloud copy with a fresh salt, publishes a
  kind:30078 to every relay, remembers the event id for later retract
  via NIP-09.

- Phone discovery: setPhone / clearPhone / findByPhones with the same
  hermes.phone/<base64url(sha256(E.164))[:32]> d-tag Theseus uses, so a
  vCard import on the browser surfaces the same matches a Theseus user
  would see.

- contactsUpdate / contactsRemove rounded out so the panel's contacts
  menu is fully wired.

Also:

- Default relay list bumped to [wss://hermes.x, wss://relay.silentmode.st,
  wss://nos.lol] in both the Theseus host and the web shim, so every
  publish/fetch hits our own strfry on the VPS and the cloud backup lives
  somewhere we control.

- Theseus host: addons-host.js + main.js now understand a `width` field
  on sidebar panels, so the Hermes panel declares width:500 and opens at
  a Telegram-ish width by default (user can still drag; saved per-panel).

- Panel + web messenger: forced color-scheme:dark (the brand mark has
  hard navy cutouts that stand out as "black inside the envelope" on a
  light background; dark everywhere is cheaper than theme-aware marks).

- Password fields: 👁 toggle appears on every <input type="password">
  via a tiny JS enhancer in both the panel and the web messenger.

- Settings/signin tabs: Sign in / Sign up persistent pill, drag-to-resize
  contacts/thread splitter (persisted per device), 2-column always.
This commit is contained in:
Local Dev 2026-10-22 15:33:07 +03:00
parent b2fef43607
commit 3b2cad491d
4 changed files with 144 additions and 34 deletions

View file

@ -562,7 +562,13 @@ class AddonHost {
return (manifest.panels || []).map((p) => {
const abs = path.join(folder, p.page);
if (!fs.existsSync(abs)) throw new Error(`sidebar panel page not found: ${abs}`);
return { panelId: `${manifest.id}:${p.id}`, title: p.title, icon: p.icon, pageFile: abs, addonId: manifest.id, side: p.side || "right" };
const rec = { panelId: `${manifest.id}:${p.id}`, title: p.title, icon: p.icon, pageFile: abs, addonId: manifest.id, side: p.side || "right" };
// Optional per-panel preferred default width (px); main.js uses it as
// the `def` passed to leftPanelWidth when the user has not resized it.
// Clamped to a sane range so a bad manifest can't take over the window.
const w = Number(p.width);
if (Number.isFinite(w) && w > 0) rec.width = Math.max(280, Math.min(1200, Math.round(w)));
return rec;
});
}
@ -890,13 +896,15 @@ class AddonHost {
if (typeof handle !== "function") throw new Error("registerSiteRoute needs a handle(request) function");
active.siteRoutes.set(h, { addonId: manifest.id, handle });
},
registerSidebarPanel: ({ id, title, icon = manifest.icon, page, side }) => {
registerSidebarPanel: ({ id, title, icon = manifest.icon, page, side, width }) => {
if (!id || !title || !page) throw new Error(`registerSidebarPanel needs {id, title, page}`);
const abs = path.join(folder, String(page).replace(/^[\\/]/, ""));
if (!fs.existsSync(abs)) throw new Error(`sidebar panel page not found: ${abs}`);
// Namespaced id so two add-ons can't collide.
const panelId = `${manifest.id}:${id}`;
const rec = { panelId, title, icon, pageFile: abs, addonId: manifest.id, side: side === "left" ? "left" : "right" };
const w = Number(width);
if (Number.isFinite(w) && w > 0) rec.width = Math.max(280, Math.min(1200, Math.round(w)));
const at = active.sidebarPanels.findIndex((p) => p.panelId === panelId);
if (at >= 0) active.sidebarPanels[at] = rec; else active.sidebarPanels.push(rec);
this.log(`[${manifest.id}] registered sidebar panel: ${panelId}`);

File diff suppressed because one or more lines are too long

View file

@ -4,21 +4,18 @@
<meta charset="utf-8">
<title>Hermes</title>
<style>
:root { color-scheme: light dark;
/* Dark-only. The brand mark has hard navy cutouts over a sky body; on a
light background the cutouts stand out as "black inside the envelope".
Keep the panel dark regardless of OS theme. */
:root { color-scheme: dark;
--bg:#0e131c; --panel:#141a24; --panel-2:#1a2130; --line:rgba(255,255,255,.09);
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678;
--sky:#38BDF8; --sky-ink:#06111a; --amber:#f4c430;
--err:#f6768a; --ok:#4fd1a5;
--bubble-me: #0c3f55; --bubble-them: #1a2130; }
@media (prefers-color-scheme: light) {
:root { --bg:#f8faff; --panel:#ffffff; --panel-2:#eef2fb; --line:rgba(0,0,0,.09);
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5;
--sky:#0891B2; --sky-ink:#ffffff;
--bubble-me:#dceefa; --bubble-them:#eef1f5; }
}
* { box-sizing: border-box; }
html, body { margin:0; height:100%; }
body { background: var(--bg); color: var(--ink);
html, body { margin:0; height:100%; background: var(--bg); }
body { color: var(--ink);
font: 13.5px/1.45 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
overflow: hidden; }
@ -71,16 +68,26 @@
/* === app screen (contacts + thread) === */
#app { display: none; flex-direction: column; }
#app.active { display: flex; }
#app .cols { display: grid; grid-template-columns: 220px 1fr; height: 100%;
min-height: 0; }
@media (max-width: 540px) {
#app .cols { grid-template-columns: 1fr; }
#app.show-thread .contacts-pane { display: none; }
#app:not(.show-thread) .thread-pane { display: none; }
/* 2-column at every width, same shape as the hermes.x /overview mockup.
Contacts column sits around a Telegram-style 320 px default, draggable
via the splitter in between. --contacts-w is set inline by the splitter
JS and persisted in localStorage so the width survives reload.
minmax(…) + min-width:0 on panes stops long names/inputs from pushing
the layout past the panel. */
#app .cols { display: grid;
grid-template-columns: minmax(200px, var(--contacts-w, 320px)) 5px minmax(0, 1fr);
height: 100%; min-height: 0; }
#app .splitter { background: transparent; cursor: col-resize; user-select: none;
border-left: 1px solid var(--line); border-right: 1px solid var(--line);
margin: 0; padding: 0; transition: background .12s; }
#app .splitter:hover, #app .splitter.dragging { background: var(--sky); opacity: .5; }
/* Narrow panel: splitter stays but contacts clamp down so the thread
has at least ~140 px to breathe. */
@media (max-width: 500px) {
#app .cols { grid-template-columns: minmax(160px, var(--contacts-w, 180px)) 5px minmax(0, 1fr); }
}
@media (min-width: 541px) { #app.show-thread .contacts-pane { display: flex; } }
.contacts-pane { display:flex; flex-direction: column; min-height: 0;
.contacts-pane { display:flex; flex-direction: column; min-height: 0; min-width: 0;
border-right: 1px solid var(--line); background: var(--panel); }
.contacts-head { padding: 10px 10px 8px; display:flex; gap: 6px; }
.contacts-head input { flex: 1; min-width: 0; font: inherit; font-size: 12.5px;
@ -111,12 +118,11 @@
border-radius: 999px; min-width: 20px; text-align: center; }
.empty { color: var(--mut); font-size: 12px; padding: 20px 14px; text-align: center; }
.thread-pane { display:flex; flex-direction: column; min-height: 0; background: var(--bg); }
.thread-pane { display:flex; flex-direction: column; min-height: 0; min-width: 0; background: var(--bg); }
.thread-head { padding: 10px 12px; border-bottom: 1px solid var(--line);
background: var(--panel); display:flex; align-items:center; gap: 10px; min-height: 44px; }
.thread-head .back { display: none; background: transparent; border: 0; color: var(--mut);
font: inherit; font-size: 18px; cursor: pointer; padding: 0 2px; }
@media (max-width: 540px) { .thread-head .back { display: inline-block; } }
/* No back button — the contacts pane is always visible beside the thread. */
.thread-head .back { display: none; }
.thread-head .nm { font-weight: 600; font-size: 13px; }
.thread-head .pk { color: var(--dim); font-size: 11px; font-family: ui-monospace, monospace;
overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
@ -159,16 +165,15 @@
</head>
<body>
<!-- Top bar — present on both screens -->
<!-- In-panel bar for the SIGNED-IN state: relay status + ⋯ menu.
The sidebar chrome Theseus draws above already shows "Hermes" + the
maximize/close icons, so we do not repeat the title or the mark here
(match Telegram's pattern — one header, not two). The whole bar is
hidden on the signed-out screen via body.signed-out .bar in shared.css. -->
<div class="bar">
<div class="mk" aria-hidden="true">
<img src="assets/mark.svg" alt="" width="24" height="24" style="display:block">
</div>
<div class="title">Hermes</div>
<div class="spc"></div>
<div class="status" id="status"><span class="dot" id="statusDot"></span><span id="statusText">—</span></div>
<button class="ic" id="menuBtn" title="More" style="display:none">⋯</button>
<button class="ic" id="popOutBtn" title="Open in a window">⧉</button>
</div>
<!-- overflow menu -->
@ -328,6 +333,7 @@
</div>
<div class="contacts-list" id="contactsList"></div>
</div>
<div class="splitter" id="colSplitter" role="separator" aria-orientation="vertical" title="Drag to resize"></div>
<div class="thread-pane">
<div class="thread-head" id="threadHead" style="display:none">
<button class="back" id="backBtn">←</button>
@ -762,6 +768,90 @@ async function initWithOpts(opts) {
setInterval(refreshStatus, 7000);
})();
// ---- splitter: drag the contacts/thread divider -----------------------
// Writes --contacts-w on #app (grid-template-columns reads it) and persists
// in localStorage under hermes.cols.contactsW. Pointer events; mouse +
// touch work the same. Clamped to [160, 70% of panel] so the thread
// column always has room.
// ---- password-eye toggle on every <input type="password"> --------------
// Wraps each one in a flex container and injects a 👁 button that flips the
// input between type="password" and "text". No per-field markup needed.
(function initPwEye() {
const EYE_OPEN =
'<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg>';
const EYE_OFF =
'<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-7-11-7a19.73 19.73 0 0 1 4.19-5.19M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 7 11 7a19.6 19.6 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>';
const style = document.createElement("style");
style.textContent = ".pw-wrap{position:relative;display:block}.pw-wrap > input{padding-right:34px!important;width:100%}" +
".pw-eye{position:absolute;top:50%;right:4px;transform:translateY(-50%);background:transparent;border:0;color:var(--mut);cursor:pointer;padding:4px;display:grid;place-items:center;line-height:0}" +
".pw-eye:hover{color:var(--ink)}";
document.head.appendChild(style);
const attach = (inp) => {
if (!inp || inp.closest(".pw-wrap")) return;
const wrap = document.createElement("span");
wrap.className = "pw-wrap";
inp.parentNode.insertBefore(wrap, inp);
wrap.appendChild(inp);
const btn = document.createElement("button");
btn.type = "button";
btn.className = "pw-eye";
btn.setAttribute("aria-label", "Show password");
btn.setAttribute("tabindex", "-1");
btn.innerHTML = EYE_OPEN;
btn.addEventListener("click", () => {
const show = inp.type === "password";
inp.type = show ? "text" : "password";
btn.innerHTML = show ? EYE_OFF : EYE_OPEN;
btn.setAttribute("aria-label", show ? "Hide password" : "Show password");
inp.focus();
});
wrap.appendChild(btn);
};
for (const inp of document.querySelectorAll('input[type="password"]')) attach(inp);
})();
(function initSplitter() {
const app = $("app"), sp = $("colSplitter");
if (!app || !sp) return;
const STORE_KEY = "hermes.cols.contactsW";
try {
const saved = Number(localStorage.getItem(STORE_KEY));
if (saved >= 160 && saved <= 2000) app.style.setProperty("--contacts-w", saved + "px");
} catch {}
let dragging = false, start = 0, startW = 0;
const clamp = (w) => {
const panelW = app.getBoundingClientRect().width || 400;
return Math.max(160, Math.min(Math.floor(panelW * 0.7), w));
};
sp.addEventListener("pointerdown", (e) => {
dragging = true; start = e.clientX;
startW = parseFloat(getComputedStyle(app).getPropertyValue("--contacts-w")) ||
app.querySelector(".contacts-pane").getBoundingClientRect().width ||
320;
sp.classList.add("dragging");
try { sp.setPointerCapture(e.pointerId); } catch {}
e.preventDefault();
});
sp.addEventListener("pointermove", (e) => {
if (!dragging) return;
const w = clamp(startW + (e.clientX - start));
app.style.setProperty("--contacts-w", w + "px");
});
const end = () => {
if (!dragging) return;
dragging = false; sp.classList.remove("dragging");
const w = parseFloat(getComputedStyle(app).getPropertyValue("--contacts-w"));
if (Number.isFinite(w)) { try { localStorage.setItem(STORE_KEY, String(Math.round(w))); } catch {} }
};
sp.addEventListener("pointerup", end);
sp.addEventListener("pointercancel", end);
// Double-click resets to the default 320 px.
sp.addEventListener("dblclick", () => {
app.style.setProperty("--contacts-w", "320px");
try { localStorage.removeItem(STORE_KEY); } catch {}
});
})();
// ---- events: tab switcher + recover link ----
$("startSignInBtn").addEventListener("click", () => showSigninView("signin"));
$("startSignUpBtn").addEventListener("click", () => showSigninView("signup"));
@ -832,7 +922,8 @@ $("recoverBtn").addEventListener("click", () => {
initWithOpts({ useVault: false, mnemonic });
});
$("popOutBtn").addEventListener("click", () => bridge.invoke("open-messages"));
// popOutBtn removed — the sidebar panel is the home. Users can maximize it
// via the host chrome's own icon if they want more room.
$("menuBtn").addEventListener("click", (e) => {
e.stopPropagation();
if ($("menu").style.display === "block") closeMenu();

14
main.js
View file

@ -4028,7 +4028,13 @@ function layoutHere() {
// An add-on's left panel. Widened, it covers the tab area like a page of
// its own, up to the right sidebar, which keeps its width: the tabs keep
// their narrow-panel bounds underneath instead of being squeezed aside.
const addonLeftW = (primary && leftPanel && leftPanelId && !fsTab) ? leftPanelWidth(LEFT_PANEL_W, width - leftW) : 0;
// Default width: the panel's manifest `width`, falling back to LEFT_PANEL_W.
const addonLeftDef = (() => {
if (!leftPanelId) return LEFT_PANEL_W;
const rec = leftPanels().find((p) => p.panelId === leftPanelId);
return (rec && Number.isFinite(rec.width) && rec.width > 0) ? rec.width : LEFT_PANEL_W;
})();
const addonLeftW = (primary && leftPanel && leftPanelId && !fsTab) ? leftPanelWidth(addonLeftDef, width - leftW) : 0;
const addonLeftShownW = addonLeftW && leftPanelMax ? Math.max(addonLeftW, width - leftW - sideW) : addonLeftW;
if (leftPanel && primary) {
leftPanel.setBounds({ x: leftW, y: chromeH + headH, width: addonLeftShownW, height: Math.max(0, bodyH - headH) });
@ -9903,7 +9909,11 @@ ipcMain.handle("switch-to-bcnr", () => {
// and secret key are never written to disk. The panel process holds one
// WebSocket per relay in HERMES_DEFAULT_RELAYS for the receive subscription,
// and opens a per-send WebSocket for publishes.
const HERMES_DEFAULT_RELAYS = ["wss://nos.lol"];
const HERMES_DEFAULT_RELAYS = [
"wss://hermes.x", // Silent Mode strfry on the VPS (BNS pinned-origin)
"wss://relay.silentmode.st", // Silent Mode strfry via DNS — same box
"wss://nos.lol", // Public fallback so we don't rely on one host
];
const HERMES_INBOX_LIMIT = 2000;
// Persistent storage. On disk: