refactor: split update service responsibilities

This commit is contained in:
Misaka
2026-03-21 18:06:10 +08:00
parent 9a1f5a483e
commit 84c6b81959
5 changed files with 298 additions and 266 deletions

View File

@@ -0,0 +1,146 @@
import { app } from 'electron'
import { createHash } from 'crypto'
import * as fs from 'fs'
import * as path from 'path'
import { spawn } from 'child_process'
import { createLogger } from '../logger'
import type { DownloadedRelease, UpdateRelease } from '../../types/update.types'
import { appendPortableLaunchLog } from './update-support'
const log = createLogger('UpdateInstaller')
export class UpdateInstaller {
public async getValidDownloadedRelease(
release: UpdateRelease
): Promise<DownloadedRelease | null> {
const downloadPath = this.getDownloadPath(release)
if (!fs.existsSync(downloadPath)) {
return null
}
const hash = await this.calculateSha256(downloadPath)
if (hash.toLowerCase() !== release.sha256.toLowerCase()) {
await fs.promises.rm(downloadPath, { force: true })
return null
}
return {
...release,
localPath: downloadPath
}
}
public getDownloadPath(release: Pick<UpdateRelease, 'channel' | 'version'>): string {
return path.join(
app.getPath('userData'),
'pending-update',
`${release.channel}-${release.version}.exe`
)
}
public async calculateSha256(filePath: string): Promise<string> {
const hash = createHash('sha256')
const input = fs.createReadStream(filePath)
await new Promise<void>((resolve, reject) => {
input.on('data', (chunk) => hash.update(chunk))
input.on('error', reject)
input.on('end', resolve)
})
return hash.digest('hex')
}
public async installDownloadedRelease(
downloaded: Pick<DownloadedRelease, 'version' | 'channel' | 'localPath'>
): Promise<void> {
const updaterSourcePath = this.resolveUpdaterBinaryPath()
const updaterPath = await this.prepareUpdaterBinary(updaterSourcePath)
const targetExe = process.env.PORTABLE_EXECUTABLE_FILE || process.execPath
const logPath = path.join(app.getPath('userData'), 'updates', 'portable-update.log')
const launchLogPath = path.join(app.getPath('userData'), 'updates', 'portable-launch.log')
const appArgs = process.argv.slice(1)
const argsBase64 =
appArgs.length > 0 ? Buffer.from(appArgs.join('\0'), 'utf-8').toString('base64') : ''
const spawnArgs = [
'--targetExe',
targetExe,
'--downloadedExe',
downloaded.localPath,
'--parentPid',
String(process.pid),
'--logPath',
logPath
]
if (argsBase64) {
spawnArgs.push('--argsBase64', argsBase64)
}
appendPortableLaunchLog(launchLogPath, 'Preparing portable updater launch', {
updaterSourcePath,
updaterPath,
targetExe,
downloadedExe: downloaded.localPath,
parentPid: process.pid,
logPath,
appArgs,
updaterExists: fs.existsSync(updaterPath),
spawnArgs
})
const child = spawn(updaterPath, spawnArgs, {
detached: true,
stdio: 'ignore',
windowsHide: true
})
appendPortableLaunchLog(launchLogPath, 'Spawn returned for portable updater', {
childPid: child.pid ?? null
})
child.on('error', (error) => {
appendPortableLaunchLog(launchLogPath, 'Portable updater executable spawn error', {
error: error instanceof Error ? error.message : String(error)
})
log.error('Failed to launch portable updater executable', {
error: error instanceof Error ? error.message : String(error)
})
})
child.once('spawn', () => {
appendPortableLaunchLog(launchLogPath, 'Portable updater executable spawned', {
childPid: child.pid ?? null
})
})
child.unref()
app.quit()
}
private resolveUpdaterBinaryPath(): string {
const packagedPath = path.join(process.resourcesPath, 'portable-updater.exe')
const devPath = path.resolve(process.cwd(), 'build', 'bin', 'portable-updater.exe')
if (fs.existsSync(packagedPath)) {
return packagedPath
}
if (fs.existsSync(devPath)) {
return devPath
}
throw new Error('未找到便携版更新器')
}
private async prepareUpdaterBinary(sourcePath: string): Promise<string> {
const updatesDir = path.join(app.getPath('userData'), 'updates')
const stagedPath = path.join(updatesDir, 'portable-updater.exe')
await fs.promises.mkdir(updatesDir, { recursive: true })
await fs.promises.copyFile(sourcePath, stagedPath)
return stagedPath
}
}

