# 文件选择、菜单、剪贴板与系统通知

## 桌面 API 的学习起点是用户动作

系统选择框、菜单、剪贴板和通知都能让网页界面更像桌面应用，但它们同时意味着不同权限。选择文件让应用接触用户磁盘内容，读取剪贴板可能接触别的应用复制的秘密，通知会把内容展示到桌面甚至锁屏。学习这些 API 时，应先定义用户想完成的动作，再开放最小能力，而不是把整组模块交给 renderer。

本章前置是 IPC 合同和发送者校验。我们制作一个本地文本查看器：用户选择一个小型 UTF-8 文本文件，页面显示内容；用户主动复制当前文本；用户主动触发一条不含文件内容的通知。菜单与按钮复用同一操作。没有任意路径读写、没有后台监控剪贴板，也没有自动把文档送到网络。

达标标准是能解释每次系统调用的输入、完成信号、取消结果和权限来源。界面出现一个文件名不等于获得永久文件授权，通知接口返回不等于用户已经看见，菜单项可点击也不等于调用者可以跳过业务检查。这些差别会直接影响真实应用的错误提示与数据保护。

## 选择框返回的是选择结果，不是文件内容

dialog.showOpenDialog 可以接收父窗口和选项，异步返回包含 canceled 与 filePaths 的对象。传入父窗口让对话框附属于它；properties 明确选择文件还是目录、是否允许多选；filters 改善可见文件类型。示例仅允许单文件选择，并主动处理取消，不把取消当成异常弹窗。

filters 不是安全验证。用户选择结果、文件扩展名、实际字节内容是三层不同信息。一个 .txt 可以装二进制，一个文件在选择后可能被修改或删除，也可能读到权限错误。主进程必须在真正使用文件时处理这些情况。页面只需要文件名和文本，不需要原始绝对路径；减少返回字段也能避免无意义地泄漏目录结构。

读取文件时先检查大小仍不够，因为文件可能在检查后增长。我们采用打开句柄、检查普通文件、最多读取上限加一字节的方法：多出的那一字节用于证明超限。读取过程有界，最后在 finally 关闭句柄。它不能解决恶意本机进程的所有竞争，但能防止把大小检查当成一次永远有效的承诺。

## 实验一：有界读取内核与离线故障验证

建立 desktop-api-lab 目录，保存 read-text.cjs 和 read-check.cjs。前者是主进程使用的真实读取内核，后者用内存句柄替代磁盘访问，验证我们写的长度、解码与清理规则。执行 node read-check.cjs，不会读取你的真实文件，也不会创建临时文件。

```js read-text.cjs
'use strict';
const { open } = require('node:fs/promises');
const path = require('node:path');
function failure(code) {
  const error = new Error(code);
  error.code = code;
  return error;
}
async function readChosenText(filePath, { openFile = open, limit = 131072 } = {}) {
  if (typeof filePath !== 'string' || path.extname(filePath).toLowerCase() !== '.txt') {
    throw failure('BAD_EXTENSION');
  }
  const handle = await openFile(filePath, 'r');
  try {
    const stats = await handle.stat();
    if (!stats.isFile()) throw failure('NOT_FILE');
    if (stats.size > limit) throw failure('TOO_LARGE');
    const buffer = Buffer.alloc(limit + 1); // 有界内存，额外一个字节检测增长
    let total = 0;
    while (total < buffer.length) {
      const { bytesRead } = await handle.read(buffer, total, buffer.length - total, null);
      if (bytesRead === 0) break;
      total += bytesRead;
    }
    if (total > limit) throw failure('TOO_LARGE');
    let text;
    try {
      text = new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, total));
    } catch {
      throw failure('BAD_UTF8');
    }
    return { name: path.basename(filePath), text, bytes: total };
  } finally {
    await handle.close(); // 成功和失败都释放文件句柄
  }
}
module.exports = { readChosenText };
```

```js read-check.cjs
'use strict';
const assert = require('node:assert/strict');
const { readChosenText } = require('./read-text.cjs');
function fixture(bytes, declaredSize = bytes.length) {
  let closed = 0;
  let offset = 0;
  return {
    get closed() { return closed; },
    async openFile() {
      return {
        async stat() { return { size: declaredSize, isFile: () => true }; },
        async read(buffer, targetOffset, length) {
          const count = Math.min(length, bytes.length - offset, 2);
          bytes.copy(buffer, targetOffset, offset, offset + count);
          offset += count;
          return { bytesRead: count };
        },
        async close() { closed += 1; }
      };
    }
  };
}
async function main() {
  const normal = fixture(Buffer.from('你好'));
  const value = await readChosenText('note.txt', { openFile: normal.openFile, limit: 8 });
  assert.equal(value.text, '你好');
  assert.equal(normal.closed, 1);
  const grown = fixture(Buffer.from('123456789'), 1); // 检查后增长
  await assert.rejects(readChosenText('note.txt', { openFile: grown.openFile, limit: 8 }),
    error => error.code === 'TOO_LARGE');
  assert.equal(grown.closed, 1);
  const invalid = fixture(Buffer.from([0xff]));
  await assert.rejects(readChosenText('note.txt', { openFile: invalid.openFile }),
    error => error.code === 'BAD_UTF8');
  assert.equal(invalid.closed, 1);
  console.log('通过：分段读取、检查后增长、非法编码与句柄释放');
}
main().catch(error => { console.error(error); process.exitCode = 1; });
```

测试中的句柄每次最多返回两个字节，说明一次 read 不保证填满全部缓冲区，需要循环处理短读。中文 UTF-8 字节也可以被分到两次读取中，最终完整收集后再严格解码。这里的 limit 是本应用选择的一百二十八 KiB，不是 Electron 文件选择框的默认上限。

## 实验二：完整文本查看器

继续在同一目录放入五个文件。read-text.cjs 是上面完整给出的依赖。以下程序固定使用 Electron 44.3.0；首次 npm install 会下载桌面二进制，安装以后查看器无需网络即可运行。

```json package.json
{
  "name": "electron-desktop-api-lab",
  "version": "1.0.0",
  "private": true,
  "main": "main.cjs",
  "scripts": { "start": "electron .", "check": "node read-check.cjs" },
  "devDependencies": { "electron": "44.3.0" }
}
```

```js main.cjs
'use strict';
const { app, BrowserWindow, ipcMain, dialog, Menu, clipboard, Notification } = require('electron');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
const { readChosenText } = require('./read-text.cjs');
const pagePath = path.join(__dirname, 'index.html');
const pageURL = pathToFileURL(pagePath).href;
let mainWindow = null;
let generation = 0;
let choosing = false;
let lastNotification = 0;
const notices = new Set();
const fail = (code, message) => ({ ok: false, error: { code, message } });

function current(win, token) {
  return win && !win.isDestroyed() && mainWindow === win
    && generation === token && win.webContents.getURL() === pageURL;
}
function trusted(event) {
  const win = mainWindow;
  return win && !win.isDestroyed() && event.sender === win.webContents
    && event.senderFrame && event.senderFrame === win.webContents.mainFrame
    && event.senderFrame.url === pageURL;
}
async function chooseText(win, token) {
  if (!current(win, token)) return fail('STALE', '页面已变化');
  if (choosing) return fail('BUSY', '文件选择框已经打开');
  choosing = true;
  try {
    const result = await dialog.showOpenDialog(win, {
      title: '选择一个 UTF-8 文本文件',
      properties: ['openFile'],
      filters: [{ name: '纯文本', extensions: ['txt'] }]
    });
    if (!current(win, token)) return fail('STALE', '页面已变化');
    if (result.canceled || result.filePaths.length === 0) return { ok: true, canceled: true };
    const value = await readChosenText(result.filePaths[0]);
    if (!current(win, token)) return fail('STALE', '页面已变化');
    return { ok: true, canceled: false, value };
  } catch (error) {
    const messages = {
      TOO_LARGE: '文件超过 128 KiB', BAD_UTF8: '文件不是有效 UTF-8 文本',
      BAD_EXTENSION: '请选择 .txt 文件', NOT_FILE: '选择结果不是普通文件'
    };
    return fail(error.code || 'READ_FAILED', messages[error.code] || '无法读取文件');
  } finally {
    choosing = false;
  }
}
ipcMain.handle('desktop:choose-text', event => {
  if (!trusted(event)) return fail('FORBIDDEN', '来源不被允许');
  return chooseText(mainWindow, generation);
});
ipcMain.handle('desktop:copy-text', async (event, text) => {
  if (!trusted(event)) return fail('FORBIDDEN', '来源不被允许');
  if (typeof text !== 'string' || text.length > 131072) return fail('BAD_INPUT', '文本长度不合法');
  try {
    await clipboard.writeText(text); // 44.3.0 契约是 Promise<void>
    return { ok: true };
  } catch {
    return fail('COPY_FAILED', '系统剪贴板写入失败');
  }
});
ipcMain.handle('desktop:notify-ready', event => {
  if (!trusted(event)) return fail('FORBIDDEN', '来源不被允许');
  if (!Notification.isSupported()) return fail('UNSUPPORTED', '当前系统不支持通知');
  if (Date.now() - lastNotification < 5000) return fail('RATE_LIMIT', '请稍后再试');
  lastNotification = Date.now();
  try {
    const notice = new Notification({ title: '文本查看器', body: '你主动请求的演示通知。' });
    notices.add(notice);
    const timer = setTimeout(() => notices.delete(notice), 30000);
    const release = () => { clearTimeout(timer); notices.delete(notice); };
    notice.once('close', release);
    notice.once('failed', release);
    notice.show();
    return { ok: true, accepted: true }; // 只表示已提交，不保证用户实际看到
  } catch {
    return fail('NOTIFY_FAILED', '通知提交失败');
  }
});
function createWindow() {
  const win = new BrowserWindow({
    width: 840, height: 620,
    webPreferences: {
      preload: path.join(__dirname, 'preload.cjs'),
      contextIsolation: true, sandbox: true, nodeIntegration: false
    }
  });
  mainWindow = win;
  win.webContents.on('did-start-navigation', details => {
    if (details.isMainFrame && !details.isSameDocument) generation += 1;
  });
  win.webContents.on('will-navigate', event => event.preventDefault());
  win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
  win.on('closed', () => {
    if (mainWindow === win) { mainWindow = null; generation += 1; }
  });
  void win.loadFile(pagePath).catch(error => { console.error(error); app.quit(); });
}
app.whenReady().then(() => {
  const menu = Menu.buildFromTemplate([
    {
      label: '文件',
      submenu: [
        {
          label: '选择文本', accelerator: 'CmdOrCtrl+O',
          click: async () => {
            const win = mainWindow;
            const token = generation;
            const reply = await chooseText(win, token);
            if (current(win, token)) win.webContents.send('desktop:selection', reply);
          }
        },
        { role: 'quit' }
      ]
    },
    { role: 'editMenu' }
  ]);
  Menu.setApplicationMenu(menu);
  createWindow();
  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) createWindow();
  });
}).catch(error => { console.error(error); app.quit(); });
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit();
});
```

