Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,56 @@ 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.

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.

## Install `nut.js`

### Open Source
Expand Down
50 changes: 31 additions & 19 deletions core/nut.js/lib/provider/provider-registry.class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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());
}
}
}

Expand Down
58 changes: 58 additions & 0 deletions core/nut.js/lib/provider/provider-registry.wsl.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
20 changes: 20 additions & 0 deletions core/nut.js/lib/provider/wsl-detection.function.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
11 changes: 11 additions & 0 deletions core/nut.js/lib/provider/wsl-detection.function.ts
Original file line number Diff line number Diff line change
@@ -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()));
}
159 changes: 159 additions & 0 deletions providers/libnut/lib/wsl/host-client.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { ChildProcessWithoutNullStreams } from "child_process";
import { EventEmitter } from "events";
import { PassThrough, Writable } from "stream";
import { commandTimeout, 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);
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([
'{"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"]);
});

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 });
});
});
Loading