Electron 服务端通过 ZIP 更新客户端完整方案

0 阅读12分钟

Electron 服务端通过 ZIP 更新客户端完整方案

一、方案约束

系统中只有两个主要进程:

Server.exe
Client.exe

必须满足:

  1. Server.exe 负责检查客户端新版本。
  2. Server.exe 负责下载 ZIP。
  3. Server.exe 负责校验 ZIP。
  4. Server.exe 负责解压 ZIP。
  5. Server.exe 负责更新固定路径下的客户端。
  6. Server.exe 不负责启动客户端。
  7. Client.exe 不连接远程更新平台。
  8. Client.exe 不下载更新包。
  9. 不存在 update.exe
  10. 不存在独立 updater 进程。
  11. 不存在 Windows 更新服务。
  12. 客户端更新完成后,由用户或现有系统重新启动。
  13. 不使用 Manifest 私钥、公钥签名。

二、总体架构

┌─────────────────────────────────────────┐
│           远程更新服务器                │
│                                         │
│  manifest.json                          │
│  client-2.1.0.zip                       │
│  HTTPS                                  │
│  Token 鉴权                             │
└──────────────────┬──────────────────────┘
                   │
                   │ HTTPS
                   │ 只有 Server 访问
                   ▼
┌─────────────────────────────────────────┐
│          Electron Server.exe            │
│                                         │
│  检查版本                               │
│  下载 ZIP                               │
│  校验文件大小                           │
│  校验 SHA-256                           │
│  解压到 staging                         │
│  校验客户端文件                         │
│  通知 Client 退出                       │
│  等待 Client 完全退出                   │
│  current / backup / staging 切换        │
│  更新失败回滚                           │
│  不启动 Client                          │
└──────────────────┬──────────────────────┘
                   │
                   │ localhost WebSocket
                   ▼
┌─────────────────────────────────────────┐
│          Electron Client.exe            │
│                                         │
│  正常业务                               │
│  接收更新通知                           │
│  停止新任务                             │
│  保存数据                               │
│  关闭连接                               │
│  主动退出                               │
└─────────────────────────────────────────┘

三、目录结构

假设应用安装在:

D:\XunweiCloudMenu

目录结构:

D:\XunweiCloudMenu\
│
├─ server\
│  ├─ Server.exe
│  ├─ resources\
│  ├─ config\
│  │  └─ update-config.json
│  ├─ data\
│  └─ logs\
│     └─ update\
│
├─ client\
│  ├─ current\
│  │  ├─ Client.exe
│  │  ├─ resources\
│  │  │  └─ app.asar
│  │  ├─ locales\
│  │  └─ version.json
│  │
│  ├─ staging\
│  │
│  ├─ backup\
│  │
│  ├─ broken\
│  │
│  ├─ packages\
│  │
│  └─ update-state\
│     ├─ state.json
│     ├─ transaction.json
│     └─ update.lock
│
└─ shared-data\
   ├─ config\
   ├─ database\
   ├─ cache\
   ├─ logs\
   └─ user-data\

必须保证:

server
client
shared-data

三个目录相互独立。

客户端业务数据不能放在:

client\current

否则更新时会被替换。


四、服务端配置

文件:

D:\XunweiCloudMenu\server\config\update-config.json

内容:

{
  "enabled": true,
  "clientRoot": "D:\XunweiCloudMenu\client",
  "clientExeName": "Client.exe",
  "manifestUrl": "https://update.example.com/api/releases/client/latest",
  "apiToken": "YOUR_UPDATE_READ_TOKEN",
  "trustedHosts": [
    "update.example.com"
  ],
  "checkIntervalMinutes": 30,
  "downloadTimeoutSeconds": 600,
  "clientExitTimeoutSeconds": 60,
  "keepBackupCount": 2,
  "allowDowngrade": false,
  "platform": "win32",
  "arch": "x64",
  "maxPackageSizeBytes": 1073741824,
  "maxExtractSizeBytes": 3221225472,
  "maxExtractFileCount": 20000
}

生产环境中,apiToken 更推荐从环境变量读取:

XUNWEI_UPDATE_TOKEN

不要把远程接口返回的路径作为本地更新路径。

远程 Manifest 只能决定:

版本
下载地址
文件大小
SHA-256
平台
架构

本地更新位置必须始终使用:

clientRoot

五、Manifest 格式

远程接口返回:

{
  "schemaVersion": 1,
  "appId": "com.xunwei.client",
  "version": "2.1.0",
  "buildNumber": 210,
  "channel": "stable",
  "mandatory": false,
  "publishedAt": "2026-08-04T10:00:00.000Z",
  "package": {
    "url": "https://update.example.com/releases/client/client-2.1.0.zip",
    "fileName": "client-2.1.0.zip",
    "size": 186542321,
    "sha256": "92e6ee24d883693d7bb63f919654070f48061289c9d786f1d2663e1f5c52a9b"
  },
  "requirements": {
    "minimumServerVersion": "1.0.0",
    "platform": "win32",
    "arch": "x64"
  },
  "releaseNotes": [
    "修复客户端启动异常",
    "优化数据同步性能"
  ]
}

不再包含:

{
  "signature": {
    "algorithm": "Ed25519",
    "keyId": "...",
    "value": "..."
  }
}

六、客户端 ZIP 结构

ZIP 根目录直接包含客户端完整文件:

client-2.1.0.zip
├─ Client.exe
├─ resources\
│  ├─ app.asar
│  └─ ...
├─ locales\
├─ chrome_100_percent.pak
├─ icudtl.dat
├─ libEGL.dll
├─ libGLESv2.dll
├─ snapshot_blob.bin
└─ version.json

version.json

{
  "appId": "com.xunwei.client",
  "version": "2.1.0",
  "buildNumber": 210,
  "platform": "win32",
  "arch": "x64",
  "entry": "Client.exe",
  "buildTime": "2026-08-04T10:00:00.000Z"
}

ZIP 中不要再嵌套:

client-2.1.0-win-unpacked\

否则解压后客户端路径可能变成:

staging\2.1.0\client-2.1.0-win-unpacked\Client.exe

增加额外判断。


七、项目目录设计

服务端主进程增加:

src/main/update/
├─ update-types.ts
├─ update-config.ts
├─ update-paths.ts
├─ update-error.ts
├─ update-state.ts
├─ update-lock.ts
├─ manifest-client.ts
├─ version-manager.ts
├─ download-manager.ts
├─ checksum-manager.ts
├─ zip-manager.ts
├─ package-validator.ts
├─ client-communication.ts
├─ client-process-watcher.ts
├─ transaction-manager.ts
├─ directory-switcher.ts
├─ rollback-manager.ts
├─ backup-cleaner.ts
└─ update-manager.ts