View File

@@ -1,23 +1,20 @@
import { BrowserWindow, app } from 'electron'
import { createHash } from 'crypto'
import * as fs from 'fs' import * as fs from 'fs'
import * as path from 'path'
import { spawn } from 'child_process'
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'
import { ConfigManager } from '../config/config-manager' import { ConfigManager } from '../config/config-manager'
import { createLogger } from '../logger' import { createLogger } from '../logger'
import { IPC_CHANNELS } from '../../../shared/ipc-channels'
import type { UpdateConfig } from '../../types/config.schema' 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,
DownloadedRelease,
ReleaseChannel, ReleaseChannel,
UpdateCatalog, UpdateCatalog,
UpdateDialogCatalog, UpdateDialogCatalog,
UpdateRelease, UpdateRelease,
UpdateStatus UpdateStatus
} from '../../types/update.types' } from '../../types/update.types'
import { UpdateInstaller } from './update-installer'
import { UpdateStorageClient } from './update-storage-client'
import { publishUpdateStatus } from './update-status-publisher'
import { DEFAULT_STATUS, getSupportState, isMissingObjectError } from './update-support'
import { import {
compareVersions, compareVersions,
limitCatalogHistory, limitCatalogHistory,
@@ -28,135 +25,12 @@ import {
const log = createLogger('UpdateService') const log = createLogger('UpdateService')
function appendPortableLaunchLog(
logPath: string,
message: string,
meta?: Record<string, unknown>
): void {
try {
fs.mkdirSync(path.dirname(logPath), { recursive: true })
const timestamp = new Date().toISOString()
const suffix = meta ? ` ${JSON.stringify(meta)}` : ''
fs.appendFileSync(logPath, `${timestamp} ${message}${suffix}\n`, 'utf-8')
} catch {
// Best-effort debug log only.
}
}
function getCurrentAppVersion(): string {
return typeof app?.getVersion === 'function' ? app.getVersion() : '0.0.0'
}
function getCurrentChannel(): ReleaseChannel {
return typeof __APP_CHANNEL__ !== 'undefined' ? __APP_CHANNEL__ : 'stable'
}
function isMissingObjectError(error: unknown): boolean {
if (!error || typeof error !== 'object') {
return false
}
const candidate = error as {
name?: string
Code?: string
code?: string
message?: string
}
return (
candidate.name === 'NoSuchKey' ||
candidate.Code === 'NoSuchKey' ||
candidate.code === 'NoSuchKey' ||
candidate.message?.includes('The specified key does not exist') === true
)
}
function getSupportState(config: UpdateConfig | null): {
supported: boolean
reason?: string
} {
if (process.platform !== 'win32') {
return { supported: false, reason: '当前仅支持 Windows 自动更新' }
}
if (app?.isPackaged) {
return { supported: true }
}
if (config?.allowDevMode) {
return { supported: true, reason: '开发模式调试已启用更新检查' }
}
return { supported: false, reason: '开发模式默认禁用自动更新检查' }
}
const DEFAULT_STATUS: UpdateStatus = {
enabled: false,
supported: false,
phase: 'idle',
currentVersion: getCurrentAppVersion(),
currentChannel: getCurrentChannel(),
currentUserType: null
}
class UpdateStorageClient {
private client: S3Client
private bucket: string
constructor(config: UpdateConfig) {
this.bucket = config.bucket
this.client = new S3Client({
region: config.region,
endpoint: config.endpoint,
credentials: {
accessKeyId: config.accessKey,
secretAccessKey: config.secretKey
},
forcePathStyle: true
})
}
async readText(key: string): Promise<string> {
const response = await this.client.send(
new GetObjectCommand({
Bucket: this.bucket,
Key: key
})
)
const chunks: Buffer[] = []
for await (const chunk of response.Body as AsyncIterable<Uint8Array>) {
chunks.push(Buffer.from(chunk))
}
return Buffer.concat(chunks).toString('utf-8')
}
async downloadToFile(key: string, destination: string): Promise<void> {
const response = await this.client.send(
new GetObjectCommand({
Bucket: this.bucket,
Key: key
})
)
await fs.promises.mkdir(path.dirname(destination), { recursive: true })
const output = fs.createWriteStream(destination)
const body = response.Body as NodeJS.ReadableStream
await new Promise<void>((resolve, reject) => {
body.on('error', reject)
output.on('error', reject)
output.on('finish', resolve)
body.pipe(output)
})
}
}
export class UpdateService { export class UpdateService {
private static instance: UpdateService | null = null private static instance: UpdateService | null = null
private config: UpdateConfig | null = null private config: UpdateConfig | null = null
private storageClient: UpdateStorageClient | null = null private storageClient: UpdateStorageClient | null = null
private installer = new UpdateInstaller()
private status: UpdateStatus = { ...DEFAULT_STATUS } private status: UpdateStatus = { ...DEFAULT_STATUS }
private catalog: UpdateCatalog = { stable: [], preview: [] } private catalog: UpdateCatalog = { stable: [], preview: [] }
private changelogCache = new Map<string, string>() private changelogCache = new Map<string, string>()
@@ -327,9 +201,9 @@ export class UpdateService {
error: undefined error: undefined
}) })
const downloadPath = this.getDownloadPath(request) const downloadPath = this.installer.getDownloadPath(request)
await this.storageClient.downloadToFile(request.artifactKey, downloadPath) await this.storageClient.downloadToFile(request.artifactKey, downloadPath)
const hash = await this.calculateSha256(downloadPath) const hash = await this.installer.calculateSha256(downloadPath)
if (hash.toLowerCase() !== request.sha256.toLowerCase()) { if (hash.toLowerCase() !== request.sha256.toLowerCase()) {
await fs.promises.rm(downloadPath, { force: true }) await fs.promises.rm(downloadPath, { force: true })
@@ -360,30 +234,6 @@ export class UpdateService {
throw new Error('没有可安装的更新包') throw new Error('没有可安装的更新包')
} }
const updaterSourcePath = this.resolveUpdaterBinaryPath()
const updaterPath = await this.prepareUpdaterBinary(updaterSourcePath)
const targetExe = process.env.PORTABLE_EXECUTABLE_FILE || process.execPath
const logPath = path.join(app.getPath('userData'), 'updates', 'portable-update.log')
const launchLogPath = path.join(app.getPath('userData'), 'updates', 'portable-launch.log')
const appArgs = process.argv.slice(1)
const argsBase64 =
appArgs.length > 0 ? Buffer.from(appArgs.join('\0'), 'utf-8').toString('base64') : ''
const spawnArgs = [
'--targetExe',
targetExe,
'--downloadedExe',
downloaded.localPath,
'--parentPid',
String(process.pid),
'--logPath',
logPath
]
if (argsBase64) {
spawnArgs.push('--argsBase64', argsBase64)
}
this.publishStatus({ this.publishStatus({
phase: 'installing', phase: 'installing',
latestVersion: downloaded.version, latestVersion: downloaded.version,
@@ -392,45 +242,7 @@ export class UpdateService {
error: undefined error: undefined
}) })
appendPortableLaunchLog(launchLogPath, 'Preparing portable updater launch', { await this.installer.installDownloadedRelease(downloaded)
updaterSourcePath,
updaterPath,
targetExe,
downloadedExe: downloaded.localPath,
parentPid: process.pid,
logPath,
appArgs,
updaterExists: fs.existsSync(updaterPath),
spawnArgs
})
const child = spawn(updaterPath, spawnArgs, {
detached: true,
stdio: 'ignore',
windowsHide: true
})
appendPortableLaunchLog(launchLogPath, 'Spawn returned for portable updater', {
childPid: child.pid ?? null
})
child.on('error', (error) => {
appendPortableLaunchLog(launchLogPath, 'Portable updater executable spawn error', {
error: error instanceof Error ? error.message : String(error)
})
log.error('Failed to launch portable updater executable', {
error: error instanceof Error ? error.message : String(error)
})
})
child.once('spawn', () => {
appendPortableLaunchLog(launchLogPath, 'Portable updater executable spawned', {
childPid: child.pid ?? null
})
})
child.unref()
app.quit()
} }
private ensureInitialized(): void { private ensureInitialized(): void {
@@ -445,9 +257,7 @@ export class UpdateService {
...next ...next
} }
BrowserWindow.getAllWindows().forEach((window) => { publishUpdateStatus(this.status)
window.webContents.send(IPC_CHANNELS.UPDATE_STATUS_CHANGED, this.status)
})
} }
private async fetchChannelIndex(channel: ReleaseChannel): Promise<UpdateRelease[]> { private async fetchChannelIndex(channel: ReleaseChannel): Promise<UpdateRelease[]> {
@@ -506,7 +316,7 @@ export class UpdateService {
: `发现稳定版 ${recommended.version}` : `发现稳定版 ${recommended.version}`
}) })
const existing = await this.getValidDownloadedRelease(recommended) const existing = await this.installer.getValidDownloadedRelease(recommended)
if (existing) { if (existing) {
this.publishStatus({ this.publishStatus({
phase: 'downloaded', phase: 'downloaded',
@@ -589,70 +399,4 @@ export class UpdateService {
this.intervalHandle = null this.intervalHandle = null
} }
} }
private async getValidDownloadedRelease(
release: UpdateRelease
): Promise<DownloadedRelease | null> {
const downloadPath = this.getDownloadPath(release)
if (!fs.existsSync(downloadPath)) {
return null
}
const hash = await this.calculateSha256(downloadPath)
if (hash.toLowerCase() !== release.sha256.toLowerCase()) {
await fs.promises.rm(downloadPath, { force: true })
return null
}
return {
...release,
localPath: downloadPath
}
}
private getDownloadPath(release: UpdateRelease): string {
return path.join(
app.getPath('userData'),
'pending-update',
`${release.channel}-${release.version}.exe`
)
}
private async calculateSha256(filePath: string): Promise<string> {
const hash = createHash('sha256')
const input = fs.createReadStream(filePath)
await new Promise<void>((resolve, reject) => {
input.on('data', (chunk) => hash.update(chunk))
input.on('error', reject)
input.on('end', resolve)
})
return hash.digest('hex')
}
private resolveUpdaterBinaryPath(): string {
const packagedPath = path.join(process.resourcesPath, 'portable-updater.exe')
const devPath = path.resolve(process.cwd(), 'build', 'bin', 'portable-updater.exe')
if (fs.existsSync(packagedPath)) {
return packagedPath
}
if (fs.existsSync(devPath)) {
return devPath
}
throw new Error('未找到便携版更新器')
}
private async prepareUpdaterBinary(sourcePath: string): Promise<string> {
const updatesDir = path.join(app.getPath('userData'), 'updates')
const stagedPath = path.join(updatesDir, 'portable-updater.exe')
await fs.promises.mkdir(updatesDir, { recursive: true })
await fs.promises.copyFile(sourcePath, stagedPath)
return stagedPath
}
} }

View File

@@ -0,0 +1,9 @@
import { BrowserWindow } from 'electron'
import { IPC_CHANNELS } from '../../../shared/ipc-channels'
import type { UpdateStatus } from '../../types/update.types'
export function publishUpdateStatus(status: UpdateStatus): void {
BrowserWindow.getAllWindows().forEach((window) => {
window.webContents.send(IPC_CHANNELS.UPDATE_STATUS_CHANGED, status)
})
}

View File

@@ -0,0 +1,57 @@
import * as fs from 'fs'
import * as path from 'path'
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'
import type { UpdateConfig } from '../../types/config.schema'
export class UpdateStorageClient {
private client: S3Client
private bucket: string
constructor(config: UpdateConfig) {
this.bucket = config.bucket
this.client = new S3Client({
region: config.region,
endpoint: config.endpoint,
credentials: {
accessKeyId: config.accessKey,
secretAccessKey: config.secretKey
},
forcePathStyle: true
})
}
async readText(key: string): Promise<string> {
const response = await this.client.send(
new GetObjectCommand({
Bucket: this.bucket,
Key: key
})
)
const chunks: Buffer[] = []
for await (const chunk of response.Body as AsyncIterable<Uint8Array>) {
chunks.push(Buffer.from(chunk))
}
return Buffer.concat(chunks).toString('utf-8')
}
async downloadToFile(key: string, destination: string): Promise<void> {
const response = await this.client.send(
new GetObjectCommand({
Bucket: this.bucket,
Key: key
})
)
await fs.promises.mkdir(path.dirname(destination), { recursive: true })
const output = fs.createWriteStream(destination)
const body = response.Body as NodeJS.ReadableStream
await new Promise<void>((resolve, reject) => {
body.on('error', reject)
output.on('error', reject)
output.on('finish', resolve)
body.pipe(output)
})
}
}

View File

@@ -0,0 +1,76 @@
import { app } from 'electron'
import * as fs from 'fs'
import * as path from 'path'
import type { UpdateConfig } from '../../types/config.schema'
import type { ReleaseChannel, UpdateStatus } from '../../types/update.types'
export function appendPortableLaunchLog(
logPath: string,
message: string,
meta?: Record<string, unknown>
): void {
try {
fs.mkdirSync(path.dirname(logPath), { recursive: true })
const timestamp = new Date().toISOString()
const suffix = meta ? ` ${JSON.stringify(meta)}` : ''
fs.appendFileSync(logPath, `${timestamp} ${message}${suffix}\n`, 'utf-8')
} catch {
// Best-effort debug log only.
}
}
export function getCurrentAppVersion(): string {
return typeof app?.getVersion === 'function' ? app.getVersion() : '0.0.0'
}
export function getCurrentChannel(): ReleaseChannel {
return typeof __APP_CHANNEL__ !== 'undefined' ? __APP_CHANNEL__ : 'stable'
}
export function isMissingObjectError(error: unknown): boolean {
if (!error || typeof error !== 'object') {
return false
}
const candidate = error as {
name?: string
Code?: string
code?: string
message?: string
}
return (
candidate.name === 'NoSuchKey' ||
candidate.Code === 'NoSuchKey' ||
candidate.code === 'NoSuchKey' ||
candidate.message?.includes('The specified key does not exist') === true
)
}
export function getSupportState(config: UpdateConfig | null): {
supported: boolean
reason?: string
} {
if (process.platform !== 'win32') {
return { supported: false, reason: '当前仅支持 Windows 自动更新' }
}
if (app?.isPackaged) {
return { supported: true }
}
if (config?.allowDevMode) {
return { supported: true, reason: '开发模式调试已启用更新检查' }
}
return { supported: false, reason: '开发模式默认禁用自动更新检查' }
}
export const DEFAULT_STATUS: UpdateStatus = {
enabled: false,
supported: false,
phase: 'idle',
currentVersion: getCurrentAppVersion(),
currentChannel: getCurrentChannel(),
currentUserType: null
}