File System Access
File system access patterns, native dialogs, and drag-and-drop for desktop applications
You are an expert in file system operations for desktop applications, including native file dialogs, reading/writing files, watching for changes, and drag-and-drop integration.
## Key Points
- Use atomic writes (write to temp file, then rename) to prevent data corruption on crash or power loss.
- Always use native file dialogs instead of building custom HTML-based file pickers. They respect OS settings and permissions.
- Debounce file watchers — file systems can emit multiple events for a single save operation.
- Store application data in the correct OS-specific directory (`userData` / `appDataDir`), not alongside the application binary.
- Validate file paths received from the renderer. Never blindly trust user-supplied paths.
- Handle large files with streams rather than reading entire contents into memory.
- Remember recent files and offer them in the File menu or a start screen for quick access.
- **Path separators**: Use `path.join()` or Tauri's path APIs instead of string concatenation. Hardcoded `/` or `\` breaks cross-platform.
- **File locking on Windows**: Windows locks files more aggressively than macOS/Linux. Handle `EBUSY` errors and retry.
- **Encoding issues**: Not all text files are UTF-8. Check for BOM markers and handle encoding detection.
- **Permissions on macOS**: macOS sandboxing and privacy controls may block access to directories outside the sandbox. Use bookmarks for persistent access.
- **Relative paths**: Always resolve to absolute paths. Relative paths resolve against the working directory, which varies depending on how the app is launched.
## Quick Example
```javascript
const { dialog, app, BrowserWindow } = require('electron');
const fs = require('node:fs');
const fsp = require('node:fs/promises');
const path = require('node:path');
```
```toml
# src-tauri/Cargo.toml
[dependencies]
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
```skilldb get desktop-app-skills/File System AccessFull skill: 414 linesFile System Access & Dialogs — Desktop Apps
You are an expert in file system operations for desktop applications, including native file dialogs, reading/writing files, watching for changes, and drag-and-drop integration.
Overview
Desktop applications have direct access to the local file system, a key advantage over web apps. This includes opening and saving files via native OS dialogs, reading/writing arbitrary paths, watching directories for changes, and supporting drag-and-drop from the OS file manager. Both Electron and Tauri provide these capabilities through their respective APIs, with security controls to limit access scope.
Setup & Configuration
Electron modules
const { dialog, app, BrowserWindow } = require('electron');
const fs = require('node:fs');
const fsp = require('node:fs/promises');
const path = require('node:path');
Tauri plugins
# src-tauri/Cargo.toml
[dependencies]
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
Tauri permissions (capabilities/default.json)
{
"identifier": "default",
"windows": ["main"],
"permissions": [
"dialog:allow-open",
"dialog:allow-save",
"fs:allow-read",
"fs:allow-write",
{
"identifier": "fs:scope",
"allow": [
{ "path": "$DOCUMENT/**" },
{ "path": "$APPDATA/**" }
]
}
]
}
Core Patterns
Electron: Open file dialog
const { ipcMain, dialog } = require('electron');
ipcMain.handle('file:open', async (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
const result = await dialog.showOpenDialog(win, {
title: 'Open File',
defaultPath: app.getPath('documents'),
filters: [
{ name: 'Text Files', extensions: ['txt', 'md', 'json'] },
{ name: 'Images', extensions: ['png', 'jpg', 'gif', 'svg'] },
{ name: 'All Files', extensions: ['*'] },
],
properties: [
'openFile', // allow file selection
// 'openDirectory', // allow directory selection
// 'multiSelections', // allow multiple selections
],
});
if (result.canceled || result.filePaths.length === 0) {
return null;
}
const filePath = result.filePaths[0];
const content = await fsp.readFile(filePath, 'utf-8');
return { filePath, content };
});
Electron: Save file dialog
ipcMain.handle('file:save', async (event, content, currentPath) => {
const win = BrowserWindow.fromWebContents(event.sender);
let filePath = currentPath;
if (!filePath) {
const result = await dialog.showSaveDialog(win, {
title: 'Save File',
defaultPath: path.join(app.getPath('documents'), 'untitled.txt'),
filters: [
{ name: 'Text Files', extensions: ['txt'] },
{ name: 'Markdown', extensions: ['md'] },
{ name: 'All Files', extensions: ['*'] },
],
});
if (result.canceled) return null;
filePath = result.filePath;
}
await fsp.writeFile(filePath, content, 'utf-8');
return filePath;
});
Electron: Directory selection
ipcMain.handle('file:select-directory', async (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
const result = await dialog.showOpenDialog(win, {
title: 'Select Folder',
properties: ['openDirectory', 'createDirectory'],
});
if (result.canceled) return null;
return result.filePaths[0];
});
Electron: Reading and writing files safely
const fsp = require('node:fs/promises');
const path = require('node:path');
// Read with encoding detection
async function readFile(filePath) {
const buffer = await fsp.readFile(filePath);
// Check for BOM to detect encoding
if (buffer[0] === 0xef && buffer[1] === 0xbb && buffer[2] === 0xbf) {
return buffer.toString('utf-8').slice(1); // strip BOM
}
return buffer.toString('utf-8');
}
// Atomic write: write to temp file then rename
async function writeFileAtomic(filePath, content) {
const dir = path.dirname(filePath);
const tempPath = path.join(dir, `.${path.basename(filePath)}.tmp`);
try {
await fsp.writeFile(tempPath, content, 'utf-8');
await fsp.rename(tempPath, filePath);
} catch (err) {
// Clean up temp file on failure
try { await fsp.unlink(tempPath); } catch {}
throw err;
}
}
// Read JSON with validation
async function readJSON(filePath) {
const content = await fsp.readFile(filePath, 'utf-8');
return JSON.parse(content);
}
// Write JSON with pretty printing
async function writeJSON(filePath, data) {
const content = JSON.stringify(data, null, 2) + '\n';
await writeFileAtomic(filePath, content);
}
Electron: File watching
const fs = require('node:fs');
const path = require('node:path');
class FileWatcher {
constructor() {
this.watchers = new Map();
}
watch(filePath, callback) {
if (this.watchers.has(filePath)) return;
let debounceTimer = null;
const watcher = fs.watch(filePath, (eventType) => {
// Debounce rapid changes
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
callback(eventType, filePath);
}, 100);
});
watcher.on('error', (err) => {
console.error(`Watch error on ${filePath}:`, err);
this.unwatch(filePath);
});
this.watchers.set(filePath, watcher);
}
watchDirectory(dirPath, callback) {
const watcher = fs.watch(dirPath, { recursive: true }, (eventType, filename) => {
if (filename) {
callback(eventType, path.join(dirPath, filename));
}
});
this.watchers.set(dirPath, watcher);
}
unwatch(filePath) {
const watcher = this.watchers.get(filePath);
if (watcher) {
watcher.close();
this.watchers.delete(filePath);
}
}
unwatchAll() {
for (const [, watcher] of this.watchers) {
watcher.close();
}
this.watchers.clear();
}
}
Electron: Drag and drop from OS
// preload.js — expose drag-drop handler
contextBridge.exposeInMainWorld('api', {
onFileDrop: (callback) => {
// This is handled in the renderer
return callback;
},
});
// renderer.js — handle file drops
const dropZone = document.getElementById('drop-zone');
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
});
dropZone.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation();
dropZone.classList.remove('drag-over');
const files = Array.from(e.dataTransfer.files);
for (const file of files) {
// file.path is available in Electron (not standard web)
console.log('Dropped file:', file.path);
const content = await window.api.invoke('file:read', file.path);
handleFileContent(file.path, content);
}
});
Tauri: File dialogs and reading
import { open, save } from '@tauri-apps/plugin-dialog';
import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs';
// Open dialog
async function openFile() {
const filePath = await open({
title: 'Open File',
multiple: false,
directory: false,
filters: [
{ name: 'Text', extensions: ['txt', 'md'] },
{ name: 'All', extensions: ['*'] },
],
});
if (filePath) {
const content = await readTextFile(filePath);
return { filePath, content };
}
return null;
}
// Save dialog
async function saveFile(content, currentPath) {
const filePath = currentPath ?? await save({
title: 'Save File',
filters: [{ name: 'Text', extensions: ['txt'] }],
});
if (filePath) {
await writeTextFile(filePath, content);
return filePath;
}
return null;
}
Tauri: Binary file operations
import { readFile, writeFile } from '@tauri-apps/plugin-fs';
// Read binary
const bytes = await readFile('/path/to/image.png');
const blob = new Blob([bytes]);
const url = URL.createObjectURL(blob);
// Write binary
const response = await fetch('https://example.com/image.png');
const arrayBuffer = await response.arrayBuffer();
await writeFile('/path/to/output.png', new Uint8Array(arrayBuffer));
App data directories
// Electron
const appDataPath = app.getPath('userData'); // Per-user app data
const documentsPath = app.getPath('documents'); // User's Documents folder
const tempPath = app.getPath('temp'); // Temp directory
const homePath = app.getPath('home'); // Home directory
// Common pattern: store config in userData
const configPath = path.join(app.getPath('userData'), 'config.json');
// Tauri
import {
appDataDir,
appConfigDir,
documentDir,
tempDir,
homeDir,
} from '@tauri-apps/api/path';
const appData = await appDataDir();
const config = await appConfigDir();
const docs = await documentDir();
Best Practices
- Use atomic writes (write to temp file, then rename) to prevent data corruption on crash or power loss.
- Always use native file dialogs instead of building custom HTML-based file pickers. They respect OS settings and permissions.
- Debounce file watchers — file systems can emit multiple events for a single save operation.
- Store application data in the correct OS-specific directory (
userData/appDataDir), not alongside the application binary. - Validate file paths received from the renderer. Never blindly trust user-supplied paths.
- Handle large files with streams rather than reading entire contents into memory.
- Remember recent files and offer them in the File menu or a start screen for quick access.
Common Pitfalls
- Path separators: Use
path.join()or Tauri's path APIs instead of string concatenation. Hardcoded/or\breaks cross-platform. - File locking on Windows: Windows locks files more aggressively than macOS/Linux. Handle
EBUSYerrors and retry. - Encoding issues: Not all text files are UTF-8. Check for BOM markers and handle encoding detection.
- Permissions on macOS: macOS sandboxing and privacy controls may block access to directories outside the sandbox. Use bookmarks for persistent access.
- Relative paths: Always resolve to absolute paths. Relative paths resolve against the working directory, which varies depending on how the app is launched.
- File watcher exhaustion: Each watched file uses an OS file descriptor. Watch directories recursively instead of individual files when monitoring many paths.
- Tauri scope restrictions: Forgetting to declare file system scope in
capabilities/causes runtime permission errors.
Core Philosophy
File system access is the defining capability that separates desktop apps from web apps, and it must be treated with proportional care. Every file operation should be atomic where possible, defensive against corruption, and respectful of the user's data. Writing directly to a file risks data loss on crash or power failure; writing to a temp file and renaming is the minimum viable strategy for protecting user data.
Use native file dialogs religiously. They respect OS accessibility settings, remember recent directories, integrate with cloud storage providers, and handle permission prompts automatically. Building a custom HTML-based file picker saves development time at the cost of user experience and platform integration.
Store application data in the right places. User documents go in the Documents folder. Application configuration goes in the platform-specific app data directory (userData in Electron, appDataDir in Tauri). Temporary files go in the temp directory. Placing files alongside the application binary, in the home directory root, or in arbitrary locations is a platform integration failure that breaks expectations on every operating system.
Anti-Patterns
-
Writing directly to the target file — overwriting a file in place without an atomic write strategy (temp file + rename) risks data corruption if the app crashes or power is lost mid-write.
-
Hardcoding path separators — using
\\or/in string concatenation instead ofpath.join()or Tauri's path APIs produces paths that work on one platform and break on others. -
Reading entire large files into memory — loading a 500MB file with
readFileexhausts memory; use streams for large files to process data incrementally. -
Not validating renderer-supplied paths — accepting a file path from the renderer without checking it against an allowed directory enables path traversal attacks that can read or write arbitrary system files.
-
Ignoring platform-specific file locking — Windows locks files more aggressively than Unix systems; not handling
EBUSYerrors or retrying on lock contention causes failures that only appear on Windows.
Install this skill directly: skilldb add desktop-app-skills
Related Skills
Auto Update
Auto-update mechanisms for desktop applications using electron-updater and Tauri's built-in updater
Electron Ipc
IPC communication patterns for Electron main and renderer process messaging
Electron
Electron fundamentals for building cross-platform desktop applications with web technologies
Native Menus
System tray icons, native application menus, and desktop notifications for Electron and Tauri apps
Packaging
Building, packaging, and distributing desktop applications for Windows, macOS, and Linux
Security
Desktop application security including context isolation, preload scripts, CSP, and sandboxing for Electron and Tauri