Initialize smartkvm package

This commit is contained in:
2026-05-16 13:41:55 +00:00
commit 8588c6c70d
18 changed files with 8751 additions and 0 deletions
+335
View File
@@ -0,0 +1,335 @@
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<void> {
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<void> {
const browser = this.browser;
this.page = undefined;
this.browser = undefined;
if (browser) {
await browser.close();
}
}
public async focusViewer(): Promise<void> {
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<IKvmFrame> {
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<void> {
const page = this.requirePage();
await this.focusViewer();
await page.keyboard.type(text, {
delay: options?.delayMs,
});
}
public async pressKey(key: TKvmKey): Promise<void> {
const page = this.requirePage();
await this.focusViewer();
await page.keyboard.press(key as plugins.puppeteer.KeyInput);
}
public async pressShortcut(keys: TKvmKey[]): Promise<void> {
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<void> {
await new Promise<void>((resolve) => setTimeout(resolve, milliseconds));
}
private async tryGenericLogin(): Promise<void> {
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<void> {
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<plugins.puppeteer.ElementHandle<Element> | 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;
}
}