feat(automation): add OS-level screen automation foundation services
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
import { getWindows, getActiveWindow, focusWindow, Window } from '@nut-tree-fork/nut-js';
|
||||
import type { ScreenRegion } from '../../../domain/value-objects/ScreenRegion';
|
||||
import type { WindowInfo, WindowFocusResult } from '../../../application/ports/IScreenAutomation';
|
||||
import type { ILogger } from '../../../application/ports/ILogger';
|
||||
import { NoOpLogAdapter } from '../logging/NoOpLogAdapter';
|
||||
|
||||
/**
|
||||
* Service for managing browser window focus and enumeration.
|
||||
* Uses nut.js window management APIs to find and focus browser windows.
|
||||
*/
|
||||
export class WindowFocusService {
|
||||
private logger: ILogger;
|
||||
private defaultTitlePattern: string;
|
||||
|
||||
constructor(defaultTitlePattern?: string, logger?: ILogger) {
|
||||
this.logger = logger ?? new NoOpLogAdapter();
|
||||
this.defaultTitlePattern = defaultTitlePattern ?? 'iRacing';
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and focus a browser window matching the title pattern.
|
||||
* @param titlePattern - Pattern to match in window title (default: 'iRacing')
|
||||
* @returns WindowFocusResult with window info if successful
|
||||
*/
|
||||
async focusBrowserWindow(titlePattern?: string): Promise<WindowFocusResult> {
|
||||
const pattern = titlePattern ?? this.defaultTitlePattern;
|
||||
|
||||
try {
|
||||
this.logger.debug('Searching for browser window', { titlePattern: pattern });
|
||||
|
||||
const windows = await getWindows();
|
||||
|
||||
for (const windowHandle of windows) {
|
||||
try {
|
||||
const windowRef = new Window(windowHandle);
|
||||
const title = await windowRef.getTitle();
|
||||
|
||||
if (title.toLowerCase().includes(pattern.toLowerCase())) {
|
||||
this.logger.debug('Found matching window', { title, handle: windowHandle });
|
||||
|
||||
await focusWindow(windowRef);
|
||||
|
||||
// Get window bounds after focusing
|
||||
const region = await windowRef.getRegion();
|
||||
const bounds: ScreenRegion = {
|
||||
x: region.left,
|
||||
y: region.top,
|
||||
width: region.width,
|
||||
height: region.height,
|
||||
};
|
||||
|
||||
const windowInfo: WindowInfo = {
|
||||
title,
|
||||
bounds,
|
||||
handle: windowHandle,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
this.logger.info('Browser window focused', { title, bounds });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
window: windowInfo,
|
||||
};
|
||||
}
|
||||
} catch (windowError) {
|
||||
// Skip windows that can't be accessed
|
||||
this.logger.debug('Could not access window', { handle: windowHandle, error: String(windowError) });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.warn('No matching browser window found', { titlePattern: pattern });
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `No window found matching pattern: ${pattern}`,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMsg = `Window focus failed: ${error}`;
|
||||
this.logger.error('Window focus failed', error instanceof Error ? error : new Error(errorMsg));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMsg,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently active window.
|
||||
* @returns WindowInfo for the active window, or null if unable to determine
|
||||
*/
|
||||
async getActiveWindow(): Promise<WindowInfo | null> {
|
||||
try {
|
||||
const activeWindow = await getActiveWindow();
|
||||
const title = await activeWindow.getTitle();
|
||||
const region = await activeWindow.getRegion();
|
||||
|
||||
const bounds: ScreenRegion = {
|
||||
x: region.left,
|
||||
y: region.top,
|
||||
width: region.width,
|
||||
height: region.height,
|
||||
};
|
||||
|
||||
return {
|
||||
title,
|
||||
bounds,
|
||||
handle: 0, // Active window doesn't expose handle directly
|
||||
isActive: true,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to get active window', error instanceof Error ? error : new Error(String(error)));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all visible windows.
|
||||
* @returns Array of WindowInfo for all accessible windows
|
||||
*/
|
||||
async listWindows(): Promise<WindowInfo[]> {
|
||||
const result: WindowInfo[] = [];
|
||||
|
||||
try {
|
||||
const windows = await getWindows();
|
||||
const activeWindow = await getActiveWindow();
|
||||
const activeTitle = await activeWindow.getTitle();
|
||||
|
||||
for (const windowHandle of windows) {
|
||||
try {
|
||||
const windowRef = new Window(windowHandle);
|
||||
const title = await windowRef.getTitle();
|
||||
const region = await windowRef.getRegion();
|
||||
|
||||
const bounds: ScreenRegion = {
|
||||
x: region.left,
|
||||
y: region.top,
|
||||
width: region.width,
|
||||
height: region.height,
|
||||
};
|
||||
|
||||
result.push({
|
||||
title,
|
||||
bounds,
|
||||
handle: windowHandle,
|
||||
isActive: title === activeTitle,
|
||||
});
|
||||
} catch {
|
||||
// Skip inaccessible windows
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to list windows', error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a window matching the pattern is currently visible.
|
||||
* @param titlePattern - Pattern to match in window title
|
||||
* @returns true if a matching window exists
|
||||
*/
|
||||
async isWindowVisible(titlePattern?: string): Promise<boolean> {
|
||||
const pattern = titlePattern ?? this.defaultTitlePattern;
|
||||
|
||||
try {
|
||||
const windows = await getWindows();
|
||||
|
||||
for (const windowHandle of windows) {
|
||||
try {
|
||||
const windowRef = new Window(windowHandle);
|
||||
const title = await windowRef.getTitle();
|
||||
|
||||
if (title.toLowerCase().includes(pattern.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the bounds of a window matching the pattern.
|
||||
* Useful for targeted screen capture.
|
||||
* @param titlePattern - Pattern to match in window title
|
||||
* @returns ScreenRegion of the window, or null if not found
|
||||
*/
|
||||
async getWindowBounds(titlePattern?: string): Promise<ScreenRegion | null> {
|
||||
const pattern = titlePattern ?? this.defaultTitlePattern;
|
||||
|
||||
try {
|
||||
const windows = await getWindows();
|
||||
|
||||
for (const windowHandle of windows) {
|
||||
try {
|
||||
const windowRef = new Window(windowHandle);
|
||||
const title = await windowRef.getTitle();
|
||||
|
||||
if (title.toLowerCase().includes(pattern.toLowerCase())) {
|
||||
const region = await windowRef.getRegion();
|
||||
|
||||
return {
|
||||
x: region.left,
|
||||
y: region.top,
|
||||
width: region.width,
|
||||
height: region.height,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to get window bounds', error instanceof Error ? error : new Error(String(error)));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the default title pattern for window searches.
|
||||
*/
|
||||
setDefaultTitlePattern(pattern: string): void {
|
||||
this.defaultTitlePattern = pattern;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current default title pattern.
|
||||
*/
|
||||
getDefaultTitlePattern(): string {
|
||||
return this.defaultTitlePattern;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user