Rebuilt from Pithos/ with scripts/build-theseus-addon.mjs. Pithos moves from a toolbar menu that opened a loopback tab to a left-sidebar panel. Existing installs get the same version over the extension update channel.
Ships Pithos (the s3d control panel) as a built-in extension: the dock
menu opens it in a tab served from a loopback port, and "Stop s3d"
shuts the gateway down. Generated by Pithos/scripts/build-theseus-addon.mjs.
yaml is vendored under vendor/yaml/lib rather than node_modules/ or
dist/, because Theseus git-ignores both and a clean-worktree release
build would otherwise ship the add-on without its only dependency.
Auto-detect already worked — the backend returns
detectedLanguage:{language,confidence} and the panel put it in the
status line. But that only appeared after a translation had already
run, in small text, away from the control that raised the question. You
could not tell what "Auto-detect" had decided before committing to it.
The first row of the source select now says "Auto-detect · German", and
it says so while you are still typing. Detection runs on its own via
LibreTranslate's /detect, debounced 700 ms and gated at 12 characters,
because a detector given two words is guessing and firing per keystroke
would pound a public mirror for nothing. It chains the same mirror
fallback as translation, so a dead primary does not make detection look
broken while translating still works.
Confidence below 60 renders as "German?" rather than silently asserting
a coin-flip. The Google backend has no detect-only route, so there
doDetect returns null instead of burning a request, and the label is
filled from the translation response — which every backend returns
anyway, so a skipped or failed detect is never worse than before.
Detection retires when a source is named explicitly, comes back on
returning to Auto-detect, and is wiped by clear. A right-click
selection schedules one too, since that text arrives with no keystroke.
Verified against the real mirror (de/fr/ja at 100/100/90%) and in the
harness: short text fires nothing, long text fires once, four rapid
edits debounce to one call, and every transition above lands.
Also adds the xray removal script used to take the old engine off all
three exits now that they run sing-box.
Navigation base, the way Firefox does about:preferences#privacy: the
address bar follows the Settings page (theseus://settings/privacy) and
the hash mirrors it, so every page has a link; a page can have
sub-pages (theseus://settings/privacy/exceptions) with a breadcrumb and
a back arrow; open-settings and theseus:// links accept the two-level
slug.
Privacy now reads top-down: a "Theseus is on guard" card (Shield and
its running total, cookie pop-ups answered, Tor state, version), then
Tracking protection with the Shield and Cookie Pop-ups cards moved here
from Performance and a Manage exceptions sub-page listing the sites
each add-on was told to leave alone (remove to protect again), then
Device access, Anti-fingerprinting, Network (Tor switch and the VPN
panel) and Browsing data. Performance is about resources again.
Both selects grow a "Frequently used" optgroup above "All languages",
holding up to ten entries ranked by how often each language has actually
been translated to or from.
Counted on a successful translation, not on a dropdown change: picking
your way down the list looking for something would otherwise rank every
language you skimmed past as highly as the ones you work in. A
detected source counts too — with Auto-detect on you never pick that
language explicitly, but it is one you read.
Ties break on the language's display name so the order is stable rather
than dependent on object key order. Counts live in the existing uiState,
so they persist through the saveUi/loadState path already there, and the
group only appears once there is something to put in it.
Verified in the harness: one translation records exactly one use for the
target and one for the detected source; the cap holds at ten with
fifteen tracked; and a pre-seeded profile comes back with its group,
target and zoom restored, Auto-detect still first in the source select.
Three bugs, all found by driving the add-on in a real Theseus and
watching the egress IP rather than reasoning about it.
1. The generated config used pre-1.11 schema. `sniff` on an inbound and
the `block` outbound type were deprecated in sing-box 1.11 and
REMOVED in 1.13, so 1.14.1 refused the whole file and exited 1.
Routing is now a bare `final`; rule `action` semantics changed in
1.12 and the explicit inbound→outbound rule was never needed.
2. xray-core 26.3.27's REALITY would not complete a handshake with a
sing-box client — and, after ruling out keys (three derivations, a
fresh pair used verbatim), shortIds (explicit and empty), clock skew,
dest reachability, TLS 1.3/X25519 on the dest, and xtls-rprx-vision,
not with a correctly configured xray client either. sing-box against
sing-box works first try. The exits now run sing-box, which is what
the add-on already ships to every client, so there is no longer a
cross-implementation surface at all. Migration script included; it
keeps the port, the SNI and the existing uuid pool and only changes
the Reality keypair.
Worth recording separately: xray's REALITY inbound field is `dest`,
not sing-box's `target`. That was wrong too, independently.
3. leaseEndpoint cached the full vless URL. The Reality key and short id
live inside that URL, so re-keying an exit left every client failing
against a stale copy for the whole 24h lease. It now caches only the
uuid and rebuilds the URL from the current catalogue entry, so a
re-key takes effect as soon as the catalogue refreshes.
Verified in Theseus over CDP: baseline 80.187.100.105, tunnel up
81.31.210.65 (the sm-1 exit), off restores the baseline, and sm-3 is
correctly refused to a free-tier caller.
Found by driving the add-on in a real Theseus rather than reasoning
about it. turnOn() failed with "Silent Mode · 1 is not yet configured
(ready)" — resolveEndpoint required the catalogue entry to carry a
vless URL, but the gateway catalogue deliberately ships none, because a
vless URL is the credential and that endpoint is public. The add-on
predates the key-issuer and was never taught to ask for a lease.
It now POSTs to /api/vpn/session for any ready exit that has no URL of
its own, and caches the lease under its serverId until a minute before
expiry. Baked-in and subscription entries still use their own URL and
never hit the network.
Shield and Cookie Pop-ups are settings more than tools, so their
switches, the cookie mode, the counters and "Update rules" now sit in
Settings › Performance under a Protections heading, driven through the
add-ons' own message handlers (Settings-only IPC). Each card opens the
add-on's panel for the per-site details, and each panel links back to
Settings. The two add-ons start hidden from the toolbar's extension
row (manifest dock:"hidden", honoured once so a user who shows them
keeps them); "Show hidden" on the row brings them back.
theseus://settings and theseus://settings/<section> are now addresses,
so any page or note can link to a Settings page.
Also: a Settings or add-on tab that the user navigates elsewhere stops
counting as that tab, otherwise "open Settings" kept focusing a tab
that no longer showed Settings.
A bundled add-on that answers cookie consent dialogs, rejecting all but
the essentials by default (or accepting, if the user prefers the banner
simply gone), so pages open without one. Built on DuckDuckGo's
autoconsent (MPL-2.0): its rule bundle covers hundreds of consent
managers, and a reject-button heuristic handles unknown banners in
reject mode. The library runs through the page-inject slot in every
http(s) frame's isolated world; the add-on hands each frame the user's
settings and the rules, and counts what was handled per site for the
panel, which also excludes a site with one click.
build-inject.js assembles inject.js from the library in node_modules
plus the glue, and copies the compact rules and licence into the add-on
so a rule update can ship through the add-on channel.
Host side: page-inject scripts get theseus.evalInPage for the few rules
that need the page's own JavaScript (they already reach the page via
contextBridge, so no new trust tier), and api.tabs is open to
page-inject add-ons as well as request-filter ones.
Theseus had no content blocking at all. Shield blocks requests to known
tracking and advertising hosts on every site, using EasyList and
EasyPrivacy through Ghostery's adblocker engine (the matcher those lists
are written for). The lists ship inside the add-on so blocking works
from the first launch, offline; the compiled engine is cached under the
add-on's data dir (a 22 ms load instead of a 500 ms parse), and the
lists refresh from their publishers about once a day.
The panel shows what was stopped on the current page, a one-click
allow for the site, the global switch, the running total and the rule
versions with an "Update now". Network filters only for now: a blocked
request never leaves the browser, but leftover empty ad boxes are not
hidden yet.
Host side: a "request-filter" capability. Chromium allows one
onBeforeRequest listener per session, so main owns it and consults the
add-ons' filters; a top-level navigation is never blocked, only http(s)
subresources are offered. api.tabs (active tab and a change event) lets
the panel show per-site numbers without seeing page content.
Three complaints, one cause between the first two.
A line of a PDF is rarely one run. pdf.js splits it wherever the file does
— a font change, a kerning adjustment, a colour change — so a heading can
be three spans and an invoice line ten. Replacing the span under the
cursor covered a fragment and left the rest of the line standing, which is
exactly what a replacement that looks like a copy laid over the original
is. A run is now the whole visual line: the spans that share its baseline
and sit close enough to be spacing rather than a second column, with the
spaces the geometry implies put back between them.
And that line is edited on the page. The dialog that used to hold a copy
of the words is gone: the cover goes down first, carrying the line's own
words at the page's own size and colour, and the caret opens on it. The
cover keeps its words hidden while you type, so the original never shows
through the thing covering it. Escape with nothing changed lifts the cover
again and leaves the page as it was found — no mark, no undo step.
The rotate grip was a square like the resize handles, wearing the open
hand that means drag-the-page. It is a disc with a turning arrow now, and
a cursor drawn to match, since no standard cursor means turn. The inline
style that was defeating the stylesheet is gone with it.
Ctrl and the wheel zoom, about the pointer rather than the top-left, so
the words you were reading stay where they were. A plain wheel still
scrolls.
Records what the gateway routes do, the four things that still have to
happen on the servers before the dropdown can light up (UUID pool per
inbound, the pool file, X-Forwarded-For on the vhost, deploy-from-repo),
and — deliberately at the same prominence — that expiry is bookkeeping
rather than enforcement until xray's handler API is wired per box.
Also flags the per-client byte cap as an unverified cheaper mitigation,
marked as needing a test rather than written up as though it works.
Drops the meridian ellipse so the globe is just a round field, and
spends the space that frees on the glyphs: tiles grow from 11.6 to 13.4
units and the A from font-size 10 to 12.4, with the 文 strokes
thickened to match. Checked the same way as last time — rasterized at
true 16px and 18px and magnified nearest-neighbour — against three
candidates (plain globe with tiles, letters bare on the globe, and a
single tile). Tiles won: bare glyphs on blue lose too much edge
contrast at 16px, and the two-tile arrangement is what reads as two
scripts rather than one word.
Same mark in the manifest and in the panel header.
A band starts from the space between things, so that a drag across words
still selects words to copy. On a page that is wall-to-wall text there is
no such space, and the gesture simply did nothing — which is how it failed
on a real document at 276% zoom, where every candidate starting point had
a line of text under it.
Alt-drag starts a band anywhere, text or not. The select hint says so.
Select, Text, Highlight, Draw, Sign — labelled, in that order, at the head
of the toolbar. Each one leads a kit rather than hiding it: the shapes sit
behind Draw, underline and strike-through behind Highlight, and nothing is
offered twice.
Draw arms the pen, which is what Draw means when there is one button for
it. The names drop out below 1180px, where five of them would start
pushing the zoom and colour controls off the end.
Auto-translate: changing either language only called saveUi(), so the
pane below kept showing the previous language's result with nothing to
say it was stale — you had to notice and press Translate. Both selects
now re-run the translation, guarded on empty input and on
source === target (which would only echo the input back).
Text size: the panes were 13.5px, small for reading a paragraph in a
language you don't know well, which is the entire job. Base is now
15px, driven by a --tsize custom property so both panes stay matched.
Zoom: −/+ either side of a percentage in the header, 70–220% in steps
of 10, clamped with the buttons disabling at each end. Click the
percentage to reset. Ctrl/Cmd with +, - or 0 does the same from either
pane. Persists per-machine alongside the language choice.
Icon: a globe with an A tile and a 文 tile, replacing the 🌐 emoji that
was indistinguishable from every other globe in the dock. Drawn against
the 16px and 18px rasterizations rather than at a comfortable size —
the first pass used a font glyph for 文 and it turned to grey mush at
16px, so the strokes are hand-drawn paths thick enough to survive. Also
drops the hardcoded icon in registerSidebarPanel, which would otherwise
shadow the manifest's mark.
Verified in a harness with a stubbed host API: language change fires
exactly one request, the two guards fire none, zoom clamps and persists,
and the layout holds at sidebar width.
Select does what selecting does in every editor people already know.
Double-click bare page and a caret opens there; double-click the document's
own words and they open for replacement; what is selected copies with
Ctrl+C, pastes with Ctrl+V and goes with Del. Nothing was taken away — a
drag on empty page still gathers an area, and a drag that starts on words
still selects words to copy.
Type text and Edit text were two buttons for one question the click already
answers. They are one Text tool: land on the document's own words and it
offers to replace them, land anywhere else and it starts new text. The
words light up under the cursor so which is which is visible before
clicking, not after.
The toolbar says what it is for. Select, Text and Sign are labelled and set
apart; the drawing kit and the markup kit are their own groups. Sign gets a
pen icon over a signature rather than a squiggle that could have been
anything.
Signatures take ink — black, blue, red, green — chosen while drawing and
kept with the signature, because people sign in a particular colour and it
belongs to the signature, not to whichever swatch was armed. And they turn:
a grip above the box, free rotation, Shift to snap to 15°, for the signing
line that is not square to the page.
Two faults the tests found, both invisible by eye:
The rotate grip was drawn in the right place and could not be grabbed —
the selection bar floats directly above a mark, which is exactly where the
grip sits, and it swallowed every click. The bar now stands clear of it.
Undo would not undo a first rotation. Restoring a mark with Object.assign
copies the keys the original HAD, so a property the drag introduced
survived the restore; the journal then recorded the rotated state as the
state to go back to. Restoring now forgets keys the original never had,
which fixes every future property with the same shape.
Also: building a document from pictures or joins refuses to start a second
one on top of the first, and says so rather than failing quietly.
Drag on empty page and a rubber band gathers every mark it sweeps over.
Touching counts rather than enclosing: a band you have to draw right
around a long arrow is a band you draw twice. Shift-click adds or removes
one, Ctrl+A takes the page.
What the group can then do is move, restyle, duplicate and delete, each as
a single undo step — six marks deleted is one thing the user did, so it
has to be one thing to undo. The selection bar offers only what is true of
every member: a group of shapes gets Fill, a group of stamps gets size and
weight, a mixed group gets neither, and none of them gets Edit, which
needs one mark to put a caret in.
No resize handles on a group. Stretching a mixed selection means deciding
what a stretch does to text, which scales by font size, and to a line,
which has no box at all; until there is an answer worth defending,
offering the grip would promise something this cannot keep.
Each member is outlined as well as the group, because a band that caught
one more mark than you meant is worth seeing before you press Delete.
A group lives on one page. A mark carries its page, and PDF user space
means nothing across two of them, so a band selects within the page it
was drawn on.
One trap found on the way: starting the band on a press means calling
preventDefault, which is also what gives a form field its caret — so the
band now keeps its hands off the annotation layer, and typing into a PDF
form still works.
Three things the editor made you work around.
Text was typed into a dialog and then placed, so you chose a size and a
weight for words you could not see against the page they were going on.
The click now opens a caret where you clicked, in the font, size and
colour the words will have, with the style bar over it; double-clicking a
stamp reopens it in place. Lining a CSS line box up with a PDF baseline is
measured from the font's own metrics, not guessed.
A signature lived in a single slot. There was nowhere to keep initials as
well as a name, nowhere to change the one you had, and reaching for the
tool again simply stamped the first one — which is the same fault three
times: one slot. It is a library now, with redraw, rename and delete, and
the choice is made when the tool is picked up, so placing stays one click.
An existing single signature is carried into it rather than dropped.
The mark you had just drawn could be resized by its handles and not moved
by its middle, because only the select tool let marks be hit-tested at
all. The SELECTED mark now takes a press whatever tool is armed. A press
anywhere else still draws, and an unfilled shape is still grabbed by its
outline — the same rule select has always followed.
Also: words default to dark ink rather than highlighter yellow, which was
unreadable on white and is now impossible to miss, since you watch
yourself type it.
0.3.56 was cut from the Aegis line (WizardConnect auto-detection, Aegis
0.8.x, PDF Editor and VPN updates) on top of 0.3.55; 0.3.57 carries that
plus the install-as-app feature and the two main-process crash fixes.
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.
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.
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.
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).
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.
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).
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.
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.
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.
Binaries uploaded to bns/theseus/vpn-binaries/<platform>/, served
through the gateway at navigate.st/bns/theseus.x/vpn-binaries/.
Manifest hashes match a curl-fetched copy through the gateway.
The mark now shares pdf-editor's silhouette, corner geometry and weight, in
blue against its red, so the two editors read as a pair in the dock. It
carries a pilcrow rather than a format label: at 16px "DOC" is a smudge
while the paragraph mark is still a glyph, and it says word processor rather
than file extension.
Drawn as paths, not <text>. An icon that needs a particular font installed
to make sense is an icon that eventually renders wrong somewhere.
Still nothing of Microsoft's: their blue sheet carries a white W, and the
page-with-a-folded-corner is the universal document glyph rather than
anyone's property.
Also adds docs/DESIGN-one-extension-catalogue.md, which writes down the
larger point this kept bumping into — that bundled and community extensions
are two systems for one kind of thing, with two trust rules and two lists in
Settings, and that "ships by default" should be a line of configuration
rather than a separate distribution path. Proposed only; the awkward part is
keeping a fresh offline install usable, which the note answers by treating
the shipped copies as a pre-seeded cache that still verifies like everything
else.
Ships the extension small (~50 KB tarball). No binaries in it — the
platform-matched sing-box is downloaded on first "Turn on" from
bns/theseus.x/vpn-binaries/<platform>/, sha256-verified against the
manifest that ships inside this operator-signed tarball, and cached
under extensions-data/vpn/bin/. Every subsequent launch re-verifies
before spawning; a mismatch redownloads rather than trusts what is on
disk.
Config generator produces a sing-box config from a vless:// URL (the
shape a 3x-UI VLESS+Reality inbound produces), plus a SOCKS5 inbound
on 127.0.0.1:<ephemeral>. api.setSessionProxy points every Theseus
request at that port while the tunnel is up; child.on("exit") clears
it if sing-box dies. Off again clears the proxy back to whatever the
browser had.
Panel is a big on/off toggle with a status pill, a paste-and-save
endpoint box, and an Advanced disclosure with "auto-on at browser
start", "re-download binary" and "clear cache". Any user with a
vless:// URL can flip it on today; the free tier and the Silent Mode
exit inbound are the server-side half, documented under DESIGN.md.
Binary manifest ships with PENDING sha256s until the binaries are
uploaded to Sia — ensureBinary refuses to activate on a platform whose
sha256 is PENDING, so a user cannot flip it on against an unverified
download.
Moving it out of the build left it with no way in. Settings can only install
from the community catalogue, so a first-party extension that isn't bundled
has a working update channel and no first copy for anyone to update — the
mechanism was all there and the front door was missing.
So it goes back beside screenshot, aegis and pdf-editor: seeded into every
profile by the build, listed under "Built into Theseus", and kept current
between releases by the operator-signed channel at
theseus.x/extensions/docx-editor/. That is the arrangement docs/ADDON-UPDATES.md
describes, and the one the signing script was written for.
About 400 KB compressed in the installer, most of it the vendored editor
libraries — next to the ~4 MB of pdf.js that pdf-editor already ships, the
weight argument for keeping it out didn't survive contact with the numbers.
The end-to-end driver goes back to checking that a fresh profile seeds it,
which is the property that actually matters now.
The panel was doing the HTTP call itself, which meant any mirror sitting
behind a Cloudflare-style anti-bot check returned "<!doctype html>…" for
a POST from Origin: file:// and JSON.parse choked on it. Moving the
fetch into index.js gets rid of the whole class of browser-context
interceptors (CORS preflights, captive portals, anti-bot pages) and
lets the addon look at the response body before trying to parse it —
an HTML body is now reported cleanly as "server returned an HTML page
instead of JSON".
While there, chain a small mirror list — translate.disroot.org,
translate.plausibility.cloud, lingva.ml — so a single mirror being down
does not take the feature with it. A user whose saved URL points at a
mirror that stopped resolving (translate.argosopentech.com is the
notable case) now transparently gets a translation from the next mirror
in line instead of a stack trace.
Verified end-to-end from Node against both a working URL and a dead
one; the dead one falls through to disroot as expected.
Everything the editor put on a page was final. A text stamp could not be
corrected without deleting it and typing it again, nothing could be resized,
and the only way to remove a mark was a Delete key nobody had been told
about — the selection drew a dashed box and offered no action at all. Placing
a stamp also left its tool armed, so the next click stamped a second copy.
Marks are now editable objects. Selecting one gives it grab handles and a
small bar pinned above it: delete and duplicate for anything, and for text an
edit button, a size stepper and bold and italic. Double-clicking text reopens
it for rewriting in place rather than adding a second one. Placing a text
stamp or a signature drops straight back to the select tool with the new mark
live, which is both what people expect and what puts it immediately within
reach of a nudge.
Resizing is one function over every mark type rather than a special case per
kind: a handle drag produces a new bounding box, and the mark is mapped from
its old box into that one. Text scales by font size instead of stretching its
glyphs, signatures keep their aspect on a corner, and lines offer their two
endpoints instead of a box that would let you stretch them in ways you never
aimed at. A whole gesture lands on the undo stack as one step.
Selecting a thin mark used to mean clicking its outline exactly — about one
screen pixel. Each stroked mark now carries an invisible fat copy of itself
purely to catch the pointer.
New marks to go with it: underline and strike-through, which share the
highlight's text-selection geometry and differ only in where the rule sits; a
plain line; and a fill toggle for rectangles and ellipses. Bold and italic
mean three more Helvetica variants embedded at save time, since a PDF treats
them as separate fonts rather than as a style.
Double-click is detected from the pointer stream rather than from a dblclick
listener, because selecting a mark calls preventDefault() on the pointerdown
and that suppresses the compatibility mouse events the browser would have
synthesised the dblclick from.