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.
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}
/>
);
}
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;
}
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,
});
}
}
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));
}
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
];
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;
}
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
}
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
}
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));
}
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)?;
// ...
}
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(())
}
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(())
}
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() };
}
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"
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
}
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"
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)
}
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();
}
}))
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.
- Website: https://max.nardit.com/beetroot
- GitHub: mnardit/beetroot-releases
Have you built a desktop app with Tauri? Hit any weird platform-specific gotchas? Let me know in the comments.

Top comments (0)