文件选择、菜单、剪贴板与系统通知
从用户动作设计最小桌面能力,用有界文本查看器理解授权、系统结果与平台差异。
本页内容
桌面 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,不会读取你的真实文件,也不会创建临时文件。
'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 };
'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 会下载桌面二进制,安装以后查看器无需网络即可运行。
{
"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" }
}
'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();
});
'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);
};
}
});
<!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>
'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 });
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 之后构造;测试中让写入先等待一个手动开关,证明函数没有提前宣布成功。
参考答案:完整异步合同测试
保存 clipboard-check.cjs,执行 node 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; });
从浏览器 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、Menu、44.3.0 剪贴板合同、Notification、通知教程 与 Node 文件接口。离线句柄与异步复制测试实际执行,Electron 文件仅语法检查;文件选择、系统菜单、真实剪贴板和通知没有做 GUI 联调。