refactor: split update service responsibilities
This commit is contained in:
146
src/main/services/update/update-installer.ts
Normal file
146
src/main/services/update/update-installer.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
9
src/main/services/update/update-status-publisher.ts
Normal file
9
src/main/services/update/update-status-publisher.ts
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
57
src/main/services/update/update-storage-client.ts
Normal file
57
src/main/services/update/update-storage-client.ts
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/main/services/update/update-support.ts
Normal file
76
src/main/services/update/update-support.ts
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user