Skip to content

Proposal: Tauri Desktop App

Status: Implemented (phases 0–4) — awaiting device smoke + first release · Last updated: 2026-07-25

Why a desktop app

Two co-primary motivations:

  1. Local folder sync. The PWA cannot touch the OS filesystem, so users can't point the Synthesize agent at their real files. Desktop grants OS folders into a project (local/<name>/… in the agent's namespace): the agent reads/writes them, a watcher reflects external edits, and disk — not IndexedDB — is the store. The browser's File System Access API was considered as a web-side fallback and rejected for v1: Chromium-only, no Safari/iOS, and permission re-prompts — the capability gate (platform.localFolders) leaves room to add it later without redesign.
  2. CORS. In a browser, PlebChat can only talk to endpoints that opt in with CORS headers:
  3. Feeds/articles: $lib/news/fetch.ts tries plain fetch first and falls back to Firecrawl or the opt-in feed proxy because most RSS/article hosts send no CORS headers. Desktop fetches anything directly — no Firecrawl key, no proxy, no failed-first-fetch latency.
  4. AI endpoints: arbitrary OpenAI-compatible gateways often don't do CORS; today those simply can't be used.
  5. Relays/Blossom/mints are unaffected (WebSockets aren't CORS-gated; Blossom/mint HTTP already works) — this is about HTTP fetch.

Secondary wins: OS-level presence (dock, real window), no Safari/iOS viewport pathology, and a future home for file associations (.epub).

Why Tauri over Electron

  • Footprint: ~10 MB installer using the OS webview vs ~150 MB bundled Chromium; trivial CI builds for three platforms.
  • CORS posture: Tauri's HTTP plugin routes fetch through the Rust side, where browser CORS simply doesn't apply — a scoped, allowlisted capability. Electron's equivalents are blunter (disable webSecurity or rewrite headers in the main process).
  • Security model: capabilities are opt-in per API; no Node in the renderer to leak. Filesystem scope is dialog-granted at runtime (the folder picker IS the consent), not statically declared.
  • The cost: the webview is WKWebView on macOS / WebView2 on Windows / WebKitGTK on Linux — our WebKit hygiene (already enforced for iOS) keeps paying off; Linux WebKitGTK is the platform to smoke-test hardest.

Architecture

The web app stays primary and unforked; Tauri is a shell around the same build/ output. GitHub Pages remains the canonical deployment; desktop is an additive target built from the same commit.

  • Serving: frontendDist: "../build" — Tauri serves the static build over its internal protocol with fallback to index.html. Every route prerenders flat (chat.html, read.html, …) with ssr=false, so cold start, per-route reload, and unknown paths all land on the same hydrated shell; path routing works as-is (hash routing is NOT needed — part of why it was rejected in app-history-model.md).
  • Platform flags: $lib/platform.ts — dependency-free (isTauri via '__TAURI_INTERNALS__' in window) with capability getters (nativeFetch, localFolders, updateChannel), mirroring how buildChatPrompt(caps) gates search providers. All Tauri API usage lives in $lib/native/ behind dynamic imports — web bundles never load @tauri-apps/*.
  • The fetch strategy — globalThis.fetch patch, not an import seam. The originally proposed appFetch module seam is dead: the agent's real HTTP calls happen inside @earendil-works/pi-ai, which constructs new OpenAI(...) / new Anthropic(...) with no fetch override (verified in its dist), so no import swap can reach them. Instead, $lib/native/fetch.ts (installed from hooks.client.ts before anything else runs) replaces globalThis.fetch with a router: relative/same-origin/blob:/data: URLs stay on the webview fetch (asset protocol: pyodide chunks, fonts, pdfjs), cross-origin http(s) goes to @tauri-apps/plugin-http's spec-compliant fetch (streams — SSE chat deltas depend on this; verified first thing). This also makes every existing call site native with zero import churn: news/fetch.ts, firecrawl.ts, openrouter.ts, paygate.ts, model.ts, client.ts, blossom.ts.
  • HTTP scope is deliberately unrestricted (http://**, https://**): user-configurable AI endpoints and arbitrary feed URLs are the point; an allowlist can't know them in advance. Signed off 2026-07-25.
  • Feature gating: when platform.nativeFetch, news/fetch.ts skips the Firecrawl/feed-proxy fallback chain entirely and settings hides the feed-proxy field.
  • Service worker/PWA: SW registration is skipped under Tauri (+layout.svelte guard); the onboarding install step reports "already installed"; the RailFooter build-stamp tap routes to $lib/native/updater.ts (Tauri updater plugin) instead of the SW check — same UpdateCheckResult vocabulary on both channels.
  • Storage: IndexedDB/localStorage work unchanged in the webviews; per-npub DBs carry over. No migration — desktop starts fresh (pre-release stance) or syncs down via relays like any second device.
  • No Rust business logic. No custom Rust commands, ever. Rust is a dumb capability layer (http, fs, dialog, persisted-scope, updater, process). All protocol/app logic remains TypeScript, shared byte-for-byte with the web build.
  • Windows/keys caveat: cyphertap's hot keys in localStorage are no worse on desktop than on the web; Tauri's stronghold/keychain plugins are the future fix — cyphertap TECH-DEBT, not this proposal.

Local folder sync (the headline feature)

Design decisions (locked 2026-07-25):

  • Per-project mounts. LocalMount { id, projectId, name, absPath, readOnly, createdAt } in a new localMounts IndexedDB store. A folder can be mounted into several projects independently. Mount lifecycle rides the same project-scoped load/reset as artifacts/sources.
  • Disk is the store. No IndexedDB mirroring of content, no ArtifactVersion history for local files — the version UI is hidden on local tabs. IndexedDB holds only the mount records.
  • Namespace: mounts appear at local/<name>/… — the third differently-backed, differently-permissioned subtree after artifacts and sources/. Nesting is allowed inside mounts (unlike flat artifacts); ../absolute segments are rejected (traversal guard); the local/ prefix is reserved against artifact creation.
  • Conflicts: last-write-wins guarded by mtime. Reads record mtime; writes stat first and refuse if disk is newer ("changed on disk — re-read"), so the agent re-reads and the editor shows a reload prompt. A per-mount recursive watcher rebuilds the tree, refreshes clean open editors, and badges dirty ones.
  • Consent = the OS folder picker (dialog plugin), which grants the fs scope at runtime; persisted-scope keeps grants across restarts (re-grant dialog as fallback if persistence proves flaky).
  • Approval: edit/rm on local files always gate through the existing AgentRunner approval seam; write gates when overwriting — same rule as artifacts. Read-only mounts refuse all mutation.
  • Text files only in v1: UTF-8, ~1 MB cap, allowlist .md .txt .markdown .json .csv .yaml .yml, skipping dotfiles, .git, node_modules. Binary/EPUB folder import for the Read library is an explicit follow-up, not this work.

Distribution — the plebchat-desktop public repo

The monorepo is private; release assets on a private repo aren't publicly downloadable. github.com/PlebeiusGaragicus/plebchat-desktop is the releases-only shell: a README, the build workflow, and GitHub Releases with installers + the signed updater latest.json. Zero source. Cloned as a sibling (PLEBCHAT-PLATFORM/plebchat-desktop), no git linkage — no submodules in either direction.

  • The build workflow lives in the public repo — public repos get free, unmetered Actions minutes (macOS runners bill 10× on private repos), so the desktop matrix costs the monorepo zero metered minutes, preserving the CI-economics stance (LESSONS.md § CI economics). It is workflow_dispatch with a monorepo tag input, checks out the private monorepo (+ cyphertap submodule) via a fine-grained PAT secret, runs tauri-action across macOS-arm64/x64, Windows, Linux, and publishes a draft release on the public repo. Public-repo hygiene: logs are public — the build must never echo source or secrets.
  • Secrets (public repo): MONOREPO_PAT (contents:read on plebchat-me AND the cyphertap fork), TAURI_SIGNING_PRIVATE_KEY, TAURI_SIGNING_PRIVATE_KEY_PASSWORD.
  • Updater endpoint (permanent): https://github.com/PlebeiusGaragicus/plebchat-desktop/releases/latest/download/latest.json
  • Release flow: bump version in app/src-tauri/tauri.conf.json → gates on the dev machine → commit, git tag desktop-vX.Y.Z, push (tag pushes cost no metered minutes) → dispatch the public repo's release workflow with the tag → review draft → publish. The monorepo tag is the source of truth; release notes carry the monorepo commit.
  • macOS signing: unsigned first (decided 2026-07-25) — right-click-open past Gatekeeper; notarization is an additive workflow change when we buy the Apple Developer ID.

Phases

All five phases landed 2026-07-25:

  1. Platform module + PWA guards: $lib/platform.ts, SW-registration guard, install-step short-circuit, RailFooter channel branch, this rewrite.
  2. app/src-tauri/ scaffold + native fetch: plugins (http, fs +watch, dialog, persisted-scope, updater, process), capabilities, hooks.client.ts fetch patch, feed-fallback skip.
  3. Local folder sync: LocalMount + workspace/local/ (fs wrappers, mounts store, watcher, LocalFilePane, LocalFoldersSection), agent-tool integration via the existing readAt/allPaths seams.
  4. Updater: signer keypair (~/.tauri/plebchat.key), $lib/native/updater.ts, launch/hourly/visibility cadence mirroring setupPwaUpdates.
  5. Distribution: plebchat-desktop workflow + secrets.

Still open before calling it shipped: the device smoke pass — SSE chat streaming through the patched fetch under pnpm tauri dev (contingency if it buffers: keep webview fetch for streaming AI calls only), the 7-item folder-sync checklist, packaged-build route reloads — then the first tagged release and a v0.1.0 → v0.1.1 update-loop verification.

Follow-ups (explicitly out of v1): binary/EPUB folder import for Read, File System Access API web fallback, native Playwright/WebDriver automation (the shared web e2e suite + a manual native smoke checklist remain the verification story), mobile Tauri (the PWA covers mobile; App Store review is a philosophical mismatch).