electron

原生对话框与文件操作

By AI-Writer 9 min read

前言

桌面应用的核心价值之一是与操作系统深度集成。Electron 提供了丰富的原生对话框(文件选择、保存确认、消息提示)和 Shell 模块,让开发者无需自己实现这些复杂的原生交互。本文将详细介绍这些功能的用法和注意事项。

原生对话框

dialog 模块

dialog 模块只能从主进程使用,用于显示系统原生对话框:

typescript
// main.ts
import { dialog, BrowserWindow } from 'electron';

// 最好关联一个父窗口,否则 macOS 上可能不显示在应用前方
const parent = BrowserWindow.getFocusedWindow();

打开文件对话框

typescript
// main.ts — 打开文件选择对话框
ipcMain.handle('dialog:open-file', async (event) => {
  const result = await dialog.showOpenDialog({
    title: '选择图片文件',
    defaultPath: '/Users', // macOS 默认路径
    buttonLabel: '打开',
    filters: [
      // 文件类型过滤器
      { name: '图片文件', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'] },
      { name: '所有文件', extensions: ['*'] },
    ],
    // 是否允许多选
    properties: ['openFile', 'multiSelections'],
    // 是否显示隐藏文件
    showHiddenFiles: false,
  });

  return result;
});

// result = { canceled: false, filePaths: ['/path/to/file.png'] }

打开文件夹对话框

typescript
ipcMain.handle('dialog:open-directory', async () => {
  const result = await dialog.showOpenDialog({
    title: '选择项目目录',
    properties: ['openDirectory', 'createDirectory'],
  });

  if (!result.canceled) {
    return result.filePaths[0];
  }
  return null;
});

保存文件对话框

typescript
ipcMain.handle('dialog:save-file', async () => {
  const result = await dialog.showSaveDialog({
    title: '保存文件',
    defaultPath: '/Users/Downloads/report.pdf',
    filters: [
      { name: 'PDF 文件', extensions: ['pdf'] },
      { name: 'Word 文档', extensions: ['docx'] },
    ],
  });

  if (!result.canceled && result.filePath) {
    // 在此将内容写入 result.filePath
    return { success: true, path: result.filePath };
  }
  return { success: false };
});

消息对话框

typescript
// 确认对话框
const { response, checkboxChecked } = await dialog.showMessageBox({
  type: 'question',        // 'none' | 'info' | 'error' | 'question' | 'warning'
  buttons: ['保存', '不保存', '取消'],
  defaultId: 0,            // 默认聚焦的按钮索引
  cancelId: 2,              // 按 ESC 时返回的索引
  title: '确认退出',
  message: '是否保存未提交的更改?',
  detail: '您有未保存的内容,退出将丢失。',
  checkboxLabel: '不再提示', // macOS/Windows 可显示复选框
  checkboxChecked: false,
});

// response: 0 = 保存, 1 = 不保存, 2 = 取消
if (response === 0) {
  await saveFile();
}
app.quit();

错误对话框

typescript
dialog.showErrorBox('错误', '发生了未知错误,请联系开发者。');
// 这在 Electron 的早期阶段(app.whenReady 之前也可使用)

Shell 模块

shell 模块用于操作系统级别的操作,在主进程和渲染进程中都可以使用(渲染进程需通过 preload 暴露)。

打开外部链接

typescript
// main.ts
import { shell } from 'electron';

// 打开外部浏览器
await shell.openExternal('https://example.com');

// 打开本地文件(使用系统默认应用)
await shell.openPath('/path/to/file.pdf');
// 等同于 macOS: open /path/to/file.pdf
//          Windows: cmd /c start /path/to/file.pdf
typescript
// ❌ 安全问题:不要直接暴露 shell.openExternal 给渲染进程
// 恶意网页可以打开任意 URL

// ✅ preload.ts — 白名单控制
contextBridge.exposeInMainWorld('electronAPI', {
  openExternal: (url: string) => {
    // 白名单校验
    const allowed = ['https://docs.example.com', 'https://help.example.com'];
    if (allowed.some((prefix) => url.startsWith(prefix))) {
      return shell.openExternal(url);
    }
    throw new Error('URL 不在白名单中');
  },
});

文件资源管理器操作

typescript
// 在文件管理器中显示文件(选中文件)
shell.showItemInFolder('/path/to/file.txt');

// 显示文件夹内容(不选中任何文件)
shell.openPath('/path/to/folder');

Shell 操作整合示例

typescript
// main.ts — 完整的文件操作 API
import { shell, dialog, app, BrowserWindow } from 'electron';
import fs from 'fs';
import path from 'path';

ipcMain.handle('file:open-in-explorer', async (_event, filePath: string) => {
  if (!fs.existsSync(filePath)) {
    throw new Error(`文件不存在: ${filePath}`);
  }
  shell.showItemInFolder(filePath);
});

ipcMain.handle('file:open-with-system', async (_event, filePath: string) => {
  if (!fs.existsSync(filePath)) {
    throw new Error(`文件不存在: ${filePath}`);
  }
  return shell.openPath(filePath);
});

ipcMain.handle('file:open-external-link', async (_event, url: string) => {
  // 验证 URL 格式
  try {
    const parsed = new URL(url);
    if (['http:', 'https:'].includes(parsed.protocol)) {
      return shell.openExternal(url);
    }
    throw new Error('仅支持 http/https 链接');
  } catch {
    throw new Error('无效的 URL');
  }
});

路径处理与用户数据目录

app.getPath

app.getPath 返回系统标准目录的绝对路径:

typescript
import { app } from 'electron';

console.log('用户数据目录:', app.getPath('userData'));
// macOS: ~/Library/Application Support/MyApp
// Windows: C:\Users\Alice\AppData\Roaming\MyApp
// Linux: ~/.config/MyApp

console.log('下载目录:', app.getPath('downloads'));
console.log('文档目录:', app.getPath('documents'));
console.log('临时目录:', app.getPath('temp'));
console.log('桌面目录:', app.getPath('desktop'));
console.log('应用日志目录:', app.getPath('logs'));

跨平台路径拼接

typescript
import path from 'path';
import { app } from 'electron';

// 始终使用 path.join 拼接路径,避免硬编码斜杠
const configDir = app.getPath('userData');
const configFile = path.join(configDir, 'config.json');
const logFile = path.join(configDir, 'logs', 'app.log');

// path.join 会自动处理平台差异
// path.join('a', 'b') → 'a/b' (macOS/Linux) 或 'a\b' (Windows)

// 注意:Electron 中使用 import 而不是 require 时
// Node.js 的 path 模块需要显式导入
import { join, dirname, basename, extname } from 'path';

const userDataPath = app.getPath('userData');
const configPath = join(userDataPath, 'settings.json');
const logDir = join(userDataPath, 'logs');

// dirname / basename / extname 实用函数
console.log(basename(configPath));      // 'settings.json'
console.log(extname(configPath));       // '.json'
console.log(dirname(configPath));        // '/Users/.../MyApp'

文件系统操作

主进程文件读写

渲染进程的文件操作受限于安全沙箱,所有文件操作应在主进程中进行:

typescript
// main.ts — 完整的文件读写 IPC 处理
import fs from 'fs/promises';
import { ipcMain, dialog, app } from 'electron';
import path from 'path';

// 读取文件
ipcMain.handle('file:read', async (_event, filePath: string) => {
  try {
    // 安全检查:限制在用户数据目录内
    const userDataDir = app.getPath('userData');
    const resolvedPath = path.resolve(filePath);
    if (!resolvedPath.startsWith(userDataDir)) {
      throw new Error('路径超出允许范围');
    }
    const content = await fs.readFile(resolvedPath, 'utf-8');
    return { success: true, data: content };
  } catch (error) {
    return { success: false, error: (error as Error).message };
  }
});

// 写入文件
ipcMain.handle('file:write', async (_event, filePath: string, data: string) => {
  try {
    const userDataDir = app.getPath('userData');
    const resolvedPath = path.resolve(filePath);
    if (!resolvedPath.startsWith(userDataDir)) {
      throw new Error('路径超出允许范围');
    }
    await fs.writeFile(resolvedPath, data, 'utf-8');
    return { success: true };
  } catch (error) {
    return { success: false, error: (error as Error).message };
  }
});

// 列出目录
ipcMain.handle('file:list', async (_event, dirPath: string) => {
  try {
    const entries = await fs.readdir(dirPath, { withFileTypes: true });
    return entries.map((entry) => ({
      name: entry.name,
      isDirectory: entry.isDirectory(),
      isFile: entry.isFile(),
    }));
  } catch (error) {
    return { error: (error as Error).message };
  }
});

渲染进程中使用

typescript
// preload.ts
contextBridge.exposeInMainWorld('electronAPI', {
  readFile: (path: string) => ipcRenderer.invoke('file:read', path),
  writeFile: (path: string, data: string) =>
    ipcRenderer.invoke('file:write', path, data),
  listDir: (path: string) => ipcRenderer.invoke('file:list', path),
  openFileDialog: () => ipcRenderer.invoke('dialog:open-file'),
  openFolderDialog: () => ipcRenderer.invoke('dialog:open-directory'),
  saveFileDialog: () => ipcRenderer.invoke('dialog:save-file'),
  showConfirmDialog: (message: string, detail?: string) =>
    ipcRenderer.invoke('dialog:confirm', message, detail),
});

实用场景:图片保存功能

typescript
// main.ts — 完整的图片保存流程
ipcMain.handle('image:save-from-url', async (_event, url: string, suggestedName: string) => {
  const downloadsDir = app.getPath('downloads');

  // 让用户选择保存位置
  const { canceled, filePath } = await dialog.showSaveDialog({
    title: '保存图片',
    defaultPath: path.join(downloadsDir, suggestedName),
    filters: [{ name: '图片', extensions: ['png', 'jpg', 'webp'] }],
  });

  if (canceled || !filePath) return { success: false, reason: '用户取消' };

  // 下载图片
  const response = await fetch(url);
  if (!response.ok) throw new Error('下载失败');

  const buffer = await response.arrayBuffer();
  await fs.writeFile(filePath, Buffer.from(buffer));

  // 在文件管理器中显示
  shell.showItemInFolder(filePath);

  return { success: true, path: filePath };
});

常见问题

dialog 在无头环境(CI)失败

typescript
// 在无 GUI 环境下,对话框会静默失败或抛出错误
// CI 环境使用 --no-sandbox 或虚拟显示
const { contextBridge } = require('electron');
// 建议在 CI 中 mock dialog 返回值进行测试

Windows 路径中文编码

typescript
// Windows 上路径含中文时可能需要额外处理
import { Buffer } from 'buffer';

// 读取文件时指定编码
const content = await fs.readFile(nativePath, 'utf-8');

小结

  • dialog.showOpenDialog / showSaveDialog 用于文件和目录选择
  • dialog.showMessageBox 显示确认、警告、错误等消息对话框
  • shell.openExternal 打开外部 URL,showItemInFolder 定位文件
  • 所有文件操作在主进程中进行,通过 IPC 暴露给渲染进程
  • 使用 path.join 处理跨平台路径,app.getPath 获取系统标准目录
  • 安全原则:永远不直接暴露文件写入权限,通过 preload 做路径限制和操作封装

下一篇文章我们将介绍 Node.js 原生模块调用,包括在 Electron 中使用 sqlite3、sharp 等原生模块,以及 electron-rebuild 编译方案。

#electron #dialog #shell #文件系统 #app.getPath

评论

A

Written by

AI-Writer

Related Articles

electron
#1

Electron 入门与核心架构

了解 Electron 的双引擎架构(Chromium + Node.js)、主进程与渲染进程的职责分工,快速搭建第一个 Hello World 应用,掌握 BrowserWindow 基本配置

Read More
electron
#8

React / Vue 与 Electron 集成

使用 electron-vite 工具链整合 Vite + React/Vue + Electron,配置路由(React Router / Vue Router),实现主进程与渲染进程状态共享(Zustand IPC 桥接)

Read More