עורך טקסט ב-Electron קופא למספר שניות בעת פתיחת קובץ גדול מ-50 מגה-בייט. משתמשים מתלוננים על פיגור בעת גרירת קבצים. הסיבה היא IPC לא מותאם וחוסר בדיקה מוקדמת של גודל הקובץ. לשני הפריימוורקים — Electron ו-Tauri — יש ניואנסים משלהם. בואו נבין כיצד ליישם כראוי גישה למערכת הקבצים באפליקציית דסקטופ כדי להימנע מבעיות כאלה.
בעיות שאנחנו פותרים
Path traversal — כאשר ה-renderer שולח נתיב כמו ../../etc/passwd. ב-Electron, זה נפתר על ידי אימות בצד של התהליך הראשי. ב-Tauri, על ידי הגדרת scope ב-capabilities/default.json. בפרויקט אחד, גילינו שהלקוח העביר נתיבים מוחלטים ללא אימות — תיקנו את זה תוך שעה, אבל יכולנו לאבד נתונים. למידע נוסף על Path traversal בויקיפדיה.
IPC לא יעיל לקבצים גדולים — העברת קבצים מעל 100 מגה-בייט דרך ipcRenderer.invoke חוסמת את ה-renderer. פתרון: שימוש ב-streams או העברת ArrayBuffer דרך SharedArrayBuffer. Electron תומך כעת ב-MessagePort להעברת כמויות גדולות ללא העתקה.
קושי בניטור שינויים — fs.watch ב-Node.js אינו יציב ב-macOS — הוא מפספס אירועים בעומס כבד. חלופה: polling במרווחים של 1000 אלפיות השנייה או שימוש ב-chokidar עם awaitWriteFinish. עבור Tauri, ה-watch של tauri-plugin-fs משתמש ב-inotify על לינוקס ו-FSEvents על macOS.
כיצד להבטיח אבטחת גישה לקבצים?
ב-Electron, כל פעולות הקלט/פלט מתבצעות בתהליך הראשי. ה-renderer מבקש פעולות דרך IPC. זה מונע התקפות ישירות על ממשק המשתמש. השתמשו ב-contextBridge לבידוד — אל תייצאו דבר מ-preload מלבד שיטות בטוחות. ב-Tauri, מודל ההרשאות מחמיר אף יותר: כל פעולה (קריאה, כתיבה, מחיקה) חייבת להיות מוצהרת ב-capabilities/default.json. הגדירו scope לתיקיות ספציפיות: app-data, documents, downloads. לעולם אל תעניקו גישה לכל מערכת הקבצים. עם ניסיון של 5+ שנים ומעל 100 פרויקטים שסופקו, אנו מבטיחים יישומים מאובטחים.
דוגמה להגדרת הרשאות ב-Tauri
{
"permissions": [
"fs:allow-read-text-file",
"fs:allow-write-text-file",
"fs:allow-read-dir",
"fs:allow-watch",
"fs:scope-app-data-recursive",
"fs:scope-document-recursive",
"dialog:allow-open",
"dialog:allow-save"
]
} יישום ב-Electron
הגישה הקלאסית היא מחלקת { "permissions": [ "fs:allow-read-text-file", "fs:allow-write-text-file", "fs:allow-read-dir", "fs:allow-watch", "fs:scope-app-data-recursive", "fs:scope-document-recursive", "dialog:allow-open", "dialog:allow-save" ] } בתהליך הראשי. היא משתמשת ב-FileSystemService ו-fs/promises לפעולות קבצים, וב-fs לממשקי פתיחה/שמירה מקוריים. חשוב: הזהירו את המשתמש על קבצים >50 מגה-בייט כדי למנוע תליית התהליך. עבור רשימות קבצים, השתמשו בסינון קבצים נסתרים ובמיון: תיקיות קודם, ואז לפי סדר אלפביתי.
// main/fs-service.js
const fs = require('fs/promises');
const fsSync = require('fs');
const path = require('path');
const { app, dialog } = 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();
גרירה ושחרור מיושמת על ידי טיפול באירוע dialog ב-renderer עם // main/fs-service.js const fs = require('fs/promises'); const fsSync = require('fs'); const path = require('path'); const { app, dialog } = 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(); (Electron מוסיף מאפיין זה). כדי לא להעמיס על התהליך הראשי, העבירו רק מטא-דאטה דרך IPC ובצעו קריאה לפי דרישה.
יישום ב-Tauri
כללו את הפלאגינים drop ו-f.path. כל פעולות הקבצים דורשות הרשאות מפורשות ב-tauri-plugin-fs.
# src-tauri/Cargo.toml [dependencies] tauri-plugin-fs = "2" tauri-plugin-dialog = "2" דוגמה לקריאה וכתיבה:
// renderer/api/fs.ts
import { readTextFile, writeTextFile, BaseDirectory } from '@tauri-apps/plugin-fs';
import { open, save } from '@tauri-apps/plugin-dialog';
export async function openAndReadFile() {
const selected = await open({ multiple: false });
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;
}עבור קבצים בינאריים, השתמשו ב-tauri-plugin-dialog ו-capabilities/default.json מאותו פלאגין — הם עובדים עם # src-tauri/Cargo.toml [dependencies] tauri-plugin-fs = "2" tauri-plugin-dialog = "2" .
למה Tauri מהיר יותר מ-Electron?
Tauri מהדר בינארי עם Rust ומשתמש ב-WebView של המערכת במקום ב-Chromium מוטבע. גודל האפליקציה קטן פי 2-3, וצריכת הזיכרון נמוכה בעד 70%. עם זאת, Tauri דורש הצהרה על כל הפעולות ב-// renderer/api/fs.ts import { readTextFile, writeTextFile, BaseDirectory } from '@tauri-apps/plugin-fs'; import { open, save } from '@tauri-apps/plugin-dialog'; export async function openAndReadFile() { const selected = await open({ multiple: false }); 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; } , מה שהופך את הקוד לבטוח יותר אך מגדיל מעט את זמן ההתקנה.
השוואה בין Electron ו-Tauri לגישת קבצים
| מאפיין | Electron | Tauri |
|---|---|---|
| API | Node.js fs (גישה מלאה) | tauri-plugin-fs (מוגבל) |
| אבטחה | דורש הפרדת תהליכים מפורשת | מודל הרשאות מובנה |
| גודל אפליקציה | >100 מגה-בייט (כולל Chromium) | <10 מגה-בייט |
| ביצועים | בינוני (בגלל Chromium) | גבוה (קוד native) |
| קבצים בינאריים | דרך Buffer ו-ArrayBuffer | דרך Uint8Array, המרה |
| ניטור קבצים | fs.watch / fs.watchFile | tauri-plugin-fs watch |
| תאימות פלטפורמות | Windows, macOS, Linux | Windows, macOS, Linux (iOS, Android בפיתוח) |
תהליך והיקף העבודה
- ניתוח — אנו לומדים את האפליקציה הנוכחית שלך, דרישות גישת הקבצים, ומזהים נקודות תורפה. אנו יוצרים מפת זרימת נתונים.
- עיצוב — אנו מפתחים תכנית IPC או הרשאות, בוחרים ספריות, ומעצבים API.
- יישום — אנו כותבים קוד עם בדיקות יחידה (jest עבור Node.js, vitest עבור Tauri). אנו משתמשים בתבנית Repository לבידוד לוגיקה עסקית.
- בדיקות — אנו בודקים עם קבצים אמיתיים (עד 10 גיגה-בייט), מדמים התקפות path traversal, ובודקים על Windows, macOS, Linux.
- פריסה — אנו בונים בינאריים, מגדירים עדכון אוטומטי, ומתעדים את ה-API.
אנו משתמשים ב-Electron 28+, Tauri 2, TypeScript, ESLint, Prettier, Husky.
מקרה: עבור סטארטאפ אחד, יישמנו מנהל קבצים על Electron המסוגל לפתוח קבצים עד 4 גיגה-בייט ללא הקפאה. הפתרון — העברה בחלקים דרך IPC עם פס התקדמות וביטול לפי דרישה. זמן הטעינה לקובץ של 500 מגה-בייט ירד מ-12 ל-3 שניות (הפחתה של 75%). בקשו ביקורת על האפליקציה שלכם ונזהה נקודות תורפה תוך יום אחד. קבלו הערכת מחיר מ-$500 עבור התקנה בסיסית.
לוחות זמנים משוערים
| שלב | זמן |
|---|---|
| ניתוח ועיצוב | יום אחד |
| גישה בסיסית (קריאה/כתיבה/דיאלוגים) | 2-3 ימים |
| ניטור וקבצים בינאריים | 1-2 ימים |
| בדיקות וניפוי שגיאות | 1-2 ימים |
| אינטגרציה לפרויקט קיים | החל מיום אחד |
המחיר מחושב באופן אישי. צרו קשר לייעוץ. הזמינו פיתוח גישת קבצים מותאם אישית ונעריך את הפרויקט שלכם תוך יום אחד.







