Skip to content

feat: self-update, and the release-readiness QoL batch - #40

Merged
evangress merged 5 commits into
mainfrom
feat/release-readiness
Aug 21, 2026
Merged

evangress merged 5 commits into
mainfrom
feat/release-readiness

Conversation

@evangress

Copy link
Copy Markdown
Collaborator

Movement I branch 5 (feat/release-readiness). Two commits: the update path, then the QoL batch.

Why this before vault search

The roadmap's printed pointer said branch 6, with branch 5 unstarted. That was overtaken by v1.0.0 shipping: the version ladder ties release-readiness to v0.2.0-alpha, and the tag ran past it. The consequence is concrete — every 1.0.0 install has no way to receive anything built after it, so the next feature could not have reached anyone who already has Toril. §7's trust before reach settles the order. ROADMAP.md now records this rather than leaving the stale pointer.

Self-update

Three official Tauri plugins, pinned (updater 2.10.1, process 2.3.1, window-state 2.4.1). No new invoke commands — they ship their own JS API, wrapped in ipc.ts so nothing else reaches past that seam.

Toril notifies; it never installs on its own. The plugin can download-and-replace silently and we deliberately never call it that way — this is an editor holding unsaved buffers, and §3 outranks saving someone two clicks. The restart is the one path in the feature that could destroy a buffer, so it is the one path that refuses: it will not relaunch over a dirty tab, and says the update applies next launch instead. The install is already on disk by then, so waiting is free.

No telemetry — the check is a plain GET for a static manifest.

The policy is split from the network call and the toast so it is gated without a release server. tests/update.test.ts pins the asymmetry that makes an updater tolerable: a startup check is rate-limited, skippable and silent; a direct question via Help → Check for Updates always gets an answer. Two rules there are not obvious:

  • A lastCheckedAt in the future fails open. A corrected clock or timezone jump would otherwise park the timestamp years ahead and disable update checks permanently, with no symptom anyone could notice.
  • Skipping a version means "stop telling me", not "never let me have it" — a later version still surfaces, and a manual check still shows the skipped one.

The notice is a fixed-position toast rather than another row in the layout, which is the opposite of conflictbar.ts on purpose: a conflict is about the document in front of you and must be impossible to miss; an update is ambient news and is never worth reflowing a sentence for.

QoL batch

Editor zoom, Ctrl-click to open links, drag-and-drop open, recent files, and a real first-run note. Three of these are decisions about untrusted input rather than conveniences, so each has a gate.

  • Zoom scales the writing surface and deliberately not the chrome — the OS already scales the whole UI. That moves editor.css heading sizes and the measure from rem to em. The ladder is fixed rather than "multiply by 1.1": free scaling drifts, so five steps in and five out would not land back on 100% and "reset" would quietly stop meaning "the size I had". A persisted 0 snaps to the default rather than clamping, because it would otherwise render the editor unreadable and unfixable — every step from zero is still zero.
  • Link opening (src/links.ts) is a §3.3 boundary. sanitize.ts already stops a hostile href executing in the webview; this stops it executing outside, which is strictly worse because the shell is not sandboxed. So it is an allowlist of three schemes parsed through URL, not a blocklist matched against raw text — the OS folds case and strips control characters before it acts, so jAvAsCrIpT: and a tab-separated java<TAB>script: are the same link to it and a different string to us. A blocklist would also need to have heard of ms-msdt: and search-ms: in advance.
  • Drag-and-drop gets its own allowlist for the same reason: it is the one open path with no file-type filter in front of it. formatForPath is not a substitute — it answers "how do I parse this", and answering "markdown" is right for an unknown text file and wrong for a dropped binary.
  • Recent files rebuild the native menu wholesale (muda submenus are built, not mutated). Items carry an index, never a path: a path is arbitrary user data, menu ids are matched as strings, and encoding one into the other makes the mapping depend on data neither side controls.
  • First run vs. empty state used to be the same two-line stub, so an existing user got the tour every time they closed their last tab. firstRun now comes from the settings file having never been written, not from "nothing was restored".

The welcome note is a round-trip fixture, because it claims in its own text that Toril does not rewrite your files — if saving it produced a diff, the first thing a new user does would contradict the paragraph they just read. That gate earned itself immediately: it caught unpadded table pipes, and a bold span ending in a backslash, where the backslash escapes its own closing marker and mangles the text. Neither was visible by reading.

Two signatures, kept apart

docs/RELEASE-SIGNING.md is new because these are easy to confuse.

Minisign signs the update artifacts and is required. createUpdaterArtifacts is on and the bundler refuses to emit an unsigned update, so release.yml checks up front and fails in five seconds with an explanation rather than twenty minutes into a Rust build. That preflight is a hard stop rather than "skip the updater and carry on": silently cutting another release with no update path is exactly how v1.0.0 stranded itself.

Authenticode (Azure Trusted Signing) is what stops SmartScreen warning, is optional, and needs an account that takes business days to provision. Its signCommand lives in src-tauri/tauri.signing.conf.json, an overlay applied in CI only when the AZURE_* secrets exist — a fork, or a local pnpm tauri build, must not need an Azure subscription to produce a working installer.

⚠️ This blocks releases until one manual step is done

plugins.updater.pubkey is empty, so release.yml will refuse to build any v* tag until:

  1. pnpm tauri signer generate -w ~/.tauri/toril-updater.key
  2. Paste the public key into src-tauri/tauri.conf.jsonplugins.updater.pubkey (it must be committed — installed builds verify against it).
  3. Add the private key's contents as the repo secret TAURI_SIGNING_PRIVATE_KEY.

I did not generate the keypair: it is the project's signing identity, and there is no rotation — every installed copy verifies against the public key it shipped with, so losing the private key strands all of them permanently. Back it up like a password-manager export.

Gates

  • 537 frontend tests (up from 498), across 4 new suites: update, zoom, links, recent, plus the drop allowlist in paths and the welcome fixture in roundtrip.
  • pnpm typecheck / pnpm build clean; cargo fmt --check, cargo clippy --workspace --all-targets, and cargo test --workspace clean (including 3 new menu.rs unit tests).

Not verified, and not claimed to be

12 new items in §D of docs/ON-DEVICE-VERIFICATION.md. Nothing headless can prove a signed artifact downloads, verifies, and replaces a running binary — that needs two real releases.

Two honest gaps worth calling out:

  • D4 (toast layout) is genuinely unverified. §12b says measure rectangles rather than eyeball screenshots; the browser harness could not be driven in the authoring session, so the toast's non-overlap with the status bar has not been measured. dev-harness.html?update serves a fixture (including a long unbroken URL) for exactly this sweep.
  • body is still a grid declaring rows and no columns — the §12b rule-2 shape behind two past cross-engine overlap bugs. #workspace and #main were converted to flex; body was not. I routed the toast out of flow instead of refactoring it mid-branch, but it is worth its own change.

🤖 Generated with Claude Code

evangress and others added 3 commits August 17, 2026 15:44
v1.0.0 shipped with no update path, so every copy is stranded on the
version it was installed with — whatever gets built next cannot reach
anyone who already has it. That is what makes this branch precede vault
search rather than follow it (ROADMAP §7, trust before reach).

Three official Tauri plugins, no new invoke commands: updater, process,
window-state. They ship their own JS API, wrapped in ipc.ts so nothing
else in the app reaches past that seam.

Toril notifies and never installs on its own. The plugin can
download-and-replace silently; we deliberately never call it that way,
because this is an editor holding unsaved buffers. The restart is the
one path in the feature that could destroy one, so it is the one path
that refuses: it will not relaunch over a dirty tab, and says the update
applies next launch instead — the install is already on disk, so waiting
is free.

The policy is split from the network call and the toast so it can be
gated without a release server. What tests/update.test.ts pins is the
asymmetry that makes an updater tolerable: a startup check is
rate-limited, skippable and silent, while a direct question via Help →
Check for Updates always gets an answer. Two rules there are not
obvious. A lastCheckedAt in the future fails *open* — a corrected clock
would otherwise disable update checks forever with no symptom anyone
could notice. And skipping a version means "stop telling me", not "never
let me have it", so a later version still surfaces and a manual check
still shows the skipped one.

The notice is a fixed-position toast rather than another row in the
layout, which is the opposite of conflictbar.ts on purpose: a conflict
is about the document in front of you and must be impossible to miss; an
update is ambient news and is never worth reflowing a sentence for. It
also keeps this branch out of body's grid, which declares rows and no
columns (§12b rule 2).

Two unrelated signatures, kept apart in docs/RELEASE-SIGNING.md. Minisign
signs the update artifacts and is required — the bundler refuses to emit
an unsigned update, so release.yml checks for the key up front and fails
in five seconds with an explanation rather than twenty minutes into a
Rust build. That preflight is a hard stop rather than a "skip the updater
and carry on": silently cutting another release with no update path is
exactly how v1.0.0 stranded itself. Authenticode is optional, needs an
Azure account that takes days to provision, and lives in an overlay
config applied in CI only when the secrets exist — a fork, or a local
pnpm tauri build, must not need an Azure subscription to produce a
working installer.

Still keyless: generating the minisign keypair, pasting the public half
into plugins.updater.pubkey and storing the private half is the owner's
step, not something to do on their behalf. Until then a check degrades
to a reported error rather than a crash.

Everything downstream of the network is unverifiable here and is listed
as §D of docs/ON-DEVICE-VERIFICATION.md rather than assumed fine —
including the toast's measured layout (§12b), which could not be swept
in the browser harness this session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The second half of release-readiness. Five small features, but three of
them are decisions about untrusted input rather than conveniences, so
each has a gate.

Editor zoom scales the writing surface and deliberately not the chrome —
the OS already scales the whole UI, and a bigger tab bar is not what a
tired writer wants. That means editor.css heading sizes and the measure
move from rem to em, so they follow the surface rather than the root.
The ladder is fixed rather than "multiply by 1.1": free scaling
accumulates float drift, so five steps in and five out would not land
back on 100%, and "reset" would quietly stop meaning "the size I had".
A persisted 0 snaps to the default rather than clamping to the nearest
end, because it would otherwise render the editor unreadable *and*
unfixable — every step from zero is still zero.

Opening links is a §3.3 boundary, not a convenience filter, and
src/links.ts is where that shows. sanitize.ts already stops a hostile
href executing in the webview; this stops it executing outside, which is
strictly worse because the shell is not sandboxed. So it is an allowlist
of three schemes parsed through URL, not a blocklist matched against raw
text — the OS folds case and strips control characters before it acts,
so jAvAsCrIpT: and java\tscript: are the same link to it and a different
string to us. A blocklist would also have to have heard of ms-msdt: and
search-ms: in advance; an allowlist refuses them without knowing they
exist.

Drag-and-drop gets its own allowlist for the same reason: it is the one
open path with no file-type filter in front of it, so anything on the
desktop can land on the window. formatForPath is not a substitute — it
answers "how do I parse this", and answering "markdown" is right for an
unknown text file and wrong for a dropped binary.

Recent files rebuild the native menu wholesale, since muda submenus are
built rather than mutated. Items carry an index, never a path: a path is
arbitrary user data, menu ids are matched as strings on the frontend,
and encoding one into the other makes the mapping depend on data neither
side controls.

The first-run note and the empty state used to be the same two-line
stub, which meant an existing user got the tour every time they closed
their last tab. They are separate now, and firstRun comes from the
settings file having never been written rather than from "nothing was
restored".

