# 完整实战：Vue 桌面笔记应用

## 这个项目把前面的知识接成什么

我们做一个真正具有桌面边界的本地笔记程序。你使用熟悉的 Vue 编写界面，在 Electron 窗口里输入标题和正文，点击保存，由主进程把数据写到应用的数据目录。关闭应用以后再次启动，能重新读到上次保存的版本。整个业务过程不需要账号、模型服务或远程后端。

这个练习的难点不是文本框，而是让每一层承担正确职责。Vue 知道用户正在编辑什么；preload 提供少量明确的方法；主进程决定谁能调用这些方法、数据放在哪里；存储模块负责校验、排队、冲突和损坏文件保护。读完这一章，你应该能够从一次按钮点击追踪到磁盘变化，也能从磁盘错误反向找到界面上的提示。

先完成[Node 与 Electron 学习路线](node-electron-roadmap.html)中的基础章节。本章把全部文件集中展示，适合在理解单个机制后组装；如果你还不知道主进程是什么，直接复制整个目录运行，只能得到一个会启动的程序，难以判断哪里出了问题。

[下载完整桌面笔记实验源码](downloads/desktop-notes-lab.zip)。压缩包包含这里展示的全部源文件、依赖锁文件、说明和检查程序。正文不展开机器生成的 package-lock.json，下载包保留完整文件，可直接使用 npm ci。阅读教材仍可完全离线；第一次安装实验依赖及 Electron 二进制需要联网。

## 先看一次保存经过哪些边界

```text
用户输入标题和正文
    ↓ Vue 保存当前草稿，以及草稿基于的 revision
window.desktopNotes.save({ title, body, expectedRevision })
    ↓ preload 只转发到固定 notes:save 频道
主进程检查：指定窗口 + 主 frame + 允许的页面 URL
    ↓ 运行时校验字段；renderer 不能提供文件路径
存储模块：进入写队列 → 重新读磁盘版本 → 比较 expectedRevision
    ↓ 写同目录临时文件 → sync → close → rename
主进程返回 { ok: true, value: note }
    ↓ Vue 接收新的 revision，把当前草稿标记为已保存
```

这里有三种不同的“完成”。按钮被点击，只表示产生意图；IPC Promise 有响应，表示收到了主进程的结果；只有结果中的 ok 为 true，才表示存储步骤成功完成。你不能在发送请求时就把界面标成“已保存”，否则磁盘无权限时，用户看到的事实会与磁盘事实相反。

跨进程还会出现更微妙的情况：主进程可能已完成写入，但窗口在接收回复前消失。对于调用者，这时结果未知，不能自动推断为没有写入。本例保留当前草稿，并提示重新读取确认。更复杂的多步骤业务可以增加操作编号和查询接口；这里先用版本号把最小的一致性问题讲清楚。

## 环境与运行命令

本项目锁定 Vue 3.5.42、Vite 8.3.0、Electron 44.3.0。建议使用受支持的 Node 24 LTS 作为学习环境。本次安装、纯 Node 检查与 Vite 构建在 Windows 的 Node 22.22.0、npm 10.9.4 上实际完成；Electron 无窗口运行检查返回其内嵌 Node 24.20.0。终端 Node 与 Electron 内嵌 Node 是两个运行环境，不要求版本相等。

把源码解压到普通的学习目录，在该目录打开终端。以下命令中的 npm 在 Windows PowerShell 受脚本执行策略影响时，可以写成 npm.cmd。每次出现“在项目目录运行”，指的是能看到 package.json 的目录，而不是任意终端当前所在位置。

```powershell 首次安装与普通启动
# 依照锁文件安装本项目依赖；首次下载需要网络。
npm.cmd ci

# 先构建 Vue 页面，再启动 Electron。退出窗口后该命令结束。
npm.cmd start
```

开发时需要两个终端。第一个终端保持 Vite 运行，第二个终端启动 Electron。启动顺序有意义：若页面服务尚未就绪，桌面窗口加载页面会失败。端口固定为 5187，并使用 strictPort；端口被占用时明确报错，不悄悄换到另一端口，否则主进程允许的地址与实际页面不一致。

```powershell 终端一：Vue 热更新
npm.cmd run dev:web
```

```powershell 终端二：Electron 开发窗口
npm.cmd run dev:desktop
```

只在浏览器中打开 Vite 地址，会看到“网页预览”的提示，本地保存不可用。这不是接口故障，因为普通浏览器没有执行 Electron 的 preload。理解这个现象比在浏览器里模拟出一个假的 desktopNotes 全局对象更有价值：同一套界面可以渲染，不等于它拥有相同的系统能力。

## 项目结构与每个文件的用途

```text
desktop-notes/
├─ package.json                 脚本、入口与固定依赖
├─ package-lock.json            npm 生成的依赖锁文件，完整放在下载包
├─ index.html                   Vue 页面的 HTML 入口
├─ vite.config.mjs              构建、资源路径、开发/构建 CSP
├─ main.cjs                     应用、窗口、协议、IPC 和能力授权
├─ preload.cjs                  只暴露三个业务方法
├─ lib/
│  ├─ policy.cjs                页面地址和静态资源白名单
│  └─ repository.cjs            固定文件存储和一致性规则
├─ src/
│  ├─ main.js                   挂载 Vue
│  ├─ App.vue                   草稿、状态、表单和纯文本预览
│  └─ desktop.d.ts              编辑器可读的桥接契约
├─ scripts/
│  ├─ launch.cjs                启动本项目 Electron 子进程
│  └─ runtime-check.cjs         无窗口读取实际内嵌版本
├─ checks/
│  ├─ policy.cjs                地址授权边界检查
│  └─ repository.cjs            真实临时文件读写检查
├─ .gitignore                   排除依赖、产物和临时检查目录
└─ README.md                    操作说明与实验边界
```

只有 Vue 页面由 Vite 编译。main.cjs、preload.cjs 和 lib 中的文件保持 CommonJS，由 Electron 在对应环境中加载。这是有意选择的最小构建结构，先减少“哪一个构建器处理哪份代码”的干扰。等你能解释这条链路，再阅读工程章节中主进程打包、源码映射和原生依赖的取舍。

### package.json：应用入口与网页入口分开

main 字段指向 Electron 应用的入口文件。它不会自动把 Vue 的 src/main.js 当成应用主进程。scripts 中的 start 明确先执行 build，再通过启动脚本运行 Electron；因此 npm start 读取的是当前源码构建的页面，不依赖一个恰好存在的旧 dist。

```json package.json
{
  "name": "handbook-desktop-notes",
  "version": "1.0.0",
  "description": "Node 与 Electron 教材的本地桌面笔记实验",
  "private": true,
  "main": "main.cjs",
  "scripts": {
    "dev:web": "vite --host 127.0.0.1 --port 5187 --strictPort",
    "dev:desktop": "node scripts/launch.cjs dev",
    "build": "vite build",
    "start": "npm run build && node scripts/launch.cjs app",
    "check:runtime": "node scripts/launch.cjs check",
    "test": "node --test checks/repository.cjs checks/policy.cjs"
  },
  "dependencies": { "vue": "3.5.42" },
  "devDependencies": { "electron": "44.3.0", "vite": "8.3.0", "@vitejs/plugin-vue": "6.0.8" }
}
```

### HTML 与 Vite：资源从哪里来

HTML 中的 __CSP__ 是本项目构建插件的输入标记；插件在开发响应和正式构建时都会替换成完整策略。最终构建的 HTML 不应残留该标记。我们没有使用远程字体、CDN 脚本或在线图片，因此断开网络后已经安装好的实验仍可使用。

```html index.html
<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="Content-Security-Policy" content="__CSP__">
  <title>桌面笔记实验</title>
</head>
<body><div id="app"></div><script type="module" src="/src/main.js"></script></body>
</html>
```

base 为相对路径，让构建资源可以位于 app://notes/index.html 对应的目录中。开发模式下，Vite 会注入样式并通过本机 WebSocket 实现热更新，所以开发 CSP 允许这些具体行为。构建页面把样式输出为本地文件，策略随之收紧。开发策略中的 unsafe-inline 只用于样式，不是允许任意内联脚本。

```javascript vite.config.mjs
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig(({ command }) => {
  const development = command === 'serve';
  // Vite 开发模式注入样式并使用本机 WebSocket；构建产物收紧为本地资源。
  const policy = [
    "default-src 'self'", "script-src 'self'",
    development ? "style-src 'self' 'unsafe-inline'" : "style-src 'self'",
    development ? "connect-src 'self' ws://127.0.0.1:5187" : "connect-src 'self'",
    "img-src 'self' data:", "object-src 'none'", "frame-src 'none'",
    "base-uri 'none'", "form-action 'none'",
  ].join('; ');
  return {
    base: './',
    plugins: [vue(), { name: 'fixed-csp', transformIndexHtml: html => html.replace('__CSP__', policy) }],
    build: { outDir: 'dist', emptyOutDir: true },
  };
});
```

## 主进程：把系统权限变成业务能力

主进程不会向页面提供 readFile(path) 这样的通用入口，因为任意路径意味着任意本地文件的读取机会。它只提供读取笔记、保存笔记和读取运行版本。笔记的位置由 app.getPath('userData') 决定，页面提交的数据只包含业务字段。增加功能时应增加具体能力和校验，而不是把整个 Node 模块交给页面。

### 页面地址策略

页面信任与文件服务是两件事。isTrustedPage 判断“谁可以请求桌面能力”：开发只接受固定本机地址，普通启动只接受指定自定义协议页面。assetPath 判断“协议可以服务什么”：只接受入口 HTML 和 assets 下的构建资源，返回相对路径。它不把 URL 直接拼接成任意磁盘路径。

对 URL 应当先解析，再比较 protocol、host、port 和 pathname。字符串包含判断会错误接受 evil-example.com 中的 example.com，简单前缀判断也容易混淆凭证、端口和路径。自定义协议在 Node 的 URL 实现中可能得到 null origin，因此本例显式比较各组成部分；不能把所有 origin 为 null 的地址都视为同一个可信来源。

```javascript lib/policy.cjs
function isTrustedPage(raw, development) {
  try {
    const url = new URL(raw);
    if (url.username || url.password || url.search) return false;
    if (development) {
      return url.protocol === 'http:' && url.hostname === '127.0.0.1'
        && url.port === '5187' && url.pathname === '/';
    }
    // 自定义协议在 Node URL 中不一定有非 null 的 origin，因此逐项比较。
    return url.protocol === 'app:' && url.host === 'notes' && url.pathname === '/index.html';
  } catch { return false; }
}

function assetPath(raw) {
  try {
    const url = new URL(raw);
    if (url.protocol !== 'app:' || url.host !== 'notes' || url.username || url.password || url.search) return null;
    if (url.pathname === '/index.html') return 'index.html';
    // 只服务 Vite 产物：不允许任意文件读取，也不允许路径穿越。
    if (/^\/assets\/[A-Za-z0-9._-]+$/.test(url.pathname)) return url.pathname.slice(1);
    return null;
  } catch { return null; }
}
module.exports = { isTrustedPage, assetPath };
```

### 应用与窗口入口

registerSchemesAsPrivileged 必须在 app ready 之前调用，真正处理请求的 protocol.handle 在 ready 之后注册。程序使用默认 session，协议和权限策略也注册在这份 session 上；如果以后给窗口设置独立 partition，需要在对应 session 中重新处理协议，而不是以为全局代码会自动覆盖所有会话。

trusted 同时检查 webContents、主 frame 和实际页面地址。只检查频道名称没有意义，因为 renderer 可以发送同名请求；只检查 webContents 也不完整，因为同一窗口中的子 frame 不应继承所有主页面能力。这里的判断发生在 main，而不是依赖页面自觉调用正确接口。

```javascript main.cjs
const { app, BrowserWindow, ipcMain, protocol, net, session } = require('electron');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
const { createRepository } = require('./lib/repository.cjs');
const { isTrustedPage, assetPath } = require('./lib/policy.cjs');

// 协议特权必须在 app ready 之前注册；只用于这个应用自己的本地界面。
protocol.registerSchemesAsPrivileged([{ scheme: 'app', privileges: {
  standard: true, secure: true, supportFetchAPI: true, corsEnabled: true,
} }]);
const development = !app.isPackaged && process.argv.includes('--dev');
const gotLock = app.requestSingleInstanceLock();
let window;
const messages = {
  INVALID_INPUT: '标题需为 1–80 个码点，正文不超过 20000 个码点；请求格式必须正确。',
  REVISION_CONFLICT: '本地笔记版本已经变化。当前输入已保留，请先复制草稿，再重新读取。',
  CORRUPT_STORAGE: '本地文件格式损坏，已停止写入以保留原文件。请先备份并排查。',
  REVISION_LIMIT: '笔记版本已超出实验支持范围。',
  FORBIDDEN: '该页面不能调用桌面能力。',
  STORAGE_ERROR: '读写本地文件失败，请检查磁盘权限和主进程日志。',
};
function trusted(event) {
  return window && !window.isDestroyed() && event.sender === window.webContents
    && event.senderFrame === window.webContents.mainFrame
    && isTrustedPage(event.senderFrame.url, development);
}
function handle(channel, operation) {
  ipcMain.handle(channel, async (event, input) => {
    if (!trusted(event)) return { ok: false, error: { code: 'FORBIDDEN', message: messages.FORBIDDEN } };
    try { return { ok: true, value: await operation(input) }; }
    catch (error) {
      const code = Object.hasOwn(messages, error.code) ? error.code : 'STORAGE_ERROR';
      console.error('desktop-operation-failed', { channel, code, systemCode: error.code });
      return { ok: false, error: { code, message: messages[code] } };
    }
  });
}
function createWindow() {
  window = new BrowserWindow({ width: 940, height: 840, minWidth: 640, minHeight: 560,
    webPreferences: { preload: path.join(__dirname, 'preload.cjs'), contextIsolation: true, sandbox: true, nodeIntegration: false },
  });
  window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
  window.webContents.on('will-navigate', (event, target) => { if (!isTrustedPage(target, development)) event.preventDefault(); });
  window.on('closed', () => { window = null; });
  const entry = development ? 'http://127.0.0.1:5187/' : 'app://notes/index.html';
  window.loadURL(entry).catch(error => console.error('page-load-failed', error.code));
}

if (!gotLock) app.quit();
else {
  app.on('second-instance', () => {
    if (window && !window.isDestroyed()) { if (window.isMinimized()) window.restore(); window.focus(); }
  });
  app.whenReady().then(() => {
    const repository = createRepository(path.join(app.getPath('userData'), 'learning-notes'));
    protocol.handle('app', request => {
      const relative = assetPath(request.url);
      if (!relative) return new Response('Not found', { status: 404 });
      return net.fetch(pathToFileURL(path.join(__dirname, 'dist', relative)).href);
    });
    session.defaultSession.setPermissionRequestHandler((_contents, _permission, callback) => callback(false));
    session.defaultSession.setPermissionCheckHandler(() => false);
    handle('notes:load', () => repository.load());
    handle('notes:save', input => repository.save(input));
    handle('runtime:versions', () => ({ electron: process.versions.electron, node: process.versions.node, chrome: process.versions.chrome }));
    createWindow();
    app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });
  }).catch(error => { console.error('startup-failed', error.message); app.quit(); });
  app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });
}
```

窗口关闭和应用退出存在平台差异。示例在 Windows、Linux 的全部窗口关闭时退出；macOS 按常见行为保留应用，激活后没有窗口则重新创建。单实例锁避免两个应用主进程同时操作同一个小型 JSON 仓库，但它不是数据库事务，也不能防止其他程序手动修改文件。

本例禁止新窗口、未允许的页面导航和系统权限请求。它只需要本地文本编辑，因此没有必要开放摄像头、麦克风或外部网页。如果未来增加外部链接，应做协议与目标白名单，再在主进程使用专门方法打开；不要删除当前检查来让所有跳转“先跑起来”。

## preload：一张很小的能力清单

preload 在渲染进程的隔离上下文中执行，不是独立进程。contextBridge 把指定的方法提供给页面。sandbox 为 true 时 preload 的 Node 能力受限；这个文件只需要 Electron 提供的 contextBridge 和 ipcRenderer，不尝试加载文件系统或任意本地模块。

下面没有暴露通用 send(channel, payload)，也没有暴露 ipcRenderer 本身。调用方不能通过改一个频道字符串变成系统命令执行器。每个方法对应一项可说明、可检查的业务能力，这会让前后端契约一样清晰地出现在桌面边界上。

```javascript preload.cjs
const { contextBridge, ipcRenderer } = require('electron');

// 页面只能调用这三个具体能力，不能指定频道、文件路径或主进程函数。
contextBridge.exposeInMainWorld('desktopNotes', {
  load: () => ipcRenderer.invoke('notes:load'),
  save: input => ipcRenderer.invoke('notes:save', input),
  versions: () => ipcRenderer.invoke('runtime:versions'),
});
```

| 页面方法 | 参数 | 成功结果 value | 可能失败 |
| --- | --- | --- | --- |
| load() | 无 | schemaVersion、revision、title、body | 文件格式损坏、权限或磁盘错误 |
| save(input) | title、body、expectedRevision，必须恰好这些字段 | 已保存笔记及增加后的 revision | 输入无效、版本冲突、文件损坏、写入错误 |
| versions() | 无 | electron、node、chrome 版本字符串 | 通信失败或页面来源被拒绝 |

所有方法都返回 Promise。业务失败使用 { ok: false, error: { code, message } }，通信本身失败仍可能让 Promise reject，因此 Vue 既要检查 ok，也要有 try/catch。把所有错误都写成一个 catch，会丢失“业务被拒绝”和“根本没收到可信回复”的区别。

## 存储：为什么一行 writeFile 还不够

我们保存的文档包括 schemaVersion 和 revision。schemaVersion 描述磁盘结构，未来格式迁移时使用；revision 描述这份笔记被成功修改的次数，防止旧草稿覆盖新状态。两个版本回答不同的问题，不能混成一个字段。磁盘没有文件时，返回 revision 为零的默认笔记；只有保存成功后才出现 revision 为一的实际文件。

保存必须在队列内重新读取磁盘，再比较 expectedRevision。如果在排队之前检查版本，两个请求可能都看到版本零，并都认为自己有权保存；进入队列只会让两个错误写入按顺序发生。这里把“读当前值、校验版本、生成下一版、写入”放在同一串行任务中，确保同一仓库实例内第二个请求看见第一步已经提交的结果。

Promise 链是队列的一种最小实现：每次任务接在 tail 后面，新的 tail 捕获该任务的失败，但返回给调用者的仍是原始 job。这样调用者会收到错误，队列又能继续处理后续请求。如果直接把 rejected 的 job 作为下一次任务的前置，后续 then 永远不会执行，第一次失败就会把整个仓库锁死。

```javascript lib/repository.cjs
const fs = require('node:fs/promises');
const path = require('node:path');
const { randomUUID } = require('node:crypto');

class AppError extends Error {
  constructor(code) { super(code); this.code = code; }
}
function validateInput(input) {
  if (!input || typeof input !== 'object' || Array.isArray(input)) throw new AppError('INVALID_INPUT');
  const keys = ['title', 'body', 'expectedRevision'];
  if (Object.keys(input).length !== keys.length || !keys.every(key => Object.hasOwn(input, key))) throw new AppError('INVALID_INPUT');
  if (typeof input.title !== 'string' || typeof input.body !== 'string') throw new AppError('INVALID_INPUT');
  const title = input.title.trim();
  if ([...title].length < 1 || [...title].length > 80 || [...input.body].length > 20000) throw new AppError('INVALID_INPUT');
  if (!Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0) throw new AppError('INVALID_INPUT');
  return { title, body: input.body, expectedRevision: input.expectedRevision };
}
function validateStored(value) {
  if (!value || value.schemaVersion !== 1 || !Number.isSafeInteger(value.revision) || value.revision < 1) throw new AppError('CORRUPT_STORAGE');
  try { validateInput({ title: value.title, body: value.body, expectedRevision: value.revision }); }
  catch { throw new AppError('CORRUPT_STORAGE'); }
  return { schemaVersion: 1, revision: value.revision, title: value.title, body: value.body };
}

function createRepository(directory) {
  // directory 由 main 决定，renderer 从来不能传入目录或文件名。
  const filename = path.join(directory, 'note.json');
  let tail = Promise.resolve();
  async function readDisk() {
    let text;
    try { text = await fs.readFile(filename, 'utf8'); }
    catch (error) {
      if (error.code === 'ENOENT') return { schemaVersion: 1, revision: 0, title: '第一篇笔记', body: '' };
      throw error;
    }
    let value;
    try { value = JSON.parse(text); } catch { throw new AppError('CORRUPT_STORAGE'); }
    return validateStored(value);
  }
  function load() { return tail.then(readDisk); }
  function save(raw) {
    const job = tail.then(async () => {
      const input = validateInput(raw);
      const previous = await readDisk();
      if (input.expectedRevision !== previous.revision) throw new AppError('REVISION_CONFLICT');
      if (previous.revision === Number.MAX_SAFE_INTEGER) throw new AppError('REVISION_LIMIT');
      const next = { schemaVersion: 1, revision: previous.revision + 1, title: input.title, body: input.body };
      await fs.mkdir(directory, { recursive: true });
      const temporary = path.join(directory, `note-${randomUUID()}.tmp`);
      let handle;
      let created = false;
      try {
        handle = await fs.open(temporary, 'wx', 0o600);
        created = true; // 只有创建成功，失败清理时才有权删除这个临时文件。
        await handle.writeFile(JSON.stringify(next, null, 2), 'utf8');
        await handle.sync();
        await handle.close(); handle = null;
        // 先写完同目录临时文件再替换；绝不先删除现有 note.json。
        await fs.rename(temporary, filename);
      } catch (error) {
        if (handle) await handle.close().catch(() => {});
        if (created) await fs.unlink(temporary).catch(() => {});
        throw error;
      }
      return next;
    });
    // 某次保存失败不应让队列永久停在 rejected 状态。
    tail = job.catch(() => {});
    return job;
  }
  return { load, save };
}
module.exports = { createRepository, AppError, validateInput };
```

临时文件采用同目录、随机名字和 wx 排他创建。写完内容后同步文件、关闭句柄，再重命名到正式文件。这样可以减少直接截断正式文件后只写了一半的风险。若排他创建失败，程序不会删除那个未由自己创建的路径；若自己创建成功后写失败，则清理自己的临时文件，并保留旧笔记。

这仍然是教学用小型 JSON 仓库。它没有跨进程事务、多个文档的原子提交或所有文件系统上的断电持久性保证；文件 sync 也不能替代完整的数据库恢复协议。Windows 杀毒软件、文件占用和权限策略可能让 rename 失败，正确响应是报告错误、保留原文件，而不是先删除原文件再重试。需要多表关系或持续高频写入时，应该评估数据库，并学习事务和迁移。

代码按 Unicode 码点计数标题和正文，而不是按 UTF-16 的 length。组合字符仍可能由多个码点组成，所以这不等于用户视觉上的“字数”；教材明确这个边界，避免把一个技术限制宣传成完整自然语言字符计数器。示例把输入限制在较小范围，不能拿 readFile 加 JSON.parse 直接处理任意大文件。

## Vue：区分草稿、忙碌状态与保存事实

Vue 中的 title 和 body 是可编辑草稿；saved 是上次可信结果的快照；revision 是草稿所依据的磁盘版本。dirty 通过比较草稿快照与 saved 得到。它不直接比较一个会不断变化的“默认对象”，也不会因为点击保存就立即变成 false。

本例在一次读取或保存期间禁用表单，避免用户在等待回复时继续输入，而 accept 用较旧结果覆盖新输入。真实复杂编辑器也可以不禁用输入，但那需要保存开始时的草稿版本、当前草稿版本与磁盘版本分别建模；先理解这个最小模型，再增加并发编辑体验。

```javascript src/main.js
import { createApp } from 'vue';
import App from './App.vue';
createApp(App).mount('#app');
```

```typescript src/desktop.d.ts
// 编辑器类型提示不会替代 main 中的运行时校验。
export {};
type Note = { schemaVersion: 1; revision: number; title: string; body: string };
type Result<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string } };
declare global {
  interface Window {
    desktopNotes?: {
      load(): Promise<Result<Note>>;
      save(input: { expectedRevision: number; title: string; body: string }): Promise<Result<Note>>;
      versions(): Promise<Result<{ electron: string; node: string; chrome: string }>>;
    };
  }
}
```

声明文件帮助编辑器知道桥接方法的形状，不会在运行时阻止非法输入。本例 App.vue 使用 JavaScript，构建并未执行全项目 TypeScript 检查；主进程中的 validateInput 才是执行时的约束。即使以后把界面改成 TypeScript，外部数据也仍然要校验。

```vue src/App.vue
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';

const bridge = window.desktopNotes;
// Vue 管理可编辑草稿；磁盘版本只有在 main 确认成功后才进入 saved。
const title = ref('');
const body = ref('');
const revision = ref(0);
const saved = ref('');
const ready = ref(false);
const busy = ref(false);
const status = ref(bridge ? '正在读取本地笔记…' : '这是网页预览；请启动 Electron 窗口体验本地保存。');
const versions = ref(null);
const snapshot = () => JSON.stringify({ title: title.value, body: body.value });
const dirty = computed(() => ready.value && snapshot() !== saved.value);

function accept(note) {
  title.value = note.title; body.value = note.body; revision.value = note.revision;
  saved.value = snapshot(); ready.value = true;
}
async function load() {
  if (!bridge || busy.value) return;
  if (dirty.value && !window.confirm('重新读取会放弃尚未保存的修改，继续吗？')) return;
  busy.value = true;
  try {
    const result = await bridge.load();
    if (!result.ok) { status.value = result.error.message; return; }
    accept(result.value); status.value = `已读取本地版本 ${revision.value}`;
  } catch { status.value = '桌面通信失败，请查看主进程日志。'; }
  finally { busy.value = false; }
}
async function save() {
  if (!bridge || busy.value || !ready.value || !dirty.value) return;
  busy.value = true;
  try {
    // expectedRevision 表示草稿从哪个磁盘版本产生，防止旧草稿覆盖新版本。
    const result = await bridge.save({ expectedRevision: revision.value, title: title.value, body: body.value });
    if (!result.ok) { status.value = result.error.message; return; }
    accept(result.value); status.value = `已保存为本地版本 ${revision.value}`;
  } catch { status.value = '保存结果未知。请先重新读取确认，不要假设已经保存。'; }
  finally { busy.value = false; }
}
function onKey(event) {
  if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
    event.preventDefault(); save();
  }
}
onMounted(async () => {
  window.addEventListener('keydown', onKey);
  await load();
  if (bridge) {
    try { const result = await bridge.versions(); if (result.ok) versions.value = result.value; }
    catch { status.value = '笔记已加载，但运行版本读取失败。'; }
  }
});
onUnmounted(() => window.removeEventListener('keydown', onKey));
</script>

<template>
  <main>
    <header><p class="eyebrow">Electron × Vue 学习实验</p><h1>本地笔记</h1></header>
    <p>笔记只保存在这个桌面应用的数据目录。请手动保存；关闭窗口不会自动保存未提交的修改。</p>
    <form @submit.prevent="save">
      <label for="title">标题</label>
      <input id="title" v-model="title" :disabled="busy || !ready" autocomplete="off">
      <label for="body">正文</label>
      <textarea id="body" v-model="body" rows="13" :disabled="busy || !ready"></textarea>
      <div class="actions">
        <button type="submit" :disabled="busy || !ready || !dirty">保存 · Ctrl / ⌘ S</button>
        <button type="button" :disabled="busy || !bridge" @click="load">重新读取</button>
        <span>{{ busy ? '正在处理' : dirty ? '有未保存修改' : '与读取版本一致' }}</span>
      </div>
    </form>
    <p class="status" role="status" aria-live="polite">{{ status }}</p>
    <section aria-labelledby="preview-title"><h2 id="preview-title">纯文本预览</h2><pre>{{ body || '正文会显示在这里。' }}</pre></section>
    <p v-if="versions" class="versions">Electron {{ versions.electron }} · 内嵌 Node {{ versions.node }} · Chromium {{ versions.chrome }}</p>
  </main>
</template>

<style scoped>
main{max-width:800px;margin:32px auto;padding:0 28px;font:16px/1.8 system-ui;color:#253248}
.eyebrow,.versions{font-size:13px;color:#647080}h1{font-size:32px;margin:0}h2{font-size:18px}
label{display:block;margin-top:16px;font-weight:600}input,textarea{box-sizing:border-box;width:100%;font:inherit;padding:10px;border:1px solid #bac5c0;border-radius:5px;background:white;color:inherit}
textarea{resize:vertical}button{font:inherit;border:1px solid #197a55;border-radius:5px;padding:6px 13px;background:#197a55;color:white;cursor:pointer}
button+button{background:white;color:#197a55}button:disabled{opacity:.5;cursor:default}.actions{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:16px}.actions span{font-size:14px}
.status{background:#eef5f1;padding:12px}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:inherit;background:#f6f6f7;padding:16px}
input:focus-visible,textarea:focus-visible,button:focus-visible{outline:2px solid #197a55;outline-offset:3px}
</style>
```

重新读取可能覆盖尚未保存的草稿，所以 load 先确认。版本冲突时不调用 accept，保留用户输入，提示复制草稿再读取。正文用插值和 pre 展示，输入的 HTML 只是文本；如果改成 Markdown，需要重新设计内容清理与链接策略，不能直接换成 v-html。

快捷键使用同一个 save 函数，不另写一套保存规则；组件卸载时移除监听，避免重复挂载以后一次按键触发多次请求。本例明确采用手动保存，关闭窗口不会自动保存未提交修改。增加关闭前提醒时，应同时考虑窗口关闭、应用退出和系统关机，不只处理一个页面的点击事件。

## 启动脚本：终端 Node 与 Electron 的关系

scripts/launch.cjs 本身由终端 Node 执行。require('electron') 在这个环境返回已安装的 Electron 可执行文件路径，spawn 用它创建子进程。子进程里 main.cjs 的 require('electron') 才返回 Electron 的应用 API。看上去是同一句 require，含义由当前运行环境决定。

部分工具环境会设置 ELECTRON_RUN_AS_NODE，使 Electron 以类似 Node 的方式运行。本例只从即将启动的子进程环境中移除这个值，不修改系统设置，也不覆盖整个 process.env。windowsHide 隐藏额外控制台窗口，不会阻止用户运行 npm start 时看到桌面应用。

```javascript scripts/launch.cjs
const path = require('node:path');
const { spawn } = require('node:child_process');
const electron = require('electron');
const root = path.join(__dirname, '..');
const mode = process.argv[2];
if (!['app', 'dev', 'check'].includes(mode)) throw new Error('模式必须是 app、dev 或 check');
const args = mode === 'check' ? [path.join(__dirname, 'runtime-check.cjs')]
  : [root, ...(mode === 'dev' ? ['--dev'] : [])];
const childEnv = { ...process.env };
// 只修改子进程副本，避免某些编辑器的环境变量把 Electron 当普通 Node 启动。
delete childEnv.ELECTRON_RUN_AS_NODE;
const child = spawn(electron, args, { cwd: root, env: childEnv, stdio: 'inherit', windowsHide: true });
child.on('error', error => { console.error(error.message); process.exitCode = 1; });
child.on('exit', code => { process.exitCode = code ?? 1; });
```

```javascript scripts/runtime-check.cjs
const { app } = require('electron');
// 无窗口检查：证明实际启动了 Electron 运行时，不验证界面或 IPC。
console.log(JSON.stringify({ electron: process.versions.electron, node: process.versions.node, chrome: process.versions.chrome, platform: process.platform }));
app.exit(0);
```

```powershell 单独检查运行环境
npm.cmd run check:runtime
```

本次无窗口实际输出如下。它证明 Electron 二进制能启动、Electron API 能加载，并确认内嵌版本；它没有创建 BrowserWindow，因此不证明 preload、IPC 或文件界面操作已通过桌面验收。

```json 本次 Windows 运行输出
{"electron":"44.3.0","node":"24.20.0","chrome":"152.0.7977.78","platform":"win32"}
```

## 自动检查：分别证明存储规则与地址规则

测试使用 Node 自带的 node:test。存储检查操作自己创建的临时目录，在清理前核对绝对路径和目录前缀，确保不会删除学习者的真实笔记。新建仓库实例重新读取能证明数据确实写进文件，而不是只停留在上一个对象的内存里。

```javascript checks/repository.cjs
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const { createRepository, validateInput } = require('../lib/repository.cjs');

async function inDirectory(run) {
  const base = path.resolve(__dirname, '../.check-data');
  await fs.mkdir(base, { recursive: true });
  const directory = await fs.mkdtemp(path.join(base, 'case-'));
  try { await run(directory); }
  finally {
    assert.equal(path.dirname(directory), base);
    assert.ok(path.basename(directory).startsWith('case-'));
    await fs.rm(directory, { recursive: true, force: true });
  }
}
test('输入校验不会接受路径、越界或错误版本', () => {
  const valid = { title: '笔记', body: '你好', expectedRevision: 0 };
  assert.equal(validateInput(valid).title, '笔记');
  assert.throws(() => validateInput({ ...valid, path: 'other.json' }));
  assert.throws(() => validateInput({ ...valid, expectedRevision: -1 }));
  assert.throws(() => validateInput({ ...valid, body: 'a'.repeat(20001) }));
});
test('首次读取、保存与新仓库实例重新读取', () => inDirectory(async directory => {
  const repository = createRepository(directory);
  assert.equal((await repository.load()).revision, 0);
  const saved = await repository.save({ expectedRevision: 0, title: '第一天', body: '主进程控制系统能力。' });
  assert.equal(saved.revision, 1);
  assert.deepEqual(await createRepository(directory).load(), saved);
}));
test('同一进程并发提交旧版本，只允许一个成功', () => inDirectory(async directory => {
  const repository = createRepository(directory);
  const input = { expectedRevision: 0, title: '并发', body: '同一个起点' };
  const results = await Promise.allSettled([repository.save(input), repository.save(input)]);
  assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
  assert.equal(results.find(result => result.status === 'rejected').reason.code, 'REVISION_CONFLICT');
  assert.equal((await repository.load()).revision, 1);
  await repository.save({ ...input, expectedRevision: 1 });
  assert.equal((await repository.load()).revision, 2); // 失败没有毒化写队列。
}));
test('损坏的原文件不会被默认数据静默覆盖', () => inDirectory(async directory => {
  const filename = path.join(directory, 'note.json');
  await fs.writeFile(filename, 'broken-content');
  const repository = createRepository(directory);
  await assert.rejects(() => repository.load(), error => error.code === 'CORRUPT_STORAGE');
  await assert.rejects(() => repository.save({ expectedRevision: 0, title: '不覆盖', body: '' }));
  assert.equal(await fs.readFile(filename, 'utf8'), 'broken-content');
}));
```

