Tauri PDF Filesystem: IPC, File Locking & Binary Transfer
Purpose
This skill documents how open-pdf-studio transfers PDF binary data between the Rust backend and the WebView frontend, how file locking prevents data loss, and when to use Tauri plugins vs custom invoke commands. EVERY file operation in the app passes through this boundary.
Architecture Overview
User Action (open/save)
│
▼
js/core/platform.js ◄── Unified API wrapper
│
├── Tauri FS Plugin ◄── window.__TAURI__.fs.readFile / writeFile
│ (Uint8Array, fast) Direct binary transfer, preferred path
│
└── Custom Invoke ◄── window.__TAURI__.core.invoke('cmd', args)
(JSON serialized) Used for lock_file, unlock_file, print, etc.
│
▼
src-tauri/src/lib.rs ◄── 28 Rust #[tauri::command] handlers
Dual IPC Pattern
Rule: ALWAYS Use the Correct IPC Mechanism
open-pdf-studio uses TWO distinct IPC mechanisms. Using the wrong one causes silent failures or performance degradation.
| Mechanism | When to Use | Data Format | Performance |
|---|---|---|---|
Tauri FS plugin (__TAURI__.fs) |
Reading/writing file bytes | Direct Uint8Array |
Fast (zero-copy transfer) |
Custom invoke (__TAURI__.core.invoke) |
All other Rust commands | JSON-serialized arguments | Varies |
Tauri FS Plugin (Binary Operations)
// js/core/platform.js — preferred path for file I/O
export async function readBinaryFile(path) {
return await window.__TAURI__.fs.readFile(path); // Returns Uint8Array
}
export async function writeBinaryFile(path, contents) {
await window.__TAURI__.fs.writeFile(path, contents); // Accepts Uint8Array
}
ALWAYS use the FS plugin for PDF byte transfer. It transfers Uint8Array directly without serialization overhead.
Custom Invoke (Non-Binary Operations)
// js/core/platform.js — for commands not covered by plugins
export async function invoke(cmd, args = {}) {
return await window.__TAURI__.core.invoke(cmd, args);
}
Used for: lock_file, unlock_file, save_session, load_session, get_printers, print_pdf, write_temp_pdf, and 20+ other commands.
Binary Fallback via Invoke
When the FS plugin is unavailable (rare), the fallback encodes PDF bytes as base64:
// invoke('write_file', { path, data: base64String })
// This is SLOWER — avoid unless FS plugin is genuinely unavailable
For write_temp_pdf (printing workflow), bytes are transferred as Array.from(pdfBytes) (a JSON number array). This is intentional — the temp file path is generated server-side.
File Locking System
Rule: ALWAYS Follow the Lock/Unlock Protocol
The app uses Rust File handles with Windows shared-read-only access to prevent concurrent writes.
Lock Lifecycle
OPEN: invoke('lock_file', { path })
→ Rust opens File with FILE_SHARE_READ (others can read, nobody else can write)
→ File handle stored in global HashMap<String, File>
SAVE: invoke('unlock_file', { path }) // Release lock
→ writeFile(path, newBytes) // Write new content
→ invoke('lock_file', { path }) // Re-acquire lock
CLOSE: invoke('unlock_file', { path })
→ File handle dropped, lock released
Lock Rules
- ALWAYS call
unlock_fileBEFOREwriteFile— writing to a locked file fails silently on Windows - ALWAYS call
lock_fileAFTERwriteFile— re-lock protects against external modifications - NEVER leave a file locked when closing a document — this blocks other applications
- The lock is per-process. Other processes CAN read the file but CANNOT write to it
Rust Lock Implementation
// src-tauri/src/lib.rs (simplified)
static FILE_LOCKS: Lazy<Mutex<HashMap<String, File>>> = ...;
#[tauri::command]
fn lock_file(path: String) -> Result<(), String> {
let file = OpenOptions::new()
.read(true)
.share_mode(FILE_SHARE_READ) // Windows: shared read, exclusive write
.open(&path)?;
FILE_LOCKS.lock().unwrap().insert(path, file);
Ok(())
}
Complete File Pipeline
Open Pipeline
1. User picks file via dialog plugin or CLI argument
→ filePath: string
2. readBinaryFile(filePath) [Tauri FS plugin]
→ typedArray: Uint8Array
3. originalBytesCache.set(filePath, typedArray.slice())
→ CRITICAL: .slice() creates a copy because PDF.js transfers
the ArrayBuffer to its web worker, detaching the original
4. invoke('lock_file', { path: filePath }) [Custom invoke]
→ Rust holds File handle with shared-read lock
5. pdfjsLib.getDocument({ data: typedArray }) [PDF.js]
→ doc.pdfDoc (rendering, text, annotations)
6. PDFDocument.load(cachedBytes) [pdf-lib, background]
→ doc._sharedPdfLibDoc (color extraction)
Save Pipeline
1. Retrieve original bytes
→ originalBytesCache.get(filePath): Uint8Array
2. PDFDocument.load(originalBytes) [pdf-lib]
→ Fresh pdf-lib document from original bytes
3. Apply modifications
→ Strip handled annotations from /Annots arrays
→ Convert app annotations to PDF annotation dicts
→ Persist form field values
→ Apply page rotations
4. pdfDocLib.save()
→ newBytes: Uint8Array
5. invoke('unlock_file', { path: filePath }) [Custom invoke]
→ Release lock
6. writeBinaryFile(filePath, newBytes) [Tauri FS plugin]
→ Write to disk
7. invoke('lock_file', { path: filePath }) [Custom invoke]
→ Re-acquire lock with new content
8. originalBytesCache.set(filePath, newBytes)
→ Update cache for next save
The 28 Rust Commands
| Category | Commands | IPC Type |
|---|---|---|
| File I/O | read_file, write_file, file_exists, delete_file, rename_file |
Custom invoke |
| File Locking | lock_file, unlock_file |
Custom invoke |
| Session | save_session, load_session |
Custom invoke |
| Preferences | save_preferences, load_preferences |
Custom invoke |
| Printing | get_printers, print_pdf, open_printer_properties, write_temp_pdf, get_temp_dir |
Custom invoke |
| Virtual Printer | install_virtual_printer, remove_virtual_printer, is_virtual_printer_installed |
Custom invoke |
| Plugins | list_plugins, install_plugin, uninstall_plugin, read_plugin_file |
Custom invoke |
| System | get_username, is_dev_mode, is_default_pdf_app, open_default_apps_settings, open_url, download_pdf_from_url, list_pdf_files, play_alert_sound, get_opened_file |
Custom invoke |
NEVER add new file I/O commands to lib.rs when the Tauri FS plugin already handles the operation. Custom commands exist only for functionality the plugin does not provide (locking, session, printing, etc.).
Binary Data Transfer Formats
| Operation | Method | Format | Notes |
|---|---|---|---|
| Read PDF | __TAURI__.fs.readFile() |
Uint8Array |
Zero-copy, fast |
| Write PDF | __TAURI__.fs.writeFile() |
Uint8Array |
Zero-copy, fast |
| Write (fallback) | invoke('write_file') |
Base64 string | Slow, avoid |
| Print temp | invoke('write_temp_pdf') |
Array.from(bytes) |
JSON number array |
Web Fallback Mode
platform.js provides browser fallbacks when Tauri is not available:
| Operation | Desktop (Tauri) | Web (Browser) |
|---|---|---|
| Read file | FS plugin → Uint8Array |
<input type="file"> → FileReader |
| Write file | FS plugin → disk | <a download> → browser download |
| File lock | Rust File handle |
No-op (not supported) |
| Session | Rust file I/O | localStorage |
NEVER assume Tauri APIs are available without checking. platform.js ALWAYS provides the correct abstraction.
Buffer Detachment Warning
PDF.js transfers the ArrayBuffer to its web worker using the Structured Clone algorithm with transfer. After transfer, the original Uint8Array becomes zero-length.
// WRONG — bytes will be detached after getDocument()
const bytes = await readBinaryFile(path);
pdfjsLib.getDocument({ data: bytes });
// bytes.length === 0 here — save will fail!
// CORRECT — clone before passing to PDF.js
const bytes = await readBinaryFile(path);
originalBytesCache.set(path, bytes.slice()); // Clone for later use
pdfjsLib.getDocument({ data: bytes }); // PDF.js takes ownership
ALWAYS clone Uint8Array before passing to pdfjsLib.getDocument(). Failure to do so causes silent data loss on save.
Key Files
| File | Lines | Purpose |
|---|---|---|
src-tauri/src/lib.rs |
~903 | All 28 Rust commands, Tauri builder setup |
js/core/platform.js |
~508 | Unified Tauri API wrapper with web fallbacks |
js/pdf/loader.js |
— | PDF loading orchestrator, calls readBinaryFile + lock_file |
js/pdf/saver.js |
— | Save orchestrator, calls unlock → write → lock sequence |
src-tauri/Cargo.toml |
— | Rust dependencies including 8 Tauri plugins |
src-tauri/tauri.conf.json |
— | Plugin permissions and window configuration |