The welcome note is a round-trip fixture, because it claims in its own
text that Toril does not rewrite your files — if saving it produced a
diff, the first thing a new user does would contradict the paragraph
they just read. That gate earned itself immediately: it caught unpadded
table pipes, and `**Ctrl+\**`, where the backslash escapes its own
closing marker and mangles the bold. Neither was visible by reading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Toril could open and save a note but not organize one: renaming meant
going to the file manager and coming back. This adds new note, new
folder, rename and delete to the files pane, and in doing so gives two
already-built, already-tested pieces of Rust their first caller —
trashbin shipped in v1.0.0 with no UI at all, and snapshots::rekey was
written for exactly this rename.

The rules go in crates/fileops rather than the command layer, because
they are not obvious and they are mostly Windows: reserved device names
that appear to work and then behave like hardware, names ending in a dot
or space that Windows silently strips (so the file that appears is not
the one we returned a path for), and a case-only rename that a
case-insensitive filesystem reports as a collision with the file itself.
Every operation refuses to clobber and requires its target inside the
open folder, mirroring trashbin's boundary for the delete direction.

Containment is checked with canonicalize and never used to build a
result. On Windows that returns a \\?\C:\... path, which does I/O fine
and matches nothing else in the app — not the sidebar tree, not an open
tab, not a note's history key — so it would quietly split one note into
two identities. Pinned by a test.

Delete offers Undo instead of asking first: the file moves into .trash/,
so the act is reversible and a confirmation would be friction in front
of it. The one thing trash cannot bring back is an unsaved buffer, and
that is the one case that stops and asks.

The section 3 surface here is rename. The watcher reports it as delete
then create, which is the shape removedOnDisk exists to catch — left
alone, an open tab decides its file vanished and offers to recreate it,
resurrecting the old note beside its new name. doRenameEntry re-points
every affected tab (including tabs under a renamed folder), bumps the
removal epoch for each old path so an in-flight reconcile cannot apply a
stale "missing" verdict, and clears removedOnDisk — all in one
synchronous block. base is deliberately untouched: a rename changes no
bytes, so the merge base is still exactly right.

The context menu is DOM, not native: a native popup cannot be driven by
the headless gates, and native dialogs are the one thing documented to
hang the app on the Linux dev box.

vaultscan now keeps an empty directory. It pruned any folder without
markdown in it, which meant New Folder created something that
immediately disappeared and could not be put a note into. Asset-only
folders stay pruned.

Gates: cargo test -p fileops (28), tests/sidebar.test.ts,
tests/contextmenu.test.ts. Two real defects were caught writing them — a
window blur listener registered with capture, which sees every element's
blur and so closed the menu the instant it focused its own first item;
and the canonicalized-path leak above.

Not verified on a device: the browser harness could not be driven in
this session (the Chrome extension was not connected). Checklist in
docs/ON-DEVICE-VERIFICATION.md section E.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
evangress and others added 2 commits August 18, 2026 06:49
Branch 12b (the chrome rework) shipped on main on 2026-08-12 as PR #36 —
panes.ts, rail.ts, resizer.ts and the tokens/chrome/editor stylesheet split
are all there — but its checkbox was never ticked, and the status block
listed it under "landed outside the movement ladder" when it is a numbered
branch in the ladder. Two records of the same thing, both wrong in
different directions.

The v0.2.0-alpha release point under branch 5 was overtaken by the v1.0.0
tag. The prose above it already says so; the unticked checkbox did not,
which left the document implying a version still to be cut. Marked as
overtaken and kept for what it meant, rather than deleted — the release
point is the record of when the data-safety floor became handable to a
stranger, and that is worth keeping even though the number is gone.

Branch 12 now names its PR. It was ticked as shipped while nothing was on
main and no branch was even pushed, which is the failure mode this
document exists to prevent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
feat(sidebar): new, rename and delete, without leaving the app
@evangress
evangress merged commit 095009a into main Aug 21, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant