A desktop spreadsheet built with Tauri 2, React 19, TypeScript, Zustand and
HyperFormula. The grid is rendered to a <canvas> so large sheets stay smooth, formula
evaluation runs in a Web Worker, and the whole thing ships as a native desktop binary. The
formula language aims to be compatible with the functions people already know from Excel and
Google Sheets.
Demo data only.
The app also runs as a plain web app (npm run dev) with a reduced feature set — anything
that needs the real filesystem degrades to browser download / file-picker flows.
npm ci
npm run dev # web-only, http://localhost:5173
npm run tauri:dev # full desktop app (requires the Rust toolchain)| Requirement | Notes |
|---|---|
| Node.js 20.19+ or 22.12+ | Floor set by Vite 8; .nvmrc pins 22 (active LTS). ESM-only project ("type": "module"). |
| Rust + Cargo | Only needed for tauri:dev / tauri:build. CI builds on stable; rust-toolchain.toml pins the channel. |
| Linux system deps | webkit2gtk-4.1, libsoup-3.0, librsvg2-dev — see the Tauri prerequisites |
npm run dev alone needs no Rust toolchain.
| Script | What it does |
|---|---|
npm run dev |
Vite dev server (web-only mode) |
npm run build |
Type-agnostic production bundle into dist/ |
npm run tauri:dev |
Build the frontend and launch the native window |
npm run tauri:build |
Produce distributable bundles (deb / AppImage / dmg / msi) |
npm run build:release |
Compile the desktop binary only (tauri build --no-bundle) |
npm test |
Vitest unit + integration suite |
npm run test:watch |
Vitest in watch mode |
npm run test:rust |
cargo test for the Rust backend |
npm run typecheck |
tsc --noEmit over src/ |
npm run lint |
ESLint (includes React Compiler diagnostics) |
npm run lint:fix |
ESLint with --fix |
npm run check |
Everything CI runs: typecheck → lint → test → build |
npm run collab-server |
Local WebSocket relay for real-time collaboration |
┌──────────────────────────── Tauri window ────────────────────────────┐
│ │
│ React 19 frontend (src/) │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ App.tsx menus, global keyboard map, startup import │ │
│ │ components/ CanvasGrid + ~40 dialogs / panels │ │
│ │ store/ Zustand store — the single source of truth │ │
│ │ core/ workbook model, cell types, pivot, rich text │ │
│ │ engine/ HyperFormula wrapper + 6 function plugins │ │
│ │ utils/ IndexedDB persistence, dialog service │ │
│ │ collab/ WebSocket client for shared editing │ │
│ │ bridge/ typed wrapper over Tauri `invoke` │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ IPC │
│ Rust backend (src-tauri/src/) ▼ │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ main.rs command handlers, single-instance, file args │ │
│ │ io/filesystem.rs path validation (home-directory jail) │ │
│ │ io/xlsx.rs .xlsx / legacy .xls import │ │
│ │ io/csv_io.rs CSV import / export │ │
│ │ cells/ server-side workbook storage │ │
│ └────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
The frontend Zustand store is the source of truth. The Rust side keeps its own
WorkbookStorage, but the frontend never syncs edits back into it — this is why
write_document takes the serialized payload as an argument rather than reading
AppState (see the comment on that command). Keep this in mind before wiring any new
feature through the Rust workbook state.
A consequence worth knowing before you touch src-tauri: of the 20 registered
commands, the frontend only ever calls five — write_document, read_document,
get_startup_import, read_import_file, and the dialog helpers. The cell, sheet and
CSV/XLSX commands (and the cells/ + io/csv_io modules behind them) are reachable
over IPC but unreferenced by the UI, which is where the backend's dead_code warnings
come from. They are kept — and hardened against malformed input — rather than deleted,
because the XLSX/XLS import path is actively being developed. Do not assume a Rust
command is exercised just because it exists.
src/engine/formulaEngine.ts wraps HyperFormula and registers six function plugins on top
of its built-ins (statistics, finance, text/info, database, dynamic arrays, operators —
roughly 120 extra functions, plus LET, LAMBDA and the MAP/REDUCE/SCAN/BYROW/
BYCOL/MAKEARRAY family implemented in the engine itself). Heavy recalculation is
offloaded to formulaWorker.ts via Comlink.
- Auto-save writes to IndexedDB, chunked (50k cells per record) and double-buffered
across two generations so an interrupted save can never corrupt the last good snapshot.
See
src/utils/persistence.ts. - Explicit save (
Ctrl+S) writes a JSON document through the Rustwrite_documentcommand, path-validated to stay inside the user's home directory.
Grid — canvas rendering, frozen panes, merged cells, row/column grouping, hidden rows/columns, resizable headers, zoom (50–200%), dark mode.
Formulas — HyperFormula core plus ~120 extra functions, named ranges, dynamic arrays, formula auditing (precedents/dependents), a cell watch panel, and a function browser.
Data — sort, filter views, remove duplicates, data validation, conditional formatting, pivot tables (dialog + live side panel), charts.
Collaboration — optional WebSocket relay with remote cursors and per-cell broadcast.
I/O — import/export XLSX, legacy XLS import, CSV/TSV, JSON; export to HTML and PDF;
OS file associations and --import <path> on the command line.
Productivity — command palette (Ctrl+K), find & replace, go-to-cell, comments,
version history, protected ranges, paste special, workbook compare, custom functions.
npm run collab-server # ws://127.0.0.1:9753
PORT=9000 npm run collab-server # custom port
HOST=0.0.0.0 COLLAB_TOKEN=secret npm run collab-server # expose on the networkThe relay broadcasts every message to every other client in the same ?room=. It is a
development convenience, not a production service, so it is deliberately hard to expose
by accident:
| Control | Behaviour |
|---|---|
| Bind address | Loopback (127.0.0.1) unless HOST says otherwise |
| Non-loopback bind | Refuses to start without COLLAB_TOKEN |
COLLAB_TOKEN |
Required as ?token=…, compared in constant time |
| Origin | Browser handshakes must match the allowlist (extend with COLLAB_ALLOWED_ORIGINS) |
| Limits | 500 rooms, 32 clients/room, 1000 total, 5 MB/s and 200 msg/s per client |
GET /healthz |
{ ok } always; room/client counts only with the token |
The Origin check matters more than it looks: WebSocket handshakes are exempt from CORS,
so without it any web page the user happens to visit could open ws://localhost:9753,
send {"type":"sync_request"} and receive the whole workbook from a real peer. Requests
with no Origin header (native clients, tests) are still accepted, since only browsers
send one.
ws:// is unencrypted — put a TLS-terminating proxy in front and use wss:// for
anything beyond a trusted LAN.
npm run check # what CI runs: typecheck, lint, test, build
npm test # vitest across engine, store, core, utils, collab
npm run test:rust # Rust backend integration testsThere is no DOM test environment installed, so .tsx components are not directly
testable. The convention is to extract the logic instead: src/core/htmlExport.ts,
src/core/csvExport.ts, src/utils/focus.ts and src/utils/url.ts all exist because
the behaviour worth testing was pulled out of a component or a store action into a pure
module.
src/collab/collabServer.test.ts is a Node-environment integration test that boots the
real relay over a socket; it is excluded from tsconfig.app.json (which is browser-typed)
but Vitest still discovers and runs it.
- No new dependencies without a clear reason. The bundle is already dominated by HyperFormula and SheetJS.
- Errors are never silently swallowed. Surface them through
toastMessageor the dialog service. - Never call native
alert/confirm/prompt. UseshowAlert/showConfirm/showPromptfromsrc/utils/dialogService.ts— the natives block the UI thread, ignore the theme, andprompt()is unimplemented in WKWebView (macOS builds). - Structural mutations (insert/delete row/column) live in one place in
spreadsheetStore.tsand are shared by the actions and by undo/redo, so a change and its reversal cannot drift apart. - Path validation is mandatory for every Rust command that touches the filesystem —
call
io::filesystem::validate_pathfirst, andcheck_readable_sizebefore reading. User-facing errors carrydisplay_name(path), not the absolute path. - Never interpolate a cell value into HTML by hand. Cell contents, sheet names and
colours all arrive from imported files and collaboration peers. Use
escapeHtml/safeCssColorfromsrc/utils/html.ts, or better, the builders insrc/core/htmlExport.ts. - Never write a raw value into a CSV field. Use
csvField()fromsrc/core/csvExport.ts; it handles RFC 4180 quoting and neutralizes values that a receiving spreadsheet would execute as a formula. - Validate every URL before opening or storing it with
safeExternalUrlfromsrc/utils/url.ts—format.hyperlinkis attacker-influenced. - The window-level key handler must not claim keys that belong to a focused control.
isNonGridControlFocusedinsrc/utils/focus.tsis the gate; without it, Tab and Enter are stolen from every button in the app.
launch-release.sh is meant to be bound to a desktop shortcut, and is what the Open With
entry installed by scripts/install-desktop-entry.sh (Linux/XDG) runs. It, which starts the compiled binary at
src-tauri/target/release/spreadsheet-backend.
The binary embeds the frontend at compile time. A binary older than the sources
therefore launches a stale app with no indication that anything is wrong. To stop that
happening silently, launch-release.sh compares the binary's mtime against src/,
public/, index.html, package.json, vite.config.ts and src-tauri/ before
launching, and rebuilds when they have moved on. Test files are excluded, so editing a
*.test.ts does not trigger a rebuild.
| Invocation | Behaviour |
|---|---|
./launch-release.sh |
Rebuild if stale (desktop notification), then launch |
./launch-release.sh -n |
Launch immediately, stale or not |
./launch-release.sh -B |
Rebuild even if current |
SPREADSHEET_AUTO_BUILD=0 |
Make -n the default |
When nothing has changed the check costs ~20 ms. A rebuild takes a couple of minutes and
is serialised with flock, so launching twice will not start two builds. If a
build fails, the previous binary is launched anyway and a notification points at
$TMPDIR/spreadsheet-build.log — a working old app beats no app.
launch.sh is the development launcher (tauri dev, hot reload). It is not bound to
any shortcut.
Build the binary with tauri build, not cargo build. Tauri's custom-protocol
feature is not in the crate's default features, so a plain cargo build --release
produces a binary that loads devUrl (localhost:5173) instead of the embedded
frontend — it opens to "Could not connect to localhost: Connection refused" unless a
dev server happens to be running. npm run build:release uses tauri build --no-bundle,
which sets the feature and skips the bundling stage (the AppImage step downloads
linuxdeploy at build time and hangs without network access).
A desktop launcher inherits none of your shell. With a bare PATH, node resolves
to the distro node, which may be older than Vite 8 accepts and Vite 8 refuses to run on (CustomEvent is not defined). scripts/launch-common.sh puts a Node matching .nvmrc on PATH before
invoking npm; both launch scripts source it.
Checking which build is running: the launch log records it.
tail -3 "${TMPDIR:-/tmp}/spreadsheet-launch-release.log"
# ... launched release binary (built 2026-08-20 13:30:18) with PID 3757707Note that pgrep -x spreadsheet-backend never matches — Linux truncates the process
name to 15 characters. Use pgrep -f target/release/spreadsheet-backend.
.github/workflows/ci.yml runs on every push to main and every pull request:
- Frontend —
npm ci→typecheck→lint→test→build. - Backend —
cargo test, plus an advisorycargo clippy.
npm run check runs the frontend half locally, so "it passes on my machine" means the
same thing CI means.
Two deliberate choices:
- Lint is gated at
--max-warnings 66, not zero. The React Compiler diagnostics are configured as warnings on purpose (see the rationale comment ineslint.config.js); the cap stops the count from growing without demanding a big-bang cleanup first. - Clippy is advisory. The Rust side carries
dead_codewarnings for commands the frontend no longer calls — see Where state lives. Gating on them would mean deleting that surface in the same change that turns the gate on.
Toolchains are pinned: .nvmrc (Node 22) and rust-toolchain.toml (stable).
| Symptom | Fix |
|---|---|
tauri:dev fails on libsoup / webkit2gtk |
Install the Tauri Linux prerequisites; export PKG_CONFIG_PATH if your pkg-config files live somewhere non-standard |
Blank window after tauri:dev |
Vite must be listening on 5173 first; strictPort is on, so a stale process on that port will break the launch |
| The launcher opens an old version | The binary embeds the frontend; rebuild with npm run build:release. launch-release.sh now does this automatically — see Desktop launcher |
| App opens to "Could not connect to localhost" | The binary was built with cargo build instead of tauri build, so it wants a dev server. Rebuild with npm run build:release |
| The launcher appears to do nothing | An instance is already running and the single-instance plugin forwarded to its window. pgrep -f target/release/spreadsheet-backend |
| Auto-save toast says storage is full | Export the workbook and delete old saved workbooks — IndexedDB quota is per-origin |
This is a personal project that I use day to day, not a finished product. Known gaps:
- Dynamic arrays and lambda helpers are sheet-local. App-level dynamic arrays
(
FILTER,SORT,UNIQUE,TRANSPOSE,SPLIT) andMAP/REDUCE/SCAN/... resolve ranges on the current sheet only, so a cross-sheet reference likeSheet1!A2:A40reads the wrong sheet. The plugin array functions (HSTACK,SORTN, ...) do handle cross-sheet refs. SUMPRODUCTcannot do range arithmetic (a HyperFormula limitation) — split it into separate terms.- Functions that need an external service are not implemented:
AI,GOOGLEFINANCE,GOOGLETRANSLATE,DETECTLANGUAGE,IMPORTDATA/IMPORTFEED/IMPORTHTML/IMPORTXML,GETPIVOTDATA. - Forwarding a file to an already-running instance (
--importon a second launch) is has been unreliable — the Rust side emitsopen-file, but the import has not always been applied. If a file does not appear, close the app and launch it with the file instead. - File access from the desktop app is restricted to the user's home directory by design.
- Only Linux builds are exercised regularly; macOS/Windows bundles are configured but untested.
- No web fonts are bundled; the UI uses the system font stack (Inter if installed).
MIT — see LICENSE. Not affiliated with or endorsed by Google, Microsoft or any other spreadsheet vendor; product names are used only to describe formula compatibility.