```js preload.cjs
'use strict';
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('desktopAPI', {
  chooseText: () => ipcRenderer.invoke('desktop:choose-text'),
  copyText: text => ipcRenderer.invoke('desktop:copy-text', text),
  notifyReady: () => ipcRenderer.invoke('desktop:notify-ready'),
  onSelection(callback) {
    if (typeof callback !== 'function') throw new TypeError('需要回调函数');
    let active = true;
    const wrapped = (_event, reply) => {
      if (active) callback(reply); // 丢弃底层 event，取消后不再转发
    };
    ipcRenderer.on('desktop:selection', wrapped);
    return () => {
      if (!active) return;
      active = false;
      ipcRenderer.removeListener('desktop:selection', wrapped);
    };
  }
});
```

```html index.html
<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta http-equiv="Content-Security-Policy"
        content="default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-src 'none'; connect-src 'none'">
  <title>本地文本查看器</title>
  <script src="./renderer.js" defer></script>
</head>
<body>
  <main>
    <h1>本地文本查看器</h1>
    <button id="choose" type="button">选择文本</button>
    <button id="copy" type="button" disabled>复制当前文本</button>
    <button id="notify" type="button">发送演示通知</button>
    <p id="status" role="status">尚未选择文件</p>
    <label for="content">文件内容</label>
    <textarea id="content" rows="18" cols="60" readonly></textarea>
  </main>
</body>
</html>
```

```js renderer.js
'use strict';
const api = window.desktopAPI;
const status = document.querySelector('#status');
const content = document.querySelector('#content');
const choose = document.querySelector('#choose');
const copy = document.querySelector('#copy');
function acceptSelection(reply) {
  if (!reply.ok) { status.textContent = reply.error.message; return; }
  if (reply.canceled) { status.textContent = '已取消选择，保留原内容'; return; }
  content.value = reply.value.text; // textarea 值不会解释 HTML
  copy.disabled = false;
  status.textContent = reply.value.name + '，' + reply.value.bytes + ' 字节';
}
choose.addEventListener('click', async () => {
  choose.disabled = true;
  try { acceptSelection(await api.chooseText()); }
  catch { status.textContent = '选择文件通信失败'; }
  finally { choose.disabled = false; }
});
copy.addEventListener('click', async () => {
  copy.disabled = true;
  try {
    const reply = await api.copyText(content.value);
    status.textContent = reply.ok ? '已写入剪贴板' : reply.error.message;
  } catch { status.textContent = '复制通信失败'; }
  finally { copy.disabled = false; }
});
document.querySelector('#notify').addEventListener('click', async () => {
  try {
    const reply = await api.notifyReady();
    status.textContent = reply.ok ? '已提交给系统，是否展示由系统决定' : reply.error.message;
  } catch { status.textContent = '通知通信失败'; }
});
const unsubscribe = api.onSelection(acceptSelection);
window.addEventListener('pagehide', unsubscribe, { once: true });
```

