compassmock/docs/modules/desktop.md
Nicholai 40fdf48cbf
feat: add conversations, desktop (Tauri), and offline sync (#81)
* feat: add conversations, desktop (Tauri), and offline sync

Major new features:
- conversations module: Slack-like channels, threads, reactions, pins
- Tauri desktop app with local SQLite for offline-first operation
- Hybrid logical clock sync engine with conflict resolution
- DB provider abstraction (D1/Tauri/memory) with React context

Conversations:
- Text/voice/announcement channels with categories
- Message threads, reactions, attachments, pinning
- Real-time presence and typing indicators
- Full-text search across messages

Desktop (Tauri):
- Local SQLite database with sync to cloud D1
- Offline mutation queue with automatic replay
- Window management and keyboard shortcuts
- Desktop shell with offline banner

Sync infrastructure:
- Vector clock implementation for causality tracking
- Last-write-wins with semantic conflict resolution
- Delta sync via checkpoints for bandwidth efficiency
- Comprehensive test coverage

Also adds e2e test setup with Playwright and CI workflows
for desktop releases.

* fix(tests): sync engine test schema and checkpoint logic

- Add missing process_after column and sync_tombstone table to test schemas
- Fix checkpoint update to save cursor even when records array is empty
- Revert claude-code-review.yml workflow changes to match main

---------

Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
2026-02-14 19:32:14 -07:00

333 lines
14 KiB
Markdown

Desktop Module
===
The desktop module wraps Compass in a native Windows, macOS, and Linux application using Tauri v2. Unlike the mobile app (which loads a remote URL in a WebView), the desktop app runs the entire Next.js application locally with a SQLite database. This means genuine offline support: you can create projects, edit schedules, and manage data without any network connection, then sync when connectivity returns.
The design principle mirrors the mobile module: **the web app doesn't know or care that it's running on desktop.** Platform detection (`isTauri()`) gates native features, but the React components, server actions, and data layer work identically whether the app is served from Cloudflare or running from a local Tauri binary.
why local sqlite
---
Cloudflare D1 is excellent for the hosted version--edge-co-located, zero-config, single-digit-millisecond latency. But it requires a network connection. For a construction project management tool, this is a genuine limitation. Jobsites often have spotty or no connectivity. A superintendent walking a site needs to check schedules, update task status, and capture notes without wondering if the API will respond.
The desktop app solves this by running SQLite locally via `@tauri-apps/plugin-sql`. The local database holds a subset of the remote data: the projects you've accessed, the schedules you're working on, the teams you belong to. When online, a sync engine pulls remote changes and pushes local mutations. When offline, the app works normally--the only difference is a small amber indicator showing pending sync count.
This isn't a PWA with a service worker cache. It's a real database with real queries, conflict resolution, and durability guarantees. The tradeoff is complexity: we maintain two database providers (D1 and SQLite), a sync protocol, and conflict resolution logic. But for desktop users who need reliable offline access, the tradeoff is worth it.
architecture
---
```
src-tauri/
├── src/
│ ├── lib.rs # App initialization, platform fixes
│ ├── commands/ # Rust commands exposed to frontend
│ │ ├── database.rs # db_query, db_execute, db_init
│ │ ├── sync.rs # get_sync_status, trigger_sync
│ │ └── platform.rs # get_platform_info, get_display_server
│ └── error.rs # AppError enum with From implementations
├── capabilities/
│ └── default.json # Security permissions (HTTP, filesystem, SQL)
├── migrations/
│ └── initial.sql # SQLite schema for local database
└── tauri.conf.json # Window config, CSP, bundle settings
src/
├── db/provider/
│ ├── interface.ts # DatabaseProvider type, isTauri(), detectPlatform()
│ └── tauri-provider.ts # SQLite implementation via @tauri-apps/plugin-sql
├── lib/sync/
│ ├── engine.ts # Pull/push sync, conflict resolution
│ ├── clock.ts # Vector clocks for causal ordering
│ ├── conflict.ts # Last-write-wins with clock comparison
│ ├── schema.ts # local_sync_metadata, mutation_queue tables
│ └── queue/
│ ├── mutation-queue.ts # Local operation queue with persistence
│ └── processor.ts # Retry logic with exponential backoff
├── hooks/
│ ├── use-desktop.ts # Platform detection hook
│ └── use-sync-status.ts # Sync state, pending count, trigger function
└── components/desktop/
├── desktop-shell.tsx # Context provider, beforeunload sync check
├── sync-indicator.tsx # Badge showing sync status
└── offline-banner.tsx # Warning when offline with pending changes
```
The Rust side is thin by design. `lib.rs` initializes the Tauri plugins (SQL, HTTP, filesystem, window state), registers the command handlers, and applies platform-specific fixes at startup. The commands are simple pass-throughs--the real logic lives in TypeScript. This keeps the Rust surface area small and lets us share code between web and desktop.
platform detection
---
`src/db/provider/interface.ts` provides the detection layer:
```typescript
export function isTauri(): boolean {
if (typeof window === "undefined") return false
return "__TAURI__" in window
}
```
The `__TAURI__` global is injected by the Tauri runtime before JavaScript executes. This means the check works after hydration but not during SSR--server-rendered HTML assumes web, and the desktop state is only known client-side.
`detectPlatform()` returns `"tauri"`, `"d1"`, or `"memory"` based on the runtime environment. This determines which database provider the sync engine uses:
```typescript
export function detectPlatform(): ProviderType {
if (isTauri()) return "tauri"
if (isCloudflareWorker()) return "d1"
return "memory"
}
```
the database provider interface
---
Both D1 and Tauri SQLite implement the same `DatabaseProvider` interface:
```typescript
export interface DatabaseProvider {
readonly type: ProviderType
getDb(): Promise<DrizzleDB>
execute(sql: string, params?: unknown[]): Promise<void>
transaction<T>(fn: (db: DrizzleDB) => Promise<T>): Promise<T>
close?(): Promise<void>
}
```
The Tauri provider uses `@tauri-apps/plugin-sql` which provides raw SQL execution:
```typescript
async function initializeDatabase(config?: TauriProviderConfig): Promise<TauriSqlDb> {
if (dbInstance) return dbInstance
if (dbInitPromise) return dbInitPromise // Prevent concurrent init
dbInitPromise = (async () => {
const { default: Database } = await import("@tauri-apps/plugin-sql")
dbInstance = await Database.load("sqlite:compass.db")
return dbInstance
})()
return dbInitPromise
}
```
The dynamic import is important--`@tauri-apps/plugin-sql` doesn't exist outside Tauri. A top-level import would crash the web app at module evaluation time. The same pattern appears in the mobile module with Capacitor plugins.
Transactions are handled manually since the Tauri SQL plugin doesn't provide a transaction API:
```typescript
async transaction<T>(fn: (db: DrizzleDB) => Promise<T>): Promise<T> {
const db = await initializeDatabase(config)
await db.execute("BEGIN TRANSACTION")
try {
const result = await fn({ _tauriDb: db } as unknown as DrizzleDB)
await db.execute("COMMIT")
return result
} catch (error) {
await db.execute("ROLLBACK")
throw error
}
}
```
the sync engine
---
Sync is the hardest part of offline-first. The desktop app needs to:
1. Pull remote changes since the last sync
2. Push local mutations created while offline
3. Detect and resolve conflicts when the same record was edited on both sides
4. Preserve causal ordering (if edit B depends on edit A, they must apply in order)
The implementation uses vector clocks for causal ordering and last-write-wins for conflict resolution. Here's the conceptual model:
**Vector clocks.** Each device maintains a map of `{ deviceId: counter }`. When a device makes a change, it increments its counter. The vector clock gets attached to every mutation. When comparing two versions of a record, the clocks tell you whether one causally precedes the other (all counters <=) or whether they're concurrent (neither dominates).
**Conflict resolution.** If clocks show concurrent edits (true conflict), we use last-write-wins based on timestamp. This is a deliberate choice--it's simple and predictable, though it can lose data. Alternatives like operational transformation or CRDTs are more correct but significantly more complex. For a project management tool where edits are mostly to different fields, last-write-wins is usually fine.
**Mutation queue.** Local mutations go into a queue table before being applied to the local database. The queue persists to localStorage as a backup--if the app crashes or is force-closed mid-sync, mutations aren't lost. On restart, the queue is restored and sync resumes.
**Tombstones.** Deletions are tricky. If device A deletes a record and device B edits it offline, we need to know the deletion happened. The solution is tombstones--when a record is deleted, we keep a marker with its ID and the deletion timestamp. Delta sync includes tombstones, and the receiver respects them.
The sync tables are defined in `src/lib/sync/schema.ts`:
```typescript
export const localSyncMetadata = sqliteTable("local_sync_metadata", {
tableName: text("table_name").notNull(),
recordId: text("record_id").notNull(),
vectorClock: text("vector_clock").notNull(),
lastSyncedAt: text("last_synced_at"),
isDeleted: integer("is_deleted", { mode: "boolean" }).default(false),
})
export const mutationQueue = sqliteTable("mutation_queue", {
id: text("id").primaryKey(),
operation: text("operation", { enum: ["insert", "update", "delete"] }).notNull(),
tableName: text("table_name").notNull(),
recordId: text("record_id").notNull(),
payload: text("payload"),
vectorClock: text("vector_clock").notNull(),
status: text("status", { enum: ["pending", "processing", "completed", "failed"] }).notNull(),
retryCount: integer("retry_count").default(0),
processAfter: text("process_after"), // For non-blocking backoff
})
```
the beforeunload problem
---
A user with pending mutations could close the app before sync completes. The mutations would be lost even though they're in the queue--the queue is persisted, but the user might not reopen the app.
The solution is a `beforeunload` handler that warns the user:
```typescript
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (pendingCount > 0 || isSyncing) {
e.preventDefault()
e.returnValue = "Sync in progress. Close anyway?"
}
}
window.addEventListener("beforeunload", handleBeforeUnload)
return () => window.removeEventListener("beforeunload", handleBeforeUnload)
}, [pendingCount, isSyncing])
```
This is implemented in `src/components/desktop/desktop-shell.tsx`. The handler only triggers when there's actual pending work--closing the app with a clean sync state is silent.
linux compatibility
---
Linux desktop support is where Tauri's WebKitGTK backend causes problems. The issue is specific to NVIDIA GPUs on Wayland: WebKitGTK's DMA-BUF renderer conflicts with NVIDIA's driver implementation, causing crashes or extreme slowness.
The fix depends on driver version:
- **NVIDIA 545+**: `__NV_DISABLE_EXPLICIT_SYNC=1` disables the new explicit sync protocol. This is the preferred fix--it maintains hardware acceleration.
- **Older drivers**: `WEBKIT_DISABLE_DMABUF_RENDERER=1` forces software rendering. This is stable but slow.
The app detects the configuration at startup in `lib.rs`:
```rust
#[cfg(target_os = "linux")]
{
let session_type = std::env::var("XDG_SESSION_TYPE").unwrap_or_default();
let is_wayland = session_type == "wayland";
let has_nvidia = std::path::Path::new("/proc/driver/nvidia").exists();
if is_wayland && has_nvidia {
if std::env::var("__NV_DISABLE_EXPLICIT_SYNC").is_err() {
std::env::set_var("__NV_DISABLE_EXPLICIT_SYNC", "1");
}
}
}
```
We only set the explicit sync flag automatically. The DMABUF fallback (`WEBKIT_DISABLE_DMABUF_RENDERER=1`) forces software rendering which is noticeably slower--if a user needs it, they can set it manually. The automatic fix works for most modern setups; the manual fallback exists for edge cases.
Wayland compositors also handle window decorations differently from X11. The app detects Wayland and disables CSD (client-side decorations):
```rust
if is_wayland {
let _ = window.set_decorations(false);
}
```
This prevents duplicate title bars on compositors like Hyprland or Sway that draw their own decorations.
security model
---
Tauri uses a capability-based security model. The `src-tauri/capabilities/default.json` file defines what the frontend can access:
**HTTP permissions.** Fetch requests are scoped to specific domains:
```json
{
"identifier": "http:default",
"allow": [
{ "url": "http://localhost:*" },
{ "url": "http://127.0.0.1:*" },
{ "url": "https://compass.work/**" },
{ "url": "https://*.cloudflare.com/**" },
{ "url": "https://api.openrouter.ai/**" },
{ "url": "https://api.workos.com/**" }
]
}
```
The localhost entries are needed for dev mode (the frontend talks to `localhost:3000`). Production domains include the app itself, the Cloudflare deployment platform, and the AI/auth providers.
**Filesystem permissions.** Access is limited to app-specific directories:
```json
{
"identifier": "fs:default",
"allow": [
{ "path": "$APPDATA/**" },
{ "path": "$APPCONFIG/**" },
{ "path": "$LOCALDATA/**" }
]
}
```
The SQLite database lives in `$APPDATA/compass/`. The app can't read or write outside these directories.
**Content Security Policy.** Configured in `tauri.conf.json`:
```
default-src 'self'
script-src 'self' 'unsafe-inline' 'unsafe-eval'
connect-src 'self' http://localhost:* https: wss:
```
The `unsafe-inline` and `unsafe-eval` are required for Next.js--the Turbopack dev server uses inline scripts and eval for hot module replacement. In a production build, these could potentially be removed, but dev mode needs them.
troubleshooting
---
**Blank window on Linux.** If the window opens but shows nothing:
1. Check that the dev server is running (`http://localhost:3000` responds)
2. Check HTTP permissions include `localhost:*` in capabilities
3. Check CSP allows `connect-src 'self' http://localhost:*`
**Crash on Wayland + NVIDIA.** The automatic fix should handle most cases. If it still crashes:
```bash
# Force software rendering (stable but slow)
WEBKIT_DISABLE_DMABUF_RENDERER=1 bun tauri:dev
# Or use X11 backend
GDK_BACKEND=x11 bun tauri:dev
```
**Slow performance in dev mode.** Dev mode is inherently slower than production:
- Turbopack compiles routes on first access (1-3s per route)
- Cloudflare bindings go through a wrangler proxy
- If DMABUF is disabled, rendering is software-only
Production builds don't have these issues. The desktop app built with `bun tauri:build` is significantly snappier.
commands
---
```bash
bun tauri:dev # Development with hot reload
bun tauri:build # Production build (creates installer)
bun tauri:preview # Run built app without rebuilding
```
No environment variables needed--the app handles platform detection and applies fixes automatically.