Back to Blog
TechnicalJune 202510 min

Building a Desktop Widget with Tauri v2

CostDog is a desktop widget that sits on top of your terminal. Here's how we built it with Tauri v2.

Why Tauri?

  • **3MB installer** vs Electron's 150MB+
  • **Rust backend** — fast, memory-safe, no runtime
  • **System WebView** — no bundled browser
  • **Native feel** — real window controls, system tray
  • The Architecture

    ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”

    │ Tauri WebView │

    │ (HTML/CSS/JS) │

    ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤

    │ Rust Backend │

    │ (SQLite, Scanner) │

    ā”œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¤

    │ System Window │

    │ (Always on top) │

    ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜

    Key Design Decisions

    1. Frameless Window

    We used `decorations: false` to remove the native title bar, then built our own draggable bar with a close button.

    2. Dynamic Resizing

    The widget starts at 410Ɨ36px (just the bar) and expands to 410Ɨ520px when clicked. We use Tauri's `resize_window` command for this.

    3. Auto-Refresh

    A background thread in Rust scans session logs every 30 seconds and emits a `refresh-data` event to the frontend via Tauri's event system.

    4. Local SQLite

    All data is stored in `~/.costdog/costdog.sqlite` using rusqlite with WAL journal mode for concurrent reads.

    Challenges

    1. **macOS dragging** — CSS `-webkit-app-region: drag` doesn't work reliably. We switched to Tauri's `data-tauri-drag-region` attribute.

    2. **Gatekeeper** — Unsigned macOS apps are blocked. Users need to run `xattr -cr` to unblock.

    3. **Window focus** — Always-on-top windows can steal focus. We had to handle click-through carefully.

    Code Example

    Here's how the Rust backend scans Claude Code sessions:

    fn scan_claude_sessions(db: &Connection) -> Result<()> {

    let home = dirs::home_dir().unwrap();

    let claude_dir = home.join(".claude").join("projects");

    for entry in WalkDir::new(&claude_dir) {

    let entry = entry?;

    if entry.path().extension().map_or(false, |e| e == "jsonl") {

    parse_jsonl(entry.path(), db)?;

    }

    }

    Ok(())

    }

    Lessons Learned

    1. **Start simple.** We started with a single HTML file. No build step, no framework.

    2. **Rust is worth it.** The learning curve is steep, but the performance and safety pay off.

    3. **Desktop apps are different.** Window management, system tray, auto-update — all need special handling.