33 KiB
NotesCraft registry: separable add-ons, and an honest debloat plan
Status: draft for owner review (2026-10-21). Numbers: measurements.md. Tasks: tasks.md. Owner request: "separate some of the plugins and certain things from the current application, add them to the notescraft registry, to debloat the app so it doesn't feel heavy".
0. Verdict first
- The registry is the right mechanism for choice and growth, not for the weight the app has today. Measured on this tree: binary 38.4 MiB, RSS 131 MB idle (physical footprint 72 MB), 0.3 s CPU to idle. Everything that could move to a registry today (extra grammars, math font, Lottie icons, Lucide long tail, extra themes) is about 0.7 MiB, 2 % of the binary, and moves no startup or RSS number you can feel.
- The real, cheap debloat needs no registry and is about 10 MB (25 %): strip symbols on macOS and Windows (-5.4 MB measured), fonts (-0.8 MiB duplicate Inter, about -1 MiB unused egui default fonts, -0.26 MiB duplicate mono),
opt-level = "s"(-2.8 MB more, perf unmeasured). Fonts are 4.2 MiB, 11 % of the file, more than all the optional crates together. - Why it "feels heavy" is vault data, not features: a 6500-note vault peaks at 494 MB while indexing and settles at 220 to 380 MB. No add-on mechanism changes that; it needs its own spec (open question 9).
- The wasm plugin host is not wired into the app at all (
Session::load_pluginshas no non-test caller;wasmiis 0 bytes in the shipped binary). The registry cannot deliver plugins until that is wired, and wiring adds an estimated 0.8 to 1.2 MiB. Order of work matters: do the no-registry savings first, or the registry makes the binary bigger before it makes it smaller. - The registry still earns its place: it stops the binary growing (every new grammar, theme, template, small command lands outside it), gives users choice, and finishes a half-built feature (
grammar.install, an emptynotescraft-registryrepo,plugins/dice).
So the plan has three layers, in this order: P0 size fixes without a registry; P1 a small, signed, offline-first registry (one engine module, no new crate); P2 move the few things that fit, to prove the pipeline and bank the 35 ms and 0.7 MiB.
1. What exists today (reuse, do not rebuild)
- Registry repo
https://git.marcospaulo.dev.br/masi/notescraft-registry(local clone/Volumes/Storage/Projects/notescraft-registry),index.jsonwith four empty lists, README schema. Schema mismatch to fix: the README names the keyname, the engine (crates/engine/src/grammars/mod.rs::registry_entry) readsid. No entry was ever published, so no compatibility burden. - Grammar flow:
grammar.info|install|install_text|remove, gates incrates/syntax/src/{fetch,validate}.rs(https only, no credentials or IP literals, host allow-listREGISTRY_HOST, no redirects, 512 KiB cap, sha256, parse and sample), transportCurl(acurlsubprocess) behind aTransporttrait, state inAppSettings::grammars(max 64) and files in<settings dir>/grammars/. - Themes: user themes are
<settings dir>/themes/*.toml(prefs/themes.rs, maxMAX_USER_THEMES, ids may not reuse a built-in). - Plugins:
crates/plugins(wasmi host, ABI v1, manifest withpermissionsallow-list), folder<vault>/.notescraft/plugins/<id>/{manifest.json,plugin.wasm}, approvals kept in settings, sampleplugins/dice(4.8 KB). No view, panel, settings or event hooks (ABI v1); UI-contribution ABI is roadmap item 25. sha2is approved;ureq(rustls, which bringsring) is already linked into the engine;craft-atomicoffersatomic_write(path, bytes)andwrite_new(path, bytes).- Core plugin cards in Settings (Calendar, Templates, Diagrams, Whiteboard) only toggle features that are compiled in. They are organisational, not size.
- Name clash to avoid:
crates/engine/src/registryis the command registry. The new engine module isaddons/; command ids areregistry.*.
2. Classification of everything that could move
Legend: core always in the binary; lazy stays in the binary, initialised on first use; feature cargo feature (compile-out); pack downloadable assets; wasm plugin on ABI v1; UI-ABI blocked on roadmap item 25. "Effect" is measured unless marked est.
| Item | Class | Effect on size / startup / RSS | Honest note |
|---|---|---|---|
| egui, wgpu, winit, text shaping, image codecs, serde, regex, TLS | core | 9 MiB of .text |
cannot move |
ui-egui (4.2 MiB), engine (1.8 MiB), vault, markdown, index, editor, i18n, themes, search, backlinks, links, tags, properties, bases core, templates, tasks, lint, blocks |
core | the product | |
| Inter Regular/SemiBold/Medium, JetBrains Mono NoLigatures | core | ||
Duplicate Inter Regular + SemiBold in engine PDF export |
fix in place (share one const) |
-0.79 MiB | no registry |
| PDF export JetBrains Mono Regular | fix in place (use NoLigatures) | -0.26 MiB | no registry |
egui default_fonts (Hack, Ubuntu-Light, emoji-icon-font; keep NotoEmoji) |
fix in place (drop the eframe feature, embed NotoEmoji only) | about -0.95 MiB (est) | needs a snapshot check for tofu |
Symbols (__LINKEDIT) |
release profile strip = "symbols" |
-5.38 MB | losing symbol names in crash backtraces is the cost |
opt-level = "s" |
release profile | -2.8 MB more | measure index build and scroll first |
| syntect default pack (75 grammars, 0.37 MiB, built on first fence: 75 ms) | lazy (already) | trimming it needs a custom dump; skip | |
| 25 NotesCraft grammars (Twig, Blade, Svelte, Vue, Nix, Zig...) | pack grammar |
-69 KB, -35 ms first highlight, repeated at every set_installed |
best first mover: tiny, pure text, proves the pipeline |
| STIX Two Math font (math only) | pack font |
-0.36 MiB | math falls back to Inter/JetBrains glyphs and boxes until installed; needs the seed (section 11) |
| Lottie icon animations (65 JSON, 0.14 MiB) | pack pack |
-0.14 MiB | static icons already exist; animation is polish |
| Lucide long tail (278 catalogue vs the ~89 used) | pack | at most -0.07 MiB | not worth the complexity; skip |
en.toml (61 KB) |
core | future locales ship as pack (language packs): zero cost now, the one place the registry clearly pays |
|
| Extra built-in themes (Frappe, Macchiato, Solarized x2) | pack theme |
under -10 KB | worth it only as the first theme entries, for the browser to show |
| Community-style themes, template sets, snippets | pack / theme / snippet |
0 (they never were in the binary) | the real value of a registry |
| Extra Hebrew/Arabic system fallback read at launch (SFHebrew, SFArabic) | lazy | -0.8 MB RSS, 1 disk read | use the existing script-on-demand loader |
| CJK and other script fonts | none | already not bundled: fonts::install loads system fonts per script on demand. A Noto download pack is only for Linux without them; defer |
|
Wasm plugin host (wasmi) |
feature plugins, default on |
0 bytes today (not linked), -37 CPU-s of compile; +0.8 to 1.2 MiB (est) once wired | wire it first (task 12), then the feature exists for CLI/server builds |
| Dice roller, Citations (roadmap 10), word tools, small text commands | wasm (ABI v1: vault.read, vault.write, editor) | 4.8 KB each, 0 in the binary | the only real "plugins" that fit today |
| Importers (Notion zip, ENEX, Roam) | needs a host call for zip/file import, or stays in-tree | ABI v1 has no file import and no binary input: not wasm yet | |
| Calendar (170 KB + 2.9k UI lines + 2k engine lines), Whiteboard (57 KB + 5.9k UI), Diagrams, Canvas, Graph, Bases Map/Cards, Kanban, Slides, Charts, Mind map, Maps | UI-ABI (item 25) to become wasm; feature to compile out | all optional crates: 1.15 MiB .text; with their UI and engine halves 2.5 to 3.5 MiB (est), 7 to 9 % |
extracting them to wasm needs views, panels, settings, events. Cargo features alone save little for the desktop app and cost a large refactor; only do it for the CLI/server builds |
| Vim, assistant (ACP), knowledge (RAG), pdf export, git, collab | feature candidates | 0.1 to 0.4 MiB each, est | organisational for the desktop app; useful for slim CLI/MCP builds |
| Map tile providers | snippet presets | 0 | network policy already opt-in (bases D13); a preset list is data |
| Pandoc | external helper, detected on PATH |
0 | do not download executables in v1 (open question 5) |
| Video | external player via shell/app/video/launch.rs |
0 | already a helper, not bundled |
| OCR backend (attachment-ocr spec) | helper binary pack, later | needs its own trust model; out of scope here |
What actually reduces what:
- Binary size: P0 (about 10 MB), then fonts-as-pack and Lottie (0.5 MiB). Cargo-feature compile-outs are the only big code lever and are a refactor, not a registry job.
- Startup: the grammar rebuild (-35 ms of 110 ms on first fence) and lazy fallback fonts. Nothing else measured moves.
- RSS: nothing in this spec measurably does; the levers are index memory and egui/GPU surfaces.
- Only organisational: core plugin cards, moving crates between folders, the Lucide tail.
3. Goals and non-goals
Goals: a signed, static, offline-first catalogue of add-ons the user installs on purpose; one engine surface (commands, CLI, MCP, UI); zero loss for anyone upgrading; hostile content cannot write outside its own folder or run anything without consent.
Non-goals (v1): third-party publishing flow or accounts; a download counter; payments; automatic code execution; executable downloads (pandoc, ffmpeg); archive formats; plugin sandbox changes; UI-contribution ABI (item 25); a custom registry URL (open question 4).
D1. Hosting and index
- v1 host: the existing Gitea repo
masi/notescraft-registry, raw layoutraw/branch/main/. No server work, history is the audit log, the host allow-listREGISTRY_HOSTS = ["git.marcospaulo.dev.br"]already exists (becomes a list). Mirror later by adding a host to the allow-list andurlin entries. - Optional later: the sync server serves the same tree from a directory with one read-only route
GET /registry/{*path}(about 30 lines inapps/notescraft-sync-server/src/api/routes.rs, public like/p/{slug}, no auth, no vault data, file allow-list by extension, caches by ETag). Not needed for v1; listed because the owner asked whether it could host one: yes, but a git repo is simpler and already trusted. - Files:
index.jsonat the root; payload files underfiles/<kind>/<id>/<version>/<name>. - The app caches the last verified index at
<data dir>/registry/index-cache.jsonwithfetched_atandetag; a conditional GET (If-None-Match) keeps checks near-free. - Index cap 1 MiB, at most 2000 entries; a bigger or non-JSON index is refused and the cache stays.
D2. Index schema v2
{
"version": 2,
"generated_at": "2026-10-21T12:00:00Z",
"entries": [
{
"id": "twig",
"kind": "grammar",
"title": "Twig",
"summary": "Twig templates",
"version": "1.0.0",
"min_app_version": "0.1.0",
"license": "MIT",
"source": "https://github.com/...",
"aliases": ["twig", "html.twig"],
"suggested": false,
"permissions": [],
"files": [
{ "path": "twig.sublime-syntax", "size": 3912, "sha256": "<64 hex>", "url": "https://git.marcospaulo.dev.br/masi/notescraft-registry/raw/branch/main/files/grammar/twig/1.0.0/twig.sublime-syntax" }
],
"signature": { "key_id": "k1", "sig": "<base64 ed25519>" }
}
],
"revoked": [ { "id": "old-thing", "version": "0.9.0" } ]
}
Fields (all required unless "optional"): id [a-z0-9-]{1,48} unique per kind; kind one of plugin theme pack grammar font snippet; title 1..80; summary 0..200 (optional); version MAJOR.MINOR.PATCH, each 0..9999, no pre-release; min_app_version same shape; license SPDX-like 1..40; source https URL (optional, display only); aliases (grammar: fence labels; others: search words), at most 16, each [a-z0-9._+-]{1,32}; suggested bool (optional, first-run list); permissions (plugin only, must be a subset of the known Permission strings; unknown rejects the entry); files 1..64, each with path, size, sha256, url; signature. The README's file becomes files[].url relative form is not allowed: urls are absolute https on an allow-listed host.
Derived, never in the file: total size = sum of files[].size.
Caps per kind (sum of file sizes): plugin 4 MiB, theme 64 KiB, grammar 512 KiB (the existing cap), snippet 1 MiB, pack 8 MiB, font 32 MiB. One file at most half of the cap of 32 MiB for fonts, otherwise the whole cap. All checked against both the index size and the bytes actually received.
Validation lives in addons/index.rs; one broken entry is skipped with a diagnostic, the rest of the index loads (same rule as plugin folders). A wrong version at the top rejects the index.
D3. Trust and signatures
- Every entry carries an ed25519 signature by the owner-held key over a canonical text:
notescraft-registry-v1\n<id>\n<kind>\n<version>\nthen for each file sorted bypath:<path>\n<sha256>\n<size>\n. The signature therefore covers every hash and size; the index itself can stay unsigned (tampering with it can only add entries whose signature fails, which is a denial of service, not code execution). - The app embeds the public keys:
addons/keys.rsTRUSTED_KEYS: [(&str, [u8; 32]); N],key_idselects one, so a rotation is a release that addsk2and later dropsk1. The private key never enters this repo; it lives with the owner. Signing happens in the registry repo with a small tool (xtask registry-signover a directory, task 18). revokedlets a release be pulled; an installed revoked version is marked and the UI offers Remove or Rollback. The index is unsigned, so an attacker can hiderevoked; stated, accepted for v1, solved by signing the index (open question 2).- Dependency question for the owner: verification needs an ed25519 implementation. Options: (a) depend on
ringdirectly, already in the lockfile and linked throughrustls, adds roughly nothing to the binary; (b)ed25519-dalek; (c) no signatures, rely on HTTPS plus sha256 plus the host allow-list. Recommendation: (a), written approval needed (AGENTS.mdrule on new dependencies). Until answered, task 3 is blocked and installs must be refused for entries without a valid signature, so the feature cannot ship half-secure. - A sideloaded or seed file (section 11) is verified with the same signature check; there is no "trust me" path.
- Integrity beyond signatures: plugin manifests inside the downloaded files must match the index entry (
id,version,permissions, commands), or the install fails.
D4. Local layout
All add-on state lives under the app data dir, never in a vault (so a synced vault cannot ship code):
<data dir>/registry/ data dir = craft_settings::paths::data_dir("notescraft"), override NOTESCRAFT_CONFIG_DIR
state.json installed table, written with craft-atomic (version 1)
index-cache.json last verified index + fetched_at + etag
store/<kind>/<id>/<version>/ immutable once state points at it; files exactly as in the entry
staging/<random>/ downloads in progress; emptied at every start
state.json: { "version": 1, "installed": { "<kind>:<id>": { "version", "previous": "1.0.0"?, "source": "registry|legacy|seed|sideload", "installed_at", "sha256_of_files": "<hex>" } } }. At most 512 installed entries.
Per-vault file <vault>/.notescraft/registry.json (written with Vault::write_private): { "version": 1, "enabled": ["plugin:dice"], "wanted": ["grammar:twig"] }.
enabled: plugins on in this vault (plugins are the only kind that is per-vault; permissions approvals stay in settings as today).wanted: ids this vault has been seen to use (a Twig fence). A new machine opening the vault shows one banner "This vault uses 2 add-ons" with an Install button. Nothing is installed from it automatically.
Grammars, themes, fonts, packs and snippets are global (user level); enabling = installed. Rollback keeps one previous version dir per id.
D5. Network policy
No network unless one of three things happens, all user-initiated:
- the user opens Settings > Registry (one conditional GET of
index.json; not at app start); - the user presses Install, Update, Check or an install button on a card;
- the setting
registry.update_checksis on (default off): at most one conditional GET per 24 h, started after the window is idle, no identifiers,User-Agent: NotesCraft/<version>, no vault data, no cookie.
Everything else (search in the browser, first-run suggestions, "this vault uses X") reads the cached or seed index and works offline. A failed fetch leaves the cache and state untouched and shows "Offline" (the offline error code exists). Same gates as grammars on every URL: https only, host allow-list, no credentials, no IP literal, no redirects (syntax::validate::check_url). Transport: ureq (already linked, no subprocess); the Transport trait stays injectable so tests never touch the network.
D6. Install, update, remove, rollback
All entry operations are one function addons::install::run(plan) over an injected Transport:
- Resolve the entry from the cached or fresh index; refuse if
min_app_versionis newer than the app, ifrevoked, if the kind has no consumer yet, if a file list check fails (D7), or if the signature does not verify. - Check free disk space (twice the entry size) and the installed-table cap.
- Download each file to
staging/<random>/<path>through the transport: streaming, hard cap per file and per entry, sha256 computed on the bytes actually received (a lyingContent-Lengthdoes not help), abort on the first mismatch. - Per-kind content check on the staged bytes (D8). A failure deletes staging; nothing else changed.
- Write the final tree
store/<kind>/<id>/<version>/<path>withcraft_atomic::write_newper file (refuses to overwrite). The version directory is unreferenced until step 6, so a crash leaves garbage, not a half-install. Garbage (version dirs not in state, staging) is removed at the next start. - Flip
state.jsonwithcraft_atomic::atomic_write(the only commit point): setversion, keep the old one inprevious. - Tell consumers to reload (grammar set rebuild, theme list, plugin set).
update = install of a newer version of an installed id. Downgrade is never an update; only rollback.
remove deletes the state entry first (commit point), then the store dirs, then reloads consumers; blocks of a removed grammar fall back to plain monospace (existing behaviour), a theme in use falls back to the built-in of the same variant with a notice, a plugin is unloaded.
rollback swaps version and previous in state.json (the old dir is still there) and reloads.
D7. Hostile input (no archives by design)
Entries are file lists, not archives: no zip or tar anywhere, which removes zip-slip, symlink and decompression-bomb classes without a parser. What remains, all in addons/fileset.rs, pure and unit-tested:
path: relative,/-separated, 1..4 components, each[A-Za-z0-9._-]{1,64}, no component empty,.or.., none starting with., no\, no:, no NUL, no Windows reserved device name (CON,NUL,COM1...) as a component, no case-insensitive duplicates, extension on the allow-list of the kind (pluginmanifest.jsonandplugin.wasmand optionalREADME.md; theme.toml; grammar.sublime-syntax; font.ttf.otf; snippet.md.txt.toml; pack: each file undergrammars/,themes/,templates/oricons/with its own extension list). The joined path is built by the code from validated components, never by string concat of server text.- Sizes: per kind caps from D2, at most 64 files, no zero-size file, sum with
checked_add. - URLs:
check_urlplusurlhost in the allow-list, length cap, no query string. - Download: streamed with a hard cap; received bytes and sha256 must match the entry; a body shorter or longer is a failure; slow-loris bounded by a total time limit (30 s per file, 5 min per entry).
- Never execute: nothing from the store is run except
plugin.wasmthrough the existing sandbox (fuel, memory, deadline limits, 3-failure auto-disable) and only after the user approved its permissions. - Writes go to
<data dir>/registry/only; the reserved vault paths.obsidian/and.notescraft/rules are unchanged;.notescraft/registry.jsonis written only throughwrite_private. - JSON bounds: index and state parse with the existing
serde_jsondepth limit, strings capped, unknownversionisErr, nothing silently repaired.
D8. Kinds
| Kind | Installed as | Consumer | Per-kind check |
|---|---|---|---|
grammar |
store/grammar/<id>/<v>/<id>.sublime-syntax |
syntax::load::set_installed fed from state (legacy AppSettings::grammars also read) |
syntax::validate::check_grammar with the entry's sample |
theme |
store/theme/<id>/<v>/<id>.toml |
Session::user_themes scans state too |
Theme::from_toml; zero hard diagnostics; id equals entry id; no built-in id |
plugin |
store/plugin/<id>/<v>/{manifest.json,plugin.wasm} |
the plugin loader reads installed plugins listed in the vault's enabled; approvals as today |
Manifest::parse, manifest equals entry, a dry Plugin::load with default limits |
pack |
several files under grammars/ themes/ templates/ icons/ in one version dir |
each consumer reads the pack's subfolder | every file runs its own kind check |
font |
store/font/<id>/<v>/<file> |
the script-on-demand font loader may use it before system fonts; not required for v1 consumers | ttf-parser-free check: magic bytes 00 01 00 00, OTTO or true, size cap (no new dependency) |
snippet |
store/snippet/<id>/<v>/*.md |
templates list (insert from the template picker) | UTF-8, size cap, no NUL |
Kinds font and snippet are accepted by the index and the installer so the schema is complete, but a kind with no consumer yet is shown as "Not available in this version" and cannot be installed (D6 step 1). The first consumers to build are grammar, theme, plugin, pack.
D9. Engine commands (all in crates/engine/src/addons/commands.rs)
Group registered by register_addons(reg) called from commands.rs::register_core. Ids use the user-facing prefix registry..
| Id | Params | Does | Network | Gate class |
|---|---|---|---|---|
registry.list |
kind?, query?, state? (installed, updates, available), limit? |
cached or seed index joined with state.json; each row: id, kind, title, version, installed version, size, state (available, installed, update, revoked, incompatible, unsupported), permissions |
no | READ |
registry.info |
kind, id |
one entry in full: files, sizes, license, source, signature key id, what a plugin may do | no | READ |
registry.check |
none | conditional GET of the index, verify, replace the cache, return update counts | yes | DELETE-class (see below) |
registry.install |
kind, id, version? |
D6 | yes (or seed) | DELETE-class |
registry.update |
kind?, id? (none = all with updates) |
D6 per entry | yes | DELETE-class |
registry.remove |
kind, id |
D6 | no | DELETE-class |
registry.rollback |
kind, id |
D6 | no | DELETE-class |
registry.enable / registry.disable |
id (plugin) |
per-vault enabled list; enabling asks the existing permission approval |
no | WRITE |
registry.suggest |
label or kind+query |
alias lookup in the cached index; used by the grammar card | no | READ |
Assistant gate (crates/engine/src/ai/assistant_gate.rs): registry.list, registry.info, registry.suggest join READ_COMMANDS. registry.enable|disable join WRITE_COMMANDS. The five that fetch or remove third-party code (check, install, update, remove, rollback) join DELETE_COMMANDS, so an assistant needs both allow_write and allow_delete, the same treatment as sync.pair and server.connect (trust-expanding actions). Test: tests/core_commands.rs CORE_IDS and a gate test per id.
Errors (CommandError codes): registry_refused (a gate failed, message names it), offline, not_found, incompatible (app too old), revoked, limit, disk_full, needs_dependency (no signature verifier yet). Existing grammar.* commands stay as thin aliases that call the same code and keep their codes.
CLI parity: notescraft run registry.install --params '{"kind":"grammar","id":"twig"}' already works through the generic Run; task 9 adds notescraft registry list|info|check|install|update|remove|rollback as sugar in apps/notescraft-cli/src/args.rs and commands/, printing id, version, size, state. MCP gets them through the registry automatically.
D10. UI (Settings > Registry)
New Settings category in views/settings_view/categories.rs, view folder views/registry_view/ (library components only: Button, Input, Switch, Modal, ConfirmModal; i18n keys under [ui.registry]; theme tokens only).
- Top row: search Input, kind chips (All, Plugins, Themes, Packs, Grammars, Fonts, Snippets), "Check for updates" Button, "Last checked " or "Never", Switch "Check for updates automatically" (off).
- List rows: title, kind chip, version, size, short summary, state:
InstallButton,Installedcheck,Update to 1.2.0Button (accent),Needs NotesCraft 0.3,Revoked,Not available in this version. Update badge: a count dot on the Settings entry and on the category, only after a check. - Detail drawer: summary, license, source link, files and sizes, permissions as sentences ("Read notes and search", "Create and change notes", "Edit the open note") for plugins, signature key id, Remove and Rollback Buttons.
- Offline banner when the last fetch failed; the list then shows the cache.
- Plugin install ends with the existing permission approval modal, then an Enable Switch for the current vault.
- First-run suggestions: once, after the first vault opens following install or upgrade, a dismissible card "Add-ons you may want" listing the entries with
suggested: truefrom the seed index plus anythingregistry.suggestfound for this vault. It never fetches; its Install buttons do (D5 trigger 2). - Code card (
views/note_view/blocks/code_card.rs): the existing "Add grammar" link becomes "Install Twig grammar (3.9 KB)" whenregistry.suggestfinds the label, falling back to today's dialog (paste a link) when it does not. - Snapshot example flag
--registryfor visual checks; the screens follow the PaperCraft tokens (rounded cards on canvas, tone not borders).
D11. Migration: nobody loses anything
Rule: nothing leaves the binary in the same release that adds its registry entry, and every removal ships with a path that needs no thought.
- Release N adds the registry, adopts legacy grammars (
source: legacy, shown as installed, no update), keeps everything bundled. - Release N+1 removes an item from the binary only if all hold: its entry is published and signed; the seed copy (below) is in the installer; a migration test exists.
- Seed: the installer carries
registry-seed/{index.json, files/...}next to the binary (macOSContents/Resources/, Windows and Linux beside the exe), a verified signed snapshot of the moved items only (about 0.6 MiB).installprefers the seed file when its sha256 equals the entry, so moved items install offline, instantly. Seed items are not auto-installed. The DMG size does not change; the binary shrinks. - Auto-offer by use, no network:
- a fence label that is no longer bundled: the code card asks
registry.suggestand shows "Install Twig grammar", one click; withregistry.auto_install_for_vaulton (default off, open question 3) it installs from the seed without asking; - first launch of N+1: a background scan (ripgrep crates already in the engine, 3 s budget, once per vault and per registry generation) of fence labels and
$math in the opened vault recordswantedin.notescraft/registry.jsonand shows one banner "This vault uses Twig, Blade (2 add-ons, 7 KB): Install"; - a selected theme that moved: the app keeps the id, falls back to the built-in of the same variant until installed, and the Settings theme picker shows it with an Install button;
- math without the font: formulas fall back to the Inter/JetBrains glyph chain (already the last resort) and the math view shows an Install button.
- a fence label that is no longer bundled: the code card asks
- Tests (task 19 to 21 each): for every moved id,
registry.suggestfinds it, the seed verifies,registry.installfrom the seed in an offline temp session makesgrammar.inforeport installed, and removal returns the old fallback behaviour.
D12. First-party content, ordered by measured benefit
| # | Item | Size | Gain | Needs |
|---|---|---|---|---|
| P0.1 | strip = "symbols" in the release profile (macOS, Windows) |
- | -5.38 MB | nothing |
| P0.2 | share one Inter Regular/SemiBold const, PDF mono = NoLigatures |
- | -1.05 MiB | brief 29 |
| P0.3 | drop eframe default_fonts, embed NotoEmoji only |
- | about -0.95 MiB | snapshot check |
| P0.4 | opt-level = "s" |
- | -2.8 MB | perf measurement |
| P1.1 | wire load_plugins (+ ~1 MiB, est) |
enables plugins | task 12 | |
| P2.1 | pack grammars-extra (25 grammars as 25 grammar entries) |
69 KB | -69 KB, -35 ms first highlight | tasks 19 |
| P2.2 | font stix-math |
376 KB | -0.36 MiB | task 20 |
| P2.3 | pack icons-animated (Lottie) |
148 KB | -0.14 MiB | task 21 |
| P2.4 | plugin dice (exists), then citations |
4.8 KB | 0 binary, first real plugin | task 22 |
| P2.5 | themes Frappe, Macchiato, Solarized | under 10 KB | negligible, fills the browser | task 22 |
| later | language packs when a second locale exists | 60 KB each | keeps en-only core |
P2.x together: about 0.6 MiB, 1.5 % of the binary. Say it plainly to users: the registry makes the app extensible; the size win is P0.
D13. Tests and acceptance
- Pure: index parse and every rejected shape; fileset table of hostile paths (
../x,a/../../b,/abs,C:\x,a\b,.hidden,a//b, names with a space or NUL, Windows reserved namesCON,NUL,COM1as a whole component, duplicates differing only in case, a 65-char component, 5 components, an empty path); caps withu64::MAXsizes (checked add); signature canonical text; version compare; state round trip; unknown version. - IO (TempDir, fake
Transport): happy install of each kind; hash mismatch; short body; long body; lying size; transport error mid-file; crash simulation (stop before the state flip) leaves the old state and GC removes the new dir; update keepsprevious; rollback; remove while in use; seed install offline; index with a revoked version; app too old; invalid signature; unknown key id. - Gate and commands: all ids in
CORE_IDS; gate classes; read-only session refuses mutating commands. - UI (kittest): rows by state; Install sends exactly one command; offline banner; permission sentences; update badge appears only after a check.
- Size acceptance (measured, not promised): after P0.1 to P0.3 the macOS binary is at most 33 MB (from 40.2 MB); the registry module itself adds at most 250 KB (estimate for index, installer, validators, UI) and wiring plugins at most 1.2 MiB; both are re-measured with the commands in
measurements.mdand written into this file anddocs/ARCHITECTURE.md. cargo xtask ci,cargo xtask i18n,cargo xtask layers,cargo xtask treegreen; nounwrap,[i],[a..b]; every public item documented; files under 400 lines; at most 12 loose.rsper directory (addons/is grouped intoindex/,install/,commands/from the start).
Open questions for the owner
- Ed25519 dependency: approve
ringas a direct dependency ofnotescraft-engine(already in the lockfile throughrustls), ored25519-dalek, or choose no signatures? Recommendation:ring, plusxtask registry-signalso using it. Blocks task 3 and every install. - Sign the index too (one extra detached signature, stops hidden
revokedand replay of an old index)? Recommendation yes, 20 lines, after question 1. - Auto-install for a vault that uses a moved item: your words were "installed automatically if the vault uses it". This conflicts with "no network unless the user opts in". Proposal: seed files make it offline and instant, so auto-installing from the seed can be on by default (no network), while anything that needs the network asks. Confirm.
- Custom registry URL / third-party registries: not in v1 (host allow-list is fixed to yours). Allow later with a per-registry key?
- Executables (pandoc, ffmpeg, OCR engines): not downloaded in v1; they stay "detected on PATH". Confirm, or ask for a separate trust design.
- Ship seed files in the installer? Costs about 0.6 MiB of installer size, buys offline migration. Alternative: network only.
- Release profile: approve
strip = "symbols"(crash backtraces lose function names; line tables are alreadydebug = line-tables-onlyin dev only) and, after a perf run,opt-level = "s". - Drop the egui
default_fontsfeature and embed only NotoEmoji (about -0.95 MiB): accept if the snapshot comparison shows no tofu in the UI chrome. - Index memory: the 6500-note vault peak of 494 MB and 220 to 380 MB steady is the real "heavy". Want a separate measurement and spec for it?
- Naming: engine module
addons/and user-facing "Registry" /registry.*: fine?