From 32df3cea6714b508d1b5c1039bf9dad46a5e00c0 Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Tue, 24 Mar 2026 15:33:52 +0800 Subject: [PATCH] feat: add Playwright browser download service and preload types --- .../playwright-browser/download-service.ts | 316 ++++++++++++++++++ src/main/services/playwright-browser/index.ts | 11 + src/preload/api/browser-download.ts | 20 ++ src/preload/api/index.ts | 4 +- src/preload/index.d.ts | 16 + src/shared/ipc-channels.ts | 7 +- 6 files changed, 372 insertions(+), 2 deletions(-) create mode 100644 src/main/services/playwright-browser/download-service.ts create mode 100644 src/main/services/playwright-browser/index.ts create mode 100644 src/preload/api/browser-download.ts diff --git a/src/main/services/playwright-browser/download-service.ts b/src/main/services/playwright-browser/download-service.ts new file mode 100644 index 0000000..f7f87a4 --- /dev/null +++ b/src/main/services/playwright-browser/download-service.ts @@ -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 = { + 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 + 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 { + 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 { + 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((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 { + 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 { + 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 => { + 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 diff --git a/src/main/services/playwright-browser/index.ts b/src/main/services/playwright-browser/index.ts new file mode 100644 index 0000000..c6e4f73 --- /dev/null +++ b/src/main/services/playwright-browser/index.ts @@ -0,0 +1,11 @@ +/** + * Playwright Browser Module Exports + */ + +export { DownloadService, default } from './download-service' +export type { + DownloadProgress, + S3Object, + ValidationResult, + DownloadConfig +} from './download-service' diff --git a/src/preload/api/browser-download.ts b/src/preload/api/browser-download.ts new file mode 100644 index 0000000..4b251e1 --- /dev/null +++ b/src/preload/api/browser-download.ts @@ -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> => { + return ipcRenderer.invoke(IPC_CHANNELS.PLAYWRIGHT_BROWSER_DOWNLOAD) + }, + + cancel: async (): Promise> => { + 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 diff --git a/src/preload/api/index.ts b/src/preload/api/index.ts index e1c835a..6ab8905 100644 --- a/src/preload/api/index.ts +++ b/src/preload/api/index.ts @@ -16,6 +16,7 @@ import { userErpConfigApi } from './materials' import { loggerApi } from './logger' +import { playwrightBrowserApi } from './browser-download' export const api = { process: processApi, @@ -33,7 +34,8 @@ export const api = { config: configApi, logger: loggerApi, report: reportApi, - update: updateApi + update: updateApi, + playwrightBrowser: playwrightBrowserApi } as const export type ElectronApi = typeof api diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index e170c82..12aff8a 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -141,6 +141,21 @@ export interface UpdateAPI { 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> + cancel: () => Promise> + onProgress: (callback: (data: DownloadProgress) => void) => () => void +} + export interface ProcessAPI { versions: { electron: string @@ -168,6 +183,7 @@ declare global { logger: LoggerAPI report: ReportAPI update: UpdateAPI + playwrightBrowser: PlaywrightBrowserAPI } api: unknown } diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index baacf94..957be3e 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -105,7 +105,12 @@ export const IPC_CHANNELS = { UPDATE_GET_CHANGELOG: 'update:getChangelog', UPDATE_DOWNLOAD_RELEASE: 'update:downloadRelease', 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 /**