import * as plugins from './plugins.js'; import type { IBrowserKvmOptions, IKvmDriver, IKvmFrame, IKvmTypeTextOptions, TKvmKey, TKvmKind, } from './smartkvm.interfaces.js'; const defaultViewerSelector = 'video, canvas'; interface ICaptureMediaResult { captured: boolean; width?: number; height?: number; dataBase64?: string; error?: string; hasMediaElement?: boolean; mediaHasFrame?: boolean; } export class SmartBrowserKvm implements IKvmDriver { public readonly kind: TKvmKind; private options: IBrowserKvmOptions; private browser?: plugins.puppeteer.Browser; private page?: plugins.puppeteer.Page; constructor(options: IBrowserKvmOptions) { this.options = options; this.kind = options.kind ?? 'generic'; } public async connect(): Promise { if (this.browser && this.page) { return; } const timeoutMs = this.options.timeoutMs ?? 30000; const args: string[] = []; if (process.env.CI || process.getuid?.() === 0) { args.push('--no-sandbox', '--disable-setuid-sandbox'); } this.browser = await plugins.puppeteer.launch({ args, acceptInsecureCerts: this.options.ignoreHttpsErrors ?? false, defaultViewport: null, executablePath: this.options.executablePath, headless: this.options.headless ?? true, timeout: timeoutMs, userDataDir: this.options.userDataDir, }); this.page = await this.browser.newPage(); this.page.setDefaultTimeout(timeoutMs); this.page.setDefaultNavigationTimeout(timeoutMs); await this.page.goto(this.options.url, { waitUntil: 'domcontentloaded', timeout: timeoutMs, }); await this.tryGenericLogin(); await this.waitForViewerReady(); await this.focusViewer(); } public async disconnect(): Promise { const browser = this.browser; this.page = undefined; this.browser = undefined; if (browser) { await browser.close(); } } public async focusViewer(): Promise { const page = this.requirePage(); const viewerSelector = this.getViewerSelector(); const viewerElement = await page.$(viewerSelector); if (!viewerElement) { throw new Error(`KVM viewer selector missing: ${viewerSelector}`); } await viewerElement.click(); } public async captureFrame(): Promise { const page = this.requirePage(); const captureSelector = this.getCaptureSelector(); const captureElement = await page.$(captureSelector); if (!captureElement) { throw new Error(`KVM capture selector missing: ${captureSelector}`); } const mediaResult = await page.evaluate((selector: string): ICaptureMediaResult => { const rootElement = document.querySelector(selector); if (!rootElement) { return { captured: false, error: `KVM capture selector missing: ${selector}`, }; } const mediaElement = (rootElement.matches('video, canvas') ? rootElement : rootElement.querySelector('video, canvas')) as HTMLVideoElement | HTMLCanvasElement | null; if (!mediaElement) { return { captured: false, hasMediaElement: false, }; } let width = 0; let height = 0; if (mediaElement instanceof HTMLVideoElement) { width = mediaElement.videoWidth; height = mediaElement.videoHeight; } else if (mediaElement instanceof HTMLCanvasElement) { width = mediaElement.width; height = mediaElement.height; } if (width <= 0 || height <= 0) { return { captured: false, hasMediaElement: true, mediaHasFrame: false, }; } try { const canvasElement = document.createElement('canvas'); canvasElement.width = width; canvasElement.height = height; const context = canvasElement.getContext('2d'); if (!context) { return { captured: false, hasMediaElement: true, mediaHasFrame: true, error: 'Could not create canvas 2D context for KVM frame capture.', }; } context.drawImage(mediaElement, 0, 0, width, height); const dataUrl = canvasElement.toDataURL('image/png'); return { captured: true, width, height, dataBase64: dataUrl.slice(dataUrl.indexOf(',') + 1), }; } catch (error) { return { captured: false, hasMediaElement: true, mediaHasFrame: true, error: error instanceof Error ? error.message : String(error), }; } }, captureSelector); if (mediaResult.captured) { return { timestamp: Date.now(), width: mediaResult.width ?? 0, height: mediaResult.height ?? 0, mimeType: 'image/png', dataBase64: mediaResult.dataBase64 ?? '', }; } if (mediaResult.error?.startsWith('KVM capture selector missing')) { throw new Error(mediaResult.error); } if (mediaResult.hasMediaElement && mediaResult.mediaHasFrame === false) { throw new Error(`KVM media element has no frame: ${captureSelector}`); } const boundingBox = await captureElement.boundingBox(); if (!boundingBox || boundingBox.width <= 0 || boundingBox.height <= 0) { throw new Error(`KVM capture selector has no visible frame: ${captureSelector}`); } const screenshot = await captureElement.screenshot({ type: 'png', }); return { timestamp: Date.now(), width: Math.round(boundingBox.width), height: Math.round(boundingBox.height), mimeType: 'image/png', dataBase64: Buffer.from(screenshot).toString('base64'), }; } public async typeText(text: string, options?: IKvmTypeTextOptions): Promise { const page = this.requirePage(); await this.focusViewer(); await page.keyboard.type(text, { delay: options?.delayMs, }); } public async pressKey(key: TKvmKey): Promise { const page = this.requirePage(); await this.focusViewer(); await page.keyboard.press(key as plugins.puppeteer.KeyInput); } public async pressShortcut(keys: TKvmKey[]): Promise { const page = this.requirePage(); await this.focusViewer(); const pressedKeys: TKvmKey[] = []; try { for (const key of keys) { await page.keyboard.down(key as plugins.puppeteer.KeyInput); pressedKeys.push(key); } } finally { for (const key of pressedKeys.reverse()) { await page.keyboard.up(key as plugins.puppeteer.KeyInput); } } } public async wait(milliseconds: number): Promise { await new Promise((resolve) => setTimeout(resolve, milliseconds)); } private async tryGenericLogin(): Promise { if (!this.options.username || !this.options.password) { return; } const page = this.requirePage(); const usernameElement = await this.findFirstElement([ 'input[name="username"]', 'input[autocomplete="username"]', 'input[type="email"]', 'input[type="text"]', ]); const passwordElement = await this.findFirstElement([ 'input[name="password"]', 'input[autocomplete="current-password"]', 'input[type="password"]', ]); if (!usernameElement || !passwordElement) { return; } await usernameElement.click({ clickCount: 3 }); await usernameElement.type(this.options.username); await passwordElement.click({ clickCount: 3 }); await passwordElement.type(this.options.password); await passwordElement.press('Enter'); await Promise.race([ page .waitForNavigation({ waitUntil: 'domcontentloaded', timeout: Math.min(this.options.timeoutMs ?? 30000, 10000), }) .catch(() => undefined), this.wait(1000), ]); } private async waitForViewerReady(): Promise { const page = this.requirePage(); const viewerSelector = this.getViewerSelector(); await page.waitForFunction( (selector: string) => { const rootElement = document.querySelector(selector); if (!rootElement) { return false; } const mediaElement = (rootElement.matches('video, canvas') ? rootElement : rootElement.querySelector('video, canvas')) as HTMLVideoElement | HTMLCanvasElement | null; if (mediaElement instanceof HTMLVideoElement) { return mediaElement.videoWidth > 0 && mediaElement.videoHeight > 0; } if (mediaElement instanceof HTMLCanvasElement) { return mediaElement.width > 0 && mediaElement.height > 0; } const boundingRect = rootElement.getBoundingClientRect(); return boundingRect.width > 0 && boundingRect.height > 0; }, { timeout: this.options.timeoutMs ?? 30000, }, viewerSelector ); } private async findFirstElement( selectors: string[] ): Promise | null> { const page = this.requirePage(); for (const selector of selectors) { const element = await page.$(selector); if (element) { return element; } } return null; } private getViewerSelector(): string { return this.options.viewerSelector ?? defaultViewerSelector; } private getCaptureSelector(): string { return this.options.captureSelector ?? this.options.viewerSelector ?? defaultViewerSelector; } private requirePage(): plugins.puppeteer.Page { if (!this.page) { throw new Error('SmartBrowserKvm is not connected. Call connect() first.'); } return this.page; } }