| name | pdfstudio-core-tauri-pdf-filesystem |
| description | Use when modifying file open/save operations in open-pdf-studio. Prevents the common mistake of using the wrong IPC mechanism (Tauri plugin vs custom invoke) or breaking the file locking system that prevents data loss. Covers dual IPC patterns, Uint8Array transfer, file locking via Rust File handles, and the complete file open/edit/save pipeline through Tauri. Keywords: Tauri, filesystem, invoke, readBinaryFile, writeBinaryFile, file lock, Uint8Array, IPC, Rust commands, save pipeline, file open fails, save not working, file locked, Tauri file operations.
|
| license | MIT |
| compatibility | Designed for Claude Code. Specific to open-pdf-studio (Tauri 2.10). |
| metadata | {"author":"OpenAEC-Foundation","version":"1.0"} |
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)
export async function readBinaryFile(path) {
return await window.__TAURI__.fs.readFile(path);
}
export async function writeBinaryFile(path, contents) {
await window.__TAURI__.fs.writeFile(path, contents);
}
ALWAYS use the FS plugin for PDF byte transfer. It transfers Uint8Array directly without serialization overhead.
Custom Invoke (Non-Binary Operations)
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:
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_file BEFORE writeFile — writing to a locked file fails silently on Windows
- ALWAYS call
lock_file AFTER writeFile — 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
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)
.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.
const bytes = await readBinaryFile(path);
pdfjsLib.getDocument({ data: bytes });
const bytes = await readBinaryFile(path);
originalBytesCache.set(path, bytes.slice());
pdfjsLib.getDocument({ data: bytes });
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 |