Compare commits

...

5 commits

Author SHA1 Message Date
Local Dev
ed98d0280c chore(theseus): 0.3.52 — one-click install from theseus.x, plug-in row with Aegis and Tor 2026-09-22 00:51:29 +02:00
Local Dev
fbde2f7650 feat(theseus): Tor toggle joins the plug-in row
The Tor chip was the widest control in the toolbar and competed with
the address bar for space. It now sits on the favorites row beside the
Aegis chip, under the Theseus menu, at the same chip height — the
network-level switch next to the wallet, both always visible. State
tints (off / connecting / on) are unchanged.
2026-09-22 00:50:05 +02:00
Local Dev
3d9b05ba25 docs: scope the signing allow-list per extension, not per app
Multiple signing keys already work — PUBKEYS_HEX is an array and
verifySignature accepts any entry that verifies, which is an allow-list
already. What it isn't is scoped: every add-on is checked against the same
array, so a key added for one extension can sign an update for any other.
The canonical message binds a signature to an extension; nothing binds an
extension to a signer.

The publisher path doesn't have that problem, because a name answers "who
may sign this" per extension and rotates on chain without an app release.
So the note now argues for generalising that rather than replacing it:
per-extension key bindings, several acceptable keys each, the chain
preferred over a compiled-in array, and co-signing considered only for the
default set — the extensions that reach every fresh profile without anyone
choosing them.

Records the open questions honestly too: where a binding can live without
being restatable by whoever controls the manifest, and that refusing to
update an extension is a much smaller decision than refusing to run it.
2026-09-22 00:47:00 +02:00
Local Dev
ed2d2e1d5a feat(theseus): plug-in dock under the Theseus menu
Aegis sat among the extension buttons and drifted as add-ons were
installed or removed. First-class Silent Mode components (manifest
category "plugin") now get a labelled chip at the right end of the
favorites row, directly below the Theseus menu — always visible, never
collapsed into the extensions overflow. The add-on host tags each
sidebar panel and toolbar menu with the category so the chrome can
split the two docks; the extensions dock keeps its behaviour.
2026-09-22 00:31:18 +02:00
Local Dev
0da15818ad feat(docx-editor): the blue DOC badge, kept as the generated source
Picks the mark from 95c93f9 over the one I drew: at the 16–18px the dock
actually renders, a solid fill reads and an outlined page does not, and
pairing with pdf-editor's red badge makes the two editors obviously a set.

It moves into icon.svg rather than living only as a data URL in the
manifest, so make-icons.mjs keeps deriving the PNGs and addon.json's copy
from one drawing. Same picture as before, one source instead of three
places to forget.
2026-09-22 00:10:56 +02:00
12 changed files with 145 additions and 37 deletions

View file

@ -687,9 +687,15 @@ class AddonHost {
toolbarMenus: this.getToolbarMenus(),
};
}
// `plugin` marks surfaces of a first-class Silent Mode component (manifest
// category "plugin", e.g. Aegis): the chrome pins those to their own dock
// instead of the extensions row.
getSidebarPanels() {
const out = [];
for (const active of this._active.values()) out.push(...active.sidebarPanels);
for (const active of this._active.values()) {
const plugin = active.manifest.category === "plugin";
for (const p of active.sidebarPanels) out.push({ ...p, plugin, addonName: active.manifest.name });
}
return out;
}
// Menu declarations from every active add-on that carries a toolbar-menu
@ -704,6 +710,7 @@ class AddonHost {
addonId: active.manifest.id,
title: tm.title,
icon: tm.icon,
plugin: active.manifest.category === "plugin",
items: tm.items.map((it) => ({ id: it.id, label: it.label, icon: it.icon })),
});
}

View file

@ -35,11 +35,14 @@ vendored editor libraries.
`icon` field, which is what the dock button and the catalogue card read — so
there is never a second version to keep in step.
It is deliberately not Microsoft's Word icon. That mark, and the blue sheet
with a white W it belongs to, are Microsoft's trademarks; dressing our
extension up as theirs would be both a legal problem and a dishonest one. The
mark here says "text document" in its own words: a page with a turned corner,
a heading rule, body lines, and a pilcrow badge in Silent Mode green.
It is a blue document badge, paired with pdf-editor's red one so the two
editors read as a set in the dock — a solid fill stays legible at the 16–18px
the dock actually renders, which an outlined page does not.
Deliberately not Microsoft's Word icon: their mark is a blue sheet carrying a
white W, and dressing our extension up as theirs would be both a legal
problem and a dishonest one. A folded-corner page is the universal document
glyph, and "DOC" is the file format rather than anyone's trademark.
## Building

