Skip to content

应用更新系统

系统架构

更新系统由以下核心组件构成:

Preview

配置项

resources/config/config.ini 中配置:

ini
# 是否在应用启动时检查更新
checkForUpdatesOnStart = true

# 更新服务器地址
updateVersionInfoUrl = https://your-update-server.com/

# 版本信息文件名
versionInfoFile = version.json

版本信息格式

服务器上的 version.json 文件格式:

json
{
  "version": "1.2.0",
  "path": "updates/app-1.2.0.zip",
  "releaseDate": "2025-12-09",
  "releaseNotes": "修复了若干问题,增加了新功能",
  "mandatory": false
}

字段说明:

  • version: 最新版本号(遵循 semver 规范)
  • path: 更新包相对路径
  • releaseDate: 发布日期
  • releaseNotes: 更新日志
  • mandatory: 是否强制更新

更新流程

完整流程图

应用启动

检查配置是否启用自动更新

  启用?

向服务器请求版本信息

比较版本号

  有新版本?

询问用户是否更新

  用户确认?

Worker线程后台下载

显示下载进度

下载完成

解压到临时目录

询问用户是否立即重启

  立即重启?

重启应用并安装更新

更新完成

1. 检查更新

javascript
/**
 * 检查更新(但不自动开始下载)
 * @param {boolean} manual - 是否为手动检查更新
 * @returns {Promise<boolean>} 是否有可用的更新
 */
async checkUpdate(manual = false) {
  if (this.updateMode) {
    await notifyUpdateStatus('busy', '更新已在进行中...', this.win);
    return false;
  }

  try {
    await notifyUpdateStatus('check', '正在检查更新...', this.win);
    const latestInfo = await getLatestVersion();

    if (!(await this.shouldUpdate(latestInfo))) {
      if (manual) {
        await notifyUpdateStatus('no-update', '当前已是最新版本', this.win);
      }
      return false;
    }

    // 发现新版本,询问用户是否更新
    const shouldDownload = await notifyUpdateStatus(
      'update',
      `当前版本: ${app.getVersion()}, 新版本: ${latestInfo.version}`,
      this.win,
      { latestInfo }
    );

    // 用户确认,开始更新流程
    if (shouldDownload) this.startUpdate(latestInfo);
    return true;
  } catch (error) {
    logger.error('检查更新失败:', error);
    await notifyUpdateStatus('error', `检查更新失败: ${error.message}`, this.win);
    return false;
  }
}

