How to Build Custom Tauri Commands for Native Filesystem Access in GeoLibre
To build custom Tauri commands for native filesystem access in GeoLibre, create async Rust functions annotated with #[tauri::command] that verify paths using app.fs_scope().is_allowed(&path) before calling standard std::fs APIs, then invoke them from the frontend using invoke().
GeoLibre's desktop application leverages Tauri v2 to bridge Rust's native performance with a modern web frontend. When you need to read, write, or manipulate files beyond the browser's sandbox, you must build custom Tauri commands for native filesystem access that respect the security boundaries defined in the app's configuration.
Understanding Tauri Commands in GeoLibre
GeoLibre's desktop code lives under apps/geolibre-desktop/src-tauri. Native functionality is exposed to the JavaScript frontend through Tauri commands—async Rust functions annotated with #[tauri::command].
According to the GeoLibre source code, any function carrying this attribute is automatically exposed to the frontend when the Tauri app is built. Commands typically accept serializable parameters and return Result<T, String> to handle both success and error cases gracefully.
The project already includes the official tauri-plugin-fs (see the tauri_plugin_fs::FsExt import in src/lib.rs), which provides the security scaffolding for all filesystem operations.
Security Model and Scope Verification
Before performing any filesystem operation, custom commands must verify that the user has explicitly granted access to the target path. The fs plugin restricts access to only paths within the allowed scope.
In apps/geolibre-desktop/src-tauri/src/lib.rs (lines 485‑487), the codebase demonstrates this pattern by calling app.fs_scope().is_allowed(&path) before any std::fs operation. This check ensures your command respects the sandbox imposed by the Tauri security model.
Never bypass this verification. Calling std::fs methods without checking fs_scope() will compile but may violate security policies and cause runtime errors in production builds.
Implementing Custom Filesystem Commands
When adding a new command, follow this pattern: declare the function in a Rust module (e.g., src/lib.rs or a dedicated file), annotate it with #[tauri::command], make it async if performing I/O, and return a JSON-serializable type.
Reading Text Files
This example demonstrates the fundamental pattern for reading files safely:
#[tauri::command]
pub async fn read_text_file(path: String, app: tauri::AppHandle) -> Result<String, String> {
// Ensure the user granted access to this path
if !app.fs_scope().is_allowed(&path) {
return Err("Path not allowed by fs scope".into());
}
std::fs::read_to_string(&path).map_err(|e| format!("Failed to read file: {e}"))
}
The frontend invokes this via invoke('read_text_file', { path }).
Handling Binary Data
For binary files, convert the bytes to a transferable format like base64:
#[tauri::command]
pub async fn read_binary_file(path: String, app: tauri::AppHandle) -> Result<String, String> {
if !app.fs_scope().is_allowed(&path) {
return Err("Access denied".into());
}
let bytes = std::fs::read(&path).map_err(|e| format!("IO error: {e}"))?;
Ok(base64::encode(&bytes))
}
Writing Structured Data
When writing JSON data, serialize the value before writing:
#[tauri::command]
pub async fn write_json_file(path: String, json: serde_json::Value, app: tauri::AppHandle) -> Result<(), String> {
if !app.fs_scope().is_allowed(&path) {
return Err("Access denied".into());
}
let text = serde_json::to_string_pretty(&json).map_err(|e| format!("Serialize error: {e}"))?;
std::fs::write(&path, text).map_err(|e| format!("Write error: {e}"))
}
Listing Directory Contents
To list directory entries while filtering for valid Unicode names:
#[tauri::command]
pub async fn list_dir(path: String, app: tauri::AppHandle) -> Result<Vec<String>, String> {
if !app.fs_scope().is_allowed(&path) {
return Err("Access denied".into());
}
let entries = std::fs::read_dir(&path)
.map_err(|e| format!("Read dir error: {e}"))?;
let mut names = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| format!("Dir entry error: {e}"))?;
if let Some(name) = entry.file_name().to_str() {
names.push(name.to_string());
}
}
Ok(names)
}
Error Handling and Async Patterns
Async execution is essential for filesystem commands. Mark your functions as async to prevent blocking the Tauri runtime. Tauri automatically offloads this work to a background thread pool.
Error handling should convert std::io::Error into user-friendly String messages. This ensures the JavaScript side receives clear, actionable error descriptions rather than raw Rust debug output.
Cross-platform paths require using PathBuf and std::path::Path utilities instead of hardcoded separators. This guarantees consistent behavior across Windows, macOS, and Linux.
Key Source Files in the Repository
-
apps/geolibre-desktop/src-tauri/src/lib.rs— Central location where most custom Tauri commands are defined and where thefsplugin is initialized (see lines 43‑64). Also contains the scope verification pattern (lines 485‑487). -
apps/geolibre-desktop/src-tauri/src/native_duckdb.rs— Example module demonstrating conditional command exposure for vector file operations (count_native_vector_file_features,load_native_vector_file). -
apps/geolibre-desktop/src-tauri/src/earth_engine_oauth.rs— Reference implementation showing async commands that interact with external services; useful for modeling long-running tasks. -
apps/geolibre-desktop/src-tauri/src/main.rs— Application entry point that registers plugins (includingtauri_plugin_fs) and launches the UI.
Summary
- Scope verification is mandatory—always call
app.fs_scope().is_allowed(&path)before anystd::fsoperation to respect Tauri's security sandbox. - Command structure requires the
#[tauri::command]attribute,asyncfunctions for I/O, andResult<T, String>return types. - Error handling converts Rust errors into descriptive strings for the frontend.
- Registration happens automatically at compile time for any function with the
#[tauri::command]attribute. - Key files for reference include
src/lib.rsfor command definitions andsrc/main.rsfor plugin initialization.
Frequently Asked Questions
Do I need to manually register custom commands in the Tauri builder?
No. As implemented in opengeos/GeoLibre, any function annotated with #[tauri::command] is automatically exposed to the JavaScript frontend when the Tauri application is built. You do not need to explicitly register each command in main.rs or the builder pattern.
How do I handle large file reads without blocking the UI?
Mark your command as async and use std::fs operations directly within the async function. Tauri will offload the blocking I/O to a background thread pool automatically. For extremely large files, consider wrapping the operation in tauri::async_runtime::spawn_blocking and streaming the data in chunks.
Why does my command fail with "Path not allowed" even though the file exists?
The tauri-plugin-fs enforces a strict sandbox. You must verify the path using app.fs_scope().is_allowed(&path) before accessing it. Additionally, ensure the path has been explicitly granted access through the Tauri configuration or dialog permissions, as the plugin restricts access to only user-approved directories.
Can I use the official fs plugin instead of writing custom commands?
Yes, but custom commands provide greater flexibility. The official tauri-plugin-fs offers standard operations, while custom commands allow you to implement complex logic—such as base64 encoding, JSON serialization, or directory filtering—while maintaining the same security model. GeoLibre uses both approaches: the plugin for basic operations and custom commands for specialized geospatial file handling.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →