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:
- 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. - CORS. In a browser, PlebChat can only talk to endpoints that opt in with CORS headers:
- Feeds/articles:
$lib/news/fetch.tstries plainfetchfirst 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. - AI endpoints: arbitrary OpenAI-compatible gateways often don't do CORS; today those simply can't be used.
- 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
webSecurityor 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 toindex.html. Every route prerenders flat (chat.html,read.html, …) withssr=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 inapp-history-model.md). - Platform flags:
$lib/platform.ts— dependency-free (isTaurivia'__TAURI_INTERNALS__' in window) with capability getters (nativeFetch,localFolders,updateChannel), mirroring howbuildChatPrompt(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.fetchpatch, not an import seam. The originally proposedappFetchmodule seam is dead: the agent's real HTTP calls happen inside@earendil-works/pi-ai, which constructsnew OpenAI(...)/new Anthropic(...)with no fetch override (verified in its dist), so no import swap can reach them. Instead,$lib/native/fetch.ts(installed fromhooks.client.tsbefore anything else runs) replacesglobalThis.fetchwith a router: relative/same-origin/blob:/data:URLs stay on the webview fetch (asset protocol: pyodide chunks, fonts, pdfjs), cross-originhttp(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.tsskips the Firecrawl/feed-proxy fallback chain entirely and settings hides the feed-proxy field. - Service worker/PWA: SW registration is skipped under Tauri
(
+layout.svelteguard); 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 — sameUpdateCheckResultvocabulary 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 newlocalMountsIndexedDB 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
ArtifactVersionhistory 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 andsources/. Nesting is allowed inside mounts (unlike flat artifacts);../absolute segments are rejected (traversal guard); thelocal/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-scopekeeps grants across restarts (re-grant dialog as fallback if persistence proves flaky). - Approval:
edit/rmon local files always gate through the existingAgentRunnerapproval seam;writegates 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 isworkflow_dispatchwith a monorepo tag input, checks out the private monorepo (+ cyphertap submodule) via a fine-grained PAT secret, runstauri-actionacross 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 onplebchat-meAND 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:
- Platform module + PWA guards:
$lib/platform.ts, SW-registration guard, install-step short-circuit, RailFooter channel branch, this rewrite. app/src-tauri/scaffold + native fetch: plugins (http, fs +watch, dialog, persisted-scope, updater, process), capabilities,hooks.client.tsfetch patch, feed-fallback skip.- Local folder sync:
LocalMount+workspace/local/(fs wrappers, mounts store, watcher,LocalFilePane,LocalFoldersSection), agent-tool integration via the existingreadAt/allPathsseams. - Updater: signer keypair (
~/.tauri/plebchat.key),$lib/native/updater.ts, launch/hourly/visibility cadence mirroringsetupPwaUpdates. - 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).