chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
/**
* Clipboard operations for CJK paste workflow.
*
* Uses clipboardy for cross-platform clipboard access
* and nut.js for key simulation.
*/
import { DesktopError } from '../utils/errors.js';
import { getPlatformPasteKeys } from '../utils/platform.js';
/**
* Read current clipboard content.
*/
export async function readClipboard(): Promise<string> {
try {
const { default: clipboard } = await import('clipboardy');
return await clipboard.read();
} catch (err) {
throw new DesktopError('clipboard.read', err instanceof Error ? err : new Error(String(err)));
}
}
/**
* Write text to clipboard.
*/
export async function writeClipboard(text: string): Promise<void> {
try {
const { default: clipboard } = await import('clipboardy');
await clipboard.write(text);
} catch (err) {
throw new DesktopError('clipboard.write', err instanceof Error ? err : new Error(String(err)));
}
}
/**
* Paste text via clipboard:
* 1. Backup current clipboard
* 2. Write target text
* 3. Simulate Cmd/Ctrl+V
* 4. Wait 100ms
* 5. Restore clipboard (best-effort)
*/
export async function pasteText(text: string): Promise<void> {
let backup: string | undefined;
try {
backup = await readClipboard();
} catch {
// Failed to backup, continue anyway
}
try {
await writeClipboard(text);
const { keyboard, Key } = await import('@nut-tree-fork/nut-js');
const pasteKeys = getPlatformPasteKeys();
const keyObjs = pasteKeys.map(k => {
const keyName = k as keyof typeof Key;
return Key[keyName];
});
await keyboard.pressKey(...keyObjs);
await keyboard.releaseKey(...[...keyObjs].reverse());
// Wait for paste to complete
await new Promise(resolve => setTimeout(resolve, 100));
} catch (err) {
throw new DesktopError('clipboard.pasteText', err instanceof Error ? err : new Error(String(err)));
}
// Restore clipboard (best-effort, do not throw)
if (backup !== undefined) {
try {
await writeClipboard(backup);
} catch {
// Ignore restore failure
}
}
}

View File

