refactor: extract update catalog decisions
This commit is contained in:
166
src/main/services/update/update-catalog-service.ts
Normal file
166
src/main/services/update/update-catalog-service.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { createLogger } from '../logger'
|
||||||
|
import type { UpdateConfig } from '../../types/config.schema'
|
||||||
|
import type { UserType } from '../../types/user.types'
|
||||||
|
import type {
|
||||||
|
UpdateCatalog,
|
||||||
|
UpdateDialogCatalog,
|
||||||
|
UpdateRelease,
|
||||||
|
UpdateStatus
|
||||||
|
} from '../../types/update.types'
|
||||||
|
import type { UpdateInstaller } from './update-installer'
|
||||||
|
import type { UpdateStorageClient } from './update-storage-client'
|
||||||
|
import { isMissingObjectError } from './update-support'
|
||||||
|
import {
|
||||||
|
compareVersions,
|
||||||
|
limitCatalogHistory,
|
||||||
|
normalizeReleases,
|
||||||
|
resolveAdminDecision,
|
||||||
|
resolveUserDecision
|
||||||
|
} from './update-utils'
|
||||||
|
|
||||||
|
const log = createLogger('UpdateCatalogService')
|
||||||
|
|
||||||
|
export class UpdateCatalogService {
|
||||||
|
constructor(
|
||||||
|
private readonly config: UpdateConfig,
|
||||||
|
private readonly storageClient: UpdateStorageClient,
|
||||||
|
private readonly installer: UpdateInstaller
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public getDialogCatalog(status: UpdateStatus, catalog: UpdateCatalog): UpdateDialogCatalog {
|
||||||
|
const currentUserType = status.currentUserType
|
||||||
|
if (!status.enabled || !currentUserType || currentUserType === 'Guest') {
|
||||||
|
return { mode: 'disabled' }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentUserType === 'User') {
|
||||||
|
return {
|
||||||
|
mode: 'user',
|
||||||
|
recommendedRelease: status.recommendedRelease
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: 'admin',
|
||||||
|
recommendedRelease: status.recommendedRelease,
|
||||||
|
channels: limitCatalogHistory(catalog, this.config.maxAdminHistoryPerChannel ?? 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async loadCatalog(currentUserType: UserType | null): Promise<UpdateCatalog> {
|
||||||
|
return {
|
||||||
|
stable: await this.fetchChannelIndex('stable'),
|
||||||
|
preview: currentUserType === 'Admin' ? await this.fetchChannelIndex('preview') : []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async resolveUserStatus(
|
||||||
|
status: UpdateStatus,
|
||||||
|
catalog: UpdateCatalog
|
||||||
|
): Promise<Partial<UpdateStatus>> {
|
||||||
|
const decision = resolveUserDecision({
|
||||||
|
currentVersion: status.currentVersion,
|
||||||
|
currentChannel: status.currentChannel,
|
||||||
|
catalog
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!decision.recommendedRelease || !decision.shouldOffer) {
|
||||||
|
return {
|
||||||
|
phase: 'idle',
|
||||||
|
recommendedRelease: undefined,
|
||||||
|
latestVersion: undefined,
|
||||||
|
latestChannel: undefined,
|
||||||
|
downloadedRelease: undefined,
|
||||||
|
progress: undefined,
|
||||||
|
adminHasAnyRelease: false,
|
||||||
|
error: undefined,
|
||||||
|
message: undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const recommended = decision.recommendedRelease
|
||||||
|
const existing = await this.installer.getValidDownloadedRelease(recommended)
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return {
|
||||||
|
phase: 'downloaded',
|
||||||
|
recommendedRelease: recommended,
|
||||||
|
latestVersion: recommended.version,
|
||||||
|
latestChannel: recommended.channel,
|
||||||
|
progress: 100,
|
||||||
|
downloadedRelease: {
|
||||||
|
version: existing.version,
|
||||||
|
channel: existing.channel,
|
||||||
|
localPath: existing.localPath
|
||||||
|
},
|
||||||
|
adminHasAnyRelease: false,
|
||||||
|
error: undefined,
|
||||||
|
message:
|
||||||
|
status.currentChannel === 'preview'
|
||||||
|
? `稳定版 ${recommended.version} 已准备安装`
|
||||||
|
: `发现稳定版 ${recommended.version}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
phase: 'available',
|
||||||
|
recommendedRelease: recommended,
|
||||||
|
latestVersion: recommended.version,
|
||||||
|
latestChannel: recommended.channel,
|
||||||
|
downloadedRelease: undefined,
|
||||||
|
progress: undefined,
|
||||||
|
adminHasAnyRelease: false,
|
||||||
|
error: undefined,
|
||||||
|
message:
|
||||||
|
status.currentChannel === 'preview'
|
||||||
|
? `当前为预览版,可切换回稳定版 ${recommended.version}`
|
||||||
|
: `发现稳定版 ${recommended.version}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public resolveAdminStatus(status: UpdateStatus, catalog: UpdateCatalog): Partial<UpdateStatus> {
|
||||||
|
const decision = resolveAdminDecision({
|
||||||
|
currentVersion: status.currentVersion,
|
||||||
|
currentChannel: status.currentChannel,
|
||||||
|
catalog,
|
||||||
|
maxHistoryPerChannel: this.config.maxAdminHistoryPerChannel ?? 10
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasHigherVersion = [...catalog.stable, ...catalog.preview].some(
|
||||||
|
(release) => compareVersions(release.version, status.currentVersion) > 0
|
||||||
|
)
|
||||||
|
const hasCrossChannelOption = [...catalog.stable, ...catalog.preview].some(
|
||||||
|
(release) => release.channel !== status.currentChannel
|
||||||
|
)
|
||||||
|
const shouldOffer = hasHigherVersion || hasCrossChannelOption
|
||||||
|
|
||||||
|
return {
|
||||||
|
phase: shouldOffer ? 'available' : 'idle',
|
||||||
|
recommendedRelease: decision.recommendedRelease,
|
||||||
|
latestVersion: decision.recommendedRelease?.version,
|
||||||
|
latestChannel: decision.recommendedRelease?.channel,
|
||||||
|
adminHasAnyRelease: shouldOffer,
|
||||||
|
downloadedRelease: undefined,
|
||||||
|
progress: undefined,
|
||||||
|
error: undefined,
|
||||||
|
message: shouldOffer ? '发现可用版本' : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchChannelIndex(channel: 'stable' | 'preview'): Promise<UpdateRelease[]> {
|
||||||
|
const key = `${this.config.basePrefix}/${channel}/index.json`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = await this.storageClient.readText(key)
|
||||||
|
const parsed = JSON.parse(raw) as unknown
|
||||||
|
return normalizeReleases(parsed, channel)
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingObjectError(error)) {
|
||||||
|
log.warn('Update channel index not found, treating as empty', { channel, key })
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,23 +5,15 @@ import type { UpdateConfig } from '../../types/config.schema'
|
|||||||
import type { UserType } from '../../types/user.types'
|
import type { UserType } from '../../types/user.types'
|
||||||
import type {
|
import type {
|
||||||
DownloadReleaseRequest,
|
DownloadReleaseRequest,
|
||||||
ReleaseChannel,
|
|
||||||
UpdateCatalog,
|
UpdateCatalog,
|
||||||
UpdateDialogCatalog,
|
UpdateDialogCatalog,
|
||||||
UpdateRelease,
|
|
||||||
UpdateStatus
|
UpdateStatus
|
||||||
} from '../../types/update.types'
|
} from '../../types/update.types'
|
||||||
|
import { UpdateCatalogService } from './update-catalog-service'
|
||||||
import { UpdateInstaller } from './update-installer'
|
import { UpdateInstaller } from './update-installer'
|
||||||
import { UpdateStorageClient } from './update-storage-client'
|
import { UpdateStorageClient } from './update-storage-client'
|
||||||
import { publishUpdateStatus } from './update-status-publisher'
|
import { publishUpdateStatus } from './update-status-publisher'
|
||||||
import { DEFAULT_STATUS, getSupportState, isMissingObjectError } from './update-support'
|
import { DEFAULT_STATUS, getSupportState } from './update-support'
|
||||||
import {
|
|
||||||
compareVersions,
|
|
||||||
limitCatalogHistory,
|
|
||||||
normalizeReleases,
|
|
||||||
resolveAdminDecision,
|
|
||||||
resolveUserDecision
|
|
||||||
} from './update-utils'
|
|
||||||
|
|
||||||
const log = createLogger('UpdateService')
|
const log = createLogger('UpdateService')
|
||||||
|
|
||||||
@@ -30,6 +22,7 @@ export class UpdateService {
|
|||||||
|
|
||||||
private config: UpdateConfig | null = null
|
private config: UpdateConfig | null = null
|
||||||
private storageClient: UpdateStorageClient | null = null
|
private storageClient: UpdateStorageClient | null = null
|
||||||
|
private catalogService: UpdateCatalogService | null = null
|
||||||
private installer = new UpdateInstaller()
|
private installer = new UpdateInstaller()
|
||||||
private status: UpdateStatus = { ...DEFAULT_STATUS }
|
private status: UpdateStatus = { ...DEFAULT_STATUS }
|
||||||
private catalog: UpdateCatalog = { stable: [], preview: [] }
|
private catalog: UpdateCatalog = { stable: [], preview: [] }
|
||||||
@@ -63,6 +56,11 @@ export class UpdateService {
|
|||||||
|
|
||||||
if (enabled && this.config) {
|
if (enabled && this.config) {
|
||||||
this.storageClient = new UpdateStorageClient(this.config)
|
this.storageClient = new UpdateStorageClient(this.config)
|
||||||
|
this.catalogService = new UpdateCatalogService(
|
||||||
|
this.config,
|
||||||
|
this.storageClient,
|
||||||
|
this.installer
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info('Update service initialized', {
|
log.info('Update service initialized', {
|
||||||
@@ -80,23 +78,11 @@ export class UpdateService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getCatalog(): UpdateDialogCatalog {
|
public getCatalog(): UpdateDialogCatalog {
|
||||||
const currentUserType = this.status.currentUserType
|
if (!this.catalogService) {
|
||||||
if (!this.status.enabled || !currentUserType || currentUserType === 'Guest') {
|
|
||||||
return { mode: 'disabled' }
|
return { mode: 'disabled' }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentUserType === 'User') {
|
return this.catalogService.getDialogCatalog(this.status, this.catalog)
|
||||||
return {
|
|
||||||
mode: 'user',
|
|
||||||
recommendedRelease: this.status.recommendedRelease
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
mode: 'admin',
|
|
||||||
recommendedRelease: this.status.recommendedRelease,
|
|
||||||
channels: limitCatalogHistory(this.catalog, this.config?.maxAdminHistoryPerChannel ?? 10)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getChangelog(release: DownloadReleaseRequest): Promise<string> {
|
public async getChangelog(release: DownloadReleaseRequest): Promise<string> {
|
||||||
@@ -144,7 +130,7 @@ export class UpdateService {
|
|||||||
|
|
||||||
public async checkForUpdates(): Promise<UpdateStatus> {
|
public async checkForUpdates(): Promise<UpdateStatus> {
|
||||||
this.ensureInitialized()
|
this.ensureInitialized()
|
||||||
if (!this.status.enabled || !this.storageClient || !this.config) {
|
if (!this.status.enabled || !this.storageClient || !this.catalogService) {
|
||||||
return this.getStatus()
|
return this.getStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,15 +142,26 @@ export class UpdateService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const currentUserType = this.status.currentUserType
|
const currentUserType = this.status.currentUserType
|
||||||
this.catalog = {
|
this.catalog = await this.catalogService.loadCatalog(currentUserType)
|
||||||
stable: await this.fetchChannelIndex('stable'),
|
|
||||||
preview: currentUserType === 'Admin' ? await this.fetchChannelIndex('preview') : []
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentUserType === 'User') {
|
if (currentUserType === 'User') {
|
||||||
await this.processUserCatalog()
|
const nextStatus = await this.catalogService.resolveUserStatus(this.status, this.catalog)
|
||||||
|
this.publishStatus(nextStatus)
|
||||||
|
|
||||||
|
if (nextStatus.phase === 'available' && nextStatus.recommendedRelease) {
|
||||||
|
try {
|
||||||
|
await this.downloadRelease(nextStatus.recommendedRelease)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : '下载更新失败'
|
||||||
|
this.publishStatus({
|
||||||
|
phase: 'error',
|
||||||
|
error: message,
|
||||||
|
message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (currentUserType === 'Admin') {
|
} else if (currentUserType === 'Admin') {
|
||||||
this.processAdminCatalog()
|
this.publishStatus(this.catalogService.resolveAdminStatus(this.status, this.catalog))
|
||||||
} else {
|
} else {
|
||||||
this.publishStatus({
|
this.publishStatus({
|
||||||
phase: 'idle',
|
phase: 'idle',
|
||||||
@@ -259,122 +256,6 @@ export class UpdateService {
|
|||||||
|
|
||||||
publishUpdateStatus(this.status)
|
publishUpdateStatus(this.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetchChannelIndex(channel: ReleaseChannel): Promise<UpdateRelease[]> {
|
|
||||||
if (!this.storageClient || !this.config) {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = `${this.config.basePrefix}/${channel}/index.json`
|
|
||||||
|
|
||||||
try {
|
|
||||||
const raw = await this.storageClient.readText(key)
|
|
||||||
const parsed = JSON.parse(raw) as unknown
|
|
||||||
return normalizeReleases(parsed, channel)
|
|
||||||
} catch (error) {
|
|
||||||
if (isMissingObjectError(error)) {
|
|
||||||
log.warn('Update channel index not found, treating as empty', { channel, key })
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async processUserCatalog(): Promise<void> {
|
|
||||||
const decision = resolveUserDecision({
|
|
||||||
currentVersion: this.status.currentVersion,
|
|
||||||
currentChannel: this.status.currentChannel,
|
|
||||||
catalog: this.catalog
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!decision.recommendedRelease || !decision.shouldOffer) {
|
|
||||||
this.publishStatus({
|
|
||||||
phase: 'idle',
|
|
||||||
recommendedRelease: undefined,
|
|
||||||
latestVersion: undefined,
|
|
||||||
latestChannel: undefined,
|
|
||||||
downloadedRelease: undefined,
|
|
||||||
progress: undefined,
|
|
||||||
adminHasAnyRelease: false,
|
|
||||||
error: undefined,
|
|
||||||
message: undefined
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const recommended = decision.recommendedRelease
|
|
||||||
this.publishStatus({
|
|
||||||
phase: 'available',
|
|
||||||
recommendedRelease: recommended,
|
|
||||||
latestVersion: recommended.version,
|
|
||||||
latestChannel: recommended.channel,
|
|
||||||
adminHasAnyRelease: false,
|
|
||||||
message:
|
|
||||||
this.status.currentChannel === 'preview'
|
|
||||||
? `当前为预览版,可切换回稳定版 ${recommended.version}`
|
|
||||||
: `发现稳定版 ${recommended.version}`
|
|
||||||
})
|
|
||||||
|
|
||||||
const existing = await this.installer.getValidDownloadedRelease(recommended)
|
|
||||||
if (existing) {
|
|
||||||
this.publishStatus({
|
|
||||||
phase: 'downloaded',
|
|
||||||
progress: 100,
|
|
||||||
downloadedRelease: {
|
|
||||||
version: existing.version,
|
|
||||||
channel: existing.channel,
|
|
||||||
localPath: existing.localPath
|
|
||||||
},
|
|
||||||
message:
|
|
||||||
this.status.currentChannel === 'preview'
|
|
||||||
? `稳定版 ${recommended.version} 已准备安装`
|
|
||||||
: `发现稳定版 ${recommended.version}`
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.downloadRelease(recommended)
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : '下载更新失败'
|
|
||||||
this.publishStatus({
|
|
||||||
phase: 'error',
|
|
||||||
error: message,
|
|
||||||
message
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private processAdminCatalog(): void {
|
|
||||||
const decision = resolveAdminDecision({
|
|
||||||
currentVersion: this.status.currentVersion,
|
|
||||||
currentChannel: this.status.currentChannel,
|
|
||||||
catalog: this.catalog,
|
|
||||||
maxHistoryPerChannel: this.config?.maxAdminHistoryPerChannel ?? 10
|
|
||||||
})
|
|
||||||
|
|
||||||
const hasHigherVersion = [...this.catalog.stable, ...this.catalog.preview].some(
|
|
||||||
(release) => compareVersions(release.version, this.status.currentVersion) > 0
|
|
||||||
)
|
|
||||||
const hasCrossChannelOption = [...this.catalog.stable, ...this.catalog.preview].some(
|
|
||||||
(release) => release.channel !== this.status.currentChannel
|
|
||||||
)
|
|
||||||
const shouldOffer = hasHigherVersion || hasCrossChannelOption
|
|
||||||
|
|
||||||
this.publishStatus({
|
|
||||||
phase: shouldOffer ? 'available' : 'idle',
|
|
||||||
recommendedRelease: decision.recommendedRelease,
|
|
||||||
latestVersion: decision.recommendedRelease?.version,
|
|
||||||
latestChannel: decision.recommendedRelease?.channel,
|
|
||||||
adminHasAnyRelease: shouldOffer,
|
|
||||||
downloadedRelease: undefined,
|
|
||||||
progress: undefined,
|
|
||||||
error: undefined,
|
|
||||||
message: shouldOffer ? '发现可用版本' : undefined
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private startPolling(): void {
|
private startPolling(): void {
|
||||||
this.clearPolling()
|
this.clearPolling()
|
||||||
if (!this.config) {
|
if (!this.config) {
|
||||||
|
|||||||
Reference in New Issue
Block a user