Electron 服务端通过 ZIP 更新客户端完整方案
一、方案约束
系统中只有两个主要进程:
Server.exe
Client.exe
必须满足:
Server.exe负责检查客户端新版本。Server.exe负责下载 ZIP。Server.exe负责校验 ZIP。Server.exe负责解压 ZIP。Server.exe负责更新固定路径下的客户端。Server.exe不负责启动客户端。Client.exe不连接远程更新平台。Client.exe不下载更新包。- 不存在
update.exe。 - 不存在独立 updater 进程。
- 不存在 Windows 更新服务。
- 客户端更新完成后,由用户或现有系统重新启动。
- 不使用 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.0 → current
↓
状态改为 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 和发布权限必须严格保护。