Skip to content

Commit 955c350

Browse files
committed
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.
1 parent e413fa1 commit 955c350

10 files changed

Lines changed: 762 additions & 19 deletions

File tree

‎README.md‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,48 @@ Wayland is **NOT** supported!
259259

260260
On e.g. Ubuntu you can switch to XWayland on your login screen as a workaround.
261261

262+
#### WSL: control the Windows host
263+
264+
When running in Windows Subsystem for Linux (WSL), the default mouse, keyboard,
265+
screen, clipboard and window providers target the Windows host. The existing
266+
nut.js API stays the same. Native Linux, macOS and Windows keep their current
267+
providers.
268+
269+
The Windows host executable is built in
270+
[libnut-core](https://github.com/nut-tree/libnut-core) and shipped in
271+
`@nut-tree/libnut-win32`. It starts on the first desktop operation and handles
272+
subsequent commands over standard input/output. Windows does not need Node.js,
273+
PowerShell or a C# compiler at runtime, and the helper does not open a network
274+
port. Screen capture returns pixels directly as a nut.js `Image`; it does not
275+
write a screenshot file for the caller to open.
276+
277+
Windows interoperability must be enabled in WSL. Run WSL from the signed-in
278+
Windows user's interactive session. Starting it through a Windows service or
279+
SSH does not give the helper access to that user's desktop. Input into elevated
280+
applications and the secure desktop remains subject to Windows restrictions.
281+
The helper uses physical pixel coordinates and captures the primary screen,
282+
matching `ScreenProviderInterface`.
283+
284+
Set `NUT_JS_DISABLE_WSL=1` before importing nut.js to keep the Linux desktop
285+
providers inside WSL. Existing `NUT_JS_DISABLE_DEFAULT_*` settings still apply.
286+
For source development, `NUT_JS_WSL_HELPER` can point to the precompiled helper's
287+
path as seen from WSL:
288+
289+
```sh
290+
export NUT_JS_WSL_HELPER=/mnt/c/path/to/build/Release/libnut-wsl-host.exe
291+
```
292+
293+
This support requires the companion libnut-core release containing
294+
`libnut-wsl-host.exe`. Older native packages do not contain it; the library
295+
reports a missing-helper error rather than compiling it or silently controlling
296+
the Linux desktop. The native package pin must be updated to that release when
297+
the two contributions are integrated.
298+
299+
The helper exits when its command pipe closes and releases input that it held.
300+
Idle helpers do not keep completed Node.js scripts alive. A failed or timed-out
301+
command is not automatically repeated because a click or keystroke might
302+
already have reached Windows.
303+
262304
## Install `nut.js`
263305

264306
### Open Source

‎core/nut.js/lib/provider/provider-registry.class.ts‎

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
DISABLE_DEFAULT_WINDOW_PROVIDER_ENV_VAR
3131
} from "../constants";
3232
import { wrapLogger } from "./log/wrap-logger.function";
33+
import { shouldUseWindowsHost } from "./wsl-detection.function";
3334

3435

