From 47cce4e9825d1fc64f1614a22c043898bad2bb9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Thu, 9 Jul 2026 22:15:34 -0300 Subject: [PATCH 1/2] fix(desktop): strip Wayland env vars so launched apps stay on the lab display Toolkits (GTK, Qt, Electron, Flutter) prefer Wayland when WAYLAND_DISPLAY is set, so apps launched via desktop_launch opened on the user's real desktop instead of the isolated Xvfb display - and driving them moved the real cursor. Unset WAYLAND_DISPLAY and WAYLAND_SOCKET in the launch env so apps always render inside the lab display, which has its own pointer. --- docs/releases/UNRELEASED.md | 9 ++++-- packages/desktop-linux/src/apps.ts | 10 ++++++- .../desktop-linux/test/integration.test.ts | 28 +++++++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/docs/releases/UNRELEASED.md b/docs/releases/UNRELEASED.md index 1d7bb83..f242290 100644 --- a/docs/releases/UNRELEASED.md +++ b/docs/releases/UNRELEASED.md @@ -6,7 +6,10 @@ then reset this file. ## User-facing changes -- None yet. +- `desktop_launch` now strips `WAYLAND_DISPLAY`/`WAYLAND_SOCKET` from the app + environment, so GTK/Qt/Electron/Flutter apps always render inside the + isolated lab display instead of opening on the user's real Wayland desktop + (where driving them moved the real cursor). ## Internal/release changes @@ -16,7 +19,9 @@ then reset this file. ### Tested -- None yet. +- Regression test asserting launched apps get `DISPLAY=` with + Wayland variables unset; full desktop-linux integration suite (Xvfb + + xdotool + xterm) passes. ### Not tested yet diff --git a/packages/desktop-linux/src/apps.ts b/packages/desktop-linux/src/apps.ts index c2fb3be..86a6dc5 100644 --- a/packages/desktop-linux/src/apps.ts +++ b/packages/desktop-linux/src/apps.ts @@ -38,7 +38,15 @@ export async function launchApp(opts: LaunchAppOptions): Promise { const daemon = await startDaemon(opts.command, opts.args ?? [], { logDir: opts.logDir, cwd: opts.cwd, - env: { ...opts.env, DISPLAY: opts.display }, + env: { + ...opts.env, + DISPLAY: opts.display, + // Toolkits (GTK, Qt, Electron, Flutter) prefer Wayland when these are + // set, which would open the app on the user's real desktop instead of + // the isolated lab display. undefined values are dropped at spawn. + WAYLAND_DISPLAY: undefined, + WAYLAND_SOCKET: undefined, + }, }); const graceDeadline = Date.now() + LAUNCH_GRACE_MS; while (Date.now() < graceDeadline) { diff --git a/packages/desktop-linux/test/integration.test.ts b/packages/desktop-linux/test/integration.test.ts index d4192ec..2a444ed 100644 --- a/packages/desktop-linux/test/integration.test.ts +++ b/packages/desktop-linux/test/integration.test.ts @@ -167,6 +167,34 @@ describe("screenshot output validation", () => { }); }); +describe("launchApp display isolation", () => { + it("strips Wayland variables so apps render on the lab display", async () => { + const outFile = path.join(tmpRoot, "env-capture.txt"); + const binDir = path.join(tmpRoot, "env-capture-bin"); + writeExecutable( + path.join(binDir, "capture-env"), + "#!/bin/sh\n" + + "printf 'DISPLAY=%s\\nWAYLAND_DISPLAY=%s\\n' " + + '"$DISPLAY" "${WAYLAND_DISPLAY-unset}" > ' + + `'${outFile}'\n` + + "sleep 5\n", + ); + const app = await launchApp({ + display: DEAD_DISPLAY, + command: path.join(binDir, "capture-env"), + env: { ...process.env, WAYLAND_DISPLAY: "wayland-1" }, + logDir: path.join(tmpRoot, "env-capture-logs"), + }); + try { + const captured = fs.readFileSync(outFile, "utf8"); + expect(captured).toContain(`DISPLAY=${DEAD_DISPLAY}`); + expect(captured).toContain("WAYLAND_DISPLAY=unset"); + } finally { + await stopPid(app.pid); + } + }); +}); + describe("launchApp early exit", () => { it("rejects with the log path when the command exits immediately", async () => { await expect( From bdc99a7653be80cd62f1ac3be71ab0025bf381c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Thu, 9 Jul 2026 22:27:34 -0300 Subject: [PATCH 2/2] fix(desktop): point WAYLAND_DISPLAY at an invalid socket instead of unsetting it With the variable unset, wl_display_connect(NULL) falls back to the default wayland-0 socket, so Wayland-first toolkits could still reach the real compositor. An invalid socket name forces the X11 fallback. --- docs/releases/UNRELEASED.md | 8 ++++---- packages/desktop-linux/src/apps.ts | 10 ++++++---- packages/desktop-linux/test/integration.test.ts | 4 ++-- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/releases/UNRELEASED.md b/docs/releases/UNRELEASED.md index f242290..45f0be0 100644 --- a/docs/releases/UNRELEASED.md +++ b/docs/releases/UNRELEASED.md @@ -6,10 +6,10 @@ then reset this file. ## User-facing changes -- `desktop_launch` now strips `WAYLAND_DISPLAY`/`WAYLAND_SOCKET` from the app - environment, so GTK/Qt/Electron/Flutter apps always render inside the - isolated lab display instead of opening on the user's real Wayland desktop - (where driving them moved the real cursor). +- `desktop_launch` now points `WAYLAND_DISPLAY` at a non-existent socket (and + strips `WAYLAND_SOCKET`), so GTK/Qt/Electron/Flutter apps always fall back + to X11 and render inside the isolated lab display instead of opening on the + user's real Wayland desktop (where driving them moved the real cursor). ## Internal/release changes diff --git a/packages/desktop-linux/src/apps.ts b/packages/desktop-linux/src/apps.ts index 86a6dc5..7ffc230 100644 --- a/packages/desktop-linux/src/apps.ts +++ b/packages/desktop-linux/src/apps.ts @@ -41,10 +41,12 @@ export async function launchApp(opts: LaunchAppOptions): Promise { env: { ...opts.env, DISPLAY: opts.display, - // Toolkits (GTK, Qt, Electron, Flutter) prefer Wayland when these are - // set, which would open the app on the user's real desktop instead of - // the isolated lab display. undefined values are dropped at spawn. - WAYLAND_DISPLAY: undefined, + // Toolkits (GTK, Qt, Electron, Flutter) try Wayland first, which would + // open the app on the user's real desktop instead of the isolated lab + // display. Merely unsetting WAYLAND_DISPLAY is not enough: libwayland + // then falls back to the default "wayland-0" socket, so point it at a + // socket that cannot exist to force the X11 fallback. + WAYLAND_DISPLAY: "picklab-no-wayland", WAYLAND_SOCKET: undefined, }, }); diff --git a/packages/desktop-linux/test/integration.test.ts b/packages/desktop-linux/test/integration.test.ts index 2a444ed..eae6529 100644 --- a/packages/desktop-linux/test/integration.test.ts +++ b/packages/desktop-linux/test/integration.test.ts @@ -168,7 +168,7 @@ describe("screenshot output validation", () => { }); describe("launchApp display isolation", () => { - it("strips Wayland variables so apps render on the lab display", async () => { + it("redirects Wayland variables so apps render on the lab display", async () => { const outFile = path.join(tmpRoot, "env-capture.txt"); const binDir = path.join(tmpRoot, "env-capture-bin"); writeExecutable( @@ -188,7 +188,7 @@ describe("launchApp display isolation", () => { try { const captured = fs.readFileSync(outFile, "utf8"); expect(captured).toContain(`DISPLAY=${DEAD_DISPLAY}`); - expect(captured).toContain("WAYLAND_DISPLAY=unset"); + expect(captured).toContain("WAYLAND_DISPLAY=picklab-no-wayland"); } finally { await stopPid(app.pid); }