336 lines
9.5 KiB
TypeScript
336 lines
9.5 KiB
TypeScript
|
|
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;
|
||
|
|
}
|
||
|
|
}
|