DEV Community

Kazutaka Sugiyama
Kazutaka Sugiyama

Posted on

Building a Cross-Platform File Search App With Tauri — Not Electron

Every knowledge worker I know has the same problem: files scattered across Google Drive, Dropbox, SharePoint, Slack, Notion, GitHub, and their local machine. When you need to find something, you end up opening 4 different search bars.

I built OmniFile to fix that — a single search bar that finds files across all your sources instantly. Desktop app, privacy-first, everything stays on your machine.

Here's what I learned building it with Tauri + Rust instead of Electron, and why integrating 7 OAuth providers in a desktop app was harder than I expected.

Why Tauri Over Electron

The decision was simple: OmniFile needs to launch instantly (it's triggered by a global shortcut) and stay lightweight in the background. Electron ships a full Chromium browser. Tauri uses the OS's native webview and a Rust backend.

The result:

  • ~8MB installer vs Electron's ~80MB+
  • ~30MB RAM at idle vs Electron's ~150MB+
  • Rust backend for CPU-intensive indexing and file I/O

The tradeoff is that you write your backend in Rust instead of JavaScript. For file search, that's actually a benefit — Rust's performance for walking directories and parsing file formats is hard to beat.

Full-Text Search with Tantivy

Tantivy is Rust's answer to Lucene. I use it as the local search engine that indexes everything into a single queryable index.

The Schema Design

schema_builder.add_text_field("title", TEXT | STORED);      // Tokenized + returned
schema_builder.add_text_field("path", STRING | STORED);      // Exact match
schema_builder.add_text_field("content", TEXT);              // Searchable but NOT stored
schema_builder.add_text_field("source", STRING | STORED);    // "local", "gdrive", etc.
schema_builder.add_i64_field("modified_at", INDEXED | STORED);
Enter fullscreen mode Exit fullscreen mode

The key decision: content is indexed but not stored. For a desktop search app, this saves significant disk space — the content is already on disk, so we re-extract it when needed for display. This keeps the index small while enabling full-text search.

Multi-Source Indexing

Each cloud provider indexes into the same Tantivy index but with a different source tag. When re-indexing Google Drive, I delete all documents where source = "gdrive" and re-add them — without touching Dropbox or local results:

let source_term = Term::from_field_text(source_field, "gdrive");
writer.delete_term(source_term);  // Clear only gdrive docs
// ... re-index gdrive files
writer.commit()?;
Enter fullscreen mode Exit fullscreen mode

This means each provider can index independently without affecting others.

File Format Extraction

OmniFile doesn't just search filenames — it extracts and indexes content from DOCX, XLSX, and text files. DOCX files are ZIP archives containing XML, so extraction means:

  1. Open the ZIP
  2. Find word/document.xml
  3. Parse XML, extract text from <w:t> tags

XLSX is trickier because Excel uses a shared strings table — cell values are stored as indices into a deduplicated string array. The extractor resolves these references at index time.

For text files, I try UTF-8 first, then fall back to Shift-JIS (common for Japanese files), then lossy UTF-8 as a last resort.

The OAuth Problem: 7 Providers, 7 Headaches

Desktop apps can't receive OAuth callbacks the way web apps do. There's no public URL to redirect to. My solution: spin up a temporary local HTTP server for each OAuth flow.

Each provider gets its own port:

Google Drive  → localhost:14200
Dropbox       → localhost:14201
Box           → localhost:14202
SharePoint    → localhost:14203
Slack         → localhost:14204 (HTTPS!)
Notion        → localhost:14205
GitHub        → localhost:14206
Enter fullscreen mode Exit fullscreen mode

The flow: open the browser → user logs in → provider redirects to localhost:PORT/callback?code=XXX → local server catches it → exchange code for token.

All providers use PKCE (Proof Key for Code Exchange) to prevent authorization code interception, which matters more for desktop apps since the redirect happens on localhost.

The Slack HTTPS Problem

Six of the seven providers accept http://localhost redirects. Slack doesn't. It requires HTTPS, even for localhost.

My solution: generate a self-signed TLS certificate on-the-fly using rcgen, then serve the callback over HTTPS:

let subject_alt_names = vec!["localhost".to_string(), "127.0.0.1".to_string()];
let CertifiedKey { cert, key_pair } = generate_simple_self_signed(subject_alt_names)?;

let config = rustls::ServerConfig::builder()
    .with_no_client_auth()
    .with_single_cert(vec![cert_der], key_der)?;

let tls_acceptor = TlsAcceptor::from(Arc::new(config));
Enter fullscreen mode Exit fullscreen mode

When the browser hits https://localhost:14204/callback, it shows a certificate warning. The user clicks through, and the callback completes. Not the most elegant UX, but it works — and the TLS handshake failure from the initial browser check is handled gracefully with a retry loop:

let mut tls_stream = match tls_acceptor.accept(stream).await {
    Ok(stream) => stream,
    Err(_) => continue,  // Browser cert warning — wait for retry
};
Enter fullscreen mode Exit fullscreen mode

A critical detail: the TCP listener must be bound before opening the browser to prevent a race condition where the redirect arrives before the server is ready.

GitHub: Why the Search API Wasn't Enough

GitHub's Search API seems like the obvious choice for file search. But it has a frustrating limitation: not all files are indexed. Repositories need to meet certain criteria, and even then the index can be stale.

Instead, I use the Trees API — fetch the entire file tree for each repository in a single recursive call:

GET /repos/{owner}/{repo}/git/trees/{branch}?recursive=1
Enter fullscreen mode Exit fullscreen mode

This returns every file path in the repository. I cache the results for 5 minutes and do case-insensitive matching client-side. To avoid hammering GitHub's rate limits, tree fetches are batched 10 repos at a time using futures::future::join_all.

The search results are ranked by relevance:

  1. Exact filename match (highest)
  2. Partial filename match
  3. Shorter path depth (higher-level files = more relevant)

If the cache refresh fails (network issues, rate limiting), the system gracefully degrades to the stale cache instead of returning an error. For a search feature, showing slightly outdated results is better than showing nothing.

Global Shortcut with Debouncing

OmniFile is designed to pop up when you hit a keyboard shortcut (like Spotlight or Alfred). Tauri's global_shortcut plugin handles this, but I needed a few extras:

Debouncing: Without it, holding the shortcut key triggers multiple show/hide cycles. A 300ms threshold prevents this.

Rollback on failure: If registering a new shortcut fails (e.g., it's already claimed by another app), the system automatically re-registers the old shortcut. The user never loses their ability to summon the app.

Three-level persistence: The shortcut is stored in memory (fast access), persisted to a settings file (survives restart), and reflected in the tray menu label (user visibility).

iCloud: The Clever Non-Integration

Apple doesn't provide a public API for iCloud Drive search. My solution was embarrassingly simple: detect the iCloud Drive folder at ~/Library/Mobile Documents/com~apple~CloudDocs and treat it as a local directory.

The file watcher picks up changes, Tantivy indexes the contents, and users get iCloud search without any OAuth flow or API integration. It just works — as long as iCloud Drive syncs files to disk (which it does by default on macOS).

Things I'd Do Differently

1. Unify the OAuth config structs. I have 7 separate *OAuthConfig structs that are 90% identical. A trait-based approach would reduce duplication.

2. Use Tantivy's query parser for scoring. My current search iterates all documents with substring matching. It works for desktop-scale data, but Tantivy's built-in BM25 scoring would be more sophisticated and faster for large indexes.

3. Plan for token refresh from day one. Some providers (Slack, Notion, GitHub) give non-expiring tokens. Others (Google, Microsoft) require refresh token flows. This divergence created special cases throughout the codebase.

The Stack

  • Framework: Tauri 2 (Rust backend + native webview)
  • Frontend: React 19 + TypeScript + Tailwind CSS
  • Search Engine: Tantivy (Rust, full-text search)
  • OAuth: oauth2 crate + PKCE
  • TLS: rustls + rcgen (for Slack)
  • File Watching: notify crate with debouncer
  • File Parsing: quick-xml (DOCX/XLSX), encoding_rs (Shift-JIS)

Try It

If you're tired of searching 5 different places for one file: omnifile.app

Free tier covers local search. Pro ($129 lifetime) unlocks all cloud integrations.

I'd love to hear from the dev.to community:

  • Which cloud integrations matter most to you?
  • Would you use a CLI version alongside the GUI?
  • Any clever search UX ideas?

Drop a comment or find me on GitHub.


Built as a solo project with Tauri + Rust. The entire app idles at ~30MB RAM. Every search query stays on your machine — no server, no telemetry.

Top comments (0)