Repository navigation
Background 03 core architecture
Research note dated 2026-10-09. Read for reasoning, not current fact; later decisions in
docs/decisions.mdanddocs/architecture-decisions.mdwin.This note assumes Tauri and a Svelte chrome, which D3 and D4 replaced; its crate layout was the starting point the scaffold adjusted. The plugin recommendation (a Rust trait, statically registered) was superseded on 2026-10-15 by the WebAssembly plugin decision; see Plugin system. Notes 07 and 08 correct parts of the earlier notes, and note 03 assumes Tauri.
Moved from
docs/research/03-core-architecture.md(original at af3de53) on 2026-10-16.
Date: 2026-10-09
Questions:
- Plugin architecture. Compare a Rust trait with static registration, dynamic libraries, WebAssembly and out-of-process plugins; say what Zed, Lapce and others do and why; recommend a design where built-in plugins use the API third-party ones will use, without a sandbox in v1; say how plugin UI (tab views, inline palette forms) reaches the chrome.
- Rust and Svelte boundary. Tauri commands, events and channels; typed TypeScript bindings and their maturity; keeping the UI thin; IPC limits for streaming search results and terminal output.
- Shared search index. SQLite FTS5 against tantivy, fuzzy matchers for the palette, and one recommendation.
- Storage and migrations. SQLite access layer, migrations, data location per OS, backup and corruption recovery.
- Testing. Core and index in plain Rust, Tauri commands, end-to-end tests per OS, and what a solo developer can sustain.
- Workspace layout. A Cargo workspace that separates shell, core and plugins, and where the Svelte app fits.
Two scope notes from the owner, received during the work: give a short answer for a chrome drawn by a Rust UI toolkit instead of Svelte (questions 1, 2 and 6; the toolkit and engine choice itself is in 06-all-rust-stack.md), and wherever a choice affects speed or memory, give numbers and pick the fastest sensible option. Svelte stays the primary case.
Terms. An ABI (application binary interface) is how compiled code lays out data and calls functions; two binaries can only call each other directly if they agree on it. WebAssembly (WASM) is a portable bytecode run inside a sandboxed runtime such as wasmtime; the component model lets a host and a WASM module exchange rich types described in WIT, an interface language. IPC is inter-process communication; here it means the bridge between Rust and the web view that draws the chrome. FTS5 is SQLite's full-text search engine; BM25 is the standard relevance score both FTS5 and tantivy use. WAL is SQLite's write-ahead-log mode. Frecency is a score mixing how often and how recently something was used. WebDriver is the W3C protocol test tools use to drive a browser; CDP is Chrome's DevTools protocol, which WebView2 also speaks.
-
Plugins are Rust crates compiled into Syrinx, each implementing one
Plugintrait from a smallsyrinx-plugin-apicrate, and registered in an explicit list. Everything that crosses the trait is an owned, serialisable value, so the same trait can later be served by a WebAssembly component for third parties (Zed's model) without touching the built-ins, which keep running natively with no sandbox cost. Dynamic libraries are ruled out (Rust's ABI has no stability guarantee andabi_stablecannot unload), and out-of-process plugins buy isolation Syrinx does not need yet. - Plugin UI reaches the chrome three ways: declarative specs (forms, lists) that the chrome renders, which is how Raycast does it and how the palette's "New lead" form should work; Svelte components shipped inside the chrome bundle for the rich built-in views (Forge, Notes), lazily loaded; and later, for third parties only, a sandboxed web view per plugin on a custom protocol. The declarative specs survive a switch to a Rust UI toolkit unchanged.
- The boundary is a few generic async Tauri commands plus one channel per stream. Keep every palette message under 8 KiB of JSON, because Tauri delivers channel messages below 8,192 bytes by direct script evaluation, measured about 2x faster than its fetch path on WebView2; send bytes as raw ArrayBuffers; never use events on hot paths. On Windows a 10 MB binary transfer was reported at about 200 ms against 5 ms on macOS, so Syrinx sends results, not data, and coalesces terminal output per frame.
-
Typed bindings:
tauri-specta, pinned exactly. It is still a release candidate (2.0.0-rc.25, May 2026) after two years. GitButler, the closest comparable app (Tauri, Svelte, Rust, rusqlite), generates TypeScript from JSON schemas instead, which is the fallback. -
Search has two tiers. Per keystroke, an in-memory catalogue of everything with a title (tabs, commands, bookmarks, recent history, note titles, plugin records) is ranked by
frizbee, a typo-tolerant matcher benchmarked at 1.78 ms per 100,000 items (about 4 to 5 timesnucleo), blended with frecency the way Zed blends usage with fuzzy score. Full text (note bodies, plugin content, old history) lives in tantivy and arrives as a second message. Phase 2 needs only the first tier. - Tantivy beats FTS5 for the full-text tier on speed and typo tolerance: the only head-to-head benchmark found shows a 0.74 ms median against 42 ms on 57,000 documents, and tantivy has Levenshtein fuzzy queries while FTS5 has none. FTS5 is simpler (one file, kept in sync by triggers), so it stays the fallback behind a trait, and a one-day bake-off on Syrinx's own data comes before Phase 4.
-
Storage is rusqlite with
rusqlite_migration, WAL withsynchronous=NORMAL, one writer thread and a few readers, in the local (not roaming) app data folder, with a dailyVACUUM INTOsnapshot. Not sqlx (its SQLite driver runs a thread per connection anyway, and checked queries need a database at build time) and nottauri-plugin-sql(it runs SQL from the frontend). -
Testing: most tests are plain
cargo testagainst crates that do not depend on Tauri; a few command tests use Tauri's mock runtime; Svelte uses Vitest with Tauri's IPC mocks; end-to-end smoke tests run on Windows through WebdriverIO's Tauri service, whose embedded driver even covers macOS but is only five months old. -
Layout: a virtual Cargo workspace with flat
crates/(syrinx-plugin-api,syrinx-core,syrinx-store,syrinx-search, one crate per plugin,syrinx-shell,xtask), wheresyrinx-shellis the only crate that depends on Tauri, andui/is a plain Vite and Svelte 5 app. In the Rust-toolkit variant,syrinx-shellandui/becomesyrinx-app,syrinx-uiand oneplugin-*-uicrate per rich plugin; nothing else moves.
Each claim carries its source and a confidence (high, medium or low).
Static registration. A trait implemented by crates linked into the binary costs nothing at run time and gives plugins the whole of Rust. inventory 0.3.25 adds "typed distributed plugin registration": collect! declares a registry and submit! adds to it from any crate, taking effect together at startup (inventory; high). Tauri's own plugins work this way: a Cargo crate plus an optional NPM package, registered in code with .plugin(...), with commands namespaced plugin:<name>|<command> and blocked until a permission grants them (Tauri plugins; high).
Dynamic libraries. The Rust Reference says "The Rust ABI offers no stability guarantees" (external blocks; high), and the default struct layout makes "no other guarantees" beyond soundness (type layout; high), so a plugin built by another compiler version cannot safely share Rust types. A stable "crABI" is still an experimental tracking issue, open, with its last comment in July 2026 (rust#111423; high). abi_stable 0.11.3 works around this by checking type layouts at load time, with FFI-safe replacements for standard types and sabi_trait trait objects, but its plugin use case is "without support for unloading" (abi_stable; high). A crashing library takes Syrinx down with it.
WebAssembly. Zed's extensions are Rust compiled to wasm32-wasip2 and run inside the Zed process in wasmtime, with host and guest bindings generated from the same WIT files; Zed first considered embedding a JavaScript runtime (Deno's V8) and chose WASM once the component model matured. The build records wasm_api_version in the manifest and the host instantiates that API version (Zed decoded: extensions; high), and the repository keeps one WIT folder per API version, since_v0.0.1 to since_v0.8.0 (Zed WIT; high). Extensions can add languages, debuggers, themes, icon themes, snippets and MCP servers (Zed docs; high) but, as of that post, could not create panels or other UI (medium; the API grows). Lapce runs its WASI plugins in-process in lapce-proxy with wasmtime, talking LSP-style JSON-RPC over in-memory pipes, and grants HTTP with allowed_hosts set to "insecure:allow-all", so its sandbox is partial (lapce wasi.rs; high). Wasmtime's component API generates typed Rust traits from a WIT world with bindgen! and supports resources and async (wasmtime::component; high); instantiation fell from about 2 ms to 5 µs in wasmtime 1.0 (Bytecode Alliance, 2022; high), so a WASM host would not hurt startup. Extism wraps wasmtime 49 with WASI preview 1 and passes buffers of bytes in and out through memory it manages (Extism runtime, Extism memory; medium that it does not use the component model, inferred from its WASI feature).
Out of process. VS Code's extension host exists so extensions cannot hurt startup, slow the UI or alter it, and activation events load extensions only when needed (VS Code; high). Raycast runs extensions in a separate Node process, one worker thread each with memory limits, over JSON-RPC; its own React reconciler produces a JSON render tree, diffs it with JSON Patch and maps it to native AppKit views, and it rejected web views to avoid "a second-class mini-app" (Raycast; high). Nushell plugins are separate nu_plugin_* executables speaking JSON or MessagePack over stdio or a local socket, with a Hello handshake that refuses mismatched minor versions before 1.0 (Nushell protocol; high). Helix's in-process Scheme plugin system (Steel) has been a draft pull request since 31 October 2023 and was still unmerged in September 2026 (helix#8675; high).
Async in the trait. Since Rust 1.75 a trait can have async fn, but such a trait is not usable as dyn Trait; the 2026 project goal only aims to settle a design, and the workarounds are async-trait or dynosaur (Rust goal 2026; high). async-trait boxes one future per call, which is noise beside an IPC hop.
How plugin UI is drawn elsewhere. Zed's built-in features render tabs by implementing its workspace Item trait, which requires Focusable + EventEmitter + Render, so built-ins use a toolkit-level API extensions never get (Zed item.rs; high). Raycast forms are declarative: TextField, TextArea, Dropdown, Checkbox, DatePicker, TagPicker and FilePicker, submitted with Action.SubmitForm, which does not fire while a field has a validation error (Raycast Form; high). That is the palette's inline form almost exactly. For isolated plugin pages, Tauri serves custom URI schemes at http://<scheme>.localhost on Windows and <scheme>://localhost elsewhere, so origins differ per platform (Tauri Builder; high); capabilities can target individual web views by label, and remote content gets no API at all unless a capability lists its URLs, though on Linux Tauri cannot tell an iframe from its window (capabilities, capability reference; high). Child web views have layout and layering limits of their own (02a-tauri-wry-facts.md).
If the chrome is a Rust toolkit. GPUI views are entities that implement Render, and GPUI is "still pre-1.0" with frequent breaking changes (GPUI README; high). iced uses the Elm architecture: state, messages, update and view (iced book; high). Floem uses fine-grained signals and builds its view tree once (Floem; high). slint-interpreter 1.18.1 loads .slint markup at run time (slint-interpreter; high), a route to third-party declarative UI with no executable code. In every case the rich built-in views become crates implementing the toolkit's view trait, as in Zed, and the declarative specs are drawn by the chrome's own widgets; the plugin trait does not change.
Commands. Arguments arrive as a JSON object with camelCase keys; return values and errors must implement Serialize; async commands run on a separate task while non-async ones run on the main thread unless marked #[tauri::command(async)]; return values "are serialized to JSON", which "can slow down your application" for large data, so binary goes back as tauri::ipc::Response (an ArrayBuffer) and comes in as tauri::ipc::Request (calling Rust; high). Building web views from synchronous commands can also deadlock on Windows (02a-tauri-wry-facts.md).
Events and channels. "The event system is not designed for low latency or high throughput situations": payloads are JSON delivered by evaluating script, untyped, outside the capability system, and async listeners may see events out of order; channels "are designed to be fast and deliver ordered data" and carry Tauri's own download progress and child-process output (calling the frontend; high). In Tauri's source, a channel sends JSON under 8,192 bytes by direct evaluation ("roughly 2x faster through eval than through fetch on WebView2 v135") and raw bytes under 1,024 bytes the same way ("roughly 30% faster ... on macOS"); anything larger is queued per web view and fetched through an internal command (channel.rs; high). A Channel<Vec<u8>> serialises as a JSON array of numbers; only InvokeResponseBody::Raw arrives as an ArrayBuffer, and messages are reordered by index on the JavaScript side (Channel; high). Tauri 2.0 (2 October 2024) moved IPC onto custom protocols with raw payloads, after a design where every payload was JSON and the overhead showed past a few kilobytes (Tauri 2.0; high).
Numbers. A Tauri maintainer wrote in December 2024 that v2's IPC "still uses the fetch api", with "a bit of a delay", relayed a non-scientific 10 MB binary transfer of about 5 ms on macOS and 200 ms on Windows, and said WebView2's shared memory was "weirdly slow" (discussion #11915; medium). A user moved a 23 MB image in 8 s through default serialisation and about 500 ms with raw Request/Response (tauri#9322; medium). Tauri's CI benchmark on windows-latest, run 2026-10-09, starts a hello-world app to DOMContentLoaded in 0.44 s on average (benchmark data; medium, shared CI machines). V8 parses JSON 1.7 times as fast as the equivalent JavaScript literal for objects of 10 kB or more (V8 blog; high), and no primary source shows MessagePack beating JSON.parse inside a web view (low). No published benchmark of Tauri terminal throughput was found (searched; low).
Typed bindings. tauri-specta 2.0.0-rc.25 (8 May 2026) is the Tauri 2 line; its only stable release, 1.0.2, is for Tauri 1; it also types events (crates.io, README; high). specta itself is at 2.0.0-rc.25 (crates.io; high). TauRPC 0.8.2 (July 2026) builds typed commands, events and channels on specta but has about 2,300 recent downloads (crates.io, TauRPC; high). ts-rs 12.0.1 (January 2026) generates types only, with 7.4 million recent downloads (crates.io; high). GitButler uses none of these: its #[but_api] macro turns one plain Rust function into a Tauri command, a JSON handler for its server and N-API bindings, registers metadata, and but-ts generates TypeScript from JSON schemas (but-api-macros, but-ts; high).
State stays in Rust. Tauri's managed State<T> is wrapped in Arc for you, the standard Mutex is preferred, and a wrong state type fails at run time, not compile time (state management; high). On the Svelte side, $state wraps arrays and objects in deep proxies, while $state.raw skips that cost for large data that is only replaced (Svelte $state; high), which suits snapshots received from Rust.
If the chrome is a Rust toolkit, there is no serialisation boundary: the UI calls the core in-process, and the shape becomes "the core owns state, the UI sends actions, the core publishes changes", as iced messages, GPUI entities and Floem signals each do (sources above). GitButler shows one Rust API serving Tauri, a server and N-API at once (above; high), which is the argument for writing Syrinx's core API as plain Rust actions and events now, whatever draws them.
FTS5 (FTS5; high unless marked). A trailing * makes a prefix query, which "requires a range scan" unless the table declares prefix indexes such as prefix='2 3'. The trigram tokenizer, added in SQLite 3.34.0 on 2020-12-01 (release log; high), matches any substring and indexes LIKE and GLOB, but substrings under three characters match nothing. bm25() takes per-column weights, and ORDER BY rank is faster than ORDER BY bm25(), particularly with LIMIT. highlight() and snippet() mark matched terms. External-content tables are kept in sync by triggers and repaired with 'rebuild'. detail=column and detail=none shrink the index (the page's example: 743, 340 and 134 MiB) at the cost of phrase and column queries. Column filters ({title body} : term) and UNINDEXED columns cover scopes. There is no typo tolerance: spellfix1 is "not included in the SQLite amalgamation" and is documented with FTS4 (spellfix1; high). rusqlite's bundled SQLite is built with SQLITE_ENABLE_FTS5 (libsqlite3-sys build.rs; high).
Tantivy 0.26.2 (September 2026) stores an index as segments; documents become searchable after a commit, which blocks and is durable (tantivy, IndexWriter; high). One writer holds a lock file per index, needs at least 15 MB per indexing thread ("the baseline consumption is 12MB") and uses at most 8 threads (index_writer.rs; high). FuzzyTermQuery matches within a Levenshtein distance, with a prefix variant, and the query parser can apply it per field (FuzzyTermQuery, QueryParser; high). Its README claims a startup under 10 ms, a memory-mapped directory, faceted search and about 2x Lucene's speed on its own benchmark (tantivy README; high that it claims this, medium for the numbers).
Head to head. One author's benchmark, run on an Apple M2 on 7 October 2026, measured FTS5 at a 4.90 ms median and 12.69 ms 95th percentile on 5,183 documents against tantivy's 0.23 and 0.38 ms, and 42.10 and 85.87 ms on 57,638 documents against 0.74 and 1.13 ms, with similar ranking quality (search-without-elasticsearch; low to medium: not a primary source, harness not inspected, and its long any-word queries are FTS5's worst case; short prefix queries were not measured). Turso replaced FTS5 with a tantivy-based index in January 2026 for transactional updates and query-planner integration, without publishing numbers (Turso; medium).
Fuzzy matchers. nucleo uses fzf's scoring with a Smith-Waterman alignment, handles graphemes, and claims about six times skim's speed (for example 2.30 ms against 17.44 ms on one benchmark it calls work in progress); nucleo-matcher is "finished", last released as 0.3.1 in February 2024 (nucleo, crates.io; high). Zed's command palette depends on fuzzy_nucleo, sorts commands by last use and then count, and keeps used commands above the fuzzy order (command_palette.rs, Cargo.toml; high). frizbee is a SIMD Smith-Waterman matcher with a max_typos setting, match indices for highlighting and parallel matching, at 0.13.0 (August 2026) after monthly minor releases, used by blink.cmp, atuin, television, skim and fff (frizbee, docs.rs, crates.io; high). Its benchmark on a Ryzen 9950X3D: 100,000 items of median length 128, partial match, nucleo 8.89 ms against frizbee 1.78 ms (2.66 ms allowing one typo); 1.4 million Chromium paths, 90.53 ms against 22.36 ms (60.76 ms with one typo) (BENCHMARKS.md; medium, the author's own). fuzzy-matcher was last released in October 2020 (crates.io; high).
Access layer. rusqlite 0.40.2's Connection is Send but not Sync, offers the online backup API behind the backup feature, and caches prepared statements with prepare_cached (rusqlite; high). GitButler's database crate uses rusqlite, not sqlx or Diesel (but-db; high). sqlx 0.9.0 bundles SQLite and allows one libsqlite3-sys in the dependency tree (sqlx sqlite; high); each SQLite connection "has a dedicated thread" fed through a channel (worker.rs; high); and query! needs DATABASE_URL at build time or a committed .sqlx directory (query!; high). tauri-plugin-sql is sqlx exposed to the frontend, which runs SQL with db.execute() (Tauri SQL; high).
Migrations. rusqlite_migration 2.6.0 records progress in SQLite's user_version header field, applies pending migrations atomically with to_latest(), offers validate() for tests and can load *.sql files from a directory (rusqlite_migration; high).
Where data lives. Tauri's app_data_dir is the roaming AppData folder on Windows; app_local_data_dir is the local one and "also the default data directory of the webviews" on Windows and Linux; app_cache_dir is local AppData on Windows and ~/Library/Caches on macOS; all can be overridden (PathResolver; high).
Speed and safety settings. In WAL mode readers and writers do not block each other, but WAL "does not work over a network filesystem" and the -wal file must stay with the database (WAL; high). synchronous=NORMAL is "the best balance between performance and safety" in WAL mode: no corruption, but the last transactions may roll back after a power cut; long-lived connections should run PRAGMA optimize=0x10002 at open and PRAGMA optimize periodically (pragma; high). SQLite reads and writes 10 KB blobs about 35% faster than separate files (faster than the filesystem; medium, by its own caveats).
Corruption and recovery. The usual causes are copying a live file, separating it from its journal, network file systems, two copies of SQLite linked into one app, and disabled syncing (how to corrupt; high). VACUUM INTO writes a consistent, minimal snapshot to a new file; the backup API uses less CPU and can run incrementally (VACUUM; high). quick_check runs in O(N), integrity_check in O(N log N) (pragma; high). The recovery extension salvages corrupt files but ships outside the amalgamation and needs SQLITE_ENABLE_DBPAGE_VTAB (recovery; high).
Tauri's test feature (an unstable module) provides mock_builder, mock_context, noop_assets and get_ipc_response to run a command through the IPC layer without a window (tauri::test; high). In the frontend, mockIPC intercepts invoke, event mocking arrived in 2.7.0, and emitTo is not mocked yet (mocking; high). Driving tauri-driver directly works only on Windows and Linux because "macOS has no WKWebView driver tool"; WebdriverIO's Tauri service covers all three through an embedded WebDriver server compiled into the app (tauri-plugin-wdio-webdriver) or CrabNebula's fork, which needs a paid key on macOS (Tauri WebDriver; high). Those plugins are test-only and belong behind debug_assertions or a feature, since the execute API runs script through window.eval() (WebdriverIO plugin setup; high). The embedded driver was first published on 1 May 2026 and reached 1.5.0 on 8 October 2026 (crates.io; high), and how it handles several child web views in one window is not documented (low). On Windows, Playwright can attach to WebView2 over CDP by setting WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=<port>, with a separate user-data folder per parallel test (Playwright WebView2; high); whether each Syrinx child web view shows up as its own target is untested (low).
A virtual manifest (a root Cargo.toml with [workspace] and no package) needs an explicit resolver, shares one Cargo.lock and target, and lets members inherit [workspace.dependencies], [workspace.package] and [workspace.lints] (Cargo workspaces; high). rust-analyzer's maintainer recommends a flat crates/ folder, folder names equal to crate names, version = "0.0.0" for internal crates and automation in an xtask crate (Large Rust workspaces; high, an experienced opinion). Tauri's src-tauri is "a normal Cargo project" with tauri.conf.json, capabilities/ and build.rs, can be a member of a larger workspace, and keeps code in lib.rs with a thin main.rs (project structure; high); SvelteKit needs adapter-static, SPA mode and ssr = false because "Tauri doesn't support server-based solutions" (Tauri SvelteKit; high). Comparables: Helix splits helix-core, helix-view, helix-term, helix-lsp and helix-event, but admits helix-view, meant to be frontend-agnostic, is "tied to the terminal UI" (Helix architecture; high); Lapce has lapce-app, lapce-core, lapce-proxy and lapce-rpc (Lapce; high); GitButler keeps 59 flat crates under crates/, with gitbutler-tauri the shell over but-api, and its Svelte app under apps/ (GitButler crates, gitbutler-tauri; high).
// crates/syrinx-plugin-api: the only Syrinx crate a plugin may depend on. No tauri, no syrinx-core.
pub const API_VERSION: u32 = 1;
pub struct Manifest {
pub id: PluginId, pub name: String, pub version: Version, pub api_version: u32,
pub tab_kinds: Vec<TabKind>, // e.g. "forge.project", each with a ViewSpec
pub commands: Vec<CommandSpec>, // id, title, keywords, scope chip, whether it shows a form
}
pub enum ViewSpec {
Component(String), // a view shipped in Syrinx's chrome bundle (Svelte now, a Rust view later)
Form(FormSpec), // declarative fields, validation, submit and "create and open"
List(ListSpec), // declarative rows with actions
} // later, third parties only: Web { entry: "syrinx-plugin://<id>/index.html" }
pub enum Outcome { Done { toast: Option<String> }, Form(FormSpec), OpenTab(TabRef) }
#[async_trait::async_trait]
pub trait Plugin: Send + Sync + 'static {
fn manifest(&self) -> Manifest;
async fn activate(&self, host: Host) -> Result<(), PluginError>; // lazily, on first use
async fn run(&self, command: &str, input: Value) -> Result<Outcome, PluginError>;
async fn open(&self, kind: &str, state: Value) -> Result<TabInit, PluginError>;
async fn call(&self, tab: TabId, method: &str, args: Value) -> Result<Value, PluginError>;
fn index_feed(&self) -> Option<&dyn IndexFeed> { None }
}
pub trait IndexFeed: Send + Sync { // pull-based; the host keeps the cursor in SQLite
fn changes_since(&self, cursor: Option<Cursor>) -> Result<ChangeBatch, PluginError>;
}
#[derive(Clone)] pub struct Host { /* own storage namespace, settings, a channel to its views, logger */ }syrinx-shell registers built-ins in a plain list (vec![Arc::new(Notes::default()), Arc::new(Forge::default()), Arc::new(Fledge::new(fledge_client::Stub::default()))]), which is clearer and easier to test than inventory. Three rules make "built-ins use the third-party API" real: a plugin crate depends only on syrinx-plugin-api; only owned, serialisable values cross the trait (no references into core state), so a later WasmPlugin adapter can implement Plugin by calling a wasmtime component whose WIT world mirrors the trait; and the plugin reaches Syrinx only through Host. Plugin calls run on async tasks, never the main thread, behind a timeout and catch_unwind, so a buggy built-in fails one command rather than the app.
UI. A command returning Outcome::Form makes the palette preview render a generic <PluginForm> from the FormSpec; Enter calls palette_submit, which calls run with the values, and Ctrl Enter submits and opens the tab. ViewSpec::Component("forge.editor") names an entry in ui/src/lib/plugins/registry.ts that maps to a dynamic import(), so Forge's editor code loads only when a Forge tab opens; the component talks to its plugin through plugin_call and its own channel. Third-party pages, when they come, get their own child web view on an syrinx-plugin:// scheme with a capability scoped to that web view's label.
All commands are async: tabs_*, palette_query(seq, text, scope), palette_run, palette_submit, plugin_call, settings_*. Rust pushes state on a single model channel (tab-list diffs, badges, plugin open state), stored in Svelte with $state.raw; each palette session gets a results channel. Each results message stays under 8 KiB (about 40 to 60 rows with highlight ranges) so it takes Tauri's eval path; previews are fetched only for the selected row; seq lets Rust drop stale queries and Svelte ignore late answers. Terminal output is coalesced into one raw ArrayBuffer per frame (about 16 ms) with InvokeResponseBody::Raw, never Channel<Vec<u8>>; Windows's reported 50 MB/s covers normal output, and a large cat needs flow control, measured in Phase 5. Events are only for rare global notices. Bindings come from tauri-specta = "=2.0.0-rc.25", generated by cargo xtask bindings into ui/src/lib/bindings.ts, committed, and checked by a test that regenerates and diffs. The core's API is plain Rust actions and events, so a Rust UI would call it directly.
-
Tier 1, the catalogue, in memory: one row per titled thing (id, kind, title, URL or path, plugin, frecency, open flag) for open tabs, commands, bookmarks, the most recent history (around 20,000 rows), note titles and plugin records. Each keystroke runs
frizbee(one typo allowed from four characters), keeps the top 50 by fuzzy score plus frecency plus an open-tab boost, and counts the scope chips in the same pass. Budget: under 5 ms at 100,000 rows. This tier is all Phase 2 needs. -
Tier 2, full text, in tantivy under
app_cache_dir()/index/: note bodies, plugin content and older history, withkindandpluginas exact-match fields for scope chips. It runs only for queries of three or more characters, after tier 1 has been sent, with a prefix on the last term and edit distance 1 on terms of five or more characters. One writer thread at a 32 MB budget commits in batches (every two seconds or 500 documents) and is dropped when idle; the reader reloads on commit. The index is derived from SQLite and the plugins' feeds, with cursors in SQLite, and is rebuilt on a schema change or any error. -
Check before Phase 4: a one-day bake-off of tantivy against FTS5 (with
prefix='2 3'andORDER BY rank LIMIT 20) on the owner's notes, with p95 budgets of 10 ms for tier 2. If FTS5 meets it, its single-file simplicity wins;syrinx-searchhides either behind aFullTexttrait.
rusqlite with the bundled and backup features is the only SQLite in the binary. One writer connection on a dedicated thread takes commands over a channel; two read connections serve queries. At open: journal_mode=WAL, synchronous=NORMAL, busy_timeout=5000, optimize=0x10002, then rusqlite_migration to_latest(); hot statements use prepare_cached. Files: app_local_data_dir()/data/syrinx.db (local, not roaming, and in its own folder because the web views share that root), the index in the cache folder, and data/backups/ holding a daily VACUUM INTO snapshot taken when idle, seven kept. A clean-shutdown flag triggers a background quick_check after a crash; a failure renames the bad file, restores the newest snapshot and tells the owner.
syrinx-core, syrinx-store (a temporary file per test) and syrinx-search carry most tests in plain Rust: the tab model, migrations through validate(), ranking orders as insta snapshots, and a fake plugin driven through the trait. syrinx-shell has one tauri::test test per command family; ui/ uses Vitest with mockIPC. Three to five end-to-end smoke flows run on Windows only, through WebdriverIO's embedded driver behind an e2e feature, with Playwright over CDP as the fallback. cargo xtask bench runs criterion benchmarks of both tiers at 100,000 rows and 50,000 documents and fails when a budget is missed.
Syrinx-app/
Cargo.toml virtual manifest: members = ["crates/*"], resolver = "3", workspace deps and lints
crates/
syrinx-plugin-api/ traits and data types (serde, specta derives); depends on no Syrinx crate
syrinx-core/ tab model, session, palette, command registry, plugin host; no tauri
syrinx-store/ rusqlite, migrations, repositories for settings, tiles, pins, bookmarks, history
syrinx-search/ catalogue (frizbee plus frecency) and full-text index (tantivy)
plugin-notes/ plugin-forge/ plugin-fledge/ each depends on syrinx-plugin-api only
fledge-client/ the Fledge API trait, its stub and later the HTTP client
syrinx-shell/ the Tauri app: tauri.conf.json, capabilities/, commands, web views
xtask/ bindings, check-deps (no crate but syrinx-shell may depend on tauri), bench
ui/ Vite + Svelte 5 + TypeScript (plain Vite: the chrome needs no SvelteKit router)
Rust-toolkit variant. syrinx-shell and ui/ become syrinx-app (window, toolkit, engine), syrinx-ui (chrome widgets, which also draw FormSpec and ListSpec) and plugin-forge-ui and plugin-notes-ui (toolkit views that depend on their plugin crate and syrinx-ui). The plugin logic crates, the core, the store and the search crate do not change, which is what keeps the toolkit decision reversible; xtask check-deps should also forbid toolkit dependencies outside those three.
The WASM host until a plugin from someone else exists; dynamic libraries for good; out-of-process plugins except where a process already exists (language servers); third-party plugin web views; full text of visited pages; the SQLite recovery extension; end-to-end tests on macOS and Linux.
-
tauri-spectahas been a release candidate for two years; pin it, and keep the GitButler-style schema generator in mind. - Windows IPC was reported about 40 times slower than macOS on a 10 MB transfer; budget everything on Windows numbers, measured in Phase 0.
- In-process built-ins share Syrinx's fate:
catch_unwindstops panics, but not an infinite loop or a crash in C code (tree-sitter, libgit2). - Two stores (SQLite and tantivy) can disagree after a crash; idempotent upserts by id and replay from the cursor cover it.
-
frizbeeis pre-1.0 and changes monthly; pin it, withnucleo-matcheras a drop-in fallback for scoring without typos. - The FTS5-against-tantivy evidence is one third-party benchmark; the bake-off is the real decision.
- The end-to-end tooling is months old and untested with several child web views.
- Will plugins ever come from people other than you? If not, the WASM host may never be needed.
- Should Syrinx index the text of the web pages you visit? It makes history searchable by content but grows the index and keeps a private record.
- Should Fledge records be copied into the local index (instant, offline) or searched live through the API (always fresh, but a network round trip per keystroke)?
- Will the Syrinx data folder ever sit in OneDrive or another synced folder? WAL breaks there, so the database would need to stay outside it.
- Are two stores (SQLite plus tantivy) acceptable for speed and typo tolerance, or do you prefer FTS5's single file if the bake-off is close?
- How many days of database snapshots should Syrinx keep?
Rust and Cargo: external blocks · type layout · crABI #111423 · async fn in dyn traits, 2026 goal · Cargo workspaces · Large Rust workspaces · inventory · abi_stable
Tauri: calling Rust · calling the frontend · channel.rs · Channel · Tauri 2.0 · discussion #11915 · issue #9322 · benchmark data · state management · plugins · Builder (URI schemes) · capabilities · capability reference · PathResolver · SQL plugin · tauri::test · mocking · WebDriver · project structure · SvelteKit
Bindings: tauri-specta and crates.io · specta · TauRPC and crates.io · ts-rs
Comparable apps: Zed extensions post, extension docs, WIT versions, item.rs, command_palette.rs, command_palette Cargo.toml, GPUI README · Lapce repository, wasi.rs, Floem · Helix #8675, architecture, nucleo · GitButler crates, but-api-macros, but-ts, but-db, gitbutler-tauri · VS Code extension host · Raycast architecture, Form · Nushell plugin protocol
WebAssembly and UI toolkits: wasmtime::component · Wasmtime 1.0 performance · Extism runtime Cargo.toml, memory · iced architecture · slint-interpreter · Svelte $state · V8: cost of JavaScript 2019
Search: FTS5 · SQLite 3.34.0 · spellfix1 · libsqlite3-sys build.rs · tantivy docs, IndexWriter, index_writer.rs, FuzzyTermQuery, QueryParser, README · search-without-elasticsearch · Turso: beyond FTS5 · frizbee README, docs, benchmarks, crates.io · nucleo-matcher · fuzzy-matcher
Storage: rusqlite Connection · rusqlite_migration · sqlx sqlite, query!, worker.rs · SQLite WAL, pragma, how to corrupt, VACUUM, recovery, faster than the filesystem
Testing: WebdriverIO Tauri plugin setup · tauri-plugin-wdio-webdriver · Playwright WebView2
Using the code
Background research
- 01 Shell and engine
- 02 Browser v1 scope
- 02a Tauri and wry facts
- 02b Default browser, import, signing
- 03 Core architecture
- 04 Forge and Notes
- 05 Fledge API
- 06 Engine and UI performance
- 07 Google sign-in and DRM
- 08 GPUI and wry on Windows
- 09 Phase 0 verdict
Ideas
In the repository