Skip to main content
Technology & EngineeringDesktop App414 lines

File System Access

File system access patterns, native dialogs, and drag-and-drop for desktop applications

Quick Summary34 lines
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 lines
Paste into your CLAUDE.md or agent config

File 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 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.
  • 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 of path.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 readFile exhausts 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 EBUSY errors or retrying on lock contention causes failures that only appear on Windows.

Install this skill directly: skilldb add desktop-app-skills

Get CLI access →