|
| 1 | +const { ipcMain, dialog, BrowserWindow } = require('electron'); |
| 2 | +const path = require('path'); |
| 3 | +const fsp = require('fs/promises'); |
| 4 | +const os = require('os'); |
| 5 | +const { identifier: APP_IDENTIFIER } = require('./package.json'); |
| 6 | + |
| 7 | +// Electron IPC only preserves Error.message when errors cross the IPC boundary (see |
| 8 | +// https://github.com/electron/electron/issues/24427). To preserve error.code for FS |
| 9 | +// operations, we catch errors and return them as plain objects {error: {code, message}}. |
| 10 | +// The preload layer unwraps these back into proper Error objects. |
| 11 | +function fsResult(promise) { |
| 12 | + return promise.catch(err => { |
| 13 | + return { __fsError: true, code: err.code, message: err.message }; |
| 14 | + }); |
| 15 | +} |
| 16 | + |
| 17 | +/** |
| 18 | + * Returns the app's local data directory path with trailing separator. |
| 19 | + * Matches Tauri's appLocalDataDir which uses the bundle identifier. |
| 20 | + * - Linux: ~/.local/share/{APP_IDENTIFIER}/ |
| 21 | + * - macOS: ~/Library/Application Support/{APP_IDENTIFIER}/ |
| 22 | + * - Windows: %LOCALAPPDATA%/{APP_IDENTIFIER}/ |
| 23 | + */ |
| 24 | +function getAppDataDir() { |
| 25 | + const home = os.homedir(); |
| 26 | + let appDataDir; |
| 27 | + switch (process.platform) { |
| 28 | + case 'darwin': |
| 29 | + appDataDir = path.join(home, 'Library', 'Application Support', APP_IDENTIFIER); |
| 30 | + break; |
| 31 | + case 'win32': |
| 32 | + appDataDir = path.join(process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local'), APP_IDENTIFIER); |
| 33 | + break; |
| 34 | + default: |
| 35 | + appDataDir = path.join(process.env.XDG_DATA_HOME || path.join(home, '.local', 'share'), APP_IDENTIFIER); |
| 36 | + } |
| 37 | + return appDataDir + path.sep; |
| 38 | +} |
| 39 | + |
| 40 | +function registerFsIpcHandlers() { |
| 41 | + // Directory APIs |
| 42 | + ipcMain.handle('get-documents-dir', () => { |
| 43 | + // Match Tauri's documentDir which ends with a trailing slash |
| 44 | + return path.join(os.homedir(), 'Documents') + path.sep; |
| 45 | + }); |
| 46 | + |
| 47 | + ipcMain.handle('get-home-dir', () => { |
| 48 | + // Match Tauri's homeDir which ends with a trailing slash |
| 49 | + const home = os.homedir(); |
| 50 | + return home.endsWith(path.sep) ? home : home + path.sep; |
| 51 | + }); |
| 52 | + |
| 53 | + ipcMain.handle('get-temp-dir', () => { |
| 54 | + return os.tmpdir(); |
| 55 | + }); |
| 56 | + |
| 57 | + ipcMain.handle('get-app-data-dir', () => getAppDataDir()); |
| 58 | + |
| 59 | + // Get Windows drive letters (returns null on non-Windows platforms) |
| 60 | + ipcMain.handle('get-windows-drives', async () => { |
| 61 | + if (process.platform !== 'win32') { |
| 62 | + return null; |
| 63 | + } |
| 64 | + // On Windows, check which drive letters exist by testing A-Z |
| 65 | + const drives = []; |
| 66 | + for (let i = 65; i <= 90; i++) { // A-Z |
| 67 | + const letter = String.fromCharCode(i); |
| 68 | + const drivePath = `${letter}:\\`; |
| 69 | + try { |
| 70 | + await fsp.access(drivePath); |
| 71 | + drives.push(letter); |
| 72 | + } catch { |
| 73 | + // Drive doesn't exist |
| 74 | + } |
| 75 | + } |
| 76 | + return drives.length > 0 ? drives : null; |
| 77 | + }); |
| 78 | + |
| 79 | + // Dialogs |
| 80 | + ipcMain.handle('show-open-dialog', async (event, options) => { |
| 81 | + const win = BrowserWindow.fromWebContents(event.sender); |
| 82 | + const result = await dialog.showOpenDialog(win, options); |
| 83 | + return result.filePaths; |
| 84 | + }); |
| 85 | + |
| 86 | + ipcMain.handle('show-save-dialog', async (event, options) => { |
| 87 | + const win = BrowserWindow.fromWebContents(event.sender); |
| 88 | + const result = await dialog.showSaveDialog(win, options); |
| 89 | + return result.filePath; |
| 90 | + }); |
| 91 | + |
| 92 | + // FS operations |
| 93 | + ipcMain.handle('fs-readdir', async (event, dirPath) => { |
| 94 | + return fsResult( |
| 95 | + fsp.readdir(dirPath, { withFileTypes: true }) |
| 96 | + .then(entries => entries.map(e => ({ name: e.name, isDirectory: e.isDirectory() }))) |
| 97 | + ); |
| 98 | + }); |
| 99 | + |
| 100 | + ipcMain.handle('fs-stat', async (event, filePath) => { |
| 101 | + return fsResult( |
| 102 | + fsp.stat(filePath).then(stats => ({ |
| 103 | + isFile: stats.isFile(), |
| 104 | + isDirectory: stats.isDirectory(), |
| 105 | + isSymbolicLink: stats.isSymbolicLink(), |
| 106 | + size: stats.size, |
| 107 | + mode: stats.mode, |
| 108 | + ctimeMs: stats.ctimeMs, |
| 109 | + atimeMs: stats.atimeMs, |
| 110 | + mtimeMs: stats.mtimeMs, |
| 111 | + nlink: stats.nlink, |
| 112 | + dev: stats.dev |
| 113 | + })) |
| 114 | + ); |
| 115 | + }); |
| 116 | + |
| 117 | + ipcMain.handle('fs-mkdir', (event, dirPath, options) => fsResult(fsp.mkdir(dirPath, options))); |
| 118 | + ipcMain.handle('fs-unlink', (event, filePath) => fsResult(fsp.unlink(filePath))); |
| 119 | + ipcMain.handle('fs-rmdir', (event, dirPath, options) => fsResult(fsp.rm(dirPath, options))); |
| 120 | + ipcMain.handle('fs-rename', (event, oldPath, newPath) => fsResult(fsp.rename(oldPath, newPath))); |
| 121 | + ipcMain.handle('fs-read-file', (event, filePath) => fsResult(fsp.readFile(filePath))); |
| 122 | + ipcMain.handle('fs-write-file', (event, filePath, data) => fsResult(fsp.writeFile(filePath, Buffer.from(data)))); |
| 123 | +} |
| 124 | + |
| 125 | +module.exports = { |
| 126 | + registerFsIpcHandlers, |
| 127 | + getAppDataDir |
| 128 | +}; |
0 commit comments