From 955c350528d166194cba26d240b679f65efc4d6d Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Wed, 30 Sep 2026 22:27:39 +0200 Subject: [PATCH 1/2] Add Windows host providers for WSL Route the five default desktop providers through one persistent Windows helper without loading Linux native libraries. Return screenshot pixels directly and preserve provider-disable settings and a Linux desktop opt-out. Add provider, protocol-client and WSL selection tests. Document the companion libnut-core release required before integration. --- README.md | 42 ++++ .../lib/provider/provider-registry.class.ts | 50 +++-- .../provider/provider-registry.wsl.spec.ts | 58 ++++++ .../provider/wsl-detection.function.spec.ts | 20 ++ .../lib/provider/wsl-detection.function.ts | 11 + providers/libnut/lib/wsl/host-client.spec.ts | 126 ++++++++++++ providers/libnut/lib/wsl/host-client.ts | 194 ++++++++++++++++++ providers/libnut/lib/wsl/index.ts | 2 + providers/libnut/lib/wsl/providers.spec.ts | 111 ++++++++++ providers/libnut/lib/wsl/providers.ts | 167 +++++++++++++++ 10 files changed, 762 insertions(+), 19 deletions(-) create mode 100644 core/nut.js/lib/provider/provider-registry.wsl.spec.ts create mode 100644 core/nut.js/lib/provider/wsl-detection.function.spec.ts create mode 100644 core/nut.js/lib/provider/wsl-detection.function.ts create mode 100644 providers/libnut/lib/wsl/host-client.spec.ts create mode 100644 providers/libnut/lib/wsl/host-client.ts create mode 100644 providers/libnut/lib/wsl/index.ts create mode 100644 providers/libnut/lib/wsl/providers.spec.ts create mode 100644 providers/libnut/lib/wsl/providers.ts diff --git a/README.md b/README.md index 1a1f6bc6..7f90446e 100644 --- a/README.md +++ b/README.md @@ -259,6 +259,48 @@ Wayland is **NOT** supported! On e.g. Ubuntu you can switch to XWayland on your login screen as a workaround. +#### WSL: control the Windows host + +When running in Windows Subsystem for Linux (WSL), the default mouse, keyboard, +screen, clipboard and window providers target the Windows host. The existing +nut.js API stays the same. Native Linux, macOS and Windows keep their current +providers. + +The Windows host executable is built in +[libnut-core](https://github.com/nut-tree/libnut-core) and shipped in +`@nut-tree/libnut-win32`. It starts on the first desktop operation and handles +subsequent commands over standard input/output. Windows does not need Node.js, +PowerShell or a C# compiler at runtime, and the helper does not open a network +port. Screen capture returns pixels directly as a nut.js `Image`; it does not +write a screenshot file for the caller to open. + +Windows interoperability must be enabled in WSL. Run WSL from the signed-in +Windows user's interactive session. Starting it through a Windows service or +SSH does not give the helper access to that user's desktop. Input into elevated +applications and the secure desktop remains subject to Windows restrictions. +The helper uses physical pixel coordinates and captures the primary screen, +matching `ScreenProviderInterface`. + +Set `NUT_JS_DISABLE_WSL=1` before importing nut.js to keep the Linux desktop +providers inside WSL. Existing `NUT_JS_DISABLE_DEFAULT_*` settings still apply. +For source development, `NUT_JS_WSL_HELPER` can point to the precompiled helper's +path as seen from WSL: + +```sh +export NUT_JS_WSL_HELPER=/mnt/c/path/to/build/Release/libnut-wsl-host.exe +``` + +This support requires the companion libnut-core release containing +`libnut-wsl-host.exe`. Older native packages do not contain it; the library +reports a missing-helper error rather than compiling it or silently controlling +the Linux desktop. The native package pin must be updated to that release when +the two contributions are integrated. + +The helper exits when its command pipe closes and releases input that it held. +Idle helpers do not keep completed Node.js scripts alive. A failed or timed-out +command is not automatically repeated because a click or keystroke might +already have reached Windows. + ## Install `nut.js` ### Open Source diff --git a/core/nut.js/lib/provider/provider-registry.class.ts b/core/nut.js/lib/provider/provider-registry.class.ts index 4e44b34b..95447092 100644 --- a/core/nut.js/lib/provider/provider-registry.class.ts +++ b/core/nut.js/lib/provider/provider-registry.class.ts @@ -30,6 +30,7 @@ import { DISABLE_DEFAULT_WINDOW_PROVIDER_ENV_VAR } from "../constants"; import { wrapLogger } from "./log/wrap-logger.function"; +import { shouldUseWindowsHost } from "./wsl-detection.function"; class DefaultProviderRegistry implements ProviderRegistry { @@ -306,25 +307,36 @@ providerRegistry.registerColorFinder(new ColorFinderImpl()); providerRegistry.registerLogProvider(new NoopLogProvider()); if (!process.env[DISABLE_DEFAULT_PROVIDERS_ENV_VAR]) { - if (!process.env[DISABLE_DEFAULT_CLIPBOARD_PROVIDER_ENV_VAR]) { - const Clipboard = require("@nut-tree/default-clipboard-provider").default; - providerRegistry.registerClipboardProvider(new Clipboard()); - } - if (!process.env[DISABLE_DEFAULT_KEYBOARD_PROVIDER_ENV_VAR]) { - const { DefaultKeyboardAction } = require("@nut-tree/libnut"); - providerRegistry.registerKeyboardProvider(new DefaultKeyboardAction()); - } - if (!process.env[DISABLE_DEFAULT_MOUSE_PROVIDER_ENV_VAR]) { - const { DefaultMouseAction } = require("@nut-tree/libnut"); - providerRegistry.registerMouseProvider(new DefaultMouseAction()); - } - if (!process.env[DISABLE_DEFAULT_SCREEN_PROVIDER_ENV_VAR]) { - const { DefaultScreenAction } = require("@nut-tree/libnut"); - providerRegistry.registerScreenProvider(new DefaultScreenAction()); - } - if (!process.env[DISABLE_DEFAULT_WINDOW_PROVIDER_ENV_VAR]) { - const { DefaultWindowAction } = require("@nut-tree/libnut"); - providerRegistry.registerWindowProvider(new DefaultWindowAction()); + if (shouldUseWindowsHost()) { + // Import the WSL entry point directly: the package's main entry loads Linux native libraries. + const { createWindowsProviders } = require("@nut-tree/libnut/dist/lib/wsl"); + const windows = createWindowsProviders(); + if (!process.env[DISABLE_DEFAULT_CLIPBOARD_PROVIDER_ENV_VAR]) providerRegistry.registerClipboardProvider(windows.clipboard); + if (!process.env[DISABLE_DEFAULT_KEYBOARD_PROVIDER_ENV_VAR]) providerRegistry.registerKeyboardProvider(windows.keyboard); + if (!process.env[DISABLE_DEFAULT_MOUSE_PROVIDER_ENV_VAR]) providerRegistry.registerMouseProvider(windows.mouse); + if (!process.env[DISABLE_DEFAULT_SCREEN_PROVIDER_ENV_VAR]) providerRegistry.registerScreenProvider(windows.screen); + if (!process.env[DISABLE_DEFAULT_WINDOW_PROVIDER_ENV_VAR]) providerRegistry.registerWindowProvider(windows.window); + } else { + if (!process.env[DISABLE_DEFAULT_CLIPBOARD_PROVIDER_ENV_VAR]) { + const Clipboard = require("@nut-tree/default-clipboard-provider").default; + providerRegistry.registerClipboardProvider(new Clipboard()); + } + if (!process.env[DISABLE_DEFAULT_KEYBOARD_PROVIDER_ENV_VAR]) { + const { DefaultKeyboardAction } = require("@nut-tree/libnut"); + providerRegistry.registerKeyboardProvider(new DefaultKeyboardAction()); + } + if (!process.env[DISABLE_DEFAULT_MOUSE_PROVIDER_ENV_VAR]) { + const { DefaultMouseAction } = require("@nut-tree/libnut"); + providerRegistry.registerMouseProvider(new DefaultMouseAction()); + } + if (!process.env[DISABLE_DEFAULT_SCREEN_PROVIDER_ENV_VAR]) { + const { DefaultScreenAction } = require("@nut-tree/libnut"); + providerRegistry.registerScreenProvider(new DefaultScreenAction()); + } + if (!process.env[DISABLE_DEFAULT_WINDOW_PROVIDER_ENV_VAR]) { + const { DefaultWindowAction } = require("@nut-tree/libnut"); + providerRegistry.registerWindowProvider(new DefaultWindowAction()); + } } } diff --git a/core/nut.js/lib/provider/provider-registry.wsl.spec.ts b/core/nut.js/lib/provider/provider-registry.wsl.spec.ts new file mode 100644 index 00000000..de6d3845 --- /dev/null +++ b/core/nut.js/lib/provider/provider-registry.wsl.spec.ts @@ -0,0 +1,58 @@ +const windows = { clipboard: {}, keyboard: {}, mouse: {}, screen: {}, window: {}, close: jest.fn() }; +const createWindowsProviders = jest.fn(() => windows); +jest.mock("@nut-tree/libnut/dist/lib/wsl", () => ({ createWindowsProviders }), { virtual: true }); +jest.mock("@nut-tree/libnut", () => ({ + DefaultKeyboardAction: jest.fn(), DefaultMouseAction: jest.fn(), + DefaultScreenAction: jest.fn(), DefaultWindowAction: jest.fn() +}), { virtual: true }); +jest.mock("@nut-tree/default-clipboard-provider", () => ({ default: jest.fn() }), { virtual: true }); + +describe("WSL default provider registration", () => { + const environment = process.env; + beforeEach(() => { + process.env = { ...environment, WSL_DISTRO_NAME: "Ubuntu" }; + for (const name of Object.keys(process.env)) { + if (name.startsWith("NUT_JS_DISABLE_")) delete process.env[name]; + } + createWindowsProviders.mockClear(); + }); + afterEach(() => { process.env = environment; }); + + function load() { + let registry: any; + jest.isolateModules(() => { registry = require("./provider-registry.class").default; }); + return registry; + } + + it("registers all five Windows providers without loading Linux native defaults", () => { + const registry = load(); + expect(registry.getClipboard()).toBe(windows.clipboard); + expect(registry.getKeyboard()).toBe(windows.keyboard); + expect(registry.getMouse()).toBe(windows.mouse); + expect(registry.getScreen()).toBe(windows.screen); + expect(registry.getWindow()).toBe(windows.window); + expect(createWindowsProviders).toHaveBeenCalledTimes(1); + expect(require("@nut-tree/libnut").DefaultMouseAction).not.toHaveBeenCalled(); + }); + + it.each(["CLIPBOARD", "KEYBOARD", "MOUSE", "SCREEN", "WINDOW"])("respects disabling the %s default provider", name => { + process.env[`NUT_JS_DISABLE_DEFAULT_${name}_PROVIDER`] = "1"; + const registry = load(); + const getter = `get${name[0]}${name.substring(1).toLowerCase()}`; + expect(() => registry[getter]()).toThrow("No"); + }); + + it("respects disabling all default providers", () => { + process.env.NUT_JS_DISABLE_DEFAULT_PROVIDERS = "1"; + const registry = load(); + expect(createWindowsProviders).not.toHaveBeenCalled(); + expect(() => registry.getMouse()).toThrow("No MouseProvider"); + }); + + it("allows explicit Linux desktop selection", () => { + process.env.NUT_JS_DISABLE_WSL = "1"; + const registry = load(); + expect(createWindowsProviders).not.toHaveBeenCalled(); + expect(registry.getMouse()).not.toBe(windows.mouse); + }); +}); diff --git a/core/nut.js/lib/provider/wsl-detection.function.spec.ts b/core/nut.js/lib/provider/wsl-detection.function.spec.ts new file mode 100644 index 00000000..522cccb4 --- /dev/null +++ b/core/nut.js/lib/provider/wsl-detection.function.spec.ts @@ -0,0 +1,20 @@ +import { shouldUseWindowsHost } from "./wsl-detection.function"; + +describe("WSL Windows host selection", () => { + it.each([ + [{ WSL_INTEROP: "/run/WSL/1_interop" }, "ordinary-linux"], + [{ WSL_DISTRO_NAME: "Ubuntu" }, "ordinary-linux"], + [{}, "6.6.87.2-microsoft-standard-WSL2"], + [{}, "4.4.0-Microsoft"] + ])("detects WSL using environment or kernel evidence", (environment, kernel) => { + expect(shouldUseWindowsHost("linux", environment, () => kernel)).toBe(true); + }); + it("leaves native Linux, macOS and Windows unchanged", () => { + expect(shouldUseWindowsHost("linux", {}, () => "6.8.0-generic")).toBe(false); + expect(shouldUseWindowsHost("darwin", { WSL_DISTRO_NAME: "Ubuntu" })).toBe(false); + expect(shouldUseWindowsHost("win32", { WSL_INTEROP: "inherited" })).toBe(false); + }); + it("allows WSL users to retain Linux desktop providers", () => { + expect(shouldUseWindowsHost("linux", { WSL_DISTRO_NAME: "Ubuntu", NUT_JS_DISABLE_WSL: "1" })).toBe(false); + }); +}); diff --git a/core/nut.js/lib/provider/wsl-detection.function.ts b/core/nut.js/lib/provider/wsl-detection.function.ts new file mode 100644 index 00000000..e25d2575 --- /dev/null +++ b/core/nut.js/lib/provider/wsl-detection.function.ts @@ -0,0 +1,11 @@ +import { release } from "os"; + +/** WSL can host Linux desktops too; callers can retain them explicitly. */ +export function shouldUseWindowsHost( + platform: NodeJS.Platform = process.platform, + environment: NodeJS.ProcessEnv = process.env, + kernelRelease: () => string = release +): boolean { + if (platform !== "linux" || environment.NUT_JS_DISABLE_WSL) return false; + return Boolean(environment.WSL_INTEROP || environment.WSL_DISTRO_NAME || /microsoft/i.test(kernelRelease())); +} diff --git a/providers/libnut/lib/wsl/host-client.spec.ts b/providers/libnut/lib/wsl/host-client.spec.ts new file mode 100644 index 00000000..5a802af0 --- /dev/null +++ b/providers/libnut/lib/wsl/host-client.spec.ts @@ -0,0 +1,126 @@ +import { ChildProcessWithoutNullStreams } from "child_process"; +import { EventEmitter } from "events"; +import { PassThrough, Writable } from "stream"; +import { WindowsHostClient } from "./host-client"; + +jest.mock("fs", () => ({ existsSync: jest.fn(() => true) })); + +function peer() { + const process = new EventEmitter(); + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const requests: { id: number; command: string; args: unknown }[] = []; + const stdin = new Writable({ + write(chunk, _encoding, callback) { + const request = JSON.parse(chunk.toString()); + requests.push(request); + if (request.command === "hello") reply(request.id, { protocol: 1 }); + callback(); + } + }); + const child = Object.assign(process, { stdin, stdout, stderr }) as unknown as ChildProcessWithoutNullStreams; + function reply(id: number, result: unknown, pixels = Buffer.alloc(0)) { + stdout.write(Buffer.concat([Buffer.from(JSON.stringify({ id, result, byteLength: pixels.length }) + "\n"), pixels])); + } + return { child, requests, reply }; +} + +describe("WindowsHostClient", () => { + const clients: WindowsHostClient[] = []; + afterEach(() => { for (const client of clients) client.close(); clients.length = 0; jest.useRealTimers(); }); + + function setup() { + const remote = peer(); + const spawn = jest.fn(() => remote.child); + const client = new WindowsHostClient(() => "/helper.exe", spawn); + clients.push(client); + return { ...remote, client, spawn }; + } + + it("starts once, handshakes and serializes commands", async () => { + const remote = setup(); + const first = remote.client.request("screenSize"); + const second = remote.client.request("cursorPosition"); + await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); + expect(remote.requests.map(request => request.command)).toEqual(["hello", "screenSize"]); + remote.reply(2, { width: 100, height: 50 }); + expect((await first).result).toEqual({ width: 100, height: 50 }); + expect(remote.requests[2].command).toBe("cursorPosition"); + remote.reply(3, { x: 4, y: 5 }); + expect((await second).result).toEqual({ x: 4, y: 5 }); + expect(remote.spawn).toHaveBeenCalledTimes(1); + }); + + it("assembles fragmented UTF-8 headers and binary screenshot data", async () => { + const remote = setup(); + const response = remote.client.request("capture"); + await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); + const pixels = Buffer.from([10, 0, 255, 255, 13, 10, 0, 255]); + const packet = Buffer.concat([Buffer.from(JSON.stringify({ id: 2, result: "é", byteLength: pixels.length }) + "\n"), pixels]); + for (const byte of packet) remote.child.stdout.emit("data", Buffer.from([byte])); + expect(await response).toEqual({ result: "é", pixels }); + }); + + it("reports command errors without repeating the command", async () => { + const remote = setup(); + const response = remote.client.request("click"); + const rejected = expect(response).rejects.toThrow("input blocked"); + await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); + remote.child.stdout.emit("data", Buffer.from('{"id":2,"error":"input blocked","byteLength":0}\n')); + await rejected; + expect(remote.requests.filter(request => request.command === "click")).toHaveLength(1); + }); + + it.each([ + '{"id":99,"result":null,"byteLength":0}\n', + '{"id":2,"result":null,"byteLength":-1}\n', + '{"id":2,"result":null,"byteLength":134217729}\n', + '{"id":2,"byteLength":0}\n', + 'not JSON\n' + ])("fails the connection on malformed responses: %s", async packet => { + const remote = setup(); + const response = remote.client.request("capture"); + const rejected = expect(response).rejects.toBeInstanceOf(Error); + await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); + remote.child.stdout.emit("data", Buffer.from(packet)); + await rejected; + await expect(remote.client.request("capture")).rejects.toBeInstanceOf(Error); + expect(remote.spawn).toHaveBeenCalledTimes(1); + }); + + it("rejects active and queued requests when the host exits", async () => { + const remote = setup(); + const first = expect(remote.client.request("click")).rejects.toThrow("not retried"); + const second = expect(remote.client.request("capture")).rejects.toThrow("not retried"); + await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); + remote.child.emit("close", 1, null); + await Promise.all([first, second]); + expect(remote.requests.map(request => request.command)).toEqual(["hello", "click"]); + }); + + it("ends helper input on close", async () => { + const remote = setup(); + const response = expect(remote.client.request("screenSize")).rejects.toThrow("closed"); + await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); + remote.client.close(); + await response; + expect(remote.child.stdin.writableEnded).toBe(true); + }); + + it("does not restart or retry after a timeout", async () => { + jest.useFakeTimers(); + const remote = setup(); + const response = expect(remote.client.request("click", {}, 10)).rejects.toThrow("may already have occurred"); + await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); + jest.advanceTimersByTime(10); + await response; + await expect(remote.client.request("click")).rejects.toThrow("may already have occurred"); + expect(remote.spawn).toHaveBeenCalledTimes(1); + }); + + it("rejects requests exceeding the protocol limit without writing them", async () => { + const remote = setup(); + await expect(remote.client.request("type", { text: "a".repeat(1024 * 1024) })).rejects.toThrow("exceeds 1 MiB"); + expect(remote.requests.map(request => request.command)).toEqual(["hello"]); + }); +}); diff --git a/providers/libnut/lib/wsl/host-client.ts b/providers/libnut/lib/wsl/host-client.ts new file mode 100644 index 00000000..e29cfef0 --- /dev/null +++ b/providers/libnut/lib/wsl/host-client.ts @@ -0,0 +1,194 @@ +import { ChildProcessWithoutNullStreams, spawn } from "child_process"; +import { existsSync } from "fs"; +import { dirname, join, resolve } from "path"; + +const maxRequestBytes = 1024 * 1024; +const maxImageBytes = 128 * 1024 * 1024; +const maxHeaderBytes = 1024 * 1024; +const defaultTimeout = 30000; + +export interface HostResponse { + result: T; + pixels: Buffer; +} + +interface ResponseHeader { + id: number; + result?: unknown; + error?: string; + byteLength: number; +} + +interface Request { + id: number; + data: string; + timeout: number; + resolve: (response: HostResponse) => void; + reject: (error: Error) => void; +} + +export type HostSpawner = (path: string) => ChildProcessWithoutNullStreams; + +export function windowsHelperPath(): string { + if (process.env.NUT_JS_WSL_HELPER) { + return resolve(process.env.NUT_JS_WSL_HELPER); + } + try { + return join(dirname(require.resolve("@nut-tree/libnut-win32/package.json")), "build", "Release", "libnut-wsl-host.exe"); + } catch { + throw new Error("The WSL backend requires @nut-tree/libnut-win32 with its precompiled Windows host. NUT_JS_WSL_HELPER can select a local build."); + } +} + +/** One ordered command stream shared by all five desktop providers. */ +export class WindowsHostClient { + private child?: ChildProcessWithoutNullStreams; + private ready?: Promise; + private failure?: Error; + private queue: Request[] = []; + private active?: Request; + private timer?: NodeJS.Timeout; + private buffer = Buffer.alloc(0); + private header?: ResponseHeader; + private pixels?: Buffer; + private pixelOffset = 0; + private nextId = 1; + private stderr = ""; + + constructor( + private readonly helperPath: () => string = windowsHelperPath, + private readonly startProcess: HostSpawner = (path) => spawn(path, [], { stdio: "pipe", windowsHide: true }) + ) {} + + async request(command: string, args: Record = {}, timeout = defaultTimeout): Promise> { + if (this.failure) throw this.failure; + if (!this.ready) this.ready = this.start(); + await this.ready; + return this.exchange(command, args, timeout) as Promise>; + } + + close = (): void => { + if (this.failure) return; + this.stop(new Error("Windows host connection closed")); + }; + + private async start(): Promise { + try { + const path = this.helperPath(); + if (!existsSync(path)) throw new Error(`Precompiled Windows host not found: ${path}. Build or upgrade libnut-core; the helper is never compiled at runtime.`); + const child = this.startProcess(path); + this.child = child; + // Idle helpers must not keep otherwise finished nut.js scripts alive. + // An active request's timeout keeps the event loop alive until its reply. + child.unref?.(); + for (const stream of [child.stdin, child.stdout, child.stderr]) { + (stream as typeof stream & { unref?: () => void }).unref?.(); + } + child.stdout.on("data", (chunk: Buffer) => this.receive(chunk)); + child.stderr.on("data", (chunk: Buffer) => { + this.stderr = (this.stderr + chunk.toString("utf8")).slice(-4096); + }); + child.stdin.on("error", (error: Error) => this.stop(error)); + child.stdout.on("error", (error: Error) => this.stop(error)); + child.on("error", (error: Error) => this.stop(new Error(`Cannot start the Windows host. Check that WSL Windows interoperability is enabled: ${error.message}`))); + child.on("close", (code, signal) => { + this.stop(new Error(`Windows host exited (${signal ?? code ?? "unknown"})${this.stderr ? `: ${this.stderr.trim()}` : ""}. Commands are not retried because input may already have occurred.`)); + process.removeListener("exit", this.close); + }); + process.once("exit", this.close); + const response = await this.exchange("hello", {}, defaultTimeout); + const result = response.result as { protocol?: unknown } | null; + if (!result || result.protocol !== 1 || response.pixels.length !== 0) { + throw new Error("Unsupported Windows host protocol; rebuild matching nut.js and libnut-core sources"); + } + } catch (error) { + const failure = error instanceof Error ? error : new Error(String(error)); + this.stop(failure); + throw failure; + } + } + + private exchange(command: string, args: Record, timeout: number): Promise> { + if (this.failure) return Promise.reject(this.failure); + const id = this.nextId++; + const data = JSON.stringify({ id, command, args }) + "\n"; + if (Buffer.byteLength(data) - 1 > maxRequestBytes) return Promise.reject(new Error("Windows host request exceeds 1 MiB")); + return new Promise((resolve, reject) => { + this.queue.push({ id, data, timeout, resolve, reject }); + this.sendNext(); + }); + } + + private sendNext(): void { + if (this.failure || this.active || !this.child) return; + const request = this.queue.shift(); + if (!request) return; + this.active = request; + this.timer = setTimeout(() => this.stop(new Error("Windows host request timed out. Its action may already have occurred; it will not be retried.")), request.timeout); + this.child.stdin.write(request.data, (error) => { if (error) this.stop(error); }); + } + + private receive(chunk: Buffer): void { + if (this.failure) return; + try { + let offset = 0; + while (offset < chunk.length) { + if (!this.header) { + const newline = chunk.indexOf(10, offset); + const end = newline === -1 ? chunk.length : newline; + if (this.buffer.length + end - offset > maxHeaderBytes) throw new Error("Windows host response header exceeds 1 MiB"); + this.buffer = Buffer.concat([this.buffer, chunk.subarray(offset, end)]); + if (newline === -1) { + return; + } + const header = JSON.parse(this.buffer.toString("utf8")) as ResponseHeader; + if (!header || !this.active || header.id !== this.active.id || + !Number.isSafeInteger(header.byteLength) || header.byteLength < 0 || header.byteLength > maxImageBytes || + (header.error !== undefined && typeof header.error !== "string") || + (header.error !== undefined ? header.byteLength !== 0 : !("result" in header))) { + throw new Error("Invalid Windows host response"); + } + this.header = header; + this.buffer = Buffer.alloc(0); + this.pixels = Buffer.allocUnsafe(header.byteLength); + this.pixelOffset = 0; + offset = newline + 1; + } + const header = this.header; + const count = Math.min(header.byteLength - this.pixelOffset, chunk.length - offset); + chunk.copy(this.pixels!, this.pixelOffset, offset, offset + count); + this.pixelOffset += count; + offset += count; + if (this.pixelOffset < header.byteLength) return; + const request = this.active!; + const pixels = this.pixels!; + this.header = undefined; + this.pixels = undefined; + this.active = undefined; + if (this.timer) clearTimeout(this.timer); + this.timer = undefined; + if (header.error !== undefined) request.reject(new Error(header.error)); + else request.resolve({ result: header.result, pixels }); + this.sendNext(); + } + } catch (error) { + this.stop(error instanceof Error ? error : new Error(String(error))); + } + } + + private stop(error: Error): void { + if (this.failure) return; + this.failure = error; + if (this.timer) clearTimeout(this.timer); + this.timer = undefined; + this.active?.reject(error); + this.active = undefined; + for (const request of this.queue) request.reject(error); + this.queue = []; + this.buffer = Buffer.alloc(0); + this.pixels = undefined; + // EOF lets the helper release its own held keys/buttons before exiting. + this.child?.stdin.end(); + process.removeListener("exit", this.close); + } +} diff --git a/providers/libnut/lib/wsl/index.ts b/providers/libnut/lib/wsl/index.ts new file mode 100644 index 00000000..304b6cab --- /dev/null +++ b/providers/libnut/lib/wsl/index.ts @@ -0,0 +1,2 @@ +export { WindowsHostClient } from "./host-client"; +export { createWindowsProviders } from "./providers"; diff --git a/providers/libnut/lib/wsl/providers.spec.ts b/providers/libnut/lib/wsl/providers.spec.ts new file mode 100644 index 00000000..c9a3e65d --- /dev/null +++ b/providers/libnut/lib/wsl/providers.spec.ts @@ -0,0 +1,111 @@ +import { Button, ColorMode, Key, Point, Region, Size } from "@nut-tree/shared"; +import { WindowsHostClient } from "./host-client"; +import { createWindowsProviders } from "./providers"; + +describe("WSL Windows providers", () => { + function setup() { + const request = jest.fn(async (_command: string, _args?: Record, _timeout?: number): Promise<{ result: unknown; pixels: Buffer }> => ({ result: null, pixels: Buffer.alloc(0) })); + const host = { request, close: jest.fn() } as unknown as WindowsHostClient; + return { request, host, providers: createWindowsProviders(host) }; + } + + it("shares one host across all providers", async () => { + const { request, providers } = setup(); + await providers.clipboard.copy("é 😀\n"); + await providers.mouse.click(Button.LEFT); + await providers.keyboard.type("hello"); + expect(request.mock.calls.map(call => call[0])).toEqual(["clipboardWrite", "click", "type"]); + }); + + it("holds a chord in order and releases in the helper without mutating the arguments", async () => { + const { request, providers } = setup(); + const keys = [Key.LeftControl, Key.LeftShift, Key.A]; + await providers.keyboard.click(...keys); + expect(keys).toEqual([Key.LeftControl, Key.LeftShift, Key.A]); + expect(request).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledWith("keyClick", { keys: ["control", "shift", "a"], delay: 10 }); + }); + + it("maps function, right modifier and keypad keys", async () => { + const { request, providers } = setup(); + await providers.keyboard.pressKey(Key.RightSuper, Key.F24, Key.NumPad9); + expect(request).toHaveBeenCalledWith("keys", { keys: ["right_win", "f24", "numpad_9"], down: true, delay: 10 }); + }); + + it("rejects unsupported keys before sending input", async () => { + const { request, providers } = setup(); + await expect(providers.keyboard.pressKey(Key.LeftControl, Key.Fn)).rejects.toThrow("Unsupported Windows key"); + expect(request).not.toHaveBeenCalled(); + }); + + it("maps horizontal and vertical scroll directions", async () => { + const { request, providers } = setup(); + await providers.mouse.scrollUp(120); await providers.mouse.scrollDown(120); + await providers.mouse.scrollLeft(120); await providers.mouse.scrollRight(120); + expect(request).toHaveBeenNthCalledWith(1, "scroll", { amount: 120, horizontal: false, delay: 100 }); + expect(request).toHaveBeenNthCalledWith(2, "scroll", { amount: -120, horizontal: false, delay: 100 }); + expect(request).toHaveBeenNthCalledWith(3, "scroll", { amount: -120, horizontal: true, delay: 100 }); + expect(request).toHaveBeenNthCalledWith(4, "scroll", { amount: 120, horizontal: true, delay: 100 }); + }); + + it("uses native desktop coordinates including negative monitor positions", async () => { + const { request, providers } = setup(); + await providers.mouse.setMousePosition(new Point(-100, 200)); + expect(request).toHaveBeenCalledWith("moveMouse", { x: -100, y: 200, delay: 100 }); + }); + + it("returns a nut.js Image directly from pixel data", async () => { + const { request, providers } = setup(); + const pixels = Buffer.from([0, 0, 255, 255]); + request.mockResolvedValueOnce({ result: { width: 1, height: 1, byteWidth: 4 }, pixels } as never); + const image = await providers.screen.grabScreen(); + expect(image.data).toBe(pixels); + expect(image.colorMode).toBe(ColorMode.BGR); + expect(image.pixelDensity).toEqual({ scaleX: 1, scaleY: 1 }); + expect(image.width).toBe(1); + }); + + it("rejects truncated screenshot data", async () => { + const { request, providers } = setup(); + request.mockResolvedValueOnce({ result: { width: 1, height: 1, byteWidth: 4 }, pixels: Buffer.alloc(3) } as never); + await expect(providers.screen.grabScreen()).rejects.toThrow("pixel data"); + }); + + it("passes screen regions and highlight parameters", async () => { + const { request, providers } = setup(); + request.mockResolvedValueOnce({ result: { width: 2, height: 3, byteWidth: 8 }, pixels: Buffer.alloc(24) } as never); + await providers.screen.grabScreenRegion(new Region(4, 5, 2, 3)); + expect(request).toHaveBeenCalledWith("capture", { x: 4, y: 5, width: 2, height: 3 }); + await providers.screen.highlightScreenRegion(new Region(4, 5, 2, 3), 1000, 0.5); + expect(request).toHaveBeenCalledWith("highlight", { x: 4, y: 5, width: 2, height: 3, duration: 1000, opacity: 0.5 }, 31000); + }); + + it("implements clipboard state and clearing", async () => { + const { request, providers } = setup(); + request.mockResolvedValue({ result: true, pixels: Buffer.alloc(0) } as never); + expect(await providers.clipboard.hasText()).toBe(true); + expect(await providers.clipboard.clear()).toBe(true); + expect(request.mock.calls.map(call => call[0])).toEqual(["clipboardHasText", "clipboardClear"]); + }); + + it("implements every window operation", async () => { + const { request, providers } = setup(); + request.mockResolvedValueOnce({ result: [1], pixels: Buffer.alloc(0) } as never); + expect(await providers.window.getWindows()).toEqual([1]); + await providers.window.getActiveWindow(); await providers.window.getWindowTitle(1); + request.mockResolvedValueOnce({ result: { x: -10, y: 20, width: 30, height: 40 }, pixels: Buffer.alloc(0) } as never); + expect(await providers.window.getWindowRegion(1)).toEqual(new Region(-10, 20, 30, 40)); + await providers.window.focusWindow(1); await providers.window.moveWindow(1, new Point(5, 6)); + await providers.window.resizeWindow(1, new Size(7, 8)); + await providers.window.minimizeWindow(1); await providers.window.restoreWindow(1); + expect(request.mock.calls.map(call => call[0])).toEqual(["windows", "activeWindow", "windowTitle", "windowRegion", "focusWindow", "moveWindow", "resizeWindow", "minimizeWindow", "restoreWindow"]); + }); + + it("validates input delays and coordinates", async () => { + const { request, providers } = setup(); + expect(() => providers.mouse.setMouseDelay(-1)).toThrow("delay"); + expect(() => providers.keyboard.setKeyboardDelay(Infinity)).toThrow("delay"); + await expect(providers.mouse.setMousePosition(new Point(NaN, 0))).rejects.toThrow("x"); + expect(request).not.toHaveBeenCalled(); + }); +}); diff --git a/providers/libnut/lib/wsl/providers.ts b/providers/libnut/lib/wsl/providers.ts new file mode 100644 index 00000000..f5ec8eff --- /dev/null +++ b/providers/libnut/lib/wsl/providers.ts @@ -0,0 +1,167 @@ +import { + ClipboardProviderInterface, KeyboardProviderInterface, MouseProviderInterface, + ScreenProviderInterface, WindowProviderInterface +} from "@nut-tree/provider-interfaces"; +import { Button, ColorMode, Image, Key, Point, Region, Size } from "@nut-tree/shared"; +import { WindowsHostClient } from "./host-client"; + +function integer(value: number, name: string, minimum = -2147483648, maximum = 2147483647): number { + if (!Number.isInteger(value) || value < minimum || value > maximum) throw new Error(`Invalid ${name}: ${value}`); + return value; +} + +function delay(value: number): number { return integer(value, "input delay", 0, 60000); } + +function keyName(key: Key): string { + const name = Key[key]; + if (!name) throw new Error(`Invalid key: ${key}`); + if (/^[A-Z]$/.test(name)) return name.toLowerCase(); + if (/^F\d+$/.test(name)) return name.toLowerCase(); + if (/^Num\d$/.test(name)) return name.substring(3); + if (/^NumPad\d$/.test(name)) return `numpad_${name.substring(6)}`; + const names: Record = { + Escape: "escape", Print: "printscreen", ScrollLock: "scroll_lock", Pause: "pause", + Grave: "`", Minus: "-", Equal: "=", Backspace: "backspace", Insert: "insert", + Home: "home", PageUp: "pageup", NumLock: "num_lock", Divide: "divide", + Multiply: "multiply", Subtract: "subtract", Tab: "tab", LeftBracket: "[", + RightBracket: "]", Backslash: "\\", Delete: "delete", End: "end", PageDown: "pagedown", + Add: "add", CapsLock: "caps_lock", Semicolon: ";", Quote: "'", Return: "return", + LeftShift: "shift", RightShift: "right_shift", Comma: ",", Period: ".", Slash: "/", + Up: "up", Enter: "enter", LeftControl: "control", RightControl: "right_control", + LeftSuper: "win", LeftWin: "win", LeftCmd: "win", RightSuper: "right_win", + RightWin: "right_win", RightCmd: "right_win", LeftAlt: "alt", RightAlt: "right_alt", + Space: "space", Menu: "menu", Left: "left", Down: "down", Right: "right", + Decimal: "numpad_decimal", Clear: "clear", AudioMute: "audio_mute", + AudioVolDown: "audio_vol_down", AudioVolUp: "audio_vol_up", AudioPlay: "audio_play", + AudioStop: "audio_stop", AudioPause: "audio_pause", AudioPrev: "audio_prev", AudioNext: "audio_next" + }; + if (!names[name]) throw new Error(`Unsupported Windows key: ${name}`); + return names[name]; +} + +class WindowsKeyboard implements KeyboardProviderInterface { + private delay = 10; + constructor(private readonly host: WindowsHostClient) {} + setKeyboardDelay(value: number): void { this.delay = delay(value); } + async type(text: string): Promise { + await this.host.request("type", { text, delay: this.delay }, 30000 + text.length * this.delay); + } + async click(...keys: Key[]): Promise { + await this.host.request("keyClick", { keys: keys.map(keyName), delay: this.delay }); + } + async pressKey(...keys: Key[]): Promise { + await this.host.request("keys", { keys: keys.map(keyName), down: true, delay: this.delay }); + } + async releaseKey(...keys: Key[]): Promise { + await this.host.request("keys", { keys: keys.map(keyName), down: false, delay: this.delay }); + } +} + +class WindowsMouse implements MouseProviderInterface { + private delay = 100; + constructor(private readonly host: WindowsHostClient) {} + setMouseDelay(value: number): void { this.delay = delay(value); } + private button(value: Button): number { + if (value === Button.LEFT) return 0; + if (value === Button.MIDDLE) return 1; + if (value === Button.RIGHT) return 2; + throw new Error(`Invalid mouse button: ${value}`); + } + async setMousePosition(position: Point): Promise { + await this.host.request("moveMouse", { x: integer(position.x, "x"), y: integer(position.y, "y"), delay: this.delay }); + } + async currentMousePosition(): Promise { + const { result } = await this.host.request<{ x: number; y: number }>("cursorPosition"); + return new Point(integer(result.x, "cursor x"), integer(result.y, "cursor y")); + } + async click(button: Button): Promise { + await this.host.request("click", { button: this.button(button), count: 1, delay: this.delay }); + } + async doubleClick(button: Button): Promise { + await this.host.request("click", { button: this.button(button), count: 2, delay: this.delay }); + } + leftClick(): Promise { return this.click(Button.LEFT); } + rightClick(): Promise { return this.click(Button.RIGHT); } + middleClick(): Promise { return this.click(Button.MIDDLE); } + async pressButton(button: Button): Promise { + await this.host.request("mouseButton", { button: this.button(button), down: true, delay: this.delay }); + } + async releaseButton(button: Button): Promise { + await this.host.request("mouseButton", { button: this.button(button), down: false, delay: this.delay }); + } + private async scroll(amount: number, horizontal: boolean): Promise { + await this.host.request("scroll", { amount: integer(amount, "scroll amount"), horizontal, delay: this.delay }); + } + scrollUp(amount: number): Promise { return this.scroll(amount, false); } + scrollDown(amount: number): Promise { return this.scroll(-amount, false); } + scrollLeft(amount: number): Promise { return this.scroll(-amount, true); } + scrollRight(amount: number): Promise { return this.scroll(amount, true); } +} + +class WindowsScreen implements ScreenProviderInterface { + constructor(private readonly host: WindowsHostClient) {} + private async capture(args: Record): Promise { + const { result, pixels } = await this.host.request<{ width: number; height: number; byteWidth: number }>("capture", args); + const width = integer(result.width, "image width", 1); + const height = integer(result.height, "image height", 1); + if (result.byteWidth !== width * 4 || pixels.length !== width * height * 4) throw new Error("Invalid Windows screenshot dimensions or pixel data"); + return new Image(width, height, pixels, 4, "wslWindowsScreen", 32, result.byteWidth, ColorMode.BGR, { scaleX: 1, scaleY: 1 }); + } + grabScreen(): Promise { return this.capture({}); } + grabScreenRegion(region: Region): Promise { + return this.capture({ x: integer(region.left, "left", 0), y: integer(region.top, "top", 0), + width: integer(region.width, "width", 1), height: integer(region.height, "height", 1) }); + } + async highlightScreenRegion(region: Region, duration: number, opacity: number): Promise { + if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1) throw new Error("Opacity must be between 0 and 1"); + const milliseconds = integer(duration, "highlight duration", 0); + await this.host.request("highlight", { x: integer(region.left, "left"), y: integer(region.top, "top"), + width: integer(region.width, "width", 1), height: integer(region.height, "height", 1), duration: milliseconds, opacity }, 30000 + milliseconds); + } + async screenSize(): Promise { + const { result } = await this.host.request<{ width: number; height: number }>("screenSize"); + return new Region(0, 0, integer(result.width, "screen width", 1), integer(result.height, "screen height", 1)); + } + async screenWidth(): Promise { return (await this.screenSize()).width; } + async screenHeight(): Promise { return (await this.screenSize()).height; } +} + +class WindowsClipboard implements ClipboardProviderInterface { + constructor(private readonly host: WindowsHostClient) {} + async hasText(): Promise { return (await this.host.request("clipboardHasText")).result; } + async clear(): Promise { return (await this.host.request("clipboardClear")).result; } + async copy(text: string): Promise { await this.host.request("clipboardWrite", { text }); } + async paste(): Promise { return (await this.host.request("clipboardRead")).result; } +} + +class WindowsWindow implements WindowProviderInterface { + constructor(private readonly host: WindowsHostClient) {} + async getWindows(): Promise { return (await this.host.request("windows")).result; } + async getActiveWindow(): Promise { return (await this.host.request("activeWindow")).result; } + async getWindowTitle(handle: number): Promise { return (await this.host.request("windowTitle", { handle })).result; } + async getWindowRegion(handle: number): Promise { + const { result } = await this.host.request<{ x: number; y: number; width: number; height: number }>("windowRegion", { handle }); + return new Region(result.x, result.y, result.width, result.height); + } + async focusWindow(handle: number): Promise { return (await this.host.request("focusWindow", { handle })).result; } + async moveWindow(handle: number, origin: Point): Promise { + return (await this.host.request("moveWindow", { handle, x: integer(origin.x, "x"), y: integer(origin.y, "y") })).result; + } + async resizeWindow(handle: number, size: Size): Promise { + return (await this.host.request("resizeWindow", { handle, width: integer(size.width, "width", 1), height: integer(size.height, "height", 1) })).result; + } + async minimizeWindow(handle: number): Promise { return (await this.host.request("minimizeWindow", { handle })).result; } + async restoreWindow(handle: number): Promise { return (await this.host.request("restoreWindow", { handle })).result; } +} + +export function createWindowsProviders(host = new WindowsHostClient()): { + clipboard: ClipboardProviderInterface; + keyboard: KeyboardProviderInterface; + mouse: MouseProviderInterface; + screen: ScreenProviderInterface; + window: WindowProviderInterface; + close: () => void; +} { + return { clipboard: new WindowsClipboard(host), keyboard: new WindowsKeyboard(host), + mouse: new WindowsMouse(host), screen: new WindowsScreen(host), window: new WindowsWindow(host), close: host.close }; +} From 1d3baddd1a4f20ae1fd057cd231570136ba6f6ca Mon Sep 17 00:00:00 2001 From: Inaya Yousfi Date: Wed, 30 Sep 2026 22:59:02 +0200 Subject: [PATCH 2/2] Fix WSL provider timing and command deadlines Include configured input delays in command timeouts and reject timer overflow before sending actions. Remove the extra string-typing delay without changing shared keyboard timing. Add regression tests for long delays, timer limits and public string typing configuration, and document the limits. --- README.md | 10 ++- providers/libnut/lib/wsl/host-client.spec.ts | 35 ++++++++- providers/libnut/lib/wsl/host-client.ts | 16 ++++ providers/libnut/lib/wsl/providers.spec.ts | 81 ++++++++++++++++++-- providers/libnut/lib/wsl/providers.ts | 26 ++++--- 5 files changed, 147 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 7f90446e..92bfad3a 100644 --- a/README.md +++ b/README.md @@ -296,7 +296,15 @@ reports a missing-helper error rather than compiling it or silently controlling the Linux desktop. The native package pin must be updated to that release when the two contributions are integrated. -The helper exits when its command pipe closes and releases input that it held. +Input command timeouts include the configured provider delay. A command timeout +must fit Node.js's maximum timer delay of 2,147,483,647 ms. Highlight durations +above 2,147,453,647 ms are rejected before sending the command, leaving 30 seconds +for completion. String typing uses the public keyboard delay without an +additional helper delay. + +The helper exits when its command pipe closes and attempts to release input +that it held. Windows can reject those releases; failed releases remain tracked +while the helper is running and shutdown failures are reported on standard error. Idle helpers do not keep completed Node.js scripts alive. A failed or timed-out command is not automatically repeated because a click or keystroke might already have reached Windows. diff --git a/providers/libnut/lib/wsl/host-client.spec.ts b/providers/libnut/lib/wsl/host-client.spec.ts index 5a802af0..777f3e7b 100644 --- a/providers/libnut/lib/wsl/host-client.spec.ts +++ b/providers/libnut/lib/wsl/host-client.spec.ts @@ -1,7 +1,7 @@ import { ChildProcessWithoutNullStreams } from "child_process"; import { EventEmitter } from "events"; import { PassThrough, Writable } from "stream"; -import { WindowsHostClient } from "./host-client"; +import { commandTimeout, WindowsHostClient } from "./host-client"; jest.mock("fs", () => ({ existsSync: jest.fn(() => true) })); @@ -69,6 +69,11 @@ describe("WindowsHostClient", () => { remote.child.stdout.emit("data", Buffer.from('{"id":2,"error":"input blocked","byteLength":0}\n')); await rejected; expect(remote.requests.filter(request => request.command === "click")).toHaveLength(1); + const next = remote.client.request("screenSize"); + await Promise.resolve(); + remote.reply(3, { width: 100, height: 50 }); + expect((await next).result).toEqual({ width: 100, height: 50 }); + expect(remote.spawn).toHaveBeenCalledTimes(1); }); it.each([ @@ -123,4 +128,32 @@ describe("WindowsHostClient", () => { await expect(remote.client.request("type", { text: "a".repeat(1024 * 1024) })).rejects.toThrow("exceeds 1 MiB"); expect(remote.requests.map(request => request.command)).toEqual(["hello"]); }); + + it("waits for accepted delays above 30 seconds", async () => { + jest.useFakeTimers(); + const remote = setup(); + const response = remote.client.request("keys", { keys: ["a"], down: true, delay: 60000 }, commandTimeout(60000)); + await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); + jest.advanceTimersByTime(60000); + remote.reply(2, null); + expect(await response).toEqual({ result: null, pixels: Buffer.alloc(0) }); + expect(remote.child.stdin.writableEnded).toBe(false); + }); + + it.each([0, -1, NaN, Infinity, 1.5, 2147483648, 2400030000])("rejects invalid timeout %s before starting the helper", async timeout => { + const remote = setup(); + await expect(remote.client.request("type", {}, timeout)).rejects.toThrow("timeout"); + expect(remote.spawn).not.toHaveBeenCalled(); + expect(remote.requests).toHaveLength(0); + }); + + it("accepts the exact maximum timer delay without shortening it", async () => { + jest.useFakeTimers(); + const remote = setup(); + const response = remote.client.request("screenSize", {}, 2147483647); + await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); + jest.advanceTimersByTime(60000); + remote.reply(2, { width: 100, height: 50 }); + expect((await response).result).toEqual({ width: 100, height: 50 }); + }); }); diff --git a/providers/libnut/lib/wsl/host-client.ts b/providers/libnut/lib/wsl/host-client.ts index e29cfef0..ff17e44c 100644 --- a/providers/libnut/lib/wsl/host-client.ts +++ b/providers/libnut/lib/wsl/host-client.ts @@ -6,6 +6,19 @@ const maxRequestBytes = 1024 * 1024; const maxImageBytes = 128 * 1024 * 1024; const maxHeaderBytes = 1024 * 1024; const defaultTimeout = 30000; +const maxTimeout = 2147483647; + +export function commandTimeout(duration = 0): number { + const timeout = defaultTimeout + duration; + validateTimeout(timeout); + return timeout; +} + +function validateTimeout(timeout: number): void { + if (!Number.isInteger(timeout) || timeout < 1 || timeout > maxTimeout) { + throw new Error(`Windows host timeout must be between 1 and ${maxTimeout} ms`); + } +} export interface HostResponse { result: T; @@ -61,6 +74,9 @@ export class WindowsHostClient { ) {} async request(command: string, args: Record = {}, timeout = defaultTimeout): Promise> { + // Validate before starting the helper or sending an action. Node replaces + // overflowing timer delays with 1 ms, which would fail after input occurs. + validateTimeout(timeout); if (this.failure) throw this.failure; if (!this.ready) this.ready = this.start(); await this.ready; diff --git a/providers/libnut/lib/wsl/providers.spec.ts b/providers/libnut/lib/wsl/providers.spec.ts index c9a3e65d..92ee0870 100644 --- a/providers/libnut/lib/wsl/providers.spec.ts +++ b/providers/libnut/lib/wsl/providers.spec.ts @@ -1,6 +1,16 @@ import { Button, ColorMode, Key, Point, Region, Size } from "@nut-tree/shared"; import { WindowsHostClient } from "./host-client"; import { createWindowsProviders } from "./providers"; +import { KeyboardClass } from "../../../../core/nut.js/lib/keyboard.class"; +import { NoopLogProvider } from "../../../../core/nut.js/lib/provider/log/noop-log-provider.class"; +import { ProviderRegistry } from "@nut-tree/provider-interfaces"; + +// sleep() uses the global registry only for logging. Do not load native defaults +// when exercising the real KeyboardClass and its actual timer implementation. +jest.mock("../../../../core/nut.js/lib/provider/provider-registry.class", () => ({ + __esModule: true, + default: { getLogProvider: () => ({ debug: jest.fn() }) } +})); describe("WSL Windows providers", () => { function setup() { @@ -23,13 +33,13 @@ describe("WSL Windows providers", () => { await providers.keyboard.click(...keys); expect(keys).toEqual([Key.LeftControl, Key.LeftShift, Key.A]); expect(request).toHaveBeenCalledTimes(1); - expect(request).toHaveBeenCalledWith("keyClick", { keys: ["control", "shift", "a"], delay: 10 }); + expect(request).toHaveBeenCalledWith("keyClick", { keys: ["control", "shift", "a"], delay: 10 }, 30010); }); it("maps function, right modifier and keypad keys", async () => { const { request, providers } = setup(); await providers.keyboard.pressKey(Key.RightSuper, Key.F24, Key.NumPad9); - expect(request).toHaveBeenCalledWith("keys", { keys: ["right_win", "f24", "numpad_9"], down: true, delay: 10 }); + expect(request).toHaveBeenCalledWith("keys", { keys: ["right_win", "f24", "numpad_9"], down: true, delay: 10 }, 30010); }); it("rejects unsupported keys before sending input", async () => { @@ -42,16 +52,16 @@ describe("WSL Windows providers", () => { const { request, providers } = setup(); await providers.mouse.scrollUp(120); await providers.mouse.scrollDown(120); await providers.mouse.scrollLeft(120); await providers.mouse.scrollRight(120); - expect(request).toHaveBeenNthCalledWith(1, "scroll", { amount: 120, horizontal: false, delay: 100 }); - expect(request).toHaveBeenNthCalledWith(2, "scroll", { amount: -120, horizontal: false, delay: 100 }); - expect(request).toHaveBeenNthCalledWith(3, "scroll", { amount: -120, horizontal: true, delay: 100 }); - expect(request).toHaveBeenNthCalledWith(4, "scroll", { amount: 120, horizontal: true, delay: 100 }); + expect(request).toHaveBeenNthCalledWith(1, "scroll", { amount: 120, horizontal: false, delay: 100 }, 30100); + expect(request).toHaveBeenNthCalledWith(2, "scroll", { amount: -120, horizontal: false, delay: 100 }, 30100); + expect(request).toHaveBeenNthCalledWith(3, "scroll", { amount: -120, horizontal: true, delay: 100 }, 30100); + expect(request).toHaveBeenNthCalledWith(4, "scroll", { amount: 120, horizontal: true, delay: 100 }, 30100); }); it("uses native desktop coordinates including negative monitor positions", async () => { const { request, providers } = setup(); await providers.mouse.setMousePosition(new Point(-100, 200)); - expect(request).toHaveBeenCalledWith("moveMouse", { x: -100, y: 200, delay: 100 }); + expect(request).toHaveBeenCalledWith("moveMouse", { x: -100, y: 200, delay: 100 }, 30100); }); it("returns a nut.js Image directly from pixel data", async () => { @@ -108,4 +118,61 @@ describe("WSL Windows providers", () => { await expect(providers.mouse.setMousePosition(new Point(NaN, 0))).rejects.toThrow("x"); expect(request).not.toHaveBeenCalled(); }); + + it("includes the maximum configured delay in all input command deadlines", async () => { + const { request, providers } = setup(); + providers.keyboard.setKeyboardDelay(60000); + providers.mouse.setMouseDelay(60000); + await providers.keyboard.click(Key.A); + await providers.keyboard.pressKey(Key.A); + await providers.keyboard.releaseKey(Key.A); + await providers.mouse.setMousePosition(new Point(0, 0)); + await providers.mouse.click(Button.LEFT); + await providers.mouse.doubleClick(Button.LEFT); + await providers.mouse.pressButton(Button.LEFT); + await providers.mouse.releaseButton(Button.LEFT); + await providers.mouse.scrollUp(120); + for (const [, args, timeout] of request.mock.calls) { + expect(args?.delay).toBe(60000); + expect(timeout).toBe(90000); + } + expect(request).toHaveBeenCalledTimes(9); + }); + + it("rejects overflowing highlight deadlines before sending the action", async () => { + const { request, providers } = setup(); + const region = new Region(0, 0, 1, 1); + await expect(providers.screen.highlightScreenRegion(region, 2147483647 - 30000 + 1, 0.5)).rejects.toThrow("timeout"); + expect(request).not.toHaveBeenCalled(); + await providers.screen.highlightScreenRegion(region, 2147483647 - 30000, 0.5); + expect(request.mock.calls[0][2]).toBe(2147483647); + }); + + it("does not add provider delay to direct string typing", async () => { + const { request, providers } = setup(); + providers.keyboard.setKeyboardDelay(60000); + await providers.keyboard.type("a".repeat(40000)); + expect(request).toHaveBeenCalledWith("type", { text: "a".repeat(40000), delay: 0 }); + }); + + it.each([0, 25, 300])("uses the current public string delay once (%i ms)", async milliseconds => { + jest.useFakeTimers(); + try { + const { request, providers } = setup(); + const registry = { + hasKeyboard: () => true, getKeyboard: () => providers.keyboard, + getLogProvider: () => new NoopLogProvider() + } as unknown as ProviderRegistry; + const keyboard = new KeyboardClass(registry); + keyboard.config.autoDelayMs = milliseconds; + const start = Date.now(); + const typing = keyboard.type("ab"); + await jest.runAllTimersAsync(); + await typing; + expect(Date.now() - start).toBe(2 * milliseconds); + expect(request.mock.calls).toEqual([ + ["type", { text: "a", delay: 0 }], ["type", { text: "b", delay: 0 }] + ]); + } finally { jest.useRealTimers(); } + }); }); diff --git a/providers/libnut/lib/wsl/providers.ts b/providers/libnut/lib/wsl/providers.ts index f5ec8eff..ed463e08 100644 --- a/providers/libnut/lib/wsl/providers.ts +++ b/providers/libnut/lib/wsl/providers.ts @@ -3,7 +3,7 @@ import { ScreenProviderInterface, WindowProviderInterface } from "@nut-tree/provider-interfaces"; import { Button, ColorMode, Image, Key, Point, Region, Size } from "@nut-tree/shared"; -import { WindowsHostClient } from "./host-client"; +import { commandTimeout, WindowsHostClient } from "./host-client"; function integer(value: number, name: string, minimum = -2147483648, maximum = 2147483647): number { if (!Number.isInteger(value) || value < minimum || value > maximum) throw new Error(`Invalid ${name}: ${value}`); @@ -44,16 +44,18 @@ class WindowsKeyboard implements KeyboardProviderInterface { constructor(private readonly host: WindowsHostClient) {} setKeyboardDelay(value: number): void { this.delay = delay(value); } async type(text: string): Promise { - await this.host.request("type", { text, delay: this.delay }, 30000 + text.length * this.delay); + // KeyboardClass already spaces characters using its current public config. + // Match native typeString: provider delays apply to key operations only. + await this.host.request("type", { text, delay: 0 }); } async click(...keys: Key[]): Promise { - await this.host.request("keyClick", { keys: keys.map(keyName), delay: this.delay }); + await this.host.request("keyClick", { keys: keys.map(keyName), delay: this.delay }, commandTimeout(this.delay)); } async pressKey(...keys: Key[]): Promise { - await this.host.request("keys", { keys: keys.map(keyName), down: true, delay: this.delay }); + await this.host.request("keys", { keys: keys.map(keyName), down: true, delay: this.delay }, commandTimeout(this.delay)); } async releaseKey(...keys: Key[]): Promise { - await this.host.request("keys", { keys: keys.map(keyName), down: false, delay: this.delay }); + await this.host.request("keys", { keys: keys.map(keyName), down: false, delay: this.delay }, commandTimeout(this.delay)); } } @@ -68,29 +70,29 @@ class WindowsMouse implements MouseProviderInterface { throw new Error(`Invalid mouse button: ${value}`); } async setMousePosition(position: Point): Promise { - await this.host.request("moveMouse", { x: integer(position.x, "x"), y: integer(position.y, "y"), delay: this.delay }); + await this.host.request("moveMouse", { x: integer(position.x, "x"), y: integer(position.y, "y"), delay: this.delay }, commandTimeout(this.delay)); } async currentMousePosition(): Promise { const { result } = await this.host.request<{ x: number; y: number }>("cursorPosition"); return new Point(integer(result.x, "cursor x"), integer(result.y, "cursor y")); } async click(button: Button): Promise { - await this.host.request("click", { button: this.button(button), count: 1, delay: this.delay }); + await this.host.request("click", { button: this.button(button), count: 1, delay: this.delay }, commandTimeout(this.delay)); } async doubleClick(button: Button): Promise { - await this.host.request("click", { button: this.button(button), count: 2, delay: this.delay }); + await this.host.request("click", { button: this.button(button), count: 2, delay: this.delay }, commandTimeout(this.delay)); } leftClick(): Promise { return this.click(Button.LEFT); } rightClick(): Promise { return this.click(Button.RIGHT); } middleClick(): Promise { return this.click(Button.MIDDLE); } async pressButton(button: Button): Promise { - await this.host.request("mouseButton", { button: this.button(button), down: true, delay: this.delay }); + await this.host.request("mouseButton", { button: this.button(button), down: true, delay: this.delay }, commandTimeout(this.delay)); } async releaseButton(button: Button): Promise { - await this.host.request("mouseButton", { button: this.button(button), down: false, delay: this.delay }); + await this.host.request("mouseButton", { button: this.button(button), down: false, delay: this.delay }, commandTimeout(this.delay)); } private async scroll(amount: number, horizontal: boolean): Promise { - await this.host.request("scroll", { amount: integer(amount, "scroll amount"), horizontal, delay: this.delay }); + await this.host.request("scroll", { amount: integer(amount, "scroll amount"), horizontal, delay: this.delay }, commandTimeout(this.delay)); } scrollUp(amount: number): Promise { return this.scroll(amount, false); } scrollDown(amount: number): Promise { return this.scroll(-amount, false); } @@ -116,7 +118,7 @@ class WindowsScreen implements ScreenProviderInterface { if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1) throw new Error("Opacity must be between 0 and 1"); const milliseconds = integer(duration, "highlight duration", 0); await this.host.request("highlight", { x: integer(region.left, "left"), y: integer(region.top, "top"), - width: integer(region.width, "width", 1), height: integer(region.height, "height", 1), duration: milliseconds, opacity }, 30000 + milliseconds); + width: integer(region.width, "width", 1), height: integer(region.height, "height", 1), duration: milliseconds, opacity }, commandTimeout(milliseconds)); } async screenSize(): Promise { const { result } = await this.host.request<{ width: number; height: number }>("screenSize");