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:
parent
b4cea7b83f
commit
9c29331e5b
10 changed files with 685 additions and 133 deletions
186
PENDING.md
186
PENDING.md
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
32
bundled-addons/nostr/addon.json
Normal file
32
bundled-addons/nostr/addon.json
Normal 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"
|
||||
}
|
||||
1
bundled-addons/nostr/icon.svg
Normal file
1
bundled-addons/nostr/icon.svg
Normal 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 |
203
bundled-addons/nostr/index.js
Normal file
203
bundled-addons/nostr/index.js
Normal 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");
|
||||
},
|
||||
};
|
||||
72
bundled-addons/nostr/nostr-inject.js
Normal file
72
bundled-addons/nostr/nostr-inject.js
Normal 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);
|
||||
156
bundled-addons/nostr/panel.html
Normal file
156
bundled-addons/nostr/panel.html
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
64
home.html
64
home.html
|
|
@ -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
96
main.js
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue