feat: add Playwright browser download service and preload types
This commit is contained in:
316
src/main/services/playwright-browser/download-service.ts
Normal file
316
src/main/services/playwright-browser/download-service.ts
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
/**
|
||||||
|
* 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: 'erpauto/resources/ms-playwright/',
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all S3 objects under the configured prefix
|
||||||
|
* Filters to only chromium-* folders
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
|
||||||
|
// Only include chromium-* folders, skip firefox and webkit
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
async downloadFile(key: string, destPath: string): Promise<void> {
|
||||||
|
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 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'
|
||||||
20
src/preload/api/browser-download.ts
Normal file
20
src/preload/api/browser-download.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
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 = {
|
||||||
|
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
|
||||||
|
|||||||
16
src/preload/index.d.ts
vendored
16
src/preload/index.d.ts
vendored
@@ -141,6 +141,21 @@ 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 {
|
||||||
|
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 +183,7 @@ declare global {
|
|||||||
logger: LoggerAPI
|
logger: LoggerAPI
|
||||||
report: ReportAPI
|
report: ReportAPI
|
||||||
update: UpdateAPI
|
update: UpdateAPI
|
||||||
|
playwrightBrowser: PlaywrightBrowserAPI
|
||||||
}
|
}
|
||||||
api: unknown
|
api: unknown
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,12 @@ 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'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user