DEV Community

Cover image for I Ditched Electron for Tauri v2: Building a 6MB Native-Feeling Clipboard Manager in React & Rust
Max Nardit
Max Nardit

Posted on

I Ditched Electron for Tauri v2: Building a 6MB Native-Feeling Clipboard Manager in React & Rust

I used the Paste app on macOS for years. It was the kind of utility you don't notice until it's gone. When I switched to Windows as my daily driver, the built-in Win+V clipboard manager felt like an absolute toy — it has a 25-item limit, zero search capabilities, and completely wipes your history after a reboot.

I looked for alternatives, but they were either visually stuck in 2005 or felt like heavy sysadmin tools.

So I built Beetroot — the clipboard manager I actually wanted to use.

Beetroot clipboard manager — dark theme with search

I refused to use Electron. A background utility should not eat 300MB of RAM just to render a list of strings. I went with Tauri v2. The backend (database, OS hooks, OCR) is written in Rust, and the frontend is React 19 + TypeScript.

The resulting installer is just ~6 MB, and it idles at around 30–50 MB of RAM.

Building a desktop app with web technologies often gets a bad rap because of sluggish UIs. But if you build it right, it can feel 100% native. Here are the engineering decisions that got me there — with real code from the codebase.


1. Rendering 10,000+ Items at 60 FPS

A clipboard manager accumulates history fast. If you try to render 10,000 React DOM nodes, your app will freeze.

The solution is list virtualization with react-window. Only the items visible on the screen (plus a small buffer) exist in the DOM.

import { List } from "react-window";

const ITEM_ROW_HEIGHT = 48;
const GROUP_HEADER_HEIGHT = 28;
const VIRTUAL_LIST_OVERSCAN = 5;