八、TypeScript 类型定义

// src/main/update/update-types.ts

export interface UpdateConfig {
  enabled: boolean
  clientRoot: string
  clientExeName: string
  manifestUrl: string
  apiToken?: string
  trustedHosts: string[]
  checkIntervalMinutes: number
  downloadTimeoutSeconds: number
  clientExitTimeoutSeconds: number
  keepBackupCount: number
  allowDowngrade: boolean
  platform: 'win32'
  arch: 'x64' | 'arm64'
  maxPackageSizeBytes: number
  maxExtractSizeBytes: number
  maxExtractFileCount: number
}

export interface PackageInfo {
  url: string
  fileName: string
  size: number
  sha256: string
}

export interface UpdateRequirements {
  minimumServerVersion: string
  platform: 'win32'
  arch: 'x64' | 'arm64'
}

export interface UpdateManifest {
  schemaVersion: number
  appId: string
  version: string
  buildNumber: number
  channel: string
  mandatory: boolean
  publishedAt: string
  package: PackageInfo
  requirements: UpdateRequirements
  releaseNotes?: string[]
}

export interface ClientVersion {
  appId: string
  version: string
  buildNumber: number
  platform: string
  arch: string
  entry: string
  buildTime?: string
}

export type UpdateStateName =
  | 'IDLE'
  | 'CHECKING'
  | 'UPDATE_AVAILABLE'
  | 'PREPARING'
  | 'DOWNLOADING'
  | 'DOWNLOADED'
  | 'VERIFYING'
  | 'VERIFIED'
  | 'EXTRACTING'
  | 'STAGED'
  | 'NOTIFYING_CLIENT'
  | 'WAITING_CLIENT_EXIT'
  | 'SWITCHING'
  | 'COMMITTING'
  | 'WAITING_CLIENT_START'
  | 'COMPLETED'
  | 'FAILED'
  | 'ROLLING_BACK'
  | 'ROLLED_BACK'

export interface UpdateState {
  updateId?: string
  fromVersion?: string
  toVersion?: string
  state: UpdateStateName
  progress: number
  errorCode?: string
  errorMessage?: string
  updatedAt: string
}

九、更新路径生成

// src/main/update/update-paths.ts

import path from 'node:path'

export interface UpdatePaths {
  root: string
  current: string
  staging: string
  backup: string
  broken: string
  packages: string
  stateDir: string
  stateFile: string
  lockFile: string
  transactionFile: string
  clientExe: string
}

export function createUpdatePaths(
  clientRoot: string,
  clientExeName: string,
): UpdatePaths {
  const root = path.resolve(clientRoot)

  return {
    root,
    current: path.join(root, 'current'),
    staging: path.join(root, 'staging'),
    backup: path.join(root, 'backup'),
    broken: path.join(root, 'broken'),
    packages: path.join(root, 'packages'),
    stateDir: path.join(root, 'update-state'),
    stateFile: path.join(
      root,
      'update-state',
      'state.json',
    ),
    lockFile: path.join(
      root,
      'update-state',
      'update.lock',
    ),
    transactionFile: path.join(
      root,
      'update-state',
      'transaction.json',
    ),
    clientExe: path.join(
      root,
      'current',
      clientExeName,
    ),
  }
}

export function ensureInsideRoot(
  rootPath: string,
  targetPath: string,
): void {
  const root =
    path.resolve(rootPath) + path.sep

  const target = path.resolve(targetPath)

  if (
    target !== path.resolve(rootPath)
    && !target.startsWith(root)
  ) {
    throw new Error(
      `目标路径不在客户端根目录内:${target}`,
    )
  }
}

十、读取配置

// src/main/update/update-config.ts

import fs from 'node:fs/promises'
import type {
  UpdateConfig,
} from './update-types'

export async function loadUpdateConfig(
  configPath: string,
): Promise<UpdateConfig> {
  const text = await fs.readFile(
    configPath,
    'utf8',
  )

  const config =
    JSON.parse(text) as UpdateConfig

  const envToken =
    process.env.XUNWEI_UPDATE_TOKEN

  if (envToken) {
    config.apiToken = envToken
  }

  if (!config.clientRoot) {
    throw new Error(
      'clientRoot 不能为空',
    )
  }

  if (!config.manifestUrl) {
    throw new Error(
      'manifestUrl 不能为空',
    )
  }

  if (
    !Array.isArray(config.trustedHosts)
    || config.trustedHosts.length === 0
  ) {
    throw new Error(
      'trustedHosts 不能为空',
    )
  }

  return config
}

十一、Manifest 获取和校验

// src/main/update/manifest-client.ts

import type {
  UpdateConfig,
  UpdateManifest,
} from './update-types'

export class ManifestClient {
  constructor(
    private readonly config: UpdateConfig,
  ) {}

  async fetchLatest(): Promise<UpdateManifest> {
    const manifestUrl =
      this.validateTrustedUrl(
        this.config.manifestUrl,
      )

    const controller =
      new AbortController()

    const timeout = setTimeout(() => {
      controller.abort()
    }, 30_000)

    try {
      const response = await fetch(
        manifestUrl,
        {
          method: 'GET',
          headers: {
            Accept: 'application/json',
            ...(this.config.apiToken
              ? {
                  Authorization:
                    `Bearer ${this.config.apiToken}`,
                }
              : {}),
          },
          signal: controller.signal,
        },
      )

      if (!response.ok) {
        throw new Error(
          `Manifest 请求失败:${response.status}`,
        )
      }

      const contentLength =
        Number(
          response.headers.get(
            'content-length',
          ) ?? 0,
        )

      if (
        contentLength > 1024 * 1024
      ) {
        throw new Error(
          'Manifest 文件过大',
        )
      }

      const manifest =
        await response.json()
          as UpdateManifest

      this.validateManifest(manifest)

      this.validateTrustedUrl(
        manifest.package.url,
      )

      return manifest
    } finally {
      clearTimeout(timeout)
    }
  }

  private validateTrustedUrl(
    rawUrl: string,
  ): string {
    const url = new URL(rawUrl)

    if (url.protocol !== 'https:') {
      throw new Error(
        '更新地址必须使用 HTTPS',
      )
    }

    if (
      !this.config.trustedHosts
        .includes(url.hostname)
    ) {
      throw new Error(
        `不受信任的更新域名:${url.hostname}`,
      )
    }

    return url.toString()
  }