```sh
node read-check.cjs
npm install
npm start
```

预期选择小型 UTF-8 文本后展示内容，取消后保留先前内容；菜单“选择文本”与按钮行为一致；复制按钮写入系统剪贴板；通知按钮显示提交状态。通知是否出现、菜单位置和系统对话框外观必须在目标平台验证，本轮没有启动 GUI，也没有读取或修改真实剪贴板。

## 逐段解释：用户授权到哪里为止

chooseText 不接收 renderer 提供的路径。路径只能来自主进程刚刚打开的系统选择框，这让操作具有清楚的用户意图。返回值只带文件名、文本与字节数，页面无法通过改动返回对象进一步读取同目录其他文件。一次选择也不自动授权后台长期监听该文件，更不授权把它上传到远端。

generation 标记当前页面文档代次。选择框打开期间页面可能重载，用户选完后不应把旧操作结果推送给新页面；因此等待前后都确认窗口和代次仍一致。这里只处理短小读取，没有实现强制关闭系统选择框；旧结果会被拒绝交付。把“不展示旧结果”与“底层操作已取消”区分开，避免错误宣传取消能力。

菜单运行在主进程，点击不是来自 renderer 的 IPC 事件，所以它直接调用同一个受限业务函数，而不是伪造发送者。这个函数仍检查窗口、代次、是否已有选择框和文件规则。按钮入口则先校验真实 IPC 发送者，再进入同一业务函数。两种入口共享的是业务规则，不是跳过规则的便利通道。

## 剪贴板写入与读取具有不同权限意义

复制当前文本只需要 writeText，不需要 readText。读取剪贴板可能接触密码管理器复制的内容、别的应用中的私人信息或一次性代码，因此“为了方便以后使用”提前公开读取能力并不合理。页面也不需要整个 clipboard 模块，只需要一个长度有界的 copyText 方法。

本章特意按 Electron 44.3.0 精确版本文档使用 await clipboard.writeText(text)，该版本返回 `Promise<void>`。常见旧教程把 writeText 写成同步调用，readText 直接返回字符串；不要把旧合同与当前版本混用。等待写入成功后才显示“已写入剪贴板”，拒绝则返回 COPY_FAILED，这样界面状态才有对应证据。

复制会覆盖系统剪贴板，这是用户明确点击按钮所请求的副作用。程序不在加载文件后自动复制，也不在退出时清空剪贴板。虽然这些操作技术上都能实现，它们改变了用户其他应用的工作状态，应该有明确产品意图。写入普通文本也避免将不可信内容作为富文本 HTML 传给别的应用。

## 通知的完成信号为什么更弱

new Notification 只是构造通知对象，还需要 show 才请求系统展示。isSupported 说明系统支持这类能力，不保证权限已授予、免打扰关闭或用户一定看见。我们返回 accepted，仅表达应用已经提交请求。把它显示成“用户已收到提醒”会夸大证据，真实送达确认需要不同机制。

本例通知标题与正文固定，不包含文件名或文件内容。通知可能出现在锁屏、共享屏幕或旁人可见的桌面上，敏感内容不应未经设计直接进入通知。频率限制避免页面连续调用造成打扰，也说明最小能力还包括调用次数，而不只是方法名称和参数类型。

Windows 通知还涉及应用标识、快捷方式与打包后的系统集成，macOS 和 Linux 也有各自权限及桌面环境差异。开发模式中一次成功或失败都不能代表打包后全部系统行为。基础章先让合同与失败提示正确，应用发布阶段再按目标平台完成集成验收，不在这里虚构跨平台通过结论。

## 实验三与练习：不要在异步写入完成前报告成功

练习实现一个复制适配器，限制文本长度，等待注入的写入函数，区分输入失败与系统失败。提示是返回值只能在 await 之后构造；测试中让写入先等待一个手动开关，证明函数没有提前宣布成功。

