Theseus 0.3.131: Mail promoted to Sidebar quicklink, Nostr signer ships, panels free RAM when idle, Game.X in SM cards

Silent Mode Mail is no longer an extension. The 0.1.0 panel was a black
screen (iframe to mail.silentmode.st which sends X-Frame-Options: DENY;
the browser rendered the block page and still fired the iframe's load
event, so the 5-second "did it embed?" watchdog saw it as loaded and the
fallback never swapped in). The whole extension existed to put one "Open
Mail" button in a panel — that is exactly what a sidebar quicklink does,
without taking a panel slot or an Extensions row. So:

  - settings.quickLinks default list gains silentmail at the top
    (Silent Mode Mail → https://mail.silentmode.st/).
  - migrateAegisRename grew a mail retirement block: any existing
    extensions/mail/ folder on the user's box moves to
    addons-backups/mail-migrated-<stamp>/ on first boot of this
    Theseus, so the old extension vanishes from Settings instead of
    sitting there with a broken panel.
  - "mail" added to the first-party reserved-ids list so no community
    add-on can claim the id after retirement.

Nostr NIP-07 signer finally ships. The bundled-addons/nostr/ folder was
sitting untracked in the main checkout, so the detached-HEAD build
worktree 0.3.130 used never saw it (same reason build/, tor/, snapshots/
have to be hand-copied into the worktree). This build committed the
folder and copied it in. Puts window.nostr on https + localhost pages;
identity is Hermes's key (one npub for DMs and public notes). Per-origin
read/sign grants; kinds 0/3/5/10002 re-prompt every time with no
remember option.

Sidebar panel apps free their render tree after idle. closeLeftPanel
used to keep the loaded page warm forever — great for fast reopen, bad
for RAM when the user docks a heavy panel (Hermes, Pithos, Aegis) once
and never comes back. A 2-minute idle timer now blanks the panel to
about:blank after close; reopen within the window still reuses the
loaded page (cancelLeftPanelIdleUnload on open), reopen after the
window reloads fresh. LEFT_PANEL_IDLE_MS = 2 * 60 * 1000. Right-side
sidebar view (webapp tabs — Telegram, X, WhatsApp, AI chat) is longer-
lived by design and untouched here.

Startpage layout:
  - Game.X takes SilentMode.X's slot in the Silent Mode cards (stub
    description + a built-in gamepad brand mark until game.x goes
    live). SilentMode.X moves to the quick-links strip as the 5th
    slot. Still TODO: push the swap into the remote home-cards.json
    for existing profiles.
  - Light-theme palette flip under body.light. The @media
    (prefers-color-scheme: light) block only triggered on OS-level
    light preference; picking the "light" background preset left --ink
    white against a white page, so hero titles, card titles, slot
    labels and the ".bch" extension read as pale ghosts. Added a full
    body.light { --ink; --mut; --card; --line; ... } block plus
    overrides for the hero + card translucent panels (dark-glass →
    light-glass on light theme) and the ctx-menu / modal. Dark theme
    untouched.
This commit is contained in:
Theseus Dev 2026-10-30 00:38:08 +02:00 • committed by Hermes Dev
parent b4cea7b83f
commit 9c29331e5b
10 changed files with 685 additions and 133 deletions

View file

