Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a78948a8c | ||
|
|
97bf918ed1 | ||
|
|
1d5268a4da | ||
|
|
2adfc77a58 | ||
|
|
2343fb2188 | ||
|
|
fb46586a13 | ||
|
|
6f21785c64 | ||
|
|
fbd62fe390 | ||
|
|
a711781f21 | ||
|
|
6ad916998c | ||
|
|
32df3cea67 |
13
docs/releases/1.5.0.md
Normal file
13
docs/releases/1.5.0.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# 1.5.0
|
||||||
|
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
- 新增 Playwright 浏览器自动下载,首次启动自动从 S3 获取。
|
||||||
|
- 实时显示下载进度(百分比、速度、剩余时间)。
|
||||||
|
- 支持取消下载,网络异常自动重试。
|
||||||
|
- 下载完成后自动进入登录界面,无需重启应用。
|
||||||
|
|
||||||
|
## 体验优化
|
||||||
|
|
||||||
|
- 修复下载完成后卡在"认证中"的问题。
|
||||||
|
- 修复速度和剩余时间显示为"计算中"的问题。
|
||||||
106
docs/releases/README.md
Normal file
106
docs/releases/README.md
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# 发布文档规范
|
||||||
|
|
||||||
|
## 文档定位
|
||||||
|
|
||||||
|
发布文档面向**最终用户**,不是技术开发日志。内容应该简洁、清晰、有价值。
|
||||||
|
|
||||||
|
## 内容风格
|
||||||
|
|
||||||
|
### ✅ 推荐写法
|
||||||
|
|
||||||
|
- **用户视角**:描述功能带来的价值,而非技术实现
|
||||||
|
- **简洁明了**:每条更新 1-2 句话,避免冗长
|
||||||
|
- **分类清晰**:按功能模块或改进类型分组
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
- 新增 Playwright 浏览器自动下载,首次启动自动从 S3 获取。
|
||||||
|
- 实时显示下载进度(百分比、速度、剩余时间)。
|
||||||
|
```
|
||||||
|
|
||||||
|
### ❌ 避免写法
|
||||||
|
|
||||||
|
- 技术细节(文件路径、代码实现、架构设计)
|
||||||
|
- 开发过程描述("重构了"、"优化了算法")
|
||||||
|
- 过长的段落(超过 2 行)
|
||||||
|
|
||||||
|
## 文档结构
|
||||||
|
|
||||||
|
### 标准格式
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# {版本号}
|
||||||
|
|
||||||
|
## {分类 1}
|
||||||
|
|
||||||
|
- {更新点 1}
|
||||||
|
- {更新点 2}
|
||||||
|
|
||||||
|
## {分类 2}
|
||||||
|
|
||||||
|
- {更新点 1}
|
||||||
|
- {更新点 2}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 常见分类
|
||||||
|
|
||||||
|
- `核心功能` - 新功能、重大特性
|
||||||
|
- `改进` / `体验优化` - 现有功能优化
|
||||||
|
- `问题修复` - Bug 修复
|
||||||
|
- `界面与交互` - UI/UX 改进
|
||||||
|
|
||||||
|
## 篇幅要求
|
||||||
|
|
||||||
|
- **小版本**(x.x.1):5-10 行
|
||||||
|
- **中版本**(x.x.0):10-20 行
|
||||||
|
- **大版本**(x.0.0):20-40 行
|
||||||
|
|
||||||
|
## 示例参考
|
||||||
|
|
||||||
|
### 简洁版(1.4.2)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 1.4.2
|
||||||
|
|
||||||
|
## 架构优化
|
||||||
|
|
||||||
|
- 重构主进程启动流程和 IPC 编排层,按领域拆分 preload API。
|
||||||
|
- 解耦更新服务职责,对话框改为懒加载以优化性能。
|
||||||
|
|
||||||
|
## 质量改进
|
||||||
|
|
||||||
|
- 修复类型检查问题,加固启动流程和认证健壮性。
|
||||||
|
- 新增核心模块测试覆盖,完善开发者文档。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 详细版(1.4.0)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 1.4.0
|
||||||
|
|
||||||
|
## 亮点
|
||||||
|
|
||||||
|
- 新增 Windows 便携版自动更新能力,支持 `stable` / `preview` 双通道发布。
|
||||||
|
- 更新策略与登录用户角色联动。
|
||||||
|
|
||||||
|
## 自动更新
|
||||||
|
|
||||||
|
- 新增便携版更新服务,支持登录后自动检查更新。
|
||||||
|
- 更新器采用原生 `portable-updater.exe`,不再依赖 PowerShell 脚本。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 发布流程
|
||||||
|
|
||||||
|
1. 创建版本文件:`docs/releases/{version}.md`
|
||||||
|
2. 参考现有文档风格编写
|
||||||
|
3. 提交 git:`git add docs/releases/{version}.md`
|
||||||
|
4. 提交信息:`docs: add release notes for version {version}`
|
||||||
|
|
||||||
|
## 维护说明
|
||||||
|
|
||||||
|
- 发布文档一旦创建,**不再修改**(除非有重大错误)
|
||||||
|
- 技术细节放入 `docs/` 下的专题文档
|
||||||
|
- Changelog 由发布脚本自动生成,不手动维护
|
||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "erpauto",
|
"name": "erpauto",
|
||||||
"version": "1.4.2",
|
"version": "1.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "erpauto",
|
"name": "erpauto",
|
||||||
"version": "1.4.2",
|
"version": "1.5.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.929.0",
|
"@aws-sdk/client-s3": "^3.929.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "erpauto",
|
"name": "erpauto",
|
||||||
"version": "1.4.2",
|
"version": "1.5.0",
|
||||||
"description": "An Electron application with React and TypeScript",
|
"description": "An Electron application with React and TypeScript",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "example.com",
|
"author": "example.com",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { app, dialog } from 'electron'
|
import { app } from 'electron'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { ConfigManager } from '../services/config/config-manager'
|
import { ConfigManager } from '../services/config/config-manager'
|
||||||
@@ -31,7 +31,11 @@ export function setupElectronRuntime(): void {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ensurePlaywrightRuntime(browsersPath: string): void {
|
/**
|
||||||
|
* Check if Playwright browsers are installed
|
||||||
|
* @returns true if browsers exist, false otherwise
|
||||||
|
*/
|
||||||
|
export function ensurePlaywrightRuntime(browsersPath: string): boolean {
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(browsersPath, { recursive: true })
|
fs.mkdirSync(browsersPath, { recursive: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -43,7 +47,7 @@ export function ensurePlaywrightRuntime(browsersPath: string): void {
|
|||||||
const chromiumPath = fs.existsSync(newChromiumPath) ? newChromiumPath : oldChromiumPath
|
const chromiumPath = fs.existsSync(newChromiumPath) ? newChromiumPath : oldChromiumPath
|
||||||
|
|
||||||
if (fs.existsSync(chromiumPath)) {
|
if (fs.existsSync(chromiumPath)) {
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
let foundRevision = false
|
let foundRevision = false
|
||||||
@@ -64,22 +68,14 @@ export function ensurePlaywrightRuntime(browsersPath: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (foundRevision) {
|
if (foundRevision) {
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog.showErrorBox(
|
|
||||||
'浏览器文件未找到',
|
|
||||||
`Playwright 浏览器文件不存在。\n\n` +
|
|
||||||
`期望路径:${newChromiumPath}\n` +
|
|
||||||
`或:${oldChromiumPath}\n\n` +
|
|
||||||
`当前目录内容:${fs.existsSync(browsersPath) ? fs.readdirSync(browsersPath).join(', ') : '目录不存在'}\n\n` +
|
|
||||||
`请运行以下命令安装浏览器:\n` +
|
|
||||||
`npx playwright install chromium`
|
|
||||||
)
|
|
||||||
console.warn(
|
console.warn(
|
||||||
'Playwright browser not found. Available:',
|
'Playwright browser not found. Available:',
|
||||||
fs.existsSync(browsersPath) ? fs.readdirSync(browsersPath) : 'none'
|
fs.existsSync(browsersPath) ? fs.readdirSync(browsersPath) : 'none'
|
||||||
)
|
)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initializeMainProcessServices(): Promise<void> {
|
export async function initializeMainProcessServices(): Promise<void> {
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ app.whenReady().then(async () => {
|
|||||||
setupProcessGuards()
|
setupProcessGuards()
|
||||||
registerMainWindowLifecycle()
|
registerMainWindowLifecycle()
|
||||||
const playwrightBrowsersPath = configurePlaywrightBrowsersPath()
|
const playwrightBrowsersPath = configurePlaywrightBrowsersPath()
|
||||||
ensurePlaywrightRuntime(playwrightBrowsersPath)
|
const browsersExist = ensurePlaywrightRuntime(playwrightBrowsersPath)
|
||||||
|
console.log('Playwright browsers exist:', browsersExist)
|
||||||
await initializeMainProcessServices()
|
await initializeMainProcessServices()
|
||||||
setupElectronRuntime()
|
setupElectronRuntime()
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { registerUserErpConfigHandlers } from './user-erp-config-handler'
|
|||||||
import { registerLoggerHandlers } from './logger-handler'
|
import { registerLoggerHandlers } from './logger-handler'
|
||||||
import { registerReportHandlers } from './report-handler'
|
import { registerReportHandlers } from './report-handler'
|
||||||
import { registerUpdateHandlers } from './update-handler'
|
import { registerUpdateHandlers } from './update-handler'
|
||||||
|
import { registerPlaywrightBrowserHandlers } from './playwright-browser'
|
||||||
import { createLogger, logError } from '../services/logger'
|
import { createLogger, logError } from '../services/logger'
|
||||||
import { serializeError, sanitizeError } from '../services/logger/error-utils'
|
import { serializeError, sanitizeError } from '../services/logger/error-utils'
|
||||||
import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors'
|
import { getErrorMessage, getErrorCode, isBaseError } from '../types/errors'
|
||||||
@@ -105,5 +106,6 @@ export function registerIpcHandlers(): void {
|
|||||||
registerLoggerHandlers()
|
registerLoggerHandlers()
|
||||||
registerReportHandlers()
|
registerReportHandlers()
|
||||||
registerUpdateHandlers()
|
registerUpdateHandlers()
|
||||||
|
registerPlaywrightBrowserHandlers()
|
||||||
log.info('All IPC handlers registered')
|
log.info('All IPC handlers registered')
|
||||||
}
|
}
|
||||||
|
|||||||
110
src/main/ipc/playwright-browser.ts
Normal file
110
src/main/ipc/playwright-browser.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Playwright Browser IPC Handlers
|
||||||
|
* Handles browser download requests from renderer process
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { app, ipcMain, IpcMainInvokeEvent } from 'electron'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||||
|
import { withErrorHandling, type IpcResult } from './index'
|
||||||
|
import { DownloadService } from '../services/playwright-browser'
|
||||||
|
import { ConfigManager } from '../services/config/config-manager'
|
||||||
|
import { S3Client } from '@aws-sdk/client-s3'
|
||||||
|
import type { DownloadProgress } from '../services/playwright-browser'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create S3 client from config
|
||||||
|
*/
|
||||||
|
function createS3Client(): S3Client {
|
||||||
|
const config = ConfigManager.getInstance().getConfig().update
|
||||||
|
if (!config) {
|
||||||
|
throw new Error('Update config is not available')
|
||||||
|
}
|
||||||
|
|
||||||
|
return new S3Client({
|
||||||
|
region: config.region,
|
||||||
|
endpoint: config.endpoint,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: config.accessKey,
|
||||||
|
secretAccessKey: config.secretKey
|
||||||
|
},
|
||||||
|
forcePathStyle: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Playwright browsers are installed
|
||||||
|
*/
|
||||||
|
async function checkBrowsersExist(): Promise<boolean> {
|
||||||
|
const fs = await import('fs')
|
||||||
|
const browsersPath = join(app.getPath('userData'), 'ms-playwright')
|
||||||
|
const newChromiumPath = join(browsersPath, 'chromium-1208', 'chrome-win64', 'chrome.exe')
|
||||||
|
const oldChromiumPath = join(browsersPath, 'chromium-win32', 'chrome.exe')
|
||||||
|
const chromiumPath = fs.default.existsSync(newChromiumPath) ? newChromiumPath : oldChromiumPath
|
||||||
|
|
||||||
|
if (fs.default.existsSync(chromiumPath)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let foundRevision = false
|
||||||
|
try {
|
||||||
|
const entries = fs.default.readdirSync(browsersPath)
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.startsWith('chromium-') && !entry.includes('headless')) {
|
||||||
|
const revisionPath = join(browsersPath, entry, 'chrome-win64', 'chrome.exe')
|
||||||
|
if (fs.default.existsSync(revisionPath)) {
|
||||||
|
foundRevision = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore browser directory probing failures
|
||||||
|
}
|
||||||
|
|
||||||
|
return foundRevision
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track active download for cancellation
|
||||||
|
*/
|
||||||
|
let activeDownload: { service: DownloadService; cancelled: boolean } | null = null
|
||||||
|
|
||||||
|
export function registerPlaywrightBrowserHandlers(): void {
|
||||||
|
ipcMain.handle(IPC_CHANNELS.PLAYWRIGHT_BROWSER_CHECK, async (): Promise<IpcResult<boolean>> => {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
return checkBrowsersExist()
|
||||||
|
}, 'playwright-browser:check')
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(
|
||||||
|
IPC_CHANNELS.PLAYWRIGHT_BROWSER_DOWNLOAD,
|
||||||
|
async (event: IpcMainInvokeEvent): Promise<IpcResult<void>> => {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
const s3Client = createS3Client()
|
||||||
|
const service = new DownloadService({ s3Client })
|
||||||
|
|
||||||
|
activeDownload = { service, cancelled: false }
|
||||||
|
|
||||||
|
await service.downloadAll((progress: DownloadProgress) => {
|
||||||
|
if (activeDownload?.cancelled) {
|
||||||
|
throw new Error('Download cancelled by user')
|
||||||
|
}
|
||||||
|
|
||||||
|
event.sender.send(IPC_CHANNELS.PLAYWRIGHT_BROWSER_PROGRESS, progress)
|
||||||
|
})
|
||||||
|
|
||||||
|
activeDownload = null
|
||||||
|
}, 'playwright-browser:download')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.PLAYWRIGHT_BROWSER_CANCEL, async (): Promise<IpcResult<void>> => {
|
||||||
|
return withErrorHandling(async () => {
|
||||||
|
if (activeDownload) {
|
||||||
|
activeDownload.cancelled = true
|
||||||
|
activeDownload = null
|
||||||
|
}
|
||||||
|
}, 'playwright-browser:cancel')
|
||||||
|
})
|
||||||
|
}
|
||||||
407
src/main/services/playwright-browser/download-service.ts
Normal file
407
src/main/services/playwright-browser/download-service.ts
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
/**
|
||||||
|
* Playwright Browser Download Service
|
||||||
|
*
|
||||||
|
* Downloads Playwright browser files from S3 to local directory
|
||||||
|
* with progress tracking and basic validation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs'
|
||||||
|
import * as path from 'path'
|
||||||
|
import { S3Client, ListObjectsV2Command, GetObjectCommand } from '@aws-sdk/client-s3'
|
||||||
|
import { createLogger } from '../logger'
|
||||||
|
|
||||||
|
const log = createLogger('PlaywrightDownloadService')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download progress event
|
||||||
|
*/
|
||||||
|
export interface DownloadProgress {
|
||||||
|
percent: number
|
||||||
|
downloadedBytes: number
|
||||||
|
totalBytes: number
|
||||||
|
currentFile: string
|
||||||
|
speed: number
|
||||||
|
eta?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S3 Object information
|
||||||
|
*/
|
||||||
|
export interface S3Object {
|
||||||
|
key: string
|
||||||
|
size?: number
|
||||||
|
lastModified?: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation result
|
||||||
|
*/
|
||||||
|
export interface ValidationResult {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
fileCount?: number
|
||||||
|
chromeExeExists?: boolean
|
||||||
|
expectedChromePath?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download configuration
|
||||||
|
*/
|
||||||
|
export interface DownloadConfig {
|
||||||
|
s3Client?: S3Client
|
||||||
|
bucket?: string
|
||||||
|
prefix?: string
|
||||||
|
destDir?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default configuration
|
||||||
|
*/
|
||||||
|
const DEFAULT_CONFIG: Required<DownloadConfig> = {
|
||||||
|
s3Client: null as unknown as S3Client,
|
||||||
|
bucket: 'erpauto',
|
||||||
|
prefix: 'resources/ms-playwright/', // ✅ Fixed: removed 'erpauto/' prefix
|
||||||
|
destDir: path.join(process.env.APPDATA || '', 'erpauto', 'ms-playwright')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DownloadService class
|
||||||
|
* Handles downloading Playwright browser files from S3
|
||||||
|
*/
|
||||||
|
export class DownloadService {
|
||||||
|
private config: Required<DownloadConfig>
|
||||||
|
private s3Client: S3Client
|
||||||
|
|
||||||
|
constructor(config?: DownloadConfig) {
|
||||||
|
if (!config?.s3Client) {
|
||||||
|
throw new Error('S3Client is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.config = {
|
||||||
|
...DEFAULT_CONFIG,
|
||||||
|
...config,
|
||||||
|
bucket: config?.bucket || DEFAULT_CONFIG.bucket,
|
||||||
|
prefix: config?.prefix || DEFAULT_CONFIG.prefix,
|
||||||
|
destDir: config?.destDir || DEFAULT_CONFIG.destDir
|
||||||
|
}
|
||||||
|
|
||||||
|
this.s3Client = this.config.s3Client
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute an operation with retry logic using exponential backoff
|
||||||
|
* @param operation - The async operation to execute
|
||||||
|
* @param context - Description of the operation for logging
|
||||||
|
* @param maxRetries - Maximum number of retry attempts (default: 3)
|
||||||
|
* @returns The result of the operation
|
||||||
|
*/
|
||||||
|
private async withRetry<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
context: string,
|
||||||
|
maxRetries = 3
|
||||||
|
): Promise<T> {
|
||||||
|
let lastError: Error | undefined
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await operation()
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error instanceof Error ? error : new Error(String(error))
|
||||||
|
|
||||||
|
// Check if error is retryable
|
||||||
|
const isRetryable = this.isRetryableError(lastError)
|
||||||
|
if (!isRetryable || attempt === maxRetries) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exponential backoff: 1s, 2s, 4s
|
||||||
|
const delay = Math.pow(2, attempt - 1) * 1000
|
||||||
|
log.warn(`${context} failed (attempt ${attempt}/${maxRetries}), retrying in ${delay}ms`, {
|
||||||
|
error: lastError.message
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if an error is retryable based on error type and message
|
||||||
|
* @param error - The error to classify
|
||||||
|
* @returns true if the error should trigger a retry
|
||||||
|
*/
|
||||||
|
private isRetryableError(error: Error): boolean {
|
||||||
|
const message = error.message.toLowerCase()
|
||||||
|
const code = (error as any).code?.toLowerCase() || ''
|
||||||
|
|
||||||
|
// Retryable: network errors, timeouts
|
||||||
|
const retryablePatterns = [
|
||||||
|
'etimedout',
|
||||||
|
'econnreset',
|
||||||
|
'timeout',
|
||||||
|
'network',
|
||||||
|
'socket hang up',
|
||||||
|
'connection reset'
|
||||||
|
]
|
||||||
|
|
||||||
|
// Not retryable: S3 errors, validation errors
|
||||||
|
const nonRetryablePatterns = [
|
||||||
|
'404',
|
||||||
|
'403',
|
||||||
|
'not found',
|
||||||
|
'access denied',
|
||||||
|
'invalid',
|
||||||
|
'validation'
|
||||||
|
]
|
||||||
|
|
||||||
|
// Check non-retryable first
|
||||||
|
for (const pattern of nonRetryablePatterns) {
|
||||||
|
if (message.includes(pattern) || code.includes(pattern)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check retryable
|
||||||
|
for (const pattern of retryablePatterns) {
|
||||||
|
if (message.includes(pattern) || code.includes(pattern)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: don't retry unknown errors
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all S3 objects under the configured prefix
|
||||||
|
* Filters to include ALL Chromium files (regular + headless)
|
||||||
|
* Simple filter: includes 'chromium' keyword
|
||||||
|
*/
|
||||||
|
async listObjects(): Promise<S3Object[]> {
|
||||||
|
log.info('Listing S3 objects', { bucket: this.config.bucket, prefix: this.config.prefix })
|
||||||
|
|
||||||
|
const objects: S3Object[] = []
|
||||||
|
let continuationToken: string | undefined
|
||||||
|
|
||||||
|
do {
|
||||||
|
const response = await this.s3Client.send(
|
||||||
|
new ListObjectsV2Command({
|
||||||
|
Bucket: this.config.bucket,
|
||||||
|
Prefix: this.config.prefix,
|
||||||
|
ContinuationToken: continuationToken
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const obj of response.Contents || []) {
|
||||||
|
if (!obj.Key) continue
|
||||||
|
|
||||||
|
// Simple filter: only download Chromium-related files
|
||||||
|
// This includes: chromium-1200, chromium-1208, chromium_headless_shell-1200, chromium_headless_shell-1208
|
||||||
|
// Excludes: firefox, webkit, ffmpeg, winldd, .links, .settings
|
||||||
|
if (!obj.Key.includes('chromium')) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
objects.push({
|
||||||
|
key: obj.Key,
|
||||||
|
size: obj.Size,
|
||||||
|
lastModified: obj.LastModified
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
continuationToken = response.IsTruncated ? response.NextContinuationToken : undefined
|
||||||
|
} while (continuationToken)
|
||||||
|
|
||||||
|
log.info(`Found ${objects.length} Chromium objects`)
|
||||||
|
return objects
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a single file from S3 with retry logic
|
||||||
|
*/
|
||||||
|
async downloadFile(key: string, destPath: string): Promise<void> {
|
||||||
|
await this.withRetry(async () => {
|
||||||
|
log.debug('Downloading file', { key, destPath })
|
||||||
|
|
||||||
|
await fs.promises.mkdir(path.dirname(destPath), { recursive: true })
|
||||||
|
|
||||||
|
const response = await this.s3Client.send(
|
||||||
|
new GetObjectCommand({
|
||||||
|
Bucket: this.config.bucket,
|
||||||
|
Key: key
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const output = fs.createWriteStream(destPath)
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
log.debug('File downloaded', { key, destPath })
|
||||||
|
}, `Download ${key}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download all Chromium browser files from S3
|
||||||
|
* Emits progress events during download
|
||||||
|
*/
|
||||||
|
async downloadAll(onProgress: (progress: DownloadProgress) => void): Promise<void> {
|
||||||
|
log.info('Starting download of all browser files', { destDir: this.config.destDir })
|
||||||
|
|
||||||
|
const objects = await this.listObjects()
|
||||||
|
if (objects.length === 0) {
|
||||||
|
throw new Error('No Chromium browser files found in S3')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total bytes
|
||||||
|
const totalBytes = objects.reduce((sum, obj) => sum + (obj.size || 0), 0)
|
||||||
|
log.info(`Total files: ${objects.length}, Total bytes: ${totalBytes}`)
|
||||||
|
|
||||||
|
let downloadedBytes = 0
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
for (const obj of objects) {
|
||||||
|
const relativePath = obj.key.replace(this.config.prefix, '')
|
||||||
|
const destPath = path.join(this.config.destDir, relativePath)
|
||||||
|
|
||||||
|
// Emit progress for current file
|
||||||
|
onProgress({
|
||||||
|
percent: Math.round((downloadedBytes / totalBytes) * 100),
|
||||||
|
downloadedBytes,
|
||||||
|
totalBytes,
|
||||||
|
currentFile: relativePath,
|
||||||
|
speed: 0,
|
||||||
|
eta: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.downloadFile(obj.key, destPath)
|
||||||
|
|
||||||
|
const fileSize = obj.size || 0
|
||||||
|
downloadedBytes += fileSize
|
||||||
|
|
||||||
|
// Calculate speed and ETA
|
||||||
|
const elapsedSeconds = (Date.now() - startTime) / 1000
|
||||||
|
const speed = Math.round(downloadedBytes / elapsedSeconds)
|
||||||
|
const remainingBytes = totalBytes - downloadedBytes
|
||||||
|
const eta = speed > 0 ? Math.round(remainingBytes / speed) : undefined
|
||||||
|
|
||||||
|
onProgress({
|
||||||
|
percent: Math.round((downloadedBytes / totalBytes) * 100),
|
||||||
|
downloadedBytes,
|
||||||
|
totalBytes,
|
||||||
|
currentFile: relativePath,
|
||||||
|
speed,
|
||||||
|
eta
|
||||||
|
})
|
||||||
|
|
||||||
|
log.debug('Downloaded file', {
|
||||||
|
key: obj.key,
|
||||||
|
size: fileSize,
|
||||||
|
speed: `${speed} bytes/s`
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Failed to download file', {
|
||||||
|
key: obj.key,
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
throw new Error(
|
||||||
|
`Failed to download ${relativePath}: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalElapsed = (Date.now() - startTime) / 1000
|
||||||
|
log.info('Download completed', {
|
||||||
|
totalFiles: objects.length,
|
||||||
|
totalBytes,
|
||||||
|
duration: `${totalElapsed.toFixed(1)}s`,
|
||||||
|
avgSpeed: `${Math.round(downloadedBytes / totalElapsed)} bytes/s`
|
||||||
|
})
|
||||||
|
|
||||||
|
// Final progress event
|
||||||
|
onProgress({
|
||||||
|
percent: 100,
|
||||||
|
downloadedBytes: totalBytes,
|
||||||
|
totalBytes,
|
||||||
|
currentFile: 'Complete',
|
||||||
|
speed: Math.round(downloadedBytes / totalElapsed),
|
||||||
|
eta: 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the downloaded files
|
||||||
|
* Checks if the destination directory exists and chrome.exe is present
|
||||||
|
*/
|
||||||
|
async validateDownload(): Promise<ValidationResult> {
|
||||||
|
log.info('Validating download', { destDir: this.config.destDir })
|
||||||
|
|
||||||
|
// Check if destination directory exists
|
||||||
|
try {
|
||||||
|
await fs.promises.access(this.config.destDir)
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Download directory does not exist',
|
||||||
|
fileCount: 0,
|
||||||
|
chromeExeExists: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find chrome.exe in chromium-* folders
|
||||||
|
const chromeExePattern = /chromium-\d+[/\\]chrome-win64[/\\]chrome\.exe$/
|
||||||
|
let chromeExeExists = false
|
||||||
|
let expectedChromePath: string | undefined
|
||||||
|
let fileCount = 0
|
||||||
|
|
||||||
|
const findChromeExe = async (dir: string): Promise<void> => {
|
||||||
|
const entries = await fs.promises.readdir(dir, { withFileTypes: true })
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry.name)
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await findChromeExe(fullPath)
|
||||||
|
} else if (entry.name.toLowerCase() === 'chrome.exe') {
|
||||||
|
// Check if path matches chromium-*/chrome-win64/chrome.exe pattern
|
||||||
|
const relativePath = path.relative(this.config.destDir, fullPath)
|
||||||
|
if (chromeExePattern.test(relativePath.replace(/\\/g, '/'))) {
|
||||||
|
chromeExeExists = true
|
||||||
|
expectedChromePath = fullPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fileCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await findChromeExe(this.config.destDir)
|
||||||
|
} catch (error) {
|
||||||
|
log.error('Error scanning download directory', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: ValidationResult = {
|
||||||
|
success: chromeExeExists,
|
||||||
|
message: chromeExeExists ? 'Validation passed' : 'chrome.exe not found in expected location',
|
||||||
|
fileCount,
|
||||||
|
chromeExeExists,
|
||||||
|
expectedChromePath
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('Validation result', result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DownloadService
|
||||||
11
src/main/services/playwright-browser/index.ts
Normal file
11
src/main/services/playwright-browser/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Playwright Browser Module Exports
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { DownloadService, default } from './download-service'
|
||||||
|
export type {
|
||||||
|
DownloadProgress,
|
||||||
|
S3Object,
|
||||||
|
ValidationResult,
|
||||||
|
DownloadConfig
|
||||||
|
} from './download-service'
|
||||||
24
src/preload/api/browser-download.ts
Normal file
24
src/preload/api/browser-download.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { ipcRenderer } from 'electron'
|
||||||
|
import { IPC_CHANNELS } from '../../shared/ipc-channels'
|
||||||
|
import type { IpcResult } from '../../main/types/ipc.types'
|
||||||
|
import type { DownloadProgress } from '../index.d'
|
||||||
|
|
||||||
|
export const playwrightBrowserApi = {
|
||||||
|
check: async (): Promise<IpcResult<boolean>> => {
|
||||||
|
return ipcRenderer.invoke(IPC_CHANNELS.PLAYWRIGHT_BROWSER_CHECK)
|
||||||
|
},
|
||||||
|
|
||||||
|
download: async (): Promise<IpcResult<void>> => {
|
||||||
|
return ipcRenderer.invoke(IPC_CHANNELS.PLAYWRIGHT_BROWSER_DOWNLOAD)
|
||||||
|
},
|
||||||
|
|
||||||
|
cancel: async (): Promise<IpcResult<void>> => {
|
||||||
|
return ipcRenderer.invoke(IPC_CHANNELS.PLAYWRIGHT_BROWSER_CANCEL)
|
||||||
|
},
|
||||||
|
|
||||||
|
onProgress: (callback: (data: DownloadProgress) => void) => {
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent, data: DownloadProgress) => callback(data)
|
||||||
|
ipcRenderer.on(IPC_CHANNELS.PLAYWRIGHT_BROWSER_PROGRESS, listener)
|
||||||
|
return () => ipcRenderer.removeListener(IPC_CHANNELS.PLAYWRIGHT_BROWSER_PROGRESS, listener)
|
||||||
|
}
|
||||||
|
} as const
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
userErpConfigApi
|
userErpConfigApi
|
||||||
} from './materials'
|
} from './materials'
|
||||||
import { loggerApi } from './logger'
|
import { loggerApi } from './logger'
|
||||||
|
import { playwrightBrowserApi } from './browser-download'
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
process: processApi,
|
process: processApi,
|
||||||
@@ -33,7 +34,8 @@ export const api = {
|
|||||||
config: configApi,
|
config: configApi,
|
||||||
logger: loggerApi,
|
logger: loggerApi,
|
||||||
report: reportApi,
|
report: reportApi,
|
||||||
update: updateApi
|
update: updateApi,
|
||||||
|
playwrightBrowser: playwrightBrowserApi
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type ElectronApi = typeof api
|
export type ElectronApi = typeof api
|
||||||
|
|||||||
17
src/preload/index.d.ts
vendored
17
src/preload/index.d.ts
vendored
@@ -141,6 +141,22 @@ export interface UpdateAPI {
|
|||||||
onStatusChanged: (callback: (data: UpdateStatus) => void) => () => void
|
onStatusChanged: (callback: (data: UpdateStatus) => void) => () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DownloadProgress {
|
||||||
|
percent: number // 0-100
|
||||||
|
downloadedBytes: number
|
||||||
|
totalBytes: number
|
||||||
|
currentFile: string
|
||||||
|
speed: number // bytes/s
|
||||||
|
eta?: number // seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaywrightBrowserAPI {
|
||||||
|
check: () => Promise<IpcResult<boolean>>
|
||||||
|
download: () => Promise<IpcResult<void>>
|
||||||
|
cancel: () => Promise<IpcResult<void>>
|
||||||
|
onProgress: (callback: (data: DownloadProgress) => void) => () => void
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProcessAPI {
|
export interface ProcessAPI {
|
||||||
versions: {
|
versions: {
|
||||||
electron: string
|
electron: string
|
||||||
@@ -168,6 +184,7 @@ declare global {
|
|||||||
logger: LoggerAPI
|
logger: LoggerAPI
|
||||||
report: ReportAPI
|
report: ReportAPI
|
||||||
update: UpdateAPI
|
update: UpdateAPI
|
||||||
|
playwrightBrowser: PlaywrightBrowserAPI
|
||||||
}
|
}
|
||||||
api: unknown
|
api: unknown
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { AuthenticatedAppShell } from './components/app/AuthenticatedAppShell'
|
import { AuthenticatedAppShell } from './components/app/AuthenticatedAppShell'
|
||||||
import { UnauthenticatedApp } from './components/app/UnauthenticatedApp'
|
import { UnauthenticatedApp } from './components/app/UnauthenticatedApp'
|
||||||
|
import PlaywrightDownloadDialog from './components/PlaywrightDownloadDialog'
|
||||||
import { useAppBootstrap } from './hooks/useAppBootstrap'
|
import { useAppBootstrap } from './hooks/useAppBootstrap'
|
||||||
|
|
||||||
function App(): React.JSX.Element {
|
function App(): React.JSX.Element {
|
||||||
@@ -22,6 +23,8 @@ function App(): React.JSX.Element {
|
|||||||
updateCatalog,
|
updateCatalog,
|
||||||
showUpdateDialog,
|
showUpdateDialog,
|
||||||
setShowUpdateDialog,
|
setShowUpdateDialog,
|
||||||
|
showPlaywrightDownload,
|
||||||
|
setShowPlaywrightDownload,
|
||||||
showError,
|
showError,
|
||||||
handleLogin,
|
handleLogin,
|
||||||
handleLoginCancel,
|
handleLoginCancel,
|
||||||
@@ -31,11 +34,29 @@ function App(): React.JSX.Element {
|
|||||||
openUpdateDialog,
|
openUpdateDialog,
|
||||||
handleInstallUserRelease,
|
handleInstallUserRelease,
|
||||||
handleAdminDownloadAndInstall,
|
handleAdminDownloadAndInstall,
|
||||||
refreshUpdateDialogState
|
refreshUpdateDialogState,
|
||||||
|
initializeAuth
|
||||||
} = useAppBootstrap()
|
} = useAppBootstrap()
|
||||||
|
|
||||||
|
const handlePlaywrightDownloadComplete = React.useCallback(() => {
|
||||||
|
setShowPlaywrightDownload(false)
|
||||||
|
// Re-trigger authentication after download completes
|
||||||
|
void initializeAuth()
|
||||||
|
}, [setShowPlaywrightDownload, initializeAuth])
|
||||||
|
|
||||||
const shouldShowLogout = currentUser?.userType === 'Admin' || isSwitchedByAdmin
|
const shouldShowLogout = currentUser?.userType === 'Admin' || isSwitchedByAdmin
|
||||||
|
|
||||||
|
// Show Playwright download dialog first (before authentication check)
|
||||||
|
if (showPlaywrightDownload) {
|
||||||
|
return (
|
||||||
|
<PlaywrightDownloadDialog
|
||||||
|
isOpen={showPlaywrightDownload}
|
||||||
|
onClose={() => {}}
|
||||||
|
onDownloadComplete={handlePlaywrightDownloadComplete}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<UnauthenticatedApp
|
<UnauthenticatedApp
|
||||||
|
|||||||
272
src/renderer/src/components/PlaywrightDownloadDialog.tsx
Normal file
272
src/renderer/src/components/PlaywrightDownloadDialog.tsx
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import React, { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { DownloadCloud, LoaderCircle, X } from 'lucide-react'
|
||||||
|
import Modal from './ui/Modal'
|
||||||
|
interface DownloadProgress {
|
||||||
|
percent: number // 0-100
|
||||||
|
downloadedBytes: number
|
||||||
|
totalBytes: number
|
||||||
|
currentFile: string
|
||||||
|
speed: number // bytes/s
|
||||||
|
eta?: number // seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlaywrightDownloadDialogProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onDownloadComplete: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlaywrightDownloadDialog({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onDownloadComplete
|
||||||
|
}: PlaywrightDownloadDialogProps): React.JSX.Element {
|
||||||
|
const [progress, setProgress] = useState<DownloadProgress | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false)
|
||||||
|
const [showCancelConfirm, setShowCancelConfirm] = useState(false)
|
||||||
|
|
||||||
|
// Format bytes to human-readable string
|
||||||
|
const formatBytes = useCallback((bytes: number): string => {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Format ETA to human-readable string
|
||||||
|
const formatETA = useCallback((seconds: number | undefined): string => {
|
||||||
|
if (seconds === undefined || seconds < 0 || !Number.isFinite(seconds)) {
|
||||||
|
return '计算中...'
|
||||||
|
}
|
||||||
|
if (seconds === 0) return '已完成'
|
||||||
|
if (seconds < 60) return `${Math.round(seconds)}秒`
|
||||||
|
const mins = Math.floor(seconds / 60)
|
||||||
|
const secs = Math.round(seconds % 60)
|
||||||
|
return `${mins}分${secs}秒`
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Subscribe to progress events
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
|
||||||
|
const unsubscribe = window.electron.playwrightBrowser.onProgress((data) => {
|
||||||
|
setProgress(data)
|
||||||
|
setError(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
return unsubscribe
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
// Start download when dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
|
||||||
|
let mounted = true
|
||||||
|
setIsDownloading(true)
|
||||||
|
setError(null)
|
||||||
|
setProgress(null)
|
||||||
|
|
||||||
|
const startDownload = async () => {
|
||||||
|
try {
|
||||||
|
const result = await window.electron.playwrightBrowser.download()
|
||||||
|
if (mounted) {
|
||||||
|
if (result.success) {
|
||||||
|
setIsDownloading(false)
|
||||||
|
onDownloadComplete()
|
||||||
|
} else {
|
||||||
|
setError(result.error || '下载失败')
|
||||||
|
setIsDownloading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (mounted) {
|
||||||
|
setError(err instanceof Error ? err.message : '下载失败')
|
||||||
|
setIsDownloading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void startDownload()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false
|
||||||
|
}
|
||||||
|
}, [isOpen, onDownloadComplete])
|
||||||
|
|
||||||
|
const handleCancel = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await window.electron.playwrightBrowser.cancel()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to cancel download:', err)
|
||||||
|
} finally {
|
||||||
|
setShowCancelConfirm(false)
|
||||||
|
setIsDownloading(false)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
const handleConfirmCancel = useCallback(() => {
|
||||||
|
void handleCancel()
|
||||||
|
}, [handleCancel])
|
||||||
|
|
||||||
|
const handleCancelDownloadClick = useCallback(() => {
|
||||||
|
setShowCancelConfirm(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCloseConfirm = useCallback(() => {
|
||||||
|
setShowCancelConfirm(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={isDownloading ? () => undefined : onClose}
|
||||||
|
title="下载 Playwright 浏览器"
|
||||||
|
size="lg"
|
||||||
|
disableBackdropClick={isDownloading}
|
||||||
|
disableEscapeKey={isDownloading}
|
||||||
|
showCloseButton={!isDownloading}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Progress Section */}
|
||||||
|
{isDownloading && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-slate-600">下载进度</span>
|
||||||
|
<span className="font-semibold text-blue-600">{progress?.percent ?? 0}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-3 w-full overflow-hidden rounded-full bg-slate-200">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-blue-500 to-blue-600 transition-all duration-300 ease-out"
|
||||||
|
style={{ width: `${progress?.percent ?? 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current File */}
|
||||||
|
{progress?.currentFile && (
|
||||||
|
<div className="rounded-lg bg-slate-50 px-4 py-3">
|
||||||
|
<div className="text-xs font-medium text-slate-500">当前文件</div>
|
||||||
|
<div
|
||||||
|
className="mt-1 truncate text-sm text-slate-700"
|
||||||
|
title={progress.currentFile}
|
||||||
|
>
|
||||||
|
{progress.currentFile}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-lg border border-slate-200 px-4 py-3">
|
||||||
|
<div className="text-xs text-slate-500">已下载</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold text-slate-900">
|
||||||
|
{progress ? formatBytes(progress.downloadedBytes) : '0 B'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-slate-200 px-4 py-3">
|
||||||
|
<div className="text-xs text-slate-500">总大小</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold text-slate-900">
|
||||||
|
{progress && progress.totalBytes > 0
|
||||||
|
? formatBytes(progress.totalBytes)
|
||||||
|
: '计算中...'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-slate-200 px-4 py-3">
|
||||||
|
<div className="text-xs text-slate-500">速度</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold text-slate-900">
|
||||||
|
{progress && progress.speed >= 0
|
||||||
|
? `${formatBytes(progress.speed)}/秒`
|
||||||
|
: '计算中...'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-slate-200 px-4 py-3">
|
||||||
|
<div className="text-xs text-slate-500">剩余时间</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold text-slate-900">
|
||||||
|
{progress?.eta !== undefined && progress.eta >= 0
|
||||||
|
? formatETA(progress.eta)
|
||||||
|
: '计算中...'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cancel Button */}
|
||||||
|
<div className="flex justify-center pt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleCancelDownloadClick}
|
||||||
|
className="inline-flex items-center gap-2 rounded-md border border-rose-200 bg-rose-50 px-4 py-2 text-sm text-rose-700 hover:bg-rose-100"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
取消下载
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Section */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||||
|
<div className="font-medium">下载失败</div>
|
||||||
|
<div className="mt-1">{error}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success Section (shown briefly before closing) */}
|
||||||
|
{!isDownloading && !error && progress?.percent === 100 && (
|
||||||
|
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DownloadCloud size={18} />
|
||||||
|
<span className="font-medium">下载完成</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">Playwright 浏览器已准备就绪。</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Initial Loading State */}
|
||||||
|
{isDownloading && !progress && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8">
|
||||||
|
<LoaderCircle size={48} className="animate-spin text-blue-500" />
|
||||||
|
<div className="mt-4 text-sm text-slate-600">正在初始化下载...</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Cancel Confirmation Dialog */}
|
||||||
|
<Modal
|
||||||
|
isOpen={showCancelConfirm}
|
||||||
|
onClose={handleCloseConfirm}
|
||||||
|
title="确认取消"
|
||||||
|
size="md"
|
||||||
|
isAlertDialog
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||||
|
<div className="font-medium">确定要取消下载吗?</div>
|
||||||
|
<div className="mt-1">这将退出应用程序,您需要重新启动来继续下载。</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleCloseConfirm}
|
||||||
|
className="rounded-md border border-slate-200 px-4 py-2 text-sm text-slate-600 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
继续下载
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirmCancel}
|
||||||
|
className="inline-flex items-center gap-2 rounded-md bg-rose-600 px-4 py-2 text-sm font-medium text-white hover:bg-rose-700"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
取消并退出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ export function useAppBootstrap() {
|
|||||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null)
|
const [updateStatus, setUpdateStatus] = useState<UpdateStatus | null>(null)
|
||||||
const [updateCatalog, setUpdateCatalog] = useState<UpdateDialogCatalog | null>(null)
|
const [updateCatalog, setUpdateCatalog] = useState<UpdateDialogCatalog | null>(null)
|
||||||
const [showUpdateDialog, setShowUpdateDialog] = useState(false)
|
const [showUpdateDialog, setShowUpdateDialog] = useState(false)
|
||||||
|
const [showPlaywrightDownload, setShowPlaywrightDownload] = useState(false)
|
||||||
|
|
||||||
const authInitializationStartedRef = useRef(false)
|
const authInitializationStartedRef = useRef(false)
|
||||||
|
|
||||||
@@ -120,7 +121,28 @@ export function useAppBootstrap() {
|
|||||||
|
|
||||||
authInitializationStartedRef.current = true
|
authInitializationStartedRef.current = true
|
||||||
logger.info('=== Initializing auth... ===')
|
logger.info('=== Initializing auth... ===')
|
||||||
void initializeAuth()
|
|
||||||
|
// Check if Playwright browsers are installed
|
||||||
|
const checkPlaywrightBrowsers = async () => {
|
||||||
|
try {
|
||||||
|
const result = await window.electron.playwrightBrowser.check()
|
||||||
|
if (result.success && !result.data) {
|
||||||
|
logger.info('Playwright browsers not found, showing download dialog')
|
||||||
|
setShowPlaywrightDownload(true)
|
||||||
|
} else {
|
||||||
|
logger.info('Playwright browsers found, continuing auth')
|
||||||
|
void initializeAuth()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to check Playwright browsers', {
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
})
|
||||||
|
// Continue with auth even if check fails
|
||||||
|
void initializeAuth()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void checkPlaywrightBrowsers()
|
||||||
}, [initializeAuth, logger])
|
}, [initializeAuth, logger])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -295,6 +317,8 @@ export function useAppBootstrap() {
|
|||||||
updateCatalog,
|
updateCatalog,
|
||||||
showUpdateDialog,
|
showUpdateDialog,
|
||||||
setShowUpdateDialog,
|
setShowUpdateDialog,
|
||||||
|
showPlaywrightDownload,
|
||||||
|
setShowPlaywrightDownload,
|
||||||
showError,
|
showError,
|
||||||
refreshUpdateState,
|
refreshUpdateState,
|
||||||
refreshUpdateCatalog,
|
refreshUpdateCatalog,
|
||||||
@@ -306,6 +330,7 @@ export function useAppBootstrap() {
|
|||||||
openUpdateDialog,
|
openUpdateDialog,
|
||||||
handleInstallUserRelease,
|
handleInstallUserRelease,
|
||||||
handleAdminDownloadAndInstall,
|
handleAdminDownloadAndInstall,
|
||||||
refreshUpdateDialogState
|
refreshUpdateDialogState,
|
||||||
|
initializeAuth
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,13 @@ export const IPC_CHANNELS = {
|
|||||||
UPDATE_GET_CHANGELOG: 'update:getChangelog',
|
UPDATE_GET_CHANGELOG: 'update:getChangelog',
|
||||||
UPDATE_DOWNLOAD_RELEASE: 'update:downloadRelease',
|
UPDATE_DOWNLOAD_RELEASE: 'update:downloadRelease',
|
||||||
UPDATE_INSTALL_DOWNLOADED: 'update:installDownloaded',
|
UPDATE_INSTALL_DOWNLOADED: 'update:installDownloaded',
|
||||||
UPDATE_STATUS_CHANGED: 'update:onStatusChanged'
|
UPDATE_STATUS_CHANGED: 'update:onStatusChanged',
|
||||||
|
|
||||||
|
// Playwright Browser
|
||||||
|
PLAYWRIGHT_BROWSER_DOWNLOAD: 'playwright-browser:download',
|
||||||
|
PLAYWRIGHT_BROWSER_CANCEL: 'playwright-browser:cancel',
|
||||||
|
PLAYWRIGHT_BROWSER_PROGRESS: 'playwright-browser:progress',
|
||||||
|
PLAYWRIGHT_BROWSER_CHECK: 'playwright-browser:check'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user