<details><summary>参考答案：完整异步合同测试</summary>

保存 clipboard-check.cjs，执行 node clipboard-check.cjs。它不访问真实剪贴板。

```js clipboard-check.cjs
'use strict';
const assert = require('node:assert/strict');
async function copyText(text, writeText) {
  if (typeof text !== 'string' || text.length > 20) {
    return { ok: false, code: 'BAD_INPUT' };
  }
  try {
    await writeText(text);
    return { ok: true };
  } catch {
    return { ok: false, code: 'COPY_FAILED' };
  }
}
async function main() {
  let release;
  let completed = false;
  const gate = new Promise(resolve => { release = resolve; });
  const pending = copyText('hello', async () => gate)
    .then(value => { completed = true; return value; });
  await Promise.resolve();
  assert.equal(completed, false);
  release();
  assert.deepEqual(await pending, { ok: true });
  assert.deepEqual(await copyText('hello', async () => { throw new Error('模拟系统失败'); }),
    { ok: false, code: 'COPY_FAILED' });
  assert.equal((await copyText('x'.repeat(21), async () => {})).code, 'BAD_INPUT');
  console.log('通过：等待写入、系统失败和长度拒绝');
}
main().catch(error => { console.error(error); process.exitCode = 1; });
```

</details>

## 从浏览器 File 对象到主进程文件句柄

浏览器里选择文件后经常拿到 File 对象，可以读取其内容；本章原生对话框返回的是主进程可用的路径字符串。路径不是内容本身，也不是一个会自动保持文件不变的快照。真正读取需要调用 Node 文件接口，它会向操作系统申请访问，可能因为权限、文件不存在或设备异常而失败。

open 返回一个文件句柄对象，可以理解为应用已经打开某个文件后的访问凭据。对这个句柄执行 stat 与 read，比检查路径后再随意重新打开更有清楚的资源归属。句柄需要 close，释放系统资源；JavaScript 垃圾回收并不是你应当依赖的正常关闭协议。示例在 finally 中关闭，意味着不论读取成功、超限还是解码失败，都有明确收尾。

Buffer 表示字节缓冲区，不等于 JavaScript 字符串。磁盘存储的是字节，字符串是程序解释编码后的文本。中文在 UTF-8 中通常占多个字节，表情也可能更多，所以文件大小限制和页面字符串长度限制不能直接当成同一单位。示例返回 bytes 给用户看实际读取字节数，复制接口的 length 则明确限制 JavaScript 字符串单元。

一次 read 可以只得到部分请求字节，这是接口允许的结果，不一定意味着失败。循环根据 bytesRead 推进偏移，直到零字节表示到达末尾，或者缓冲区达到上限。若不处理短读，文件尾部可能悄悄丢失；若不推进偏移，又可能覆盖前面已经读到的内容。这些机制与前端处理流式网络响应相通，只是资源来自本地文件。

## 用具体字节推演上限为什么需要多读一个

假设上限是八字节，文件最初报告只有一字节，但读取时实际出现九字节。只分配八字节并读满后停止，你无法区分“文件恰好八字节”与“后面还有内容”；如果直接返回，会悄悄截断而没有告诉用户。分配九字节，读到第九字节即可判定超限，随后拒绝结果并关闭句柄。

这不是让应用无限向前读取，而是一个固定的额外成本。无论实际文件有多大，本次读取最多占用上限加一的缓冲区。前面的 stat 大小检查用于尽早拒绝明显过大的文件，后面的有界读取用于防止检查后变化；二者目的不同，组合使用比只保留其中一个更容易解释。

如果文件读到一半被截短，循环可能较早到达末尾，示例返回实际读到的有效文本。若业务要求某一时刻的一致快照，需要更严格的文件版本或锁策略，本章查看器没有这种承诺。学习时应当把“安全地限制读取大小”与“保证内容从未变化”分开，不能因为加了一次 stat 就同时宣称两种能力。

严格 UTF-8 解码拒绝非法字节序列，避免把乱码当成正常内容继续处理。它不验证文本的业务意义，也不会过滤其中的 HTML 或脚本字符串；这些字符串能否执行由呈现方式决定。本例放到 textarea.value，只展示原文。若以后支持 Markdown，需要单独设计净化与链接策略，不能用“文件已通过编码校验”代替内容安全。

## 菜单是另一种入口，业务规则只有一份