触发时机:

  • 应用启动时(如果 checkForUpdatesOnStart = true
  • 用户手动点击"检查更新"菜单

2. 下载更新

使用 Worker 线程后台下载,不阻塞主进程:

javascript
async downloadUpdate(latestInfo) {
  await fs.ensureDir(this.tempDir);
  const filePath = path.join(this.tempDir, latestInfo.path);

  return new Promise((resolve, reject) => {
    // 启用子线程下载更新文件
    const worker = new Worker(path.join(__dirname, '../workers/download-worker.js'));

    // 往worker发送消息,开始下载
    worker.postMessage({
      url: `${getConfigValue('updateVersionInfoUrl')}${latestInfo.path}`,
      filePath,
    });

    // 监听worker消息
    worker.on('message', (message) => {
      switch (message.type) {
        case 'progress':
          // 更新进度条和标题
          const { progress, speed } = message.data;
          this.win.setProgressBar(progress / 100);
          notifyUpdateStatus('download', `更新包下载进度: ${progress}%`, this.win, {
            progress: progress / 100,
            speed: speed,
          });
          break;

        case 'complete':
          worker.terminate();
          this.win.setProgressBar(-1);
          this.win.setTitle(`${APP_TITLE} - 更新包下载完成`);
          resolve(message.data);
          break;

        case 'error':
          worker.terminate();
          reject(new Error(message.error));
          break;
      }
    });
  });
}

下载特性:

  • ✅ Worker 线程后台下载,不阻塞主进程
  • ✅ 实时显示下载进度和速度
  • ✅ 任务栏进度条显示
  • ✅ 支持取消下载
  • ✅ 下载失败自动清理

3. 解压和安装

javascript
async extractAndInstall(filePath, latestInfo) {
  const appPath = process.cwd();
  const tempExtractPath = path.join(this.tempDir, 'extract');
  const pendingRename = path.join(this.tempDir, 'pending_rename.json');

  try {
    notifyUpdateStatus('unzip', '正在解压...', this.win);

    // 解压到临时目录
    process.noAsar = true;
    const zip = new AdmZip(filePath);
    await zip.extractAllToAsync(tempExtractPath, true);

    // 创建更新信息文件,供应用下次启动时使用
    await fs.writeJson(pendingRename, {
      source: tempExtractPath,
      destination: appPath,
      updateReady: true,
    });

    notifyUpdateStatus('ready', '更新已准备就绪,重启应用后将完成安装', this.win);

    // 询问用户是否立即重启
    const shouldRestart = await notifyUpdateStatus('update-restart', '', this.win, {
      latestInfo,
    });

    if (shouldRestart) {
      // 重启应用
      app.relaunch({ args: process.argv.slice(1).concat(['--relaunch']) });
      app.exit(0);
    }
  } catch (error) {
    logger.error('准备更新失败:', error);
    notifyUpdateStatus('error', '更新准备失败,请稍后重试', this.win);

    // 清理临时文件
    await fs.remove(tempExtractPath);
    await fs.remove(pendingRename);

    throw error;
  }
}

4. 重启后安装

应用重启后,检查是否有待安装的更新:

javascript
// 在 app.whenReady() 中检查
const pendingRename = path.join(TEMP_DIR, 'pending_rename.json');
if (await fs.pathExists(pendingRename)) {
  const updateInfo = await fs.readJson(pendingRename);

  if (updateInfo.updateReady) {
    // 执行文件替换
    await fs.copy(updateInfo.source, updateInfo.destination, {
      overwrite: true,
    });

    // 清理临时文件
    await fs.remove(updateInfo.source);
    await fs.remove(pendingRename);

    logger.info('更新安装完成');
  }
}

版本比较

使用 semver 规范比较版本号:

javascript
/**
 * 比较两个版本号
 * @param {string} v1 - 当前版本
 * @param {string} v2 - 目标版本
 * @returns {number} -1: v1<v2, 0: v1==v2, 1: v1>v2
 */
function compareVersions(v1, v2) {
  const parts1 = v1.split('.').map(Number);
  const parts2 = v2.split('.').map(Number);

  for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
    const part1 = parts1[i] || 0;
    const part2 = parts2[i] || 0;

    if (part1 < part2) return -1;
    if (part1 > part2) return 1;
  }

  return 0;
}

/**
 * 判断是否需要更新
 * @returns {Promise<boolean>}
 */
async shouldUpdate(latestInfo) {
  if (!latestInfo.version) return false;
  return compareVersions(app.getVersion(), latestInfo.version) < 0;
}

版本号格式: major.minor.patch

  • 1.0.0 < 1.0.1 (patch 升级)
  • 1.0.1 < 1.1.0 (minor 升级)
  • 1.1.0 < 2.0.0 (major 升级)

用户交互

更新状态通知

通过 IPC 通信向渲染进程发送更新状态:

javascript
/**
 * 通知更新状态
 * @param {string} status - 状态类型
 * @param {string} message - 状态消息
 * @param {BrowserWindow} win - 窗口实例
 * @param {Object} data - 附加数据
 */
async function notifyUpdateStatus(status, message, win, data = {}) {
  if (win && !win.isDestroyed()) {
    return new Promise((resolve) => {
      win.webContents.send('update-status', { status, message, data });

      // 需要用户确认的状态
      if (status === 'update' || status === 'update-restart') {
        ipcMain.once('update-response', (event, response) => {
          resolve(response);
        });
      } else {
        resolve(true);
      }
    });
  }
}

状态类型

状态说明用户操作
check正在检查更新无需操作
no-update已是最新版本无需操作
update发现新版本确认是否下载
download正在下载查看进度
unzip正在解压等待完成
ready更新准备就绪无需操作
update-restart询问是否重启确认是否重启
error更新失败查看错误信息
busy正在更新中等待完成

手动更新

托盘菜单触发

javascript
// packages/main/src/config/menu-config.js
{
  label: '检查更新',
  click: () => {
    updateHandler.checkUpdate(true); // manual = true
  }
}

渲染进程触发

javascript
// 渲染进程中
window.electronAPI.checkForUpdates();

// 监听更新状态
window.electronAPI.onUpdateStatus((status, message, data) => {
  console.log('更新状态:', status, message);

  if (status === 'update') {
    // 显示更新对话框
    const confirmed = confirm(`发现新版本 ${data.latestInfo.version},是否更新?`);
    window.electronAPI.sendUpdateResponse(confirmed);
  }

  if (status === 'update-restart') {
    // 询问是否重启
    const confirmed = confirm('更新已准备就绪,是否立即重启?');
    window.electronAPI.sendUpdateResponse(confirmed);
  }
});

更新包制作

1. 打包应用

bash
# 构建渲染进程
pnpm build:renderer

# 打包应用(不创建安装程序,只打包文件)
pnpm electron:dir

2. 创建更新包

将打包后的文件压缩为 ZIP 格式:

bash
# Windows
cd dist/win-unpacked
7z a -tzip ../app-1.2.0.zip *

# Linux/macOS
cd dist/linux-unpacked
zip -r ../app-1.2.0.zip *

3. 上传到服务器

your-update-server.com/
├── version.json          # 版本信息文件
└── updates/
    ├── app-1.0.0.zip
    ├── app-1.1.0.zip
    └── app-1.2.0.zip     # 最新版本

4. 更新 version.json

json
{
  "version": "1.2.0",
  "path": "updates/app-1.2.0.zip",
  "releaseDate": "2025-12-09",
  "releaseNotes": "- 新增功能A\n- 修复Bug B\n- 优化性能",
  "mandatory": false
}

安全考虑

1. HTTPS 传输

ini
# 使用 HTTPS 确保传输安全
updateVersionInfoUrl = https://your-update-server.com/

2. 文件校验

建议在 version.json 中添加文件哈希:

json
{
  "version": "1.2.0",
  "path": "updates/app-1.2.0.zip",
  "sha256": "abc123...", // 添加文件哈希
  "size": 52428800 // 文件大小(字节)
}

下载后验证文件完整性:

javascript
const crypto = require('crypto');
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(filePath);
stream.on('data', (data) => hash.update(data));
stream.on('end', () => {
  const fileHash = hash.digest('hex');
  if (fileHash !== expectedHash) {
    throw new Error('文件校验失败');
  }
});

3. 签名验证

生产环境建议对更新包进行代码签名:

javascript
// electron-builder.config.js
module.exports = {
  win: {
    certificateFile: './cert.pfx',
    certificatePassword: process.env.CERT_PASSWORD,
  },
};

最佳实践

1. 增量更新

对于大型应用,建议实现增量更新:

javascript
// 只下载变化的文件
{
  "version": "1.2.0",
  "type": "incremental",
  "baseVersion": "1.1.0",
  "path": "updates/patch-1.1.0-to-1.2.0.zip"
}

2. 更新回滚

安装前备份当前版本:

javascript
async backupCurrentVersion() {
  const appPath = process.cwd();
  const backupPath = path.join(this.backupDir, `backup-${app.getVersion()}`);

  await fs.copy(appPath, backupPath, {
    filter: (src) => !src.includes('node_modules')
  });

  return backupPath;
}

3. 静默更新

配置静默下载,用户无感知:

ini
# 在后台静默下载,安装时才提示
silentDownload = true

4. 分阶段发布

通过服务端控制,逐步推送更新:

json
{
  "version": "1.2.0",
  "rollout": {
    "percentage": 20, // 推送给 20% 用户
    "users": ["beta-group"] // 或指定用户组
  }
}

故障处理

下载失败

  • 自动重试机制(最多 3 次)
  • 断点续传支持
  • 降级到旧版本

安装失败

  • 保留备份文件
  • 提供回滚功能
  • 记录错误日志

版本冲突

javascript
// 检查版本兼容性
if (latestInfo.minRequiredVersion) {
  if (compareVersions(app.getVersion(), latestInfo.minRequiredVersion) < 0) {
    // 当前版本过旧,必须更新
    mandatory = true;
  }
}

相关文件

  • packages/main/src/handlers/update-handler.js - 更新处理器
  • packages/main/src/workers/download-worker.js - 下载 Worker
  • packages/main/src/utils/update-utils.js - 更新工具函数
  • resources/bin/update.bat - Windows 更新脚本

相关文档

基于 MIT 许可发布