@ -8,7 +8,7 @@ what belongs to whom and coordinate. Naming convention:
`YYYY-MM-DD:short-slug` for date-bounded work, or `parallel:<theme>` for
work owned by a session that isn't the current one.
Last updated: 2026-10-29.
Last updated: 2026-10-29 (post-0.3.130 batch).
---
@ -16,122 +16,84 @@ Releases are no longer recorded here: the live version and hashes are in
`../site/releases-manifest.json` and `https://dl.silentmode.st/releases-manifest.json`,
and each release commit is `Theseus 0.3.x: ...` in git history.
## Staged for the next release (post-0.3.129)
## Staged for the next release (post-0.3.130)
Queue of asks for the next cut. Items marked LANDED are edited in the
working tree waiting for a bump + build. Items marked TODO still need the
code change.
- **TODO: Home — swap `SilentMode.X` out, `Game.X` in** on the Silent Mode
cards section ([home.html:366-375](home.html) `defaultState().sections.sm`).
Game.X should pick up its own description + favicon from the live site,
so the entry is a stub (`{ title: "Game.X", url: "https://game.x/",
brand: "game" }`) until game.x goes live. SilentMode.X moves to a short
link somewhere else — the user suggested either under the search field,
at the bottom of the home page, or in the quicklinks (sections.q). The
canonical remote list at `https://dl.silentmode.st/home-cards.json`
needs the same swap so existing profiles pick it up without a reset
(local `sections.sm` isn't reseeded by `migrateCards` — the remote cache
is the only way through to installed users). `game.x` isn't resolving
yet (no s3 pointer on-chain, no manifest); wait until the service is up
before baking its description/favicon.
- **LANDED: Searchbox no longer auto-grows on focus; URL/search ratio
capped at 75/25.** Reverts the 0.3.128 "give the searchbox more room
while typing" rule — focusing the searchbox was pulling the drag handle
with it and making the ratio hard to set by hand. Changes in
[chrome.html](chrome.html): dropped the
`.urlsearch:has(.searchbox:focus-within) .searchbox { flex: 2 1 480px }`
rule and its `.14s` transition on `.searchbox`; added
`max-width: 75%` to `.urlwrap` so a stored `urlBarWidthPx` from the old
80 % ceiling clamps on load without the user having to redrag; bumped
the drag `MIN_SHARE` 0.20 → 0.25 so new drags can't push the search
bar below 25 % of the shared budget. 0.3.128's typing-wider effect
stays off; the user owns the ratio again.
- **LANDED: Startpage sidebar runs the full page height alongside the
hero; slots fill the whole sidebar panel; empty cells double as the
"add link" surface.** [home.html](home.html):
- **Page layout restructured.** Hero + homepage are now wrapped in a
single `.page` grid (`grid-template-columns: minmax(240, 280) 1fr`)
with the sidebar `<aside>` as a true left column and `.main-col` on
the right holding the hero on top of the SM cards. The sidebar's
top edge now aligns with the Theseus logo + search box instead of
starting below them. Hero top padding dropped (page handles it);
hero stays centered inside the main column.
- **Sidebar fills the panel vertically.** `.sidebar { align-self:
stretch }` makes the aside match the main column's height.
`.slots-list { grid-template-columns: repeat(3, 1fr); width: 100% }`
stretches cells to the sidebar's full width (icons stay 48 px,
centered inside the wider cell; labels gain `max-width: 100%` so
they center over the cell). A new `minSlotsForFill()` computes row
count from `.main-col` height on each render; a ResizeObserver
re-renders on viewport resize so new rows appear (or drop off) as
the window changes.
- **Empty tiles top-aligned.** `.slot-empty` switched to
`flex-direction: column; justify-content: flex-start` so the dashed
tile sits at the top of its 72×68 cell — same Y as a filled slot's
`.tile` (which sits above its label). Before this the dashed box
was centered in the cell while filled tiles were top-aligned, so
the two read as misaligned.
- **Chromeless sidebar.** `.left-column` renamed to `.sidebar`,
`.right-column` renamed to `.cards-wrap`; the new `.section.chromeless`
modifier removes background, inset border, and padding box on the
sidebar so it blends with the page body. The SM cards area keeps
its soft panel (the cards area is "the rest of the page", not the
sidebar).
- **Dedicated `.slot-add` cell removed; dashed outline semi-transparent.**
`.slot-empty::before` has `content: ""` (no "+" glyph) and renders at
.18 opacity on container hover, .35 on this-cell hover, strong acid
only on drop-hot — so empty cells never compete visually with the
real icons. Clicking any empty cell opens the Add-link modal with
that cell's index pre-pinned (`openModal(sec, i, pinSlot)` carries
the pin through `editKey` to the save handler), so the new link
lands exactly where the user clicked.
- `layoutSlots()` default `minSlots` still 18 (fallback for the first
render before layout is measured); render() passes the dynamic
value from `minSlotsForFill()` which uses it as a floor.
- **LANDED: Startpage shows running version + link to release notes.**
New line under the "Tip: hello.bch" hint on [home.html](home.html):
"Theseus v0.3.X — What's new ↗", linking to
`https://silentmode.st/releases`. Version comes from
`window.home.getVersion()` (added to [home-preload.js](home-preload.js);
wired to the existing `app-version` IPC — no new backend). Visible on
every startpage load so a user who just updated has a one-click path to
the changelog without hunting for a chrome chip that only shows when a
NEWER version is out.
- **LANDED: Claude connector detects Microsoft Store install of Claude
Desktop.** [bundled-addons/claude-connector/addon.json](bundled-addons/claude-connector/addon.json)
bumped from 0.2.0 to 0.2.1 so Theseus's `seedBundledAddons` reseeds the
stale copy sitting in `%APPDATA%\Theseus\extensions\claude-connector\`
on first boot of the next Theseus. The actual detection code (Store
package path under `%LOCALAPPDATA%\Packages\Claude_<pub>\LocalCache\
Roaming\Claude\` + fallback to `AnthropicClaude` / `Programs\Claude` /
`Program Files\Claude`) is already in the tree in
[bundled-addons/claude-connector/lib/clients.cjs](bundled-addons/claude-connector/lib/clients.cjs);
it just wasn't reaching installed users because the addon version
hadn't been bumped — `seedBundledAddons` skips the overwrite when
user version >= bundle version. User-visible symptom: the connector
panel showed Claude Desktop as "Not installed on this computer" on
every box where Claude Desktop is the Store install (which is the
default modern install).
- **LANDED: Claude connector reaches add-on sidebar panels** (Aegis, Hermes,
Pithos, and any other sidebar add-on). The tab-control hook now exposes
each open panel as a tab with a negative id, `kind: "panel"`, `addon`
and `panelId` fields ([main.js](main.js) `panelCtrlEntries` /
`tabControlHook.list`). The connector's `use()` branches to `_usePanel`,
approves control under a synthetic `theseus-panel://<addonId>` origin
(one approval covers every panel owned by that add-on), skips origin
pinning (panels load from the add-on scheme and never change under us)
and drives the panel's `webContents` with the same `TabDriver` as a web
tab. `navigate` / `activate_tab` / `close_tab` refuse panels — the user
owns panel visibility. The approval modal says "Let Claude control the
Aegis panel?" when the origin is a panel origin. list_tabs returns
closed panels too so a client can see what exists; `webContents(-N)`
throws "<addon> is not open; ask the user to open the panel from the
sidebar first" until the user docks it. Password-field refusal and the
action log still apply. The connector panel.html now splits site
approvals from panel approvals into their own sections, with the add-on's
own title ("Aegis panel") above the mono origin string and a ✕ Remove
button that goes through the existing `remove-origin` IPC.
- **LANDED: Nostr NIP-07 signer** — new bundled add-on `bundled-addons/nostr/`
(0.1.0) plus host support. Puts `window.nostr` on https + localhost pages so
any Nostr client (Primal, Coracle, Snort, nostrudel) can sign with the
identity Theseus already holds. **Needs a Theseus release**: it changes
`main.js` and bundles a new add-on, so the extension OTA alone cannot
deliver it. **MISSED 0.3.130** — the folder is untracked (`??` in git
status), so the detached-HEAD build worktree never saw it. For 0.3.131
the ship script must either `git add` the folder first or `cp -r` it
into the build worktree the same way `build/`, `tor/`, `snapshots/`
are copied.
- [lib/nostr-signer.js](lib/nostr-signer.js) — new. Crypto half:
`getPublicKey`, `signEvent` (BIP340 via nostr-tools `finalizeEvent`),
nip04 + nip44 encrypt/decrypt. Recomputes `pubkey`/`id` rather than
trusting the page.
- [main.js](main.js) — `global._nostrAPI` next to `_hermesAPI`.
- Identity is Hermes's key (`nostrKeyFromRoot(messengerRoot)`); vault
with no mnemonic reports no identity instead of failing obscurely.
- Policy per-origin (read vs sign), kinds 0/3/5/10002 re-prompt every
time with no remember option.
- Tests: `scratchpad/signer-test.mjs`, `nostr-policy-test.mjs`,
`nostr-e2e.mjs`.
- **LANDED: Home — SilentMode.X moved to the quick-links strip, Game.X
takes its place in the Silent Mode cards.** [home.html](home.html)
`defaultState().sections.sm` and `.q`. Game.X entry uses a built-in
`game` brand mark (new SVG in `BRAND`); the real description and
favicon will come from the live site once game.x is up (currently a
stub). SilentMode.X moves into the slots strip as the 5th quick-link.
**Still TODO:** update the remote cache at
`https://dl.silentmode.st/home-cards.json` so existing profiles pick
the swap up without resetting (local `sections.sm` isn't reseeded by
`migrateCards` — only the remote fetch rewrites it for installed
users).
- **LANDED: Mail is now a Sidebar quicklink, not an extension.** The
user flagged two things in one: the Mail add-on's 0.1.0 panel was a
black screen (iframe + XFO: DENY), AND Mail conceptually belongs
under Settings → Sidebar (webapp quicklinks) rather than Settings →
Extensions. Both resolved by dropping the extension form entirely:
- `settings.quickLinks` default list in [main.js](main.js) gains
`{ id: "silentmail", url: "https://mail.silentmode.st/", title:
"Silent Mode Mail" }` as the first entry.
- `migrateAegisRename()` grew a mail retirement block: on first boot
of this Theseus, any installed `extensions/mail/` folder moves to
`addons-backups/mail-migrated-<stamp>/`. The one-off `mail/`
directory I added under `bundled-addons/` earlier in this session
is removed again (the quicklink + retire is the right path).
- `"mail"` added to the first-party reserved-ids list so a community
add-on can't claim the id after retirement.
- The quicklink does exactly what the old extension's "Open Mail"
button did (new tab at `mail.silentmode.st`), without taking a
panel slot or an Extensions row.
- **LANDED: Left-side sidebar panels free their render tree after idle.**
[main.js](main.js) `closeLeftPanel`: closed panels used to keep their
loaded page (`leftPanelLoadedId` kept, webContents warm) forever —
great for fast reopen, bad for RAM when the user docks a heavy
app-like panel (Hermes, Pithos, mail) once and never comes back.
Now a 2-minute idle timer blanks the panel to `about:blank` after
close, dropping iframes/media/listeners; reopen inside the window
still reuses the loaded page (`cancelLeftPanelIdleUnload` on open).
`LEFT_PANEL_IDLE_MS = 2 * 60 * 1000`. Does NOT touch the right-side
`sidebar` view (webapp tabs — Telegram, X, WhatsApp, AI chat) — those
are longer-lived by design; follow-up decision whether to apply the
same idle unload there.
- **LANDED: Startpage light theme palette flip under `body.light`.**
[home.html](home.html): the existing `@media (prefers-color-scheme:
light)` block only flipped tokens when the OS preferred light. Picking
the "light" background preset (`body.light`, set by `applyBg()`) left
`--ink` white against a white page, so hero title, SM card titles,
slot labels and the ".bch" extension read as pale ghosts. Added a
full `body.light { --ink; --mut; --card; --line; ... }` block plus
overrides for the hero/card translucent panels (dark-glass → light-
glass on light theme so the dark text inside reads), the ctx-menu
and modal (white surfaces, dark text). Dark theme untouched.
## session: `parallel:collision-policy`