@@ -0,0 +1,185 @@
/**
* Display information provider.
*
* Uses platform-specific APIs for accurate scaleFactor and multi-display support:
* - macOS: CoreGraphics via child_process (system_profiler or screen capture metadata)
* - Windows/Linux: nut.js fallback with scaleFactor detection
*
* Falls back to nut.js single-display when platform APIs are unavailable.
*/
import { DesktopError } from '../utils/errors.js';
import { getPlatform } from '../utils/platform.js';
import { logInfo, logDebug, logWarn } from '../utils/logger.js';
export interface DisplayDescriptor {
index: number;
label: string;
width: number;
height: number;
scaleFactor: number;
isPrimary: boolean;
origin: { x: number; y: number };
}
/**
* List all connected displays with accurate metadata.
*/
export async function listDisplays(): Promise<DisplayDescriptor[]> {
try {
const platform = getPlatform();
if (platform === 'macos') {
return await listDisplaysMacOS();
}
// Windows/Linux: use nut.js with scaleFactor detection
return await listDisplaysFallback();
} catch (err) {
logWarn(`Platform-specific display detection failed, using fallback: ${err instanceof Error ? err.message : String(err)}`);
return await listDisplaysFallback();
}
}
/**
* macOS: Use system_profiler to enumerate displays with scaleFactor.
*/
async function listDisplaysMacOS(): Promise<DisplayDescriptor[]> {
const { execSync } = await import('child_process');
try {
const output = execSync(
'system_profiler SPDisplaysDataType -json',
{ timeout: 5000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] },
);
const data = JSON.parse(output);
const displays: DisplayDescriptor[] = [];
const gpus = data.SPDisplaysDataType || [];
for (const gpu of gpus) {
const ndrvs = gpu.spdisplays_ndrvs || [];
for (const disp of ndrvs) {
// Parse resolution string like "1440 x 900 @ 60.00Hz" or "2560 x 1440"
const resStr: string = disp._spdisplays_resolution || disp.spdisplays_resolution || '';
const retinaStr: string = disp.spdisplays_retina || '';
const isRetina = retinaStr.toLowerCase().includes('yes');
// Parse pixel resolution
const pixelRes: string = disp._spdisplays_pixels || disp.spdisplays_pixels || '';
let physicalWidth = 0;
let physicalHeight = 0;
const pixMatch = pixelRes.match(/(\d+)\s*x\s*(\d+)/);
if (pixMatch) {
physicalWidth = parseInt(pixMatch[1], 10);
physicalHeight = parseInt(pixMatch[2], 10);
}
// Parse logical resolution
let logicalWidth = 0;
let logicalHeight = 0;
const resMatch = resStr.match(/(\d+)\s*x\s*(\d+)/);
if (resMatch) {
logicalWidth = parseInt(resMatch[1], 10);
logicalHeight = parseInt(resMatch[2], 10);
}
// Calculate scaleFactor
let scaleFactor = 1;
if (physicalWidth > 0 && logicalWidth > 0) {
scaleFactor = Math.round(physicalWidth / logicalWidth);
} else if (isRetina) {
scaleFactor = 2;
}
// Use logical resolution (or fall back to physical if no logical)
const width = logicalWidth || physicalWidth;
const height = logicalHeight || physicalHeight;
if (width > 0 && height > 0) {
const isPrimary = disp.spdisplays_main === 'spdisplays_yes' ||
disp._spdisplays_displayID === '1' ||
displays.length === 0;
displays.push({
index: displays.length,
label: disp._name || `Display ${displays.length}`,
width,
height,
scaleFactor,
isPrimary,
origin: { x: 0, y: 0 }, // macOS manages global offsets; nut.js uses logical
});
}
}
}
if (displays.length > 0) {
logInfo(`Detected ${displays.length} display(s) via system_profiler`);
for (const d of displays) {
logDebug(`Display ${d.index}: ${d.width}x${d.height} @${d.scaleFactor}x "${d.label}" primary=${d.isPrimary}`);
}
return displays;
}
} catch (err) {
logDebug(`system_profiler failed: ${err instanceof Error ? err.message : String(err)}`);
}
// Fallback
return await listDisplaysFallback();
}
/**
* Fallback: use nut.js for basic display info with scaleFactor detection.
*/
async function listDisplaysFallback(): Promise<DisplayDescriptor[]> {
const { screen } = await import('@nut-tree-fork/nut-js');
const width = await screen.width();
const height = await screen.height();
// Detect scaleFactor: capture a small region and compare physical vs logical
let scaleFactor = 1;
try {
const { Region } = await import('@nut-tree-fork/nut-js');
const testRegion = new Region(0, 0, 10, 10);
const capture = await screen.grabRegion(testRegion);
// If physical capture is larger than requested logical region, we have HiDPI
if (capture.width > 10) {
scaleFactor = Math.round(capture.width / 10);
}
} catch {
logDebug('scaleFactor detection failed, defaulting to 1');
}
const primary: DisplayDescriptor = {
index: 0,
label: 'Primary',
width,
height,
scaleFactor,
isPrimary: true,
origin: { x: 0, y: 0 },
};
logInfo(`Detected display (fallback): ${width}x${height} @${scaleFactor}x`);
return [primary];
}
/**
* Get a specific display by index.
*/
export async function getDisplay(index: number): Promise<DisplayDescriptor> {
const displays = await listDisplays();
const display = displays.find(d => d.index === index);
if (!display) {
throw new DesktopError('getDisplay', new Error(`Display index ${index} not found (${displays.length} displays available)`));
}
return display;
}
/**
* Get the primary display.
*/
export async function getPrimaryDisplay(): Promise<DisplayDescriptor> {
const displays = await listDisplays();
return displays.find(d => d.isPrimary) ?? displays[0];
}

View File

