Files
BIPMaterialManager/src/main/services/update/update-service.ts
Misaka ffc3cbb4a9 feat: implement background update download without blocking user login
- Change update check and download to async execution, allowing immediate app entry for User users
- Updates run in background, showing update prompt when download completes
- Optimize login flow experience, eliminating blocking time caused by update downloads
- Add release notes for version 1.5.1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:43:03 +08:00

289 lines
8.4 KiB
TypeScript

import * as fs from 'fs'
import { ConfigManager } from '../config/config-manager'
import { createLogger } from '../logger'
import type { UpdateConfig } from '../../types/config.schema'
import type { UserType } from '../../types/user.types'
import type {
DownloadReleaseRequest,
UpdateCatalog,
UpdateDialogCatalog,
UpdateStatus
} from '../../types/update.types'
import { UpdateCatalogService } from './update-catalog-service'
import { UpdateInstaller } from './update-installer'
import { UpdateStorageClient } from './update-storage-client'
import { publishUpdateStatus } from './update-status-publisher'
import { DEFAULT_STATUS, getSupportState } from './update-support'
const log = createLogger('UpdateService')
export class UpdateService {
private static instance: UpdateService | null = null
private config: UpdateConfig | null = null
private storageClient: UpdateStorageClient | null = null
private catalogService: UpdateCatalogService | null = null
private installer = new UpdateInstaller()
private status: UpdateStatus = { ...DEFAULT_STATUS }
private catalog: UpdateCatalog = { stable: [], preview: [] }
private changelogCache = new Map<string, string>()
private intervalHandle: NodeJS.Timeout | null = null
private initialized = false
public static getInstance(): UpdateService {
if (!UpdateService.instance) {
UpdateService.instance = new UpdateService()
}
return UpdateService.instance
}
public initialize(): void {
if (this.initialized) {
return
}
this.config = ConfigManager.getInstance().getConfig().update ?? null
const supportState = getSupportState(this.config)
const enabled = Boolean(this.config?.enabled && supportState.supported)
this.status = {
...this.status,
enabled,
supported: supportState.supported,
message: supportState.reason
}
if (enabled && this.config) {
this.storageClient = new UpdateStorageClient(this.config)
this.catalogService = new UpdateCatalogService(
this.config,
this.storageClient,
this.installer
)
}
log.info('Update service initialized', {
enabled,
supported: supportState.supported,
currentVersion: this.status.currentVersion,
currentChannel: this.status.currentChannel
})
this.initialized = true
}
public getStatus(): UpdateStatus {
return { ...this.status }
}
public getCatalog(): UpdateDialogCatalog {
if (!this.catalogService) {
return { mode: 'disabled' }
}
return this.catalogService.getDialogCatalog(this.status, this.catalog)
}
public async getChangelog(release: DownloadReleaseRequest): Promise<string> {
this.ensureInitialized()
if (!this.status.enabled || !this.storageClient) {
throw new Error('自动更新不可用')
}
const cacheKey = `${release.channel}:${release.version}`
const cached = this.changelogCache.get(cacheKey)
if (cached) {
return cached
}
const markdown = await this.storageClient.readText(release.changelogKey)
this.changelogCache.set(cacheKey, markdown)
return markdown
}
public async setUserContext(userType: UserType | null): Promise<void> {
this.ensureInitialized()
this.status.currentUserType = userType
if (!this.status.enabled || !userType || userType === 'Guest') {
this.clearPolling()
this.catalog = { stable: [], preview: [] }
this.publishStatus({
phase: 'idle',
currentUserType: userType,
recommendedRelease: undefined,
latestVersion: undefined,
latestChannel: undefined,
downloadedRelease: undefined,
progress: undefined,
adminHasAnyRelease: false,
error: undefined,
message: this.status.supported ? undefined : this.status.message
})
return
}
// 启动异步更新检查,不阻塞登录流程
void this.checkForUpdates().catch((error) => {
log.warn('Async update check failed', {
error: error instanceof Error ? error.message : String(error)
})
})
this.startPolling()
}
public async checkForUpdates(): Promise<UpdateStatus> {
this.ensureInitialized()
if (!this.status.enabled || !this.storageClient || !this.catalogService) {
return this.getStatus()
}
this.publishStatus({
phase: 'checking',
error: undefined,
message: '正在检查更新...'
})
try {
const currentUserType = this.status.currentUserType
this.catalog = await this.catalogService.loadCatalog(currentUserType)
if (currentUserType === 'User') {
const nextStatus = await this.catalogService.resolveUserStatus(this.status, this.catalog)
this.publishStatus(nextStatus)
if (nextStatus.phase === 'available' && nextStatus.recommendedRelease) {
// 异步下载,不阻塞更新检查流程
void this.downloadRelease(nextStatus.recommendedRelease).catch((error) => {
const message = error instanceof Error ? error.message : '下载更新失败'
log.warn('Async update download failed', { error: message })
this.publishStatus({
phase: 'error',
error: message,
message
})
})
}
} else if (currentUserType === 'Admin') {
this.publishStatus(this.catalogService.resolveAdminStatus(this.status, this.catalog))
} else {
this.publishStatus({
phase: 'idle',
message: undefined,
error: undefined
})
}
} catch (error) {
const message = error instanceof Error ? error.message : '检查更新失败'
log.error('Failed to check for updates', { error: message })
this.publishStatus({
phase: 'error',
error: message,
message: '检查更新失败'
})
}
return this.getStatus()
}
public async downloadRelease(request: DownloadReleaseRequest): Promise<UpdateStatus> {
this.ensureInitialized()
if (!this.status.enabled || !this.storageClient) {
throw new Error('自动更新不可用')
}
this.publishStatus({
phase: 'downloading',
progress: 0,
latestVersion: request.version,
latestChannel: request.channel,
recommendedRelease: request,
message: `正在下载 ${request.channel} ${request.version}...`,
error: undefined
})
const downloadPath = this.installer.getDownloadPath(request)
await this.storageClient.downloadToFile(request.artifactKey, downloadPath)
const hash = await this.installer.calculateSha256(downloadPath)
if (hash.toLowerCase() !== request.sha256.toLowerCase()) {
await fs.promises.rm(downloadPath, { force: true })
throw new Error('更新包校验失败,文件哈希不匹配')
}
this.publishStatus({
phase: 'downloaded',
progress: 100,
downloadedRelease: {
version: request.version,
channel: request.channel,
localPath: downloadPath
},
latestVersion: request.version,
latestChannel: request.channel,
message: `已下载 ${request.channel} ${request.version}`
})
return this.getStatus()
}
public async installDownloadedRelease(): Promise<void> {
this.ensureInitialized()
const downloaded = this.status.downloadedRelease
if (!this.status.enabled || !downloaded) {
throw new Error('没有可安装的更新包')
}
this.publishStatus({
phase: 'installing',
latestVersion: downloaded.version,
latestChannel: downloaded.channel,
message: `正在安装 ${downloaded.version}...`,
error: undefined
})
await this.installer.installDownloadedRelease(downloaded)
}
private ensureInitialized(): void {
if (!this.initialized) {
this.initialize()
}
}
private publishStatus(next: Partial<UpdateStatus>): void {
this.status = {
...this.status,
...next
}
publishUpdateStatus(this.status)
}
private startPolling(): void {
this.clearPolling()
if (!this.config) {
return
}
this.intervalHandle = setInterval(
() => {
this.checkForUpdates().catch((error) => {
log.warn('Periodic update check failed', {
error: error instanceof Error ? error.message : String(error)
})
})
},
this.config.checkIntervalMinutes * 60 * 1000
)
}
private clearPolling(): void {
if (this.intervalHandle) {
clearInterval(this.intervalHandle)
this.intervalHandle = null
}
}
}