View file

@ -0,0 +1,32 @@
{
"id": "nostr",
"name": "Nostr",
"version": "0.1.0",
"description": "Sign in to Nostr apps with the identity Theseus already holds. Adds window.nostr (NIP-07): sites can ask who you are and ask you to sign, one approval at a time, and your key never leaves the browser.",
"author": "Silent Mode",
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0Ij4gPHJlY3QgeD0iMSIgeT0iMSIgd2lkdGg9IjIyIiBoZWlnaHQ9IjIyIiByeD0iNS41IiBmaWxsPSIjOGU1Y2Y3Ii8+IDxjaXJjbGUgY3g9IjkuMyIgY3k9IjkuMyIgcj0iMy42IiBmaWxsPSJub25lIiBzdHJva2U9IiNmZmZmZmYiIHN0cm9rZS13aWR0aD0iMi40Ii8+IDxwYXRoIGQ9Ik0xMS45IDExLjkgMTkgMTkiIHN0cm9rZT0iI2ZmZmZmZiIgc3Ryb2tlLXdpZHRoPSIyLjQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPiA8cGF0aCBkPSJNMTYuMiAxNi4ybC0xLjkgMS45IiBzdHJva2U9IiNmZmZmZmYiIHN0cm9rZS13aWR0aD0iMi40IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz4gPC9zdmc+",
"main": "index.js",
"activation": "on-demand",
"panels": [
{
"id": "main",
"title": "Nostr",
"page": "panel.html"
}
],
"capabilities": [
"sidebar-panel",
"page-inject",
"approval-modal"
],
"page-inject": {
"preload": "nostr-inject.js",
"origins": [
"https://*/*",
"http://localhost/*",
"http://127.0.0.1/*",
"http://[::1]/*"
]
},
"updateURL": "https://navigate.st/bns/theseus.x/extensions/nostr/updates.json"
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"> <rect x="1" y="1" width="22" height="22" rx="5.5" fill="#8e5cf7"/> <circle cx="9.3" cy="9.3" r="3.6" fill="none" stroke="#ffffff" stroke-width="2.4"/> <path d="M11.9 11.9 19 19" stroke="#ffffff" stroke-width="2.4" stroke-linecap="round"/> <path d="M16.2 16.2l-1.9 1.9" stroke="#ffffff" stroke-width="2.4" stroke-linecap="round"/> </svg>

After

Width:  |  Height:  |  Size: 421 B

View file

@ -0,0 +1,203 @@
// Nostr — NIP-07 signer for Theseus.
//
// Splits the same way Hermes does, for the same reason: the identity key is
// derived from the vault and lives in the host (`global._nostrAPI`, backed by
// lib/nostr-signer.js). This file never sees it. What lives HERE is policy —
// which origin may ask for what, when to prompt, and what the prompt says —
// because policy needs UI and storage, and the crypto should stay pure.
//
// Flow for a page call:
// page → nostr-inject.js (window.nostr) → onMessage here → permission check
// → approval modal if needed → global._nostrAPI → signed result back
//
// The origin is NOT taken from the page. Main derives it from the tab's
// committed URL and passes it in `ctx`; a page cannot claim to be someone
// else, and only the top-level frame can speak at all.
const STORE_KEY = "grants"; // { "<origin>": { level, at, kinds: {} } }
const LEVELS = { read: 1, sign: 2 }; // sign implies read
// Kinds that rewrite or destroy something, where "you already allowed this
// site" is not good enough — the user should see what is being signed even
// on a trusted origin. Deleting notes or replacing a contact list is not the
// same act as posting.
const ALWAYS_ASK_KINDS = new Map([
[0, "replace your profile"],
[3, "replace your contact list"],
[5, "delete events"],
[10002, "replace your relay list"],
]);
const host = () => {
const api = global._nostrAPI;
if (!api) throw new Error("the Nostr signer is unavailable in this Theseus build");
return api;
};
module.exports = {
activate(api) {
api.registerSidebarPanel({ id: "main", title: "Nostr", page: "panel.html" });
// ---- grant storage ------------------------------------------------------
const allGrants = () => {
const g = api.storage.get(STORE_KEY, {});
return g && typeof g === "object" ? g : {};
};
const grantFor = (origin) => allGrants()[origin] || null;
function setGrant(origin, level) {
const g = allGrants();
g[origin] = { level, at: Date.now() };
api.storage.set(STORE_KEY, g);
}
function dropGrant(origin) {
const g = allGrants();
delete g[origin];
api.storage.set(STORE_KEY, g);
}
function has(origin, need) {
const g = grantFor(origin);
if (!g) return false;
return (LEVELS[g.level] || 0) >= (LEVELS[need] || 0);
}
// ---- the prompt ---------------------------------------------------------
// One shape for every request so the user learns to read it: who is
// asking, what they want, and what exactly gets signed.
async function ask({ origin, title, body, rows, remember }) {
const actions = [{ id: "deny", label: "Deny" }, { id: "allow", label: "Allow", primary: true }];
const r = await api.approvalModal({
title, body, origin, rows: rows || [],
actions,
checkbox: remember ? { id: "always", label: `Always allow this on ${origin}` } : null,
});
const s = String(r || "");
if (!s.startsWith("allow")) return { ok: false };
return { ok: true, remember: s.includes("+always") };
}
async function gate(ctx, need, prompt) {
const origin = ctx && ctx.origin;
if (!origin) throw new Error("this call did not come from a page");
if (has(origin, need)) return origin;
const r = await ask({ origin, remember: true, ...prompt });
if (!r.ok) throw new Error("denied");
if (r.remember) setGrant(origin, need);
return origin;
}
// Readable one-line summary of what a sign request actually is.
function describeEvent(ev) {
const kind = Number(ev && ev.kind);
const special = ALWAYS_ASK_KINDS.get(kind);
if (special) return `kind ${kind} — ${special}`;
if (kind === 1) return "kind 1 — a public note";
if (kind === 7) return "kind 7 — a reaction";
if (kind === 4 || kind === 14) return `kind ${kind} — a direct message`;
return `kind ${kind}`;
}
const preview = (s, n = 180) => {
const t = String(s ?? "");
return t.length > n ? t.slice(0, n) + "…" : t;
};
// ---- page-facing methods ------------------------------------------------
api.onMessage("getPublicKey", async (_p, ctx) => {
const st = await host().status();
if (!st.ready) throw new Error(st.reason);
await gate(ctx, "read", {
title: "Share your Nostr identity?",
body: "This site is asking which Nostr account you are. It will see your public key — "
+ "the same one your notes are signed with. It cannot post or read messages with this alone.",
rows: [{ label: "Your key", value: st.npub, mono: true }],
});
return (await host().getPublicKey()).pubkey;
});
api.onMessage("signEvent", async (p, ctx) => {
const st = await host().status();
if (!st.ready) throw new Error(st.reason);
const ev = (p && p.event) || {};
const kind = Number(ev.kind);
const origin = ctx && ctx.origin;
if (!origin) throw new Error("this call did not come from a page");
// Destructive or identity-rewriting kinds are shown every time, even on
// a site the user already trusts for ordinary posting.
const special = ALWAYS_ASK_KINDS.get(kind);
if (special || !has(origin, "sign")) {
const rows = [{ label: "Event", value: describeEvent(ev) }];
if (typeof ev.content === "string" && ev.content) rows.push({ label: "Content", value: preview(ev.content) });
if (Array.isArray(ev.tags) && ev.tags.length) rows.push({ label: "Tags", value: preview(JSON.stringify(ev.tags), 120), mono: true });
const r = await ask({
origin,
title: special ? "Sign something that changes your account?" : "Sign as you on Nostr?",
body: special
? `This site wants to ${special}. This is published under your name and other clients will follow it.`
: "This site wants to publish an event signed with your Nostr key. Anyone can see what you sign.",
// Never offer "always" for the destructive kinds.
remember: !special,
rows,
});
if (!r.ok) throw new Error("denied");
if (r.remember) setGrant(origin, "sign");
}
return (await host().signEvent({ event: ev })).event;
});
const cryptoCall = (name, need, label, legacy) =>
api.onMessage(name, async (p, ctx) => {
const st = await host().status();
if (!st.ready) throw new Error(st.reason);
await gate(ctx, need, {
title: `Let this site ${label} with your key?`,
body: (legacy
? "This uses NIP-04, the older scheme, which leaks message length and who you are talking to. "
: "This uses NIP-44. ")
+ `The site will be able to ${label} messages between you and the key below for as long as it is allowed.`,
rows: [{ label: "Other party", value: String((p && p.peer) || "?"), mono: true }],
});
const fn = host()[name];
return (await fn.call(host(), p || {})).result;
});
cryptoCall("nip04Encrypt", "sign", "encrypt", true);
cryptoCall("nip04Decrypt", "sign", "read", true);
cryptoCall("nip44Encrypt", "sign", "encrypt", false);
cryptoCall("nip44Decrypt", "sign", "read", false);
// ---- panel methods ------------------------------------------------------
// These come from the add-on's own page (ctx.from === "panel"), never a
// website, so they carry no origin gate — but they must refuse a page.
const panelOnly = (ctx) => {
if (ctx && ctx.from === "page") throw new Error("not available to pages");
};
api.onMessage("status", async (_p, ctx) => { panelOnly(ctx); return host().status(); });
api.onMessage("listGrants", async (_p, ctx) => {
panelOnly(ctx);
const g = allGrants();
return {
ok: true,
grants: Object.entries(g)
.map(([origin, v]) => ({ origin, level: v.level, at: v.at || 0 }))
.sort((a, b) => b.at - a.at),
};
});
api.onMessage("revoke", async (p, ctx) => {
panelOnly(ctx);
const origin = String((p && p.origin) || "");
if (!origin) throw new Error("no origin");
dropGrant(origin);
api.log(`revoked ${origin}`);
return { ok: true };
});
api.onMessage("revokeAll", async (_p, ctx) => {
panelOnly(ctx);
api.storage.set(STORE_KEY, {});
api.log("revoked every site");
return { ok: true };
});
api.log("registered NIP-07 signer");
},
};

View file

@ -0,0 +1,72 @@
// window.nostr — the NIP-07 provider.
//
// Runs in the isolated world of every matching tab and exposes the standard
// surface on the page's main world. Nothing here can sign: every method is a
// round trip into the add-on, which checks the per-origin grant, may raise an
// approval modal, and only then asks the host (which holds the key).
//
// `theseus` is provided by the host: { id, origin, contextBridge, invoke }.
//
// Shape follows NIP-07 exactly, because clients feature-detect on it:
// await window.nostr.getPublicKey() -> hex string
// await window.nostr.signEvent(event) -> signed event
// await window.nostr.nip04.encrypt(pubkey, plain) -> ciphertext
// await window.nostr.nip04.decrypt(pubkey, cipher) -> plaintext
// await window.nostr.nip44.encrypt(pubkey, plain) -> ciphertext
// await window.nostr.nip44.decrypt(pubkey, cipher) -> plaintext
// getRelays() is deliberately absent — see the note at the bottom.
const call = (msg, payload) =>
theseus.invoke(msg, payload).catch((e) => {
// Electron wraps handler errors; unwrap so a client sees "denied", not
// "Error invoking remote method 'addon-page-msg': Error: denied".
const text = String((e && e.message) || e)
.replace(/^Error invoking remote method '[^']+': (Error: )?/, "");
throw new Error(text);
});
// Only plain data crosses the bridge. A page could hand us an object with
// getters or a prototype that misbehaves once it is read on the other side;
// round-tripping through JSON pins the value at the moment of the call.
function plain(value) {
try { return JSON.parse(JSON.stringify(value ?? null)); }
catch { throw new Error("that value cannot be sent to the signer"); }
}
const nostr = {
async getPublicKey() {
return call("getPublicKey", {});
},
async signEvent(event) {
if (!event || typeof event !== "object") throw new Error("signEvent: an event object is required");
return call("signEvent", { event: plain(event) });
},
nip04: {
async encrypt(pubkey, plaintext) {
return call("nip04Encrypt", { peer: String(pubkey || ""), plaintext: String(plaintext ?? "") });
},
async decrypt(pubkey, ciphertext) {
return call("nip04Decrypt", { peer: String(pubkey || ""), ciphertext: String(ciphertext ?? "") });
},
},
nip44: {
async encrypt(pubkey, plaintext) {
return call("nip44Encrypt", { peer: String(pubkey || ""), plaintext: String(plaintext ?? "") });
},
async decrypt(pubkey, ciphertext) {
return call("nip44Decrypt", { peer: String(pubkey || ""), ciphertext: String(ciphertext ?? "") });
},
},
// Not implemented on purpose. getRelays() is optional in NIP-07, and every
// client treats an absent method as "the user has no relay preference here"
// and falls back to its own list. Returning an empty object instead would
// read as "this user has zero relays", which some clients act on by
// publishing nowhere. Absent is the honest answer until Theseus exposes the
// Hermes relay set deliberately.
};
theseus.contextBridge.exposeInMainWorld("nostr", nostr);

View file

@ -0,0 +1,156 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Nostr</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;
--btn:#1c2432; --btn-h:#242e40; --err:#ff9081; --purple:#8e5cf7; }
@media (prefers-color-scheme: light) {
:root { --bg:#f8faff; --panel:#ffffff; --line:rgba(0,0,0,.10);
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5;
--btn:#f0f3fa; --btn-h:#e3e8f2; --acid:#088A66; --err:#c81f0f; --purple:#6d28d9; }
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body { background: var(--bg); color: var(--ink);
font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
display: flex; flex-direction: column; }
header { display: flex; align-items: center; gap: 8px;
padding: 10px 14px; border-bottom: 1px solid var(--line); background: var(--panel); }
header .t { font-weight: 600; display: flex; gap: 8px; align-items: center; }
header .t svg { width: 16px; height: 16px; display: block; }
header .m { color: var(--dim); font-size: 11.5px; flex: 1; text-align: right; }
main { flex: 1; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 14px; }
h2 { font-size: 11px; text-transform: uppercase; letter-spacing: .6px;
color: var(--dim); margin: 0 0 6px; font-weight: 600; }
.card { border: 1px solid var(--line); border-radius: 10px; background: var(--panel); padding: 12px; }
.key { font-family: ui-monospace, Consolas, monospace; font-size: 11.5px;
word-break: break-all; color: var(--ink); }
.muted { color: var(--mut); font-size: 12px; }
.warn { color: var(--err); }
button { border: 1px solid var(--line); border-radius: 7px; background: var(--btn);
color: var(--ink); cursor: pointer; font: inherit; padding: 6px 10px; }
button:hover:not(:disabled) { background: var(--btn-h); }
button:disabled { opacity: .45; cursor: default; }
button.sm { font-size: 11.5px; padding: 4px 8px; }
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.spread { justify-content: space-between; }
table { width: 100%; border-collapse: collapse; }
td { padding: 7px 0; border-top: 1px solid var(--line); vertical-align: middle; }
tr:first-child td { border-top: 0; }
td.o { word-break: break-all; }
.lvl { font-size: 10.5px; color: var(--dim); }
.empty { color: var(--dim); font-size: 12px; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--purple); flex: none; }
</style>
</head>
<body>
<header>
<div class="t">
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="1" y="1" width="22" height="22" rx="5.5" fill="#8e5cf7"/>
<circle cx="9.3" cy="9.3" r="3.6" fill="none" stroke="#fff" stroke-width="2.1"/>
<path d="M11.9 11.9 19 19M16.2 16.2l-2 2M18.1 18.1l-1.6 1.6" stroke="#fff" stroke-width="2.1" stroke-linecap="round"/>
</svg>
<span>Nostr</span>
</div>
<div class="m" id="hdr"></div>
</header>
<main>
<div>
<h2>Your identity</h2>
<div class="card" id="idCard"><span class="muted">Checking…</span></div>
</div>
<div>
<h2>Sites using your key</h2>
<div class="card" id="grants"><span class="empty">Loading…</span></div>
</div>
<div class="muted">
Theseus signs with the same key Hermes uses, so a note you post and a message
someone sends you belong to one identity. Your key never leaves Theseus — sites
ask it to sign and get back only the signature.
</div>
</main>
<script>
(() => {
"use strict";
const S = window.silentmode;
const $ = (id) => document.getElementById(id);
const el = (tag, props, ...kids) => {
const n = Object.assign(document.createElement(tag), props || {});
for (const k of kids.flat()) if (k != null && k !== false) n.append(k);
return n;
};
const put = (node, ...kids) => { node.textContent = ""; for (const k of kids.flat()) if (k != null && k !== false) node.append(k); };
async function copy(text, msg) {
try { await navigator.clipboard.writeText(text); flash(msg || "Copied"); }
catch { flash("Could not copy"); }
}
let flashT = null;
function flash(t) {
$("hdr").textContent = t;
clearTimeout(flashT);
flashT = setTimeout(() => { $("hdr").textContent = ""; }, 1600);
}
async function drawIdentity() {
let st;
try { st = await S.invoke("status", {}); }
catch (e) { put($("idCard"), el("span", { className: "warn", textContent: e?.message || String(e) })); return; }
if (!st || !st.ready) {
put($("idCard"),
el("div", { className: "muted", textContent: st?.reason || "No Nostr identity yet." }));
return;
}
put($("idCard"),
el("div", { className: "row spread" },
el("div", { className: "row" }, el("span", { className: "dot" }), el("strong", { textContent: "Connected" })),
el("button", { className: "sm", textContent: "Copy npub", onclick: () => copy(st.npub, "npub copied") })),
el("div", { className: "key", style: "margin-top:8px", textContent: st.npub }),
el("div", { className: "muted", style: "margin-top:6px" },
"Shared with Hermes — the same identity signs your messages."));
}
async function drawGrants() {
let r;
try { r = await S.invoke("listGrants", {}); }
catch (e) { put($("grants"), el("span", { className: "warn", textContent: e?.message || String(e) })); return; }
const list = (r && r.grants) || [];
if (!list.length) {
put($("grants"), el("span", { className: "empty" },
"No site has been allowed yet. The first time one asks, Theseus will show you what it wants."));
return;
}
const rows = list.map((g) => el("tr", {},
el("td", { className: "o" },
el("div", { textContent: g.origin }),
el("div", { className: "lvl", textContent: g.level === "sign" ? "can sign and encrypt" : "can see your public key" })),
el("td", { style: "text-align:right;width:84px" },
el("button", { className: "sm", textContent: "Revoke", onclick: async () => {
try { await S.invoke("revoke", { origin: g.origin }); flash("Revoked"); refresh(); }
catch (e) { flash(e?.message || "Failed"); }
} }))));
put($("grants"),
el("table", {}, el("tbody", {}, rows)),
el("div", { className: "row", style: "margin-top:10px;justify-content:flex-end" },
el("button", { className: "sm", textContent: "Revoke all", onclick: async () => {
try { await S.invoke("revokeAll", {}); flash("All revoked"); refresh(); }
catch (e) { flash(e?.message || "Failed"); }
} })));
}
function refresh() { drawIdentity(); drawGrants(); }
refresh();
// The list changes when a site is allowed from a prompt in another tab.
document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); });
})();
</script>
</body>
</html>