export function ClipboardList({ items, selectedIndex }: Props) {
  const listRef = useListRef(null);
  const groupLabels = useMemo(() => computeGroupLabels(items), [items]);

  // Dynamic height: normal row 48px, with group header ("Today", "Yesterday") +28px
  const getRowHeight = useMemo(() => {
    return (index: number) =>
      groupLabels.has(index)
        ? ITEM_ROW_HEIGHT + GROUP_HEADER_HEIGHT
        : ITEM_ROW_HEIGHT;
  }, [groupLabels]);

  // Auto-scroll to selected item on keyboard navigation
  useEffect(() => {
    if (items.length > 0 && selectedIndex < items.length) {
      listRef.current?.scrollToRow({ index: selectedIndex, align: "auto" });
    }
  }, [selectedIndex]);

  return (
    <List
      listRef={listRef}
      rowCount={items.length}
      rowHeight={getRowHeight}
      rowComponent={Row}
      overscanCount={VIRTUAL_LIST_OVERSCAN}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

The math: 680×480px window ÷ 48px row = ~15 visible items + 5 overscan = ~20 DOM nodes regardless of total count. react-window positions items via CSS transform: translateY(), which avoids expensive DOM reflows.


2. Smart Content Badges with an LRU Cache

When you copy something, Beetroot automatically tags it: URL, Email, JSON, Color, or Code. This detection runs synchronously during render — not on the Rust backend.

The key insight: with a virtualized list, the same items get re-rendered on scroll. An LRU cache eliminates redundant regex work:

const URL_RE = /^https?:\/\/\S+|^www\.\S+/i;
const HEX_COLOR_RE = /^#(?:[0-9a-f]{3}){1,2}$/i;
const SQL_RE = /(?:^|[;(])\s*(?:SELECT|INSERT|UPDATE|DELETE|CREATE)\b/im;
const SHELL_RE = /(?:^|\|)\s*(?:sudo|npm|git|docker|curl|cargo)\b/m;

const contentTypeCache = new Map<string, ContentType>();

export function detectContentType(text: string): ContentType {
  const trimmed = text.trim();

  const cached = contentTypeCache.get(trimmed);
  if (cached !== undefined) return cached;

  let result: ContentType = "plain";

  // Order matters: cheap regex first, expensive JSON.parse last
  if (HEX_COLOR_RE.test(trimmed)) {
    result = "color";
  } else if (URL_RE.test(trimmed)) {
    result = "url";
  } else if ((trimmed[0] === "{" || trimmed[0] === "[") && trimmed.length > 1) {
    try { JSON.parse(trimmed); result = "json"; }
    catch { if (looksLikeCode(trimmed)) result = "code"; }
  } else if (looksLikeCode(trimmed)) {
    result = "code";
  }

  // Evict 200 entries when cache exceeds 1000
  if (trimmed.length <= 10_000) {
    if (contentTypeCache.size > 1000) {
      const keys = contentTypeCache.keys();
      for (let i = 0; i < 200; i++) {
        const next = keys.next();
        if (next.done) break;
        contentTypeCache.delete(next.value);
      }
    }
    contentTypeCache.set(trimmed, result);
  }

  return result;
}
Enter fullscreen mode Exit fullscreen mode

False positive prevention is critical here. A code detector that flags every email as "code" is useless:

Type Detection Guard
Code JS/Python keywords + {}; Requires both keyword AND braces
SQL SELECT/INSERT + FROM/WHERE Requires both statement AND clause
Regex \d, [^...], (?=) Excludes Windows paths (C:\) and LaTeX (\section)
JSON JSON.parse() Fallback to code detection on parse failure

3. Native Windows 11 Visuals (Mica & Acrylic)

The biggest complaint about web-based desktop apps is that they look out of place. Beetroot supports native Windows 11 Mica, Windows 10 Acrylic, and a Pure Dark OLED mode — and it switches between them with a single function.

The trick is combining Tauri's Window API with CSS custom properties:

import { getCurrentWindow, Effect, EffectState } from "@tauri-apps/api/window";

export function applyWindowEffect(effect: "mica" | "acrylic" | "solid", forceOpaque: boolean) {
  const root = document.documentElement;
  const win = getCurrentWindow();

  if (forceOpaque || effect === "solid") {
    // OLED Pure Dark needs true #000000 — any transparency gives gray
    root.style.setProperty("--bg-app-alpha", "1");
    root.style.setProperty("--backdrop-blur", "0px");
    document.body.style.background = theme.colors["--bg-app"];
    win.clearEffects();

  } else if (effect === "acrylic") {
    // Acrylic: semi-transparent with CSS blur
    root.style.setProperty("--bg-app-alpha", "0.85");
    root.style.setProperty("--backdrop-blur", "20px");
    document.body.style.background = "transparent";
    win.setEffects({
      effects: [Effect.Acrylic],
      state: EffectState.FollowsWindowActiveState,
    });

  } else {
    // Mica: semi-transparent, OS handles the blur
    root.style.setProperty("--bg-app-alpha", "0.78");
    root.style.setProperty("--backdrop-blur", "0px");
    document.body.style.background = "transparent";
    win.setEffects({
      effects: [Effect.Mica],
      state: EffectState.FollowsWindowActiveState,
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The CSS side is minimal:

.app-container {
  background: rgba(var(--bg-app-rgb), var(--bg-app-alpha, 1));
  backdrop-filter: blur(var(--backdrop-blur, 0px));
}
Enter fullscreen mode Exit fullscreen mode

Critical prerequisite: Your tauri.conf.json must have "transparent": true on the window. Without it, none of the effects work — you just get a white rectangle.

The app also auto-detects the Windows version to show the right options:

const isWin11 = osBuild >= 22000;
const effectOptions = [
  ...(isWin11 ? [{ value: "mica" }] : []),  // Mica: Win11 only
  { value: "acrylic" },                      // Acrylic: Win10+
  { value: "solid" },                        // Always available
];
Enter fullscreen mode Exit fullscreen mode

4. The Clipboard Self-Recording Problem

Here's a subtle bug that plagues clipboard managers: when Beetroot pastes an item, it writes to the system clipboard first — which triggers its own clipboard monitor. Without protection, every paste creates a duplicate entry.

The fix is a suppress flag with a 2-second auto-reset timeout:

let suppressNext = false;
let suppressTimer: ReturnType<typeof setTimeout> | null = null;

export function setSuppressNext() {
  suppressNext = true;
  // Auto-reset after 2s in case the clipboard event never fires
  if (suppressTimer) clearTimeout(suppressTimer);
  suppressTimer = setTimeout(() => {
    suppressNext = false;
    suppressTimer = null;
  }, 2000);
}

export function checkAndResetSuppress(): boolean {
  if (suppressNext) {
    suppressNext = false;
    if (suppressTimer) {
      clearTimeout(suppressTimer);
      suppressTimer = null;
    }
    return true;  // Skip this clipboard event
  }
  return false;
}
Enter fullscreen mode Exit fullscreen mode

Every paste wraps the clipboard write in this guard:

export async function pasteItem(item: ClipboardEntry) {
  setSuppressNext();                    // 1. Set flag
  await writeText(item.content);        // 2. Write to clipboard (triggers monitor)
  await pasteSelectedItem();            // 3. Simulate Ctrl+V via SendInput
  // Monitor fires → checkAndResetSuppress() returns true → event skipped
}
Enter fullscreen mode Exit fullscreen mode

The 2-second timeout is the safety net. If the clipboard event never fires (slow system, clipboard locked by another app), the flag auto-resets so the monitor doesn't stay permanently muted.


5. Password Manager Detection via Clipboard Formats

Clipboard managers and password managers are natural enemies. If 1Password copies a password to your clipboard, you definitely don't want it saved in your history.

Windows has a lesser-known mechanism for this: applications can register custom clipboard formats as signals. Beetroot enumerates all formats on each clipboard event using the Win32 API:

pub fn get_format_names() -> Vec<String> {
    let mut names = Vec::new();
    unsafe {
        if OpenClipboard(HWND::default()).is_err() { return names; }
        let mut format = EnumClipboardFormats(0);
        while format != 0 {
            if format >= 0xC000 {  // Custom registered formats
                let mut buf = [0u16; 256];
                let len = GetClipboardFormatNameW(format, &mut buf);
                if len > 0 {
                    names.push(String::from_utf16_lossy(&buf[..len as usize]));
                }
            }
            format = EnumClipboardFormats(format);
        }
        let _ = CloseClipboard();
    }
    names
}
Enter fullscreen mode Exit fullscreen mode

The frontend checks against a known list of password manager signals:

const PASSWORD_MANAGER_FORMATS = [
  "CF_CLIPBOARD_VIEWER_IGNORE",
  "Clipboard Viewer Ignore",
  "ExcludeClipboardContentFromMonitorProcessing",
  "CanIncludeInClipboardHistory",
  "CanUploadToCloudClipboard",
];

async function shouldSkipPasswordManager(): Promise<boolean> {
  const formats = await getClipboardFormats();
  return formats.some((f) => PASSWORD_MANAGER_FORMATS.includes(f));
}
Enter fullscreen mode Exit fullscreen mode

This covers 1Password, Bitwarden, KeePass, LastPass, and others — they all use at least one of these standard format names.


6. Adaptive Tray Icon (Reading the Windows Registry from Rust)

A small touch that makes a big difference: the system tray icon automatically adapts to the taskbar theme. Dark taskbar gets a light icon, light taskbar gets a dark icon.

This is done by reading the Windows registry at startup:

const TRAY_LIGHT: &[u8] = include_bytes!("../icons/tray-light.png");
const TRAY_DARK: &[u8] = include_bytes!("../icons/tray-dark.png");

fn is_light_taskbar() -> bool {
    use winreg::enums::HKEY_CURRENT_USER;
    use winreg::RegKey;
    let hkcu = RegKey::predef(HKEY_CURRENT_USER);
    if let Ok(key) = hkcu.open_subkey(
        "Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"
    ) {
        if let Ok(val) = key.get_value::<u32, _>("SystemUsesLightTheme") {
            return val == 1;
        }
    }
    false  // Default to dark taskbar
}

pub fn create_tray(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
    let icon_bytes = if is_light_taskbar() { TRAY_DARK } else { TRAY_LIGHT };
    let icon = Image::from_bytes(icon_bytes)?;
    // ...
}
Enter fullscreen mode Exit fullscreen mode

include_bytes! embeds both icons directly into the binary at compile time — no filesystem access needed at runtime.


7. Multi-Monitor: Following the Cursor

Most clipboard managers open on the primary monitor. But if you're working on your secondary monitor, that's a context switch.

Beetroot detects which monitor has the cursor and positions the window there. The critical detail: all math uses physical pixels to avoid DPI mismatch between monitors with different scaling.

fn try_center_on_cursor_monitor(win: &WebviewWindow) -> Result<(), Box<dyn Error>> {
    let cursor = win.cursor_position()?;
    let monitors = win.available_monitors()?;

    let target = monitors.iter().find(|m| {
        let pos = m.position();
        let size = m.size();
        let (x, y, w, h) = (pos.x as f64, pos.y as f64, size.width as f64, size.height as f64);
        cursor.x >= x && cursor.x < x + w && cursor.y >= y && cursor.y < y + h
    });

    if let Some(monitor) = target {
        let mon_pos = monitor.position();
        let mon_size = monitor.size();
        let win_size = win.outer_size()?;

        // Center on monitor, clamp to edges
        let x = (mon_pos.x as f64 + (mon_size.width as f64 - win_size.width as f64) / 2.0)
            .max(mon_pos.x as f64)
            .min(mon_pos.x as f64 + mon_size.width as f64 - win_size.width as f64);

        win.set_position(PhysicalPosition::new(x as i32, y as i32))?;
    }
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

There's also a follow-cursor mode where the window pops up right at the mouse, like a context menu. If the window doesn't fit below the cursor, it flips above:

const CURSOR_OFFSET_Y: f64 = 12.0;

fn try_show_near_cursor(win: &WebviewWindow) -> Result<(), Box<dyn Error>> {
    let cursor = win.cursor_position()?;
    // ... find monitor ...

    let x = cursor.x.max(mon_x).min(mon_x + mon_w - win_w);

    // Prefer below cursor; flip above if it overflows
    let y_below = cursor.y + CURSOR_OFFSET_Y;
    let y = if y_below + win_h <= mon_y + mon_h {
        y_below
    } else {
        (cursor.y - win_h - CURSOR_OFFSET_Y).max(mon_y)
    };

    win.set_position(PhysicalPosition::new(x as i32, y as i32))?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

8. Client-Side AI with BYOK

Beetroot lets you transform copied text (fix grammar, translate, extract JSON) using OpenAI — but with a twist: the request goes directly from the frontend to OpenAI. No backend proxy, no middleman.

export async function runAITransform(
  apiKey: string, model: OpenAIModel, prompt: string, inputText: string
): Promise<OpenAIResult> {
  if (inputText.length > 50_000) {
    return { error: "Text too long for AI transform" };
  }

  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), 30_000);

  const response = await fetch("https://api.openai.com/v1/chat/completions", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${apiKey}`,
    },
    body: JSON.stringify({
      model,
      messages: [
        {
          role: "developer",
          content: "You are a text transformation assistant. Return ONLY the transformed text with no explanation, no quotes, no markdown formatting.",
        },
        { role: "user", content: `Instruction: ${prompt}\n\nText:\n${inputText}` },
      ],
      max_completion_tokens: 4096,
      reasoning_effort: "low",
    }),
    signal: controller.signal,
  });

  clearTimeout(timeoutId);
  const data = await response.json();
  return { text: data?.choices?.[0]?.message?.content?.trim() };
}
Enter fullscreen mode Exit fullscreen mode

Why role: "developer"? It makes the model return just the raw transformed text, without wrapping it in markdown blocks or adding "Here is your result:". Combined with reasoning_effort: "low", the response is fast and minimal.

Why not route through Rust? The API key stays in the browser's isolated localStorage (WebView2 profile). No key serialization across IPC boundaries, no extra attack surface. And the CSP ensures the frontend can only talk to api.openai.com:

"csp": "default-src 'self'; connect-src 'self' https://api.openai.com"
Enter fullscreen mode Exit fullscreen mode

9. Crash-Proof SQLite (The Cloud Drive Problem)

Because Beetroot saves history instantly, the SQLite database runs in WAL (Write-Ahead Logging) mode. This allows concurrent reads and writes without blocking the UI.

But many users sync their %LOCALAPPDATA% folder via OneDrive or Google Drive. These cloud clients often fail to sync the -wal and -shm sidecar files atomically, which instantly corrupts the database.

To handle this, I built a 3-phase auto-recovery system:

Phase 1: Atomic non-blocking backups.
Instead of copying the file, Beetroot uses the official SQLite Backup API (rusqlite::backup::Backup). It copies the database in chunks of 100 pages, sleeping 10ms between iterations — so the UI doesn't freeze. The backup then gets a PRAGMA wal_checkpoint(TRUNCATE) and switches to DELETE journal mode, making it a single self-contained file. Finally, an atomic tmp → rename prevents partial writes.

Phase 2: Corruption detection.
On startup: PRAGMA quick_check(1). On runtime SQLite errors (SQLITE_CORRUPT): drop a FORCE_RECOVERY marker file and alert the user.

Phase 3: Auto-recovery.
On next launch, if the marker exists: delete the broken -wal/-shm files, find the latest timestamped backup, run full_integrity_check, and restore it. The user sees a notification, not a crash.

Beetroot also proactively warns if your data folder is inside a cloud sync directory:

pub fn detect_cloud_sync(path: &str) -> Option<&'static str> {
    const MARKERS: &[(&str, &str)] = &[
        ("onedrive", "OneDrive"),
        ("dropbox", "Dropbox"),
        ("google drive", "Google Drive"),
    ];
    let lower = path.to_lowercase();
    for (pattern, service) in MARKERS {
        if lower.contains(&format!("\\{}", pattern))
            || lower.contains(&format!("/{}", pattern)) {
            return Some(service);
        }
    }
    None
}
Enter fullscreen mode Exit fullscreen mode

10. Privacy by Design

When building a clipboard manager, trust is everything. Your clipboard contains passwords, API keys, private messages, and proprietary code. Here's the security architecture:

Zero network calls by default. The only request is checking GitHub for updates — and you can disable that. No telemetry, no analytics, no crash reporting.

Content Security Policy locks down what the WebView can access:

"csp": "default-src 'self'; connect-src 'self' https://api.openai.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://asset.localhost"
Enter fullscreen mode Exit fullscreen mode

Path traversal protection on the Rust backend validates every file path against blocked system directories and canonicalizes symlinks:

const BLOCKED_DIRS: &[&str] = &[
    "c:\\windows", "c:\\program files", "c:\\program files (x86)",
    "c:\\programdata",
];

pub fn validate_data_path(path: &str) -> Result<PathBuf, String> {
    if path.starts_with("\\\\") || path.starts_with("//") {
        return Err("Network paths are not supported".to_string());
    }
    let canonical = canonicalize_with_ancestors(&PathBuf::from(path))?;
    let lower = canonical.to_string_lossy().to_lowercase();
    for b in BLOCKED_DIRS {
        if lower.starts_with(b) {
            return Err(format!("Cannot use system directory: {}", b));
        }
    }
    Ok(canonical)
}
Enter fullscreen mode Exit fullscreen mode

Single instance via Tauri plugin — prevents two copies from corrupting the same database:

.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
    if let Some(w) = app.get_webview_window("main") {
        let _ = w.show();
        let _ = w.set_focus();
    }
}))
Enter fullscreen mode Exit fullscreen mode

The Numbers

Metric Value
Installer size ~6 MB
Idle RAM 30–50 MB
DOM nodes at 10K items ~20
Content detection <1ms per item
Rust source files 12
Languages supported 26

Conclusion

You don't need Electron to build a great desktop app. By combining Tauri v2 for native OS integration and React 19 with list virtualization, you can build tools that respect the user's system resources while still feeling premium.

The hardest problems weren't in the UI — they were in the platform edges: clipboard format enumeration, DPI-aware multi-monitor math, cloud sync corruption, and the subtle self-recording bug that every clipboard manager has to solve.

Beetroot is free to use, no telemetry, no accounts. If you're building your own Tauri app, I hope these patterns save you some debugging time.


Have you built a desktop app with Tauri? Hit any weird platform-specific gotchas? Let me know in the comments.

Top comments (0)