  private validateManifest(
    manifest: UpdateManifest,
  ): void {
    if (
      manifest.schemaVersion !== 1
    ) {
      throw new Error(
        '不支持的 Manifest schemaVersion',
      )
    }

    if (
      manifest.appId
      !== 'com.xunwei.client'
    ) {
      throw new Error(
        'Manifest appId 不匹配',
      )
    }

    if (
      !manifest.version
      || !Number.isInteger(
        manifest.buildNumber,
      )
    ) {
      throw new Error(
        'Manifest 版本信息无效',
      )
    }

    if (
      manifest.requirements.platform
      !== this.config.platform
    ) {
      throw new Error(
        '更新包平台不匹配',
      )
    }

    if (
      manifest.requirements.arch
      !== this.config.arch
    ) {
      throw new Error(
        '更新包架构不匹配',
      )
    }

    if (
      manifest.package.size <= 0
      || manifest.package.size
        > this.config
          .maxPackageSizeBytes
    ) {
      throw new Error(
        '更新包大小不合法',
      )
    }

    if (
      !/^[a-fA-F0-9]{64}$/.test(
        manifest.package.sha256,
      )
    ) {
      throw new Error(
        'SHA-256 格式无效',
      )
    }
  }
}

十二、本地客户端版本读取

// src/main/update/version-manager.ts

import fs from 'node:fs/promises'
import path from 'node:path'
import type {
  ClientVersion,
  UpdateManifest,
} from './update-types'

export class VersionManager {
  constructor(
    private readonly currentDir: string,
  ) {}

  async readCurrentVersion():
    Promise<ClientVersion> {
    const versionFile = path.join(
      this.currentDir,
      'version.json',
    )

    const content =
      await fs.readFile(
        versionFile,
        'utf8',
      )

    return JSON.parse(content)
      as ClientVersion
  }

  shouldUpdate(
    current: ClientVersion,
    manifest: UpdateManifest,
    allowDowngrade: boolean,
  ): boolean {
    if (
      manifest.buildNumber
      > current.buildNumber
    ) {
      return true
    }

    if (
      allowDowngrade
      && manifest.buildNumber
        < current.buildNumber
    ) {
      return true
    }

    return false
  }
}

十三、下载 ZIP

下载中的文件:

client-2.1.0.zip.part

完成后:

client-2.1.0.zip
// src/main/update/download-manager.ts

import fs from 'node:fs'
import fsPromises from 'node:fs/promises'
import path from 'node:path'
import {
  Readable,
} from 'node:stream'
import {
  pipeline,
} from 'node:stream/promises'
import type {
  UpdateConfig,
  UpdateManifest,
} from './update-types'

export class DownloadManager {
  constructor(
    private readonly config: UpdateConfig,
    private readonly packagesDir: string,
  ) {}

  async download(
    manifest: UpdateManifest,
    onProgress?: (
      progress: number,
    ) => Promise<void> | void,
  ): Promise<string> {
    await fsPromises.mkdir(
      this.packagesDir,
      {
        recursive: true,
      },
    )

    const finalPath = path.join(
      this.packagesDir,
      manifest.package.fileName,
    )

    const partPath =
      `${finalPath}.part`

    await fsPromises.rm(
      partPath,
      {
        force: true,
      },
    )

    const controller =
      new AbortController()

    const timeout = setTimeout(() => {
      controller.abort()
    }, this.config
      .downloadTimeoutSeconds * 1000)

    try {
      const response = await fetch(
        manifest.package.url,
        {
          headers: {
            ...(this.config.apiToken
              ? {
                  Authorization:
                    `Bearer ${this.config.apiToken}`,
                }
              : {}),
          },
          signal: controller.signal,
        },
      )

      if (!response.ok) {
        throw new Error(
          `ZIP 下载失败:${response.status}`,
        )
      }

      if (!response.body) {
        throw new Error(
          'ZIP 响应体为空',
        )
      }

      const total =
        Number(
          response.headers.get(
            'content-length',
          )
          ?? manifest.package.size,
        )

      let downloaded = 0

      const source =
        Readable.fromWeb(
          response.body as never,
        )

      source.on('data', chunk => {
        downloaded +=
          Buffer.byteLength(chunk)

        const progress =
          total > 0
            ? Math.floor(
                downloaded
                / total
                * 100,
              )
            : 0

        void onProgress?.(progress)
      })

      const output =
        fs.createWriteStream(
          partPath,
          {
            flags: 'w',
          },
        )

      await pipeline(
        source,
        output,
      )

      const stat =
        await fsPromises.stat(
          partPath,
        )

      if (
        stat.size
        !== manifest.package.size
      ) {
        throw new Error(
          `ZIP 文件大小不一致,期望 ${manifest.package.size},实际 ${stat.size}`,
        )
      }

      await fsPromises.rm(
        finalPath,
        {
          force: true,
        },
      )

      await fsPromises.rename(
        partPath,
        finalPath,
      )

      return finalPath
    } catch (error) {
      await fsPromises.rm(
        partPath,
        {
          force: true,
        },
      ).catch(() => undefined)

      throw error
    } finally {
      clearTimeout(timeout)
    }
  }
}

十四、SHA-256 校验

// src/main/update/checksum-manager.ts

import fs from 'node:fs'
import fsPromises from 'node:fs/promises'
import crypto from 'node:crypto'

export async function calculateSha256(
  filePath: string,
): Promise<string> {
  return new Promise(
    (resolve, reject) => {
      const hash =
        crypto.createHash('sha256')

      const stream =
        fs.createReadStream(filePath)

      stream.on('data', chunk => {
        hash.update(chunk)
      })

      stream.on('error', reject)

      stream.on('end', () => {
        resolve(hash.digest('hex'))
      })
    },
  )
}

export async function verifyPackage(
  filePath: string,
  expectedSize: number,
  expectedSha256: string,
): Promise<void> {
  const stat =
    await fsPromises.stat(filePath)

  if (stat.size !== expectedSize) {
    throw new Error(
      `ZIP 大小校验失败,期望 ${expectedSize},实际 ${stat.size}`,
    )
  }

  const actualSha256 =
    await calculateSha256(filePath)

  if (
    actualSha256.toLowerCase()
    !== expectedSha256.toLowerCase()
  ) {
    throw new Error(
      'ZIP SHA-256 校验失败',
    )
  }
}