菜单模板描述的是系统界面结构，label 决定显示文本，submenu 表达子项，accelerator 定义快捷键，click 是用户触发后的主进程回调。role 可以让系统处理常见编辑动作，减少手工实现复制、粘贴和撤销差异的工作。菜单对象不是 renderer 的 DOM，你不能用 querySelector 找到它再改属性。

同一操作可能从按钮、菜单和快捷键发起。若每个入口各自实现一遍文件读取，大小限制和取消逻辑很快会分叉：按钮拒绝大文件，菜单却能打开；按钮校验代次，快捷键却把旧结果写到新页面。把共享规则放在主进程业务函数中，再分别适配入口，可以让这类差异变成明显的代码结构问题。

菜单可见性也不是权限控制。禁用某个菜单项可以减少用户误操作，但页面若仍有对应桥方法，就仍能尝试调用。因此主进程必须检查当前窗口、任务状态和输入约束。本例即使连续点击按钮与菜单，也只有一个文件选择流程进入，另一个收到 BUSY；按钮上的 disabled 只是交互反馈，不是唯一防线。

在 macOS 上应用菜单位于系统菜单栏，即使没有窗口，应用仍可能存在；Windows 与 Linux 的菜单位置和快捷键行为不同。因此菜单回调不能无条件假设 mainWindow 一直有效。chooseText 的 current 检查会在没有窗口时返回状态错误，这比调用已销毁对象后抛出不可预期异常更清楚。

## 系统 API 的默认行为需要主动选择

文件选择框可配置默认目录、按钮文字和多选选项，但这些是用户体验，不应成为业务授权推导的依据。默认目录可能受系统最近使用位置、平台文件选择器及版本影响；需要稳定定位时应明确传入合法 defaultPath，同时允许用户自行选择。示例不强迫访问某个个人目录，减少初次运行的环境假设。

通知也不是越多越好。用户主动点一次演示按钮与后台每次状态变化都发通知，是两种不同产品行为。后者需要用户偏好、聚合规则、静默时段和可取消机制。基础示例用固定内容与频率上限，重点是解释系统提交合同，不暗中建立后台提醒功能。

剪贴板同样属于共享系统状态。复制操作成功以后，另一应用可能立刻覆盖它，因此稍后读回来不同内容并不一定表示本次写入失败。我们根据当前 API 完成结果反馈，不通过持续轮询验证“剪贴板永远属于我”。把共享资源视为暂时持有，能够避免桌面工具干扰用户其他工作。

## 从演示到真实应用时保留哪些边界

可以替换的是页面框架、按钮布局和文件解析业务；应保留的是路径来源、大小边界、错误分类、句柄释放和精确能力桥。如果需求变成导入多个文档，先定义总大小与并发限制，再增加多选；如果需求变成保存，先规定目标位置、覆盖确认和写入原子性，再开放保存动作。不要把“已经能打开文件”直接扩展成任意文件管理器。

本章只查看用户选中的小文本，读取并不持久化授权记录，也不在下一次启动时自动重开路径。若要做最近文件列表，需要考虑路径失效、权限变化和隐私展示；如果要跨启动恢复，必须把“曾经选择过”与“现在仍可访问”区分开。操作系统最终仍可能拒绝访问，应用必须处理现实结果。

## 故障推演、掌握标准与来源

扩展名通过却解码失败，应提示格式问题；文件选择后删除，应提示读取失败；取消选择应保留当前内容；页面重载后旧结果应被代次检查拒绝。这些情况不应统一变成“未知错误，请重装应用”。准确错误分类来自对每一层输入输出的理解。

掌握标准是能为四种系统能力分别指出用户动作、主进程入口、参数限制和完成证据。自测一：选择框 filters 能证明文件内容安全吗？不能。自测二：只需要复制为什么不公开整个剪贴板模块？读取与其他格式会扩大权限。自测三：通知 show 后能保证用户看见吗？不能，系统仍决定展示行为。

官方来源包括 [dialog](https://www.electronjs.org/docs/latest/api/dialog)、[Menu](https://www.electronjs.org/docs/latest/api/menu)、[44.3.0 剪贴板合同](https://github.com/electron/electron/blob/v44.3.0/docs/api/clipboard.md)、[Notification](https://www.electronjs.org/docs/latest/api/notification)、[通知教程](https://www.electronjs.org/docs/latest/tutorial/notifications) 与 [Node 文件接口](https://nodejs.org/api/fs.html)。离线句柄与异步复制测试实际执行，Electron 文件仅语法检查；文件选择、系统菜单、真实剪贴板和通知没有做 GUI 联调。