View file

@ -24,9 +24,9 @@ contextBridge.exposeInMainWorld("home", {
// Main pushes an updated list here after a successful remote refresh
// (only when the user has not customised locally). home.html re-renders.
onCards: (cb) => ipcRenderer.on("home-cards", (_e, list) => cb(list)),
// Current Theseus version — exposed so the startpage can show a tiny
// "What's new in Theseus 0.3.X" link next to the search box. The IPC is
// the same one Settings uses; it reads app.getVersion() with no network.
// Current Theseus version — the startpage renders a small
// "Theseus v0.3.X — What's new" link beside the hero that points at
// the release notes. IPC is the same one Settings uses; no network.
getVersion: () => ipcRenderer.invoke("app-version"),
});
// Same preload also serves the branded error page (error.html). Handlers in

View file

@ -242,6 +242,47 @@
transition: opacity .18s, transform .18s; z-index: 1200; }
.toast.on { opacity: 1; transform: translateX(-50%) translateY(0); }
/* Light-theme palette flip. Applies when the user picks the "light"
background preset (body.light, set by applyBg()) OR when the OS
prefers light (the @media block below). Every element that reads from
--ink / --mut / --card / --line follows along; the handful of hero
and card elements that hard-code dark-glass rgba() values (built
for dark page body) have their own body.light overrides so the panel
flips to light-glass and the dark text inside stays readable. */
body.light {
--bg1: #dce6f2; --bg2: #eef0f4;
--card: #ffffff; --card-hi: #f4f7fb;
--line: rgba(0,0,0,.10); --line2: rgba(0,0,0,.14);
--ink: #1a1f28; --mut: #4a5262; --dim: #8a93a2;
--acid: #0AC18E;
}
body.light .hero .hero-id { background: rgba(255,255,255,.65);
box-shadow: 0 10px 30px rgba(0,0,0,.08), inset 0 0 0 1px rgba(0,0,0,.06); }
body.light .hero .tag { background: rgba(255,255,255,.55); color: var(--ink); }
body.light .hero form input { background: rgba(255,255,255,.78); color: var(--ink); border-color: var(--line2); }
body.light .hero .hint { background: rgba(255,255,255,.55); color: var(--mut); }
body.light .card { background: rgba(255,255,255,.6); border-color: var(--line); }
body.light .card:hover { background: rgba(255,255,255,.85); border-color: rgba(75,123,236,.4); }
body.light .card .card-title,
body.light .card .card-sub { color: var(--ink); }
body.light .card .card-sub { color: var(--mut); }
body.light .hero .ver-line a { background: rgba(255,255,255,.5); color: var(--mut); }
body.light .hero .ver-line a:hover { color: var(--acid); }
body.light .card-add { background: rgba(255,255,255,.35); color: var(--mut); border-color: var(--line2); }
body.light .card-add:hover { color: var(--acid); border-color: var(--acid); }
body.light .slot .label { color: #0b0e14; text-shadow: 0 1px 2px rgba(255,255,255,.65); }
body.light .slot .tile { box-shadow: 0 3px 10px rgba(0,0,0,.12), inset 0 0 0 1px rgba(0,0,0,.05); }
body.light .ctx-menu { background: #ffffff; }
body.light .ctx-menu button { color: var(--ink); }
body.light .ctx-menu button:hover { background: #f0f3fa; }
body.light .ctx-menu button.danger { color: #c43c2c; }
body.light .modal { background: #ffffff; color: var(--ink); }
body.light .modal input, body.light .modal select { background: #f8faff; color: var(--ink); border-color: var(--line2); }
body.light .modal button { color: var(--ink); border-color: var(--line2); }
body.light .modal button:hover { background: #f0f3fa; }
body.light .modal button.primary { background: var(--blue); color: #ffffff; border-color: var(--blue); }
body.light .toast { background: #ffffff; color: var(--ink); border-color: var(--line2); }
@media (prefers-color-scheme: light) {
:root { --bg1: #dce6f2; --bg2: #eef0f4;
--card: #ffffff; --card-hi: #f4f7fb; --line: rgba(0,0,0,.10); --line2: rgba(0,0,0,.14);
@ -384,6 +425,7 @@
pithos: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><path d="M22 18h20v4c0 2-2 3-2 5 8 3 14 10 14 20 0 10-9 17-22 17S10 57 10 47c0-10 6-17 14-20 0-2-2-3-2-5z" fill="#3ddc97"/><path d="M14 36h36M14 44h36" stroke="#0b1f18" stroke-width="2.5" stroke-linecap="round" opacity=".55"/></svg>`,
aegis: `<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"><polygon points="16,2 28,9 28,23 16,30 4,23 4,9" fill="#0a0a0d" stroke="#D6FF3D" stroke-width="1.6" stroke-linejoin="round"/><circle cx="16" cy="16" r="4.5" fill="none" stroke="#D6FF3D" stroke-width="1.4"/><circle cx="16" cy="16" r="1.6" fill="#D6FF3D"/></svg>`,
bergamot: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><path d="M14 50 C 14 28, 30 14, 54 14 C 54 36, 40 50, 18 50 C 20 42, 30 32, 44 28" fill="#9fd98f" stroke="#4fa66b" stroke-width="2" stroke-linejoin="round"/></svg>`,
game: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><rect x="8" y="20" width="48" height="26" rx="12" fill="#4b7bec"/><circle cx="22" cy="33" r="3.2" fill="#e7eaf1"/><circle cx="30" cy="33" r="3.2" fill="#e7eaf1"/><rect x="38" y="30.4" width="12" height="5.2" rx="1.2" fill="#e7eaf1"/><rect x="18" y="29" width="2.4" height="8" rx="1" fill="#0b0e14"/><rect x="14.8" y="32.2" width="8.8" height="2.4" rx="1" fill="#0b0e14"/></svg>`,
hellobch: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><text x="32" y="38" font-family="system-ui" font-weight="800" font-size="18" text-anchor="middle" fill="#8b98a9">hello</text><text x="32" y="54" font-family="system-ui" font-weight="800" font-size="13" text-anchor="middle" fill="#d6ff3d">.bch</text></svg>`,
siacoin: `<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" rx="12" fill="#0b0e14"/><circle cx="32" cy="32" r="18" fill="#3ddc97"/><text x="32" y="40" font-family="system-ui" font-weight="800" font-size="22" text-anchor="middle" fill="#0b0e14">S</text></svg>`,
};
@ -401,7 +443,7 @@
sections: {
sm: [
{ title: "Theseus.X", url: "https://theseus.x/", brand: "theseus", sub: "The Web Navigator — this browser's home." },
{ title: "SilentMode.X", url: "https://silentmode.x/", brand: "silentmode", sub: "Infrastructure for a decentralized web." },
{ title: "Game.X", url: "https://game.x/", brand: "game", sub: "Decentralized gaming on BCDN." },
{ title: "Sirius.X", url: "https://sirius.x/", brand: "sirius", sub: "Register and manage BCDN names." },
{ title: "Hephaestus.X", url: "https://hephaestus.x/", brand: "hephaestus", sub: "The forge — Silent Mode's code host." },
{ title: "Prometheus.X", url: "https://prometheus.x/", brand: "prometheus", sub: "Decentralized app marketplace." },
@ -412,12 +454,14 @@
],
// Your-links icons live in fixed grid cells; drag reorders which
// icon is in which cell. Order in the array = position in the grid
// (row-first, 3 per row).
// (row-first, 3 per row). SilentMode.X moved here from the sm cards
// so Game.X can take its slot on the right.
q: [
{ title: "hello.bch", url: "https://hello.bch/", brand: "hellobch" },
{ title: "sia.coin", url: "https://sia.coin/", brand: "siacoin" },
{ title: "Pithos.sia", url: "https://pithos.sia/", brand: "pithos" },
{ title: "Bergamot.X", url: "https://bergamot.x/", brand: "bergamot" },
{ title: "hello.bch", url: "https://hello.bch/", brand: "hellobch" },
{ title: "sia.coin", url: "https://sia.coin/", brand: "siacoin" },
{ title: "Pithos.sia", url: "https://pithos.sia/", brand: "pithos" },
{ title: "Bergamot.X", url: "https://bergamot.x/", brand: "bergamot" },
{ title: "SilentMode.X", url: "https://silentmode.x/", brand: "silentmode" },
],
},
};
@ -977,10 +1021,9 @@
if (name) $("q").placeholder = "Search the web with " + name;
}).catch(() => {});
}
// Running-version footer: "Theseus v0.3.X — What's new ↗". The link uses
// home.navigate so it opens release notes in this tab, same as any other
// link on the startpage. Hidden until the version resolves — kept off the
// page if the IPC isn't available (shouldn't happen in-app, but safe).
// Running-version footer: "Theseus v0.3.X — What's new ↗". Uses
// home.navigate so it opens release notes in this tab, same as any
// other link on the startpage. Hidden until the version resolves.
if (window.home && window.home.getVersion) {
window.home.getVersion().then((v) => {
if (!v) return;
@ -993,7 +1036,6 @@
line.hidden = false;
}).catch(() => {});
}
// Container-level drop handlers accept cross-column drops onto empty space.
wireContainer($("slotsList"), "q");
wireContainer($("cardsGrid"), "sm");

96
main.js
View file

@ -692,11 +692,12 @@ const SETTINGS_DEFAULTS = {
// service shortcuts; click opens the URL in a new tab. Toggle via settings.
quickLinksShow: true,
quickLinks: [
{ id: "proton", url: "https://mail.proton.me/", title: "Proton Mail" },
{ id: "telegram", url: "https://web.telegram.org/k/", title: "Telegram" },
{ id: "whatsapp", url: "https://web.whatsapp.com/", title: "WhatsApp" },
{ id: "x", url: "https://x.com/", title: "X" },
{ id: "youtube", url: "https://www.youtube.com/", title: "YouTube" },
{ id: "silentmail", url: "https://mail.silentmode.st/", title: "Silent Mode Mail" },
{ id: "proton", url: "https://mail.proton.me/", title: "Proton Mail" },
{ id: "telegram", url: "https://web.telegram.org/k/", title: "Telegram" },
{ id: "whatsapp", url: "https://web.whatsapp.com/", title: "WhatsApp" },
{ id: "x", url: "https://x.com/", title: "X" },
{ id: "youtube", url: "https://www.youtube.com/", title: "YouTube" },
],
// Settings › Sidebar. Per-app panel widths and pins (keys "q:<link id>" /
// "p:<panelId>"), extension panels the user took off the strip, and when
@ -3065,7 +3066,7 @@ function firstPartyAddonIds() {
// leave the bundle: their extensions-data/<id>.json and vault.derive
// namespace (e.g. pithos/sia-recovery/v1) outlive them, and a community
// add-on installed under a dropped id would inherit both.
for (const id of ["bchwallet", "siawallet", "aegis", "blocker", "consent", "docx-editor", "notepad", "pdf-editor", "pithos", "recorder", "screenshot", "translate", "vpn", "claude-connector"]) {
for (const id of ["bchwallet", "siawallet", "aegis", "blocker", "consent", "docx-editor", "mail", "notepad", "nostr", "pdf-editor", "pithos", "recorder", "screenshot", "translate", "vpn", "claude-connector"]) {
if (!bundled.has(id)) absorbed.add(id);
}
firstPartyIdsCache = { bundled, absorbed };
@ -3135,6 +3136,20 @@ function migrateAegisRename() {
try { fs.renameSync(siaDir, backup); console.log(`[addons] retired addons/siawallet -> addons-backups/${path.basename(backup)} (absorbed by aegis)`); }
catch (err) { console.warn("[addons] retire siawallet failed:", err?.message); }
}
// Mail retired as an extension — it's now a plain quicklink in the
// sidebar strip (settings.quickLinks "silentmail"). The old 0.1.0
// extension was just a landing page with an "Open Mail" button; the
// quicklink does the same thing without taking a slot in the
// Extensions list. Keep the user's mail addon folder visible via the
// backup for one release, in case the user had opened it recently and
// wants to reinstate it from community-extensions, then forget.
const mailDir = path.join(addonsRoot, "mail");
if (fs.existsSync(mailDir)) {
const backup = path.join(backups, `mail-migrated-${stamp}`);
try { fs.renameSync(mailDir, backup); console.log(`[addons] retired addons/mail -> addons-backups/${path.basename(backup)} (now a sidebar quicklink)`); }
catch (err) { console.warn("[addons] retire mail failed:", err?.message); }
}
}
function seedBundledAddons() {
const dst = addonsUserDir();
@ -10305,9 +10320,21 @@ ipcMain.handle("panel-grip-reset", (e) => {
layout();
saveSettings();
});
// A closed left panel's webContents is kept warm so reopening the same
// panel is instant and in-panel state (scroll, draft input, loaded data)
// survives. If the panel stays closed longer than LEFT_PANEL_IDLE_MS it
// gets blanked to about:blank — the render tree, iframes and media are
// freed, so a never-reopened panel doesn't hold RAM forever. Reopening
// after the unload reloads from scratch, same as a cold open.
const LEFT_PANEL_IDLE_MS = 2 * 60 * 1000;
let leftPanelIdleTimer = null;
function cancelLeftPanelIdleUnload() {
if (leftPanelIdleTimer) { clearTimeout(leftPanelIdleTimer); leftPanelIdleTimer = null; }
}
function openLeftPanel(panelId) {
const p = leftPanels().find((x) => x.panelId === panelId);
if (!leftPanel || !p) return false;
cancelLeftPanelIdleUnload();
activeQuickLinkId = null;
leftPanelId = panelId;
if (leftPanelLoadedId !== panelId) {
@ -10329,6 +10356,17 @@ function closeLeftPanel() {
layout();
emitQuickLinksState();
try { leftPanel?.webContents.send("sidebar-visibility", false); } catch {}
// Arm the idle unload. If the user reopens the same panel within the
// window the timer is cancelled in openLeftPanel and the loaded page
// is reused — so fast toggle keeps state, but a panel left closed
// frees its render tree.
cancelLeftPanelIdleUnload();
leftPanelIdleTimer = setTimeout(() => {
leftPanelIdleTimer = null;
if (leftPanelId) return; // reopened before the timer fired
leftPanelLoadedId = null;
try { leftPanel?.webContents.loadURL("about:blank"); } catch {}
}, LEFT_PANEL_IDLE_MS);
}
// The sidebar app for a URL's site (web.telegram.org -> Telegram), if the
// strip is on and has one.
@ -11351,6 +11389,52 @@ function parseVcardContacts(text) {
// remain for the standalone Messages window, which still uses them under
// HERMES_ONLY gating. Bodies kept inline / duplicated with the handlers so
// the refactor to a shared module stays optional.
// ---- NIP-07 signer (bundled-addons/nostr) -----------------------------------
// Same shape as _hermesAPI: the add-on proxies to this, so the identity key
// never crosses into add-on code. Identity is deliberately Hermes's — one
// npub for DMs and public notes alike (see lib/nostr-signer.js for why).
//
// Policy — which origin may call, and whether to prompt — is NOT here. The
// add-on owns that, because it needs the approval modal and per-origin
// storage; this stays a pure crypto surface that fails closed when locked.
let nostrSignerInst = null;
async function nostrSigner() {
if (nostrSignerInst) return nostrSignerInst;
const { createSigner } = await import(`file://${path.join(__dirname, "lib", "nostr-signer.js").replace(/\\/g, "/")}`);
const H = await loadHermesLib();
nostrSignerInst = createSigner({
getSk: () => {
// Only mnemonic vaults carry messengerRoot; a vault without one has no
// Nostr identity at all, which is a different problem from "locked".
if (!vaultState || !vaultState.messengerRoot) return null;
return H.nostrKeyFromRoot(vaultState.messengerRoot).sk;
},
});
return nostrSignerInst;
}
global._nostrAPI = {
async status() {
const hasVault = !!vaultState;
const hasIdentity = !!(vaultState && vaultState.messengerRoot);
if (!hasIdentity) {
return {
ok: true, ready: false,
reason: hasVault
? "This vault has no messaging identity. Set up Hermes to create one."
: "Unlock the password vault to use your Nostr identity.",
};
}
const s = await nostrSigner();
return { ok: true, ready: true, pubkey: s.getPublicKey(), npub: s.npub() };
},
async getPublicKey() { return { ok: true, pubkey: (await nostrSigner()).getPublicKey() }; },
async signEvent({ event } = {}) { return { ok: true, event: (await nostrSigner()).signEvent(event) }; },
async nip04Encrypt({ peer, plaintext } = {}) { return { ok: true, result: await (await nostrSigner()).nip04Encrypt(peer, plaintext) }; },
async nip04Decrypt({ peer, ciphertext } = {}) { return { ok: true, result: await (await nostrSigner()).nip04Decrypt(peer, ciphertext) }; },
async nip44Encrypt({ peer, plaintext } = {}) { return { ok: true, result: (await nostrSigner()).nip44Encrypt(peer, plaintext) }; },
async nip44Decrypt({ peer, ciphertext } = {}) { return { ok: true, result: (await nostrSigner()).nip44Decrypt(peer, ciphertext) }; },
};
global._hermesAPI = {
status: () => {
if (!hermesState) return hOk({ ready: false });

View file

@ -1,6 +1,6 @@
{
"name": "theseus-navigator",
"version": "0.3.130",
"version": "0.3.131",
"license": "MPL-2.0",
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
"author": "Silent Mode",