@@ -0,0 +1,121 @@
/**
* Image search wrapper around nut.js template matcher.
*
* Uses nut.js screen.find() with configurable confidence threshold.
* Temp file is written once and reused across poll iterations.
*/
import { promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
import { DesktopError } from '../utils/errors.js';
export interface ImageSearchResult {
found: boolean;
region?: { x: number; y: number; width: number; height: number };
confidence?: number;
}
export interface WaitForImageResult extends ImageSearchResult {
elapsed: number;
}
/**
* Find an image on screen by template matching.
*/
export async function findImage(templateBase64: string, confidence: number = 0.9): Promise<ImageSearchResult> {
try {
const { screen, loadImage } = await import('@nut-tree-fork/nut-js');
// Write template to temp file (async)
const tmpFile = path.join(os.tmpdir(), `gui-agent-template-${Date.now()}-${Math.random().toString(36).slice(2)}.png`);
await fs.writeFile(tmpFile, Buffer.from(templateBase64, 'base64'));
try {
const templateImage = await loadImage(tmpFile);
// Set confidence on the screen finder
screen.config.confidence = confidence;
const result = await screen.find(templateImage);
return {
found: true,
region: {
x: result.left,
y: result.top,
width: result.width,
height: result.height,
},
confidence,
};
} catch {
return { found: false };
} finally {
try { await fs.unlink(tmpFile); } catch { /* ignore */ }
}
} catch (err) {
if (err instanceof DesktopError) throw err;
throw new DesktopError('imageSearch.findImage', err instanceof Error ? err : new Error(String(err)));
}
}
/**
* Find an image using an already-written temp file path.
* Used internally by waitForImage to avoid re-writing the file on each poll.
*/
async function findImageFromFile(tmpFile: string, confidence: number): Promise<ImageSearchResult> {
const { screen, loadImage } = await import('@nut-tree-fork/nut-js');
const templateImage = await loadImage(tmpFile);
screen.config.confidence = confidence;
try {
const result = await screen.find(templateImage);
return {
found: true,
region: {
x: result.left,
y: result.top,
width: result.width,
height: result.height,
},
confidence,
};
} catch {
return { found: false };
}
}
/**
* Wait for an image to appear on screen with timeout.
* Writes the template to a temp file once and reuses it across polls.
*/
export async function waitForImage(
templateBase64: string,
timeout: number = 10000,
confidence: number = 0.9,
): Promise<WaitForImageResult> {
const start = Date.now();
const pollInterval = 500;
// Write temp file once, reuse across polls
const tmpFile = path.join(os.tmpdir(), `gui-agent-wait-${Date.now()}-${Math.random().toString(36).slice(2)}.png`);
await fs.writeFile(tmpFile, Buffer.from(templateBase64, 'base64'));
try {
while (Date.now() - start < timeout) {
try {
const result = await findImageFromFile(tmpFile, confidence);
if (result.found) {
return { ...result, elapsed: Date.now() - start };
}
} catch {
// Individual poll failure is non-fatal
}
await new Promise(resolve => setTimeout(resolve, pollInterval));
}
return { found: false, elapsed: Date.now() - start };
} finally {
try { await fs.unlink(tmpFile); } catch { /* ignore */ }
}
}

View File

@@ -0,0 +1,125 @@
/**
* Keyboard operations with CJK/long text routing.
*
* - Non-ASCII or text > 50 chars → clipboard paste
* - Otherwise → nut.js keyboard.type()
*/
import { DesktopError } from '../utils/errors.js';
import { pasteText } from './clipboard.js';
const NON_ASCII_RE = /[^\x00-\x7F]/;
const MAX_TYPE_LENGTH = 50;
/**
* Key name aliases mapping to nut.js Key enum names.
* LLMs may return various names like "Meta", "Command", "Cmd", "Win", "Super".
* nut.js uses: LeftSuper, RightSuper, LeftControl, RightControl, etc.
* Arrow keys in nut.js are: Up, Down, Left, Right (not ArrowUp, etc.)
*/
const KEY_ALIASES: Record<string, string> = {
// macOS Command key aliases → Super
Meta: 'LeftSuper',
Command: 'LeftSuper',
Cmd: 'LeftSuper',
'⌘': 'LeftSuper',
// Windows/Linux Super/Win key aliases
Win: 'LeftSuper',
Super: 'LeftSuper',
// Control key aliases
Control: 'LeftControl',
Ctrl: 'LeftControl',
'⌃': 'LeftControl',
// Alt/Option key aliases
Alt: 'LeftAlt',
Option: 'LeftAlt',
Opt: 'LeftAlt',
'⌥': 'LeftAlt',
// Shift key aliases
Shift: 'LeftShift',
'⇧': 'LeftShift',
// Arrow key aliases (nut.js uses Up/Down/Left/Right, not ArrowUp/ArrowDown)
ArrowUp: 'Up',
ArrowDown: 'Down',
ArrowLeft: 'Left',
ArrowRight: 'Right',
// Common alternative names
Return: 'Enter',
Esc: 'Escape',
Del: 'Delete',
Ins: 'Insert',
};
/**
* Type text with smart routing:
* - CJK/non-ASCII or long text → clipboard paste
* - Short ASCII → direct nut.js typing
*/
export async function typeText(text: string): Promise<void> {
if (NON_ASCII_RE.test(text) || text.length > MAX_TYPE_LENGTH) {
await pasteText(text);
return;
}
try {
const { keyboard } = await import('@nut-tree-fork/nut-js');
await keyboard.type(text);
} catch (err) {
throw new DesktopError('keyboard.typeText', err instanceof Error ? err : new Error(String(err)));
}
}
/**
* Normalize key name using aliases mapping.
*/
function normalizeKeyName(key: string): string {
// First check aliases
if (KEY_ALIASES[key]) {
return KEY_ALIASES[key];
}
// Then return as-is (for F1-F24, ArrowUp, Escape, etc.)
return key;
}
/**
* Press a single key by name.
*/
export async function pressKey(key: string): Promise<void> {
try {
const { keyboard, Key } = await import('@nut-tree-fork/nut-js');
const normalizedKey = normalizeKeyName(key);
const keyObj = Key[normalizedKey as keyof typeof Key];
if (keyObj === undefined) {
throw new Error(`Unknown key: ${key} (normalized: ${normalizedKey})`);
}
await keyboard.pressKey(keyObj);
await keyboard.releaseKey(keyObj);
} catch (err) {
throw new DesktopError('keyboard.pressKey', err instanceof Error ? err : new Error(String(err)));
}
}
/**
* Press a key combination (hotkey).
*
* Note: nut.js pressKey doesn't support multiple keys, but type() does.
* For hotkeys, we use keyboard.type(Key1, Key2, ...) which simulates
* pressing keys together as a combination.
*/
export async function hotkey(keys: string[]): Promise<void> {
try {
const { keyboard, Key } = await import('@nut-tree-fork/nut-js');
const keyObjs = keys.map(k => {
const normalizedKey = normalizeKeyName(k);
const keyObj = Key[normalizedKey as keyof typeof Key];
if (keyObj === undefined) {
throw new Error(`Unknown key: ${k} (normalized: ${normalizedKey})`);
}
return keyObj;
});
// Use keyboard.type() for hotkeys - it handles key combinations correctly
await keyboard.type(...keyObjs);
} catch (err) {
throw new DesktopError('keyboard.hotkey', err instanceof Error ? err : new Error(String(err)));
}
}

View File

@@ -0,0 +1,90 @@
/**
* Mouse operations wrapper around nut.js.
*/
import { DesktopError } from '../utils/errors.js';
type ButtonType = 'left' | 'right' | 'middle';
async function getNutMouse() {
const { mouse, Button, Point, straightTo } = await import('@nut-tree-fork/nut-js');
return { mouse, Button, Point, straightTo };
}
function mapButton(button: ButtonType | undefined, Button: any): any {
switch (button) {
case 'right': return Button.RIGHT;
case 'middle': return Button.MIDDLE;
default: return Button.LEFT;
}
}
export async function click(x: number, y: number, button?: ButtonType): Promise<void> {
try {
const { mouse, Button, Point, straightTo } = await getNutMouse();
await mouse.move(straightTo(new Point(x, y)));
await mouse.click(mapButton(button, Button));
} catch (err) {
throw new DesktopError('mouse.click', err instanceof Error ? err : new Error(String(err)));
}
}
export async function doubleClick(x: number, y: number, button?: ButtonType): Promise<void> {
try {
const { mouse, Button, Point, straightTo } = await getNutMouse();
await mouse.move(straightTo(new Point(x, y)));
await mouse.doubleClick(mapButton(button, Button));
} catch (err) {
throw new DesktopError('mouse.doubleClick', err instanceof Error ? err : new Error(String(err)));
}
}
export async function moveTo(x: number, y: number): Promise<void> {
try {
const { mouse, Point, straightTo } = await getNutMouse();
await mouse.move(straightTo(new Point(x, y)));
} catch (err) {
throw new DesktopError('mouse.moveTo', err instanceof Error ? err : new Error(String(err)));
}
}
export async function drag(startX: number, startY: number, endX: number, endY: number, button?: ButtonType): Promise<void> {
try {
const { mouse, Button, Point, straightTo } = await getNutMouse();
await mouse.move(straightTo(new Point(startX, startY)));
await mouse.pressButton(mapButton(button, Button));
await mouse.move(straightTo(new Point(endX, endY)));
await mouse.releaseButton(mapButton(button, Button));
} catch (err) {
throw new DesktopError('mouse.drag', err instanceof Error ? err : new Error(String(err)));
}
}
export async function scroll(x: number, y: number, deltaY: number, deltaX?: number): Promise<void> {
try {
const { mouse, Point, straightTo } = await getNutMouse();
await mouse.move(straightTo(new Point(x, y)));
if (deltaY > 0) {
await mouse.scrollDown(Math.abs(deltaY));
} else if (deltaY < 0) {
await mouse.scrollUp(Math.abs(deltaY));
}
if (deltaX && deltaX > 0) {
await mouse.scrollRight(Math.abs(deltaX));
} else if (deltaX && deltaX < 0) {
await mouse.scrollLeft(Math.abs(deltaX));
}
} catch (err) {
throw new DesktopError('mouse.scroll', err instanceof Error ? err : new Error(String(err)));
}
}
export async function getPosition(): Promise<{ x: number; y: number }> {
try {
const { mouse } = await getNutMouse();
const pos = await mouse.getPosition();
return { x: pos.x, y: pos.y };
} catch (err) {
throw new DesktopError('mouse.getPosition', err instanceof Error ? err : new Error(String(err)));
}
}

View File

@@ -0,0 +1,99 @@
/**
* Screen analysis using vision model.
*
* Captures screenshot and sends to vision model for analysis.
*/
import { complete } from '@mariozechner/pi-ai';
import type { Model, Api } from '@mariozechner/pi-ai';
import { captureScreenshot } from './screenshot.js';
import { DesktopError } from '../utils/errors.js';
export interface AnalyzeResult {
analysis: string;
imageWidth: number;
imageHeight: number;
}
const SCREEN_ANALYSIS_SYSTEM_PROMPT = `You are a GUI screen analysis assistant specialized in desktop automation.
Your task is to analyze screenshots and provide actionable information for automation tasks.
When analyzing the screen:
1. Identify UI elements: buttons, menus, input fields, icons, windows, dialogs
2. Provide approximate coordinates when asked (as percentages like "top-left quadrant" or pixel estimates)
3. Describe element states: enabled/disabled, focused, selected, minimized, etc.
4. Read visible text content accurately
5. Identify the active application and window focus
Be concise but thorough. Format your response clearly with:
- Element descriptions
- Locations (when relevant)
- States and any actionable details
If the user asks about specific elements, locate them precisely and describe their position relative to the screen or window.`;
/**
* Capture screenshot and analyze with vision model.
*
* @param model - The vision model to use
* @param apiKey - API key for the model
* @param prompt - Analysis instruction (e.g., "What buttons are visible?")
* @param displayIndex - Display to capture (default: 0)
*/
export async function analyzeScreen(
model: Model<Api>,
apiKey: string,
prompt: string,
displayIndex: number = 0,
): Promise<AnalyzeResult> {
try {
// Capture screenshot
const screenshot = await captureScreenshot(displayIndex);
// Build message with image
const messages = [
{
role: 'user' as const,
content: [
{
type: 'image' as const,
data: screenshot.image,
mimeType: screenshot.mimeType,
},
{
type: 'text' as const,
text: prompt,
},
],
timestamp: Date.now(),
},
];
// Call vision model using pi-ai complete function
const response = await complete(model, {
systemPrompt: SCREEN_ANALYSIS_SYSTEM_PROMPT,
messages,
}, {
apiKey,
});
// Extract text from response
let analysis = '';
if (response && response.content) {
for (const part of response.content) {
if (part.type === 'text') {
analysis += part.text;
}
}
}
return {
analysis,
imageWidth: screenshot.imageWidth,
imageHeight: screenshot.imageHeight,
};
} catch (err) {
throw new DesktopError('screenAnalyzer.analyze', err instanceof Error ? err : new Error(String(err)));
}
}

View File

@@ -0,0 +1,113 @@
/**
* Screenshot pipeline: capture → scale → JPEG → base64
*
* 1. nut.js screen.capture() at physical resolution
* 2. sharp resize to logical resolution (absorb scaleFactor)
* 3. If longest edge > 1920, proportionally scale to 1920
* 4. JPEG encode with configurable quality
* 5. Return base64 + metadata
*/
import sharp from 'sharp';
import { getDisplay } from './display.js';
import { DesktopError } from '../utils/errors.js';
import { logInfo, logDebug } from '../utils/logger.js';
const MAX_LONGEST_EDGE = 1920;
const MAX_JPEG_BYTES = 1_500_000; // 1.5 MB hard limit for LLM input
const QUALITY_RETRY_STEPS = [60, 45, 30]; // Fallback quality levels
export interface ScreenshotResult {
/** Base64-encoded JPEG image */
image: string;
mimeType: 'image/jpeg';
/** Byte size of the JPEG */
imageBytes: number;
/** Width of the final image sent to LLM */
imageWidth: number;
/** Height of the final image sent to LLM */
imageHeight: number;
/** Logical display width */
logicalWidth: number;
/** Logical display height */
logicalHeight: number;
/** Physical capture width */
physicalWidth: number;
/** Physical capture height */
physicalHeight: number;
/** Display scale factor */
scaleFactor: number;
/** Which display was captured */
displayIndex: number;
}
export async function captureScreenshot(displayIndex: number, jpegQuality: number = 75): Promise<ScreenshotResult> {
try {
const display = await getDisplay(displayIndex);
const { screen } = await import('@nut-tree-fork/nut-js');
// Capture raw RGBA buffer at physical resolution
const { Region } = await import('@nut-tree-fork/nut-js');
const region = new Region(display.origin.x, display.origin.y, display.width, display.height);
const captured = await screen.grabRegion(region);
const physicalWidth = captured.width;
const physicalHeight = captured.height;
const rawBuffer = captured.data;
// Logical resolution (absorb scaleFactor)
const scaleFactor = display.scaleFactor;
let logicalWidth = Math.round(physicalWidth / scaleFactor);
let logicalHeight = Math.round(physicalHeight / scaleFactor);
// Target dimensions: start with logical
let targetWidth = logicalWidth;
let targetHeight = logicalHeight;
// If longest edge > MAX, proportionally scale down
const longestEdge = Math.max(targetWidth, targetHeight);
if (longestEdge > MAX_LONGEST_EDGE) {
const scale = MAX_LONGEST_EDGE / longestEdge;
targetWidth = Math.round(targetWidth * scale);
targetHeight = Math.round(targetHeight * scale);
}
// Resize + JPEG encode
const sharpInstance = sharp(rawBuffer, {
raw: { width: physicalWidth, height: physicalHeight, channels: 4 },
}).resize(targetWidth, targetHeight, { kernel: 'lanczos3' });
let jpegBuffer = await sharpInstance.clone().jpeg({ quality: jpegQuality }).toBuffer();
// Quality degradation retry — if bytes exceed limit, retry with lower quality
if (jpegBuffer.length > MAX_JPEG_BYTES) {
for (const retryQuality of QUALITY_RETRY_STEPS) {
if (retryQuality >= jpegQuality) continue; // Skip if not lower than current
logDebug(`Screenshot ${jpegBuffer.length} bytes exceeds ${MAX_JPEG_BYTES}, retrying with quality=${retryQuality}`);
jpegBuffer = await sharpInstance.clone().jpeg({ quality: retryQuality }).toBuffer();
if (jpegBuffer.length <= MAX_JPEG_BYTES) break;
}
}
const base64 = jpegBuffer.toString('base64');
logDebug(`Screenshot: ${physicalWidth}x${physicalHeight}${targetWidth}x${targetHeight}, ${jpegBuffer.length} bytes`);
return {
image: base64,
mimeType: 'image/jpeg',
imageBytes: jpegBuffer.length,
imageWidth: targetWidth,
imageHeight: targetHeight,
logicalWidth,
logicalHeight,
physicalWidth,
physicalHeight,
scaleFactor,
displayIndex,
};
} catch (err) {
if (err instanceof DesktopError) throw err;
throw new DesktopError('captureScreenshot', err instanceof Error ? err : new Error(String(err)));
}
}