View file

@ -4,7 +4,7 @@
"version": "0.1.1",
"description": "Open, edit and save Word documents (.docx) in a full Theseus tab, or export them as PDF. Ribbon-style formatting, tables, lists, images and links; headers, footers, footnotes, page setup and the document's own styles are carried through a save untouched.",
"author": "Silent Mode",
"icon": "data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%3E%3Cpath%20d%3D%22M6%202h8l6%206v12a2%202%200%200%201-2%202H6a2%202%200%200%201-2-2V4a2%202%200%200%201%202-2z%22%20fill%3D%22%232563eb%22%2F%3E%3Cpath%20d%3D%22M14%202v6h6z%22%20fill%3D%22%231e40af%22%2F%3E%3Cg%20fill%3D%22%23fff%22%3E%3Cpath%20d%3D%22M12.3%208.6a3.1%203.1%200%200%200%200%206.2z%22%2F%3E%3Crect%20x%3D%2212.05%22%20y%3D%228.6%22%20width%3D%221.7%22%20height%3D%2210.8%22%20rx%3D%22.2%22%2F%3E%3Crect%20x%3D%2214.85%22%20y%3D%228.6%22%20width%3D%221.7%22%20height%3D%2210.8%22%20rx%3D%22.2%22%2F%3E%3Crect%20x%3D%2212.05%22%20y%3D%228.6%22%20width%3D%224.5%22%20height%3D%221.7%22%20rx%3D%22.2%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E",
"icon": "data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%3E%3Cpath%20d%3D%22M6%202h8l6%206v12a2%202%200%200%201-2%202H6a2%202%200%200%201-2-2V4a2%202%200%200%201%202-2z%22%20fill%3D%22%232563eb%22%2F%3E%3Cpath%20d%3D%22M14%202v6h6z%22%20fill%3D%22%231e40af%22%2F%3E%3Ctext%20x%3D%2212%22%20y%3D%2217%22%20text-anchor%3D%22middle%22%20font-family%3D%22system-ui%2C-apple-system%2Csans-serif%22%20font-size%3D%225.5%22%20font-weight%3D%22800%22%20fill%3D%22%23fff%22%3EDOC%3C%2Ftext%3E%3C%2Fsvg%3E",
"main": "index.js",
"capabilities": [
"sidebar-panel",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 398 B

After

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 576 B

After

Width:  |  Height:  |  Size: 634 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 680 B

After

Width:  |  Height:  |  Size: 711 B

View file

@ -1,27 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<!--
Silent Mode's mark for the Word editor.
Silent Mode's mark for the Word editor: a blue document badge, paired
with pdf-editor's red one so the two editors read as a set in the dock.
Originally drawn in commit 95c93f9 alongside the PDF badge; kept here as
the single source that addon-build/docx-editor/make-icons.mjs rasterises
to the PNGs and writes into addon.json.
Deliberately not Microsoft's Word icon. That mark, and the blue sheet
with a white "W" it belongs to, are Microsoft's trademarks; dressing our
extension up as theirs would be both a legal problem and a dishonest one.
It shares pdf-editor's page silhouette, corner geometry and weight so the
two editors read as a set in the dock, and carries a pilcrow — the
paragraph mark every word processor has used since the trade was done on
paper — rather than a format label. The glyph is drawn as paths, not
<text>: an icon that needs a particular font installed to make sense is
an icon that eventually renders wrong somewhere.
Deliberately not Microsoft's Word icon. Their mark is a blue sheet
carrying a white "W", and dressing our extension up as theirs would be
both a legal problem and a dishonest one. A folded-corner page is the
universal document glyph, and "DOC" is the file format, not anyone's
trademark.
-->
<path d="M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2z" fill="#2563eb"/>
<path d="M14 2v6h6z" fill="#1e40af"/>
<g fill="#fff">
<!-- bowl: a half disc hanging off the left stem -->
<path d="M12.3 8.6a3.1 3.1 0 0 0 0 6.2z"/>
<!-- the two stems -->
<rect x="12.05" y="8.6" width="1.7" height="10.8" rx=".2"/>
<rect x="14.85" y="8.6" width="1.7" height="10.8" rx=".2"/>
<!-- top bar tying the bowl and stems together -->
<rect x="12.05" y="8.6" width="4.5" height="1.7" rx=".2"/>
</g>
<text x="12" y="17" text-anchor="middle" font-family="system-ui,-apple-system,sans-serif" font-size="5.5" font-weight="800" fill="#fff">DOC</text>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1,017 B

View file

@ -335,7 +335,10 @@
.engine-btn .esym { font-size: 14px; line-height: 1; }
.engine-btn .cv { font-size: 8px; color: var(--dim); }
#search { flex: 1; padding: 7px 4px; }
.tor { padding: 7px 10px; border-radius: 8px; border: 1px solid var(--line); background: #2b2f3a; color: var(--ink); cursor: pointer; white-space: nowrap; font-size: 12.5px; }
/* Tor toggle lives on the favorites row beside the plug-in dock (same
chip height as .plugbtn); the tint still says off / connecting / on. */
.tor { display: inline-flex; align-items: center; height: 22px; padding: 0 10px; border-radius: 6px; border: 1px solid var(--line);
background: #2b2f3a; color: var(--ink); cursor: pointer; white-space: nowrap; font: inherit; font-size: 12px; font-weight: 600; }
.tor.connecting { background: #7a5c14; } .tor.on { background: #6b3fa0; }
.logo { display: flex; align-items: center; gap: 6px; font-weight: 700; color: var(--acid); white-space: nowrap;
padding: 6px 12px; border: 1px solid color-mix(in srgb, var(--acid) 20%, transparent); border-radius: 8px; cursor: pointer; background: rgb(from var(--acid) r g b / .08); font-size: 13px; }
@ -345,6 +348,18 @@
uniform fixed width so the row reads as a neat grid regardless of the
underlying title length; the strip is 28px tall to fit the roomier chips. */
.bookmarks { display: flex; align-items: center; gap: 6px; padding: 4px 10px 6px; height: 28px; overflow: hidden; }
#bmlist { display: flex; align-items: center; gap: 6px; flex: 1 1 auto; min-width: 0; overflow: hidden; }
/* Plug-in dock: first-class Silent Mode components (Aegis) get a labelled
chip at the right end of the favorites row, directly under the Theseus
menu — always visible, never collapsed with the extensions. */
.bmright { margin-left: auto; flex: none; display: flex; align-items: center; gap: 6px; padding-left: 8px; }
.plugdock { display: flex; align-items: center; gap: 4px; }
.plugbtn { display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 10px 0 8px; border-radius: 6px;
font: inherit; font-size: 12px; font-weight: 600; color: var(--mut); cursor: pointer; white-space: nowrap;
border: 1px solid var(--line); background: transparent; }
.plugbtn:hover { background: var(--line2); color: var(--ink); }
.plugbtn.active { background: rgb(from var(--acid) r g b / .12); color: var(--acid); border-color: color-mix(in srgb, var(--acid) 35%, transparent); }
.plugbtn img { display: block; }
/* Ctrl+F find-in-page bar. Sits below the address bar (above tabs body),
~32px tall so it's part of the chrome view height like .tordisc / .bcnrbar.
syncHeight in the tabs handler picks it up automatically. */
@ -473,11 +488,10 @@
<button class="extmore" id="extmore" title="Extensions" aria-label="Extensions"></button>
</span>
<span class="upchip" id="upchip" hidden><button class="upcore" id="upDownload" title=""></button><button class="updismiss" id="upDismiss" title="Not now">✕</button></span>
<button class="tor" id="tor" title="Route traffic through Tor">🧅 Tor: Off</button>
<button class="logo" id="logo" title="Settings"><span>⛓ Theseus</span><span class="gear">⚙</span></button>
</div>
<div class="loadbar" id="loadbar"></div>
<div class="bookmarks" id="bookmarks" hidden></div>
<div class="bookmarks" id="bookmarks" hidden><div id="bmlist"></div><span class="bmright"><span class="plugdock" id="plugdock" hidden></span><button class="tor" id="tor" title="Route traffic through Tor">🧅 Tor: Off</button></span></div>
<div class="findbar" id="findbar" hidden>
<input id="findInput" type="text" placeholder="Find in page" spellcheck="false" autocomplete="off">
<span class="findcount" id="findCount">–</span>
@ -666,15 +680,21 @@
// an addon that declares a toolbar-menu both get one entry here. panels
// stay clickable-toggle; menus open a floating dropdown positioned under
// the icon and dispatch the picked item back to the add-on.
let lastPanels = [], lastMenus = [], lastButtons = [];
let lastPanels = [], lastMenus = [], lastButtons = [], lastAllPanels = [], lastAllMenus = [];
let sidebarOpen = false, activePanelId = null;
let openMenuAddonId = null; // which addon's menu dropdown is currently open
function renderSidebarState(d) {
if (!d) return;
lastPanels = Array.isArray(d.panels) ? d.panels : [];
lastMenus = Array.isArray(d.toolbarMenus) ? d.toolbarMenus : [];
const allPanels = Array.isArray(d.panels) ? d.panels : [];
const allMenus = Array.isArray(d.toolbarMenus) ? d.toolbarMenus : [];
sidebarOpen = !!d.visible;
activePanelId = d.active || null;
// Plug-ins (Aegis) live in their own dock on the favorites row, under
// the Theseus menu; everything else is an extension in the toolbar dock.
lastAllPanels = allPanels; lastAllMenus = allMenus;
lastPanels = allPanels.filter((p) => !p.plugin);
lastMenus = allMenus.filter((m) => !m.plugin);
renderPlugDock(allPanels.filter((p) => p.plugin), allMenus.filter((m) => m.plugin));
// Build the flat button list, panels first (matches shipped order) then
// toolbar-menu buttons in registration order.
lastButtons = [];
@ -724,6 +744,36 @@
$("extmore").classList.toggle("active", (sidebarOpen && !!activePanelId) || !!openMenuAddonId);
}
// Plug-in dock (favorites row, right end). A chip per plug-in surface:
// icon + name, tinted while its sidebar panel or menu is open. Same
// actions as the toolbar dock buttons, never collapsed.
function renderPlugDock(panels, menus) {
const dock = $("plugdock");
const chips = [];
// Chip label is the plug-in's product name (e.g. "Aegis Wallet"), the
// panel title goes in the tooltip.
for (const p of panels) chips.push({ kind: "panel", panelId: p.panelId, icon: p.icon, title: p.title || "Plug-in", name: p.addonName || p.title || "Plug-in", active: sidebarOpen && p.panelId === activePanelId });
for (const m of menus) chips.push({ kind: "menu", addonId: m.addonId, icon: m.icon, title: m.title || m.addonId, name: m.title || m.addonId, active: openMenuAddonId === m.addonId });
dock.hidden = chips.length === 0;
dock.innerHTML = chips.map((c) => {
const dataAttr = c.kind === "panel" ? `data-panel="${c.panelId.replace(/"/g,"&quot;")}"` : `data-menu="${c.addonId.replace(/"/g,"&quot;")}"`;
const label = String(c.name).split(/\s+[—–-]\s+/, 1)[0].replace(/</g,"&lt;");
return `<button class="plugbtn${c.active ? " active" : ""}" ${dataAttr} title="${c.title.replace(/"/g,"&quot;")}">${addonIconHtml(c.icon, 14)}<span>${label}</span></button>`;
}).join("");
dock.querySelectorAll(".plugbtn").forEach((btn) => {
btn.onclick = () => {
if (btn.dataset.panel) {
const pid = btn.dataset.panel;
closeToolbarMenu();
if (sidebarOpen && pid === activePanelId) { T.closeSidebar && T.closeSidebar(); }
else { T.openSidebar && T.openSidebar(pid); }
} else if (btn.dataset.menu) {
openToolbarMenu(btn.dataset.menu, btn);
}
};
});
}
// ---- toolbar-menu dropdown (per-addon) ----
// Positions under the clicked icon, styled after .extpop. Clicking an
// item dispatches to main via addonMenuSelect(addonId, itemId) and
@ -734,7 +784,7 @@
if (openMenuAddonId) {
openMenuAddonId = null;
// Re-render dock so the "active" tint drops off the icon.
renderSidebarState({ visible: sidebarOpen, active: activePanelId, panels: lastPanels, toolbarMenus: lastMenus });
renderSidebarState({ visible: sidebarOpen, active: activePanelId, panels: lastAllPanels, toolbarMenus: lastAllMenus });
}
}
function openToolbarMenu(addonId, anchorBtn) {
@ -933,7 +983,7 @@
return bookmarkBrandCase(cut);
}
function renderBookmarks() {
const box = $("bookmarks");
const box = $("bmlist");
box.innerHTML = bookmarks.length
? bookmarks.map((b, i) => {
const fav = b.favicon

View file

@ -35,6 +35,49 @@ is a list of ids in the app's config, not a folder that gets copied. Changing
the default set should be editing that list, not moving directories around
and rebuilding.
## Which keys may sign what
Multiple signing keys already work, and there is already an allow-list: the
`PUBKEYS_HEX` array in `addon-update-pubkeys.js`. `verifySignature` walks it
and accepts an update if any entry verifies, so adding a second operator key
is a line in that file plus a Theseus release. The file's own comment sets
out the rotation: sign with both, ship a release carrying both, then drop
the old one.
The problem is not that the list is missing. It is that the list is
**global**. Every add-on is verified against the same array, so any key on
it can sign an update for any id — a key meant for the screenshot tool can
push code for the wallet. The signed message is
`silentmode.addon-update-v1|<id>|<version>|<sha256>`, which binds a
signature to an extension, but nothing binds an extension to a signer.
The publisher path does not have this problem. An entry carrying
`publisherSig` is checked against the current owner of that extension's
name, so the permission is per-extension by construction and rotates on
chain without an app release. That is the model to generalise, not replace.
So: keep an allow-list, and scope it.
- **Per-extension, not global.** Each extension names who may sign it — a
BNS name for anything in the catalogue, or a pinned pubkey for a member of
the default set. `verifySignature` takes that extension's keys instead of
the global array, and its callers look the binding up by id.
- **Plural per extension.** Two or three acceptable keys, any one
sufficient, so a rotation or a lost key doesn't need an emergency release.
- **Prefer the name-owner path for anything new.** The chain is an
allow-list that can be corrected the day a key is lost; a compiled-in
array can only be corrected by shipping a build.
- **Consider co-signing for the default set alone.** Those extensions reach
every fresh profile without anyone choosing them, which makes a single
compromised key worst exactly there. Requiring two signatures is a bounded
amount of work when it applies to a handful of ids rather than to the
whole catalogue.
The narrowing itself is small — `verifySignature` and its two callers. The
work is in deciding where the binding lives and showing it honestly in
Settings, so "who is allowed to update this" is something a user can read
rather than infer.
## The one thing that needs care
If defaults are fetched from the catalogue on first run, a fresh install with
@ -66,7 +109,9 @@ everywhere else.
row, next to the publisher's name. The separate Community section and its
Install button fold into the single catalogue view.
- **`addon-updater.js`**: drop the distinction between the two channel
shapes; an extension has a publisher and an updates URL.
shapes; an extension has a publisher and an updates URL. `verifySignature`
takes the keys allowed for *that* id rather than the global array, and
`pickChannelEntry` resolves the binding before it verifies anything.
- **Gateway**: the catalogue gains first-party entries. Nothing changes
structurally — `/api/ext` already stores whatever a name owner signs.
- **Publishing**: `scripts/sign-addon-update.mjs` and the operator key stop
@ -85,3 +130,15 @@ everywhere else.
- **Does the operator key survive at all?** Keeping it as an accepted signer
for the default set is a hedge against the name-owner key being lost;
keeping it forever means the two-rules problem never fully goes away.
- **Where does a per-extension key binding live?** In the manifest, where
the extension states its own publisher and an attacker who can rewrite the
manifest can restate it; in the app's config, where it is trustworthy but
only changes with a release; or on chain, where the name already answers
the question. Probably: on chain for the catalogue, pinned in config for
the default set, and never simply believed from the manifest.
- **What happens to an extension whose signer is no longer allowed?** It
should stop updating, but stopping it from *running* is a different and
much ruder decision, and the two shouldn't be conflated by accident.
- **Does co-signing need a threshold, or just two named keys?** Two keys is
simple to implement and to explain; m-of-n is where this quietly turns
into key management.

View file

@ -1,6 +1,6 @@
{
"name": "theseus-navigator",
"version": "0.3.51",
"version": "0.3.52",
"description": "Theseus Navigator — a browser that follows the thread. By Silent Mode, a Deviant project.",
"author": "Silent Mode",
"main": "main.js",