feat: rebuild portable auto-update flow
This commit is contained in:
@@ -9,6 +9,7 @@ import { logAudit } from './services/logger/audit-logger'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname } from 'path'
|
||||
import fs from 'fs'
|
||||
import { UpdateService } from './services/update/update-service'
|
||||
|
||||
// Set Playwright browsers path BEFORE any playwright import
|
||||
process.env.PLAYWRIGHT_BROWSERS_PATH = join(app.getPath('userData'), 'ms-playwright')
|
||||
@@ -110,6 +111,7 @@ app.whenReady().then(async () => {
|
||||
try {
|
||||
const configManager = ConfigManager.getInstance()
|
||||
await configManager.initialize()
|
||||
UpdateService.getInstance().initialize()
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize ConfigManager:', error)
|
||||
// Continue anyway - default config will be created
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { UserInfo } from '../types/user.types'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { ValidationError } from '../types/errors'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
import { UpdateService } from '../services/update/update-service'
|
||||
|
||||
const log = createLogger('AuthHandler')
|
||||
|
||||
@@ -70,6 +71,7 @@ export interface CurrentUserResponse {
|
||||
*/
|
||||
export function registerAuthHandlers(): void {
|
||||
const sessionManager = SessionManager.getInstance()
|
||||
const updateService = UpdateService.getInstance()
|
||||
|
||||
/**
|
||||
* Get computer name
|
||||
@@ -93,6 +95,8 @@ export function registerAuthHandlers(): void {
|
||||
const userInfo = sessionManager.getUserInfo()
|
||||
|
||||
if (success && userInfo) {
|
||||
await updateService.setUserContext(userInfo.userType)
|
||||
|
||||
// Check if admin needs user selection
|
||||
const requiresUserSelection = userInfo.userType === 'Admin'
|
||||
|
||||
@@ -119,6 +123,7 @@ export function registerAuthHandlers(): void {
|
||||
}
|
||||
}
|
||||
|
||||
await updateService.setUserContext(null)
|
||||
throw new ValidationError('无感登录失败:未找到匹配用户', 'VAL_INVALID_INPUT')
|
||||
}, 'auth:silentLogin')
|
||||
}
|
||||
@@ -144,6 +149,7 @@ export function registerAuthHandlers(): void {
|
||||
|
||||
if (success && userInfo) {
|
||||
log.info('Login successful', { username, userType: userInfo.userType })
|
||||
await updateService.setUserContext(userInfo.userType)
|
||||
|
||||
// Audit log: LOGIN success (non-blocking)
|
||||
const os = await import('os')
|
||||
@@ -172,6 +178,7 @@ export function registerAuthHandlers(): void {
|
||||
}).catch((err) => log.warn('Failed to write audit log', { err }))
|
||||
|
||||
log.warn('Login failed - invalid credentials', { username })
|
||||
await updateService.setUserContext(null)
|
||||
throw new ValidationError('用户名或密码错误', 'VAL_INVALID_INPUT')
|
||||
}, 'auth:login')
|
||||
}
|
||||
@@ -198,6 +205,7 @@ export function registerAuthHandlers(): void {
|
||||
}
|
||||
|
||||
sessionManager.logout()
|
||||
await updateService.setUserContext(null)
|
||||
}, 'auth:logout')
|
||||
})
|
||||
|
||||
@@ -241,6 +249,7 @@ export function registerAuthHandlers(): void {
|
||||
if (success) {
|
||||
const newUser = sessionManager.getUserInfo()
|
||||
log.info('User switch successful', { newUsername: newUser?.username })
|
||||
await updateService.setUserContext(newUser?.userType ?? null)
|
||||
return {
|
||||
success: true,
|
||||
userInfo: newUser ?? undefined
|
||||
|
||||
@@ -15,6 +15,7 @@ import { registerMaterialTypeHandlers } from './material-type-handler'
|
||||
import { registerUserErpConfigHandlers } from './user-erp-config-handler'
|
||||
import { registerLoggerHandlers } from './logger-handler'
|
||||
import { registerReportHandlers } from './report-handler'
|
||||
import { registerUpdateHandlers } from './update-handler'
|
||||
import { createLogger, logError } from '../services/logger'
|
||||
import { serializeError, sanitizeError } from '../services/logger/error-utils'
|
||||
import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors'
|
||||
@@ -105,5 +106,6 @@ export function registerIpcHandlers(): void {
|
||||
registerUserErpConfigHandlers()
|
||||
registerLoggerHandlers()
|
||||
registerReportHandlers()
|
||||
registerUpdateHandlers()
|
||||
log.info('All IPC handlers registered')
|
||||
}
|
||||
|
||||
55
src/main/ipc/update-handler.ts
Normal file
55
src/main/ipc/update-handler.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||
import { withErrorHandling, type IpcResult } from './index'
|
||||
import { UpdateService } from '../services/update/update-service'
|
||||
import type {
|
||||
DownloadReleaseRequest,
|
||||
UpdateDialogCatalog,
|
||||
UpdateStatus
|
||||
} from '../types/update.types'
|
||||
|
||||
export function registerUpdateHandlers(): void {
|
||||
const updateService = UpdateService.getInstance()
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.UPDATE_GET_STATUS, async (): Promise<IpcResult<UpdateStatus>> => {
|
||||
return withErrorHandling(async () => updateService.getStatus(), 'update:getStatus')
|
||||
})
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.UPDATE_CHECK_NOW, async (): Promise<IpcResult<UpdateStatus>> => {
|
||||
return withErrorHandling(async () => updateService.checkForUpdates(), 'update:checkNow')
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.UPDATE_GET_CATALOG,
|
||||
async (): Promise<IpcResult<UpdateDialogCatalog>> => {
|
||||
return withErrorHandling(async () => updateService.getCatalog(), 'update:getCatalog')
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.UPDATE_GET_CHANGELOG,
|
||||
async (_event, request: DownloadReleaseRequest): Promise<IpcResult<string>> => {
|
||||
return withErrorHandling(
|
||||
async () => updateService.getChangelog(request),
|
||||
'update:getChangelog'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.UPDATE_DOWNLOAD_RELEASE,
|
||||
async (_event, request: DownloadReleaseRequest): Promise<IpcResult<UpdateStatus>> => {
|
||||
return withErrorHandling(
|
||||
async () => updateService.downloadRelease(request),
|
||||
'update:downloadRelease'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.UPDATE_INSTALL_DOWNLOADED, async (): Promise<IpcResult<void>> => {
|
||||
return withErrorHandling(
|
||||
async () => updateService.installDownloadedRelease(),
|
||||
'update:installDownloaded'
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -102,6 +102,18 @@ const DEFAULT_CONFIG: FullConfig = {
|
||||
secretKey: '',
|
||||
bucket: 'erpauto',
|
||||
region: 'us-east-1'
|
||||
},
|
||||
update: {
|
||||
enabled: false,
|
||||
allowDevMode: false,
|
||||
endpoint: '',
|
||||
accessKey: '',
|
||||
secretKey: '',
|
||||
bucket: '',
|
||||
region: 'us-east-1',
|
||||
basePrefix: 'updates/win-portable',
|
||||
checkIntervalMinutes: 30,
|
||||
maxAdminHistoryPerChannel: 10
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
650
src/main/services/update/update-service.ts
Normal file
650
src/main/services/update/update-service.ts
Normal file
@@ -0,0 +1,650 @@
|
||||
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 {
|
||||
compareVersions,
|
||||
limitCatalogHistory,
|
||||
normalizeReleases,
|
||||
resolveAdminDecision,
|
||||
resolveUserDecision
|
||||
} from './update-utils'
|
||||
|
||||
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 {
|
||||
private static instance: UpdateService | null = null
|
||||
|
||||
private config: UpdateConfig | null = null
|
||||
private storageClient: UpdateStorageClient | null = null
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
const currentUserType = this.status.currentUserType
|
||||
if (!this.status.enabled || !currentUserType || currentUserType === 'Guest') {
|
||||
return { mode: 'disabled' }
|
||||
}
|
||||
|
||||
if (currentUserType === 'User') {
|
||||
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> {
|
||||
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
|
||||
}
|
||||
|
||||
await this.checkForUpdates()
|
||||
this.startPolling()
|
||||
}
|
||||
|
||||
public async checkForUpdates(): Promise<UpdateStatus> {
|
||||
this.ensureInitialized()
|
||||
if (!this.status.enabled || !this.storageClient || !this.config) {
|
||||
return this.getStatus()
|
||||
}
|
||||
|
||||
this.publishStatus({
|
||||
phase: 'checking',
|
||||
error: undefined,
|
||||
message: '正在检查更新...'
|
||||
})
|
||||
|
||||
try {
|
||||
const currentUserType = this.status.currentUserType
|
||||
this.catalog = {
|
||||
stable: await this.fetchChannelIndex('stable'),
|
||||
preview: currentUserType === 'Admin' ? await this.fetchChannelIndex('preview') : []
|
||||
}
|
||||
|
||||
if (currentUserType === 'User') {
|
||||
await this.processUserCatalog()
|
||||
} else if (currentUserType === 'Admin') {
|
||||
this.processAdminCatalog()
|
||||
} 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.getDownloadPath(request)
|
||||
await this.storageClient.downloadToFile(request.artifactKey, downloadPath)
|
||||
const hash = await this.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('没有可安装的更新包')
|
||||
}
|
||||
|
||||
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,
|
||||
latestChannel: downloaded.channel,
|
||||
message: `正在安装 ${downloaded.version}...`,
|
||||
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()
|
||||
}
|
||||
|
||||
private ensureInitialized(): void {
|
||||
if (!this.initialized) {
|
||||
this.initialize()
|
||||
}
|
||||
}
|
||||
|
||||
private publishStatus(next: Partial<UpdateStatus>): void {
|
||||
this.status = {
|
||||
...this.status,
|
||||
...next
|
||||
}
|
||||
|
||||
BrowserWindow.getAllWindows().forEach((window) => {
|
||||
window.webContents.send(IPC_CHANNELS.UPDATE_STATUS_CHANGED, 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.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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
123
src/main/services/update/update-utils.ts
Normal file
123
src/main/services/update/update-utils.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type {
|
||||
ReleaseChannel,
|
||||
UpdateCatalog,
|
||||
UpdateDialogCatalog,
|
||||
UpdateRelease
|
||||
} from '../../types/update.types'
|
||||
|
||||
export function compareVersions(left: string, right: string): number {
|
||||
const leftParts = left.split('.').map((part) => Number.parseInt(part, 10) || 0)
|
||||
const rightParts = right.split('.').map((part) => Number.parseInt(part, 10) || 0)
|
||||
const length = Math.max(leftParts.length, rightParts.length)
|
||||
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const leftValue = leftParts[index] ?? 0
|
||||
const rightValue = rightParts[index] ?? 0
|
||||
|
||||
if (leftValue > rightValue) return 1
|
||||
if (leftValue < rightValue) return -1
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
export function normalizeReleases(input: unknown, channel: ReleaseChannel): UpdateRelease[] {
|
||||
const list = Array.isArray(input)
|
||||
? input
|
||||
: input && typeof input === 'object' && Array.isArray((input as { releases?: unknown[] }).releases)
|
||||
? (input as { releases: unknown[] }).releases
|
||||
: []
|
||||
|
||||
return list
|
||||
.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
|
||||
.map((item) => ({
|
||||
version: String(item.version ?? ''),
|
||||
channel,
|
||||
artifactKey: String(item.artifactKey ?? ''),
|
||||
sha256: String(item.sha256 ?? ''),
|
||||
size: Number(item.size ?? 0),
|
||||
publishedAt: String(item.publishedAt ?? ''),
|
||||
changelogKey: String(item.changelogKey ?? ''),
|
||||
notesSummary: item.notesSummary ? String(item.notesSummary) : undefined
|
||||
}))
|
||||
.filter(
|
||||
(item) =>
|
||||
!!item.version &&
|
||||
!!item.artifactKey &&
|
||||
!!item.sha256 &&
|
||||
!!item.changelogKey &&
|
||||
!!item.publishedAt
|
||||
)
|
||||
}
|
||||
|
||||
export function sortReleases(releases: UpdateRelease[]): UpdateRelease[] {
|
||||
return [...releases].sort((left, right) => {
|
||||
const versionCompare = compareVersions(right.version, left.version)
|
||||
if (versionCompare !== 0) {
|
||||
return versionCompare
|
||||
}
|
||||
|
||||
return new Date(right.publishedAt).getTime() - new Date(left.publishedAt).getTime()
|
||||
})
|
||||
}
|
||||
|
||||
export function pickLatestRelease(releases: UpdateRelease[]): UpdateRelease | undefined {
|
||||
return sortReleases(releases)[0]
|
||||
}
|
||||
|
||||
export function limitCatalogHistory(catalog: UpdateCatalog, maxPerChannel: number): UpdateCatalog {
|
||||
return {
|
||||
stable: sortReleases(catalog.stable).slice(0, maxPerChannel),
|
||||
preview: sortReleases(catalog.preview).slice(0, maxPerChannel)
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveUserDecision(params: {
|
||||
currentVersion: string
|
||||
currentChannel: ReleaseChannel
|
||||
catalog: UpdateCatalog
|
||||
}): {
|
||||
recommendedRelease?: UpdateRelease
|
||||
shouldOffer: boolean
|
||||
} {
|
||||
const latestStable = pickLatestRelease(params.catalog.stable)
|
||||
if (!latestStable) {
|
||||
return { recommendedRelease: undefined, shouldOffer: false }
|
||||
}
|
||||
|
||||
if (params.currentChannel === 'preview') {
|
||||
return { recommendedRelease: latestStable, shouldOffer: true }
|
||||
}
|
||||
|
||||
return {
|
||||
recommendedRelease: latestStable,
|
||||
shouldOffer: params.currentVersion !== latestStable.version
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAdminDecision(params: {
|
||||
currentVersion: string
|
||||
currentChannel: ReleaseChannel
|
||||
catalog: UpdateCatalog
|
||||
maxHistoryPerChannel: number
|
||||
}): UpdateDialogCatalog {
|
||||
const channels = limitCatalogHistory(params.catalog, params.maxHistoryPerChannel)
|
||||
const allReleases = sortReleases([...channels.stable, ...channels.preview])
|
||||
|
||||
const higherVersionRelease = allReleases.find(
|
||||
(release) => compareVersions(release.version, params.currentVersion) > 0
|
||||
)
|
||||
|
||||
const preferredCrossChannelRelease = allReleases.find(
|
||||
(release) => release.channel !== params.currentChannel
|
||||
)
|
||||
|
||||
return {
|
||||
mode: 'admin',
|
||||
recommendedRelease:
|
||||
higherVersionRelease ??
|
||||
preferredCrossChannelRelease ??
|
||||
pickLatestRelease(channels[params.currentChannel]),
|
||||
channels
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,22 @@ export const rustfsConfigSchema = z.object({
|
||||
region: z.string().default('us-east-1')
|
||||
})
|
||||
|
||||
/**
|
||||
* Update service configuration schema
|
||||
*/
|
||||
export const updateConfigSchema = z.object({
|
||||
enabled: z.boolean().default(false),
|
||||
allowDevMode: z.boolean().default(false),
|
||||
endpoint: z.string().default(''),
|
||||
accessKey: z.string().default(''),
|
||||
secretKey: z.string().default(''),
|
||||
bucket: z.string().default(''),
|
||||
region: z.string().default('us-east-1'),
|
||||
basePrefix: z.string().default('updates/win-portable'),
|
||||
checkIntervalMinutes: z.number().int().min(1).max(1440).default(30),
|
||||
maxAdminHistoryPerChannel: z.number().int().min(1).max(100).default(10)
|
||||
})
|
||||
|
||||
/**
|
||||
* 完整应用配置 Schema
|
||||
*/
|
||||
@@ -155,7 +171,8 @@ export const fullConfigSchema = z.object({
|
||||
cleaner: cleanerConfigSchema,
|
||||
orderResolution: orderResolutionSchema,
|
||||
logging: loggingConfigSchema,
|
||||
rustfs: rustfsConfigSchema.optional()
|
||||
rustfs: rustfsConfigSchema.optional(),
|
||||
update: updateConfigSchema.optional()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -168,6 +185,7 @@ export type SqlServerConfig = z.infer<typeof sqlServerConfigSchema>
|
||||
export type ErpSystemConfig = z.infer<typeof erpSystemConfigSchema>
|
||||
export type LoggingConfig = z.infer<typeof loggingConfigSchema>
|
||||
export type RustfsConfig = z.infer<typeof rustfsConfigSchema>
|
||||
export type UpdateConfig = z.infer<typeof updateConfigSchema>
|
||||
|
||||
/**
|
||||
* 验证并解析配置
|
||||
|
||||
66
src/main/types/update.types.ts
Normal file
66
src/main/types/update.types.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { UserType } from './user.types'
|
||||
|
||||
export type ReleaseChannel = 'stable' | 'preview'
|
||||
|
||||
export type UpdatePhase =
|
||||
| 'idle'
|
||||
| 'checking'
|
||||
| 'available'
|
||||
| 'downloading'
|
||||
| 'downloaded'
|
||||
| 'installing'
|
||||
| 'error'
|
||||
|
||||
export interface UpdateRelease {
|
||||
version: string
|
||||
channel: ReleaseChannel
|
||||
artifactKey: string
|
||||
sha256: string
|
||||
size: number
|
||||
publishedAt: string
|
||||
changelogKey: string
|
||||
notesSummary?: string
|
||||
}
|
||||
|
||||
export interface UpdateCatalog {
|
||||
stable: UpdateRelease[]
|
||||
preview: UpdateRelease[]
|
||||
}
|
||||
|
||||
export interface DownloadedRelease extends UpdateRelease {
|
||||
localPath: string
|
||||
}
|
||||
|
||||
export interface UpdateStatus {
|
||||
enabled: boolean
|
||||
supported: boolean
|
||||
phase: UpdatePhase
|
||||
currentVersion: string
|
||||
currentChannel: ReleaseChannel
|
||||
currentUserType: UserType | null
|
||||
message?: string
|
||||
latestVersion?: string
|
||||
latestChannel?: ReleaseChannel
|
||||
progress?: number
|
||||
downloadedRelease?: Pick<DownloadedRelease, 'version' | 'channel' | 'localPath'>
|
||||
recommendedRelease?: UpdateRelease
|
||||
error?: string
|
||||
adminHasAnyRelease?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateDialogCatalog {
|
||||
mode: 'user' | 'admin' | 'disabled'
|
||||
recommendedRelease?: UpdateRelease
|
||||
channels?: UpdateCatalog
|
||||
}
|
||||
|
||||
export interface DownloadReleaseRequest {
|
||||
version: string
|
||||
channel: ReleaseChannel
|
||||
artifactKey: string
|
||||
sha256: string
|
||||
size: number
|
||||
publishedAt: string
|
||||
changelogKey: string
|
||||
notesSummary?: string
|
||||
}
|
||||
16
src/preload/index.d.ts
vendored
16
src/preload/index.d.ts
vendored
@@ -22,6 +22,11 @@ import type {
|
||||
import type { IpcResult } from '../main/ipc'
|
||||
import type { LogLevel } from '../shared/ipc-channels'
|
||||
import type { CleanerConfig } from '../main/types/config.schema'
|
||||
import type {
|
||||
DownloadReleaseRequest,
|
||||
UpdateDialogCatalog,
|
||||
UpdateStatus
|
||||
} from '../main/types/update.types'
|
||||
|
||||
export interface ResolverAPI {
|
||||
resolve: (input: ResolverInput) => Promise<IpcResult<ResolverResponse>>
|
||||
@@ -120,6 +125,16 @@ export interface LoggerAPI {
|
||||
log: (level: LogLevel, message: string, context?: Record<string, unknown>) => void
|
||||
}
|
||||
|
||||
export interface UpdateAPI {
|
||||
getStatus: () => Promise<IpcResult<UpdateStatus>>
|
||||
checkNow: () => Promise<IpcResult<UpdateStatus>>
|
||||
getCatalog: () => Promise<IpcResult<UpdateDialogCatalog>>
|
||||
getChangelog: (release: DownloadReleaseRequest) => Promise<IpcResult<string>>
|
||||
downloadRelease: (release: DownloadReleaseRequest) => Promise<IpcResult<UpdateStatus>>
|
||||
installDownloaded: () => Promise<IpcResult<void>>
|
||||
onStatusChanged: (callback: (data: UpdateStatus) => void) => () => void
|
||||
}
|
||||
|
||||
export interface ProcessAPI {
|
||||
versions: {
|
||||
electron: string
|
||||
@@ -146,6 +161,7 @@ declare global {
|
||||
config: ConfigAPI
|
||||
logger: LoggerAPI
|
||||
report: ReportAPI
|
||||
update: UpdateAPI
|
||||
}
|
||||
api: unknown
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import type {
|
||||
import type { IpcResult } from '../main/ipc'
|
||||
import { IPC_CHANNELS, type LogLevel } from '../shared/ipc-channels'
|
||||
import type { CleanerConfig } from '../main/types/config.schema'
|
||||
import type { DownloadReleaseRequest, UpdateStatus } from '../main/types/update.types'
|
||||
import type { UpdateDialogCatalog } from '../main/types/update.types'
|
||||
|
||||
type ErpSettingsPayload = {
|
||||
erp?: {
|
||||
@@ -218,6 +220,25 @@ const api = {
|
||||
listByUser: (username: string): Promise<IpcResult> =>
|
||||
invokeIpc(IPC_CHANNELS.REPORT_LIST_BY_USER, username),
|
||||
download: (key: string): Promise<IpcResult> => invokeIpc(IPC_CHANNELS.REPORT_DOWNLOAD, key)
|
||||
},
|
||||
|
||||
update: {
|
||||
getStatus: (): Promise<IpcResult<UpdateStatus>> => invokeIpc(IPC_CHANNELS.UPDATE_GET_STATUS),
|
||||
checkNow: (): Promise<IpcResult<UpdateStatus>> => invokeIpc(IPC_CHANNELS.UPDATE_CHECK_NOW),
|
||||
getCatalog: (): Promise<IpcResult<UpdateDialogCatalog>> =>
|
||||
invokeIpc(IPC_CHANNELS.UPDATE_GET_CATALOG),
|
||||
getChangelog: (release: DownloadReleaseRequest): Promise<IpcResult<string>> =>
|
||||
invokeIpc(IPC_CHANNELS.UPDATE_GET_CHANGELOG, release),
|
||||
downloadRelease: (release: DownloadReleaseRequest): Promise<IpcResult<UpdateStatus>> =>
|
||||
invokeIpc(IPC_CHANNELS.UPDATE_DOWNLOAD_RELEASE, release),
|
||||
installDownloaded: (): Promise<IpcResult<void>> =>
|
||||
invokeIpc(IPC_CHANNELS.UPDATE_INSTALL_DOWNLOADED),
|
||||
onStatusChanged: (callback: (data: UpdateStatus) => void) => {
|
||||
const subscription = (_event: Electron.IpcRendererEvent, data: UpdateStatus) =>
|
||||
callback(data)
|
||||
ipcRenderer.on(IPC_CHANNELS.UPDATE_STATUS_CHANGED, subscription)
|
||||
return () => ipcRenderer.removeListener(IPC_CHANNELS.UPDATE_STATUS_CHANGED, subscription)
|
||||
}
|
||||
}
|
||||
} as const
|
||||
|
||||
|
||||
@@ -9,7 +9,17 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { LayoutDashboard, Download, Trash2, Settings, Database, User, LogOut } from 'lucide-react'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Download,
|
||||
Trash2,
|
||||
Settings,
|
||||
Database,
|
||||
User,
|
||||
LogOut,
|
||||
ArrowUpCircle,
|
||||
LoaderCircle
|
||||
} from 'lucide-react'
|
||||
import { useLogger } from './hooks/useLogger'
|
||||
import LoginDialog from './components/LoginDialog'
|
||||
import UserSelectionDialog, {
|
||||
@@ -19,6 +29,12 @@ import { Toast } from './components/ui/Toast'
|
||||
import ExtractorPage from './pages/ExtractorPage'
|
||||
import CleanerPage from './pages/CleanerPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import UpdateDialog from './components/UpdateDialog'
|
||||
import type {
|
||||
DownloadReleaseRequest,
|
||||
UpdateDialogCatalog,
|
||||
UpdateStatus
|
||||
} from '../../main/types/update.types'
|
||||
|
||||
type Page = 'home' | 'extractor' | 'cleaner' | 'settings'
|
||||
|
||||
@@ -51,6 +67,9 @@ function App(): React.JSX.Element {
|
||||
|
||||
// Navigation state
|
||||
const [currentPage, setCurrentPage] = useState<Page>('extractor') // Default to extractor for the new layout
|
||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null)
|
||||
const [updateCatalog, setUpdateCatalog] = useState<UpdateDialogCatalog | null>(null)
|
||||
const [showUpdateDialog, setShowUpdateDialog] = useState(false)
|
||||
|
||||
// Load error message from sessionStorage
|
||||
const showError = (message: string) => {
|
||||
@@ -65,6 +84,46 @@ function App(): React.JSX.Element {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.update.onStatusChanged((status) => {
|
||||
setUpdateStatus(status)
|
||||
if (status.phase === 'available' || status.phase === 'downloaded' || status.phase === 'idle') {
|
||||
void refreshUpdateCatalog()
|
||||
}
|
||||
})
|
||||
|
||||
void refreshUpdateState()
|
||||
|
||||
return unsubscribe
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
void refreshUpdateState()
|
||||
void refreshUpdateCatalog()
|
||||
return
|
||||
}
|
||||
|
||||
setUpdateStatus(null)
|
||||
setUpdateCatalog(null)
|
||||
setShowUpdateDialog(false)
|
||||
}, [isAuthenticated])
|
||||
|
||||
const refreshUpdateState = async () => {
|
||||
const result = await window.electron.update.getStatus()
|
||||
if (result.success && result.data) {
|
||||
setUpdateStatus(result.data)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshUpdateCatalog = async () => {
|
||||
const result = await window.electron.update.getCatalog()
|
||||
if (result.success && result.data) {
|
||||
setUpdateCatalog(result.data)
|
||||
}
|
||||
}
|
||||
|
||||
const initializeAuth = async () => {
|
||||
logger.info('=== Starting initializeAuth ===')
|
||||
try {
|
||||
@@ -195,6 +254,44 @@ function App(): React.JSX.Element {
|
||||
setShowLoginDialog(true)
|
||||
}
|
||||
|
||||
const openUpdateDialog = async () => {
|
||||
await refreshUpdateCatalog()
|
||||
setShowUpdateDialog(true)
|
||||
}
|
||||
|
||||
const handleInstallUserRelease = async () => {
|
||||
if (!updateCatalog?.recommendedRelease) {
|
||||
showError('暂无可安装更新')
|
||||
return
|
||||
}
|
||||
|
||||
if (updateStatus?.phase !== 'downloaded') {
|
||||
const downloadResult = await window.electron.update.downloadRelease(updateCatalog.recommendedRelease)
|
||||
if (!downloadResult.success) {
|
||||
showError(downloadResult.error || '下载更新失败')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const installResult = await window.electron.update.installDownloaded()
|
||||
if (!installResult.success) {
|
||||
showError(installResult.error || '启动安装失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdminDownloadAndInstall = async (release: DownloadReleaseRequest) => {
|
||||
const downloadResult = await window.electron.update.downloadRelease(release)
|
||||
if (!downloadResult.success) {
|
||||
showError(downloadResult.error || '下载更新失败')
|
||||
return
|
||||
}
|
||||
|
||||
const installResult = await window.electron.update.installDownloaded()
|
||||
if (!installResult.success) {
|
||||
showError(installResult.error || '启动安装失败')
|
||||
}
|
||||
}
|
||||
|
||||
// Check if should show logout button
|
||||
// Show logout if: user is Admin, OR user was switched by Admin
|
||||
const shouldShowLogout = currentUser?.userType === 'Admin' || isSwitchedByAdmin
|
||||
@@ -317,6 +414,24 @@ function App(): React.JSX.Element {
|
||||
{ id: 'settings', label: '系统设置 (Settings)', icon: <Settings size={18} /> }
|
||||
]
|
||||
|
||||
const showUpdateEntry =
|
||||
!!updateStatus &&
|
||||
((currentUser?.userType === 'User' && updateStatus.phase === 'downloaded') ||
|
||||
(currentUser?.userType === 'Admin' &&
|
||||
(updateStatus.adminHasAnyRelease ||
|
||||
updateStatus.phase === 'downloading' ||
|
||||
updateStatus.phase === 'downloaded' ||
|
||||
updateStatus.phase === 'installing')))
|
||||
|
||||
const updateButtonLabel =
|
||||
currentUser?.userType === 'Admin'
|
||||
? updateStatus?.phase === 'downloading'
|
||||
? '下载更新中...'
|
||||
: '有可用版本'
|
||||
: updateStatus?.phase === 'downloaded'
|
||||
? `发现稳定版 V${updateStatus.latestVersion}`
|
||||
: '发现新版本'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-slate-50 text-slate-800 font-sans overflow-hidden">
|
||||
{/* ================= 顶部导航与标题栏 ================= */}
|
||||
@@ -370,6 +485,20 @@ function App(): React.JSX.Element {
|
||||
className="flex items-center gap-4 text-sm"
|
||||
style={{ WebkitAppRegion: 'no-drag' } as any}
|
||||
>
|
||||
{showUpdateEntry && (
|
||||
<button
|
||||
onClick={() => void openUpdateDialog()}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-blue-500/40 bg-blue-500/10 px-3 py-1.5 text-xs font-medium text-blue-100 transition hover:bg-blue-500/20"
|
||||
title={updateStatus?.message || '查看更新'}
|
||||
>
|
||||
{updateStatus?.phase === 'downloading' || updateStatus?.phase === 'installing' ? (
|
||||
<LoaderCircle size={15} className="animate-spin" />
|
||||
) : (
|
||||
<ArrowUpCircle size={15} />
|
||||
)}
|
||||
<span>{updateButtonLabel}</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-xs bg-slate-800 px-3 py-1.5 rounded-full border border-slate-700">
|
||||
<Database size={14} className="text-green-500" />
|
||||
<span className="text-slate-300">数据库已连接</span>
|
||||
@@ -418,6 +547,22 @@ function App(): React.JSX.Element {
|
||||
|
||||
{/* Toast Notifications */}
|
||||
<Toast />
|
||||
<UpdateDialog
|
||||
isOpen={showUpdateDialog}
|
||||
userType={currentUser?.userType ?? null}
|
||||
status={updateStatus}
|
||||
catalog={updateCatalog}
|
||||
onClose={() => setShowUpdateDialog(false)}
|
||||
onInstallUserRelease={handleInstallUserRelease}
|
||||
onDownloadAndInstallAdminRelease={async (release) =>
|
||||
handleAdminDownloadAndInstall(release)
|
||||
}
|
||||
onRefreshCatalog={async () => {
|
||||
await window.electron.update.checkNow()
|
||||
await refreshUpdateCatalog()
|
||||
await refreshUpdateState()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
267
src/renderer/src/components/UpdateDialog.tsx
Normal file
267
src/renderer/src/components/UpdateDialog.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { DownloadCloud, LoaderCircle, RefreshCw } from 'lucide-react'
|
||||
import Modal from './ui/Modal'
|
||||
import type { UserType } from '../../../main/types/user.types'
|
||||
import type {
|
||||
DownloadReleaseRequest,
|
||||
UpdateDialogCatalog,
|
||||
UpdateRelease,
|
||||
UpdateStatus
|
||||
} from '../../../main/types/update.types'
|
||||
|
||||
interface UpdateDialogProps {
|
||||
isOpen: boolean
|
||||
userType: UserType | null
|
||||
status: UpdateStatus | null
|
||||
catalog: UpdateDialogCatalog | null
|
||||
onClose: () => void
|
||||
onInstallUserRelease: () => Promise<void>
|
||||
onDownloadAndInstallAdminRelease: (release: DownloadReleaseRequest) => Promise<void>
|
||||
onRefreshCatalog: () => Promise<void>
|
||||
}
|
||||
|
||||
function releaseKey(release: UpdateRelease): string {
|
||||
return `${release.channel}:${release.version}`
|
||||
}
|
||||
|
||||
export default function UpdateDialog({
|
||||
isOpen,
|
||||
userType,
|
||||
status,
|
||||
catalog,
|
||||
onClose,
|
||||
onInstallUserRelease,
|
||||
onDownloadAndInstallAdminRelease,
|
||||
onRefreshCatalog
|
||||
}: UpdateDialogProps): React.JSX.Element {
|
||||
const [selectedRelease, setSelectedRelease] = useState<UpdateRelease | undefined>(undefined)
|
||||
const [changelog, setChangelog] = useState<string>('')
|
||||
const [isLoadingChangelog, setIsLoadingChangelog] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !catalog) return
|
||||
|
||||
if (catalog.mode === 'user') {
|
||||
setSelectedRelease(catalog.recommendedRelease)
|
||||
return
|
||||
}
|
||||
|
||||
if (catalog.mode === 'admin') {
|
||||
setSelectedRelease(
|
||||
catalog.recommendedRelease ?? catalog.channels?.stable[0] ?? catalog.channels?.preview[0]
|
||||
)
|
||||
}
|
||||
}, [catalog, isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !selectedRelease) {
|
||||
setChangelog('')
|
||||
return
|
||||
}
|
||||
|
||||
let active = true
|
||||
setIsLoadingChangelog(true)
|
||||
|
||||
window.electron.update
|
||||
.getChangelog(selectedRelease)
|
||||
.then((result) => {
|
||||
if (!active) return
|
||||
setChangelog(result.success && result.data ? result.data : '暂无更新说明。')
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) return
|
||||
setChangelog('暂无更新说明。')
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) {
|
||||
setIsLoadingChangelog(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [isOpen, selectedRelease])
|
||||
|
||||
const isBusy =
|
||||
status?.phase === 'downloading' || status?.phase === 'installing' || status?.phase === 'checking'
|
||||
|
||||
const renderReleaseList = (title: string, releases: UpdateRelease[]) => {
|
||||
if (releases.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-slate-200 px-3 py-4 text-sm text-slate-500">
|
||||
暂无版本
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-slate-500">{title}</div>
|
||||
{releases.map((release) => {
|
||||
const selected = selectedRelease && releaseKey(selectedRelease) === releaseKey(release)
|
||||
return (
|
||||
<button
|
||||
key={releaseKey(release)}
|
||||
onClick={() => setSelectedRelease(release)}
|
||||
className={`w-full rounded-lg border px-3 py-3 text-left transition ${
|
||||
selected
|
||||
? 'border-blue-500 bg-blue-50 text-blue-900'
|
||||
: 'border-slate-200 bg-white text-slate-700 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-medium">V{release.version}</span>
|
||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs uppercase text-slate-600">
|
||||
{release.channel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-500">{release.publishedAt}</div>
|
||||
{release.notesSummary && (
|
||||
<div className="mt-2 text-xs text-slate-600">{release.notesSummary}</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={isBusy ? () => undefined : onClose}
|
||||
title="应用更新"
|
||||
size="3xl"
|
||||
disableBackdropClick={isBusy}
|
||||
disableEscapeKey={isBusy}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-lg bg-slate-50 px-4 py-3 text-sm">
|
||||
<div className="text-slate-600">
|
||||
当前版本 <span className="font-semibold text-slate-900">V{status?.currentVersion}</span>
|
||||
<span className="ml-2 rounded-full bg-slate-200 px-2 py-0.5 text-xs uppercase text-slate-700">
|
||||
{status?.currentChannel ?? __APP_CHANNEL__}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void onRefreshCatalog()}
|
||||
className="inline-flex items-center gap-2 rounded-md border border-slate-200 px-3 py-1.5 text-xs text-slate-600 hover:bg-slate-100"
|
||||
disabled={isBusy}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{catalog?.mode === 'admin' ? (
|
||||
<div className="grid gap-4 lg:grid-cols-[280px_1fr]">
|
||||
<div className="space-y-4">
|
||||
{renderReleaseList('Stable', catalog.channels?.stable ?? [])}
|
||||
{renderReleaseList('Preview', catalog.channels?.preview ?? [])}
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-200 bg-white">
|
||||
<div className="border-b border-slate-200 px-4 py-3">
|
||||
<div className="text-sm font-semibold text-slate-900">
|
||||
{selectedRelease
|
||||
? `${selectedRelease.channel.toUpperCase()} V${selectedRelease.version}`
|
||||
: '请选择一个版本'}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
{selectedRelease?.notesSummary ?? '选择版本后可查看详细更新说明。'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[420px] overflow-auto px-4 py-4 prose prose-slate max-w-none prose-headings:mb-2 prose-p:my-2 prose-li:my-1">
|
||||
{isLoadingChangelog ? (
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<LoaderCircle size={16} className="animate-spin" />
|
||||
正在加载更新说明...
|
||||
</div>
|
||||
) : (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{changelog || '暂无更新说明。'}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-slate-200 bg-white">
|
||||
<div className="border-b border-slate-200 px-4 py-3">
|
||||
<div className="text-sm font-semibold text-slate-900">
|
||||
{selectedRelease ? `发现稳定版 V${selectedRelease.version}` : '暂无可用更新'}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
{status?.currentChannel === 'preview'
|
||||
? '当前设备正在运行预览版,普通用户将更新回稳定版。'
|
||||
: '更新包已在后台准备完成,确认后将自动关闭并重启应用。'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[420px] overflow-auto px-4 py-4 prose prose-slate max-w-none prose-headings:mb-2 prose-p:my-2 prose-li:my-1">
|
||||
{isLoadingChangelog ? (
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<LoaderCircle size={16} className="animate-spin" />
|
||||
正在加载更新说明...
|
||||
</div>
|
||||
) : (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{changelog || '暂无更新说明。'}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status?.error && (
|
||||
<div className="rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
{status.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between border-t border-slate-200 pt-4">
|
||||
<div className="text-sm text-slate-500">{status?.message ?? ' '}</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md border border-slate-200 px-4 py-2 text-sm text-slate-600 hover:bg-slate-50"
|
||||
disabled={isBusy}
|
||||
>
|
||||
稍后
|
||||
</button>
|
||||
{userType === 'Admin' ? (
|
||||
<button
|
||||
onClick={() =>
|
||||
selectedRelease ? void onDownloadAndInstallAdminRelease(selectedRelease) : undefined
|
||||
}
|
||||
className="inline-flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-300"
|
||||
disabled={!selectedRelease || isBusy}
|
||||
>
|
||||
{isBusy ? (
|
||||
<LoaderCircle size={16} className="animate-spin" />
|
||||
) : (
|
||||
<DownloadCloud size={16} />
|
||||
)}
|
||||
下载并更新
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => void onInstallUserRelease()}
|
||||
className="inline-flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-blue-300"
|
||||
disabled={!selectedRelease || isBusy}
|
||||
>
|
||||
{isBusy ? (
|
||||
<LoaderCircle size={16} className="animate-spin" />
|
||||
) : (
|
||||
<DownloadCloud size={16} />
|
||||
)}
|
||||
立即更新
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
1
src/renderer/src/env.d.ts
vendored
1
src/renderer/src/env.d.ts
vendored
@@ -2,3 +2,4 @@
|
||||
|
||||
declare const __APP_VERSION__: string
|
||||
declare const __GIT_HASH__: string
|
||||
declare const __APP_CHANNEL__: 'stable' | 'preview'
|
||||
|
||||
1
src/shared/app-env.d.ts
vendored
Normal file
1
src/shared/app-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare const __APP_CHANNEL__: 'stable' | 'preview'
|
||||
@@ -96,7 +96,16 @@ export const IPC_CHANNELS = {
|
||||
// Report
|
||||
REPORT_LIST_ALL: 'report:listAll',
|
||||
REPORT_LIST_BY_USER: 'report:listByUser',
|
||||
REPORT_DOWNLOAD: 'report:download'
|
||||
REPORT_DOWNLOAD: 'report:download',
|
||||
|
||||
// Update
|
||||
UPDATE_GET_STATUS: 'update:getStatus',
|
||||
UPDATE_CHECK_NOW: 'update:checkNow',
|
||||
UPDATE_GET_CATALOG: 'update:getCatalog',
|
||||
UPDATE_GET_CHANGELOG: 'update:getChangelog',
|
||||
UPDATE_DOWNLOAD_RELEASE: 'update:downloadRelease',
|
||||
UPDATE_INSTALL_DOWNLOADED: 'update:installDownloaded',
|
||||
UPDATE_STATUS_CHANGED: 'update:onStatusChanged'
|
||||
} as const
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user