```javascript checks/policy.cjs
const test = require('node:test');
const assert = require('node:assert/strict');
const { isTrustedPage, assetPath } = require('../lib/policy.cjs');
test('只信任当前本地应用页面或指定开发入口', () => {
  assert.equal(isTrustedPage('app://notes/index.html', false), true);
  assert.equal(isTrustedPage('http://127.0.0.1:5187/', true), true);
  for (const url of ['app://evil/index.html', 'app://notes/assets/a.js', 'https://example.test/', 'app://notes/index.html?script=x']) {
    assert.equal(isTrustedPage(url, false), false);
  }
  assert.equal(isTrustedPage('http://127.0.0.1:5188/', true), false);
});
test('协议仅映射构建产物的受限路径', () => {
  assert.equal(assetPath('app://notes/index.html'), 'index.html');
  assert.equal(assetPath('app://notes/assets/a-123.js'), 'assets/a-123.js');
  for (const url of ['app://notes/../../secret.txt', 'app://notes/assets/%2e%2e/secret', 'file:///c:/secret', 'app://notes/assets/a%2Fb.js']) {
    assert.equal(assetPath(url), null);
  }
});
```

```powershell 在项目目录运行
npm.cmd test
npm.cmd run build
```

本次六项测试通过，Vite 构建成功。并发检查故意从同一个 expectedRevision 提交两个保存，要求仅一个成功，并确认冲突后队列还能继续。这比只检查 save 返回一个对象更有意义，因为它对应用户可能失去数据的失败条件。损坏文件检查则确保程序不会把读取失败误当作“第一次启动”，然后覆盖原文件。

### 手动桌面验收清单

以下是需要在真实窗口执行的步骤和预期，本次没有代替你完成这些 GUI 操作。不要把“步骤写在教材里”理解为“所有平台已验证”。

| 操作 | 预期结果 | 失败时先检查 |
| --- | --- | --- |
| npm start | 出现本地笔记窗口，无远程服务依赖 | 构建输出、Electron 下载、主进程启动日志 |
| 修改正文并保存 | 提示保存的新版本，未保存标记消失 | bridge 是否存在、main 校验、userData 权限 |
| 退出再启动 | 读取最后一次成功保存的标题和正文 | 实际数据目录、应用身份、读取错误 |
| 输入空白标题并保存 | 显示输入错误，草稿仍保留 | 主进程 validateInput 与返回契约 |
| 修改草稿后点重新读取 | 先确认，取消则保持草稿 | dirty 与 saved 的更新时机 |
| 打开普通浏览器预览 | 明确显示网页预览，本地保存不可用 | 不应为了预览关闭沙箱 |
| 第二次启动同一应用 | 聚焦已有窗口，而非并发写同一仓库 | 单实例锁和 second-instance 处理 |
| 按 Ctrl / Command + S | 复用同一保存逻辑，不弹网页另存为 | 键盘监听、preventDefault 与 busy 状态 |

## 练习一：新增一个有界的“复制正文”能力

先说清契约：页面允许请求把一段不超过两万个码点的纯文本写入剪贴板；主进程校验来源和参数；返回成功事实。不要实现通用 clipboard(method, args)，也不要把剪贴板模块整个暴露给页面。这里只给出与现有工程配合的完整新增代码段，每段明确放置位置；它不是另一套可以脱离主项目运行的程序。

<details><summary>参考实现与验证要点</summary>

在 main.cjs 的 Electron 解构导入中增加 clipboard，并在 app.whenReady 内现有 handle 注册处加入下列处理器。复用的 handle 已包含来源校验和结果封装，所以 operation 只负责这项业务的参数与操作。

```javascript main.cjs 新增处理器
handle('notes:copy-text', async input => {
  if (typeof input !== 'string' || [...input].length > 20000) {
    const error = new Error('INVALID_INPUT');
    error.code = 'INVALID_INPUT';
    throw error;
  }
  await clipboard.writeText(input); // Electron 44 的此 API 返回 Promise，完成后再报告成功。
  return { copied: true };
});
```

preload.cjs 中传给 exposeInMainWorld 的对象增加下面这个方法。页面调用 window.desktopNotes.copyText(body.value)，检查 ok 后再显示成功；通信失败仍需要 catch。

这里特意使用 await：本项目固定版本 Electron 44.3.0 的 clipboard.writeText 返回 Promise。不能照搬早期同步示例，在剪贴板写入完成前就向页面报告成功。升级时应查[对应版本的 clipboard 契约](https://github.com/electron/electron/blob/v44.3.0/docs/api/clipboard.md)。

```javascript preload.cjs 对象中的新增成员
copyText: text => ipcRenderer.invoke('notes:copy-text', text),
```

desktop.d.ts 中 Window.desktopNotes 的对象类型增加对应声明，参数为 string，返回 Promise<Result<{ copied: true }>>。同时把 INVALID_INPUT 文案改为适用于多种能力的通用提示，或为复制增加独立错误码，避免复制失败时显示“标题长度错误”。验收应包含中文、空字符串、超长正文、非字符串以及页面来源拒绝；真正的系统剪贴板结果必须在桌面环境检查。

</details>

## 练习二：解释一次版本冲突，而不是绕过它

设磁盘版本为五，窗口甲和窗口乙都基于版本五编辑。甲先保存成功到版本六，乙还提交 expectedRevision 为五。请解释为什么乙应该收到冲突，以及为什么“失败后自动把 expectedRevision 改成六再保存”会破坏保护作用。

<details><summary>参考答案</summary>

乙的草稿没有包含甲的修改。如果自动替换版本号重试，就等于让乙声称自己已经基于版本六编辑，但这个声明是假的。正确做法是保留乙的草稿，读取当前版本，让用户比较并决定如何合并，再基于已确认的新版本提交。后续可以做字段级合并或编辑历史，但不能通过删除冲突检查来获得表面的成功率。

当前应用只有一个窗口和单实例入口，存储检查仍使用两个并发请求验证这一规则，因为请求可能来自重复动作或后续扩展。它证明的是同一仓库实例中的顺序与版本约束，不证明两个独立进程同时写文件的正确性。若需求扩展到多进程写入，需引入可靠事务或专门存储服务。

</details>

## 练习三：损坏数据为什么必须留下证据

读取 note.json 时，ENOENT 与 JSON 解析失败能否都返回默认空笔记？如果不能，它们分别代表什么？请画出默认数据被错误保存后覆盖原内容的路径。

<details><summary>参考答案</summary>

ENOENT 表示目标文件不存在，首次运行可以合理返回默认内容。解析失败意味着文件存在，但当前程序无法解释它；这可能是内容损坏，也可能是程序版本与格式不兼容。此时返回默认数据会让界面看起来像首次启动，再次保存就可能用空内容覆盖唯一证据。正确处理是停止写入、提示错误、保留原文件，并先复制备份再人工排查。不能仅因为用户希望“能打开窗口”，就把数据损坏隐藏成成功。

</details>

## 收尾文件与本章验收

```text .gitignore
node_modules/
dist/
.check-data/
.runtime-check/
```

```markdown README.md
# Vue + Electron 本地笔记实验

手册「完整 Vue 桌面笔记实验」的全部源码。Vue 3.5.42、Vite 8.3.0、Electron 44.3.0。

需要本机 Node（建议受支持的 Node 24 LTS）和 npm。安装及首次获取 Electron 二进制需要联网；本应用不调用模型、不上传笔记、不需要账号。

## 运行

1. 在本目录执行 `npm ci`；Windows 可使用 `npm.cmd ci`。
2. `npm start`：构建 Vue 页面并启动 Electron 窗口。
3. 输入标题和正文，手动保存。关闭再打开后重新读取已保存版本。

开发时开两个终端：先运行 `npm run dev:web`，再运行 `npm run dev:desktop`。单独访问网页只能看预览，不能访问 preload 的本地保存能力。

## 验证

- `npm test`：输入、读写、版本冲突、损坏文件保护和协议路径的 Node 检查。
- `npm run build`：Vue 编译。
- `npm run check:runtime`：启动无窗口 Electron，输出其内嵌运行时版本；这不是 GUI 或 IPC 验收。

真实桌面保存、键盘、重启与开发模式需按教材中的步骤操作。本实验没有安装包、签名、自动更新或真实 GUI 自动化验收。

## 保存规则

笔记写入 `app.getPath('userData')` 下的 `learning-notes/note.json`，由主进程确定位置。renderer不能传入路径或任意IPC频道。主进程串行处理保存，并要求版本匹配；损坏文件会报错并保留。

只在点击保存成功后提交数据，关闭窗口不会自动保存未提交修改。同目录临时写入加重命名减少半写文件的风险，不承诺所有文件系统上的断电持久性；写队列仅协调当前进程。本例使用单实例锁，不能把它当作多进程数据库。
```

你应能在不关闭 contextIsolation 或 sandbox 的情况下运行项目，解释三个运行位置，追踪一次保存的参数、来源校验、磁盘步骤和返回状态。你还应能证明旧版本提交被拒绝、失败不锁死队列、损坏文件不被默认值覆盖，并说明纯 Node 检查、Vue 构建、Electron 无窗口启动与实际桌面验收各自证明了什么。

进一步的产品化学习进入[本地数据](electron-local-data.html)、[后台任务](electron-background-jobs.html)、[打包与分发](electron-package-release.html)。这份源码是一个边界完整的小实验，不包含安装器、代码签名、自动更新、云同步和 AI 调用。扩展功能时保留现有检查，每增加一项系统能力，就增加明确契约和对应的失败验收。

## 官方参考

- [Electron 进程模型](https://www.electronjs.org/docs/latest/tutorial/process-model)：区分主进程、渲染进程和 preload。
- [Electron IPC 教程](https://www.electronjs.org/docs/latest/tutorial/ipc)：请求响应模式与 contextBridge。
- [Electron 安全建议](https://www.electronjs.org/docs/latest/tutorial/security)：隔离、沙箱、导航与发送者校验。
- [Electron 自定义协议](https://www.electronjs.org/docs/latest/api/protocol)：协议注册时机与 session 范围。
- [Node 24 文件系统 API](https://nodejs.org/docs/latest-v24.x/api/fs.html)：文件句柄、同步、重命名和并发写入约束。
- [Vite 配置](https://vite.dev/config/)：构建环境、资源基础路径和插件配置。
