How I Built a Local-First Reading App

I love reading books and annotating them. What I love more is being able to own those books and annotations digitally. What I don't love is having my library, highlights, and reading progress locked inside someone else's cloud. Every e-book reader I tried either required an account, synced through a proprietary server, or couldn't handle the formats I actually use EPUB, MOBI, PDF, CBZ/CBR comics, and even RSS feeds. When I discovered Readest, an excellent open-source e-book reader, I was impressed but disappointed with cluttered UI and complex settings.
So I built Theorem: a local-first reading app that does everything offline and syncs peer-to-peer, with no server, no accounts, and no data leaving your network. It reads EPUB, MOBI, AZW, AZW3, FB2, CBZ, CBR, and PDF.
Architecture: Tauri 2 + Rust + React 19
Theorem is built on Tauri 2, which gives a native Rust backend with a WebView frontend. The stack is:
- Backend: Rust (Tauri 2 commands, file system access, HTTP server, ONNX inference)
- Frontend: React 19 + TypeScript + Tailwind CSS v4, rendered via WebView
- State management: Zustand 5 with
persistmiddleware and versioned migrations - Persistence: SQLite via
rusqlite(desktop) / IndexedDB (web fallback) - P2P Sync: X25519 + ChaCha20-Poly1305 encryption over HTTP, QR-code pairing
- TTS: Kokoro-82M ONNX models running locally via
kokoro-en+ortcrates
The key decision was moving all data-heavy processing to Rust — file parsing, text extraction, encryption, and TTS inference while the React frontend handles layout, rendering, and interactivity.
Multi-Format Ebook Reader
Rather than reinventing the wheel, I built the reader on top of foliate-js — the rendering engine behind the popular Foliate GTK reader — vendored as a git submodule and wrapped in a custom runtime layer. This gives me tested EPUB/CBZ/MOBI/FB2 rendering with CSS pagination, reflowable and fixed-layout support, and EPUB CFI navigation.

The two rendering engines are:
- FoliateEngine : Handles EPUB, MOBI, AZW, AZW3, FB2, CBZ, CBR. Uses the foliate-paginator web component with CSS grid pagination in a closed shadow DOM. The paginator container dimensions are determined by grid track sizing not iframe content, so layout is stable and measurable on the first frame with zero settle delay.
- PDFJsEngine : Wraps
pdfjs-distv6 as a React component with multi-page canvas rendering, text layer, and annotation overlays. Supports zoom modes (custom, page-fit, width-fit) with mouse/pinch-center anchoring, and freehand pen drawing with coordinate-based text notes.
Format breakdown:
- EPUB: OPF manifest, spine, NCX navigation parsed through the foliate-js EPUB runtime. On desktop, a Rust pre-parser streams the container.xml, OPF, and nav documents with
quick-xml, then pre-decodes all HTML sections into a text cache — allowing the reader to skip zip.js entirely for text content, only lazy-loading it for embedded images. - MOBI/AZW/AZW3: Parsed via the foliate-js MOBI runtime (Palm Database format with PalmDoc and Mobipocket headers), with
fflatefor zlib decompression. - FB2: FictionBook XML format, parsed directly in the runtime.
- CBZ: Standard ZIP of page images. CBR is converted to CBZ at import time using Rust's
unrar-ng. - PDF: Rendered page-by-page via pdfjs-dist. Tauri commands handle file I/O natively, bypassing browser CORS restrictions.

## EPUB Pre-Parser (Rust Fast Path)
A detail I'm particularly proud of: when you open an EPUB on the desktop, Rust pre-decodes all XHTML content into a structured cache before React even mounts. The command:
- Streams
container.xml(handling UTF-8/UTF-16 BOMs), extracts the rootfile path with percent-encoded fallbacks - Parses the OPF for manifest/spine and the NCX/nav for table of contents, all using streaming
quick-xml— no regex - Pre-decodes every text section into a
textCache(capped at 100 sections), along with asizesmap of compressed sizes for every zip entry
This runs on spawn_blocking for true parallelism with the WebView boot. The makeZipLoader bridge in the foliate-js runtime checks for this cache — if it exists, getEntries() returns immediately from the pre-decoded data without touching zip.js at all.
Neural TTS: Kokoro-82M + ONNX Runtime (Offline, 6 Voices)