3536
class DefaultProviderRegistry implements ProviderRegistry {
@@ -306,25 +307,36 @@ providerRegistry.registerColorFinder(new ColorFinderImpl());
306307
providerRegistry.registerLogProvider(new NoopLogProvider());
307308

308309
if (!process.env[DISABLE_DEFAULT_PROVIDERS_ENV_VAR]) {
309-
if (!process.env[DISABLE_DEFAULT_CLIPBOARD_PROVIDER_ENV_VAR]) {
310-
const Clipboard = require("@nut-tree/default-clipboard-provider").default;
311-
providerRegistry.registerClipboardProvider(new Clipboard());
312-
}
313-
if (!process.env[DISABLE_DEFAULT_KEYBOARD_PROVIDER_ENV_VAR]) {
314-
const { DefaultKeyboardAction } = require("@nut-tree/libnut");
315-
providerRegistry.registerKeyboardProvider(new DefaultKeyboardAction());
316-
}
317-
if (!process.env[DISABLE_DEFAULT_MOUSE_PROVIDER_ENV_VAR]) {
318-
const { DefaultMouseAction } = require("@nut-tree/libnut");
319-
providerRegistry.registerMouseProvider(new DefaultMouseAction());
320-
}
321-
if (!process.env[DISABLE_DEFAULT_SCREEN_PROVIDER_ENV_VAR]) {
322-
const { DefaultScreenAction } = require("@nut-tree/libnut");
323-
providerRegistry.registerScreenProvider(new DefaultScreenAction());
324-
}
325-
if (!process.env[DISABLE_DEFAULT_WINDOW_PROVIDER_ENV_VAR]) {
326-
const { DefaultWindowAction } = require("@nut-tree/libnut");
327-
providerRegistry.registerWindowProvider(new DefaultWindowAction());
310+
if (shouldUseWindowsHost()) {
311+
// Import the WSL entry point directly: the package's main entry loads Linux native libraries.
312+
const { createWindowsProviders } = require("@nut-tree/libnut/dist/lib/wsl");
313+
const windows = createWindowsProviders();
314+
if (!process.env[DISABLE_DEFAULT_CLIPBOARD_PROVIDER_ENV_VAR]) providerRegistry.registerClipboardProvider(windows.clipboard);
315+
if (!process.env[DISABLE_DEFAULT_KEYBOARD_PROVIDER_ENV_VAR]) providerRegistry.registerKeyboardProvider(windows.keyboard);
316+
if (!process.env[DISABLE_DEFAULT_MOUSE_PROVIDER_ENV_VAR]) providerRegistry.registerMouseProvider(windows.mouse);
317+
if (!process.env[DISABLE_DEFAULT_SCREEN_PROVIDER_ENV_VAR]) providerRegistry.registerScreenProvider(windows.screen);
318+
if (!process.env[DISABLE_DEFAULT_WINDOW_PROVIDER_ENV_VAR]) providerRegistry.registerWindowProvider(windows.window);
319+
} else {
320+
if (!process.env[DISABLE_DEFAULT_CLIPBOARD_PROVIDER_ENV_VAR]) {
321+
const Clipboard = require("@nut-tree/default-clipboard-provider").default;
322+
providerRegistry.registerClipboardProvider(new Clipboard());
323+
}
324+
if (!process.env[DISABLE_DEFAULT_KEYBOARD_PROVIDER_ENV_VAR]) {
325+
const { DefaultKeyboardAction } = require("@nut-tree/libnut");
326+
providerRegistry.registerKeyboardProvider(new DefaultKeyboardAction());
327+
}
328+
if (!process.env[DISABLE_DEFAULT_MOUSE_PROVIDER_ENV_VAR]) {
329+
const { DefaultMouseAction } = require("@nut-tree/libnut");
330+
providerRegistry.registerMouseProvider(new DefaultMouseAction());
331+
}
332+
if (!process.env[DISABLE_DEFAULT_SCREEN_PROVIDER_ENV_VAR]) {
333+
const { DefaultScreenAction } = require("@nut-tree/libnut");
334+
providerRegistry.registerScreenProvider(new DefaultScreenAction());
335+
}
336+
if (!process.env[DISABLE_DEFAULT_WINDOW_PROVIDER_ENV_VAR]) {
337+
const { DefaultWindowAction } = require("@nut-tree/libnut");
338+
providerRegistry.registerWindowProvider(new DefaultWindowAction());
339+
}
328340
}
329341
}
330342

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
const windows = { clipboard: {}, keyboard: {}, mouse: {}, screen: {}, window: {}, close: jest.fn() };
2+
const createWindowsProviders = jest.fn(() => windows);
3+
jest.mock("@nut-tree/libnut/dist/lib/wsl", () => ({ createWindowsProviders }), { virtual: true });
4+
jest.mock("@nut-tree/libnut", () => ({
5+
DefaultKeyboardAction: jest.fn(), DefaultMouseAction: jest.fn(),
6+
DefaultScreenAction: jest.fn(), DefaultWindowAction: jest.fn()
7+
}), { virtual: true });
8+
jest.mock("@nut-tree/default-clipboard-provider", () => ({ default: jest.fn() }), { virtual: true });
9+
10+
describe("WSL default provider registration", () => {
11+
const environment = process.env;
12+
beforeEach(() => {
13+
process.env = { ...environment, WSL_DISTRO_NAME: "Ubuntu" };
14+
for (const name of Object.keys(process.env)) {
15+
if (name.startsWith("NUT_JS_DISABLE_")) delete process.env[name];
16+
}
17+
createWindowsProviders.mockClear();
18+
});
19+
afterEach(() => { process.env = environment; });
20+
21+
function load() {
22+
let registry: any;
23+
jest.isolateModules(() => { registry = require("./provider-registry.class").default; });
24+
return registry;
25+
}
26+
27+
it("registers all five Windows providers without loading Linux native defaults", () => {
28+
const registry = load();
29+
expect(registry.getClipboard()).toBe(windows.clipboard);
30+
expect(registry.getKeyboard()).toBe(windows.keyboard);
31+
expect(registry.getMouse()).toBe(windows.mouse);
32+
expect(registry.getScreen()).toBe(windows.screen);
33+
expect(registry.getWindow()).toBe(windows.window);
34+
expect(createWindowsProviders).toHaveBeenCalledTimes(1);
35+
expect(require("@nut-tree/libnut").DefaultMouseAction).not.toHaveBeenCalled();
36+
});
37+
38+
it.each(["CLIPBOARD", "KEYBOARD", "MOUSE", "SCREEN", "WINDOW"])("respects disabling the %s default provider", name => {
39+
process.env[`NUT_JS_DISABLE_DEFAULT_${name}_PROVIDER`] = "1";
40+
const registry = load();
41+
const getter = `get${name[0]}${name.substring(1).toLowerCase()}`;
42+
expect(() => registry[getter]()).toThrow("No");
43+
});
44+
45+
it("respects disabling all default providers", () => {
46+
process.env.NUT_JS_DISABLE_DEFAULT_PROVIDERS = "1";
47+
const registry = load();
48+
expect(createWindowsProviders).not.toHaveBeenCalled();
49+
expect(() => registry.getMouse()).toThrow("No MouseProvider");
50+
});
51+
52+
it("allows explicit Linux desktop selection", () => {
53+
process.env.NUT_JS_DISABLE_WSL = "1";
54+
const registry = load();
55+
expect(createWindowsProviders).not.toHaveBeenCalled();
56+
expect(registry.getMouse()).not.toBe(windows.mouse);
57+
});
58+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { shouldUseWindowsHost } from "./wsl-detection.function";
2+
3+
describe("WSL Windows host selection", () => {
4+
it.each([
5+
[{ WSL_INTEROP: "/run/WSL/1_interop" }, "ordinary-linux"],
6+
[{ WSL_DISTRO_NAME: "Ubuntu" }, "ordinary-linux"],
7+
[{}, "6.6.87.2-microsoft-standard-WSL2"],
8+
[{}, "4.4.0-Microsoft"]
9+
])("detects WSL using environment or kernel evidence", (environment, kernel) => {
10+
expect(shouldUseWindowsHost("linux", environment, () => kernel)).toBe(true);
11+
});
12+
it("leaves native Linux, macOS and Windows unchanged", () => {
13+
expect(shouldUseWindowsHost("linux", {}, () => "6.8.0-generic")).toBe(false);
14+
expect(shouldUseWindowsHost("darwin", { WSL_DISTRO_NAME: "Ubuntu" })).toBe(false);
15+
expect(shouldUseWindowsHost("win32", { WSL_INTEROP: "inherited" })).toBe(false);
16+
});
17+
it("allows WSL users to retain Linux desktop providers", () => {
18+
expect(shouldUseWindowsHost("linux", { WSL_DISTRO_NAME: "Ubuntu", NUT_JS_DISABLE_WSL: "1" })).toBe(false);
19+
});
20+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { release } from "os";
2+
3+
/** WSL can host Linux desktops too; callers can retain them explicitly. */
4+
export function shouldUseWindowsHost(
5+
platform: NodeJS.Platform = process.platform,
6+
environment: NodeJS.ProcessEnv = process.env,
7+
kernelRelease: () => string = release
8+
): boolean {
9+
if (platform !== "linux" || environment.NUT_JS_DISABLE_WSL) return false;
10+
return Boolean(environment.WSL_INTEROP || environment.WSL_DISTRO_NAME || /microsoft/i.test(kernelRelease()));
11+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { ChildProcessWithoutNullStreams } from "child_process";
2+
import { EventEmitter } from "events";
3+
import { PassThrough, Writable } from "stream";
4+
import { WindowsHostClient } from "./host-client";
5+
6+
jest.mock("fs", () => ({ existsSync: jest.fn(() => true) }));
7+
8+
function peer() {
9+
const process = new EventEmitter();
10+
const stdout = new PassThrough();
11+
const stderr = new PassThrough();
12+
const requests: { id: number; command: string; args: unknown }[] = [];
13+
const stdin = new Writable({
14+
write(chunk, _encoding, callback) {
15+
const request = JSON.parse(chunk.toString());
16+
requests.push(request);
17+
if (request.command === "hello") reply(request.id, { protocol: 1 });
18+
callback();
19+
}
20+
});
21+
const child = Object.assign(process, { stdin, stdout, stderr }) as unknown as ChildProcessWithoutNullStreams;
22+
function reply(id: number, result: unknown, pixels = Buffer.alloc(0)) {
23+
stdout.write(Buffer.concat([Buffer.from(JSON.stringify({ id, result, byteLength: pixels.length }) + "\n"), pixels]));
24+
}
25+
return { child, requests, reply };
26+
}
27+
28+
describe("WindowsHostClient", () => {
29+
const clients: WindowsHostClient[] = [];
30+
afterEach(() => { for (const client of clients) client.close(); clients.length = 0; jest.useRealTimers(); });
31+
32+
function setup() {
33+
const remote = peer();
34+
const spawn = jest.fn(() => remote.child);
35+
const client = new WindowsHostClient(() => "/helper.exe", spawn);
36+
clients.push(client);
37+
return { ...remote, client, spawn };
38+
}
39+
40+
it("starts once, handshakes and serializes commands", async () => {
41+
const remote = setup();
42+
const first = remote.client.request("screenSize");
43+
const second = remote.client.request("cursorPosition");
44+
await Promise.resolve(); await Promise.resolve(); await Promise.resolve();
45+
expect(remote.requests.map(request => request.command)).toEqual(["hello", "screenSize"]);
46+
remote.reply(2, { width: 100, height: 50 });
47+
expect((await first).result).toEqual({ width: 100, height: 50 });
48+
expect(remote.requests[2].command).toBe("cursorPosition");
49+
remote.reply(3, { x: 4, y: 5 });
50+
expect((await second).result).toEqual({ x: 4, y: 5 });
51+
expect(remote.spawn).toHaveBeenCalledTimes(1);
52+
});
53+
54+
it("assembles fragmented UTF-8 headers and binary screenshot data", async () => {
55+
const remote = setup();
56+
const response = remote.client.request("capture");
57+
await Promise.resolve(); await Promise.resolve(); await Promise.resolve();
58+
const pixels = Buffer.from([10, 0, 255, 255, 13, 10, 0, 255]);
59+
const packet = Buffer.concat([Buffer.from(JSON.stringify({ id: 2, result: "é", byteLength: pixels.length }) + "\n"), pixels]);
60+
for (const byte of packet) remote.child.stdout.emit("data", Buffer.from([byte]));
61+
expect(await response).toEqual({ result: "é", pixels });
62+
});
63+
64+
it("reports command errors without repeating the command", async () => {
65+
const remote = setup();
66+
const response = remote.client.request("click");
67+
const rejected = expect(response).rejects.toThrow("input blocked");
68+
await Promise.resolve(); await Promise.resolve(); await Promise.resolve();
69+
remote.child.stdout.emit("data", Buffer.from('{"id":2,"error":"input blocked","byteLength":0}\n'));
70+
await rejected;
71+
expect(remote.requests.filter(request => request.command === "click")).toHaveLength(1);
72+
});
73+
74+
it.each([
75+
'{"id":99,"result":null,"byteLength":0}\n',
76+
'{"id":2,"result":null,"byteLength":-1}\n',
77+
'{"id":2,"result":null,"byteLength":134217729}\n',
78+
'{"id":2,"byteLength":0}\n',
79+
'not JSON\n'
80+
])("fails the connection on malformed responses: %s", async packet => {
81+
const remote = setup();
82+
const response = remote.client.request("capture");
83+
const rejected = expect(response).rejects.toBeInstanceOf(Error);
84+
await Promise.resolve(); await Promise.resolve(); await Promise.resolve();
85+
remote.child.stdout.emit("data", Buffer.from(packet));
86+
await rejected;
87+
await expect(remote.client.request("capture")).rejects.toBeInstanceOf(Error);
88+
expect(remote.spawn).toHaveBeenCalledTimes(1);
89+
});
90+
91+
it("rejects active and queued requests when the host exits", async () => {
92+
const remote = setup();
93+
const first = expect(remote.client.request("click")).rejects.toThrow("not retried");
94+
const second = expect(remote.client.request("capture")).rejects.toThrow("not retried");
95+
await Promise.resolve(); await Promise.resolve(); await Promise.resolve();
96+
remote.child.emit("close", 1, null);
97+
await Promise.all([first, second]);
98+
expect(remote.requests.map(request => request.command)).toEqual(["hello", "click"]);
99+
});
100+
101+
it("ends helper input on close", async () => {
102+
const remote = setup();
103+
const response = expect(remote.client.request("screenSize")).rejects.toThrow("closed");
104+
await Promise.resolve(); await Promise.resolve(); await Promise.resolve();
105+
remote.client.close();
106+
await response;
107+
expect(remote.child.stdin.writableEnded).toBe(true);
108+
});
109+
110+
it("does not restart or retry after a timeout", async () => {
111+
jest.useFakeTimers();
112+
const remote = setup();
113+
const response = expect(remote.client.request("click", {}, 10)).rejects.toThrow("may already have occurred");
114+
await Promise.resolve(); await Promise.resolve(); await Promise.resolve();
115+
jest.advanceTimersByTime(10);
116+
await response;
117+
await expect(remote.client.request("click")).rejects.toThrow("may already have occurred");
118+
expect(remote.spawn).toHaveBeenCalledTimes(1);
119+
});
120+
121+
it("rejects requests exceeding the protocol limit without writing them", async () => {
122+
const remote = setup();
123+
await expect(remote.client.request("type", { text: "a".repeat(1024 * 1024) })).rejects.toThrow("exceeds 1 MiB");
124+
expect(remote.requests.map(request => request.command)).toEqual(["hello"]);
125+
});
126+
});

0 commit comments

Comments
 (0)