Theseus 0.3.132: layout revert, Nostr per-site identities, Mail as sidebar quicklink, connector token stays persistent
Startpage layout reverted to the pre-0.3.131 shape. 0.3.131's full-page-
height sidebar was a mistake — slots in a short strip under a hero at
the top, cards and slots sharing one row of panels at the same height,
is what matches the design. .page/.main-col/.sidebar structure dropped,
hero back as a body sibling of .homepage, slots-list back to repeat(3,
72px) with minSlots=9. Two of the user's own tweaks baked into the
defaults: .section.chromeless on the left column so slot icons float
on the page background instead of sitting in a boxed panel, and
defaultState().iconSize = "large" (60 px tiles).
Game.X in the SM cards (replaces SilentMode.X in its slot, stub
description + a built-in gamepad brand mark until game.x goes live).
SilentMode.X moved to the quick-links strip as the 5th slot. moveItem
now strips the pinned-slot anchor when a q item moves to sm — a link
dragged onto the cards used to flip back on the next render because
its slot number was still set.
Nostr NIP-07 signer grows per-site identities (0.1.0 → 0.1.1). First
approval modal picks between the Hermes identity (one npub for DMs
and public notes, the one published as the np record on your BNS
name) and a site-specific HKDF-derived npub (unique per origin, so
Nostr clients can't cross-correlate your activity). The chooser shows
both npubs so the user sees what each button reveals. Grants without
a stored profileId (migrated from 0.3.131) default to the Hermes
identity so existing approvals keep behaving the same.
lib/hermes.js:nostrKeyFromRootForOrigin HKDFs with info
silentmode/nostr-origin/v1/<origin> over the existing 32-byte
messengerRoot. main.js _nostrAPI methods take {profileId, origin};
signer cache per profile.
Silent Mode Mail retired as an extension — now a Sidebar quicklink
(id: silentmail). Its 0.1.0 panel iframed 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 s "did it embed?"
watchdog saw it as loaded and the panel stayed black. The whole
extension was just a landing page with an Open Mail button — exactly
what a sidebar quicklink does, without taking a panel slot or an
Extensions row. migrateAegisRename auto-retires any existing
extensions/mail/ to addons-backups/mail-migrated-<stamp>/ on first
boot of this Theseus; "mail" added to the first-party reserved-ids
list so a community add-on can't claim the id.
Claude connector 0.2.1 → 0.2.2: the bearer token no longer rotates on
every server stop. The old stop() generated a fresh token every time
Theseus quit, the panel toggled off, or a 0.3.X install replaced the
app — the Connect button wrote the new token to ~/.claude.json but
any already-running Claude Code or Claude Desktop process held the
previous token in memory and only re-reads config on launch, so /mcp
reconnect kept sending the stale bearer and the server rejected it
forever. Infinite loop: install Theseus upgrade, click Connect, MCP
still 401, close Claude, open Claude, install another upgrade a day
later, repeat. The explicit Revoke token button still rotates for
users who want fresh keys; everything else is persistent.
Sidebar panel apps blank to about:blank after 2 min of being closed to
free their render tree (iframes, media, listeners). Fast reopen within
the window still reuses the loaded page.
Light theme palette flip under body.light — the full set of
--ink / --mut / --card / --line tokens flip plus hero + card
translucent panels switch to light-glass, so hero titles, SM card
text, slot labels and the ".bch" tld read correctly on the light
background preset instead of as pale ghosts.
This commit is contained in:
parent
9c29331e5b
commit
f76ca4f9f4
11 changed files with 453 additions and 217 deletions
94
PENDING.md
94
PENDING.md
|
|
@ -16,6 +16,100 @@ 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.131)
|
||||
|
||||
- **LANDED: Startpage layout reverted to the pre-0.3.131 default,
|
||||
plus the user's two preferred tweaks baked into the defaults — the
|
||||
slot column is chromeless (floats on the page background, no panel
|
||||
box around it) and default `iconSize` is "large" (60 px tiles).**
|
||||
Changes in [home.html](home.html):
|
||||
- HTML: `<div class="hero">` and `<div class="homepage">` back as
|
||||
body siblings (not nested in a `.page` grid with a sidebar aside).
|
||||
`.homepage` is the 2-col grid `minmax(260, 300) 1fr`, hero padded
|
||||
`5vh 1rem 0` as the top of the page again.
|
||||
- CSS: `.sidebar` renamed back to `.left-column`, `.cards-wrap` back
|
||||
to `.right-column`, the `.page` + `.main-col` grid dropped.
|
||||
`.section.chromeless` kept (brought back) — the left column uses it
|
||||
so slots float on the page background like the user had it; the SM
|
||||
cards keep the soft panel surface.
|
||||
- `defaultState().iconSize` is `"large"` (60 px slot tiles) —
|
||||
matches the sizing in the user's screenshot; existing profiles
|
||||
with their own `iconSize` keep that choice.
|
||||
- `.slots-list { grid-template-columns: repeat(3, 72px); width:
|
||||
max-content; margin: 0 auto }` — fixed-cell 232 px grid, centered
|
||||
in its panel. `layoutSlots` back to `minSlots = 9` (3 × 3 rows,
|
||||
matches the SM cards grid height). `minSlotsForFill()` and its
|
||||
`ResizeObserver` are gone.
|
||||
- `.hero .ver-line` CSS + its `body.light` override removed; the
|
||||
`<p class="ver-line" id="verLine">` markup gone; the init-script
|
||||
block that wired `getVersion()` → `navigate("silentmode.st/releases")`
|
||||
gone. The user asked for "What's new" to be dropped again after I
|
||||
restored it for 0.3.131; `window.home.getVersion` exposure in
|
||||
[home-preload.js](home-preload.js) also removed since nothing calls
|
||||
it any more.
|
||||
- `moveItem()` now strips the `slot` property when a q item moves to
|
||||
sm (link → card). Pin-to-slot is q-only, so leaving the anchor on
|
||||
an sm card re-pinned it back to a slot cell on the next revert.
|
||||
This fixes the user-reported "moving a link to card doesn't work"
|
||||
(actually it moved, but the stale slot anchor flipped it back on
|
||||
the next render).
|
||||
|
||||
- **Live user state reset.** Backed up `%APPDATA%\Theseus\home-cards.json`
|
||||
to `.bak-before-reset`, rewrote it to the restored defaults — SM
|
||||
cards list contains Game.X (replacing SilentMode.X in its slot),
|
||||
quicklinks strip holds hello.bch / sia.coin / Pithos.sia /
|
||||
Bergamot.X / SilentMode.X, plus any user-added extras preserved.
|
||||
Preserved: `background` (indigo preset) and `iconSize` (large).
|
||||
User must reload the open home tab (Ctrl+R or close/reopen) to pick
|
||||
up the new file.
|
||||
|
||||
- **LANDED: Claude connector no longer rotates its bearer on every
|
||||
stop (0.2.2).** The old `stop()` generated a fresh token on every
|
||||
server stop, so a Theseus quit, a panel toggle-off, or even a 0.3.X
|
||||
→ 0.3.X+1 install silently invalidated every registered Claude. The
|
||||
Connect button wrote the new token to `~/.claude.json`, but any
|
||||
already-running Claude Code or Claude Desktop process holds the
|
||||
previous token in memory and only re-reads config on launch, so
|
||||
`/mcp` reconnect kept sending the stale bearer and the server
|
||||
rejected it. Observed symptom from the connector log: after
|
||||
installing 0.3.131 and clicking Connect, `.claude.json` was
|
||||
updated (correct new token written at 01:02:53) but the running
|
||||
Claude Code session's MCP calls stayed 401 forever. Fix in
|
||||
[bundled-addons/claude-connector/index.js](bundled-addons/claude-connector/index.js):
|
||||
`stop()` keeps the stored token; only the panel's "Revoke token"
|
||||
button rotates. Addon version bumped 0.2.1 → 0.2.2 so
|
||||
`seedBundledAddons` picks up the fix on the first boot of 0.3.132.
|
||||
|
||||
- **LANDED: Nostr per-site identities (0.1.1).** The 0.3.131 Nostr add-on
|
||||
always signed with Hermes's one `npub`, which gives sites a single
|
||||
identity they can correlate across the Nostr network. 0.3.132 adds a
|
||||
per-origin profile picked on first approval:
|
||||
- `lib/hermes.js` gains `nostrKeyFromRootForOrigin(messengerRoot, origin)`
|
||||
— HKDF with info `silentmode/nostr-origin/v1/<origin>` over the
|
||||
existing 32-byte messengerRoot, so every site that chooses "site-
|
||||
specific identity" sees a different `npub` and clients can't cross-
|
||||
correlate. Deterministic from the mnemonic, no new vault field
|
||||
needed.
|
||||
- [main.js](main.js): `_nostrAPI` methods take `{ profileId, origin }`.
|
||||
`profileId` is `"hermes"` or `"origin"`; the signer cache keys on
|
||||
both. Per-origin secret keys are hot-cached under
|
||||
`nostrOriginSkCache` so the async HKDF only runs once per
|
||||
(vault, origin). Backward-compatible: callers that omit `profileId`
|
||||
get the Hermes identity (what 0.3.131 did).
|
||||
- [bundled-addons/nostr/index.js](bundled-addons/nostr/index.js): grant
|
||||
schema gains `profileId`. First-time approval modal shows BOTH npubs
|
||||
side-by-side and two Allow buttons ("Use site-specific identity" is
|
||||
primary, "Use my Hermes identity" is the opt-in to the shared key).
|
||||
Subsequent calls reuse the stored profile silently. Pre-upgrade
|
||||
grants (no `profileId` field) default to `"hermes"` so existing
|
||||
approvals keep behaving the same.
|
||||
- [bundled-addons/nostr/panel.html](bundled-addons/nostr/panel.html):
|
||||
each grant row shows which identity the site sees — "Shared Hermes
|
||||
identity" or "Site-specific identity" with the derived npub under
|
||||
it.
|
||||
- Addon version bumped 0.1.0 → 0.1.1 so `seedBundledAddons` reseeds
|
||||
the fresh-from-0.3.131 copy on the next Theseus start.
|
||||
|
||||
## Staged for the next release (post-0.3.130)
|
||||
|
||||
Queue of asks for the next cut. Items marked LANDED are edited in the
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "claude-connector",
|
||||
"name": "Claude connector",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.2",
|
||||
"description": "Lets Claude Code or Claude Desktop on this computer work in your Theseus tabs through a local MCP connection: read pages, click, type, paste and upload. Off until you turn it on; asks before each new site; never types into password fields; logs every action.",
|
||||
"author": "Silent Mode",
|
||||
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PHJlY3QgeD0iNCIgeT0iNCIgd2lkdGg9IjU2IiBoZWlnaHQ9IjU2IiByeD0iMTQiIGZpbGw9IiNkOTc3NTciLz48cGF0aCBkPSJNMzIgMTN2MzhNMTMgMzJoMzhNMTguNiAxOC42bDI2LjggMjYuOE00NS40IDE4LjYgMTguNiA0NS40IiBzdHJva2U9IiNmZmY0ZWUiIHN0cm9rZS13aWR0aD0iNiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIi8+PC9zdmc+DQo=",
|
||||
|
|
|
|||
|
|
@ -256,14 +256,18 @@ module.exports = {
|
|||
await controller.releaseAll();
|
||||
policy.reset();
|
||||
try { fs.rmSync(endpointFile, { force: true }); } catch {}
|
||||
// Rotate the bearer token on every stop so that re-enabling the
|
||||
// connector doesn't resurrect a credential a client (or anyone who
|
||||
// read its config while it was off) still has — matches the mental
|
||||
// model where "off" means "the keys change". The panel's installed-
|
||||
// command strings reflect the new token on the next start.
|
||||
// Token is PERSISTED across stop/start — a Theseus restart or a
|
||||
// panel toggle-off/on does NOT invalidate connected clients. The
|
||||
// old behavior rotated here on every stop, which looked tidy but
|
||||
// broke every registered Claude on every quit: the Connect button
|
||||
// writes the new token to ~/.claude.json, but any already-running
|
||||
// Claude Code or Claude Desktop process is holding the previous
|
||||
// token in memory and only re-reads on launch, so /mcp
|
||||
// reconnect kept sending the stale bearer. For explicit rotation
|
||||
// the panel's "Revoke token" button still exists and still
|
||||
// generates a fresh one.
|
||||
if (s) {
|
||||
api.storage.set("token", newToken());
|
||||
log.add({ tool: "connector", args: `${reason || "stopped"}; token rotated`, ok: true });
|
||||
log.add({ tool: "connector", args: reason || "stopped", ok: true });
|
||||
}
|
||||
pushStatus();
|
||||
})().finally(() => { stopping = null; });
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "nostr",
|
||||
"name": "Nostr",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"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+",
|
||||
|
|
|
|||
|
|
@ -13,9 +13,22 @@
|
|||
// 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.
|
||||
//
|
||||
// --- Per-site identities (0.3.132) ---------------------------------------
|
||||
// Each origin's grant carries a profileId chosen on the first approval:
|
||||
// "hermes" — the user's one messenger npub (also used by Hermes DMs and
|
||||
// publicly as the np record on their BNS name).
|
||||
// "origin" — an HKDF-derived per-origin npub, so sites can't correlate
|
||||
// posts across Nostr clients.
|
||||
// Grants with no profileId (migrated from pre-0.3.132) default to "hermes"
|
||||
// so existing approvals keep behaving the same.
|
||||
|
||||
const STORE_KEY = "grants"; // { "<origin>": { level, at, kinds: {} } }
|
||||
const LEVELS = { read: 1, sign: 2 }; // sign implies read
|
||||
const STORE_KEY = "grants"; // { "<origin>": { level, at, profileId, kinds: {} } }
|
||||
const LEVELS = { read: 1, sign: 2 }; // sign implies read
|
||||
const PROFILE_LABELS = {
|
||||
hermes: "Your Hermes identity",
|
||||
origin: "A site-specific identity (unique to this site)",
|
||||
};
|
||||
|
||||
// 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
|
||||
|
|
@ -44,9 +57,14 @@ module.exports = {
|
|||
return g && typeof g === "object" ? g : {};
|
||||
};
|
||||
const grantFor = (origin) => allGrants()[origin] || null;
|
||||
function setGrant(origin, level) {
|
||||
function setGrant(origin, level, profileId) {
|
||||
const g = allGrants();
|
||||
g[origin] = { level, at: Date.now() };
|
||||
const prev = g[origin] || {};
|
||||
g[origin] = {
|
||||
level,
|
||||
at: Date.now(),
|
||||
profileId: profileId || prev.profileId || "hermes",
|
||||
};
|
||||
api.storage.set(STORE_KEY, g);
|
||||
}
|
||||
function dropGrant(origin) {
|
||||
|
|
@ -60,30 +78,87 @@ module.exports = {
|
|||
if (!g) return false;
|
||||
return (LEVELS[g.level] || 0) >= (LEVELS[need] || 0);
|
||||
}
|
||||
// profileId for a known-granted origin, with the pre-0.3.132 default.
|
||||
function profileFor(origin) {
|
||||
const g = grantFor(origin);
|
||||
return (g && g.profileId) || "hermes";
|
||||
}
|
||||
|
||||
// ---- 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 }];
|
||||
// asking, what they want, and what exactly gets signed. On the first
|
||||
// approval for an origin, chooseProfile=true adds a profile-picker row:
|
||||
// two distinct "Allow" buttons for the two identities (the user's Hermes
|
||||
// key vs. a fresh per-site key); subsequent approvals reuse the stored
|
||||
// profile so the modal stays a single-click yes/no.
|
||||
async function ask({ origin, title, body, rows, remember, chooseProfile, previews }) {
|
||||
const actions = chooseProfile
|
||||
? [
|
||||
{ id: "deny", label: "Deny" },
|
||||
{ id: "allow-origin", label: "Use site-specific identity", primary: true },
|
||||
{ id: "allow-hermes", label: "Use my Hermes identity" },
|
||||
]
|
||||
: [{ id: "deny", label: "Deny" }, { id: "allow", label: "Allow", primary: true }];
|
||||
// When choosing, show both npubs so the user sees exactly what each
|
||||
// button will reveal to the site. previews is {hermes:{npub}, origin:{npub}}.
|
||||
const rowList = (rows || []).slice();
|
||||
if (chooseProfile && previews) {
|
||||
if (previews.hermes && previews.hermes.npub) {
|
||||
rowList.push({ label: "Your Hermes identity", value: previews.hermes.npub, mono: true });
|
||||
}
|
||||
if (previews.origin && previews.origin.npub) {
|
||||
rowList.push({ label: `Site-specific for ${origin}`, value: previews.origin.npub, mono: true });
|
||||
}
|
||||
}
|
||||
const r = await api.approvalModal({
|
||||
title, body, origin, rows: rows || [],
|
||||
title, body, origin, rows: rowList,
|
||||
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") };
|
||||
let profileId;
|
||||
if (s.startsWith("allow-origin")) profileId = "origin";
|
||||
else if (s.startsWith("allow-hermes")) profileId = "hermes";
|
||||
return { ok: true, remember: s.includes("+always"), profileId };
|
||||
}
|
||||
|
||||
// Compute both npubs so the first-time chooser modal can show them side
|
||||
// by side. Returns null on either if the vault has no identity.
|
||||
async function identityPreviews(origin) {
|
||||
try {
|
||||
const h = await host().status({ profileId: "hermes" });
|
||||
const o = await host().status({ profileId: "origin", origin });
|
||||
return {
|
||||
hermes: h.ready ? { npub: h.npub, pubkey: h.pubkey } : null,
|
||||
origin: o.ready ? { npub: o.npub, pubkey: o.pubkey } : null,
|
||||
ready: !!(h.ready && o.ready),
|
||||
reason: h.ready ? (o.ready ? null : o.reason) : h.reason,
|
||||
};
|
||||
} catch (e) {
|
||||
return { ready: false, reason: e && e.message || String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
// gate() returns {origin, profileId} so callers know which identity to
|
||||
// sign with. First-time gates show the profile chooser; subsequent calls
|
||||
// reuse the stored profileId silently.
|
||||
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 (has(origin, need)) return { origin, profileId: profileFor(origin) };
|
||||
const previews = await identityPreviews(origin);
|
||||
if (!previews.ready) throw new Error(previews.reason || "no identity available");
|
||||
const r = await ask({
|
||||
origin, remember: true,
|
||||
chooseProfile: !grantFor(origin),
|
||||
previews,
|
||||
...prompt,
|
||||
});
|
||||
if (!r.ok) throw new Error("denied");
|
||||
if (r.remember) setGrant(origin, need);
|
||||
return origin;
|
||||
const profileId = r.profileId || profileFor(origin);
|
||||
if (r.remember) setGrant(origin, need, profileId);
|
||||
return { origin, profileId };
|
||||
}
|
||||
|
||||
// Readable one-line summary of what a sign request actually is.
|
||||
|
|
@ -103,53 +178,73 @@ module.exports = {
|
|||
|
||||
// ---- 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", {
|
||||
const { origin, profileId } = 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 }],
|
||||
+ "the same one your notes get signed with. It cannot post or read messages with this alone.\n\n"
|
||||
+ "Pick the Hermes identity if this site is one you want to be publicly linkable to the rest "
|
||||
+ "of your Nostr activity; pick a site-specific identity to keep this site isolated.",
|
||||
});
|
||||
return (await host().getPublicKey()).pubkey;
|
||||
return (await host().getPublicKey({ profileId, origin })).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")) {
|
||||
// A sign request promotes the grant to "sign" level. If no grant yet,
|
||||
// this is also the profile-chooser moment.
|
||||
let profileId;
|
||||
if (!has(origin, "sign")) {
|
||||
const previews = await identityPreviews(origin);
|
||||
if (!previews.ready) throw new Error(previews.reason || "no identity available");
|
||||
const chooseProfile = !grantFor(origin);
|
||||
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 special = ALWAYS_ASK_KINDS.get(kind);
|
||||
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,
|
||||
chooseProfile,
|
||||
previews,
|
||||
rows,
|
||||
});
|
||||
if (!r.ok) throw new Error("denied");
|
||||
if (r.remember) setGrant(origin, "sign");
|
||||
profileId = r.profileId || profileFor(origin);
|
||||
if (r.remember) setGrant(origin, "sign", profileId);
|
||||
} else {
|
||||
profileId = profileFor(origin);
|
||||
// Destructive kinds re-prompt every time even on a trusted site, but
|
||||
// the profile is fixed (same profile this origin has already been
|
||||
// posting with), so no chooser — just a per-event confirmation.
|
||||
const special = ALWAYS_ASK_KINDS.get(kind);
|
||||
if (special) {
|
||||
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: "Sign something that changes your account?",
|
||||
body: `This site wants to ${special}. This is published under your name and other clients will follow it.`,
|
||||
remember: false,
|
||||
rows,
|
||||
});
|
||||
if (!r.ok) throw new Error("denied");
|
||||
}
|
||||
}
|
||||
return (await host().signEvent({ event: ev })).event;
|
||||
return (await host().signEvent({ event: ev, profileId, origin })).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, {
|
||||
const { origin, profileId } = 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. "
|
||||
|
|
@ -158,7 +253,7 @@ module.exports = {
|
|||
rows: [{ label: "Other party", value: String((p && p.peer) || "?"), mono: true }],
|
||||
});
|
||||
const fn = host()[name];
|
||||
return (await fn.call(host(), p || {})).result;
|
||||
return (await fn.call(host(), { ...(p || {}), profileId, origin })).result;
|
||||
});
|
||||
|
||||
cryptoCall("nip04Encrypt", "sign", "encrypt", true);
|
||||
|
|
@ -172,16 +267,41 @@ module.exports = {
|
|||
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("status", async (p, ctx) => {
|
||||
panelOnly(ctx);
|
||||
// The panel's "status" is always the Hermes profile (that's the one it
|
||||
// shows as "Your identity" at the top). Per-origin keys are listed in
|
||||
// the grants section with their own derived npubs.
|
||||
return host().status({ profileId: "hermes" });
|
||||
});
|
||||
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),
|
||||
};
|
||||
const rows = Object.entries(g).map(([origin, v]) => ({
|
||||
origin,
|
||||
level: v.level,
|
||||
at: v.at || 0,
|
||||
profileId: v.profileId || "hermes",
|
||||
profileLabel: PROFILE_LABELS[v.profileId || "hermes"] || "Hermes identity",
|
||||
}));
|
||||
// Enrich with the derived npub for site-specific grants so the panel
|
||||
// can show the user exactly which key the site sees. Skip if the vault
|
||||
// is locked (status not ready).
|
||||
const h = await host().status({ profileId: "hermes" });
|
||||
if (h.ready) {
|
||||
for (const r of rows) {
|
||||
if (r.profileId === "origin") {
|
||||
try {
|
||||
const o = await host().status({ profileId: "origin", origin: r.origin });
|
||||
if (o.ready) r.npub = o.npub;
|
||||
} catch {}
|
||||
} else {
|
||||
r.npub = h.npub;
|
||||
}
|
||||
}
|
||||
}
|
||||
rows.sort((a, b) => b.at - a.at);
|
||||
return { ok: true, grants: rows };
|
||||
});
|
||||
api.onMessage("revoke", async (p, ctx) => {
|
||||
panelOnly(ctx);
|
||||
|
|
@ -198,6 +318,6 @@ module.exports = {
|
|||
return { ok: true };
|
||||
});
|
||||
|
||||
api.log("registered NIP-07 signer");
|
||||
api.log("registered NIP-07 signer (per-origin profiles)");
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -128,15 +128,28 @@
|
|||
"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"); }
|
||||
} }))));
|
||||
// Per-origin row: origin + capability + which identity it sees (Hermes
|
||||
// key vs. a site-specific derived key with its own npub) + revoke. The
|
||||
// identity label matters — a user glancing down the list should see at
|
||||
// once which sites share their "public" Hermes npub and which are
|
||||
// isolated per-site.
|
||||
const rows = list.map((g) => {
|
||||
const profileLine = g.profileId === "origin"
|
||||
? el("div", { className: "lvl" },
|
||||
document.createTextNode("Site-specific identity"),
|
||||
g.npub ? el("div", { className: "key", style: "margin-top:2px;font-size:10.5px;opacity:.8", textContent: g.npub }) : null)
|
||||
: el("div", { className: "lvl", textContent: "Shared Hermes identity" });
|
||||
return 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" }),
|
||||
profileLine),
|
||||
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" },
|
||||
|
|
|
|||
|
|
@ -24,10 +24,6 @@ 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 — 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
|
||||
// main.js sender-check for error.html so a random page seeing the API shape
|
||||
|
|
|
|||
204
home.html
204
home.html
|
|
@ -12,12 +12,12 @@
|
|||
html, body { margin: 0; padding: 0; min-height: 100vh; font-family: system-ui, sans-serif; color: var(--ink); }
|
||||
body { background: radial-gradient(1200px 600px at 50% -10%, var(--bg1), var(--bg2)); }
|
||||
|
||||
/* Hero (identity + search). Lives inside the right-hand .main-col,
|
||||
centered within it. The outer .page grid handles the top padding so
|
||||
the hero's own top padding is 0 — this keeps the sidebar's top edge
|
||||
flush with the hero's top edge; the slots start at the same height
|
||||
as the Theseus logo + search box. */
|
||||
.hero { text-align: center; padding: 0 1rem; max-width: 820px; margin: 0 auto; }
|
||||
/* Hero (identity + search). Centered at the top of the page — the
|
||||
user's most-used thing on this screen. The slot + cards grid lives
|
||||
beneath. The mark + wordmark share a translucent panel so they read
|
||||
on any wallpaper; the tag, search box and hint sit outside it,
|
||||
centered. */
|
||||
.hero { text-align: center; padding: 5vh 1rem 0; max-width: 820px; margin: 0 auto; }
|
||||
.hero .hero-id { display: inline-flex; align-items: center; gap: 14px;
|
||||
padding: 12px 24px 12px 14px; border-radius: 22px;
|
||||
background: rgba(11,14,20,.55); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
|
||||
|
|
@ -39,58 +39,38 @@
|
|||
.hero .hint { color: var(--mut); font-size: 12.5px; margin-top: .8rem;
|
||||
display: inline-block; padding: 4px 12px; border-radius: 999px;
|
||||
background: rgba(11,14,20,.42); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); }
|
||||
/* "Theseus v0.3.X — What's new ↗" line under the hint. Muted so it does
|
||||
not pull attention from the search box, but visible enough that a user
|
||||
who just updated has a one-click path to the release notes. Dynamic:
|
||||
version comes from the running app via home-preload. */
|
||||
.hero .ver-line { margin: .6rem 0 0; font-size: 11.5px; }
|
||||
.hero .ver-line a { color: var(--mut); text-decoration: none;
|
||||
padding: 2px 10px; border-radius: 999px;
|
||||
background: rgba(11,14,20,.3); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); }
|
||||
.hero .ver-line a:hover { color: var(--acid); }
|
||||
|
||||
/* Page-level 2-column layout: the sidebar is a left column that starts
|
||||
at the top of the page (same top edge as the hero's title) and spans
|
||||
the full height of the content, so the slot grid sits beside the
|
||||
Theseus logo + search box instead of below them. The main column
|
||||
stacks the hero (title + search + version link) on top of the Silent
|
||||
Mode cards. Narrow viewports collapse to one column: sidebar first,
|
||||
then hero, then cards. */
|
||||
.page { display: grid; grid-template-columns: minmax(240px, 280px) minmax(380px, 1fr);
|
||||
gap: 28px; max-width: 1680px; margin: 0 auto;
|
||||
padding: 32px 32px 48px; align-items: start; }
|
||||
.main-col { min-width: 0; display: flex; flex-direction: column; gap: 2rem; }
|
||||
@media (max-width: 900px) { .page { grid-template-columns: 1fr; gap: 20px; padding: 14px; }
|
||||
.main-col { gap: 1.4rem; } }
|
||||
/* Two sections under the hero: a short "Your links" slot grid on the
|
||||
LEFT (3 cols × 3 rows by default — matches the SM cards grid in
|
||||
height so the two columns read as a single row of panels) and the
|
||||
Silent Mode cards on the RIGHT. Each sits in its own soft panel so
|
||||
they read as grouped collections, not a shared body. Collapses to
|
||||
one column on narrow viewports. */
|
||||
.homepage { display: grid; grid-template-columns: minmax(260px, 300px) minmax(380px, 1fr);
|
||||
gap: 24px; max-width: 1680px; margin: 2.4rem auto 0; padding: 0 32px 48px; align-items: start; }
|
||||
@media (max-width: 900px) { .homepage { grid-template-columns: 1fr; gap: 20px; padding: 0 14px 32px; } }
|
||||
|
||||
/* The RIGHT column keeps its subtle panel surface — the Silent Mode cards
|
||||
read as a grouped collection and benefit from the soft tint. The LEFT
|
||||
column opts out via the chromeless class below, which overrides this. */
|
||||
.section { background: rgba(255,255,255,.025); border-radius: 16px;
|
||||
padding: 16px 16px 20px; box-shadow: inset 0 0 0 1px rgba(255,255,255,.03);
|
||||
width: 100%; min-width: 0; }
|
||||
body.light .section { background: rgba(0,0,0,.03); box-shadow: inset 0 0 0 1px rgba(0,0,0,.03); }
|
||||
/* The slot column opts out of the section panel chrome — slots float on
|
||||
the page background so the icons read as "yours, loose on the desktop"
|
||||
while the SM cards stay in a grouped panel on the right. */
|
||||
.section.chromeless { background: transparent; box-shadow: none; padding: 4px 0 0; }
|
||||
body.light .section.chromeless { background: transparent; box-shadow: none; }
|
||||
.section-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .9px;
|
||||
color: var(--mut); margin: 0 2px 14px; }
|
||||
|
||||
/* LEFT AREA — a fixed 3-column grid of slots. Each slot holds one link
|
||||
tile; empty slots are invisible by default and reveal subtly on
|
||||
container hover so the user can see where a dragged tile will land.
|
||||
3 × 72 px tiles + 2 × 8 px gaps = 232 px — the sidebar column is sized
|
||||
to that so the grid sits flush with the hero and doesn't float in
|
||||
half-empty whitespace. */
|
||||
/* The sidebar grid cell stretches to match the main column's height, so
|
||||
the sidebar panel is as tall as the hero + Silent Mode cards combined.
|
||||
The slots-list fills that full area: 3 columns at 1fr each (stretch to
|
||||
sidebar width — the slot's inner tile stays 48 px and centers inside
|
||||
the wider cell), and enough rows to cover the sidebar's vertical span
|
||||
(row count computed in render() from the main-col height, so a taller
|
||||
page gets more rows automatically). */
|
||||
.sidebar { align-self: stretch; }
|
||||
.slots-list { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px 8px;
|
||||
width: 100%; align-items: start; justify-content: stretch; }
|
||||
/* LEFT COLUMN — a fixed 3×3 slot grid (72 px cells, 232 px wide). The
|
||||
grid centers inside its panel so it doesn't lean to one side when
|
||||
the panel is wider than the slot grid. Empty slots stay invisible
|
||||
by default and reveal subtly on hover, acting as drop targets AND
|
||||
click-to-add surfaces. */
|
||||
.left-column { align-self: start; }
|
||||
.slots-list { display: grid; grid-template-columns: repeat(3, 72px); gap: 10px 8px;
|
||||
width: max-content; align-items: start; justify-content: start;
|
||||
margin: 0 auto; }
|
||||
.cards-grid.drop-target { outline: 2px dashed var(--acid); outline-offset: 4px; border-radius: 14px; }
|
||||
|
||||
.slot { position: relative; display: flex; flex-direction: column; align-items: center;
|
||||
|
|
@ -145,11 +125,10 @@
|
|||
.slot-empty:hover::before { border-color: rgb(from var(--acid) r g b / .35); background: rgb(from var(--acid) r g b / .03); }
|
||||
.slot-empty.drop-hot::before { border-color: var(--acid); background: rgb(from var(--acid) r g b / .14); }
|
||||
|
||||
/* Silent Mode cards area — lives under the hero inside .main-col. The
|
||||
.cards-wrap keeps the subtle panel surface (background, inset border,
|
||||
rounded corners) so the cards read as a grouped collection; the
|
||||
sidebar is chromeless so only this side has panel chrome. */
|
||||
.cards-wrap { align-self: stretch; }
|
||||
/* RIGHT COLUMN — the Silent Mode cards. Keeps its subtle panel surface
|
||||
(background, inset border, rounded corners) same as the slot panel
|
||||
on the left, so the two columns match. */
|
||||
.right-column { display: flex; flex-direction: column; gap: 22px; min-width: 0; }
|
||||
.cards-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; align-content: start; }
|
||||
@media (max-width: 980px) { .cards-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
@media (max-width: 560px) { .cards-grid { grid-template-columns: 1fr; } }
|
||||
|
|
@ -266,8 +245,6 @@
|
|||
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); }
|
||||
|
|
@ -294,35 +271,33 @@
|
|||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<aside class="section chromeless sidebar">
|
||||
<div class="hero">
|
||||
<div class="hero-id">
|
||||
<div class="mark">
|
||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="64" height="64" rx="12" fill="#0b0e14"/>
|
||||
<path d="M32 6 L38 32 L26 32 Z" fill="#e04e4e"/>
|
||||
<path d="M38 32 L32 58 L26 32 Z" fill="#d6ff3d"/>
|
||||
<path d="M6 32 L32 27 L58 32 L32 37 Z" fill="#d6ff3d" opacity="0.55"/>
|
||||
<circle cx="32" cy="32" r="3" fill="#0b0e14"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>Theseus <span class="g">Navigator</span></h1>
|
||||
</div>
|
||||
<p class="tag">A browser that follows the thread. Names that live on decentralized blockchain.</p>
|
||||
<form id="searchForm">
|
||||
<input id="q" placeholder="Search the web" autofocus spellcheck="false">
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
<p class="hint">Tip: type <b>hello.bch</b> in the address bar above to open a decentralized site.</p>
|
||||
</div>
|
||||
|
||||
<div class="homepage">
|
||||
<div class="section chromeless left-column">
|
||||
<div class="slots-list" id="slotsList"></div>
|
||||
</aside>
|
||||
<div class="main-col">
|
||||
<div class="hero">
|
||||
<div class="hero-id">
|
||||
<div class="mark">
|
||||
<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="64" height="64" rx="12" fill="#0b0e14"/>
|
||||
<path d="M32 6 L38 32 L26 32 Z" fill="#e04e4e"/>
|
||||
<path d="M38 32 L32 58 L26 32 Z" fill="#d6ff3d"/>
|
||||
<path d="M6 32 L32 27 L58 32 L32 37 Z" fill="#d6ff3d" opacity="0.55"/>
|
||||
<circle cx="32" cy="32" r="3" fill="#0b0e14"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>Theseus <span class="g">Navigator</span></h1>
|
||||
</div>
|
||||
<p class="tag">A browser that follows the thread. Names that live on decentralized blockchain.</p>
|
||||
<form id="searchForm">
|
||||
<input id="q" placeholder="Search the web" autofocus spellcheck="false">
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
<p class="hint">Tip: type <b>hello.bch</b> in the address bar above to open a decentralized site.</p>
|
||||
<p class="ver-line" id="verLine" hidden><a id="verLink" href="https://silentmode.st/releases"></a></p>
|
||||
</div>
|
||||
<div class="section cards-wrap">
|
||||
<div class="cards-grid" id="cardsGrid"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section right-column">
|
||||
<div class="cards-grid" id="cardsGrid"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -438,7 +413,7 @@
|
|||
function defaultState() {
|
||||
return {
|
||||
version: 6,
|
||||
iconSize: "medium",
|
||||
iconSize: "large",
|
||||
background: { kind: "preset", value: "dark" },
|
||||
sections: {
|
||||
sm: [
|
||||
|
|
@ -525,7 +500,7 @@
|
|||
// hover of the grid. The "+" cell sits at the end so adding is always
|
||||
// one click away.
|
||||
const q = state.sections.q || [];
|
||||
const slotCells = layoutSlots(q, minSlotsForFill());
|
||||
const slotCells = layoutSlots(q);
|
||||
const list = $("slotsList");
|
||||
list.innerHTML = "";
|
||||
slotCells.forEach((entry, cellIdx) => {
|
||||
|
|
@ -579,25 +554,13 @@
|
|||
$("ctxIconSizeVal").textContent = state.iconSize;
|
||||
}
|
||||
|
||||
// Compute how many slot rows fit the vertical space the sidebar covers.
|
||||
// Sidebar height = main-col height (align-self: stretch on the aside),
|
||||
// so basing the row count on main-col keeps the slot grid filling the
|
||||
// whole sidebar panel instead of ending halfway down. Falls back to 24
|
||||
// (8 rows) when the layout hasn't been measured yet (first render).
|
||||
function minSlotsForFill() {
|
||||
const main = document.querySelector(".main-col");
|
||||
const h = main ? main.getBoundingClientRect().height : 0;
|
||||
const rowH = 78; // .slot-empty height (68) + .slots-list row gap (10)
|
||||
const rows = h > 0 ? Math.max(6, Math.floor(h / rowH)) : 8;
|
||||
return rows * 3;
|
||||
}
|
||||
// Lay out the q[] items into a fixed-slot grid: pinned items (with a
|
||||
// `slot: number`) go into that cell; unpinned items flow into the first
|
||||
// free cell, top-to-bottom. The grid always extends to at least one
|
||||
// trailing empty row for drop space. minSlots defaults to 18 (3 × 6)
|
||||
// when nothing better is passed; render() passes minSlotsForFill() so
|
||||
// the grid actually covers the whole sidebar height.
|
||||
function layoutSlots(items, minSlots = 18) {
|
||||
// trailing empty row for drop space. minSlots defaults to 9 (3 × 3) —
|
||||
// matches the SM cards grid height on the right, so the two columns
|
||||
// read as a single row of panels.
|
||||
function layoutSlots(items, minSlots = 9) {
|
||||
const pinned = []; const unpinned = [];
|
||||
items.forEach((ic, i) => {
|
||||
const s = Number.isInteger(ic.slot) && ic.slot >= 0 ? ic.slot : null;
|
||||
|
|
@ -793,6 +756,10 @@
|
|||
function tryParse(s) { try { return JSON.parse(s); } catch { return null; } }
|
||||
function moveItem(fromSec, fromI, toSec, toIdx) {
|
||||
const [m] = state.sections[fromSec].splice(fromI, 1);
|
||||
// Moving a slot (q) into cards (sm): drop the pinned-slot anchor —
|
||||
// slot is a q-only concept, and leaving it on an sm card would wire
|
||||
// the item back to a slot cell if the user ever moved it back.
|
||||
if (fromSec === "q" && toSec !== "q" && Number.isInteger(m.slot)) delete m.slot;
|
||||
if (fromSec === toSec && toIdx > fromI) toIdx--;
|
||||
toIdx = Math.max(0, Math.min(state.sections[toSec].length, toIdx));
|
||||
state.sections[toSec].splice(toIdx, 0, m);
|
||||
|
|
@ -1021,21 +988,6 @@
|
|||
if (name) $("q").placeholder = "Search the web with " + name;
|
||||
}).catch(() => {});
|
||||
}
|
||||
// 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;
|
||||
const line = $("verLine"), a = $("verLink");
|
||||
a.textContent = "Theseus v" + v + " — What’s new ↗";
|
||||
a.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
window.home.navigate("https://silentmode.st/releases");
|
||||
});
|
||||
line.hidden = false;
|
||||
}).catch(() => {});
|
||||
}
|
||||
// Container-level drop handlers accept cross-column drops onto empty space.
|
||||
wireContainer($("slotsList"), "q");
|
||||
wireContainer($("cardsGrid"), "sm");
|
||||
|
|
@ -1046,33 +998,13 @@
|
|||
let loaded = null;
|
||||
try { if (window.home && window.home.getCards) loaded = await window.home.getCards(); } catch {}
|
||||
state = ensureShape(loaded) || defaultState();
|
||||
applyBg();
|
||||
render();
|
||||
// The first render runs before the main column has laid out, so the
|
||||
// row count comes from the fallback (24). Re-render once the layout
|
||||
// settles so the slot grid fills the sidebar vertically.
|
||||
requestAnimationFrame(() => render());
|
||||
applyBg(); render();
|
||||
if (window.home && window.home.onCards) {
|
||||
window.home.onCards((data) => {
|
||||
const next = ensureShape(data);
|
||||
if (next) { state = next; applyBg(); render(); }
|
||||
});
|
||||
}
|
||||
// Resize the slot grid when the viewport changes so new rows appear
|
||||
// (or drop off) to match the sidebar's new height. ResizeObserver on
|
||||
// the main column catches both window resizes and content-driven
|
||||
// height changes (e.g. new cards being added).
|
||||
const main = document.querySelector(".main-col");
|
||||
if (main && window.ResizeObserver) {
|
||||
let lastRows = -1, t = null;
|
||||
const ro = new ResizeObserver(() => {
|
||||
const rows = Math.floor(minSlotsForFill() / 3);
|
||||
if (rows === lastRows) return;
|
||||
lastRows = rows;
|
||||
clearTimeout(t); t = setTimeout(() => render(), 80);
|
||||
});
|
||||
ro.observe(main);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -89,6 +89,30 @@ export function nostrKeyFromRoot(root) {
|
|||
return { sk, pkHex, npub: nip19.npubEncode(pkHex) };
|
||||
}
|
||||
|
||||
/** Per-origin Nostr identity: HKDF(messengerRoot, info = "nostr-origin/v1/<origin>")
|
||||
* gives each site its own secp256k1 key, so a page at primal.net and one at
|
||||
* nostrudel.ninja see different npubs and cannot correlate the user across
|
||||
* Nostr clients. The derivation is deterministic from messengerRoot, so the
|
||||
* same mnemonic rebuilds every per-origin key. Compromising messengerRoot
|
||||
* (= compromising the Hermes identity) exposes every per-origin key too —
|
||||
* domain separation is only against the pages, not against seed theft. */
|
||||
export async function nostrKeyFromRootForOrigin(root, origin) {
|
||||
const rootBytes = typeof root === "string"
|
||||
? Uint8Array.from(Buffer.from(root, "hex"))
|
||||
: root;
|
||||
if (!(rootBytes instanceof Uint8Array) || rootBytes.length !== 32) {
|
||||
throw new Error("nostrKeyFromRootForOrigin: expected 32-byte Uint8Array or 64-char hex");
|
||||
}
|
||||
const originStr = String(origin || "").trim().toLowerCase();
|
||||
if (!originStr) throw new Error("nostrKeyFromRootForOrigin: origin is required");
|
||||
const key = await wc.subtle.importKey("raw", rootBytes, "HKDF", false, ["deriveBits"]);
|
||||
const info = enc.encode(`silentmode/nostr-origin/v1/${originStr}`);
|
||||
const sk = new Uint8Array(await wc.subtle.deriveBits(
|
||||
{ name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info }, key, 256));
|
||||
const pkHex = getPublicKey(sk);
|
||||
return { sk, pkHex, npub: nip19.npubEncode(pkHex) };
|
||||
}
|
||||
|
||||
// ---- record parsers --------------------------------------------------------
|
||||
// The BNS resolver is transparent: whatever the name owner published in `r`
|
||||
// lands as entry.records.<key>. Here we validate and normalise the two Hermes
|
||||
|
|
@ -309,13 +333,20 @@ export async function hashPhone(input) {
|
|||
|
||||
export const PHONE_DTAG_PREFIX = "hermes.phone/";
|
||||
|
||||
export async function buildPhoneClaimEvent({ sk, phone } = {}) {
|
||||
export async function buildPhoneClaimEvent({ sk, phone, attestation } = {}) {
|
||||
if (!sk) throw new Error("sk required");
|
||||
const hash = await hashPhone(phone);
|
||||
const tags = [["d", PHONE_DTAG_PREFIX + hash]];
|
||||
// Embed a Hermes-verify attestation when the caller has one. Format:
|
||||
// ["att", "tg:<base64url payload>:<base64url sig>"] — resolvers verify
|
||||
// against the pinned Ed25519 pubkey. See VPS/SilentMode/hermes-verify.
|
||||
if (attestation && attestation.payload && attestation.sig) {
|
||||
tags.push(["att", "tg:" + attestation.payload + ":" + attestation.sig]);
|
||||
}
|
||||
return finalizeEvent({
|
||||
kind: 30078,
|
||||
content: "{}",
|
||||
tags: [["d", PHONE_DTAG_PREFIX + hash]],
|
||||
tags,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
}, sk);
|
||||
}
|
||||
|
|
|
|||
78
main.js
78
main.js
|
|
@ -11397,23 +11397,47 @@ function parseVcardContacts(text) {
|
|||
// 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;
|
||||
// Signer cache keyed by `${profileId}:${origin||""}`. Each entry wraps one
|
||||
// derived secret key. profileId "hermes" uses the Hermes identity (same key
|
||||
// Hermes signs its DMs and public notes with); profileId "origin" uses an
|
||||
// HKDF derivation per web origin (different npub per site, blocks cross-site
|
||||
// correlation on Nostr). getSk reads vaultState fresh each call so a lock /
|
||||
// unlock cycle flips every cached signer to "unavailable" without a refresh.
|
||||
const nostrSignerCache = new Map();
|
||||
async function nostrSignerFor({ profileId, origin }) {
|
||||
const key = `${profileId || "hermes"}:${origin || ""}`;
|
||||
const hit = nostrSignerCache.get(key);
|
||||
if (hit) return hit;
|
||||
const { createSigner } = await import(`file://${path.join(__dirname, "lib", "nostr-signer.js").replace(/\\/g, "/")}`);
|
||||
const H = await loadHermesLib();
|
||||
nostrSignerInst = createSigner({
|
||||
const inst = 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;
|
||||
if (profileId === "origin") {
|
||||
if (!origin) return null;
|
||||
// nostrKeyFromRootForOrigin is async; the signer's getSk is sync.
|
||||
// Keep the per-origin sk hot in a module-level cache so the async
|
||||
// derivation only runs once per (vault, origin) pair.
|
||||
const ck = `${vaultState.messengerRoot}|${origin}`;
|
||||
return nostrOriginSkCache.get(ck) || null;
|
||||
}
|
||||
return H.nostrKeyFromRoot(vaultState.messengerRoot).sk;
|
||||
},
|
||||
});
|
||||
return nostrSignerInst;
|
||||
if (profileId === "origin" && origin) {
|
||||
// Prime the per-origin sk cache before the signer is used.
|
||||
const ck = `${vaultState.messengerRoot}|${origin}`;
|
||||
if (!nostrOriginSkCache.has(ck)) {
|
||||
const { sk } = await H.nostrKeyFromRootForOrigin(vaultState.messengerRoot, origin);
|
||||
nostrOriginSkCache.set(ck, sk);
|
||||
}
|
||||
}
|
||||
nostrSignerCache.set(key, inst);
|
||||
return inst;
|
||||
}
|
||||
const nostrOriginSkCache = new Map(); // `${messengerRootHex}|${origin}` -> sk
|
||||
global._nostrAPI = {
|
||||
async status() {
|
||||
async status({ profileId, origin } = {}) {
|
||||
const hasVault = !!vaultState;
|
||||
const hasIdentity = !!(vaultState && vaultState.messengerRoot);
|
||||
if (!hasIdentity) {
|
||||
|
|
@ -11424,15 +11448,37 @@ global._nostrAPI = {
|
|||
: "Unlock the password vault to use your Nostr identity.",
|
||||
};
|
||||
}
|
||||
const s = await nostrSigner();
|
||||
return { ok: true, ready: true, pubkey: s.getPublicKey(), npub: s.npub() };
|
||||
const pid = profileId || "hermes";
|
||||
if (pid === "origin" && !origin) {
|
||||
return { ok: true, ready: false, reason: "the per-origin profile needs an origin" };
|
||||
}
|
||||
const s = await nostrSignerFor({ profileId: pid, origin });
|
||||
return { ok: true, ready: true, profileId: pid, pubkey: s.getPublicKey(), npub: s.npub() };
|
||||
},
|
||||
async getPublicKey({ profileId, origin } = {}) {
|
||||
const s = await nostrSignerFor({ profileId: profileId || "hermes", origin });
|
||||
return { ok: true, pubkey: s.getPublicKey() };
|
||||
},
|
||||
async signEvent({ event, profileId, origin } = {}) {
|
||||
const s = await nostrSignerFor({ profileId: profileId || "hermes", origin });
|
||||
return { ok: true, event: s.signEvent(event) };
|
||||
},
|
||||
async nip04Encrypt({ peer, plaintext, profileId, origin } = {}) {
|
||||
const s = await nostrSignerFor({ profileId: profileId || "hermes", origin });
|
||||
return { ok: true, result: await s.nip04Encrypt(peer, plaintext) };
|
||||
},
|
||||
async nip04Decrypt({ peer, ciphertext, profileId, origin } = {}) {
|
||||
const s = await nostrSignerFor({ profileId: profileId || "hermes", origin });
|
||||
return { ok: true, result: await s.nip04Decrypt(peer, ciphertext) };
|
||||
},
|
||||
async nip44Encrypt({ peer, plaintext, profileId, origin } = {}) {
|
||||
const s = await nostrSignerFor({ profileId: profileId || "hermes", origin });
|
||||
return { ok: true, result: s.nip44Encrypt(peer, plaintext) };
|
||||
},
|
||||
async nip44Decrypt({ peer, ciphertext, profileId, origin } = {}) {
|
||||
const s = await nostrSignerFor({ profileId: profileId || "hermes", origin });
|
||||
return { ok: true, result: s.nip44Decrypt(peer, ciphertext) };
|
||||
},
|
||||
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 = {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "theseus-navigator",
|
||||
"version": "0.3.131",
|
||||
"version": "0.3.132",
|
||||
"license": "MPL-2.0",
|
||||
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
|
||||
"author": "Silent Mode",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue