Tabs keep emitting events while the window is torn down: a hovered
link fires update-target-url, which positioned the link-status pill
against win.getContentBounds() on a destroyed window ("Object has been
destroyed", 2026-09-27). With installed web apps the browser window can
now close while their windows keep the process alive, so this stops
being a quit-time blip and becomes a normal state.
The overlay helpers and layout() now check the window is alive, the
session is captured on close (the quit-time save no longer overwrites it
with an empty list once the tabs are gone), and "closed" drops every
reference to the window's views. A later createWindow() starts from a
clean tab list, so a page opened from an app window after the browser
window was closed brings the window back with the restored session.
Node's ws aborts the handshake when close() is called on a CONNECTING
socket and emits an error on the next tick; with no listener that is an
uncaught exception, and Electron answers with the modal "A JavaScript
error occurred in the main process". nostr-tools drops its onerror
handler right before closing, which is what the WizardConnect relay
teardown in Aegis runs on every wallet disconnect while a relay is
still connecting. Browser WebSockets ignore the same sequence, which is
why the library gets away with it elsewhere.
Every consumer in the main process shares the one ws module, so
close() now adds a no-op error listener to a connecting socket before
aborting it. Anything else that still escapes to the top of the process
is logged to <userData>/main-errors.log instead of raising the modal;
Electron continued after that dialog anyway, so only the interruption
goes.
Electron ships Chromium's renderer without the browser-side web-app
install machinery, so beforeinstallprompt never fires and every site's
own "Install our app" chip (coin-spectrum.com's, for one) stays hidden
in Theseus. The browser side now exists:
- webapps.js reads a page's <link rel="manifest">, accepts it when it
names an app with a standalone-style display mode and a start_url on
the page's origin, and records the descriptor on the tab.
- The address bar shows an install chip for such pages (filled once the
app is installed: click then opens or removes it); the page context
menu carries the same entry.
- Pages get a synthetic beforeinstallprompt whose prompt() routes to the
Theseus install dialog and resolves userChoice like Chrome, and an
appinstalled event afterwards, so sites' own chips appear and work.
- Installing stores the app under <userData>/webapps/, wraps the
manifest icon into an .ico, writes a Start Menu (optionally desktop)
shortcut that launches Theseus with --app=<start_url>, and opens the
app in a chromeless window with its own taskbar identity. The window
shares the session, BCNR resolution, fingerprint and add-on bridges
with tabs; popups and "open in Theseus" go to the browser window,
Alt+arrows / F5 / Ctrl+R cover navigation without a toolbar.
- Theseus takes the single-instance lock so a shortcut launch lands in
the running browser (second-instance) instead of a second profile
owner; launched cold, --app= opens only the app window and a later
plain launch brings the browser window back.
syncHeight queued its measurement in requestAnimationFrame, and frames
stop whenever the window is occluded — so a prompt closing, or a row
hiding, while another window covered Theseus left the chrome at its
old height until the window came back. A zero timeout coalesces the
same way and always runs. The open-prompt allowance now lives in
syncHeight itself, so any re-measure while a prompt is up keeps its
room, and the growth helper simply defers to it.
The chrome view is exactly as tall as its rows, and a fixed-position
prompt mask adds nothing to the height it reports, so any prompt taller
than the toolbar — the new two-field favorites form lost its title and
buttons — was clipped at the view's edge. The view now grows to fit the
open prompt and shrinks back when it closes.
The favorites row's right-click menu could only save the current page.
"Add a page…" opens a two-field form: the address (scheme optional,
must be a real host — Chromium's parser happily percent-encodes junk
like "not a url" into a bogus bookmark, so hosts are checked) and a
title that fills itself from the address, brand-cased like the chips,
until you edit it. Duplicates are refused; the site's /favicon.ico is
used for the chip.
Electron hands every page an empty window.chrome. Real Chrome's carries
app, csi, loadTimes and runtime, and bot checks — Google's sign-in
botguard among them — look for exactly those to tell Chrome from an
embedded Chromium. The per-tab identity script now fills the object
with the same shapes and return types; a site learns nothing it would
not also learn from stock Chrome.
The Custom box validated for a vless:// prefix and rejected anything
else, so a provider's subscription URL — the thing most people are
handed — got "paste a vless:// URL first" with no hint that the
Subscription card two sections down was what it wanted. Now an
http(s):// paste in that box is detected and routed to the subscription
importer, the placeholder says both are accepted, and the dropdown
option reads "Custom — vless:// or subscription URL".
Also renames the three bundled entries' status from "coming-soon" to
"awaiting-key-issuer". The exits exist and are running xray; what is
missing is a way to hand a client credentials without shipping a shared
secret. DESIGN.md now records why that list stays empty, since this is
the second time the shortcut looked attractive: a vless:// URL is the
credential, so writing one into the tarball (immutable, mirrored) or
onto a public Sia object (mutable but world-readable) are the same
category of mistake. Per-session minting is the fix, because then no
shared credential exists to leak.
Parser verified against plain-text, standard-base64 and url-safe
unpadded-base64 subscription bodies; an HTML error page correctly
yields zero entries instead of a JSON parse crash.
pws=0 (personalisation off) is the parameter rank-tracking scrapers put on
every query, and Google weighs it when deciding to serve the "unusual
traffic" check. With cookies cleared on quit the personalisation it turned
off was already minimal, so the flag bought nothing and made every search
from Theseus look like a bot's. hl and gl stay: they keep Google from
answering with a consent redirect or a region-detect start page.
Clicking the dock raised a native file browser, which was the right answer
while the editor had exactly one thing to offer an empty tab. It is the
wrong answer now: a file browser can only ask which PDF, and the answer is
sometimes none of them.
So the dock opens the editor, and the empty editor says what it can do.
The drop zone stays, and learns to read what it is given — pictures become
pages, several PDFs become one document. Beside it sit the three ways in
as buttons.
Not included: compress, which cannot be done honestly without re-encoding
the images, and split, which is the page rail plus Save a copy.
A document built from pictures or joins has never been on disk, so it is
marked unsaved from the moment it opens — otherwise closing the tab would
bin it without asking. An empty editor also stops claiming to hold a file
called document.pdf.
Completes the three detection paths. The injected provider shipped in
0.8.8; these two needed host support, because nothing in the add-on API
could reach the active tab's content (captureTab is pixels, not DOM).
wiz:// links (main.js)
A click on a wiz:// anchor is intercepted in will-navigate and in the
window-open handler (target="_blank" lands there instead), and routed
to the wallet with the offering page's origin attached, so the
approval names the real site. The tab never navigates. This needs
nothing from the dapp beyond rendering the URI as a link, so it works
for third-party dapps that will never adopt a Silent Mode API.
scan-page capability (addons-host.js + main.js)
New capability backing api.scanActiveTabForUris({scheme, limit}).
Deliberately NOT a "read the page" API: the host runs the match and
returns only the URIs found, so an add-on holding this still cannot
see page text, markup or form values. It sits well below page-inject
on the trust ladder — it learns that a page offers a wiz:// code and
nothing else. Scheme is validated against [a-z][a-z0-9+.-]* and the
result count is capped.
The matcher also accepts WizardConnect's QR-alphanumeric spelling
(WIZ://%3FP%3D…), which is frequently the only form present when a
dapp renders its pairing code as a QR, and decodes it. Verified
against the SDK: decodeKeyExchangeURI accepts standard, QR-raw and
QR-decoded alike.
Regex sources are built host-side and passed as JSON rather than
assembled inside the injected string — hand-escaping backslashes and
quotes through two levels of literal was both wrong on the first
attempt and unreviewable.
Aegis
Declares scan-page, adds the wcScanPage handler and a "Scan page"
button next to Connect. A scan fills the URI field and stops there
rather than pairing outright: the user still chooses which wallet
signs and still presses Connect, because a scan that silently paired
would carry far more consequence than the button implies. Older hosts
without the capability get a clear "update Theseus" message instead of
a dead button.
Completes the parity work 0.8.7 started for Tron. Imported ETH and SOL
wallets showed a native balance and nothing else, because the JSON-RPC
endpoints they poll have no history or token concept at all.
- ETH history + ERC-20 balances via Blockscout, which needs no API key
(Etherscan V2 does). Mainnet RPC moves off eth.llamarpc.com, which was
answering 525 with an HTML error page — that parsed as a JSON error and
showed as a 0 balance.
- SOL history via getSignaturesForAddress and SPL balances via
getTokenAccountsByOwner, both keyless on the public RPC.
Three things the live testing turned up:
- A Blockscout mempool entry is {result:"pending", status:null}. Reading
that as "not ok, therefore failed" showed pending sends as failures.
Now carries a distinct pending state through to the row.
- History `delta` is now a number, a decimal string, or null. ETH wei
needs the string (18 decimals overflows a JS number, and Math.abs was
silently rounding it); Solana's signature feed carries no amount at
all, and null >= 0 is true, so unknown amounts were rendering as a
"+" that claimed a receive we cannot verify. Unknown now renders as a
neutral row instead.
- A real address came back with 855 ERC-20s and 3078 SPL mints, nearly
all airdrop spam, some with blank, zero-width or bidi-override
symbols that render as an empty row borrowing trust from its
neighbours. Token text is sanitised and lists are capped at 50, sorted
so named tokens survive the cap.
Also: the first-run setup screen forced text-align:left on the form, so
its helper copy ran ragged under a centred mark, title and description.
The form now inherits the centred alignment; the mnemonic box stays
left-aligned on purpose, since centring wrapped seed words makes them
harder to check.
Ships the fix from 04b38cb: a run lifted out of the document is text, so
the selection bar now offers Edit, size, bold and italic on it, and the
size picker carries the document's own size rather than rounding an 11 pt
line up to 12 on the way out.
Launched from a shell, the main process inherits that shell's stdout. When
the shell exits the pipe breaks, and the next console.log from the add-on
host raises EPIPE — which Electron reports to the user as a fatal uncaught
exception, over a diagnostic line nobody was left to read.
Testing 0.3.0 on a real install showed the gap immediately: select a run you
had replaced and the selection bar offered duplicate and delete and nothing
else. The one mark made entirely of words was the one with no way to change
them, double-clicking it did nothing, and the size stepper and the bold and
italic buttons all stayed hidden. Everything else that holds text could be
reopened; this could not.
The cause was three places testing `kind === "text"` where the question was
really "does this mark hold words". A replacement holds words.
Reusing the dialog exposed a second, quieter fault. A run lifted out of a
document is whatever size the document set — 11 pt, 9.5 pt — while the size
picker lists round numbers. Selecting a value the list does not contain leaves
the select empty, and the size on the way out fell back to 12. Editing the
wording of an 11 pt line would silently have resized it. The dialog now adds
the document's own size as an option for as long as it is open, and falls back
to the size it started with rather than to a guess.
Wallet strip:
- Clicking a coin opens that coin's page (addresses, price, totals, back
and close) instead of only flipping the selection and leaving the list
sitting there. The page already existed but was reachable only via the
small count chip.
- The per-coin second action was a gear that selected the wallet and
opened the global Settings tab — the same destination for every coin,
so it read as a per-coin control that wasn't one. It is now Remove,
behind a confirm, with the default/legacy wallet showing a lock
instead since it gates legacy funds.
- Each address in the drilldown can expand to show what THAT address
holds: TRC20/SPL via the adapter's tokens, BCH CashTokens via
tokenBalances. walletSummary now carries both per wallet, so the view
no longer has to borrow the selected wallet's assets.
WizardConnect — the Connect pane was effectively unusable:
- The locked-vault branch told the user to unlock and gave them nothing
to click. It is reachable without the lock screen ever appearing,
because a mounted imported wallet makes overallPhase read "ready".
It now carries the same unlock form the lock screen uses.
- Imported BCH wallets were never registered with the WC manager —
startForWallet ran only in the vault-derived mount branch. They mount
as ready, so they appeared in the "Sign with" picker and then failed
on pair. They now register from their stored seed. WC derives a child
key tree, so single-key (WIF) imports genuinely cannot pair; those are
disabled in the picker with the reason, rather than failing on click.
- Adds window.wizardconnect so a dapp can hand over the wiz:// URI it
already generated instead of making the user copy it between tabs.
The protocol is Nostr-relay pairing designed for phone-scans-QR, and
the SDK has no in-page discovery at all, so this is our own surface:
connect() + isReady(), plus a wizardconnect:announceProvider event
shaped like EIP-6963 so several WC wallets can coexist. Pairing always
goes through the approval modal; the URI is validated before any UI
shows, and the wallet never reads the page to find one.
A PDF does not contain paragraphs. It contains glyphs with coordinates, and
there is no heading, no list, no table and no guaranteed reading order —
only runs of characters that happen to sit near each other. Converting to
Word means working out where the paragraphs were, from geometry. That
inference is the whole feature, and it is sometimes wrong, so this is called
a conversion and never an edit, and the dialog reports what it found before
anything is written.
Lines are grouped by baseline, runs joined with the spaces a PDF only implies
by leaving a gap, and paragraphs ended where the next line sits unusually far
below, is indented, or where the previous one stopped short of the measure.
Headings come from size relative to the body — which is the most common size
on the page, not the average, because a page of 11 pt under a 28 pt title
averages to something that is neither. Bold and italic come from the font's
name, the only place a PDF records them.
What it refuses to fake is as important. A page set in columns is reported,
not silently interleaved. A page with no text says so, and says why: it is an
image of writing, and reading that needs character recognition this editor
does not have. Tables become plain paragraphs rather than an invented grid,
because a wrong table is harder to repair than no table.
The .docx is written here rather than by a vendored builder: a Word file is a
zip of five XML parts, and the subset that can honestly be produced —
paragraphs of styled runs — is about two hundred lines. Vendoring a document
library would have added another megabyte on top of the four pdf.js and
pdf-lib already weigh, to generate markup we would still have to get right.
Entries are stored rather than deflated, which keeps a compressor out of the
add-on; the CRCs are the part that cannot be skipped, since Word calls the
file corrupt rather than naming the part that upset it.
Text replaced in place converts as replaced. Converting would otherwise hand
back the words the user had just edited away.
Checked by taking the output apart — every CRC verified, both XML parts run
through a real parser — and then, because that is still marking my own
homework, by opening the result in the Word editor extension, where mammoth
reads it with none of my code involved.
Aegis relaunches Theseus after staging its own update; seen twice in a
dev instance, the whole browser restarted with no warning, mid-session.
The add-on API's restartApp now asks the user in a native dialog
("Aegis Wallet wants to restart Theseus" — Restart now / Later, Later
is the default) and resolves { restarted, deferred }. Declining loses
nothing: a staged update applies on the next normal launch. The guard
lives in the host, so it covers every Aegis version on the channel and
any future add-on.
Imported Tron wallets pointed at api.nileex.io, which only serves the
/wallet/* JSON-RPC family and 404s all of /v1/. That REST family is
where transaction history and the trc20 balance map live, so an
imported Nile wallet showed a native balance and nothing else. The
built-in Tron adapter was already on nile.trongrid.io, which is why
only imports were affected.
Switches the imported Nile endpoint to nile.trongrid.io and fills in
the two features that were never implemented for imported account-
model wallets:
- History via /v1/accounts/<addr>/transactions, with the signed delta
computed by comparing owner_address against the wallet's own address
in 41-hex form (the feed returns hex regardless of visible:true).
- TRC20 balances via /v1/accounts/<addr>, joined against token_info
harvested from recent trc20 transfers to recover symbol + decimals.
Both are best-effort so a chain with no keyless feed can't blank a
wallet whose balance fetch succeeded. Contracts with no registry entry
render as "Unknown token" with a raw amount rather than a number
invented from assumed decimals, and named tokens sort above them so
airdrop spam can't bury real holdings.
Until now "editing" a PDF here meant laying things over it. You could put a
word on top of a word, but the document underneath never changed, and the
result read like a sticker because it was one. This adds the thing the word
Edit actually promises: click a line of the document's text, type different
words, and they land where the old ones were, in the old size and the old
colour.
The position and size come from pdf.js's text layer, which has already placed
a span over every run and carries that run's size in unscaled PDF points — so
the size is right whatever the zoom, which reading it off the rendered box
would not be. The colours come from the rendered page, because nothing in the
text API reports them: the background is the average of the most common colour
bucket in the run's box, since type is a minority of the pixels even when it
is dense, and the ink is whatever sits furthest from that background. On the
test fixture it recovers the marker's red exactly.
Two things that look like details and are not. The bucket only chooses WHICH
pixels are background; the colour itself is their average, because rebuilding
it from the bucket index rounds white down to #f8f8f8 and a not-quite-white
patch on a white page is a visible seam. And the cover reaches below the
baseline by a quarter of the font size, because pdf.js sizes its spans to the
em box: cut the cover to the span and every descender in the original line
survives as a little hook under the replacement.
A replacement is a cover plus text, so it is a mark like any other — movable,
resizable, undoable, and rendered on screen from the same numbers the writer
uses, which is what makes the preview trustworthy.
Said plainly in the dialog and again in the save summary: this hides the
original, it does not remove it. The old glyphs are still in the content
stream underneath. Redact is the tool that takes text away, and it says so
too.
window.confirm/alert render as chrome-owned, Theseus-branded OS boxes
outside the sidebar, which breaks the illusion that Aegis is one
coherent surface — and they can't carry an icon, a danger-styled
button, or formatted copy.
Adds aegisConfirm() / aegisAlert(): the same overlay shell the manage
and import modals already use, resolving like confirm() so callers
just await it. Escape cancels, Enter confirms, click-outside cancels.
Swapped at all four confirm sites (remove wallet from the picker,
remove wallet from Settings, remove PIN, sign out) and all seven
alert sites.
Drawing a rectangle left it selected with its handles showing, and then
refused to let you touch them. The handles were only live under the select
tool, so sizing the shape you were still looking at meant a trip to the
toolbar and back — for a gesture the editor had already drawn the grips for.
Handles are now grabbable whatever tool is armed. They cannot be confused
with drawing: a handle is a nine-pixel square that only exists while
something is selected, nobody lands on one by accident, and Escape drops the
selection if the space is wanted back for drawing. The original gate was
protecting against a collision that does not really happen, at the cost of
one that does.
Making them universal opened a trap in the release path, fixed here too: the
text-markup tools return early from onUp to commit a text selection, which
would have stranded a resize half-done — applied to the live mark, never
journalled, with the drag still set so the next press behaved oddly. A
handle drag is now finished first, whatever tool is armed.
Every Coin-Spectrum poll silently returned an empty map because we
were reading body.price_usd (top-level) while the API wraps its data
under body.asset. Every chain's Number(undefined) came back NaN, so
Settings › Prices showed "✓ 0 coins" and majority-vote reconciliation
had one fewer source than intended.
Now reads body.asset.price_usd (with a top-level fallback in case the
API is ever flattened).
Updates published after launch never showed: the add-on channel was
checked once, 30 s after boot, and staged copies only applied on the
next launch with nothing telling the user. On 2026-09-22 Aegis 0.8.3
and VPN 0.1.3 landed minutes after the app's only check and stayed
invisible through manual scans made earlier and a restart made before
they were published.
Now: the check repeats every 4 hours; every check (boot, timer, manual,
add-on-driven) reports what is staged to the chrome, which shows a chip
for staged extensions; clicking it, or the "Update to vX" button that
appears on the extension's row and detail in Settings, promotes the
staged folder over the installed one and rebuilds the add-on host, so
the new version runs without a restart. Plug-ins (Aegis) are excluded
from the chip and the hot swap — a wallet updates from its own panel
and applies on the next launch.
Also from the same review: the new-tab button follows the last tab and
parks after the scroll arrow only when the strip overflows; Tor sits
left of the Aegis chip; plug-ins no longer appear in Settings ›
Extensions (they have Plug-ins); the extension detail view has a
labelled Back button, a close button and a Check now button; the
promotion helper returns what it promoted and accepts a filter.
Two follow-ups on 0.8.0's currency picker after a testing pass:
- The network label was a tiny gray suffix next to the wallet name in
the header, so "which network am I on" wasn't obvious at a glance.
Now it's a separate row of one clickable chip under the coin ticker;
clicking jumps into the browse:chain view where the network strip
actually switches network.
- Clicking the header opened a "Pick a coin" pane that only listed
coins the user already had a wallet for — a first-time user with a
single BCH wallet would see one row and no way to add more. Now it
shows every supported coin behind a search box; owned coins jump to
the wallet list, unowned coins jump straight into the create-wallet
flow with that coin's network group pre-expanded.
Addon:
- Paste any https:// URL that returns a list of vless:// (either
newline-separated or base64) and the extension fetches, decodes,
parses, and adds every server to the dropdown. The full vless URL
never leaves the panel — the addon holds it in its own storage and
passes an opaque "sub-<hash>" id back for selection.
- Subscription CRUD on the addon side (listSubscriptions,
addSubscription, refreshSubscription, removeSubscription). A refresh
is a no-op inside the 6-hour TTL to avoid pounding the provider.
- Merges subscription servers with the baked-in three and gateway
overlay by id; the dropdown groups them under one banner.
Site:
- silentmode.st/vpn landing page: three-plan grid (Free, Pro at $1/mo
BCH, Max at $4/mo BCH), how-it-works four-step block, "the three
servers" strip with per-tier availability, why-this-VPN cards, FAQ.
Priced in USD, paid in BCH via the oracle at pay-time — same pattern
as the marketplace's USD-listing covenant, no reintroduction of
fiat/card processors.
Blockchair's BCH explorer is slow and ad-heavy; bchexplorer.cash is
the Bitcoin Cash community's own instance, faster on tx pages and
with a proper mempool view. Same /tx/ + /address/ path scheme
(address takes the bitcoincash: prefix as-is), so no other code has
to change.
Chipnet explorer stays on chipnet.imaginary.cash — bchexplorer.cash
is mainnet-only.
The Extensions list was a stack of tall cards — description, author,
capabilities and buttons on every one — so seven add-ons filled the
page before the user found the toggle. Rows are now one line each,
Firefox-style: icon, name, built-in badge, version, a short update
status, the on/off switch and a ⋯ menu, grouped Enabled / Disabled /
Failed to load. Clicking a row opens the detail view in place: back
arrow, description, update status, author, version, type, folder with
Show folder, and a Permissions block that explains each declared
capability in plain words. The ⋯ menu (and right-click) offers Turn
on/off, Details, Show folder and, for non-bundled extensions, Remove —
a new addons-remove IPC that deletes the folder under the extensions
directory, refuses bundled add-ons (they would only be reseeded), and
clears the dock prefs it left behind.
Settings grew tall enough that the user had to scroll past a dozen
cards to reach Prices, Sites or About. Split it into six named
sections (Security, Session, Wallet, Prices, Sites, About) with a
chip nav row at the top; only one section is visible at a time and
the choice persists across restarts.
Adds an About card that names the wallet, the aegis.x front-door
site and the silentmode.st umbrella, so support triage has a
one-click way to reach either from within the panel.
Includes the accumulated 0.7.x-0.8.1 wallet work that was already
shipping on OTA (CashTokens/BCMR, imported-wallet spend, siascan
integration, consolidate, currency picker, footer update chip).
The lock still said 0.3.51 after the version bump; npm rewrote the two
version fields on the next install. No dependency changed — the diff is
those two lines.
Every commercial VPN client stores its server catalog as a JSON on the
backend and lets the panel pick from a dropdown; this pulls that shape
into the extension.
- server-list.json: baked-in default the tarball ships with. Three
Silent Mode slots (sm-1..sm-3), status "coming-soon" until the VLESS
URLs land — the toggle stays disabled for any entry whose status is
not "ready", so a placeholder cannot be selected by accident.
- Gateway overlay: index.js fetches
https://navigate.st/api/vpn/servers on activation (with a 6-hour TTL
and a "refresh" button in the panel) and merges by id — remote wins,
new remote entries append. Cached to per-addon storage so an offline
boot still has the last-good catalog.
- turnOn now accepts { serverId } or { vless }. Server id is resolved
through the catalog inside the addon; the panel only sees a public
view (label, flag, country, ready/coming-soon), never the raw URL.
- Panel: dropdown of servers + a "Custom vless://" option that reveals
the paste box. Selection persists per-machine, refresh button forces
a re-fetch, disabled toggle explains why in the hint area.
No behavioural change for anyone with a saved vless:// paste — that
path is now "Custom" in the dropdown and still works identically.
Since 0.1.1: Save as… with a real file dialog, PDF export through Chromium's
print pipeline, several documents open at once as tabs with their own close
buttons and menus, a page that scales to fill the window, and Ubuntu and
Fraunces bundled so the ribbon can offer fonts Windows does not have.
The description gains the parts a user would look for before installing.
The page sat marooned in the middle of a wide window with dark space either
side of it. It is now drawn at the size the document actually claims — A4
stays A4, margins come from its own sectPr — and CSS `zoom` scales that to
fit, defaulting to Fit width with a control in the footer and Ctrl +/-/0.
Scaling rather than widening is deliberate. A page stretched to the window
would break every line somewhere different from where the printed page
breaks it, and an editor whose whole claim is that it shows you the document
should not lie about where the lines end. `zoom` also beats a transform
here: it affects layout, so the board scrolls correctly and ProseMirror's
coordinate maths keeps working.
Ubuntu and Fraunces now ship in fonts/, because Windows has neither and a
font offered in the ribbon that the machine lacks is a font the user picks
and then cannot see. Fetched once by `npm run fonts` and committed, never at
runtime: an extension in a browser built around not phoning home should not
ask a font CDN what a document looks like every time one is opened.
Two things had to be worked around. On file:// Chromium registers @font-face
rules and then refuses to fetch the files — the family appears in
document.fonts and every glyph still renders in the fallback — so the add-on
reads the woff2 and hands the page a stylesheet with them inlined as data
URLs. The PDF export needed the same treatment for a different reason: its
print window runs from a temp folder, where a relative url() resolves to
nothing, which would have quietly undone the one-stylesheet-for-both promise
that lib/doc-css.js exists to keep. If either path fails, the ribbon labels
those families "(not available)" rather than implying otherwise.
About 700 KB, most of it Ubuntu's Cyrillic and Greek — kept because the
documents this is used on are not all English. Licences ship alongside.
Opening a second .docx used to mean a second browser tab: a whole ribbon,
banner and footer repeated, with one ✕ at the far end of a row that also
held the file's name. The name looked like a tab and nothing about it
behaved like one.
Now the editor holds documents the way the browser holds pages. A strip
under the toolbar carries one tab per open document — icon, name, unsaved
dot, its own ✕ — plus a + to open another. Middle-click closes, Ctrl+W
closes, Ctrl+Tab cycles, and right-click (or the caret on the tab under the
pointer) drops a menu: Duplicate, Open in the default app, Show in folder,
Close others, Close. The gestures are the browser's because that is the tab
strip every user of this editor already knows.
Under it, one ProseMirror view is handed a different state per document
rather than one view per tab, and the module-level "current document"
variables are marshalled in and out on a switch. That keeps the change out
of every function that touches the current document, at the price of one
list — DOC_FIELDS in captureActive/adoptDoc — that has to stay complete. A
variable missed there leaks one document's state into another, which would
look like the editor corrupting a file, so it is called out in a comment.
Closing the last document closes the editor tab, the way closing a
browser's last tab closes the window; an empty ribbon staring at the user
is not a state worth having.
The add-on hands a newly opened document to the editor that is already up
and fronts it, falling back to opening a tab if no editor acknowledges
within 900ms — so a crashed or closed editor degrades to exactly the old
behaviour rather than swallowing the document.
api.emit() only ever reached the sidebar, even though addon-tab-preload has
always exposed silentmode.on() — so an add-on could hear from its panel but
had no way to tell its own editor tab anything. An add-on that wants a
second document to land in the editor already open, instead of a third tab
full of ribbon, needs exactly that.
Two small pieces:
- emitToPanel now also delivers to every tab owned by the same add-on. The
sidebar keeps its existing condition; tabs are additional, not instead.
- addon-tab-focus, the counterpart of addon-tab-close: a page asking for its
own tab to be fronted. Needed because the click that hands an open page
something new usually happens somewhere else — the sidebar — and the
result would otherwise appear in a tab nobody is looking at.
Both derive the tab from the sender's webContents, the way the close handler
already does, so a page can only front or close the tab it is itself in.
The extension buttons sat in registration order with no way to change
it, hide one, or switch an add-on off without opening Settings. Buttons
are now draggable (drop side follows the pointer, same feedback as tabs
and bookmark chips) and the order is persisted per profile. Right-click
opens a native menu: open/close the panel, move left/right, hide from
the toolbar, turn the add-on off, and Manage extensions; the dock's own
right-click offers Show hidden. Main owns the prefs (dockOrder,
dockHidden) and the actions, so the chrome only renders. Add-on
rediscovery now pushes a fresh dock state to the chrome, which it never
did before — turning an add-on off or installing one from a page
updates the toolbar at once.
Opening a PDF cost four clicks across two screens: the dock icon, a dropdown
with a single entry in it, an editor with an empty drop zone, and finally the
Open button that produced a file browser. Three of those were the program
asking the user to confirm what they had already said by clicking a PDF icon.
The dock item now raises the file browser itself. The dialog is native and
raised from the add-on's Node side, because a file:// page cannot open one
without a user gesture of its own and a freshly-opened tab has none to spend.
The chosen file goes through the same scratch handoff a right-clicked link
already used, so the editor opens with the document in it rather than with an
invitation to find one. Cancelling still lands in the empty editor, which is
where drag-and-drop works and is probably still where someone who changed
their mind about the file wants to be.
Second click removed in the chrome: a toolbar menu holding exactly one item is
not a menu, it is a button wearing a dropdown, so it now dispatches directly.
Falls through to the popup if the renderer has no addonMenuSelect.
Not yet exercised end to end — TheseusNavigator/node_modules is empty while
another session reinstalls it, so the CDP suites cannot boot Electron. Both
files parse; run scratchpad/verify-pdf-editor/drive.mjs once the tree is back.
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.
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.
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.