注意:

SHA-256 只能确认 ZIP 与 Manifest 中记录的一致。

它不能防止攻击者同时修改 Manifest 和 ZIP。

因此本方案必须依赖:

HTTPS
固定域名
Token
更新服务器权限控制

十五、ZIP 解压

推荐使用:

npm install yauzl
npm install -D @types/yauzl

不要直接使用一个不做路径检查的简单解压函数。

// src/main/update/zip-manager.ts

import fs from 'node:fs'
import fsPromises from 'node:fs/promises'
import path from 'node:path'
import yauzl from 'yauzl'
import type {
  UpdateConfig,
} from './update-types'

export class ZipManager {
  constructor(
    private readonly config: UpdateConfig,
    private readonly stagingRoot: string,
  ) {}

  async extract(
    zipPath: string,
    version: string,
  ): Promise<string> {
    const tempDir = path.join(
      this.stagingRoot,
      `${version}.tmp`,
    )

    const finalDir = path.join(
      this.stagingRoot,
      version,
    )

    await fsPromises.rm(
      tempDir,
      {
        recursive: true,
        force: true,
        maxRetries: 5,
        retryDelay: 500,
      },
    )

    await fsPromises.rm(
      finalDir,
      {
        recursive: true,
        force: true,
        maxRetries: 5,
        retryDelay: 500,
      },
    )

    await fsPromises.mkdir(
      tempDir,
      {
        recursive: true,
      },
    )

    try {
      await this.extractInternal(
        zipPath,
        tempDir,
      )

      await fsPromises.rename(
        tempDir,
        finalDir,
      )

      return finalDir
    } catch (error) {
      await fsPromises.rm(
        tempDir,
        {
          recursive: true,
          force: true,
          maxRetries: 5,
          retryDelay: 500,
        },
      ).catch(() => undefined)

      throw error
    }
  }

  private extractInternal(
    zipPath: string,
    targetRoot: string,
  ): Promise<void> {
    return new Promise(
      (resolve, reject) => {
        yauzl.open(
          zipPath,
          {
            lazyEntries: true,
          },
          (openError, zipFile) => {
            if (
              openError
              || !zipFile
            ) {
              reject(
                openError
                ?? new Error(
                  '无法打开 ZIP',
                ),
              )
              return
            }

            let fileCount = 0
            let extractedSize = 0
            let settled = false

            const fail = (
              error: unknown,
            ) => {
              if (settled) return
              settled = true
              zipFile.close()
              reject(error)
            }

            zipFile.on(
              'error',
              fail,
            )

            zipFile.on(
              'end',
              () => {
                if (settled) return
                settled = true
                resolve()
              },
            )

            zipFile.on(
              'entry',
              entry => {
                fileCount += 1

                if (
                  fileCount
                  > this.config
                    .maxExtractFileCount
                ) {
                  fail(
                    new Error(
                      'ZIP 文件数量超过限制',
                    ),
                  )
                  return
                }

                extractedSize +=
                  entry.uncompressedSize

                if (
                  extractedSize
                  > this.config
                    .maxExtractSizeBytes
                ) {
                  fail(
                    new Error(
                      'ZIP 解压总大小超过限制',
                    ),
                  )
                  return
                }

                let targetPath: string

                try {
                  targetPath =
                    this.resolveEntryPath(
                      targetRoot,
                      entry.fileName,
                    )
                } catch (error) {
                  fail(error)
                  return
                }

                const isDirectory =
                  //$/.test(
                    entry.fileName,
                  )

                if (isDirectory) {
                  fsPromises.mkdir(
                    targetPath,
                    {
                      recursive: true,
                    },
                  )
                    .then(() => {
                      zipFile.readEntry()
                    })
                    .catch(fail)

                  return
                }

                fsPromises.mkdir(
                  path.dirname(
                    targetPath,
                  ),
                  {
                    recursive: true,
                  },
                )
                  .then(() => {
                    zipFile.openReadStream(
                      entry,
                      (
                        streamError,
                        readStream,
                      ) => {
                        if (
                          streamError
                          || !readStream
                        ) {
                          fail(
                            streamError
                              ?? new Error(
                                '读取 ZIP 条目失败',
                              ),
                          )
                          return
                        }

                        const writeStream =
                          fs.createWriteStream(
                            targetPath,
                            {
                              flags: 'wx',
                            },
                          )

                        readStream
                          .pipe(writeStream)

                        writeStream.on(
                          'finish',
                          () => {
                            zipFile.readEntry()
                          },
                        )

                        writeStream.on(
                          'error',
                          fail,
                        )

                        readStream.on(
                          'error',
                          fail,
                        )
                      },
                    )
                  })
                  .catch(fail)
              },
            )

            zipFile.readEntry()
          },
        )
      },
    )
  }

  private resolveEntryPath(
    targetRoot: string,
    entryName: string,
  ): string {
    if (
      entryName.includes('\0')
    ) {
      throw new Error(
        'ZIP 条目包含非法字符',
      )
    }

    const normalized =
      entryName.replace(/\/g, '/')

    if (
      normalized.startsWith('/')
      || /^[A-Za-z]:/.test(
        normalized,
      )
      || normalized.startsWith('//')
    ) {
      throw new Error(
        `ZIP 包含绝对路径:${entryName}`,
      )
    }

    const root =
      path.resolve(targetRoot)
      + path.sep

    const target =
      path.resolve(
        targetRoot,
        normalized,
      )

    if (!target.startsWith(root)) {
      throw new Error(
        `ZIP 路径越界:${entryName}`,
      )
    }

    return target
  }
}

十六、解压后校验

// src/main/update/package-validator.ts

import fs from 'node:fs/promises'
import path from 'node:path'
import type {
  ClientVersion,
  UpdateConfig,
  UpdateManifest,
} from './update-types'

export class PackageValidator {
  constructor(
    private readonly config: UpdateConfig,
  ) {}

  async validate(
    stagingDir: string,
    manifest: UpdateManifest,
  ): Promise<ClientVersion> {
    const clientExe = path.join(
      stagingDir,
      this.config.clientExeName,
    )

    const appAsar = path.join(
      stagingDir,
      'resources',
      'app.asar',
    )

    const versionFile = path.join(
      stagingDir,
      'version.json',
    )

    await this.requireFile(
      clientExe,
      'Client.exe',
    )

    await this.requireFile(
      appAsar,
      'resources/app.asar',
    )

    await this.requireFile(
      versionFile,
      'version.json',
    )

    const content =
      await fs.readFile(
        versionFile,
        'utf8',
      )

    const version =
      JSON.parse(content)
        as ClientVersion

    if (
      version.appId
      !== manifest.appId
    ) {
      throw new Error(
        'ZIP 内 appId 与 Manifest 不一致',
      )
    }

    if (
      version.version
      !== manifest.version
    ) {
      throw new Error(
        'ZIP 内版本号与 Manifest 不一致',
      )
    }

    if (
      version.buildNumber
      !== manifest.buildNumber
    ) {
      throw new Error(
        'ZIP 内 buildNumber 与 Manifest 不一致',
      )
    }

    if (
      version.platform
      !== this.config.platform
    ) {
      throw new Error(
        'ZIP 内平台不匹配',
      )
    }

    if (
      version.arch
      !== this.config.arch
    ) {
      throw new Error(
        'ZIP 内架构不匹配',
      )
    }

    if (
      version.entry
      !== this.config.clientExeName
    ) {
      throw new Error(
        'ZIP 客户端入口文件不匹配',
      )
    }

    return version
  }

  private async requireFile(
    filePath: string,
    displayName: string,
  ): Promise<void> {
    const stat =
      await fs.stat(filePath)
        .catch(() => null)

    if (
      !stat
      || !stat.isFile()
    ) {
      throw new Error(
        `更新包缺少 ${displayName}`,
      )
    }
  }
}

十七、客户端通信协议

服务端与客户端通过:

ws://127.0.0.1:端口

或:

http://127.0.0.1:端口

通信。

服务端通知:

{
  "type": "CLIENT_UPDATE_PREPARE",
  "updateId": "update-20260804-001",
  "targetVersion": "2.1.0",
  "mandatory": true,
  "exitDeadlineSeconds": 60
}

客户端回复:

{
  "type": "CLIENT_UPDATE_READY",
  "updateId": "update-20260804-001",
  "pid": 18240,
  "ready": true
}

客户端重新启动后上报:

{
  "type": "CLIENT_STARTED",
  "version": "2.1.0",
  "buildNumber": 210,
  "pid": 19520
}

启动失败时上报:

{
  "type": "CLIENT_START_FAILED",
  "version": "2.1.0",
  "errorCode": "INIT_FAILED",
  "errorMessage": "客户端初始化失败"
}

十八、客户端退出逻辑

// Client Electron 主进程

import {
  app,
  BrowserWindow,
} from 'electron'

let preparingUpdate = false

interface PrepareUpdateMessage {
  type: 'CLIENT_UPDATE_PREPARE'
  updateId: string
  targetVersion: string
  exitDeadlineSeconds: number
}

export async function handlePrepareUpdate(
  message: PrepareUpdateMessage,
): Promise<void> {
  if (preparingUpdate) {
    return
  }

  preparingUpdate = true

  try {
    await stopAcceptingNewTasks()

    await flushPendingData()

    await saveWindowState()

    await closeDatabase()

    await reportUpdateReady({
      type: 'CLIENT_UPDATE_READY',
      updateId: message.updateId,
      pid: process.pid,
      ready: true,
    })

    await closeBusinessConnections()

    for (
      const window
      of BrowserWindow.getAllWindows()
    ) {
      window.removeAllListeners('close')
    }

    app.quit()

    setTimeout(() => {
      app.exit(0)
    }, 10_000).unref()
  } catch (error) {
    preparingUpdate = false

    await reportUpdateReady({
      type: 'CLIENT_UPDATE_READY',
      updateId: message.updateId,
      pid: process.pid,
      ready: false,
      errorMessage:
        error instanceof Error
          ? error.message
          : String(error),
    })
  }
}

客户端只负责退出,不执行:

fetch(packageUrl)
unzip()
rename()
spawn()
exec()

十九、检测客户端进程退出

服务端应记录客户端上报的 PID。

Windows 检测 PID 示例:

// src/main/update/client-process-watcher.ts

import {
  execFile,
} from 'node:child_process'
import {
  promisify,
} from 'node:util'

const execFileAsync =
  promisify(execFile)

export class ClientProcessWatcher {
  async isProcessRunning(
    pid: number,
  ): Promise<boolean> {
    try {
      const {
        stdout,
      } = await execFileAsync(
        'tasklist',
        [
          '/FI',
          `PID eq ${pid}`,
          '/FO',
          'CSV',
          '/NH',
        ],
        {
          windowsHide: true,
        },
      )

      return stdout.includes(
        `"${pid}"`,
      )
    } catch {
      return false
    }
  }

  async waitForExit(
    pid: number,
    timeoutSeconds: number,
  ): Promise<boolean> {
    const deadline =
      Date.now()
      + timeoutSeconds * 1000

    while (
      Date.now() < deadline
    ) {
      const running =
        await this.isProcessRunning(pid)

      if (!running) {
        return true
      }

      await new Promise(resolve => {
        setTimeout(resolve, 1000)
      })
    }

    return false
  }
}

本方案默认不强制杀死客户端。

如果超时:

CLIENT_EXIT_TIMEOUT

处理方式:

不修改 current
保留 staging
结束本次更新
下次重新通知客户端

二十、更新状态记录

// src/main/update/update-state.ts

import fs from 'node:fs/promises'
import path from 'node:path'
import type {
  UpdateState,
  UpdateStateName,
} from './update-types'

export class UpdateStateStore {
  constructor(
    private readonly stateFile: string,
  ) {}

  async set(
    state: UpdateStateName,
    data: Partial<UpdateState> = {},
  ): Promise<void> {
    const value: UpdateState = {
      state,
      progress:
        data.progress ?? 0,
      updatedAt:
        new Date().toISOString(),
      ...data,
    }

    await fs.mkdir(
      path.dirname(
        this.stateFile,
      ),
      {
        recursive: true,
      },
    )

    const temp =
      `${this.stateFile}.tmp`

    await fs.writeFile(
      temp,
      JSON.stringify(
        value,
        null,
        2,
      ),
      'utf8',
    )

    await fs.rename(
      temp,
      this.stateFile,
    )
  }

  async read():
    Promise<UpdateState | null> {
    try {
      const text =
        await fs.readFile(
          this.stateFile,
          'utf8',
        )

      return JSON.parse(text)
        as UpdateState
    } catch {
      return null
    }
  }
}

二十一、更新锁

// src/main/update/update-lock.ts

import fs from 'node:fs/promises'
import path from 'node:path'

export class UpdateLock {
  constructor(
    private readonly lockFile: string,
  ) {}

  async acquire(
    updateId: string,
    targetVersion: string,
  ): Promise<void> {
    await fs.mkdir(
      path.dirname(
        this.lockFile,
      ),
      {
        recursive: true,
      },
    )

    try {
      const handle =
        await fs.open(
          this.lockFile,
          'wx',
        )

      await handle.writeFile(
        JSON.stringify(
          {
            updateId,
            targetVersion,
            serverPid: process.pid,
            createdAt:
              new Date()
                .toISOString(),
          },
          null,
          2,
        ),
      )

      await handle.close()
    } catch (
      error: unknown
    ) {
      const nodeError =
        error as NodeJS.ErrnoException

      if (
        nodeError.code === 'EEXIST'
      ) {
        throw new Error(
          '已有客户端更新正在执行',
        )
      }

      throw error
    }
  }

  async release(): Promise<void> {
    await fs.rm(
      this.lockFile,
      {
        force: true,
      },
    )
  }
}

二十二、目录切换事务

切换前写:

{
  "updateId": "update-20260804-001",
  "fromVersion": "2.0.0",
  "toVersion": "2.1.0",
  "phase": "PREPARED",
  "currentPath": "D:\XunweiCloudMenu\client\current",
  "stagingPath": "D:\XunweiCloudMenu\client\staging\2.1.0",
  "backupPath": "D:\XunweiCloudMenu\client\backup\2.0.0",
  "updatedAt": "2026-08-04T10:00:00.000Z"
}

阶段:

PREPARED
CURRENT_MOVED_TO_BACKUP
STAGING_MOVED_TO_CURRENT
COMPLETED
ROLLING_BACK
ROLLED_BACK

二十三、目录切换代码

// src/main/update/directory-switcher.ts

import fs from 'node:fs/promises'
import path from 'node:path'

export class DirectorySwitcher {
  constructor(
    private readonly clientRoot: string,
    private readonly transactionFile: string,
  ) {}

  async switchVersion(
    updateId: string,
    currentVersion: string,
    targetVersion: string,
  ): Promise<void> {
    const currentDir =
      path.join(
        this.clientRoot,
        'current',
      )

    const stagingDir =
      path.join(
        this.clientRoot,
        'staging',
        targetVersion,
      )

    const backupDir =
      path.join(
        this.clientRoot,
        'backup',
        currentVersion,
      )

    await this.writeTransaction({
      updateId,
      fromVersion:
        currentVersion,
      toVersion:
        targetVersion,
      phase: 'PREPARED',
      currentPath:
        currentDir,
      stagingPath:
        stagingDir,
      backupPath:
        backupDir,
    })

    let currentMoved = false

    try {
      await fs.mkdir(
        path.dirname(
          backupDir,
        ),
        {
          recursive: true,
        },
      )

      await fs.rm(
        backupDir,
        {
          recursive: true,
          force: true,
          maxRetries: 5,
          retryDelay: 500,
        },
      )

      await fs.rename(
        currentDir,
        backupDir,
      )

      currentMoved = true

      await this.writeTransaction({
        updateId,
        fromVersion:
          currentVersion,
        toVersion:
          targetVersion,
        phase:
          'CURRENT_MOVED_TO_BACKUP',
        currentPath:
          currentDir,
        stagingPath:
          stagingDir,
        backupPath:
          backupDir,
      })

      await fs.rename(
        stagingDir,
        currentDir,
      )

      await this.writeTransaction({
        updateId,
        fromVersion:
          currentVersion,
        toVersion:
          targetVersion,
        phase:
          'STAGING_MOVED_TO_CURRENT',
        currentPath:
          currentDir,
        stagingPath:
          stagingDir,
        backupPath:
          backupDir,
      })
    } catch (error) {
      if (currentMoved) {
        await fs.rm(
          currentDir,
          {
            recursive: true,
            force: true,
            maxRetries: 5,
            retryDelay: 500,
          },
        ).catch(() => undefined)

        await fs.rename(
          backupDir,
          currentDir,
        ).catch(() => undefined)
      }

      throw error
    }
  }

  private async writeTransaction(
    value: Record<string, unknown>,
  ): Promise<void> {
    await fs.mkdir(
      path.dirname(
        this.transactionFile,
      ),
      {
        recursive: true,
      },
    )

    const temp =
      `${this.transactionFile}.tmp`

    await fs.writeFile(
      temp,
      JSON.stringify(
        {
          ...value,
          updatedAt:
            new Date()
              .toISOString(),
        },
        null,
        2,
      ),
      'utf8',
    )

    await fs.rename(
      temp,
      this.transactionFile,
    )
  }
}

要求:

current
staging
backup

必须位于同一磁盘分区。


二十四、回滚逻辑

// src/main/update/rollback-manager.ts

import fs from 'node:fs/promises'
import path from 'node:path'

export class RollbackManager {
  constructor(
    private readonly clientRoot: string,
  ) {}

  async rollback(
    currentVersion: string,
    failedVersion: string,
  ): Promise<void> {
    const currentDir =
      path.join(
        this.clientRoot,
        'current',
      )

    const backupDir =
      path.join(
        this.clientRoot,
        'backup',
        currentVersion,
      )

    const brokenDir =
      path.join(
        this.clientRoot,
        'broken',
        failedVersion,
      )

    await fs.mkdir(
      path.dirname(
        brokenDir,
      ),
      {
        recursive: true,
      },
    )

    await fs.rm(
      brokenDir,
      {
        recursive: true,
        force: true,
        maxRetries: 5,
        retryDelay: 500,
      },
    )

    await fs.rename(
      currentDir,
      brokenDir,
    )

    await fs.rename(
      backupDir,
      currentDir,
    )
  }
}

回滚完成后,服务端仍然不启动客户端。

用户下一次启动:

client\current\Client.exe

会运行恢复后的旧版本。


二十五、完整 UpdateManager

// src/main/update/update-manager.ts

import crypto from 'node:crypto'
import type { UpdateConfig } from './update-types'
import { createUpdatePaths } from './update-paths'
import { ManifestClient } from './manifest-client'
import { VersionManager } from './version-manager'
import { DownloadManager } from './download-manager'
import { verifyPackage } from './checksum-manager'
import { ZipManager } from './zip-manager'
import { PackageValidator } from './package-validator'
import { ClientProcessWatcher } from './client-process-watcher'
import { DirectorySwitcher} from './directory-switcher'
import { UpdateStateStore } from './update-state'
import {  UpdateLock } from './update-lock'

export interface ClientBridge {
  notifyPrepareUpdate(
    message: {
      updateId: string
      targetVersion: string
      exitDeadlineSeconds: number
    },
  ): Promise<{
    pid: number
    ready: boolean
    errorMessage?: string
  }>
}

export class UpdateManager {
  private readonly paths
  private readonly stateStore
  private readonly lock
  private readonly manifestClient
  private readonly versionManager
  private readonly downloadManager
  private readonly zipManager
  private readonly packageValidator
  private readonly processWatcher
  private readonly directorySwitcher

  constructor(
    private readonly config:
      UpdateConfig,
    private readonly clientBridge:
      ClientBridge,
  ) {
    this.paths =
      createUpdatePaths(
        config.clientRoot,
        config.clientExeName,
      )

    this.stateStore =
      new UpdateStateStore(
        this.paths.stateFile,
      )

    this.lock =
      new UpdateLock(
        this.paths.lockFile,
      )

    this.manifestClient =
      new ManifestClient(config)

    this.versionManager =
      new VersionManager(
        this.paths.current,
      )

    this.downloadManager =
      new DownloadManager(
        config,
        this.paths.packages,
      )

    this.zipManager =
      new ZipManager(
        config,
        this.paths.staging,
      )

    this.packageValidator =
      new PackageValidator(config)

    this.processWatcher =
      new ClientProcessWatcher()

    this.directorySwitcher =
      new DirectorySwitcher(
        this.paths.root,
        this.paths.transactionFile,
      )
  }

  async execute(): Promise<void> {
    if (!this.config.enabled) {
      return
    }

    const updateId =
      `update-${Date.now()}-${crypto
        .randomBytes(4)
        .toString('hex')}`

    let lockAcquired = false

    try {
      await this.stateStore.set(
        'CHECKING',
        {
          updateId,
          progress: 0,
        },
      )

      const current =
        await this.versionManager
          .readCurrentVersion()

      const manifest =
        await this.manifestClient
          .fetchLatest()

      const shouldUpdate =
        this.versionManager
          .shouldUpdate(
            current,
            manifest,
            this.config
              .allowDowngrade,
          )

      if (!shouldUpdate) {
        await this.stateStore.set(
          'IDLE',
          {
            updateId,
            fromVersion:
              current.version,
            toVersion:
              manifest.version,
            progress: 100,
          },
        )

        return
      }

      await this.lock.acquire(
        updateId,
        manifest.version,
      )

      lockAcquired = true

      await this.stateStore.set(
        'UPDATE_AVAILABLE',
        {
          updateId,
          fromVersion:
            current.version,
          toVersion:
            manifest.version,
          progress: 5,
        },
      )

      await this.stateStore.set(
        'DOWNLOADING',
        {
          updateId,
          fromVersion:
            current.version,
          toVersion:
            manifest.version,
          progress: 10,
        },
      )

      const packagePath =
        await this.downloadManager
          .download(
            manifest,
            async downloadProgress => {
              await this.stateStore.set(
                'DOWNLOADING',
                {
                  updateId,
                  fromVersion:
                    current.version,
                  toVersion:
                    manifest.version,
                  progress:
                    10
                    + Math.floor(
                        downloadProgress
                        * 0.4,
                      ),
                },
              )
            },
          )

      await this.stateStore.set(
        'VERIFYING',
        {
          updateId,
          fromVersion:
            current.version,
          toVersion:
            manifest.version,
          progress: 55,
        },
      )

      await verifyPackage(
        packagePath,
        manifest.package.size,
        manifest.package.sha256,
      )

      await this.stateStore.set(
        'EXTRACTING',
        {
          updateId,
          fromVersion:
            current.version,
          toVersion:
            manifest.version,
          progress: 65,
        },
      )

      const stagingDir =
        await this.zipManager.extract(
          packagePath,
          manifest.version,
        )

      await this.packageValidator
        .validate(
          stagingDir,
          manifest,
        )

      await this.stateStore.set(
        'STAGED',
        {
          updateId,
          fromVersion:
            current.version,
          toVersion:
            manifest.version,
          progress: 75,
        },
      )

      await this.stateStore.set(
        'NOTIFYING_CLIENT',
        {
          updateId,
          fromVersion:
            current.version,
          toVersion:
            manifest.version,
          progress: 80,
        },
      )

      const readyResult =
        await this.clientBridge
          .notifyPrepareUpdate({
            updateId,
            targetVersion:
              manifest.version,
            exitDeadlineSeconds:
              this.config
                .clientExitTimeoutSeconds,
          })

      if (!readyResult.ready) {
        throw new Error(
          readyResult.errorMessage
          ?? '客户端拒绝退出',
        )
      }

      await this.stateStore.set(
        'WAITING_CLIENT_EXIT',
        {
          updateId,
          fromVersion:
            current.version,
          toVersion:
            manifest.version,
          progress: 85,
        },
      )

      const exited =
        await this.processWatcher
          .waitForExit(
            readyResult.pid,
            this.config
              .clientExitTimeoutSeconds,
          )

      if (!exited) {
        throw new Error(
          'CLIENT_EXIT_TIMEOUT',
        )
      }

      await this.stateStore.set(
        'SWITCHING',
        {
          updateId,
          fromVersion:
            current.version,
          toVersion:
            manifest.version,
          progress: 90,
        },
      )

      await this.directorySwitcher
        .switchVersion(
          updateId,
          current.version,
          manifest.version,
        )

      await this.stateStore.set(
        'WAITING_CLIENT_START',
        {
          updateId,
          fromVersion:
            current.version,
          toVersion:
            manifest.version,
          progress: 95,
        },
      )

      // 这里不能启动客户端。
      // 必须等待用户或现有外部机制重新启动 Client.exe。
    } catch (error) {
      await this.stateStore.set(
        'FAILED',
        {
          updateId,
          progress: 100,
          errorCode:
            error instanceof Error
              ? error.message
              : 'UNKNOWN_ERROR',
          errorMessage:
            error instanceof Error
              ? error.message
              : String(error),
        },
      )

      throw error
    } finally {
      if (lockAcquired) {
        await this.lock.release()
      }
    }
  }
}

二十六、客户端重新启动确认

服务端更新完成后状态:

WAITING_CLIENT_START

用户手动启动客户端后,客户端调用:

POST http://127.0.0.1:19990/api/client/runtime/started

请求:

{
  "version": "2.1.0",
  "buildNumber": 210,
  "pid": 19520
}

服务端处理:

async function handleClientStarted(
  body: {
    version: string
    buildNumber: number
    pid: number
  },
): Promise<void> {
  const state =
    await updateStateStore.read()

  if (
    state?.state
    !== 'WAITING_CLIENT_START'
  ) {
    return
  }

  if (
    state.toVersion
    !== body.version
  ) {
    throw new Error(
      '客户端启动版本与目标版本不一致',
    )
  }

  await updateStateStore.set(
    'COMPLETED',
    {
      ...state,
      progress: 100,
    },
  )
}

服务端不会执行:

spawn(clientExe)

二十七、客户端启动失败处理

新版客户端启动后,如果初始化失败,应尽量在退出前通知服务端:

POST /api/client/runtime/start-failed
{
  "version": "2.1.0",
  "errorCode": "DATABASE_INIT_FAILED",
  "errorMessage": "数据库初始化失败"
}

服务端处理:

收到 CLIENT_START_FAILED
    ↓
等待新版客户端退出
    ↓
current → broken\2.1.0
    ↓
backup\2.0.0current
    ↓
状态改为 ROLLED_BACK
    ↓
不启动旧客户端

用户再次打开客户端时,会运行旧版本。


二十八、自动检查更新

服务端启动后:

let checking = false

async function checkUpdateJob(): Promise<void> {
  if (checking) {
    return
  }

  checking = true

  try {
    await updateManager.execute()
  } catch (error) {
    console.error(
      '客户端更新失败',
      error,
    )
  } finally {
    checking = false
  }
}

setTimeout(() => {
  void checkUpdateJob()
}, 30_000)

setInterval(() => {
  void checkUpdateJob()
}, config.checkIntervalMinutes
  * 60
  * 1000)

生产环境需要防止:

定时任务
手动更新
启动检查

同时触发。

update.lock 可以阻止并发更新。


二十九、完整执行顺序

1. Server.exe 启动。

2. Server 读取 update-config.json。

3. Server 生成固定客户端目录路径。

4. Server 读取 current\version.json。

5. Server 请求远程 manifest。

6. Server 检查 Manifest schemaVersion。

7. Server 检查 appId。

8. Server 检查平台和架构。

9. Server 检查 ZIP 地址是否为 HTTPS。

10. Server 检查 ZIP 域名是否在 trustedHosts。

11. Server 比较 buildNumber。

12. Server 创建 update.lock。

13. Server 检查磁盘和目录权限。

14. Server 下载 ZIP 到 packages\xxx.zip.part。

15. 下载完成后校验文件大小。

16. 将 .zip.part 重命名为 .zip。

17. Server 计算 ZIP SHA-256。

18. 与 Manifest 中的 SHA-256 比较。

19. Server 解压 ZIP 到 staging\版本.tmp。

20. Server 检查 ZIP 路径穿越。

21. Server 检查解压文件数量。

22. Server 检查总解压大小。

23. Server 校验 Client.exe。

24. Server 校验 resources\app.asar。

25. Server 校验 version.json。

26. Server 将 staging\版本.tmp 改为 staging\版本。

27. Server 通知 Client 准备退出。

28. Client 停止新任务。

29. Client 保存未完成状态。

30. Client 关闭数据库和网络连接。

31. Client 返回 PID 和 READY。

32. Client 调用 app.quit()。

33. Server 等待 PID 完全退出。

34. 超时则取消本次切换。

35. Server 写入 transaction.json。

36. Server 将 current 改名为 backup\旧版本。

37. Server 将 staging\新版本改名为 current。

38. 切换失败则恢复 backup。

39. Server 状态改为 WAITING_CLIENT_START。

40. Server 不启动 Client。

41. 用户手动启动 Client。

42. Client 上报 CLIENT_STARTED。

43. Server 验证启动版本。

44. Server 将更新状态改为 COMPLETED。

45. Server 清理 update.lock。

46. Server 按配置保留最近两个备份版本。

三十、最低安全要求

由于本方案没有数字签名,必须至少保证:

Manifest 使用 HTTPS
ZIP 使用 HTTPS
Manifest API 使用 Token
ZIP 下载使用 Token
固定 trustedHosts
禁止远程指定本地路径
严格校验 ZIP 大小
严格校验 ZIP SHA-256
严格校验 version.json
防止 ZIP 路径穿越
限制 ZIP 文件数量
限制 ZIP 解压大小
保留旧版本
支持事务恢复

不要允许:

HTTP 更新地址
任意下载域名
任意本地目标路径
不校验 SHA-256
直接解压到 current
客户端运行中覆盖文件

三十一、故障处理表

故障处理方式
Manifest 请求失败保持旧版本,等待下次检查
Token 失效记录鉴权失败,不更新
ZIP 下载中断删除 .part 或下次断点续传
ZIP 大小不匹配删除 ZIP,不解压
SHA-256 不一致删除 ZIP,记录校验失败
ZIP 解压失败删除 staging 临时目录
ZIP 路径越界立即终止解压
Client.exe 缺失删除 staging,不通知客户端
客户端拒绝退出保留 staging,不切换
客户端退出超时保持 current 不变
current 备份失败停止更新
staging 切换失败backup 恢复到 current
新版启动失败用户或客户端触发回滚
Server 切换时崩溃根据 transaction.json 恢复

三十二、最终方案

最终实现为:

远程更新平台
    │
    ├─ HTTPS
    ├─ Token
    ├─ Manifest
    ├─ ZIP
    └─ SHA-256
    │
    ▼
Electron Server
    │
    ├─ 固定更新路径
    ├─ 固定可信域名
    ├─ 检查版本
    ├─ 下载 ZIP
    ├─ 校验大小
    ├─ 校验 SHA-256
    ├─ 安全解压
    ├─ 校验 version.json
    ├─ 通知 Client 退出
    ├─ 等待进程退出
    ├─ 切换目录
    ├─ 事务恢复
    ├─ 失败回滚
    └─ 不启动 Client
    │
    ▼
Electron Client
    │
    ├─ 不访问远程更新服务器
    ├─ 不下载 ZIP
    ├─ 不解压 ZIP
    ├─ 不替换文件
    ├─ 不包含 update.exe
    ├─ 收到通知后保存数据
    ├─ 主动退出
    └─ 下次由用户或外部机制启动

这套方案可以作为第一阶段正式实现。没有 Manifest 签名不会影响更新流程本身,但远程更新服务器、HTTPS、Token 和发布权限必须严格保护。