Реализация доступа к файловой системе в десктоп-приложении
Работа с файловой системой — одно из главных преимуществ десктоп-приложения перед веб-версией. Electron даёт полный доступ через Node.js API; Tauri — через Rust-команды с явными разрешениями. Разберём оба варианта.
Electron: прямой доступ через Node.js
Весь файловый ввод-вывод выполняется в main process. Renderer запрашивает операции через IPC.
// main/fs-service.js
const fs = require('fs/promises');
const fsSync = require('fs');
const path = require('path');
const { app, dialog, BrowserWindow } = require('electron');
class FileSystemService {
// Диалог открытия файла
async openFileDialog(win, options = {}) {
const result = await dialog.showOpenDialog(win, {
properties: ['openFile'],
filters: options.filters ?? [{ name: 'All Files', extensions: ['*'] }],
...options
});
if (result.canceled || result.filePaths.length === 0) return null;
return this.readFile(result.filePaths[0]);
}
// Диалог открытия папки
async openFolderDialog(win) {
const result = await dialog.showOpenDialog(win, {
properties: ['openDirectory']
});
if (result.canceled) return null;
return result.filePaths[0];
}
// Чтение файла
async readFile(filePath) {
const stat = await fs.stat(filePath);
// Предупреждение при больших файлах
if (stat.size > 50 * 1024 * 1024) {
throw new Error(`File too large: ${(stat.size / 1024 / 1024).toFixed(1)} MB`);
}
const content = await fs.readFile(filePath, 'utf-8');
return {
path: filePath,
name: path.basename(filePath),
ext: path.extname(filePath).slice(1),
content,
size: stat.size,
modified: stat.mtimeMs
};
}
// Запись файла с диалогом если путь не указан
async saveFile(win, content, currentPath = null) {
let savePath = currentPath;
if (!savePath) {
const result = await dialog.showSaveDialog(win, {
defaultPath: path.join(app.getPath('documents'), 'untitled.txt')
});
if (result.canceled) return null;
savePath = result.filePath;
}
await fs.writeFile(savePath, content, 'utf-8');
return savePath;
}
// Список файлов в директории
async listDirectory(dirPath, options = {}) {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
const items = await Promise.all(
entries
.filter(e => options.showHidden || !e.name.startsWith('.'))
.map(async (entry) => {
const fullPath = path.join(dirPath, entry.name);
let stat;
try { stat = await fs.stat(fullPath); }
catch { return null; }
return {
name: entry.name,
path: fullPath,
isDirectory: entry.isDirectory(),
size: entry.isFile() ? stat.size : 0,
modified: stat.mtimeMs,
ext: entry.isFile() ? path.extname(entry.name).slice(1) : null
};
})
);
return items
.filter(Boolean)
.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
return a.name.localeCompare(b.name);
});
}
// Рекурсивное копирование
async copyDirectory(src, dest) {
await fs.mkdir(dest, { recursive: true });
const entries = await fs.readdir(src, { withFileTypes: true });
await Promise.all(entries.map(entry => {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
return entry.isDirectory()
? this.copyDirectory(srcPath, destPath)
: fs.copyFile(srcPath, destPath);
}));
}
// Слежение за изменениями
watchFile(filePath, callback) {
const watcher = fsSync.watch(filePath, { persistent: false }, (eventType) => {
callback({ eventType, path: filePath });
});
return () => watcher.close();
}
watchDirectory(dirPath, callback) {
const watcher = fsSync.watch(dirPath, { recursive: true, persistent: false }, (eventType, filename) => {
if (filename) {
callback({ eventType, path: path.join(dirPath, filename), filename });
}
});
return () => watcher.close();
}
// Пути приложения
getAppPaths() {
return {
userData: app.getPath('userData'),
documents: app.getPath('documents'),
downloads: app.getPath('downloads'),
temp: app.getPath('temp'),
home: app.getPath('home')
};
}
}
module.exports = new FileSystemService();
Drag & Drop файлов
// main/preload.js — регистрируем в contextBridge
onFileDrop: (callback) => {
ipcRenderer.on('files:dropped', (_, files) => callback(files));
return () => ipcRenderer.removeAllListeners('files:dropped');
}
// renderer — обработка drop
const dropZone = document.getElementById('drop-zone');
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
dropZone.classList.add('dragging');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('dragging');
});
dropZone.addEventListener('drop', async (e) => {
e.preventDefault();
dropZone.classList.remove('dragging');
// В Electron webContents drag-n-drop работает нативно
// e.dataTransfer.files — реальные пути к файлам
const files = Array.from(e.dataTransfer.files).map(f => ({
name: f.name,
path: f.path, // Electron добавляет .path — полный путь
size: f.size,
type: f.type
}));
for (const file of files) {
const content = await window.electronAPI.fs.readFile(file.path);
handleFile(content);
}
});
Tauri: файловая система через плагин
# src-tauri/Cargo.toml
[dependencies]
tauri-plugin-fs = "2"
tauri-plugin-dialog = "2"
// src-tauri/capabilities/default.json
{
"permissions": [
"fs:allow-read-text-file",
"fs:allow-write-text-file",
"fs:allow-read-dir",
"fs:allow-create-dir",
"fs:allow-remove-file",
"fs:allow-copy-file",
"fs:allow-stat",
"fs:allow-watch",
"fs:scope-app-data-recursive",
"fs:scope-document-recursive",
"fs:scope-download-recursive",
"dialog:allow-open",
"dialog:allow-save"
]
}
// renderer/api/fs.ts — Tauri
import { readTextFile, writeTextFile, readDir, watch, BaseDirectory } from '@tauri-apps/plugin-fs';
import { open, save } from '@tauri-apps/plugin-dialog';
export async function openAndReadFile() {
const selected = await open({
multiple: false,
filters: [{ name: 'Text', extensions: ['txt', 'md', 'json'] }]
});
if (!selected) return null;
const content = await readTextFile(selected as string);
return { path: selected as string, content };
}
export async function saveToFile(content: string, currentPath?: string) {
const filePath = currentPath ?? await save({
filters: [{ name: 'Text', extensions: ['txt'] }]
});
if (!filePath) return null;
await writeTextFile(filePath as string, content);
return filePath;
}
// Чтение с использованием BaseDirectory (относительно папки приложения)
export async function readConfig() {
return readTextFile('config.json', { baseDir: BaseDirectory.AppData });
}
export async function writeConfig(config: object) {
await writeTextFile('config.json', JSON.stringify(config, null, 2), {
baseDir: BaseDirectory.AppData
});
}
// Слежение за файлом
export async function watchFile(path: string, onChange: () => void) {
const stopWatching = await watch(path, () => onChange());
return stopWatching; // вызвать для прекращения слежения
}
Работа с двоичными файлами
// Electron — чтение бинарного файла
ipcMain.handle('fs:readBinary', async (event, filePath) => {
const buffer = await fs.readFile(filePath); // Buffer
// Передаём как ArrayBuffer через IPC
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
});
// Renderer — работа с ArrayBuffer
const arrayBuffer = await window.electronAPI.fs.readBinary(path);
const uint8 = new Uint8Array(arrayBuffer);
// Для изображений — конвертируем в blob URL
const blob = new Blob([uint8]);
const url = URL.createObjectURL(blob);
imgElement.src = url;
Временные файлы и очистка
// main/temp-manager.js
const fs = require('fs/promises');
const path = require('path');
const os = require('os');
const { app } = require('electron');
const tempDir = path.join(app.getPath('temp'), 'myapp-temp');
async function createTempFile(content, ext = 'tmp') {
await fs.mkdir(tempDir, { recursive: true });
const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
const filePath = path.join(tempDir, filename);
await fs.writeFile(filePath, content);
return filePath;
}
async function cleanupTemp() {
try {
const files = await fs.readdir(tempDir);
const now = Date.now();
await Promise.all(files.map(async (file) => {
const filePath = path.join(tempDir, file);
const stat = await fs.stat(filePath);
// Удаляем файлы старше 1 часа
if (now - stat.mtimeMs > 3600000) {
await fs.unlink(filePath);
}
}));
} catch {}
}
// Очистка при выходе
app.on('before-quit', async () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
Важный момент для обоих фреймворков: никогда не доверяйте путям, пришедшим от renderer без валидации в main process. Path traversal (../../etc/passwd) — реальный вектор атаки в Electron-приложениях, которые рендерят удалённый контент.







