feat: self-update, and the release-readiness QoL batch - #40
Merged
Merged
Conversation
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>
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.0shipping: the version ladder ties release-readiness tov0.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.mdnow records this rather than leaving the stale pointer.Self-update
Three official Tauri plugins, pinned (
updater2.10.1,process2.3.1,window-state2.4.1). No newinvokecommands — they ship their own JS API, wrapped inipc.tsso 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.tspins 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:lastCheckedAtin 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.The notice is a fixed-position toast rather than another row in the layout, which is the opposite of
conflictbar.tson 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.
editor.cssheading sizes and the measure fromremtoem. 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 persisted0snaps to the default rather than clamping, because it would otherwise render the editor unreadable and unfixable — every step from zero is still zero.src/links.ts) is a §3.3 boundary.sanitize.tsalready 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 throughURL, not a blocklist matched against raw text — the OS folds case and strips control characters before it acts, sojAvAsCrIpT:and a tab-separatedjava<TAB>script:are the same link to it and a different string to us. A blocklist would also need to have heard ofms-msdt:andsearch-ms:in advance.formatForPathis 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.firstRunnow 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.mdis new because these are easy to confuse.Minisign signs the update artifacts and is required.
createUpdaterArtifactsis on and the bundler refuses to emit an unsigned update, sorelease.ymlchecks 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 howv1.0.0stranded itself.Authenticode (Azure Trusted Signing) is what stops SmartScreen warning, is optional, and needs an account that takes business days to provision. Its
signCommandlives insrc-tauri/tauri.signing.conf.json, an overlay applied in CI only when theAZURE_*secrets exist — a fork, or a localpnpm tauri build, must not need an Azure subscription to produce a working installer.plugins.updater.pubkeyis empty, sorelease.ymlwill refuse to build anyv*tag until:pnpm tauri signer generate -w ~/.tauri/toril-updater.keysrc-tauri/tauri.conf.json→plugins.updater.pubkey(it must be committed — installed builds verify against it).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
update,zoom,links,recent, plus the drop allowlist inpathsand the welcome fixture inroundtrip.pnpm typecheck/pnpm buildclean;cargo fmt --check,cargo clippy --workspace --all-targets, andcargo test --workspaceclean (including 3 newmenu.rsunit 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:
dev-harness.html?updateserves a fixture (including a long unbroken URL) for exactly this sweep.bodyis still a grid declaring rows and no columns — the §12b rule-2 shape behind two past cross-engine overlap bugs.#workspaceand#mainwere converted to flex;bodywas 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