From 84c6b8195921f27548ea3d0d1aed773567482be0 Mon Sep 17 00:00:00 2001 From: Misaka Date: Sat, 21 Mar 2026 18:06:10 +0800 Subject: [PATCH] refactor: split update service responsibilities --- src/main/services/update/update-installer.ts | 146 +++++++++ src/main/services/update/update-service.ts | 276 +----------------- .../update/update-status-publisher.ts | 9 + .../services/update/update-storage-client.ts | 57 ++++ src/main/services/update/update-support.ts | 76 +++++ 5 files changed, 298 insertions(+), 266 deletions(-) create mode 100644 src/main/services/update/update-installer.ts create mode 100644 src/main/services/update/update-status-publisher.ts create mode 100644 src/main/services/update/update-storage-client.ts create mode 100644 src/main/services/update/update-support.ts diff --git a/src/main/services/update/update-installer.ts b/src/main/services/update/update-installer.ts new file mode 100644 index 0000000..c13dc7d --- /dev/null +++ b/src/main/services/update/update-installer.ts @@ -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 { + 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): string { + return path.join( + app.getPath('userData'), + 'pending-update', + `${release.channel}-${release.version}.exe` + ) + } + + public async calculateSha256(filePath: string): Promise { + const hash = createHash('sha256') + const input = fs.createReadStream(filePath) + + await new Promise((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 + ): Promise { + 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 { + 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 + } +} diff --git a/src/main/services/update/update-service.ts b/src/main/services/update/update-service.ts index a8f3762..832a9e7 100644 --- a/src/main/services/update/update-service.ts +++ b/src/main/services/update/update-service.ts @@ -1,23 +1,20 @@ -import { BrowserWindow, app } from 'electron' -import { createHash } from 'crypto' 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 { createLogger } from '../logger' -import { IPC_CHANNELS } from '../../../shared/ipc-channels' import type { UpdateConfig } from '../../types/config.schema' import type { UserType } from '../../types/user.types' import type { DownloadReleaseRequest, - DownloadedRelease, ReleaseChannel, UpdateCatalog, UpdateDialogCatalog, UpdateRelease, UpdateStatus } 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 { compareVersions, limitCatalogHistory, @@ -28,135 +25,12 @@ import { const log = createLogger('UpdateService') -function appendPortableLaunchLog( - logPath: string, - message: string, - meta?: Record -): 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 { - 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) { - chunks.push(Buffer.from(chunk)) - } - return Buffer.concat(chunks).toString('utf-8') - } - - async downloadToFile(key: string, destination: string): Promise { - 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((resolve, reject) => { - body.on('error', reject) - output.on('error', reject) - output.on('finish', resolve) - body.pipe(output) - }) - } -} - export class UpdateService { private static instance: UpdateService | null = null private config: UpdateConfig | null = null private storageClient: UpdateStorageClient | null = null + private installer = new UpdateInstaller() private status: UpdateStatus = { ...DEFAULT_STATUS } private catalog: UpdateCatalog = { stable: [], preview: [] } private changelogCache = new Map() @@ -327,9 +201,9 @@ export class UpdateService { error: undefined }) - const downloadPath = this.getDownloadPath(request) + const downloadPath = this.installer.getDownloadPath(request) 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()) { await fs.promises.rm(downloadPath, { force: true }) @@ -360,30 +234,6 @@ export class UpdateService { 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({ phase: 'installing', latestVersion: downloaded.version, @@ -392,45 +242,7 @@ export class UpdateService { error: undefined }) - 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() + await this.installer.installDownloadedRelease(downloaded) } private ensureInitialized(): void { @@ -445,9 +257,7 @@ export class UpdateService { ...next } - BrowserWindow.getAllWindows().forEach((window) => { - window.webContents.send(IPC_CHANNELS.UPDATE_STATUS_CHANGED, this.status) - }) + publishUpdateStatus(this.status) } private async fetchChannelIndex(channel: ReleaseChannel): Promise { @@ -506,7 +316,7 @@ export class UpdateService { : `发现稳定版 ${recommended.version}` }) - const existing = await this.getValidDownloadedRelease(recommended) + const existing = await this.installer.getValidDownloadedRelease(recommended) if (existing) { this.publishStatus({ phase: 'downloaded', @@ -589,70 +399,4 @@ export class UpdateService { this.intervalHandle = null } } - - private async getValidDownloadedRelease( - release: UpdateRelease - ): Promise { - 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 { - const hash = createHash('sha256') - const input = fs.createReadStream(filePath) - - await new Promise((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 { - 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 - } } diff --git a/src/main/services/update/update-status-publisher.ts b/src/main/services/update/update-status-publisher.ts new file mode 100644 index 0000000..d8f3007 --- /dev/null +++ b/src/main/services/update/update-status-publisher.ts @@ -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) + }) +} diff --git a/src/main/services/update/update-storage-client.ts b/src/main/services/update/update-storage-client.ts new file mode 100644 index 0000000..c259e10 --- /dev/null +++ b/src/main/services/update/update-storage-client.ts @@ -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 { + 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) { + chunks.push(Buffer.from(chunk)) + } + return Buffer.concat(chunks).toString('utf-8') + } + + async downloadToFile(key: string, destination: string): Promise { + 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((resolve, reject) => { + body.on('error', reject) + output.on('error', reject) + output.on('finish', resolve) + body.pipe(output) + }) + } +} diff --git a/src/main/services/update/update-support.ts b/src/main/services/update/update-support.ts new file mode 100644 index 0000000..2cdcda1 --- /dev/null +++ b/src/main/services/update/update-support.ts @@ -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 +): 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 +}