I wanted text-to-speech without calling to ElevenLabs or Google. **Kokoro** is an MIT-licensed TTS model with ~82M parameters in ONNX format. I use the `kokoro-en` crate with the `misaki-lean` feature — a pure-Rust G2P phonemizer that eliminates the espeak-ng subprocess dependency.
The model (onnx-community/Kokoro-82M-ONNX) is downloaded from HuggingFace on demand, not bundled. Inference runs through the ort crate (ONNX Runtime bindings) on CPU — no GPU needed. The pipeline:
Text → misaki G2P phonemizer → Kokoro ONNX Model → Audio Samples → Streaming WAV → Audio Output
Six English voices are available: three female (af_bella, af_nicole, af_sarah) and three male (am_adam, am_michael, bm_george).
Audio is streamed via Tauri events as chunks are generated, with prewarming on desktop startup (mobile loads lazily to avoid ANR). The TTS model status is tracked through dedicated Tauri commands.
Encrypted P2P LAN Sync
This is the feature I'm most proud of. When you want to sync two Theorem instances, one device shows a QR code containing its IP, port, and an ephemeral X25519 public key. The other device scans it (or enters the code manually), and a pairing handshake begins.
The sync infrastructure has three layers:
- CORE: shared library with the crypto protocol, embedded HTTP server (axum), and persistence
- Command : Tauri command wrappers for pairing, pushing, and pulling data
- Daemon : standalone sidecar process that runs independently of the GUI, auto-syncing every 120 seconds via a control API on localhost:43936
Pairing flow:
Device A: Generates ephemeral X25519 keypair, embeds public key in QR
Device B: Scans QR, extracts (ip, port, host_public_key, nonce)
Device B → A: POST /pair with B's ephemeral public key (encrypted with session key)
Both: Compute shared secret via X25519 ECDH
Both: Derive ChaCha20-Poly1305 key via HKDF (salt = pairing nonce)
A → B: Pairing response with verified proof, both store symmetric key
Sync protocol:
- Transport: HTTP/1.1 over TCP, fixed port 43935 (falls back to OS-assigned)
- Encryption: ChaCha20-Poly1305 AEAD, random 12-byte nonce per message
- Merge strategy: Last-writer-wins per domain, using
settingsLastModifiedAttimestamps - Sync domains: books, annotations, collections, tombstones, vocabulary, RSS feeds/articles, settings, reading stats
No mDNS, no WebRTC, no cloud relay. Discovery is intentional — you choose when to pair by scanning a QR code on the same WiFi, not by broadcasting your presence to the network.
Reading Statistics
I built a full reading statistics system that tracks time spent, books completed, daily streaks, and yearly goals. The statistics page includes a 12-week reading heatmap and shareable stats cards because seeing your reading habit visualized is surprisingly motivating.

RSS Feed Aggregation
Theorem doubles as an RSS reader. It parses RSS 2.0, Atom, JSON Feed, and RDF format. A token-bucket rate limiter (2 req/s for feeds, 1 req/s for articles) prevents hammering servers. On desktop, feeds are fetched through Rust's native HTTP client; in the browser, a CORS proxy is used.

Vocabulary Builder with StarDict
I integrated the StarDict dictionary format you can bring your own dictionary file or download the prelinked dictionary that i converted to StarDict from wikitionary data . Long-press any word in the reader to see definitions, save terms with their meanings (deduplicated across sources), and review them later. Dictionaries are downloaded and extracted (tar.bz2/zip) via a dedicated Tauri command with progress reporting, then stored in IndexedDB (browser) or SQLite blobs (desktop).

Markdown Export
Every highlight and annotation can be exported as Markdown with proper YAML frontmatter, ready to drop into an Obsidian vault or any Knowledge Management System. The vault sync module creates per-book pages in a -books subdirectory and a consolidated theorem-vocabulary.md vocabulary page.
---
source: "The Pragmatic Programmer"
author: "Andy Hunt & Dave Thomas"
timestamp: 2026-05-12T14:30:00Z
page: 47
---
> “Invest regularly in your knowledge portfolio.”
My note: This is the software equivalent of compound interest. Spend 30 min a day learning something new.
Share Studio
A dedicated UI for sharing quotes as social media cards. Choose a template and color scheme, and export as PNG. Quotes can also be shared directly to X/Twitter via an intent link.

Persistence: Dual-Layer Storage
State management uses five Zustand stores, all persisted through a custom theoremPersistStorage abstraction that transparently routes between SQLite (rusqlite) on desktop and IndexedDB in the browser. Each store has versioned migrations:
| Store | Purpose | Current Version |
|---|---|---|
useUIStore | Navigation, sidebar, sync status | 1 |
useLibraryStore | Books, collections, annotations, tombstones | 6 |
useSettingsStore | App settings, reader settings, stats, TTS, sync | 6 |
useVocabularyStore | Terms, dictionaries, lookup cache | 5 |
useRssStore | Feeds, articles, current article (capped at 500, 30 days) | 1 |
Cover images are stored separately and restored on rehydration in batches of 24 to keep the persisted state lean.
The full source is on GitHub at github.com/fundaments-work/theorem. It's MIT licensed, v1.0 launched June 2026, and I'm actively developing it. If you're tired of cloud-dependent reading apps, give it a try — and open an issue if something doesn't work. You can also try the demo at https://app.theorem.fundaments.work/